技技术学习庭院建造日志2026.09.02

从零写一个中文全文搜索:bigram 的浪漫

当 Pagefind 遇到中文,为什么一个简单的双字切分就足够好用?从索引、打分到高亮,完整拆一遍。

4 MIN READ976 字

博客里的文章多了以后,最先崩溃的不是导航,而是我自己。

想找「虚拟内存」那篇笔记,却只记得里面提过 page fault;想找 Blender 的灯光参数,却忘了标题,只记得「体积雾」三个字。目录按时间排得再整齐,也不如一个能理解中文的搜索框。

为什么不直接用 Pagefind

Pagefind 很优秀:构建后自动生成索引,前端按需加载,还自带一个不错的 UI。

问题出在中文分词。Pagefind 的索引流程依赖空格和词边界,而中文句子是连续书写的:

虚拟内存把地址空间切成许多页

如果没有额外的分词器,整句会被当成一个巨大的 token。用户搜「内存」时,可能什么都找不到。

理论上可以引入结巴分词或 WASM 分词库,但为了一个个人博客,这会让构建流程和包体积都变得沉重。我想要一个更小的答案。

双字切分:足够粗糙,也足够好用

中文里绝大多数有意义的词是两个字或以上。于是可以把连续的中文文本切成重叠的双字组(bigram):

虚拟内存
→ 虚拟 / 拟内 / 内存

用户输入「内存」时,查询同样被切成一个 bigram;索引里恰好也有这个 bigram,就能命中。

代价是索引会变大,也会产生一些无意义的组合(比如「拟内」)。但在博客这个量级——几百万字以内——它的召回率和实现成本之间取得了很好的平衡。

一个不到 30 行的分词器

const normalize = (value) =>
  value.toLowerCase().normalize('NFKC').replace(/\s+/g, ' ').trim();

export function tokenize(query) {
  const text = normalize(query);
  if (!text) return [];

  // 拉丁词、数字、常见技术符号
  const latin = text.match(/[a-z0-9]+(?:[._+#-][a-z0-9]+)*/g) || [];

  // 连续的中文块
  const cjkGroups = text.match(/[㐀-鿿豈-﫿]+/g) || [];

  const grams = [];
  for (const group of cjkGroups) {
    if (group.length === 1) {
      grams.push(group);
      continue;
    }
    for (let i = 0; i < group.length - 1; i += 1) {
      grams.push(group.slice(i, i + 2));
    }
  }

  return [...new Set([...latin, ...grams])];
}

单字查询要保留,否则用户搜「页」的时候会得到空数组。Set 去重可以避免「内存内存」这类输入把权重刷爆。

打分:让标题比正文更响

搜索不是「包含就给 1 分」。同一组关键词,出现在标题里和出现在第三百行的代码注释里,意义完全不同。

我给不同字段分配了简单权重:

字段完全包含短语包含 token
标题+26+12
标签+12+7
描述—+5
系列 / 分类—+3
正文—每次出现 +1,最多 4 次

写成公式:

score(d,q)=∑t∈tokens(q)(12⋅1t∈title+7⋅1t∈tags+5⋅1t∈desc+min⁡(4,countbody(t)))\mathrm{score}(d, q) = \sum_{t \in \mathrm{tokens}(q)} \left( 12 \cdot \mathbb{1}_{t \in title} + 7 \cdot \mathbb{1}_{t \in tags} + 5 \cdot \mathbb{1}_{t \in desc} + \min(4, \mathrm{count}_{body}(t)) \right)

它显然不是一个严谨的 BM25,但对个人站点而言,可解释性比理论完备更重要。如果搜不到东西,我知道该调哪一行。

索引长什么样

构建时,Astro 用一个静态 endpoint 把文章压成 JSON:

// src/pages/search.json.ts
import type { APIRoute } from 'astro';
import { getPosts, getPostText } from '../utils/posts';

export const GET: APIRoute = async () => {
  const posts = await getPosts();
  const data = posts.map((post) => ({
    title: post.data.title,
    description: post.data.description,
    category: post.data.category,
    tags: post.data.tags,
    series: post.data.series ?? '',
    date: post.data.pubDate.toISOString().slice(0, 10),
    url: `/posts/${post.id}`,
    body: getPostText(post).slice(0, 6000),
  }));

  return new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json; charset=utf-8' },
  });
};

正文截断到 6000 字,是为了控制索引体积。多数查询的关键词会出现在文章前半段,而完整正文本来也不适合塞进首屏请求。

摘录与高亮

找到文章以后,还要告诉用户为什么它被找到。

摘录的逻辑很朴素:在正文里找到第一个命中的 token,然后从它前面一点开始截取,前后加省略号。高亮则把查询 token 作为正则做替换:

const pattern = new RegExp(`(${tokens.map(escapeRegExp).join('|')})`, 'gi');
return escapeHtml(text).replace(pattern, '<mark>$1</mark>');

这里有一个顺序陷阱:必须先转义 HTML,再插入 <mark>。反过来的话,文章里的 &lt;script&gt; 会被二次解析。

交互:搜索框也是舞台

命令行风格的搜索面板,键盘导航是灵魂:

  • Ctrl / Cmd + K 或 / 打开
  • ↑ ↓ 移动,Home / End 跳到首尾
  • Enter 打开,Esc 收起
  • 鼠标移动同步高亮项,点击直接跳转

同时保留一个独立的 /search 页面,支持 ?q= 参数。这样搜索结果可以被分享,也能在禁用 JavaScript 时给用户一个交代。

它不完美,但它属于我

这个搜索没有词干提取,不理解同义词,也不会自动纠正错别字。搜「缓存」不会命中只写了「cache」的文章。

但它只有几百行,加载一个几十 KB 的 JSON,在断网后甚至能被 Service Worker 缓存;出错时我能顺着数据流一路读到底。

有时候,一个自己能完全理解的简单系统,比一个黑盒的完美系统更让人安心。

搜索不是为了让内容被找到,而是为了让写作被看见。

MARGINALIA

留言0

路过的话,留下一枚脚印吧。
BE KIND, BLOOM SOFTLY.

    还没有留言。做第一个在这里留下脚印的人吧。

    登录后会把留言记在你的名字下;不注册也可以直接留言。
    支持换行 · 最多 2000 字0 / 2000