从 charabia 到 ICU:我的静态博客搜索里,两本词典打了一架

静态站没后端,全文搜索交给 Pagefind 在访客的浏览器里跑。上线第二天我用真实词一搜,复合词全军覆没,单字却条条命中。这篇记录了从复现到修复的全过程——根因是索引侧和查询侧各带一本中文词典、切法还不一样;修法是把索引侧对齐到浏览器这本,而查询侧连一点插手的余地都没有。

搜索是前一天刚上线的。静态站没后端,方案选了 Pagefind:构建完对预渲染好的 HTML 建索引,访客在浏览器里查,全程零服务器参与——和这个站「访客只碰静态文件」的架构严丝合缝。验收那天随手输了几个词都能出结果,满意上线。

第二天用真实词一搜,傻了:

  • 搜「单」→ 13 条结果
  • 搜「单片」→ 0 条
  • 搜「单片机」→ 0 条

更邪门的是,「单」的 13 条结果里,连某篇知识库文章里「一扇单行门」的「单」都算命中。搜索没坏,它只是对「单片机」这三个字有自己的理解。

第一个线索藏在高亮里

Pagefind 的搜索结果自带 <mark> 高亮。我把搜「单」的结果挨个点开,高亮出来的词形是这样的:

单片、单行、单词、单、单独、单体、单位

我查的是「单」这一个字,高亮出来的却是「单词」「单独」「单位」这些完整的词。这说明索引词表里存的不是单字,是词典切出来的多字词——「单片机」那篇文章的正文,在索引里大概是「单片」+「机」这样存的。

也就是说:索引侧有一本词典,而且它认得「单片」。那搜「单片」为什么是零结果?

查询侧还有一本词典

翻 Pagefind 的产物,pagefind-entry.json 里整站只有一条语言记录:

{"version":"1.5.2","languages":{"zh-cn":{"hash":"…","wasm":null,"page_count":39}}}

(节选,原文件一行到底。)既然产物按语言分索引,运行时里就一定有按语言分支的逻辑。顺着往源码里挖(运行时是个 45 KB 的压缩 JS,好在搜索入口这段没被混淆到读不了):

needsWordSegmentation = (lang) =>
  ["zh", "ja", "th"].includes(lang.split("-")[0].toLowerCase());
// …
if (needsWordSegmentation(trueLanguage)) {
  const wordSegmenter = new Intl.Segmenter(trueLanguage, {granularity: "word"});
  for (const {segment: word} of wordSegmenter.segment(term)) {
    /* 逐词重组,空格分隔 */
  }
  term = term_chunks.join(" ")…
}

(同样是节选,变量名和换行做了整理。)

查询侧对中文根本不看索引词表,而是直接用浏览器的 Intl.Segmenter——也就是 ICU 的中文词典——把查询串切词。在浏览器里实测:

[...new Intl.Segmenter("zh-CN", {granularity: "word"}).segment("单片机")]
  .map(s => s.segment)   // → ["单", "片", "机"]
[...new Intl.Segmenter("zh-CN", {granularity: "word"}).segment("编码器")]
  .map(s => s.segment)   // → ["编码", "器"]

ICU 的中文词典里没有「单片」这个词,「单片机」被切成三个单字。而 Pagefind 的多词查询是 AND 语义——三个词都得在索引词表里命中,结果全部落空,零结果。

至此拼图完整了:

对「单片机」的切法
索引侧(构建时,charabia 词典) 单片 + 机
查询侧(浏览器,ICU 词典) 单 + 片 + 机

两本词典,同一个词,两种切法,AND 查询全灭。至于搜「单」为什么能出 13 条——Pagefind 对查询词默认做前缀匹配,一个「单」字把词表里所有「单」开头的词(单片、单词、单独……)全捞了回来。这个假象让我在验收时误以为搜索是好的。

去上游翻 issue,这事不止一个人撞上:#1237 记录了日语的完全同构问题(日语句子里「新幹線」被索引切碎成「新+幹線」,查询侧 ICU 却保整词,方向恰好和我们相反,症状是搜出来的全是「新」字打头的不相关页面);#817 确认了查询侧用 Intl.Segmenter 是 1.5.0 的正式设计。两套词典不一致,是这套架构的固有缺陷。

一条死路:查询侧没有自由度

我最先想到的修法在前端:既然两侧词典不一致,那就把查询串按几种候选方式切分、各查一遍取并集——「单片机」同时试「单 片 机」「单片 机」「单片机」。

写代码之前我又读了一遍运行时源码,然后发现这条路根本不通:任何传给 pagefind.search() 的字符串,都会先被运行时的 ICU 分词器重新切一遍。你用空格预切的「单片 机」,会被拆回「单 片 机」;连引号 exact 模式也是先把引号当标点剥掉、切完词再附加位置约束。查询侧的词形被 ICU 锁死,零自由度——空格和引号都只是切词下游的语义调节,而问题恰恰出在切词本身。

反转:改不了查询,那就改索引

死胡同走到头,方向反转:查询侧的词典动不了,那就把索引侧的词典换成查询侧这本。

关键的抓手是 \u200B(零宽空格)。这不是外挂字符:Pagefind 的索引管线本来就在用它标记词边界,索引时切完词会把 \u200B 写进内容——把产物分片解压开看,词与词之间就夹着它。那问题就变成了:如果我在 HTML 里预先按 ICU 的词边界插好 \u200B,charabia 会不会尊重这个既成事实的边界?

两页对照实验,五分钟出结果:

<!-- z1.html:原始文本 -->
<p>比赛是使用Ti公司的单片机会有得分加成。</p>

<!-- z2.html:按 ICU 词边界插零宽空格(用 <wbr> 做可读性示意) -->
<p>比赛是使用Ti公司的单<wbr>片<wbr>机会有得分加成。</p>

两页各自建索引后查询:z1 搜「单片机」零结果(复现线上问题),z2 直接命中;顺手加的对照组「得分」两边都正常。charabia 把 \u200B 当词切分边界,索引词形从此与 ICU 一致。

落地:prepagefind

最终实现是一个构建期预处理脚本:pnpm build 之后、Pagefind 建索引之前,把产物目录克隆一份,用 jsdom 遍历每个页面 [data-pagefind-body] 子树的文本节点,按 Intl.Segmenter('zh-CN') 的词边界插入 \u200B,然后对这份副本建索引,再把索引产物移回原目录。线上 HTML 零改动——被预处理的只有索引的输入。前端唯一的配套改动,是渲染搜索结果时把标题和摘要里的零宽字符剥掉,免得复制粘贴带出隐形垃圾。

(脚本已贴在 gist,百来行,jsdom + Intl.Segmenter,自包含。)

顺手还做了一个本来就该做的改进:索引范围原来只圈到正文容器,这次把 data-pagefind-body 上移到整个 <article>——标题、分类、标签文本一并进索引,h1 加权。效果分两类:正文里本来出现过的词,排得更准了;只存在于分类和标签里的词,从搜不到变成精确命中——搜「电赛实录」(标签)0 条变 2 条,搜「代码艺术」(分类名)从 20 条模糊命中收敛到正好是带这个分类的 6 篇。

修复前后的对照:

查询 修复前 修复后
单片机 0 6
单片 0 6
电赛实录(标签,正文里没这个词) 0 2
代码艺术(分类名) 20 6
编码器(标题词,正文里也有) 6 6
ffmpeg(英文,回归对照) 1 1

(「修复前」一列是在旧索引仍在线时于线上实测的。)

还试了回退 1.4

排查过程中顺手做了一组对照:用 1.4.0 对同样的页面重建索引。1.4 没有查询侧切词,「单片机」整串直达索引,miss 之后 wasm 有一条从尾部缩短的回退路径——截到「单片」(索引里恰有这个词)命中,excerpt 高亮 <mark>单片</mark>机。

所以 1.4 也能「搜到」,但那是子词级的回退匹配,不是整词命中;这个回退遇到判别性差的子词照样会打出不相关结果(上游 issue 里有 1.4 的假阳性实测)。回退版本等于用永久的升级税换一个黑盒兜底,不如把索引词形对齐来得干净。

几点反思

  • 验收要用完整复合词。 单字能搜到是前缀匹配的假象,我差点带着这个假象上线。中文搜索的测试用例得是「单片机」「编码器」这种真实词,外加一个「正文里有、但和意图无关」的干扰项。
  • 静态站全文搜索的本质是词典一致性。 Pagefind 把「轻量、零后端、浏览器内查询」做到了极致,代价是索引侧和查询侧各带一本词典、互不通气——只要词典不一致,怎么查都是错的。上游 #1237 已经收录了日语、繁中、简中三个语种的实测数据,等哪天 Pagefind 在索引期直接输出 ICU 词形(issue 里讨论的方向之一),我这种预插边界的站点可以无缝受益。
  • 压缩后的运行时代码是最好的文档。 文档说「支持中文搜索」,源码说查询串会被 ICU 重切——两个说法都没错,但对上后者才救得了命。正常人读压缩js代码太要命了,但是交给AI来研究正合适。
  • 给上游提数据是有用的。 #1237 里日语、繁中各有形态,简中这个「索引词比查询词长、纯召回归零」的第三种方向补上了最后一块拼图——现在上游想不修都难了(笑)。

现在,搜「单片机」,六篇文章整整齐齐。