给博客做个站内全文搜索:纯前端离线倒排索引的设计与取舍
最近给本站正在使用的Chaos主题 增加并逐步完善了一套在客户端进行的全文检索功能。
早年本站使用的是Google站内搜索,不过换成Hugo以后模板中的这个搜索框也就去掉了,毕竟现在也没什么人看blog了,写下去除了给自己留点记录之外,或多或少其实是一种行为艺术,而本站的读者基本上也都知道如何使用搜索引擎。
不过,有时我自己也会希望在blog里找一些以前的文章,虽然本站在Google搜索中的覆盖率还相当高,但有些时候新的文章会搜索不到,或是其实内容已经过时但仍然被索引。虽然在大语言模型的帮助下我整体重建了分类和标签的标注,但这些对于定位有时还是不太方便。
Hugo作为一个静态站点生成器,其实已经有了不少成熟的本地搜索方案。不过,今年都已经2026年了,vibe一个本地搜索并不困难。这里记录一下这个本地搜索的演化过程,以及在这个过程中的一些工程取舍。
这两天和张师傅聊起他在别处用的vibe coding的时候我说了一句:
Quote: 你小心vibe出来点什么不干净的东西
— 𝓧𝓲𝓷 𝓛𝓲 (@delphij) 2026年10月1日
不过话说回来,2026年各种大模型的能力已经越来越强,实际用下来效果真的不差。
为什么没有用现成的方案
比较明显的几种做法包括:
- 外部服务:例如早年的Google Site Search,以及后来的Programmable Search Engine;
- 自建服务端搜索引擎:例如架设Typesense、Meilisearch或Elasticsearch;
- 构建时生成索引的纯前端搜索:例如Pagefind、Lunr、Fuse.js,或者自己写一个。
从运行复杂度来说,第一种最为简单,但对中国大陆的读者不太友好。其次是第三种,本质上它是塞给用户一套全站的索引,然后用JavaScript在本地完成搜索。最麻烦的是第二种,需要在服务器上运行索引和搜索服务。
最终我选择了第三种,并且是自己vibe的那一种。
为什么不用Google站内搜索
早年的个人博客直接嵌一个Google搜索框是很普遍的做法,但现在看有两个问题。
一是隐私和外部依赖。引入第三方服务意味着读者要向第三方提供数据,有些读者不愿意,有些读者根本访问不了。本站目前没有引用任何第三方的跟踪脚本和公共CDN,我也不太想为了搜索破这个例。
二是索引滞后。搜索引擎要等爬虫来抓。虽然我提供了sitemap,但它只是告诉爬虫有哪些页面,并不能让它马上来(Google早年提供过sitemap ping,2023年已经停用;现在的Indexing API只面向招聘信息、直播这类特定内容)。所以文章发布之后有一段时间是搜不到的,而我恰恰经常想在刚写完的时候搜一下相关的旧文章。在构建时生成的索引是和文章一起部署的,上线就能搜到。
为什么不用Typesense之类的服务端搜索
Typesense和Meilisearch都很好用,速度快,容错匹配也做得不错,但它们都需要在服务器上常驻一个进程。本站是FreeBSD上的nginx直接提供预先生成好的静态文件,除了独立部署的评论系统之外没有任何动态后端,平时基本不用管。为了搜索多跑一个服务,就要多管一个服务和一份上游的安全更新,不太划算。
另一方面,数据量也不大:写了二十多年,文章不到两千篇,全部Markdown加起来其实很小。这个量级把索引直接发给浏览器、在读者本地做匹配和评分完全没有问题。如果是几百万篇文档的站点,那当然是另一回事。
索引格式与检索架构的演进
索引由主题里的一个Python脚本在构建之前生成:用jieba分词,对每篇文章的标题、标签、分类和正文(包括代码块)建立倒排表,输出成JSON;浏览器端的search.js负责加载、匹配和排序,没有第三方依赖。剩下的问题主要是如何让这个索引小一些,加载快一些。
1. 两层索引
最初的版本是一个单一的JSON文件。对全站建立索引之后它会有一兆多,而大部分搜索其实只靠标题和标签就能命中,所以后来把索引拆成了两个文件:
- 核心索引(
search-index.json):全部文章的元数据(标题、日期、URL、标签,以及Front Matter里的描述),加上只针对标题、标签和分类建立的倒排表。目前1949篇文章对应的这个文件是400 KB,其中300 KB左右是元数据,Brotli压缩后约112 KB。 - 正文索引(
search-index-body.json):只有正文和代码中词条的倒排表,目前是992 KB,Brotli压缩后约461 KB。如果某个词已经出现在一篇文章的标题或标签里,这篇文章就不会再出现在该词的正文列表中,这样合并两层时不必去重,文件也小一些。
打开搜索框时先加载核心索引,加载完就可以搜标题和标签了。之后search.js用requestIdleCallback(没有这个API的浏览器退回到setTimeout)在浏览器空闲时去取正文索引,取到之后合并进内存中的索引,并把当前的查询重新跑一遍。这两次请求都是异步的,不影响输入。
2. 从JSON数组到Base-32差值编码的字符串
最初的倒排表是很直观的JSON:
{"freebsd":[0,3,15,42,108],"kernel":[0,1,3,7,15]}当索引里有五六万个词条时,这个格式有两个问题:
- 浏览器解析时要创建五六万个小数组;
- 文档ID是十进制数字,到1000以上每个就要4个字节,再加上逗号,文件的大部分都是数字和分隔符。
于是我把倒排表改成了两个长字符串:
terms:所有词条按字典序排列,以空格连接;postings:与词条一一对应的倒排列表,同样以空格连接。
每个倒排列表内部使用差值编码:
| |
也就是说,记录的不是文档ID本身,而是升序排列后相邻两个ID的差值减一。64个字符中每个字符表示6位:低5位是数值,第6位表示后面还有没有(和Protobuf的Varint是一个思路)。差值不超过31时,一个字符就能表示一篇文档。
文档ID是按发布时间从新到旧排的,而同一话题的文章在时间上往往比较集中,所以差值通常不大。就本站目前的索引而言,将近一半的差值可以用一个字符表示,平均每个引用约1.6个字符。
改格式时的测量结果(1950篇文章,在我的MacBook Pro上用Node模拟):
- 正文索引从1.8 MB降到0.9 MB;不过Brotli本来就很擅长压缩重复的数字,压缩之后只是从527 KB降到432 KB;
- 加载两个索引的时间从38 ms降到13 ms,这是主要的收获;
search.js不再为每个词条预先建立数组,而是保留字符串,查询命中某个词条时才解码那一条。
在这之后我又把正文索引从只取每篇文章的前6000个字符改成了全文,所以现在的文件比上面的数字略大一些。
3. 技术词汇、繁简中文与容错
作为一个技术博客,还有几个额外的需求:
- 带符号的技术词汇:
C++、C#、.NET这类词如果交给通用分词器会被切碎,脚本中对它们做了特殊处理,作为完整的词条保留;带+或#的标签也会自动加入这个列表。代码块和行内代码的内容同样会被索引,不过标识符会在_和-处断开,例如kmem_alloc在索引中是kmem和alloc两个词条,搜索时也按同样的方式拆开,两个词都命中的文章会排在前面。 - 繁简统一:旧文章和引文里有一些繁体中文。脚本用OpenCC的字符表,在分词之前把繁体字折叠成简体字;两个索引文件各带一份约5 KB的映射表(只包含索引中实际用得到的字),
search.js对输入的查询做同样的折叠。这样无论用繁体还是简体都能搜到,而结果中的标题仍然按原文显示。 - 拼写容错与部分匹配:4个字符以上的英文单词,如果既不是索引中的词条、也不是某个词条的开头(比如
freebds、opnssl、kernal),会去找相差一次编辑的词条:多一个、少一个、错一个字符,或是相邻两个字符写反,并以较低的权重计入结果。中文方面,jieba会把人名这类词作为一个整体,只输入其中两个字时,会在词条内部做子串查找。
为什么从fingerprint哈希文件名改回固定URL
最初两个索引文件是通过Hugo的资源管道加上指纹的,简化之后大致是这样:
{{ $res := resources.Get "search-index.json" | resources.Fingerprint }}
<dialog id="searchDialog" data-index-url="{{ $res.RelPermalink }}"></dialog>生成的文件名形如/search-index.8a3f9c….json。给静态资源加上内容哈希是很常见的做法:服务器可以放心地配置Cache-Control: max-age=31536000, immutable,内容变了文件名就变,不必担心客户端拿到旧文件。本站的CSS和JavaScript也是这样做的。
但对搜索索引来说,这个做法有个副作用:改一篇文章,就需要重写全站的HTML
搜索框的模板在每一个页面里都有,于是每一个HTML文件中都写着这个带哈希的URL。发一篇新文章,或者改一篇旧文章,只要索引的内容因此变了,哈希就会变,全站近两千篇文章的HTML也就全都跟着变了。
本站的构建脚本会把上一次构建中没有变化的文件连同其.br和.gz一起沿用,只压缩变化了的文件。全站HTML都变了的话就得全部重新用brotli --best和zopfli压缩一遍,这还挺慢的;rsync也要把它们全部重新传一遍,读者浏览器里缓存的页面同样全部作废。
为此我给Chaos主题增加了search.fingerprint配置项(默认仍然是加指纹),并在本站把它关掉:
[params.search]
enable = true
indexURL = "/search-index.json"
fingerprint = false这样索引文件始终发布在/search-index.json和/search-index-body.json。发表或修改文章时,变化的就只有这篇文章本身、引用到它的列表页和feed,以及两个索引文件。
索引文件的缓存则交给HTTP的条件请求。在nginx中给这两个文件配置Cache-Control: no-cache:
location ~* ^/search-index(-body)?\.json$ {
add_header Cache-Control "no-cache" always;
}no-cache这个名字容易让人误会,它并不是「不缓存」(那是no-store),而是浏览器可以缓存,但每次使用之前要向服务器确认一下。浏览器会带上之前拿到的ETag(If-None-Match)或修改时间(If-Modified-Since)去问:
- 文件没变,nginx回一个只有响应头的
304 Not Modified,浏览器继续用本地的缓存; - 文件变了,nginx回
200 OK和新的索引文件(预先压缩好的Brotli或gzip版本)。
代价是每次打开搜索框会多两个很小的请求,我觉得可以接受。
小结
对一个不到两千篇文章的静态博客来说,这套做法已经够用了:服务器上不需要多跑任何东西,新文章部署完就能搜到,读者的查询也不会发给任何第三方。如果数据量再大几个数量级,或者需要按权限过滤结果,那还是应该老老实实地用Typesense或Elasticsearch。