在论坛里搜“发帖”两个字结果页面干干净净换成“post”立刻刷出一屏。这种情况我遇到过不止一次都是一样的原因Flarum 默认的全文检索把整段中文当成一个长单词去匹配搜索引擎根本不知道“这个帖子怎么发”里面含有“发帖”这两个字。它的分词逻辑是按空格切而中文句子从头到尾没有空格于是每一个帖子在索引里都只是“一整块”文本除非用户把标题一字不差地抄进去否则永远命不中。更要命的是Flarum 默认只检索帖子正文标题压根不在查询范围内所以即便正文里出现过用户搜标题关键词也会扑空。这篇内容适合三类人看一是自己搭了个 Flarum 论坛发现中文搜索形同虚设的站长二是接手了别人二手论坛、正被用户吐槽“搜索没用”的维护者三是对 MySQL 全文索引有兴趣、想搞清楚 ngram 到底怎么工作的开发者。下面这套做法不依赖任何付费服务只动数据库和一小段扩展代码中小规模的论坛完全够用。如果你追求的是“安装即用”的懒人方案我也会在最后一个章节讲清楚外部搜索引擎那条路的成本和收益你自己权衡。1. 中文搜不到不是 Flarum 的锅是分词器在按空格切词先把问题定性清楚后面才不会乱改。很多人第一反应是“Flarum 的搜索有 bug”其实它老老实实把活儿干完了只是干的不是中文的活。Flarum 的搜索走的是 MySQL 全文索引底层语句大致长这样SELECT * FROM discussions WHERE id IN ( SELECT discussion_id FROM posts WHERE MATCH (content) AGAINST (发帖 IN NATURAL LANGUAGE MODE) );MATCH ... AGAINST这套机制在建立索引的时候会把文本按“词”切开存进一张倒排表。而 MySQL 默认的全文解析器判断“词”边界的依据非常粗暴空格、标点、连字符。英文天生带空格所以how to post a thread会被切成how、to、post、a、thread五个 token查post就能命中。中文“这个帖子怎么发”没有任何空格整句被塞成一条记录索引里存的就是“这个帖子怎么发”这一坨。你搜“帖子”它拿“帖子”去跟“这个帖子怎么发”做整词比对自然对不上。这里有个很多人忽略的细节不是完全对不上而是“碰运气式命中”。如果某个帖子的正文恰好只有两个字、或者那一整句话正好等于你搜的词它就能命中。所以你会看到一种很诡异的现象——搜单个词偶尔能出一条搜两个词反而什么都没有。这不是随机 bug恰恰证明了整词匹配确实在工作只是粒度不对。还有一层问题藏在后面。就算分词做对了Flarum 的搜索局限在posts.content这一个字段上。discussions表里的title字段虽然也存着标题但它跟全文索引没有任何关系。用户在前台搜索框里输入的是他记得的标题关键词而系统只拿这个关键词去正文里找两边根本不在一个频道上。这就是为什么你明明记得有篇帖子标题叫《新手发帖指南》搜“发帖指南”却什么都搜不到——正文里没写这四个字。所以真正的修复要分两步走缺一不可让 MySQL 学会切中文再让查询同时覆盖标题和正文。这两个动作的难度和风险完全不同先做哪一个、怎么验证后面逐个拆。2. 摸清 Flarum 的搜索链路从 q 参数到 MATCH AGAINST动手改代码之前得先搞清楚请求是怎么流转的不然容易改在错误的位置白忙一场。Flarum 前台的搜索框提交后实际打到的是这样一个 APIGET /api/discussions?filter[q]发帖page[offset]0这个filter[q]会被DiscussionSearcher接住。它内部维护了一组 gambit每个 gambit 负责解析查询串里的一种语法比如sort:控制排序、author:限定作者、is:unread过滤状态而q这个 key 对应的就是全文检索 gambit。不同版本里这个类的目录结构略有差异1.x 大致在vendor/flarum/core/src/Discussion/Search/DiscussionSearcher.php vendor/flarum/core/src/Discussion/Search/Gambit/FulltextGambit.php直接打开FulltextGambit.php看apply方法核心就几行大意是拿Post模型构造一个查询调whereFulltext(content, $bit)然后把命中的discussion_id反过来限制主查询。Laravel 的whereFulltext方法最终编译出来的 SQL 就是MATCH (content) AGAINST (?)不带IN BOOLEAN MODE修饰也就是自然语言模式。自然语言模式有几个行为需要提前知道它们会直接影响你后面调参。第一它按相关性打分排序但 Flarum 的主查询最后会用last_posted_at之类的字段覆盖排序所以相关性分数基本被丢掉了。第二它天然是“或”的逻辑多个词之间不需要全部命中命中越多分数越高。第三它对停用词有过滤但默认停用词表是英文的对中文没有实质影响。搞清楚了这条链路你就知道改动点只有两个数据库侧的索引配置以及应用侧的 gambit 逻辑。前者不需要改 Flarum 一行代码后者需要写一个小扩展。两边可以分开做、分开验证出问题了也好回滚。提示在动任何东西之前先在测试环境把posts表的索引结构和 MySQL 版本确认一遍。SHOW INDEX FROM posts;一看便知SELECT VERSION();决定你后面的语法能不能用。3. 把 MySQL 的 ngram 分词器接上参数、索引、重建MySQL 从 5.7.6 开始内置了 ngram 解析器专门处理中日韩这类没有空格分隔的语言。它的原理是把一段文本切成固定长度的连续字符序列比如ngram_token_size2时“中文搜索”会被切成“中文”“文搜”“搜索”三个 token。用户搜“搜索”正好能命中第三个 token问题解决。这个方案的好处是完全在数据库内部完成不动应用层性能也由 MySQL 自己扛。第一步是改参数。ngram_token_size是只读的全局变量改完必须重启 MySQL 才生效[mysqld] ngram_token_size2这个值的选择有讲究。设成 2 意味着最小检索单位是两个字中文里绝大多数有意义的词都是双字词覆盖率高索引体积也还能接受。设成 1 的话单字也能搜但索引体积会膨胀好几倍而且单字召回噪音极大搜“的”能给你返回全站帖子。设成 3 则“发帖”这种双字词就搜不到了。所以除非你有特殊需求2 是最稳的选择。顺带说一句这个参数只对 ngram 解析器生效跟innodb_ft_min_token_size那套是两套体系别混着调。第二步是重建索引。Flarum 安装时 Laravel 自动建的那个全文索引用的是默认解析器名字一般是posts_content_fulltext。先确认一下SHOW INDEX FROM posts WHERE Index_type FULLTEXT;然后删掉重建明确指定解析器ALTER TABLE posts DROP INDEX posts_content_fulltext; ALTER TABLE posts ADD FULLTEXT INDEX posts_content_fulltext (content) WITH PARSER ngram;这两条语句在大表上会跑一阵子。InnoDB 从 5.7 开始支持在线添加全文索引写入不会被完全阻塞但会占用大量 IO建议放在低峰期执行。执行完之后用OPTIMIZE TABLE posts;把碎片整理一下索引体积会小一圈。第三步是验证。别急着去前台点搜索先在数据库里直接跑SELECT id, discussion_id, MATCH (content) AGAINST (发帖 IN NATURAL LANGUAGE MODE) AS score FROM posts WHERE MATCH (content) AGAINST (发帖 IN NATURAL LANGUAGE MODE) LIMIT 10;如果score全是 0说明索引没建对大概率是参数没生效或者索引还是老解析器建的。这里有个隐蔽的坑ngram_token_size改动之后如果只重建索引而不重启或者只重启不重建都会出现“参数生效了但索引里还是老 token”的错配表现为部分词能搜到、部分词搜不到非常迷惑。所以顺序一定要是「改配置 → 重启 → 删索引 → 建索引」一步都不能少。还有一个实测经验ngram 解析器不区分语言英文单词也会被切成二元组。flarum会变成fl、la、ar、ru、um五条。这意味着加完 ngram 之后英文搜索的误召回会变多搜fl也可能命中一堆不相关的内容。中英混排的论坛要有心理准备这是这套方案的固有代价不是你配错了。4. 标题为什么搜不到discussions 表压根没进检索范围索引调好之后正文搜索基本就通了但标题依然是盲区。原因在上一章已经点破posts.content有全文索引discussions.title没有而且查询语句压根没碰这张表。有人会想一个取巧的办法发帖时把标题拼接到正文最前面一起存进去这样索引自然就覆盖了。这个思路能跑通但后患不小。一是正文里会多出一段用户没写过的文字引用、编辑、导出的时候都会带出来很别扭二是帖子编辑改标题之后正文里的副本不会跟着变需要额外写同步逻辑三是首帖和其他回复的处理不一致容易埋雷。所以我不推荐这条路除非你只是临时验证一下效果。正统做法是给discussions.title单独建一条全文索引然后在查询层把两边的结果合起来。建索引的语句很直接ALTER TABLE discussions ADD FULLTEXT INDEX discussions_title_fulltext (title) WITH PARSER ngram;title是varchar(255)行数比posts少一个数量级建索引的速度快很多索引体积也小。这一步基本没什么风险可以放心执行。建完之后先单独验证标题能不能命中SELECT id, title, MATCH (title) AGAINST (发帖指南 IN NATURAL LANGUAGE MODE) AS score FROM discussions WHERE MATCH (title) AGAINST (发帖指南 IN NATURAL LANGUAGE MODE) LIMIT 10;注意这里有个容易被忽略的细节标题通常比较短如果标题只有两个字而ngram_token_size2那它只会产生一个 token能命中但如果用户搜的是三个字而标题只有两个字就命中不了。反过来标题长、搜索词短命中率反而高。这就意味着标题检索的召回率天然受标题长度影响短标题的帖子更容易被漏掉。这一点没法靠调参解决只能靠内容侧引导——比如在发帖规范里建议标题写完整。另一个值得注意的点是权限。discussions表里有is_private之类的字段但你不用担心子查询泄露数据。因为最终的主查询还是走 Flarum 原本的可见性 scope子查询只负责返回一批候选 ID真正的权限过滤在主查询那一层完成。换句话说即使子查询返回了私密帖的 ID主查询也会把它过滤掉不会出现越权展示。5. 写一个标题优先的 Gambit把两条 SQL 合成一次查询数据库侧准备就绪接下来是应用侧。目标是替换掉默认的qgambit让它在一次查询里同时考虑标题和正文。这里我建议用子查询而不是先查 ID 再whereIn原因是后者在数据量大时会把几十万个 ID 塞进 SQL既慢又容易触发max_allowed_packet限制。先写 gambit 本体?php namespace App\Search; use Flarum\Search\GambitInterface; use Flarum\Search\SearchState; class TitleContentGambit implements GambitInterface { public function apply(SearchState $search, $bit) { $bit trim((string) $bit); if ($bit ) { return; } $search-getQuery()-where(function ($outer) use ($bit) { $outer-whereIn(discussions.id, function ($sub) use ($bit) { $sub-select(id) -from(discussions) -whereFulltext(title, $bit); })-orWhereIn(discussions.id, function ($sub) use ($bit) { $sub-select(discussion_id) -from(posts) -whereFulltext(content, $bit); }); }); } }这段代码的关键在于orWhereIn的嵌套位置。如果把两个whereIn平铺在外层它们会被 AND 连接变成“标题和正文都必须命中”召回率直接砍半。套一层闭包包住才能得到“标题命中 或 正文命中”的效果。这是个特别常见的低级错误写完一定要拿只有标题命中的帖子测一遍。然后是替换。Flarum 1.x 的DiscussionSearcher通过getGambits()返回一个以 gambit 名为键的数组q就是其中一个键。最省事的做法是继承它覆盖这一个方法?php namespace App\Search; use Flarum\Discussion\Search\DiscussionSearcher as BaseSearcher; class TitleAwareDiscussionSearcher extends BaseSearcher { protected function getGambits(): array { $gambits parent::getGambits(); $gambits[q] new TitleContentGambit(); return $gambits; } }再把容器里的绑定换掉。在你的扩展 ServiceProvider 的boot方法里加一行$container-bind( \Flarum\Discussion\Search\DiscussionSearcher::class, \App\Search\TitleAwareDiscussionSearcher::class );改完记得清缓存php flarum cache:clear这里我必须提醒一句Flarum 各个版本的搜索类结构改过好几次getGambits()的方法签名、返回结构、gambit 的注册方式都可能有差异。上面这段是以 1.x 为基准写的落地之前务必打开你本地vendor/flarum/core里的实际源码对一遍。如果方法名对不上就照着你那版的写法改思路是一样的——找到q这个键换成自己的实现。替换完成之后前台搜索的行为会有一个变化原本qgambit 只查正文现在标题也参与进来了。但要注意q的其他配套行为比如空查询的兜底处理不会自动继承所以如果你的 gambit 遇到空字符串没做拦截可能会出现全表扫描。上面的代码里已经加了trim和空判断别把它删了。6. 索引里混进了 BBCode正文用什么文本建索引到这里功能已经能跑了但搜索结果的质量往往还差一口气。你可能会发现搜“图片”会命中一堆根本没提到图片的帖子搜“链接”会返回所有带外链的内容。这不是分词的问题是索引里混进了不该有的东西。Flarum 的posts.content存的不是纯文本而是 s9e TextFormatter 序列化之后的 XML。一段用户写的普通文字在数据库里大概长这样rp这个帖子怎么发啊/pQUOTEp原帖内容/p/QUOTEURL url...链接/URL/r全文索引会把这整段 XML 一起切词于是QUOTE、URL、url、p这些标签名全都进了倒排表。用户搜“链接”的时候URL标签被 ngram 切成UR、RL中文搜不到但如果有人搜英文缩写就会大面积误命中。更实际的影响是索引体积膨胀——标签字符往往占正文的三到四成白白多占空间、拖慢检索。彻底的解法是单独维护一个纯文本列。思路是在posts表加一个content_plain字段在帖子保存时用 Flarum 的格式化器把 XML 渲染成 HTML 再剥掉标签只留文字。大概是这样use Flarum\Formatter\Formatter; $formatter resolve(Formatter::class); $html $formatter-render($formatter-parse($post-content)); $plain html_entity_decode(strip_tags($html), ENT_QUOTES, UTF-8);然后把全文索引从这个新列上建gambit 里的whereFulltext也改成查这一列。这一步的代价是要写一个监听帖子创建和编辑的事件历史数据还得跑一次回填脚本。如果论坛帖子量不大回填脚本用分页跑一遍就行量大的话建议分批处理每批五百到一千条中间 sleep 一下别把数据库打满。注意回填的时候别忘了软删除的帖子。Flarum 的posts表有deleted_at字段回填脚本如果不加withTrashed()删过的帖子content_plain会永远是空的将来恢复帖子时搜索就失灵了。如果觉得这套改造太重也有一个折中方案把索引建在一个 MySQL 生成列上用SUBSTRING_INDEX之类的函数做粗略清洗。但 XML 结构复杂正则式的清洗很容易误伤正文里的尖括号内容我不太推荐。老老实实加一列可控性高得多。7. 上线前的验证清单与常见翻车点改完这些东西别急着在正式环境宣布“搜索修好了”。我整理了一份上线前的检查项按顺序过一遍能挡掉大部分问题检查项验证方法常见异常ngram 参数是否生效SHOW VARIABLES LIKE ngram_token_size;显示 2 但索引是旧的posts 索引是否重建SHOW INDEX FROM posts WHERE Index_typeFULLTEXT;索引名重复导致建失败discussions 索引是否存在SHOW INDEX FROM discussions;漏建或建在错误字段上标题单独命中直接跑 MATCH 查询score 全 0仅正文命中找一篇标题无关、正文含关键词的帖子返回空说明 AND 逻辑写错空查询不崩搜?filter[q]全表扫描或 500权限不越界用普通账号搜私密帖关键词出现无权访问的标题编辑后能重新命中改标题再搜索引未更新其中“编辑后能重新命中”这一项最容易被忽略。MySQL 的全文索引是随写入自动维护的理论上改完立刻生效但 Flarum 的帖子编辑走的是异步流程还是同步流程、有没有走队列各版本不一样。如果你的论坛配了队列帖子编辑后可能要等队列消费完索引才更新测的时候别急着下结论。另一个高频翻车点是索引重建时的锁表。ALTER TABLE ... ADD FULLTEXT虽然是在线操作但在某些 MySQL 版本和表结构下依然会短暂锁写。如果你的论坛帖子量在十万级以上建议先用pt-online-schema-change之类的工具做或者干脆在维护窗口执行。我见过有人在大中午直接跑结果论坛卡了十几分钟用户以为站点挂了。还有个细节值得单独说搜索词里包含特殊符号时的行为。自然语言模式对、-、这些符号相对宽容不会像布尔模式那样直接报错但也不保证完全无副作用。如果你在 gambit 里自己拼过 SQL 字符串务必用参数绑定别图省事做字符串拼接。Flarum 的whereFulltext第二个参数走的是绑定这一点它做对了你替换的时候别把它改坏。最后说一个实测下来很有用的技巧在 gambit 里加一行日志把用户实际搜的词和命中数量打出来。跑上几天你就能看到真实的搜索行为——哪些词搜得多、哪些词零结果。零结果的词往往就是下一轮优化的方向比如专有名词、英文缩写、或者带空格的长句。这比拍脑袋调参有效得多。8. 外部搜索引擎不是万能药什么时候该换MySQL 这套方案撑到多大合适我的经验是帖子量在几十万级别、日搜索请求在几千次以内ngram 加全文索引完全够用查询延迟通常在几十毫秒。再往上走瓶颈会先出现在写入上——每次发帖都要更新两条全文索引ngram 的 token 数量是原文的好几倍写放大会很明显。真到了那个量级就该考虑外部搜索引擎了。这类方案的共同点是把帖子内容同步到独立的检索服务里查询走那边MySQL 只负责存储。好处是中文分词质量高很多引擎内置了词典级分词不是简单的二元组切分、支持拼音、支持高亮、支持相关性排序调权重。坏处也很明确多了一个需要运维的服务多了一套数据同步逻辑帖子一改、一删、一恢复都得同步过去任何一环掉链子就会出现“搜到了点进去 404”的尴尬。Flarum 生态里有基于 Laravel Scout 的集成扩展能对接几种主流的检索服务。要不要上取决于三个问题你的帖子量是不是真的到了 MySQL 扛不住的程度你有没有人力和能力维护额外服务你对搜索质量的要求是不是高到必须支持拼音和模糊匹配。三个问题里有两个答“是”再考虑迁移只有一个那就老老实实把 ngram 调好性价比高得多。我个人在这个问题上的体会是大多数论坛的搜索痛点八成来自分词没配对两成来自标题没进索引真正需要换搜索引擎的少之又少。先把这两件事做扎实观察一段时间的搜索日志再决定要不要上更重的方案。上来就堆一套检索集群往往是把简单问题复杂化了后面维护的人还得骂你。还有个小建议如果你的论坛是中英混排的可以在 gambit 里做个简单判断查询串全是 ASCII 字符时走LIKE前缀匹配包含中文时走 ngram 全文索引。这样能绕开 ngram 对英文误召回的问题实现成本也就十几行代码。我自己在一台小站上这么干过英文搜索的准确率提升挺明显中文那边完全不受影响。