从零写一个中文全文搜索:bigram 的浪漫
当 Pagefind 遇到中文,为什么一个简单的双字切分就足够好用?从索引、打分到高亮,完整拆一遍。
博客里的文章多了以后,最先崩溃的不是导航,而是我自己。
想找「虚拟内存」那篇笔记,却只记得里面提过 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 次 |
写成公式:
它显然不是一个严谨的 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>。反过来的话,文章里的 <script> 会被二次解析。
交互:搜索框也是舞台
命令行风格的搜索面板,键盘导航是灵魂:
Ctrl / Cmd + K或/打开↑↓移动,Home/End跳到首尾Enter打开,Esc收起- 鼠标移动同步高亮项,点击直接跳转
同时保留一个独立的 /search 页面,支持 ?q= 参数。这样搜索结果可以被分享,也能在禁用 JavaScript 时给用户一个交代。
它不完美,但它属于我
这个搜索没有词干提取,不理解同义词,也不会自动纠正错别字。搜「缓存」不会命中只写了「cache」的文章。
但它只有几百行,加载一个几十 KB 的 JSON,在断网后甚至能被 Service Worker 缓存;出错时我能顺着数据流一路读到底。
有时候,一个自己能完全理解的简单系统,比一个黑盒的完美系统更让人安心。
搜索不是为了让内容被找到,而是为了让写作被看见。
MARGINALIA
留言0
路过的话,留下一枚脚印吧。
BE KIND, BLOOM SOFTLY.
还没有留言。做第一个在这里留下脚印的人吧。