零后端混合搜索引擎:在浏览器里跑 BM25 + 语义搜索
静态博客最大的痛点之一是搜索。没有后端服务器,意味着不能用 Elasticsearch、不能调数据库、甚至连一个简单的全文检索 API 都没有。大多数人的选择要么是接入第三方搜索服务(Algolia),要么干脆放弃搜索功能。
我选了第三条路:把整个搜索引擎搬到浏览器里。不是简单的字符串匹配,而是一套完整的 BM25 + 语义扩展混合搜索系统,支持中英文跨语言检索,全程零后端依赖。
本文是这套搜索系统的完整技术总结。
架构概览
整个系统分两部分:构建期(Node.js)和运行时(浏览器)。
构建期 (node) 运行时 (browser)
┌─────────────────┐ ┌──────────────────────┐
│ Markdown/Notebook│ │ P0: 倒排索引 + 元数据 │ → 即时关键词搜索
│ 照片相册 + AI标签 │ build.sh │ P1: 关键词向量 (KVEC) │ → 语义扩展就绪
│ ────────────────→ │ ──────────→ │ P2: ONNX 模型 │ → 完整语义搜索
│ index-builder │ └──────────────────────┘
│ vector-builder │ ↓
│ image-tagger │ BM25 + 语义扩展 → 复合评分融合
└─────────────────┘
关键设计原则:渐进式加载。用户打开页面的瞬间就能用关键词搜索(P0),不需要等模型加载。语义搜索在后台逐步就绪,锦上添花而非必须。
构建期:从内容到索引
分词器:中文 unigram + bigram
搜索系统的基石是分词。英文天然以空格分隔,中文则没有这个便利。常见方案是引入 jieba 等分词库,但会增加构建和运行时的依赖。
我用了一个更轻量的方案:中文字符 unigram + bigram。
function tokenize(text) {
const tokens = text.toLowerCase().match(/[\u4e00-\u9fff]+|[a-z0-9]+/g) || [];
const result = [];
for (const token of tokens) {
if (/[\u4e00-\u9fff]/.test(token)) {
// 中文:逐字 + 相邻两字
for (let i = 0; i < token.length; i++) {
result.push(token[i]);
if (i < token.length - 1) {
result.push(token.slice(i, i + 2));
}
}
} else if (token.length >= 2) {
result.push(token);
}
}
return result.filter(t => !STOPWORDS.has(t)
&& (t.length >= 2 || /[\u4e00-\u9fff]/.test(t)));
}
举例:"搜索引擎" → ["搜", "搜索", "索", "索引", "引", "引擎", "擎"]
这种方案有几个好处:
- 无外部依赖:不需要分词词典
- 召回率高:bigram 覆盖了大部分常见词汇("搜索"、"引擎"都能命中)
- 单字也能搜:unigram 保证了"树"、"花"这样的单字查询不会落空
- 构建期和运行时一致:同一套分词逻辑,避免索引与查询不匹配
代价是精度略低("索引"和"引擎"都会匹配到"引"),但 BM25 的 IDF 权重会自然压低高频通用 token 的贡献。
倒排索引:紧凑的 v2 格式
索引构建器扫描所有 Markdown 文章、Jupyter Notebook 和照片相册,生成三个文件:
| 文件 | 内容 | 大小 |
|---|---|---|
search-inverted.json |
紧凑倒排索引 | ~2.1 MB |
search-metadata.json |
文章元数据(标题、日期、摘要) | ~117 KB |
search-vocab.json |
词汇表统计信息 | ~2.4 MB |
倒排索引使用紧凑的 v2 格式,用数字 ID 替代 URL 字符串:
{
"v": 2,
"docs": ["/blog/posts/2026/...", "/gallery/20210711-Chengdu Panda zoo/", ...],
"avgDL": 250.5,
"N": 282,
"dl": [245, 268, ...],
"idx": {
"搜索": [[0, 5], [3, 2], [12, 1]],
"search": [[0, 3], [5, 4]]
}
}
[[docNum, tf], ...] 替代原来的 [{id: "url", tf: 5}, ...],JSON 体积减小约 50%。
文档分块
长文章会被切分成 ~500 字符的 chunk,重叠 50 字符。切分点优先选在句号或换行处,避免在句子中间断开。最终索引以文章为单位聚合(同一篇文章的所有 chunk 合并计算 TF),这样 BM25 评分反映的是整篇文章的相关度。
照片相册的双语索引
照片搜索是这个系统的特色之一。每个照片相册由 AI 生成双语标签(后面会详细介绍),然后和文章一起进入同一套倒排索引:
// 索引文本 = 地点 + 描述 + 英文标签 + 中文标签 + 年份
const text = [album.location, description, tagsEn, tagsZh, year].join(' ');
// 标签字段(用于 title/tag 权重提升)
const tags = `photo gallery ${album.location} ${tagsEn} ${tagsZh}`;
这意味着搜索"熊猫"能直接命中成都大熊猫动物园的相册,搜索"museum"也能找到博物馆的照片。
构建期:关键词向量
为什么要预计算向量?
语义搜索的常规做法是:对查询和文档都做 embedding,然后计算余弦相似度。但在浏览器里对几百篇文章做实时 embedding 太慢了。
换一个思路:不 embed 文档,embed 词汇表。
构建期从倒排索引的 ~70,000 个 term 中筛选出 8,000 个最有价值的,预计算它们的 embedding。搜索时,只需要把查询文本 embed 一次,然后跟这 8,000 个预计算向量做点积——纯 CPU 运算,50ms 内完成。
词汇筛选策略
70,000 个 term 不可能全部向量化(太大了)。筛选策略:
得分 = 0
if (出现在标题/标签中) 得分 += 100 // 精选术语,最高优先级
得分 += min(文档频率, 50) × 2 // 出现范围广 = 匹配价值高
得分 += min(最大词频, 20) // 在某些文档中高频 = 有意义的术语
if (中文 && 长度 >= 2) 得分 += 10 // 中文双字词有意义
if (英文 && 长度 >= 4) 得分 += 5 // 较长英文词更有辨识度
取前 8,000 名进入向量化。
特别地,中文 bigram 默认被过滤(因为大部分是"景的"、"了一"这样的无意义组合),但出现在标题或标签中的中文 bigram 会被保留——这些是经过精心策划的有意义词汇,比如照片标签里的"教堂"、"熊猫"。
multilingual-e5-small:跨语言的关键
这套系统最初用的是 BGE-small-zh-v1.5(512 维,中文专用模型)。它在中文内的语义匹配效果不错,但跨语言就完全不行:
BGE-small-zh:
cosine("教堂", "cathedral") = 0.33 ← 远低于阈值
cosine("利物浦", "liverpool") = 0.28 ← 几乎正交
换成 multilingual-e5-small(384 维,支持 100+ 语言)后:
multilingual-e5-small:
cosine("埃及", "egypt") = 0.917 ✓
cosine("展览", "exhibition") = 0.897 ✓
cosine("雕像", "sculpture") = 0.879 ✓
cosine("博物", "museum") = 0.838 ✓
cosine("熊猫", "panda") = 0.830 ✓
e5 模型有一个重要的约定:查询文本需要加 "query: " 前缀,而被检索的文本不加。构建期计算词汇表向量时不加前缀,浏览器端搜索时加前缀。
Int8 量化与 KVEC 二进制格式
384 维 × 8,000 词 × 4 字节 = 12.3 MB,对浏览器来说太大了。
解决方案:Int8 量化。e5 输出的向量已经 L2 归一化(值域 [-1, 1]),直接乘以 127 取整:
quantized = clamp(round(float × 127), -128, 127)
精度损失 < 0.5%,存储压缩 4 倍:12.3 MB → 3.1 MB,gzip 后 ~1.8 MB。
二进制文件格式(KVEC):
[4B 魔数 "KVEC"]
[4B 词汇量 uint32]
[4B 维度 uint32]
[词汇量 × 维度 bytes: Int8 向量,行优先]
[剩余 bytes: JSON 术语数组,UTF-8]
嵌入缓存
计算 8,000 个 embedding 需要几分钟。为了加速增量构建,向量构建器维护一个 JSON 缓存文件。每次构建只计算新增/变更的术语,已有的直接复用。缓存文件每次构建后会被裁剪,只保留当前词汇表中的术语。
构建期:AI 图像自动标注
为什么需要图像标注?
照片相册的元数据通常只有英文的地点名和日期。用中文搜索"大熊猫"找不到 "Chengdu Panda zoo",搜索"教堂"也找不到 "Liverpool Metropolitan Cathedral"。
解决方案:用多模态 AI 在构建期分析相册的代表性图片,生成中英双语标签。
AI 降级链
// 优先级:Gemini CLI → OpenAI API → Claude CLI
if (hasGemini()) tryGemini(image); // 本地 CLI,速度最快
if (hasOpenAI()) tryOpenAI(image); // 云端 API,可靠性高
if (hasClaude()) tryClaude(image); // 开发者环境常备
Prompt 要求输出纯 JSON:
分析这张照片,输出一个 JSON 对象:
- "en": 5-10 个英文关键词标签
- "zh": 5-10 个中文关键词标签
示例: {"en":["cathedral","gothic architecture"], "zh":["教堂","哥特式建筑"]}
标签质量
AI 不只是做机械翻译,它会根据图片内容生成文化适配的标签:
// 成都大熊猫动物园
{"en": ["pandas", "bamboo", "zoo", "wildlife", "natural habitat"],
"zh": ["熊猫", "竹子", "动物园", "野生动物", "自然栖息地"]}
// 上海博物馆埃及特展
{"en": ["exhibit", "ancient artifact", "Egyptian history", "museum"],
"zh": ["展览", "古代文物", "埃及历史", "博物馆"]}
幂等性与缓存
脚本检查每个相册目录下是否已存在 tags.json,存在即跳过。这意味着:
- 重复运行不会浪费 API 调用
- 新增相册自动处理
- 可以手动编辑
tags.json覆盖 AI 结果
运行时:浏览器端搜索
渐进式加载
用户体验的关键在于不等待:
| 阶段 | 加载内容 | 延迟 | 能力 |
|---|---|---|---|
| P0 | 倒排索引 + 元数据 | < 100ms | 完整的关键词搜索 |
| P1 | 关键词向量 (1.8MB) | 200-500ms | 语义扩展就绪 |
| P2 | ONNX 模型 (~20MB) | 2-20s | 完整语义搜索 |
P0 完成后立即可用。用户输入查询的同时,P1/P2 在后台加载。如果用户搜索时模型还没准备好,UI 会显示加载提示,关键词结果先展示,语义结果加载完成后动态合并。
BM25 关键词搜索
标准的 BM25 实现,参数 k1=1.2, b=0.75:
score(q, d) = Σ IDF(t) × (tf × (k1+1)) / (tf + k1 × (1-b + b × |d|/avgDL))
一个巧妙的设计:前缀匹配降级。当完全匹配无结果时,自动尝试前缀匹配("water" → "waterfall", "watermelon"),但分数打 0.8 折。
语义扩展:不是重新排序,是扩充查询
语义搜索不是对 BM25 结果做重排序,而是发现新的关联词汇,用这些词汇再做一轮 BM25 检索。
流程:
- 用 e5 模型 embed 查询文本(加 "query: " 前缀)
- 跟 8,000 个预计算的词汇向量做余弦相似度
- 取相似度 > 0.82 的前 8 个术语作为扩展词
- 用扩展词做 BM25 检索,但不使用 TF——每个扩展词的文档贡献以语义相似度加权
为什么不用 TF?因为语义扩展找到的是相关主题,而不是精确匹配。一篇文章提到一次"瀑布"和提到十次"瀑布",对于"waterfall"这个语义扩展词来说相关度是一样的。
复合评分融合
最终排名融合两条路径的分数:
finalScore = 0.6 × normalize(BM25分数) + 0.4 × normalize(语义分数)
加上两个奖励因子:
- 共现加成 ×1.2:同时出现在关键词和语义结果中的文档
- 标题命中 ×1.5:查询词出现在文章标题中
两路分数各自归一化(最高分 → 1.0),避免某一路因为绝对值大而主导结果。
搜索结果 UI
结果分两类渲染:
- 照片:顶部横向滚动画廊,展示封面图、地点、日期
- 文章:纵向列表,展示标题、日期、摘要
每个结果标注来源:蓝色 "keyword" 标签表示关键词命中,粉色 "AI" 标签表示语义扩展命中。两者兼有则都显示。
语义搜索完成后还会展示扩展词列表(如 "Related: cathedral, church, gothic"),让用户了解搜索引擎"联想"到了什么。
Service Worker:模型缓存
ONNX 模型文件(~20MB)通过 Service Worker 做 cache-first 缓存:
// 匹配模型文件的 URL 模式
const MODEL_PATTERNS = ['/onnx/', 'multilingual-e5', '.onnx', 'tokenizer', '/public/models/'];
// 拦截策略:cache-first
if (isModelFile(url)) {
const cached = await caches.match(request);
if (cached) return cached; // 缓存命中,直接返回
const response = await fetch(request);
cache.put(request, response.clone()); // 首次加载后缓存
return response;
}
首次访问后,后续加载都是本地读取。这意味着即使在离线环境下,语义搜索也能正常工作(前提是之前加载过模型)。
性能数据
在本站(282 篇文章 + 137 个照片相册,71,332 个索引术语)上的实测数据:
| 指标 | 数值 |
|---|---|
| P0 关键词搜索延迟 | < 10ms |
| P1 向量加载 | ~300ms (1.8MB gzip) |
| P2 模型首次加载 | 5-15s(取决于网络) |
| P2 模型缓存后加载 | < 2s |
| 语义扩展计算 | ~50ms (8000 次点积) |
| 倒排索引大小 | 2.1 MB (gzip ~400KB) |
| 关键词向量大小 | 3.1 MB (gzip ~1.8MB) |
| 搜索结果渲染 | < 5ms |
效果示例
跨语言搜索
| 查询 | 关键词路径 | 语义路径 | 结果 |
|---|---|---|---|
| "熊猫" | 命中中文标签 → 成都大熊猫动物园 | 扩展到 "panda", "zoo" | 照片 + 相关文章 |
| "cathedral" | 命中英文地点名 → Liverpool Cathedral | — | 照片相册 |
| "教堂" | 无直接命中 | 扩展到 "cathedral", "church" | 找到大教堂照片 |
| "museum" | 命中多个博物馆相册 | 扩展到 "exhibit", "artifact" | 照片 + 文章 |
单字搜索
"树"、"花"这样的单字查询也能正常工作——分词器保留了有意义的中文单字,只过滤英文单字母和停用词。
构建流水线
完整的构建流程(build.sh):
1. HEIC → JPG 转换(照片格式统一)
2. 照片压缩
3. 相册描述生成(文字 AI)
4. 图像标签生成(多模态 AI,新增)
5. Office/LaTeX 文档转换
6. posts.json(博客文章索引)
7. photos.json(照片相册索引,含双语标签)
8. videos.json(视频索引)
9. 搜索倒排索引构建
10. 关键词向量构建
11. 静态 HTML 生成
其中图像标签生成和向量构建都有缓存机制,增量构建只处理新增内容。
架构审查:设计 vs 实现
这套系统是按照一份详细的架构设计文档构建的。以下是原始设计与最终实现的回顾对比。
按计划达成的目标
| 设计目标 | 实现情况 |
|---|---|
| 保留 BM25,语义路由叠加在上层 | 完全按计划,BM25 是基座,语义扩展是叠加层 |
| 渐进增强 P0/P1/P2 | 关键词即时可用,向量次之,模型最后 |
| Int8 量化 + 二进制格式 | KVEC 格式,384 维 × 8,000 词,gzip ~1.8MB |
| 词级语义路由(非文档级) | 核心创新保留——embed 词汇表而非文档 |
| 双路径评分融合 | 0.6/0.4 权重,加共现加成和标题命中 bonus |
| 扩展词 UI 透明可见 | "Related: cathedral, church, gothic" 展示给用户 |
| Service Worker 模型缓存 | cache-first 策略 |
| 照片相册进统一索引 | AI 双语标签 + 统一倒排索引 |
| 推迟 CLIP / 推迟 PDF | 用 LLM API 代替 CLIP 做图片打标,PDF 推迟 |
| 直接替换(无 Feature flag) | 旧搜索系统完全替换 |
有意为之的偏离
1. multilingual-e5-small 替代 BGE-small-zh-v1.5
原设计指定 BGE(512 维,中文专用模型)。两个模型都很小很快,但 BGE 的跨语言相似度低到不可用——"教堂" vs "cathedral" 只有 0.33。e5 同样的测试达到 0.83+。对于一个双语博客来说,跨语言能力是刚需。代价是 e5 有 "query: " 前缀约定,需要确保构建期和运行时一致。
2. 用 LLM API 替代 CLIP 做图片打标
原设计用 CLIP 配合预定义候选标签池做 cosine 匹配。实现改用 Gemini/OpenAI/Claude 多模态 API 生成自由文本双语标签。AI 能生成文化适配的标签(比如给熊猫栖息地照片生成"自然栖息地"),这是 CLIP 从静态标签池里选不出来的。API 依赖仅在构建期,且有缓存和幂等保障。
3. unigram + bigram 替代 jieba
原设计用 jieba/nodejieba 做离线分词,和 FlexSearch CJK 模式之间存在已知的分词一致性风险。实现直接用字符级 unigram + bigram,构建期和运行时完全一致,彻底消除了分词一致性问题。精度略低,但 BM25 的 IDF 会自然压低噪音。
4. 统一评分排序替代 DF 阈值过滤
原设计用分层过滤(标题词无条件保留、DF=1 且 TF≤2 过滤、DF>80% 过滤)。实现改成统一评分函数(标题 +100 分、DF/TF 加权、长度 bonus),取 Top-8,000。更灵活,阈值更好调。
5. 语义阈值 0.82 替代 0.55
原设计建议 ≥0.55,上限 8 个扩展词。实现用了 0.82。原因是 e5 的相似度分布整体偏高——"熊猫" vs "panda" 就有 0.83。提高阈值是为了保持精度。需要留意边缘 case 是否会因此丢失有用的扩展词。
6. 语义路径不使用 TF
原设计未明确提及这一点。实现中语义扩展词的检索只按相似度加权,不考虑词频。理由是:语义扩展找到的是相关主题,不是精确匹配。一篇文章提到一次"瀑布"和提到十次"瀑布",对于语义扩展词来说相关度是一样的。
待改进项
1. SoA(结构体数组)内存布局 — 原设计强调 SoA 以优化 cache line。当前 KVEC 格式是行优先(AoS)布局:每个词的 384 字节连续存储。在 8,000 词(~3MB)的规模下,整个数据集能放进 L3 缓存,影响可忽略。如果词汇量增长到 4 万以上,SoA 的优势会体现出来。
2. 扩展词可交互移除 — 原设计要求用户可以点击移除单个扩展词并触发重新搜索。当前实现把扩展词渲染为静态标签,没有点击事件。这是一个值得加的体验优化——实现也不复杂:给标签加点击事件,从扩展列表中移除该词,重新走融合评分即可,不需要重新计算 embedding。
总结
这套系统证明了一件事:纯静态站点也能拥有媲美动态服务的搜索体验。
核心设计思路:
- 渐进增强:关键词搜索即时可用,语义搜索锦上添花
- 构建期投入,运行时零成本:AI 标注、向量预计算都在构建期完成
- 跨语言不靠翻译:多语言 embedding 模型天然支持语义桥接
- Int8 量化:在精度和体积之间找到最佳平衡点
- 幂等构建:缓存机制确保重复构建不浪费资源
整套方案的维护成本极低——不需要服务器、不需要数据库、不需要付费搜索服务。每次 git push 就是一次完整的搜索引擎更新。