你给站内搜索加上了全文检索:建表、灌数据、写查询,一切顺利。可当你在搜索框里敲下最常见的两字中文词——"列表""设置""评论"——结果一条都没有。换成三个字、四个字,又正常了。你会怀疑是数据没进去,或者中文分词出了问题,但重建索引也没用。
现象
索引建表成功,数据也确实灌进去了,偏偏短词搜不到:
SELECT * FROM docs WHERE docs MATCH '列表';
-- 返回 0 行
SELECT * FROM docs WHERE docs MATCH '列表工具';
-- 正常返回若干行
规律很清晰:查询词不足 3 个字符时返回空,达到或超过 3 个字符就正常。 而中文里两字词恰恰是最常见的搜索长度——"登录""缓存""权限",全是两字。于是搜索功能在用户眼里几乎是坏的。
根因
根因在 trigram 分词器的切分规则,而不是中文分词本身。
SQLite 的 FTS5 提供一个 trigram 分词器,它是为子串匹配设计的:把整段文本按"每 3 个字符一个片段、每次滑动 1 个字符"切成一组三联片段,查询时同样切成三联片段去比对。
举个例子,文本"Claude 踩坑"会被切成 Cla、lau、aud、ude……这样一叠三联片段。查询"踩坑"时,分词器要把查询词也切成三联片段——可"踩坑"只有 2 个字符,凑不满一个 3 字片段,于是切出空集。查询词切不出任何片段,自然匹配不到任何东西。
这就是"短词完全失效"的机制:trigram 有一个硬性的长度下限——3 个字符。 低于这个长度,它在分词阶段就已经"无货可发",检索无从谈起。中文两字词正好全部落在下限之下,而它又是最高频的查询长度,所以问题显得特别严重。
要区分的是:这不是"中文分词没做对"。trigram 本来就按固定长度切,跟语言无关。它适合做子串模糊匹配,但它对查询长度有硬约束。
解决
思路:对短查询做回退,用 LIKE 子串匹配兜底。
在查询入口判断词长,分两条路径:
-- 查询词长度 >= 3:走全文索引,快
SELECT * FROM docs WHERE docs MATCH :kw;
-- 查询词长度 < 3:回退到子串匹配
SELECT * FROM docs WHERE content LIKE '%' || :kw || '%';
应用层的判断逻辑大致是:
if (len(kw) >= 3) -> 用 MATCH 走 FTS 索引
else -> 用 LIKE '%kw%' 全表扫描
注意几个配套细节:
- 回退路径会变慢。
LIKE '%词%'用不到索引,是全表扫描。两字词查询量大时,性能会明显下降。可以给它加一层缓存,或者接受"短词查询慢一点"这个取舍。 LIKE的通配符要转义。 用户输入的词里若含%或_,需要转义,否则会被当成通配符。用LIKE ... ESCAPE '\'并预处理输入。- 索引重建时注意。改用别的分词方案(比如给中文引入第三方分词)属于更大的改动,权衡成本后再动;多数场景下"短词回退
LIKE"就是性价比最高的解。
改造后做一个覆盖测试:分别用 2 字、3 字、4 字词各搜一遍,确认短词走回退路径、长词走索引路径,都能返回结果。
延伸与预防
这条坑的通用教训是:索引的适用长度,必须和你的查询长度分布对得上。
在设计检索方案前,先看一眼真实查询的长度分布——中文场景里两字词占大头,这是必须纳入设计的前提。凡是"分词器、n-gram、前缀匹配"这类按长度切分的机制,都要先问一句:我的最短查询是多少字符,它落在分词器的下限之上吗?
更普遍地说,任何索引都有它的"适用边界",边界之内是加速,边界之外往往是静默失效——不报错,只是搜不到。上线前用真实分布的数据去压一遍,比读文档更能发现问题。