砚台

SQLite 全文检索对中文的三个坑

给这个博客加搜索时踩出来的,每一条都是实测

给博客加全文搜索,我以为是十分钟的活。FTS5 是 SQLite 内建的, CREATE VIRTUAL TABLE ... USING fts5(...) 一行就有了。结果在中文上连撞三次。

一、unicode61 把整段中文当成一个词

默认分词器 unicode61 靠空白和标点切词。中文没有空格,于是「原生博客」是一个 token, 搜「博客」匹配不到——它不是这个 token 的前缀。

二、trigram 要求查询至少三个字符

tokenize='trigram' 是官方给非空格语言的答案,把文本切成三字符滑窗。 问题是中文的常用词大量是两个字:博客、部署、缓存、索引。 这些查询在 trigram 索引里连一个完整窗口都凑不出来,直接返回空。

实测三种方案:

查询 unicode61 trigram 单字切分
博客 落空 落空 命中
随时随地 落空 命中 命中
agent 命中 命中 命中

三、最后用的办法,以及它自带的第二个坑

索引和查询两侧都把 CJK 拆成单字,多字词落成短语查询。"博客" 变成 MATCH "博 客", 英文原样保留走前缀匹配。

真正花时间的是还原。搜索结果要显示带高亮的片段,而片段取自索引里那份分过词的文本, 得把插进去的分隔还原掉。我第一版用空格做分词标记,于是遇到这个问题:

原文:中文检索里"能跑"和"对"之间
片段:中文检索里 " 能跑 " 和 " 对 " 之间

还原代码没法判断某个空格是"我插进去的边界"还是"原文本来就有的"——两者形态完全相同。 我试过按前后字符是不是汉字来猜,能修好中文夹标点的情况,一遇到 写 markdown 这种 中英混排就又错了。

换一个不会和原文冲突的标记就行了:用 \x01 这个控制字符当分词符。 unicode61 把任何非字母数字都当分隔符,所以它照样正确切词;而还原时只要 无条件删掉所有 \x01,原文的空格一个不动。

一句话:当你需要"事后区分自己加的东西和原有的东西",就不要用原有内容里也会出现的符号做标记。 这条在转义、序列化、模板里是同一个道理,我却是在排版散架的搜索结果里重新学了一遍。

附带的两个洞

  • 片段是要插进 HTML 的。 顺序必须是「先转义整段,再把占位符换成 <mark>」。 反过来写就是一个注入洞——文章里但凡有一行代码带 <,页面就散了。
  • 用户会在搜索框里敲 OR AND / OR / NOT / NEAR 是 FTS5 的操作符, 裸拼进 MATCH 表达式会变成语法错误,用户看到的是 500 而不是"没找到"。 把每个词都包成短语(引号)就没这回事了。

结论

中文检索里,"能跑"和"对"之间隔着三次实测。别信第一次成功的那个查询—— 拿两个字的词、中英混排的句子、和一段带标点的正文各试一遍,才算测过。