在前端做全文搜索,不一定要用搜索引擎
几百篇文章的站内检索,用几百行 TypeScript 就够了。聊聊倒排索引、TF-IDF 加权和中文分词的那些坑。
目录(7 节)
给博客加搜索,第一反应是 Algolia 或者 Meilisearch。但对一个个人站点来说,这些方案有点重:要么每月付费,要么得单独跑一个服务。
文章总数不到一千的场合,把索引整个塞进浏览器是完全可行的。
第一步:构建期生成索引
搜索发生在浏览器里,所以需要一个 JSON 索引。用 Vite 的 import.meta.glob 在构建时把所有 Markdown 读进来:
const files = import.meta.glob('../content/blog/**/*.md', {
query: '?raw',
import: 'default',
eager: true,
});
关键是 ?raw——拿到的是未编译的原始 Markdown 文本。这比走内容集合再去要 body 更直接,也避免了依赖某些会变动的 API。
索引里存什么?标题、摘要、标签、日期,以及去掉 Markdown 标记后的正文纯文本。代码块不要丢,搜 API 名字的时候很有用。
第二步:分词
英文分词简单,按非字母数字切就行。中文没有空格,麻烦得多。
完整的中文分词需要词典,体积动辄几 MB,不适合放在前端。退而求其次,用二元组(bigram):把「静态网站」切成「静态」「态网」「网站」。
function bigrams(term: string): string[] {
if (term.length < 3) return [];
const out: string[] = [];
for (let i = 0; i < term.length - 1; i++) {
out.push(term.slice(i, i + 2));
}
return out;
}
查询时先试完全匹配,没命中再算 bigram 覆盖率。覆盖率超过一半就当作模糊命中,按比例给分。
这个办法召回率高,代价是有少量误报。对博客搜索来说,误报比漏报好受得多。
第三步:加权打分
不同字段的重要性不一样。标题里出现关键词,显然比正文里出现过三十次更相关。所以给每个字段一个权重:
| 字段 | 权重 | 说明 |
|---|---|---|
| 标签完全匹配 | 140 | #技术 这种精确命中 |
| 标题开头 | 130 | 相关性最强的信号 |
| 标题包含 | ~108 | 位置越靠前分越高 |
| 摘要 | 55 | |
| 正文 | 45+ | 出现次数越多分越高,但封顶 |
多关键词查询时,如果某个词只命中了一部分文章,总分要打折扣——用户想要的是同时包含所有关键词的结果。
第四步:高亮
把命中的位置找出来包进 <mark>,顺便截取上下文当摘要。注意两个细节:
- 先转义再插标签。正文里可能有
<script>,直接拼进innerHTML就是个 XSS 漏洞 - 合并重叠区间。多个关键词的命中范围可能重叠,得先排序再合并,否则会生成嵌套错乱的标签
function mergeRanges(ranges: Array<[number, number]>) {
ranges.sort((a, b) => a[0] - b[0]);
const merged = [ranges[0]];
for (const [start, end] of ranges.slice(1)) {
const last = merged[merged.length - 1];
if (start <= last[1]) last[1] = Math.max(last[1], end);
else merged.push([start, end]);
}
return merged;
}
性能
一个 24 KB 的文档,在上面做十几次 indexOf,现代浏览器大概只要零点几毫秒。二十篇文章的话,每次按键的总耗时远低于一帧(16ms)。
真正要小心的是在长文本上做模糊匹配。如果对正文每个位置都做一次编辑距离计算,复杂度立刻爆炸。我的做法是:模糊匹配只用在标题和标签这类短字段上,正文只做子串查找。
用户体验上的小设计
几个不明显但很影响手感的地方:
#前缀切换模式。输入#搜索就只搜标签,不用先在界面上选分类- 键盘导航。
⌘K打开、方向键移动、回车跳转、Esc关闭,全程不用碰鼠标 - 索引懒加载。第一次打开搜索框才去请求 JSON,首屏不受影响
- 空结果要给建议。列出热门标签,比一句「没有找到」有用
什么时候该换方案
文章超过几千篇,或者需要同义词、拼写纠错、结果排序调优的时候,自己写就不划算了。那时候:
- Pagefind:构建期生成索引,按需分片加载,几乎零配置
- Orama / MiniSearch:成熟的前端全文搜索库,自带中文支持和向量检索
自研的价值在于可控和零依赖。但如果目标只是「能用」,上面这些库都比从头写省事。