返回文章列表

把搜索索引搬到构建期:638 个章节文档、1.2 MB 和中文分词的代价

静态站的全文搜索该什么时候建索引、按什么粒度切文档、中文为什么必须换分词器?本文用本仓库 99 篇文章切片出的 638 个章节文档实测 Fuse.js 7.5 的默认模糊搜索与 token 搜索,记录 Bitap 的 32 字符上限、Intl.Segmenter 带来的 6 倍建索引开销,以及 1.2 MB 索引的静态分发预算。

12 分钟阅读

搜索框是最容易被敷衍的功能。加一个输入框,把标题数组丢给模糊匹配库,回车能出结果,就算"搜索做完了"。真正用起来才会发现它在三种情况下必然失手:查询变长、查询不带空格、用户搜的是中文术语而不是关键词。

本文讨论的不是"怎么接入一个搜索库",而是三个更靠前的问题:索引在构建期建还是运行时建、文档切成什么粒度、中文要不要单独处理分词。为了不让结论停在文档转述上,所有实测数字都来自本仓库:99 篇 MDX 文章、638 个章节文档,fuse.js 7.5.0、Next.js 16.4.2、Node v22.12.0,测量时间为 2026 年 11 月 10 日。

三种必然失手的查询

先用最朴素的配置打一遍底。默认模糊搜索、ignoreLocation: true、threshold: 0.4,字段为标题、章节标题和正文,权重依次递增:

查询默认模糊搜索结果数
静态搜索索引0
中文分词 搜索0
缓存组件 渲染边界0
build time search index0

四条查询全部零结果。这不是权重没调好,是两个机制层面的边界同时生效。

Bitap 的 32 字符上限

Fuse.js 的底层是改造过的 Bitap 算法,用位运算算编辑距离。官方文档写得很清楚:每个搜索词的模式长度上限是 32 个字符,超过部分不参与匹配(Fuse.js: Fuzzy Search)。

同一个页面还定义了另外两个参数的联动关系,这条经常被忽略:

  • threshold 默认 0.6,是模糊度截断,0 要求完全匹配。
  • location 默认为 0,表示期望匹配出现在文本开头。
  • distance 默认 100,表示偏离 location 多远开始被惩罚到排除。
  • 有效搜索窗口是 threshold × distance。按默认值算,模式必须落在位置 0 起 60 个字符内才算命中。

ignoreLocation: true 能解掉窗口问题,但解不掉 32 字符上限。而默认模式把整条查询当成一个模式——缓存组件 渲染边界 这 9 个字符虽然没超限,但要求它在文档里以"近似连续字符串"的形式存在,中间隔了别的文字就匹配不上。这正是中文多词查询最恶劣的地方:用户脑子里想的是"这两个词都出现过",算法找的是"这串字符近似出现过"。

token 搜索:从整串匹配改成逐词匹配

Fuse.js 文档里标注为 New 的能力叫 token 搜索,把查询按分隔切成多个词,每个词独立跑一次 Bitap,再用 BM25 风格的 IDF 给结果排序。开启方式是 useTokenSearch: true(Fuse.js: Token Search)。

它解决了两件事:

  1. 长度不再受限。6 个词的查询就是 6 次独立 Bitap,每次都在 32 字符内。
  2. 词序无关,词频有感知。IDF 使用 BM25 公式 idf = log(1 + (fieldCount - docFreq + 0.5) / (docFreq + 0.5)),罕见词权重高于到处都有的词。

同一批文档,换成 token 搜索后结果变成:

查询默认分词器换成 Intl.Segmenter
静态搜索索引0 条有结果,首位得分 0.842
中文分词 搜索有结果,首位 0.795有结果,首位 0.896
缓存组件 渲染边界3 条并列 0.8750.590 / 0.730 / 0.759,区分度打开

注意第一行:开了 token 搜索,静态搜索索引 依然是 0 条。原因在分词器。

默认分词器不认识中文

