文章 · 2026-02-15

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

举例:"搜索引擎" → ["搜", "搜索", "索", "索引", "引", "引擎", "擎"]

这个方案的优势:

代价是精度略低("索引"和"引擎"都会匹配到"引"),但 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 太慢了。换个思路:不 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,存在即跳过。这意味着:

运行时:浏览器端搜索

渐进式加载

用户体验的关键在于不等待

阶段 加载内容 延迟 能力
P0 倒排索引 + 元数据 < 100ms 完整的关键词搜索
P1 关键词向量 (1.8MB) 200-500ms 语义扩展就绪
P2 ONNX 模型 (~20MB) 2-20s 完整语义搜索

P0 完成后立即可用。用户输入查询的同时,P1/P2 在后台加载。如果用户搜索时模型还没准备好,关键词结果先显示,语义结果加载完成后动态合并。

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?语义扩展找到的是相关主题,而不是精确匹配。一篇文章提到一次"瀑布"和提到十次"瀑布",对于语义扩展词来说相关度是一样的。

复合评分融合

最终排名融合两条路径的分数:

finalScore = 0.6 × normalize(BM25分数) + 0.4 × normalize(语义分数)

加上两个奖励因子:

两路分数各自归一化(最高分 → 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" 照片 + 文章

单字搜索

"树"、"花"这样的单字查询也能正常工作——分词器保留了有意义的中文单字,只过滤英文单字母和停用词。

构建流水线

完整的构建流程:

1. HEIC → JPG 转换(照片格式统一)
2. 照片压缩
3. 相册描述生成(文字 AI)
4. 图像标签生成(多模态 AI,新增)
5. Office/LaTeX 文档转换
6. posts.json(博客文章索引)
7. gallery.json(照片相册索引,含双语标签)
8. videos.json(视频索引)
9. 搜索倒排索引构建
10. 关键词向量构建
11. 静态 HTML 生成

图像标签生成和向量构建都有缓存机制,增量构建只处理新增内容。

设计 vs 实现

这套系统是按照一份详细的架构设计文档构建的。对比原始设计与最终实现,既有严格的遵循,也有意有所为的偏离。

核心设计忠实落地

原始设计指定了:BM25 作为基座,语义扩展作为上层增强,渐进式加载(P0/P1/P2),预计算词汇向量而非实时 embed 文档,词级语义路由,双路径评分融合并在 UI 中可见扩展词,Service Worker 缓存,照片相册进入统一索引。

这些全部按计划实现了。实现紧跟了架构蓝图。

有意为之的偏离

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。

总结

纯静态站点也能拥有媲美动态服务的搜索体验——关键是在构建期投入,而不是把重担推给浏览器。

让这个方案成功的核心决策:

维护成本几乎为零——不需要服务器、不需要数据库、不需要搜索服务账单。每次 git push 就更新整个搜索引擎。

© 2026 Yuxu Ge ·