1. 纯向量检索到底哪里不够用
1.1 从一个真实翻车案例说起
去年我帮一个团队调他们的知识库问答系统,场景是内部技术文档检索。他们用的是当时最流行的方案:文档切块、embedding 模型编码、灌进向量库、查询时做余弦相似度召回 Top-K。Demo 阶段效果惊艳,问什么都能答上来。上线两周后,投诉开始集中爆发。
最典型的一个 case:有人搜“接口超时重试策略”,系统返回了一堆讲“网络协议分层”的文档片段,因为这两者在向量空间里距离很近——都涉及网络、都涉及通信。但用户要的是具体的重试次数配置和退避算法,不是 OSI 七层模型科普。另一个 case 更离谱:搜一个具体的错误码“ERR_4032”,向量检索返回的是“ERR_4030”“ERR_4035”相关的文档,因为这几个错误码的 embedding 几乎重合,模型根本分不清数字后缀的差异。
这两个问题暴露了纯向量检索的两个致命伤:语义相似不等于精确匹配,以及向量模型对细粒度差异不敏感。大厂之所以不用纯向量方案,不是因为它不好,而是因为它在真实业务场景下的召回质量撑不住。
1.2 向量检索的本质局限
要理解为什么纯向量不够,得先搞清楚向量检索到底在做什么。Embedding 模型把一段文本映射成一个高维空间中的点,语义相近的文本在空间中距离近。检索时,把查询也映射成点,找最近的 K 个邻居。这个过程本质上是一次模糊的语义聚类。
问题在于,这个“模糊”是有代价的。向量模型在训练时做的是对比学习,它学到的是“这两段话在讲同一件事”,而不是“这两段话包含同一个关键实体”。所以当你的查询里有一个必须精确匹配的元素——产品型号、错误码、人名、特定术语——向量检索没法保证这个元素被正确匹配。
我做过一个粗略的统计:在一个包含 5 万条技术文档的知识库里,纯向量检索对于包含专有名词的查询,Top-5 召回率只有 62% 左右。而加上关键词过滤后,这个数字能拉到 89%。这 27 个百分点的差距,就是大厂不敢用纯向量的直接原因。
1.3 大厂的真实检索链路长什么样
大厂的 RAG 系统,检索环节从来不是单点方案,而是一条多路召回 + 融合排序的流水线。典型的结构是这样的:
- 第一路:稀疏检索(BM25/SPLADE)。负责精确匹配关键词、短语、数字、代码片段。BM25 虽然老,但它对 term 的精确匹配能力是向量替代不了的。
- 第二路:稠密检索(向量)。负责语义召回,处理那些“换了说法但意思一样”的查询。
- 第三路:结构化过滤。按时间、来源、权限、文档类型做硬过滤,把不该出现的结果直接排除。
- 融合层:RRF 或加权融合。把多路结果合并成一个统一排序。
- 重排层:Cross-Encoder 或 LLM 重排。对融合后的 Top-N 做精细打分,输出最终 Top-K。
这套链路的核心思想是:不同检索方式有互补的盲区,用多路召回互相兜底。纯向量只解决了其中一路的问题,把它当成全部,等于把其他几路的盲区全暴露了。
2. 多路召回的核心技术拆解
2.1 BM25 为什么还没被淘汰
很多人觉得 BM25 是上个时代的产物,有了向量就该扔掉。这个想法很危险。BM25 的核心价值在于它做的是词项级别的精确匹配,而且它的打分函数有明确的数学解释。
BM25 的打分公式大致是这样的:对于查询中的每个词,计算它在文档中的出现频率(TF),乘以一个逆文档频率(IDF)的权重,再除以一个文档长度归一化因子。直观理解就是:一个词在文档里出现得越多、在整个语料库里越稀有,这个文档就越相关。
这个机制让 BM25 在以下场景里碾压向量检索:
- 精确术语匹配:查询“Kafka 消费者组 rebalance”,BM25 能精确命中包含这些词的文档,向量检索可能返回一堆讲“消息队列负载均衡”的泛泛之谈。
- 数字和代码:错误码、版本号、配置参数,BM25 按 token 匹配,不会把“v2.3.1”和“v2.3.2”混为一谈。
- 低频专有名词:某个内部系统的代号、某个特定 API 的名字,这些词在 embedding 模型训练时可能根本没出现过,向量表示质量很差,但 BM25 照样能精确匹配。
当然 BM25 也有明显短板:它不理解同义词。用户搜“怎么调优”,文档里写的是“性能优化”,BM25 匹配不上。这就是为什么需要向量检索来补位。
2.2 向量检索的正确打开方式
向量检索不是没用,而是要用对地方。我的经验是,向量检索最适合处理以下几类查询:
- 自然语言问句:用户用完整句子提问,比如“为什么我的服务在高峰期响应变慢”,这种查询的关键信息分散在语义里,BM25 很难匹配。
- 跨语言/跨表述检索:查询和文档用不同的说法表达同一个意思,向量能桥接这个 gap。
- 模糊概念召回:用户自己也不太清楚要搜什么,只有一个模糊的方向,向量能帮你找到语义相近的内容。
但即便在这些场景里,向量检索也需要配合其他手段。比如做查询改写:把用户的自然语言问句先拆解成关键词 + 语义两部分,关键词部分走 BM25,语义部分走向量。再比如做混合检索:把 BM25 分数和向量相似度分数做加权融合,权重根据查询类型动态调整。
2.3 RRF 融合:让多路结果和平共处
多路召回的结果怎么合并?最简单的方法是加权求和,但 BM25 分数和余弦相似度的量纲完全不同,直接加权需要归一化,而归一化又会引入新的偏差。大厂更常用的是RRF(Reciprocal Rank Fusion)。
RRF 的思路很巧妙:不看具体分数,只看排名。对于每个文档,它在每一路召回中的排名是 r,那么它的 RRF 分数就是 sum(1/(k+r)),其中 k 是一个平滑常数,通常取 60。最后按 RRF 分数排序。
这样做的好处是:不需要处理不同检索方式的分数归一化问题,而且对异常分数有天然的鲁棒性。一个文档如果在 BM25 里排第 1、在向量里排第 50,它的 RRF 分数仍然不错;如果一个文档只在某一路里排第 1、另一路里根本没出现,它的分数也不会被过度放大。
我实测下来,RRF 在大多数场景下比加权求和更稳,尤其是当两路召回的质量差异较大时。唯一需要注意的是 k 值的选择:k 太小,排名靠前的结果权重过大;k 太大,排名差异被抹平。60 是一个经过大量实验验证的经验值,但具体场景还是得调。
2.4 重排层:最后一道质量闸门
多路召回 + 融合之后,通常会得到一个 Top-50 到 Top-100 的候选集。这个规模直接送给 LLM 做生成太浪费 token,而且噪声太多。所以需要重排层来精筛。
重排有两种主流方案:
- Cross-Encoder 重排:把查询和每个候选文档拼在一起,送进一个专门的交叉编码器打分。优点是精度高,因为它能建模查询和文档之间的细粒度交互;缺点是慢,因为每个候选都要单独跑一次模型。
- LLM 重排:直接用大模型对候选文档做相关性打分或排序。优点是灵活,可以通过 prompt 控制排序标准;缺点是成本高,而且 LLM 的打分有时不太稳定。
我的建议是:如果候选集在 50 以内,用 Cross-Encoder;如果对成本敏感且候选集较大,可以先用轻量级模型粗筛到 20,再用 LLM 精排。重排层的存在,让前面多路召回的“宁滥勿缺”策略成为可能——召回阶段可以放宽标准,反正后面有重排兜底。
3. 实操:搭建一套多路召回 RAG 检索链路
3.1 环境准备与工具选型
先说一下我用的技术栈,这套组合在多个项目里验证过,稳定性和效果都不错:
- 稀疏检索:Elasticsearch 或 OpenSearch,自带 BM25 打分,支持丰富的过滤条件。
- 稠密检索:Milvus 或 Qdrant 做向量库,embedding 模型用 BGE-M3 或类似的多语言模型。
- 融合与重排:Python 脚本做 RRF 融合,Cross-Encoder 用 BGE-Reranker 系列。
- 编排:LangChain 或 LlamaIndex 做流程串联,但核心检索逻辑建议自己写,框架的抽象有时会挡住调优空间。
安装依赖的部分就不展开了,重点说配置。Elasticsearch 的索引 mapping 需要把文档的文本字段设成text类型(用于 BM25),同时把需要精确过滤的字段(如文档 ID、来源、时间)设成keyword或date类型。向量库那边,需要确认 embedding 的维度与模型输出一致,距离度量用余弦相似度。
3.2 文档切块策略:比检索算法更重要
很多人把精力全花在检索算法上,却忽略了切块策略。我踩过的最大的坑就是:切块没切好,后面怎么检索都是白搭。
切块的核心矛盾是:块太小,语义不完整,向量表示质量差;块太大,噪声多,检索精度下降。我的经验值是 256 到 512 个 token 之间,具体取决于文档类型。技术文档可以小一点,因为信息密度高;叙述性文档可以大一点,因为需要更多上下文才能理解。
更重要的是切块方式。不要简单地按固定长度切,那样会把一个完整的段落从中间截断。我通常用递归切块:先按段落切,如果段落太长再按句子切,如果句子还太长才按固定长度切。同时保留一定的重叠(overlap),通常 10% 到 20%,防止关键信息刚好落在切块边界上。
还有一个技巧:给每个块加上元数据。比如它来自哪个文档、在文档中的位置、所属章节标题。这些元数据在检索时可以用来做过滤,也可以拼在块内容前面一起做 embedding,提升向量表示的质量。
3.3 多路召回的实现细节
先看稀疏检索这一路。Elasticsearch 的查询构造很直接:
{ "query": { "bool": { "must": [ { "match": { "content": "接口超时重试策略" } } ], "filter": [ { "term": { "doc_type": "technical" } }, { "range": { "updated_at": { "gte": "2024-01-01" } } } ] } }, "size": 50 }这里的关键是filter部分:用结构化条件做硬过滤,把不符合要求的文档直接排除。这一步能大幅减少后续融合和重排的计算量。
向量检索这一路,先把查询用同一个 embedding 模型编码,然后在向量库里做 ANN 搜索:
query_vector = embedding_model.encode(query) results = vector_store.search( query_vector=query_vector, top_k=50, metric_type="cosine" )注意top_k要设得比最终需要的数量大,因为后面还要融合和重排。我通常设 50 到 100。
两路都拿到结果后,做 RRF 融合:
def rrf_fusion(bm25_results, vector_results, k=60): scores = {} for rank, doc in enumerate(bm25_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) for rank, doc in enumerate(vector_results): scores[doc.id] = scores.get(doc.id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)这段代码很简单,但效果很实在。融合后的排序通常比任何单路都好。
3.4 重排与最终输出
融合后的 Top-50 送给 Cross-Encoder 重排:
pairs = [(query, doc.content) for doc in fused_results[:50]] rerank_scores = reranker.predict(pairs) reranked = sorted(zip(fused_results, rerank_scores), key=lambda x: x[1], reverse=True) final_results = [doc for doc, score in reranked[:5]]最终取 Top-5 送给 LLM 做生成。这里有个细节:重排的输入不一定是原始块内容,可以把块的元数据(章节标题、来源)也拼进去,给重排模型更多判断依据。
整个链路跑下来,单次查询的延迟大概在 200 到 500 毫秒之间,取决于候选集大小和重排模型的大小。对于大多数在线场景,这个延迟是可以接受的。如果要求更高,可以把重排模型换成更轻量的版本,或者做异步重排。
4. 常见问题与排查技巧实录
4.1 召回率突然下降怎么排查
这是最常见的问题。我的排查顺序是这样的:
- 先看查询本身。是不是查询里包含了新的专有名词,而 embedding 模型没见过?是不是查询太短,语义信息不足?
- 再看召回结果。把 BM25 和向量两路的结果分别打印出来,看是哪一路出了问题。如果 BM25 召回正常但向量召回跑偏,可能是 embedding 模型不适合这个领域;如果两路都差,可能是切块或索引出了问题。
- 检查过滤条件。有时候是 filter 写得太严,把相关文档误杀了。可以先把 filter 去掉,看召回是否恢复。
- 检查索引更新。新文档有没有及时灌进索引?向量库和 ES 的数据是否一致?
我遇到过一次诡异的问题:召回率突然从 85% 掉到 40%。排查了半天,发现是 ES 的索引在某个时间点被重建了,但向量库没有同步重建,导致两路召回的数据源不一致。这种问题只能靠监控和定期一致性检查来预防。
4.2 融合后排序反而变差怎么办
RRF 虽然稳,但也不是万能的。如果融合后排序比单路还差,通常是以下原因:
- 某一路召回质量太差。如果向量检索的 Top-50 里大部分是噪声,融合后会把 BM25 的好结果也拉低。解决办法是给两路设置不同的权重,或者对质量差的那一路做截断。
- k 值不合适。k 太小会让排名靠前的结果权重过大,k 太大会让排名差异被抹平。可以试试 k=20、k=60、k=100 几个值,看哪个效果最好。
- 两路召回的结果重叠度太低。如果 BM25 和向量召回的结果几乎不重叠,说明两路捕捉的是完全不同的信号,融合时需要考虑是否真的应该融合,还是应该分别处理。
4.3 重排模型太慢怎么优化
Cross-Encoder 的延迟和候选集大小成正比。如果 Top-50 重排太慢,可以:
- 先粗筛再精排。用一个小模型或简单的规则先把 Top-50 筛到 Top-20,再用 Cross-Encoder 精排。
- 用 ONNX 或 TensorRT 加速推理。把重排模型导出成优化后的格式,推理速度能提升 2 到 3 倍。
- 批处理。如果查询量不大,可以把多个查询的重排请求攒在一起做批处理,提高 GPU 利用率。
- 换更小的模型。BGE-Reranker 有 base 和 large 两个版本,base 版本速度更快,效果差距在大多数场景下可以接受。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 专有名词搜不到 | 向量模型对低频词表示差 | 检查 BM25 是否命中 | 确保 BM25 路正常,提高其权重 |
| 同义词搜不到 | BM25 无法匹配语义 | 检查向量路召回 | 确保向量路正常,或加查询改写 |
| 召回结果重复 | 切块重叠过大 | 检查块间重叠比例 | 降低 overlap 到 10% 左右 |
| 排序靠前的结果不相关 | 重排模型不适配领域 | 人工评估重排分数 | 换领域适配的重排模型或微调 |
| 延迟过高 | 候选集太大或模型太重 | 分段计时 | 缩小候选集,换轻量模型 |
| 新文档搜不到 | 索引未更新 | 检查索引时间戳 | 建立实时索引更新机制 |
4.5 几个我踩过的坑
坑一:embedding 模型和向量库的维度不匹配。换模型时忘了重建索引,查询时报维度错误。这个坑很低级,但很容易犯,建议在配置里加一个维度校验。
坑二:BM25 的 analyzer 没配好。中文文档如果没配中文分词器,BM25 会把整句话当成一个 token,完全没法匹配。Elasticsearch 需要装 IK 分词器,或者用内置的 smartcn。
坑三:RRF 的 k 值用了默认的 60 但没调。不同场景下最优 k 值差异很大,建议把 k 作为超参数在验证集上调。
坑四:重排模型的输入长度超限。Cross-Encoder 通常有最大输入长度限制(如 512 token),如果查询加文档超过这个长度会被截断,影响打分。解决办法是控制块大小,或者在重排前对文档做摘要。
坑五:忽略了权限过滤。多路召回时如果忘了加权限过滤,可能会把用户无权访问的文档召回出来。这个在内部系统里是严重问题,一定要在每一路召回里都加上权限过滤条件。
5. 不同规模团队的落地建议
5.1 小团队:从混合检索起步
如果你是一个人或者两三个人的小团队,没必要一上来就搭全套多路召回。我的建议是先从BM25 + 向量混合检索开始,用最简单的加权融合,先把基础效果跑通。等业务量上来了,再逐步加重排和更复杂的融合策略。
小团队的优势是决策快、迭代快。不要被大厂的复杂架构吓到,他们的架构是为了支撑海量数据和超高并发,你的场景可能根本不需要那么复杂。一个 ES 加一个向量库,配上简单的 RRF,就能解决 80% 的问题。
5.2 中型团队:重视评估和监控
当你的 RAG 系统开始有真实用户、开始承载业务指标时,评估和监控就变得比算法选型更重要。你需要一套自动化的评估流程,定期跑一批标注好的查询-文档对,计算召回率、MRR、NDCG 等指标。同时要有线上监控,跟踪查询延迟、召回数量、用户点击率等信号。
我见过太多团队把精力全花在调模型上,却没有一套像样的评估体系,结果改了半天不知道到底变好了还是变差了。评估集不需要很大,几百条高质量标注就够用,但一定要持续维护。
5.3 大团队:关注一致性和可扩展性
大团队面临的挑战不是单点效果,而是系统的一致性和可扩展性。多路召回意味着多个数据源,如何保证它们之间的数据一致性?如何在不影响线上服务的情况下更新索引?如何水平扩展以支撑更高的并发?
这些问题没有标准答案,但有几个原则可以参考:索引更新走双写或影子索引,确保切换时无感知;召回服务做无状态化,方便水平扩展;融合和重排层做异步化,避免阻塞主链路。这些工程上的考量,往往比算法本身更能决定系统的成败。
6. 一些个人体会
这套多路召回的方案,我在几个项目里反复迭代过,最大的感受是:检索质量的上限取决于数据质量,而不是算法复杂度。我见过太多团队在算法上疯狂堆料,却不愿意花时间清洗文档、优化切块、维护评估集。结果就是算法越堆越复杂,效果提升却越来越小。
另一个体会是:没有银弹。BM25 有 BM25 的盲区,向量有向量的盲区,重排也有重排的盲区。多路召回的本质是用工程手段互相兜底,而不是找到一个完美的单点方案。接受这个现实,你就能把精力放在如何让各路召回更好地协作上,而不是追求某个算法的极致。
最后说一个实操小技巧:在查询入口做意图分类。不同类型的查询,适合的召回策略不同。事实型查询(“XX 的配置参数是什么”)应该偏重 BM25,探索型查询(“怎么优化系统性能”)应该偏重向量。用一个轻量级分类器先判断查询类型,再动态调整各路召回的权重,效果比固定权重好很多。这个分类器不需要很准,哪怕只有 70% 的准确率,也能带来明显的提升。