AI 快讯浏览器里跑混合检索:不用后端也能搜出语义
实战教程

浏览器里跑混合检索:不用后端也能搜出语义

2026-07-11T15:03:48.882Z
浏览器里跑混合检索:不用后端也能搜出语义

开发者 Bart de Goede 用 WebAssembly 把 BM25 和向量检索塞进浏览器,实现完全本地化的混合搜索。这套方案让静态站点、文档、笔记类应用无需后端也能拥有企业级检索能力。

过去两年,语义搜索基本被绑在服务端——Pinecone、Weaviate、pgvector,随便挑一个都得先起一台服务器、配一套 embedding pipeline。但最近开发者 Bart de Goede 在他的博客里丢出一套完全跑在浏览器里的混合检索方案,把 BM25、向量索引、embedding 推理三样东西全塞进 WebAssembly,端到端不碰后端。

这不是玩具 demo。他在一个约 5000 篇文档的语料上做了完整评测,冷启动加载模型加索引不到 2 秒,单次查询 P95 在 40ms 以内,跑在一台普通的 MacBook Air 上。对做文档站、离线笔记应用、隐私敏感场景的开发者,这个思路值得认真看一看。

浏览器端混合检索架构示意图,展示 BM25 与向量检索并行执行后融合排序的流程

为什么要在浏览器里做这件事

先说清楚动机。把检索搬到浏览器端,直觉上像是在为难自己——服务端有的是 CPU 和内存,为什么要跟 WebAssembly 的沙盒较劲?

三个理由:

第一,静态托管的成本优势。一个 Docusaurus 或 VitePress 搭的文档站,本来只要挂在 Cloudflare Pages 上就能跑,一旦要接语义搜索就得起 Serverless Function、维护 embedding 服务,账单和运维复杂度直接翻一个数量级。如果整个检索链路能塞进 bundle 里,就还是一个纯静态站。

第二,隐私。用户搜什么、点什么,一条都不出浏览器。这对法律、医疗、企业内网文档这类场景不是加分项,是硬门槛。传统方案哪怕你自己搭 pgvector,query 也得走一趟服务器。

第三,离线可用。PWA、Electron、Tauri 这些壳子里跑的应用,用户断网时搜索还能工作,是明显的体验差异。Obsidian、Logseq 这类本地优先的笔记工具早就在往这个方向卷了,只是过去大家用的都是纯关键词检索。

混合检索到底混的是什么

先补一句基础知识,不然后面的工程细节没法讲。

所谓混合检索,混的是两种召回信号:

  • 稀疏检索(Sparse):以 BM25 为代表,本质是加权版的关键词匹配。它对精确词、专有名词、代码符号极其敏感,你搜 useEffect 它就找 useEffect,不会给你返回一堆讲「副作用」的哲学文章。
  • 稠密检索(Dense):把 query 和文档都变成向量,用余弦相似度找语义相近的。你搜「怎么让组件不重复渲染」,它能给你返回讲 memouseMemoshouldComponentUpdate 的文章,哪怕原文一个字都没提「重复渲染」。

两者各有短板。BM25 遇到同义词、上下位词、口语化提问直接翻车;向量检索又经常在精确匹配上离谱,你搜一个错误码 E_INVAL_ARG,它可能给你返回讲「参数校验」的一般性文章,就是不返回真正报这个错的那一页。

混合的做法是并行跑两路,然后把两个排名列表融合。目前工业界的主流融合方法有两种:

  1. 加权分数融合:把 BM25 分数和向量相似度归一化到同一量纲,按权重相加。Meilisearch 的 semanticRatio 就是这个思路,0 是纯关键词,1 是纯语义,中间自己调。
  2. RRF(Reciprocal Rank Fusion):不看分数看排名,score = Σ 1/(k + rank_i),k 通常取 60。这个方法的好处是完全不用管两路分数的绝对值,谁在自己那一路排得高就得分高。

Bart 的方案用的是 RRF,理由很实在——浏览器端两路检索的分数分布本来就不稳定,归一化容易出坑,RRF 反而稳。

技术栈拆解

整个方案的核心组件如下:

| 环节 | 选型 | 说明 | |------|------|------| | BM25 索引 | 自研 WASM 实现 | 基于倒排索引,压缩后约 200KB | | 向量索引 | HNSW(hnswlib-wasm) | 近似最近邻,召回速度对数级 | | Embedding 模型 | all-MiniLM-L6-v2(ONNX 量化版) | 22MB,384 维 | | 推理引擎 | Transformers.js + ONNX Runtime Web | 支持 WebGPU 加速 | | 融合策略 | RRF | k=60 |

有几个点值得单独说说。

Embedding 模型的选择

all-MiniLM-L6-v2 是老将了,但对浏览器场景仍然是最优解之一。原因不是它效果最好——现在 bge-smallgte-small 在 MTEB 上都比它高——而是它够小、量化后够快、生态支持够广。

Bart 试过 bge-small-en-v1.5(量化后约 33MB),效果确实略好,但首次加载时间从 1.2s 涨到 2.1s,对 Web 场景不划算。384 维在这里也是一个甜点,索引大小和检索速度都能压得住。

如果你的语料是中文,可以换成 bge-small-zh-v1.5 或者 text2vec-base-chinese,思路是一样的。

HNSW 在浏览器里的现实

HNSW 是目前 ANN 里最流行的算法,pgvector、Qdrant、Weaviate 全都用它。核心思想是分层图,越上层节点越稀疏,查询时从顶层往下逐层贪心。

问题是 HNSW 的索引不小。假设 5000 个文档、384 维向量、M=16(每个节点的连接数),光索引本体就得占几 MB 内存。Bart 的处理是把索引序列化后跟着 bundle 一起发下来,浏览器端只做查询不做构建——构建过程放在 CI 里跑,产物直接 commit 进仓库。