token 搜索的默认分词正则是 [\p{L}\p{M}\p{N}_]+,任何 Unicode 字母、附加符号、数字都算词的一部分。对英文这没问题,对中日韩就不行——中文句子没有空格,整段连续汉字会被切成一个 token。

所以 中文分词 搜索 能出结果,纯粹是因为用户自己敲了空格,把查询分成了两个 token,刚好和正文里以空格或标点分隔的片段对上。而 静态搜索索引 是连续 6 个汉字,默认分词器认为它是一个词,正文里几乎不存在这个完整字面串,于是零召回。页面上还写着"默认分词器开箱支持 CJK",支持的是"不会崩",不是"分得对"。

正确做法是用 Intl.Segmenter 做按词典的词切分。它是 ECMAScript 内置的本地化文本分段 API,MDN 标注为基线特性,自 2024 年 4 月起在主流浏览器可用(MDN: Intl.Segmenter)。接到 Fuse.js 上要用函数形式的分词器:

const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });

const fuse = new Fuse(docs, {
  useTokenSearch: true,
  keys: [{ name: 'heading', weight: 3 }, { name: 'title', weight: 2 }, { name: 'text', weight: 1 }],
  tokenize: (text) =>
    Array.from(segmenter.segment(text), (s) => (s.isWordLike ? s.segment : null)).filter(Boolean),
});

isWordLike 过滤掉标点和空白段,函数必须返回 string[]。文档里还有两条容易被踩的约束:函数分词器接收的是已做大小写折叠与变音符号处理之后的文本,而且必须是确定性的——非确定性的分词会静默破坏文档频率统计,IDF 从此算错。

代价:建索引时间从 28 ms 变成 177 ms

分词不是免费的。同一批 638 个章节文档,两种分词器各建一次索引:

索引配置建索引耗时
token 搜索 + 默认正则分词28 ms
token 搜索 + Intl.Segmenter177 ms

6 倍左右。按本仓库规模这是可以忽略的成本,但它揭示了一个重要的架构前提:这个开销只值得在一次构建里付一遍,不该让每个访客的浏览器在打开搜索时重复付。如果索引放到运行时建,付出的就是每个用户的首屏时间。

章节级文档:切分粒度决定召回质量

索引里放什么,比用什么算法更影响体验。只索引文章级摘要,用户搜到"某个概念在哪一节讲过"时只能拿到文章列表,还要自己翻;索引整篇正文,一篇长文会淹没所有短文章的排序,命中片段也没法定位。

本仓库的做法是构建期解析 MDX AST,按 H2/H3 切出章节级文档,每个文档带上所属文章标题、章节标题、正文片段和锚点。99 篇文章切出 638 个章节文档,平均每篇 6.4 节。同时必须排除三类内容:fenced code block、ESM 导入导出语句、JSX 组件树——它们既不是人话,还会把代码里的变量名变成高频 token,污染 IDF。

slug + heading + anchor 这一组字段还顺手解决了跳转:搜索结果直接落到 /articles/<slug>#<anchor>,不需要二次搜索。

代价是文档数量膨胀了 6 倍多,索引体积随章节数线性增长。所以下一步要谈预算。

1.2 MB 的分发预算

把 638 个章节文档序列化成 JSON,未压缩体积是 844 KB。线上实际的 /site-search-index.json 包含首页常驻内容、文章摘要、文章根文档和章节文档,实测响应体 1,254,070 字节,约 1.2 MB。

1.2 MB 放在首屏是灾难,放在按需加载里就还好:

  • 归档页首屏只传当前页的 10 条摘要。
  • 用户按下搜索按钮、Ctrl/Cmd + K,或 URL 带上 ?q= / 旧 ?tag= 时,才请求完整索引,同时按需加载 Fuse.js。
  • 普通文章详情页、标签页、分页页完全不碰这个文件。

静态分发端也存在硬边界。按 Cloudflare Workers 静态资源配置的上限,文件数量为免费版每个 Worker 版本 20,000 个、付费版 100,000 个,单文件 25 MiB(Cloudflare Workers Limits)。索引是单个文件,25 MiB 这个上限意味着章节文档数还有很大空间,但文件总数会先成为约束——这也正是"不按分页拆源文件、派生索引全部构建期生成"的原因:静态页面数量随文章数与标签组合增长,比索引体积更早撞墙。

能召回不等于排得对

必须承认实测里不好看的那一半。静态搜索索引 分词后虽然召回了结果,首位却是"静态保证:ensureStatic 防止动态组件悄悄混入"(0.842),而真正相关的那篇讲索引架构的文章不在前三。缓存组件 渲染边界 也一样,分词把区分度从"三条并列 0.875"变成 0.59 / 0.73 / 0.76,排序合理了一些,但仍有明显偏差。

这说明分词解决的是召回问题,排序是另一层问题:IDF 只知道词有多罕见,不知道这个词在标题里还是在脚注里,也不知道"章节文档"和"搜索索引"在语义上属于同一主题。当前能做的补丁都不优雅但有效:

  • 章节标题权重取正文的 3 倍,实验下来这是让"搜术语、命中讲该术语的小节"最稳的一档。
  • threshold 从默认 0.6 收紧到 0.4,宁可少召回也不要让模糊匹配把无关章节顶上来。
  • limit 限定返回条数,文档说明这通过堆选择实现,顺带省掉不必要的排序开销。
  • 把"同义词/中英对照"做成构建期写入文档的隐藏字段——比如给章节额外挂一列 aliases,权重设 0.5。这属于内容工程,不是算法能自动解决的。

不是所有场景都适合这一套

最后约束一下适用范围。以上结论建立在"内容在构建期完全可知"这个前提上。Fuse.js 文档本身也列了几条会改变结论的限制:

  • token 搜索只在完整构建里提供,basic 构建为压缩体积不带它,useTokenSearch: true 会直接抛错。引入前要先确认打包体积能否接受。
  • 函数分词器不能进 Web Worker,因为它无法通过 postMessage 传递。想把 Intl.Segmenter 的分词器塞进 FuseWorker 跑,走不通。
  • 自定义正则分词器必须带 g 标志,否则每段文本只会切出第一个词;开发构建会打一次警告,生产环境安静地出错。
  • 索引构建有固定开销。官方基准(随机生成文档,标题 + 正文两个字段)给出 5,000 文档时索引创建开销为 5.5 倍、单次建索引约 46 ms,单词搜索约 1.8 倍、多词搜索约 1.2 倍。这是合成语料的数字,中文分词后的真实开销会比它更高——本仓库 638 文档实测 177 ms,与官方 5,000 文档 46 ms 的绝对量级不可直接比较,但趋势一致:建索引贵在一次性,查询贵在每词一次 Bitap。

如果内容在运行时会频繁变化,或者需要跨字段的复杂布尔查询、聚合、拼写纠错学习,那么构建期静态索引从一开始就是错的选择,应该直接上检索服务。静态索引真正的优势只有一条:访客打开页面时,没有任何东西需要被现场计算。

一份可执行的检查清单

把上面的结论压成落地顺序:

  1. 先确认内容量级与更新频率,内容在构建期可知才考虑静态索引。
  2. 切文档粒度按 H2/H3,带上文章标题、章节标题、锚点,排除代码块与 JSX。
  3. 中文内容必须换 Intl.Segmenter 分词器,并验证"连续汉字、无空格"的查询不再零召回。
  4. 权重按 heading > title > text 排,threshold 收到 0.4 左右,limit 显式给值。
  5. 索引只按需加载,首屏不碰;同时把索引体积记进监控指标。
  6. 用 20 条真实查询做回归,检查前三位是否命中,而不是只看"有没有结果"。

第 6 条最容易被跳过,也最能说明问题:前五条决定搜索能不能用,最后一条决定它好不好用。

(内容由AI生成,仅供参考)