深入浅出:从「向量召回+BM25兜底」看现代搜索引擎的核心设计
那天我搜了个东西,然后全错了
前阵子我在自己的笔记库里搜 OOM Killer。
我搭的搜索系统用的是纯向量检索——就是那种你说话它尽量"理解你意思"的方案。我敲完回车,等着它给我弹出 Linux 内核里那个把进程杀掉的机制的文档。
结果前三行蹦出来的是:
- 「内存不足错误排查指南」
- 「进程被系统杀死怎么办」
- 「容器资源耗尽处理方案」
这些文章语义上跟 OOM 确实沾边。但搞笑的是,我写 OOM Killer 笔记的时候根本没提过"内存不足"四个字——我用的就是 OOM Killer 这个术语。而我的搜索系统一本正经地把所有语义相关的文档都推给我了,唯独没推那篇标题里就带着 OOM Killer 的文章。
这就是向量搜索最卧槽的地方——它太聪明了。聪明到把你看得见摸得着的关键词都给忽略了。
后来我加了一套 BM25 做兜底。同样的查询再搜,那篇 OOM Killer 的笔记直接排第一。因为 BM25 不跟你谈"理解",它只认字。
这件事让我从头到尾重新捋了一遍:一个正常的搜索引擎到底该长什么样。下面就是我这趟折腾的全过程。
第一层:一个搜索请求,你到底对机器做了什么
你每次在搜索框里敲回车,系统后台会走大概四步。不管你用的是 ChatGPT 的搜索、飞书文档的搜索、还是你自己搭的 RAG 系统,骨架都是这个:
你输入 "nginx stream proxy_bind 怎么配"
│
▼
┌─────────────────────────────┐
│ 1. 把你的问题压成一个向量 │
│ 就是把这句话变成 768 个小数 │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ 2. 向量召回(主力) │
│ 去向量库里找最像的 K 个文档 │
│ 算法叫 HNSW,毫秒级就能跑完 │
└─────────────┬───────────────┘
│
▼
最高分低于阈值?
├── 是 ──▶ BM25 兜底召回
│ (倒排索引 + 纯关键词匹配)
│
└── 否 ──▶ 直接用向量结果
│
▼
┌─────────────────────────────┐
│ 3. 合并 + 去重 │
│ 两条路找回的结果拼在一起 │
│ 同一个文档去个重 │
└─────────────┬───────────────┘
│
▼
┌─────────────────────────────┐
│ 4. 重排 │
│ 用一个更精密的模型把 │
│ 这几百条文档重新打分 │
│ 最后挑最好的 10 条 │
└─────────────┬───────────────┘
│
▼
返回给你
就这么四步。但每一步背后都有大量工程决策。
你可能会想:为什么要拆成"召回"和"重排"两轮?为什么不一步到位?四步是不是过度设计了?
不是。你往下看就明白了。
第二层:向量召回——它是怎么"理解"你的
你先感受一下这件事到底多神奇
假设你有一个自动问答系统,里面存了几万篇技术文档。
有一天你问它:「怎么快速瘦下来?」
一个传统的搜索引擎会怎么做?它会把你的查询拆成一个个词——"怎么""快速""瘦""下来"——然后在文档库里找包含这些词的文章。
问题来了。
你的知识库里有一篇文章叫《减肥最高效的方法》。这篇文章通篇没出现"瘦"这个字,更不用说"快速瘦下来"这个短语了。传统搜索引擎扫完这篇文章,判定:不相关。
你自己想想,这两句话说的是不是一件事?
这就是向量召回最牛逼的地方:"怎么快速瘦下来"和"减肥最高效的方法"在字面上几乎没有交集,但向量模型把这两句话编码之后,发现它们在 768 维空间里挨得很近。
注意我说的——"挨得很近"。这他妈的才是关键。
什么叫他妈的"768 维空间"
你会经常听到"768 维向量"这个词,但没几个人正经解释它到底是什么意思。我试着说清楚。
"768 维"不是玄学。它就是长度 768 的一个数组,每个位置是一个小数。
比如,把「猫吃鱼」这句话喂进 BERT 模型之后,它吐出来的东西长这样:
[0.52, -0.13, 0.89, 0.01, ..., -0.32] ← 总共 768 个数字
你现在把它想象成 768 维空间里的一个坐标点。
2 维坐标是 (x, y)。你得写两个数字。
768 维坐标就是 (v₁, v₂, v₃, ..., v₇₆₈)。你得写 768 个数字。
在 2 维空间里,两个点挨得近不近你用肉眼就能看出来。在 768 维空间里,你看不出来——但计算机算得出来。
「宠物喜欢吃鱼」这句话编码出来的 768 个数字,跟「猫吃鱼」的 768 个数字,整体上会很接近。
「北京到上海的机票」编码出来的 768 个数字,跟「猫吃鱼」的离得就远了。
就他妈的这么简单。
一个文本变成一个向量,形状永远是 (768,)。那多个文本呢?
说一个容易搞混的地方。
单个文本的向量永远是一维的——就是一个列表(list),形状 (768,)。
如果你一次性把 3 句话一起喂给模型,输出就不是三个单独的向量了,而是一个 (3, 768) 的二维数组。相当于 3 个 768 维的向量摞在了一起。
在深度学习里,这种多维的数据结构统称张量(Tensor)。按维度分层次看:
| 维度 | 叫法 | 例子 |
|---|---|---|
| 0 维 | 标量 | 5(就一个数) |
| 1 维 | 向量 | [1, 2, 3, 4],形状 (4,)。你刚才看到的文本向量就在这一层 |
| 2 维 | 矩阵 | [[1, 2], [3, 4]],形状 (2, 2)。批量编码 3 句话就是 (3, 768) |
| 3 维 | 张量 | 一张彩色图片就是 (224, 224, 3):高 224 × 宽 224 × RGB 三个通道 |
| 4 维 | 张量 | 一段 10 秒视频(每秒 30 帧)就是 (300, 224, 224, 3) |
回到搜索这件事上:图片在模型内部是 3 维张量,视频是 4 维,但最终存进向量数据库的——永远是压缩后的一维向量。ResNet 把一张图压成 (2048,),VideoBERT 把一段视频压成 (4096,)。
压缩完以后,文本、图片、视频全都变成了同一个格式:一维的浮点数数组。你搜「一只猫」,能把猫的图片也召回——因为文本向量和图片向量被映射到了同一个语义空间。
所以核心区别是这样的:模型在"理解和处理"阶段会保留高维结构(空间、时间、颜色通道),以捕捉最丰富的特征。但到了"存储和检索"阶段,全压成一维,因为向量数据库只认固定长度的一维数组。它不关心你这个向量是从一段文字来的,还是一张图来的,还是一段视频来的。
几百万个向量,怎么快速找到最近的
好了。假设你的文档库里有 100 万篇文档,每篇都编码好存进去了。现在用户输入一个查询,你也把它编码成同一个长度的向量。
问题变成:在这 100 万个向量里,找出离查询向量最近的 K 个。
如果你一个个算距离,100 万次计算——单次搜索的延迟就炸了。这显然不能用。
这就是 ANN(近似最近邻搜索) 解决的问题。现在最主流的是 HNSW 算法(Hierarchical Navigable Small World)。
我用路网来帮你理解。
你去一个新城市想找一家餐馆:
- 最顶层是高速公路——让你快速横跨整个城市,到目标区域附近。
- 下一层是省道——让你进入具体的街区。
- 最底层是小路——让你精确导航到餐馆门口。
HNSW 就这结构。最高层的节点很少,连边很长(像高速公路);越往下,节点越密,连边越短(像小路)。搜索时先走高速,快速逼近目标区域;到了之后再走细路,精确找到最近的点。
这样一次检索,你只需要访问很少一部分节点,不用全扫。
单次延迟:几个毫秒到十几毫秒。线上搜索完全够用。
向量的罩门——它猜得太多了
向量召回最大的问题正好就是它最大的优点带来的副作用:语义感太强了。
你搜「RTX 4090 显卡」。你要的是什么呢?十有八九就是要找标题或正文里真正写了"RTX 4090"这几个字的文档。
但向量模型不这么想。它觉得以下内容跟你查询意思很近:
- 「NVIDIA 最新旗舰 GPU」
- 「高端游戏显卡推荐」
- 「Ada Lovelace 架构详解」
语义上,这些都相关。但你的本意就是精确匹配那几个字。这时候语义泛化变成了噪音。
关键是——向量模型没办法告诉你它"不确定"。它返回的相似度分数可能都不高,但它还是会返回——而且返回的东西跟你想要的完全不在一个频道。
这时候你需要另一个家伙。
第三层:BM25——那个犟种,跟语义绝缘
你就把它想成一个只会数数的傻子
BM25 不关心"意思"。它的逻辑就一句话:query 里的词在文档里出现得越多,同时这些词在整个文档集里越稀有,分数就越高。
注意两样东西:
-
词在全文集里越稀有,命中时权重越高。比如「RTX4090」这种专有名词,几乎只出现在极少数文档里,命中了就是重磅加分。"的""是""了"这种每篇文档都有几百个的词——意思不大。
-
词在单篇文档里出现次数多,分数会涨——但有饱和效应。一个词出现 100 次和 50 次,分数差距其实很小。这是 BM25 的改进之一,防止长文档靠重复灌水霸榜。
这就是 TF-IDF 思想的进化版。去掉了原始 TF-IDF 的一些 bug(比如长文档天然吃亏、比如一个词出现 20 次和 10 次得分要翻倍这种扯淡逻辑)。
它吃死一种场景
回到开头我那个失败案例。
搜 OOM Killer,BM25 会怎么做?它把查询拆成 OOM 和 Killer 两个 token,去倒排索引里找到包含这两个词的文档。只要能同时命中这两个词,分数就高。如果文档里出现了完整词组 OOM Killer,分数更高。
它绝不会把「内存不足错误」推给你——因为那两个 token 根本不在里面。
精确匹配低频词、专有名词、配置参数、命令、版本号这类东西的时候,BM25 比向量召回清醒一百倍。
这就是为什么你的搜索系统不能只有向量召回。
BM25 不是 Elasticsearch。说清楚这件事我不想再跟人解释了
好,这件事我他妈的一定要讲清楚,因为太多人搞混了。
BM25 是一个算法。 1994 年 Stephen Robertson 提出来的一个数学公式。属于概率检索模型。你打开一个 Python 脚本,pip install rank-bm25,十几行代码就能跑起来。它就是一个函数——输进去是 query 和文档,吐出来是一个分数。
Elasticsearch 是一个产品。 一个基于 Lucene 的分布式搜索引擎。ES 从 5.0 版本开始把 BM25 设为了默认的评分算法,但它在底层跑的东西远比一个 BM25 公式多得多——分词器、倒排索引、分布式文档路由、聚合、高亮,这些东西跟 BM25 没有半毛钱关系。
所以日常对话里有人跟你说「我们用的是 BM25」,你听听就行。99% 的情况他们想说「我们用的是 Elasticsearch 或者 Solr 或者 Lucene,里面跑的是 BM25 评分算法」。
还有另一个更经典的误会:「倒排索引是 BM25 的一部分」。
卧槽,不是。
倒排索引是一个数据结构。想象你在图书馆找所有提到"量子力学"的书——正常做法是一本一本翻开找,翻到天黑。但如果你提前做了一张大表,上面写着:"量子力学"这个词,出现在了书A第34页、书B第78页、书C第205页。以后每次有人来查"量子力学",你连书架都不用去,看一眼这张表就知道答案在哪几本书里。这张表,就叫倒排索引。它的逻辑反直觉:不是记"每本书有哪些词",而是记"每个词出现在哪些书里"。
BM25 是在这个数据结构上跑的一个评分规则。倒排索引告诉你"哪些文档命中了这个词",BM25 告诉你"这些文档里哪个最相关"。
倒排索引是索引,BM25 是打分器。两者经常一起出现,但不是一回事。倒排索引的完整工作原理第八章会展开讲,现在你只需要记住它俩不是同一个东西。
第四层:兜底——不是备胎,是安全带
"兜底"到底是什么意思
"兜底"这个词翻译得不好。它听起来像"实在没办法了才用"的预备方案。
但在很多实际的搜索系统里,BM25 和向量召回是并行跑的。
什么意思?
每个查询进来,两条路同时走:向量那边召回一批结果,BM25 召回另一批。两边的结果合并、去重,一起喂给后面的重排模型。精确匹配的信号和语义匹配的信号全部进了最终决策——不存在谁优先谁替补。
那为什么还叫"兜底"?
因为向量召回确实有两类场景会掉链子。当它掉链子的时候,BM25 在底下托着你,让你不至于什么结果都没有。
向量召回的阿喀琉斯之踵
两类场景:
一是 query 太短或太模糊。 用户只打了 bug 三个字母。他想找哪种 bug?代码缺陷?昆虫?窃听器?向量模型猜不出,相似度分数普遍不高。
二是 query 里塞满了极度具体的专有名词和参数。 「nginx 1.27.3 stream module proxy_bind 配置」——卧槽,向量模型在训练时几乎没见过这种排列组合。召回质量大概率一塌糊涂。
这时候兜底逻辑就一行伪代码:
def search(query):
vector_results = vector_search(query)
if vector_results.top_score < THRESHOLD or len(vector_results) == 0:
return bm25_search(query)
return vector_results
向量的最高分低于某个设定好的阈值,就切到 BM25。就这样。
阈值怎么定——卧槽,这不是算出来的
第一次接触这个设计的人,十个有九个会问:「这个阈值有没有什么公式?」
答案是:没有。这个数就是反复试出来的。
流程:
第一步,准备标注数据。 收集一批真实用户的 query,人工标注每个 query 对应哪些相关文档。这步最累,但少了它后面所有的调优都是胡扯。
第二步,跑分布图。 用向量模型对所有 query 做召回。记录每个 query 下面相关文档的相似度分数。画一个直方图。
你大概会看到这样的东西:
相似度区间 相关文档占比
─────────────────────────────
0.8 - 1.0 ████████████████ 40%
0.7 - 0.8 ██████████████ 35%
0.6 - 0.7 ████████ 15%
0.5 - 0.6 ████ 7%
0.4 - 0.5 ██ 3%
注意这些数字是我瞎编的,为的是让你看懂这个思路。
第三步,选分位数。 如果 90% 的相关文档分数都在 0.55 以上,阈值放在 0.5 到 0.55 这个区间就比较合理。实际经验里,余弦相似度的阈值经常落在 0.4 到 0.6 之间。
第四步,上线,接着磨。 离线数据模拟不了真实用户行为。上线之后看你系统的兜底触发率、用户点击率、无结果率——这些才是真信号。来回调。
还有一个经验——不同业务,阈值绝对不一样。
电商搜索商品,阈值可以低一点。搜不出来比搜不准更让人抓狂——用户看不到结果直接走了。
医疗文献检索,阈值得高一些。搜不准比搜不出来更危险——把一篇无关但看起来沾边的论文推给医生?你等着出事吧。
第五层:重排——海选完了,现在是决赛
为什么非得分两轮
你的文档库可能有一千万篇文档。海选阶段(向量召回 + BM25)的任务是:在毫秒级的时间窗口里,从一千万里捞出几百条来——不能漏掉该捞的。
讲求的是"不漏"。
等到了重排阶段,只有几百条候选了。你可以掏出一个更重、更精细的模型,一条一条仔细研判——哪篇跟用户的 query 最相关。
讲求的是"准"。
如果你把重排模型拿到海选阶段去用,一道数学题就教会你做人:
Cross-Encoder 处理一条文档大概要 10 到 50 毫秒。乘以一千万篇文档。五万秒到二十八万秒。搜一次等半天。
这他妈的不行。
所以你必须拆。
| 召回 | 重排 | |
|---|---|---|
| 候选量 | 百万到亿级 | 几十到几百 |
| 延迟要求 | 毫秒级 | 百毫秒级 |
| 模型 | 简单(双塔/BM25) | 重(Cross-Encoder/GBDT) |
| 目标 | 别漏 | 往准了搞 |
这个架构不是一个理想方案——这是规模和延迟压到你面前时,你不得不接受的取舍。
重排模型选谁
Cross-Encoder。 效果最好,也最慢。
做法是把 query 和 document 拼在一起,一起塞进 BERT 里。两个文本从输入层就交叉在一起,注意力机制在每一层都会互相照到。输出就是一个相关度分数。
这玩意儿能把句子里的否定词、条件限定、语序差异全吃进去——「不需要重启」和「必须重启」在双塔模型眼里差不多,到了 Cross-Encoder 这里立刻区分出正负。
代价你知道的——每条候选都得完整跑一次。没法像双塔那样提前算好文档向量存起来。因此你只能拿它处理几百条的候选集。
ColBERT 等延迟交互模型。 折中。
文档和 query 先各自独立编码(省计算),最后做一次轻量交互(补精度)。在很多 benchmark 上的效果接近 Cross-Encoder,但快不少。
GBDT / LambdaMART。 不走深度学习。
输入不只是向量相似度分数——BM25 分数、文档发布时间、历史点击率、作者权威度,这些都可以塞进去。可解释、好调参。如果业务上需要"看得懂且能手动干预"的排序策略,树模型比深度学习香。
但说真的,你做项目的时候,重排模型基本不需要从零训。 上 HuggingFace 拉一个 cross-encoder/ms-marco-MiniLM-L-6-v2,拿你自己的标注数据微调一轮,绝大部分场景就够了。
多级重排——不是炫技,是把钱花在刀刃上
你有没有想过,重排本身也可以分两层?
预排。把召回后的几百条先用一个轻量模型(比如双塔向量相似度 + BM25 分数简单加权)快速筛到 50 条。再把这 50 条喂给 Cross-Encoder 精排。
为什么这么搞?
Cross-Encoder 吃显存。几百条一起过,显存直接爆——你得一批一批跑,批处理越大延迟就越长。先筛一轮,让重模型的 GPU 时间只花在最有价值的候选上。延迟和成本都能砍掉一半以上。
这是工程上最他妈的划算的优化之一——比你换一个大了一倍的模型效果还好使。
什么时候 Cross-Encoder 是过度设计
如果你的召回质量本身就一塌糊涂——切分乱切、Embedding 用了个跟你的领域八竿子打不着的模型——那重排模型再好也是垃圾进垃圾出。
重排的上限取决于召回的下限。
先把召回搞到及格线以上,再谈重排优化。这个顺序不能反。
另一个信号:如果你的 query 平均长度不到 5 个字,Cross-Encoder 的优势几乎发挥不出来。短 query 缺少足够的上下文让注意力机制做细粒度交互。这时候一个简单的双塔相似度加上 BM25 分数加权,可能跟 Cross-Encoder 差不了多少——但推理成本差了 50 倍。
特征工程在重排里还活着
别以为有了深度学习就不需要特征工程了。在重排层,以下特征经常比你换模型带来的提升还大:
- 文档侧特征。 文档长度、发布时间、历史点击率、作者权威度、所属分类、被引用次数。
- Query 侧特征。 Query 长度、是否包含专有名词、是否包含否定词、历史同类型 query 的数量。
- 匹配特征。 BM25 分数、向量相似度、query 和文档的 token 重叠率、最长公共子序列长度、包含的关键词个数。
把这些特征拼在一起喂给 GBDT,可解释性极强。哪个特征在排序里权重最高——一眼就能看出来。Cross-Encoder 不给你这个。如果你需要跟业务方解释「为什么这篇排在这篇上面」,树模型比深度学习香多了。
重排的推理成本是你最容易低估的坑
一个 ms-marco-MiniLM-L-6-v2 跑在单张 T4 上,处理 50 条候选大概 30-50 毫秒。听起来还行对吧?
假设你每天 100 万次搜索。GPU 时间 = 100 万 × 50ms = 50000 秒 ≈ 14 个 GPU 小时。
一张 T4 一小时大概一块钱人民币。14 块钱一天——还行。
但如果候选量涨到 500 条,推理时间翻几倍。如果你的模型从 MiniLM 换成 base 或者 large——成本再翻几倍甚至几十倍。
卧槽,你以为你在优化准确率,其实在烧钱。
算清楚这笔账再决定用什么重排模型。别在 prod 里部署完才去看账单。
多路召回 + 单一重排:一个被低估的架构
你的召回层可以不止向量 + BM25 两路。如果文档有标签体系,再加一路「标签精确匹配」;如果文档是按时间组织的,再加一路「时间范围内的召回」;如果用户有历史行为,再加一路「协同过滤」。
每路召回各自捞几十条,汇总去重,一起扔给重排模型。
重排模型不管这条路是谁捞上来的——它只管「这一条对你的 query 到底多相关」。把召回的多样性交给上游,把相关度判定交给重排——职责清晰,每条链路可独立调优、可独立降级。
这个架构的真正价值在于:你加一条新召回通路,不需要动重排层。你换重排模型,不需要动召回通路。 解耦程度决定了你迭代的速度。
第六层:切分——地基要是歪了,楼再高也白搭
一个 bad case 顶一万个字
我见过太多人把精力花在挑 Embedding 模型和调重排模型上了,最前面的文档切分——问起来就是"固定 512 token 切开"。
甚至不知道自己在切什么。
你看看这个:
原文:「本系统使用 PostgreSQL 作为主数据库,因为其出色的并发控制能力。」
按固定长度硬切 512 token 的话,可能会切成:
- 片段 A:「本系统使用 PostgreSQL 作为主数据库,因为」
- 片段 B:「其出色的并发控制能力。」
你觉得片段 A 的向量和片段 B 的向量能准吗?
片段 A 话还没说完。片段 B 缺了主语,看起来像是在说某个随机的东西有出色的并发控制能力。两个片段的语义都是残的——Embedding 模型再强也不可能从半句话里猜出全貌。
但如果按句号自然边界切,这一整句就是一个片段,语义完整,向量准确。
不夸张地说——切分的质量就是这个系统的天花板。 切分烂了,后面的 Embedding 和重排都是在错误的基础上做优化。没用。
三条实操原则
按语义边界切,别按固定字数硬砍。
至少按句号、换行的自然边界来。text-splitter 这类库里有基于 NLP 模型的语义分割器,效果更好。固定 token 数(比如 512)只能当保底上限——但不能是唯一策略。
相邻片段之间留重叠。
10% 到 20% 的 overlap。防止关键信息正好被卡在切分边界上,两边各存一半,检索时两边都搜不到。
保留上下文元信息。
一个片段来自哪篇文档?文档标题是什么?属于哪个章节?这些信息在重排时价值巨大。两条片段相似度分数差不多的话,来自更权威文档的那条应该排前面。但你得先把这些信息记下来。
第七层:Embedding——挑模型就是选性价比
通用模型还是领域模型
如果你的文档内容是新闻、百科、通用问答这类,直接拿 text-embedding-3-large 或者 bge-large-zh 用就行。不用折腾。省心。
但如果是医学论文、法律文书、内部代码文档这种强领域数据——通用模型的语义空间很可能对不准你的数据。领域里面的词和关系它没见过,向量质量自然打折。
这时候两条路:要么在领域数据上微调一把通用模型,要么直接用领域特化模型(比如 BioBERT 的 Embedding 版本)。
大就是好?开什么玩笑
1024 维的模型和 768 维的模型,精度差距可能就 1% 到 2%。但存储和算力的开销能涨 30% 以上。
文档量到了百万级,这 30% 是实打实的钱——更多的硬盘、更多的内存、更慢的检索速度。
你挑维度的时候,打开成本表,别只看论文里的 benchmark 指标。性价比才是工程上唯一的标准。
最容易忽略的工程坑——后续维护
向量不是算一次就完事了。
文档更新了?向量得重新生成。新文档入库?索引得增量写入。旧文档删掉?你得把它从向量数据库里清掉。
这套增量同步流水线要是没设计好,你的搜索系统每隔一段时间就会开始返回过期结果。一致性问题是 Embedding 环节里被忽视最多的工程坑。
第八层:倒排索引——BM25 凭什么能这么快
正向索引有多笨
假设你有四篇文档:
文档1:猫吃鱼
文档2:狗吃肉
文档3:猫和狗打架
文档4:猫喜欢吃鱼不喜欢吃米
现在用户搜「猫 吃 鱼」这三个词。
正向索引的做法是:从文档1第一个字开始,一行一行扫。 扫完「猫吃鱼」——嗯,猫出现了,吃出现了,鱼出现了,三个全中,文档1候选。然后扫「狗吃肉」——猫呢?没有。吃呢?有。鱼呢?没有。只中一个,分低。继续扫文档3、文档4……
你搜三个词,引擎就要把四篇文档的每个字都扫一遍。如果是十万篇、一亿篇文档呢?你搜一次,引擎就要从上亿个汉字里挨个找这三个词。
这他妈的就是个线性扫描。数据量翻一倍,查询时间翻一倍。一亿篇文档搜一次等半分钟——用户已经走了。
倒排索引反着记
倒排索引不记录「每篇文档有哪些词」,而是反过来——记录「每个词出现在哪些文档里」。
对上面四篇文档建倒排索引,分三步。
第一步:分词。
中文分词有专门的库(比如 jieba),我们这里人工分一下:
文档1 → [猫, 吃, 鱼]
文档2 → [狗, 吃, 肉]
文档3 → [猫, 和, 狗, 打架]
文档4 → [猫, 喜欢, 吃, 鱼, 不, 喜欢, 吃, 米]
第二步:建词项字典。 把所有出现过的唯一词提取出来,排序:
不, 吃, 打架, 狗, 和, 猫, 米, 肉, 喜欢, 鱼
总共 10 个词。以后查任何一个词,二分查找 O(logN),10 个词最多比 4 次。就算十亿个词,也就比 30 次。
第三步:建倒排记录表。 给每个词挂一张列表,记录它在哪些文档里出现过、出现几次、在什么位置:
"不" → (doc=4, freq=1, pos=[5])
"吃" → (doc=1, freq=1, pos=[1]),
(doc=2, freq=1, pos=[1]),
(doc=4, freq=2, pos=[3,6])
"打架" → (doc=3, freq=1, pos=[3])
"狗" → (doc=2, freq=1, pos=[0]),
(doc=3, freq=1, pos=[2])
"和" → (doc=3, freq=1, pos=[1])
"猫" → (doc=1, freq=1, pos=[0]),
(doc=3, freq=1, pos=[0]),
(doc=4, freq=1, pos=[0])
"米" → (doc=4, freq=1, pos=[7])
"肉" → (doc=2, freq=1, pos=[2])
"喜欢" → (doc=4, freq=2, pos=[1,4])
"鱼" → (doc=1, freq=1, pos=[2]),
(doc=4, freq=1, pos=[4])
这就是倒排索引的全貌——一张词典 + 每个词下面的文档列表。构建过程只需要做一次,放在离线跑。文档更新时增量追加。
查询时发生了什么
还是搜「猫 吃 鱼」。流程是:
- 词项字典里二分查找「猫」→ 直接拿到它的倒排记录表:
[doc1, doc3, doc4] - 二分查找「吃」→ 拿到
[doc1, doc2, doc4] - 二分查找「鱼」→ 拿到
[doc1, doc4] - AND 逻辑 → 三个列表求交集。
[doc1, doc3, doc4] ∩ [doc1, doc2, doc4] ∩ [doc1, doc4]→ 结果:[doc1, doc4] - 对 doc1 和 doc4 用 BM25 打分排序。
关键是:从头到尾没有扫描任何一篇文档的原文。 引擎触碰的只有 10 个词的词典和 3 个短的文档ID列表。文档4说了"猫喜欢吃鱼不喜欢吃米"这8个字引擎根本不需要再读——建索引的时候已经读过了。
就好比你考试开卷,别人是从第一页翻到最后一页找答案。你手里有一张自己做的索引卡——「猫」在哪些页、「吃」在哪些页、「鱼」在哪些页——三行一交,答案就出来了。
世界上任何一个正经的全文搜索引擎,底层逻辑就是这么干。
工程上还能继续榨
上面的例子四篇文档,倒排记录表很短。到了一亿篇文档,某些高频词(比如「的」「是」「在」)下面的记录表可能有几千万条。两个列表求交集如果还是一条一条比对,还是慢。
所以 Lucene 在倒排记录表上加了两个硬优化。
跳表。 两个排好序的文档ID列表求交集,不用逐个比。跳表在长列表上每隔一段放一个跳跃指针,可以大幅跳过不可能命中的区间——想象你在翻字典,你不需要一页一页翻,你可以先跳到字母区,再跳到具体页。归并的变体,但更聪明。
压缩编码。 lucene 不存完整的 [doc1, doc4, doc7, doc12, doc15],而是存差值 [1, 3, 3, 5, 3],再用变长编码压缩。跨几十亿篇文档的差值通常很小,存一个字节以内就搞定了。
这些优化做到了工业极限。几百亿级文档,单次关键词检索也就十几毫秒。
一句话:倒排索引的本质是"视角转换"
正向索引的视角:文档 → 它包含哪些词。这是写文档时的自然视角——「我刚写的这篇东西里有什么」。但搜索的时候你要的视角是反的。
倒排索引的视角:词 → 包含它的文档有哪些。这是搜索者视角——「跟我说'量子力学'这个事,我需要知道它在哪」。
这个视角一转,搜索就从线性扫描变成了 O(logN) 的词典查找 + 列表求交集。四篇文档跟四十亿篇文档,查一个词的思路完全一样。
第九层:离线干重活,在线只跑轻活
你一定要理解:一个搜索引擎有两条完全独立的时间线。
离线(你看不到,但一直在后台跑)
- 文档入库 → 切成片段。
- 每个片段过 Embedding 模型 → 生成向量。
- 向量写入向量数据库 → 构建 HNSW 或 IVF 索引。
- 同时建倒排索引 → 给 BM25 用。
- 文档更新 → 增量同步所有索引。
这些活计算量巨大。通常是在 CPU 集群或 GPU 集群上批量跑。但不碰用户请求,延迟无所谓。重点是吞吐量拉满,一致性做好。
在线(你每次按回车真实感受到的)
- 收到查询 → 向量化。
- 向量召回(主力)+ 可选 BM25 兜底。
- 合并去重。
- 重排模型精排。
- 返回 Top N。
这条链路的计时器卡得很死。搜索响应超过 200 毫秒,你会觉得有点卡。超过 1 秒——你开始烦躁。超过 3 秒——你已经打开另一个产品了。
两层的核心原则就一条:能放在离线预计算的所有活全放离线。在线链路瘦到极致。
第十层:你把各环节拼一起试试
有时候你会有个冲动——"把这两步拼一起,还能省一轮计算。"
别。我帮你算过账了。
Embedding + 召回合并。 "边向量化边检索。" BERT 一次前向传播就几十毫秒。几百万文档逐个跑一遍——你等出结果的时候黄花菜都凉透了。Embedding 必须放离线。
召回 + 重排合并。 Cross-Encoder 处理一条文档 10 到 50 毫秒。一千万条——五万秒起。搜一次等十几个小时。你猜用户愿不愿意等?
切分 + Embedding 合并。 一个是规则操作为主(CPU 干),一个是神经网络推理(GPU 干)。混在一起两边效率都打折。分开跑,各用各的硬件,反而更快。
这些环节的分离不是架构师有洁癖——纯粹是因为规模太大、延迟要求太死,不拆就跑不动。
读者困惑预判——这些是你读到这儿大概率会问的
Q1:向量召回和 BM25 到底怎么配合?谁先谁后?
答案不是固定的。有三种模式,你对着自己的场景选:
模式一:并行召回,合并重排。 向量和 BM25 同时跑,各自取 Top K,合并去重后一起重排。这是最主流的做法,适合大多数通用搜索场景。两路信号互补——语义相关和精确命中都在最终排序里有一票。
模式二:向量先跑,不行的再补 BM25。 向量结果分数高于阈值,直接用;低于阈值,切 BM25。适合向量召回质量整体不错、只有少数硬核场景掉链子的系统。省了一条并行链路的算力。
模式三:BM25 先跑,精确命中的直接排前面。 适合你的用户几乎都在搜代码、配置、版本号、专有名词这类硬货的场景。语义泛化在这种场景里更多是噪音。先拿 BM25 把精确命中的捞出来,向量召回当补充。
你选哪种模式不取决于技术信仰——取决于你看了 query log 以后发现用户到底在搜什么。
Q2:文档切分大小该怎么选?512 还是 1024?
别背数字。这个问题的答案不在任何博客里——在你的文档里。
你抽 20 篇有代表性的文档,用不同的 token 数去切,然后人工看切出来的片段质量。如果一个 512 的方案切出来到处都是半截句子、话没说完就断了——那就加。如果一个 1024 的方案切出来的片段信息密度太低,一个片段讲了三件不相关的事——那就减。
经验范围:
- 技术文档、论文:512 到 768 token 比较稳。这类文本信息密度高,512 足够承载一个完整语义单元。
- 法律文书、合同:需要更大,1024 甚至 2048。一条完整的条款可能本身就几百字。
- 对话记录、客服问答:256 到 512 就够。单条消息天然不长。
关键不在数字——在你切的片段能不能独立表达一个完整意思。这是唯一的标准。
Q3:Embedding 模型选哪个?BGE、OpenAI、还是其他?
先看一个事实:在 MTEB 中文榜单上,top 10 的模型之间的分数差距通常不超过 2%。在真实业务场景里,这 2% 几乎感觉不到。
所以选模型的优先级是这样的:
1. 先看领域。 你的文档是中文还是英文?通用还是垂直领域?中文选 BGE 系列、text2vec 系列;英文选 OpenAI 的 text-embedding-3;代码选 CodeBERT 系。别用一个英文模型给中文文档打向量——那效果逆天差。
2. 再看部署方式。 OpenAI 的 Embedding API 每 1000 token 收几分钱,量大了一样烧钱。BGE 或者 GTE 可以本地跑,一张 T4 就够。百万级文档用 API,月费你算算就懂了。
3. 最后看维度。 768 维、1024 维、4096 维。精度差距和成本差距一起看。别单独追求高维。
一个他妈的实话:大部分人花两天纠结选哪个模型,不如花两小时看一下自己切出来的片段质量。切分的影响比模型选择大一个数量级。
Q4:倒排索引和向量索引能放在一起维护吗?
能,而且应该放在一起。
Elasticsearch 从 8.0 开始支持向量检索。你的文档存在 ES 里,倒排索引归 Lucene 管,向量索引用 HNSW 构建——同一套集群、同一份数据、同一个 API。不用维护两套存储。
如果你不想用 ES,也可以用专门的向量数据库加一个独立的倒排索引——比如 Milvus + 自建 Elasticsearch。但这是给自己找活干。数据要同步两遍,一致性要自己保证,运维复杂度翻倍。什么鬼,一个人维护两套数据库?
除非你对 BM25 检索的延迟有极端要求(比如要求 1ms 以内),否则 ES 的单体方案足够应对绝大多数场景。
Q5:为什么不能只用向量召回?BM25 到底兜什么底?
兜两类东西:
第一类:精确术语匹配。 你搜「Kubernetes 1.32.1 CVE-2024-12345」,向量模型大概率只对「Kubernetes」和「CVE」有反应——后面的版本号和漏洞编号它根本没见过。BM25 直接命中这四个词组,不需要理解。
第二类:冷门文档。 向量模型看过的训练数据里没有你的内部文档。一篇讲你公司自建 RPC 框架的文章,Embedding 模型从来没见过这个词——它只能根据上下文(RPC、框架、通信)猜个大概。BM25 不需要训练——这个词只要在你的文档里出现过,就能被命中。
这两类场景合在一起,占了企业搜索里相当大的一部分请求量。把 BM25 砍掉——你在这些 query 上的体验就是不可逆的退化。
BM25 不是备胎——它是你系统在「没见过」和「不认识」面前的最后一道防线。
Q6:HNSW 的 M 和 efConstruction 参数怎么调?
M 是每个节点在图中连多少条边。efConstruction 是建图时搜索的宽度。两个都影响两个东西:召回质量和内存消耗。
- M 默认 16。调大到 32 或 64,召回率会提升——但图会变大,内存占用也涨。M 超过 64 后收益递减明显。
- efConstruction 默认 200。调大到 400 或 800,建图质量更好——但建图时间变长。这是个离线成本,线上不疼。
一个实操经验:先把 efConstruction 拉到 500,M 拉到 32。上线跑一周看召回率。如果满意,把 M 往下调一格(到 24),再跑一周。找到"召回率开始下降"的那个拐点——那就是你系统的最优配置。
别一上来就抄别人的参数组合。你的向量分布跟别人的不一样——参数得跟着数据走。
Q7:我的文档里有大量表格和代码块,该怎么切?
表格和代码是切分器最他妈的头疼的东西。
表格。 如果按行硬切,一行「销售额 | 120万 | 2024 Q1」脱离了表头,就变成了一行没意义的数字。解法是表头和每一行数据拼在一起成一个独立的 chunk。或者——更好的办法——把表格整体转成 Markdown 格式的文本,让 Embedding 模型看到完整的列名和数据关系。
代码块。 如果按 token 硬切,你可能会把 def handle_request( 和下面的函数体、返回语句切开。解决方案是按函数级边界切——AST 解析代码,每个函数或类单独成一个片段。别用自然语言的切分器切代码——傻逼效果。
混合文档。 一篇文档里既有文字也有表格和代码:需要做内容识别。先用解析器把文档分成"文字段""表格段""代码段",每种类型用自己的切分策略。这是真活儿——但做了以后召回质量能提升 30% 以上。
Q8:向量数据库选 Milvus 还是 Qdrant 还是 pgvector?
问这个问题之前先问:你有多少向量?
- 少于 100 万:pgvector 就够了。你已经在用 PostgreSQL 了对吧?直接开 pgvector 插件,省掉一套独立数据库的运维成本。什么鬼还去搭 Milvus——过度设计了。
- 100 万到 1000 万:Qdrant 或 Weaviate。单机部署,API 友好,社区活跃。
- 超过 1000 万:Milvus。它的分布式架构、索引分片、GPU 加速检索是真正在大规模下验证过的。
还有一个被忽略的考量:你的团队多大?Milvus 功能强但运维门槛也高——一个人维护一个 Milvus 集群?逆天。Qdrant 和 pgvector 的管理成本显著更低。
选型不是选最强——是选你的团队能长期兜住的。
工程实践——从 demo 到 prod 的最后一公里
监控不是可选的
至少做四个面板:
召回层监控。 向量召回的平均分数、兜底触发率、BM25 的结果量和平均分数。兜底触发率突然暴涨——大概率是你的 Embedding 服务挂了或者模型更新出了事故。
重排层监控。 候选量、精排延迟 P99、GPU 使用率。P99 延迟突然飙到 500ms 以上——你的 GPU 在拼命或者候选量爆了。
业务指标监控。 无结果率、首条点击率(CTR)、平均浏览深度。技术指标再好,用户在第一个结果就点走或者直接关掉搜索框——你的系统就没真的在帮上忙。
一致性监控。 新入库的文档多久能从搜索结果里搜到?增量同步的延迟是多少?这个问题不监控,你会是最后一个知道搜索结果过期的人。
日志要能复现
每一个用户的搜索请求,记录以下东西,缺一不可:
- 原始 query
- 向量召回返回的候选列表(文档 ID + 相似度分数)
- BM25 召回的候选列表
- 重排后的 Top 10
- 每一层的延迟(P50 / P99 / P999)
出 bug 的时候,你能用这些日志在本地把用户的搜索现场一模一样复现出来。没有这个——线上搜出奇怪的东西,你只能猜。猜就等于瞎。
冷启动不是问题——是你的文档量还不够
小于 1000 篇文档的时候,别纠结向量召回了——BM25 已经是满分答案。文档太少,向量模型的语义泛化优势根本体现不出来。
这个阶段你应该把所有精力花在三件事上:切分质量、文档组织结构、增量同步流水线。等到文档过了 1 万篇,向量召回的优势才开始显出来。
一个逆天的事实:很多人一上来就上了全套——向量库、HNSW、Cross-Encoder、两路并行——然后文档只有 500 篇。这是拿着核弹轰蚂蚁。
文档变更不是小事
如果你的文档是 GitHub 仓库里的 Markdown、Confluence 上的页面、Notion 里的笔记——它们每天都在变。你的系统是跟着变的吗?
增量同步的核心挑战不在"把新文档加进去",而是"把旧版本删干净"。一个文档改了三段话,你只更新了对应的向量——但旧向量还在库里。用户搜到的是两个版本的混合。
方案:每个文档存一个 version 字段。增量写入时先软删除旧版本(标记过期),再写入新版本。定期做硬删除。不要让旧数据持续污染检索结果。
成本是和延迟、准确率并列的约束条件
很多人做技术选型的时候只在延迟和准确率之间做 trade-off。忘了第三根柱子——钱。
给你一个极其简单的成本模型:
- Embedding API:每 1000 token 约 0.00002 美元(OpenAI text-embedding-3-small)
- 一篇 5000 字的文档:约 3500 token → 0.00007 美元
- 100 万篇文档:70 美元,还行。1000 万篇:700 美元
- 加上每天的增量更新、用户 query 的实时向量化——月费过千是分分钟的事
本地部署 BGE:一张 T4 一小时 1 块钱,一天 24 块,一个月 720 块。吞吐量足够处理千万级文档的一次性编码加上每天百万级的查询向量化。
自己算一下——哪个更划算。
你缺的不是一个更好的模型,是对失败案例的耐心
最后一条不是工程建议,但比前面所有都重要。
你上线第一版搜索系统的时候,它一定会在某些 query 上表现成一坨屎。别急着换模型。把那些失败的 query 收集起来,一个一个看:
- 是切分把关键信息切碎了吗?
- 是 Embedding 模型不认识你的领域词吗?
- 是重排模型被无关但语义沾边的文档带偏了吗?
- 还是你的用户搜的东西根本不在你的文档库里?
修错误 case 的优先级远远高于换模型。十个错误 case 的真因分析,比你对着 benchmark 挑三天模型更值得。
最后——不是总结,是给你一套判断框架
整个搜索系统摊开来看就是一层漏斗:
全部文档(百万到亿级)
│
▼ 召回层(毫秒级,抓得广)
几百条候选
│
▼ 重排层(百毫秒级,判得准)
Top 10
每一层要回答的都是同一个问题:在我这把时间预算里,怎么能让吐出来的结果最靠谱。
但你在实际做项目的时候,别去纠结"哪个模型分最高"这种问题。那是 2018 年的人问的。
你应该问自己这三个问题:
1. 你的用户搜什么?
如果他们搜的是"内存不足怎么排查"——向量召回很好使。
如果他们搜的是"RTX 4090 功耗参数"——BM25 是亲爹。
做之前先花半天看真实用户的 query log。数据长什么样,系统就该长什么样。
2. 你的延迟预算是多少?
用户的耐心是一条硬线。反过来推你能在每一步花多少时间。Embedding 能不能更快?维数能不能砍?HNSW 的层数能不能调?重排候选能不能再缩?——这些都是顺着延迟预算往回算的。
3. 你的文档会变吗?
如果文档是静态的(比如一本已出版的书),Embedding 跑一次就行。
如果文档在频繁更新(比如一个协作知识库),你的增量同步流水线比什么都重要。在这上面省力气的团队,最后都会还回来。
用一张巨型卡片目录来收尾:
- 文档切分——把一整本书拆成能独立检索的小卡片。
- Embedding——给每张卡片打上一组只有机器能读懂的「理解分」。
- 向量召回——「把感觉最像我说的那几张卡片找出来」。这是语义匹配。
- BM25——「把字面上有我提到的那几个词的卡片找出来」。这是精确匹配。
- 重排——两叠卡片合在一起,逐张精读,挑最好的 10 张。
搜索引擎好不好,不看哪个 benchmark 上拿了第一。只看一件事:在你的数据、你的用户、你的延迟预算这三个框里面,它站着还是跪着。
最后多说一句 RAG。
RAG 本质上就是把"召回 + 重排"吐出的文档列表直接丢给大模型做总结、做生成。
搜索搞懂了,RAG 就懂了一大半。
剩下的那部分,是大模型自己的事。
读者来信
暂无来信,期待你的分享。