← Back to Blog
EN中文

零后端混合搜索引擎:在浏览器里跑 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 检索。

流程:

  1. 用 e5 模型 embed 查询文本(加 "query: " 前缀)
  2. 跟 8,000 个预计算的词汇向量做余弦相似度
  3. 取相似度 > 0.82 的前 8 个术语作为扩展词
  4. 用扩展词做 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 就是一次完整的搜索引擎更新。