在前端做全文搜索,不一定要用搜索引擎

几百篇文章的站内检索,用几百行 TypeScript 就够了。聊聊倒排索引、TF-IDF 加权和中文分词的那些坑。

2 分钟阅读 806 字 zero
目录(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)。

真正要小心的是在长文本上做模糊匹配。如果对正文每个位置都做一次编辑距离计算,复杂度立刻爆炸。我的做法是:模糊匹配只用在标题和标签这类短字段上,正文只做子串查找。

用户体验上的小设计

几个不明显但很影响手感的地方:

  1. # 前缀切换模式。输入 #搜索 就只搜标签,不用先在界面上选分类
  2. 键盘导航⌘K 打开、方向键移动、回车跳转、Esc 关闭,全程不用碰鼠标
  3. 索引懒加载。第一次打开搜索框才去请求 JSON,首屏不受影响
  4. 空结果要给建议。列出热门标签,比一句「没有找到」有用

什么时候该换方案

文章超过几千篇,或者需要同义词、拼写纠错、结果排序调优的时候,自己写就不划算了。那时候:

  • Pagefind:构建期生成索引,按需分片加载,几乎零配置
  • Orama / MiniSearch:成熟的前端全文搜索库,自带中文支持和向量检索

自研的价值在于可控和零依赖。但如果目标只是「能用」,上面这些库都比从头写省事。

  • Vibe Coding 全流程提示词规范

    以 AI 为核心驱动的五阶段提示词工作流:从需求方案到代码实现、测试、调试与提交,附可直接复用的 Prompt 模板。

    3 分钟阅读 1265 字
  • 深色模式的三个细节

    主题切换看起来很简单,但要做得不闪、不刺眼、不违和,有几个容易忽略的地方。

    2 分钟阅读 687 字
  • 用 Astro 搭建一个静态博客

    从零开始,用 Astro 的内容集合(Content Collections)组织 Markdown,构建期生成静态页面,顺便聊聊为什么静态站点正在回归。

    2 分钟阅读 605 字