这套流程和静态站生成器(SSG)天然契合:build 阶段生成索引,deploy 阶段作为静态资源分发,运行时只加载。

WebGPU 的加成

2024 年下半年 Chrome 稳定版全面开放 WebGPU 之后,Transformers.js 的推理速度提升非常明显。Bart 实测在 M2 芯片上,同一个 MiniLM 模型走 WASM 后端 encode 一个句子约 45ms,走 WebGPU 后端只要 8ms。

不过 WebGPU 目前在 Safari 上还需要开 flag,Firefox 也是刚 GA。生产环境建议做 feature detection,能走 WebGPU 就走,不能就 fallback 到 WASM。

工程细节里的坑

看博客的评论区能捞到不少一手信息,也印证了几个我自己踩过的点。

冷启动是最大的敌人。用户第一次访问,得下载模型(22MB)、下载索引(几 MB 到几十 MB)、初始化 ONNX Runtime、加载 HNSW,这中间任何一步阻塞主线程都会让人以为页面挂了。Bart 的做法是把这些全放进 Web Worker,主线程只负责 UI,Worker 加载完成前搜索框显示 loading state,加载完成后热切换。

索引更新怎么办。文档站每次发版可能改几十上百篇内容,如果每次都让用户重新下几十 MB 的索引,体验很差。Bart 的方案是把索引分片,用内容哈希做文件名,配合 HTTP 缓存,改动的分片才重下。这套思路和 Webpack 的 chunk hash 是一回事。

中文分词。BM25 依赖分词,英文按空格切就行,中文得靠 jieba 之类的工具。目前 jieba 有 WASM 版本,但体积不小(约 3MB 词典),做中文场景要额外权衡。一个折中方案是用 bigram 切分,不用词典,效果比想象中好。

内存占用。HNSW 索引一旦加载就常驻内存,5000 篇文档大概占 20-40MB,加上模型权重和运行时开销,整个搜索模块的内存峰值可能到 100MB。对桌面端浏览器不是问题,移动端要小心 iOS Safari 的内存限制。

什么场景该用这套方案

把话说清楚:这不是万金油。

适合

  • 语料规模在几千到几万条之间的文档站、博客、知识库
  • 隐私敏感、需要离线可用的应用
  • 静态托管、想避开服务端成本的项目
  • 内容更新频率不高,能接受 build-time 建索引的场景

不适合

  • 语料上百万条——索引下发就是灾难
  • 内容实时更新——每次改动都重建索引不现实
  • 多语言混合、复杂 filter 条件——服务端方案的调优空间大得多
  • 用户设备可能非常拉——低端手机跑 embedding 会明显卡顿

对大部分做文档、笔记、内容站的开发者来说,这个 sweet spot 其实很宽。相比继续用 Algolia(贵,且要把内容送出去)或者只上关键词搜索(体验差),浏览器端混合检索是一个真正意义上的第三选项。

一个可以直接抄的最小实现

参考 Bart 的代码结构,最小可用版本的核心逻辑大概长这样:

import { pipeline } from '@xenova/transformers';
import { HierarchicalNSW } from 'hnswlib-wasm';
import BM25 from './bm25-wasm';

// 初始化(Worker 内执行)
const embedder = await pipeline(
  'feature-extraction',
  'Xenova/all-MiniLM-L6-v2',
  { quantized: true, device: 'webgpu' }
);

const hnsw = new HierarchicalNSW('cosine', 384);
await hnsw.readIndex('/search/vectors.bin');

const bm25 = await BM25.load('/search/bm25.bin');

// 查询
async function hybridSearch(query, topK = 10) {
  // 并行跑两路
  const [denseResults, sparseResults] = await Promise.all([
    (async () => {
      const emb = await embedder(query, { pooling: 'mean', normalize: true });
      return hnsw.searchKnn(emb.data, topK * 2);
    })(),
    bm25.search(query, topK * 2),
  ]);

  // RRF 融合
  const k = 60;
  const scores = new Map();
  denseResults.neighbors.forEach((id, rank) => {
    scores.set(id, (scores.get(id) || 0) + 1 / (k + rank));
  });
  sparseResults.forEach(({ id }, rank) => {
    scores.set(id, (scores.get(id) || 0) + 1 / (k + rank));
  });

  return [...scores.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, topK)
    .map(([id, score]) => ({ id, score }));
}

真实项目里还要处理分词、高亮、字段权重、结果去重这些细节,但骨架就是这么简单。Bart 的仓库把完整实现都开源了,值得对着抄一遍。

一点判断

浏览器端跑 AI 这件事,最近一年半的进展比很多人以为的快。Transformers.js 从玩具走到生产可用、WebGPU 稳定落地、ONNX Runtime Web 支持越来越完善——这些底层能力凑齐之后,一大批原本非做后端不可的场景开始有了新选择。

混合检索只是最先跑通的一个。往后看,reranker、小模型 RAG、甚至部分 agent 工作流搬进浏览器,都是可预期的方向。对开发者的意义不只是省一台服务器,而是产品形态本身可以重新想——一个能在断网飞机上给你写代码、找笔记、答疑的应用,跟一个必须联网调 API 的应用,体验完全是两码事。

短期内混合检索这件事,服务端方案依然是大项目的默认选择。但如果你在做的是文档、笔记、个人知识库这类应用,认真考虑一下把这套方案抄回去——两周的工作量,换来的产品体验和运维负担降低是实打实的。

参考来源

相关推荐

查看全部

联系我们

我们通常在工作时间快速响应

扫码添加微信

专属客服:Hub 助手

微信号: