☰
混合检索实战:BM25与向量检索融合提升RAG召回率
2026/10/13 1:49:11 网站建设 项目流程

1. 纯向量检索到底哪里不够用

1.1 从一个真实翻车案例说起

去年帮一个团队调优他们的知识库问答系统,场景很典型:内部技术文档检索,大概三万多个文档块,用的是一个主流向量数据库,嵌入模型选的也是榜上有名的开源方案。离线评测的时候召回率看着还行,Top-5 能到 78% 左右,团队觉得可以上线了。

结果上线第一周就炸了。用户搜"XX 接口的超时时间怎么配置",返回的全是讲"XX 接口的鉴权流程"的段落——语义确实相关,都是同一个接口的文档,但用户要的那个具体参数值,藏在另一个段落里,向量检索死活排不到前面。更离谱的是搜错误码"0x8003",返回的居然是"0x8001"和"0x8005"的说明,因为这几个错误码的文本描述高度相似,嵌入向量几乎挤在一起。

这就是纯向量检索最要命的地方:它擅长的是"意思差不多",不擅长"精确匹配"。而真实业务里,用户大量的查询恰恰是精确的——错误码、函数名、配置项、版本号、订单号、产品型号。这些东西在向量空间里没有区分度,因为嵌入模型压根不是为区分"0x8003"和"0x8005"训练的。

1.2 向量检索的三个结构性缺陷

我把纯向量检索的问题归纳成三个层面,理解了这三个层面,你就明白为什么大厂的 RAG 系统从来不只用向量。

第一,语义漂移。嵌入模型把文本压缩成一个固定维度的向量,这个过程必然有信息损失。两个意思完全不同的句子,如果共享大量上下文词汇,向量相似度可能很高。比如"如何关闭自动续费"和"如何开启自动续费",在多数嵌入模型里余弦相似度能到 0.9 以上,但用户要的是完全相反的操作。

第二,精确匹配无能。向量检索本质上是近似最近邻搜索,它返回的是"距离最近"的结果,不是"包含关键词"的结果。你搜一个具体的错误码、一个特定的 API 名称、一个精确的数字,向量检索没有任何机制保证这些东西出现在结果里。

第三,无法表达结构化约束。真实查询经常带条件:"最近三个月内的"、"属于财务部门的"、"状态为已完成的"。纯向量检索对这些元数据条件无能为力,你只能靠后置过滤,但后置过滤会导致召回数量不可控——过滤完可能一条都不剩。

这里有个常见误区:很多人觉得换个更强的嵌入模型就能解决。实测下来,嵌入模型从小的换到大的,精确匹配能力提升非常有限,因为这是向量检索的范式问题,不是模型能力问题。

1.3 大厂的真实做法:混合检索

大厂 RAG 系统普遍采用的是混合检索(Hybrid Retrieval),核心思路是把向量检索和关键词检索的结果融合,再经过重排序。关键词检索负责"精确命中",向量检索负责"语义泛化",两者互补。

具体来说,关键词检索这一路通常用 BM25 或其变体。BM25 是经典的信息检索算法,核心思想是:一个词在文档中出现频率越高、在整个语料库中出现频率越低,这个词的区分度就越高。它天然擅长精确匹配,你搜"0x8003",它就能精确找到包含这个字符串的文档。

向量检索这一路负责处理"用户说的和文档写的不一样"的情况。比如用户问"怎么让程序跑得更快",文档里写的是"性能优化指南",纯关键词检索可能匹配不上,但向量检索能通过语义相似度找到。

两路结果各取所长,融合后送入重排序模型精排,最终返回给大模型。这套架构在多个公开评测集上,比纯向量检索的召回率能提升 15 到 30 个百分点,具体取决于数据特点和查询分布。

2. 混合检索的核心组件拆解

2.1 BM25 为什么至今仍是关键词检索的首选

BM25 诞生几十年了,深度学习时代它依然没有被淘汰,原因很简单:它在精确匹配场景下的表现,至今没有哪个神经检索模型能稳定超越,而且它不需要训练、不需要 GPU、延迟极低。

BM25 的评分公式看起来复杂,但核心逻辑就三条:

  • 词频饱和:一个词在文档里出现 10 次和出现 100 次,相关性差距不是线性的,出现到一定次数后收益递减。
  • 逆文档频率:越罕见的词权重越高。"的"这种词到处都是,权重接近零;"0x8003"这种词很罕见,权重很高。
  • 文档长度归一化:长文档天然更容易包含查询词,需要做惩罚,否则长文档永远排前面。

实际工程中,BM25 有两个参数需要调:k1 控制词频饱和速度,b 控制文档长度归一化强度。多数场景下 k1 取 1.2 到 2.0,b 取 0.75 是安全的默认值。但如果你的文档长度差异极大(比如有的文档块 50 字,有的 2000 字),b 可以适当调低到 0.5 左右,减少长度归一化的激进程度。

中文场景下 BM25 有个坑:需要先做分词。分词的粒度直接影响检索效果。我的经验是,技术文档场景用细粒度分词(比如把"向量数据库"切成"向量"、"数据库"),配合 BM25 效果更好,因为用户查询可能只包含其中一部分。但如果是法律、医疗这类术语密集的领域,粗粒度分词保留完整术语反而更准。

2.2 向量检索这一路怎么选型和调优

向量检索这一路,选型主要看三个维度:嵌入模型、索引类型、距离度量。

嵌入模型的选择,不要只看排行榜。排行榜上的模型多是在通用语义相似度任务上评测的,你的业务场景可能完全不同。我的做法是:拿 200 条真实用户查询,人工标注正确答案,然后测几个候选模型的召回率。实测下来,一个在排行榜上排 20 名开外的模型,在特定垂直领域可能吊打排名前 3 的通用模型。

索引类型方面,主流向量数据库都支持多种索引。HNSW 是图索引,查询快、召回高,但内存占用大;IVF 是倒排索引,内存友好,但需要训练且召回略低。数据量在百万级以下,HNSW 基本是首选,因为省心。千万级以上就要考虑 IVF 加量化了,用精度换内存和速度。

距离度量要和嵌入模型的训练目标匹配。多数现代嵌入模型用余弦相似度训练,那就用余弦;有些模型用点积训练,那就用内积。用错了度量方式,效果会明显下降。这个细节很多人忽略,但实测影响不小。

2.3 融合策略:RRF 为什么比加权求和更稳

两路检索各自返回一个有序列表,怎么融合成一个列表?常见做法有两种:加权求和和 RRF(Reciprocal Rank Fusion)。

加权求和是给两路分数各乘一个权重再加起来。问题是,BM25 的分数和向量相似度的分数不在一个量纲上,BM25 分数可能是 0 到 30,余弦相似度是 0 到 1,直接加权需要先归一化,而归一化方式的选择又很讲究,搞不好就引入偏差。

RRF 的思路完全不同:它不看分数,只看排名。公式很简单,每个文档的最终得分是sum(1 / (k + rank)),k 通常取 60。也就是说,一个文档在 BM25 里排第 1、在向量检索里排第 5,它的 RRF 得分就是1/(60+1) + 1/(60+5)。

RRF 的好处是:不需要归一化,不需要调权重,对两路检索的分数分布不敏感。实测下来,RRF 在多数场景下比精心调参的加权求和还要稳,而且几乎不需要调参。这也是为什么大厂系统里 RRF 出现频率极高。

实操心得:RRF 的 k 值取 60 是论文里的默认值,但我试过在文档块数量较少(几千条)的场景下调到 20 到 30,效果更好,因为排名差异被放大了。数据量大就保持 60。

3. 从零搭建混合检索的完整实操

3.1 环境准备与依赖安装

先明确技术栈。向量检索我用一个轻量级的本地向量库,关键词检索用 BM25 的实现,重排序用一个小的交叉编码器模型。整套东西可以在一台普通开发机上跑起来,不需要 GPU 集群。

pip install rank_bm25 pip install sentence-transformers pip install numpy

向量库这里我用一个内存版的方案演示,生产环境换成任意主流向量数据库即可,接口逻辑是一样的。重排序模型用交叉编码器,它比双编码器慢,但精度高,适合对少量候选做精排。

from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer, CrossEncoder import numpy as np

3.2 文档预处理与分块策略

分块是 RAG 系统里最容易被低估的环节。块切得太大,检索精度下降;切得太小,上下文丢失。我的经验是:技术文档按语义段落切,每块 200 到 500 字,块之间保留 50 字左右的重叠。

重叠的作用是防止关键信息正好被切在边界上。比如一个配置说明跨了两个段落,没有重叠的话,两个块各拿一半,检索时可能都匹配不完整。

def chunk_document(text, chunk_size=400, overlap=50): chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) start = end - overlap return chunks

中文场景下,按字符数切块比按词数更直观,因为中文字符的信息密度比较均匀。但要注意,切块时尽量在句号、换行这些自然边界处断开,不要硬切在句子中间。

预处理阶段还要做一件事:给每个块附加元数据。至少要有文档 ID、块序号、来源标题。这些元数据在后续过滤和结果展示时都会用到。

3.3 BM25 索引构建与查询

BM25 这一路的构建很直接。先把所有文档块分词,然后建索引。

tokenized_corpus = [list(chunk) for chunk in all_chunks] bm25 = BM25Okapi(tokenized_corpus) def bm25_search(query, top_k=20): tokenized_query = list(query) scores = bm25.get_scores(tokenized_query) top_indices = np.argsort(scores)[::-1][:top_k] return [(idx, scores[idx]) for idx in top_indices]

这里有个细节:中文按字符分词是最简单的做法,但效果一般。更好的做法是用分词工具切成词。不过对于技术文档,按字符分词的鲁棒性反而不错,因为技术术语里经常混着英文、数字、符号,分词工具容易切错,按字符切虽然粒度细,但不会漏。

BM25 返回的 top_k 建议设大一点,比如 20 到 50。因为后面还要融合和重排序,这一路多召回一些,给后续环节更多选择空间。

3.4 向量索引构建与查询

向量这一路,先用嵌入模型把所有块编码成向量。

model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2') embeddings = model.encode(all_chunks, normalize_embeddings=True) def vector_search(query, top_k=20): query_vec = model.encode([query], normalize_embeddings=True)[0] scores = np.dot(embeddings, query_vec) top_indices = np.argsort(scores)[::-1][:top_k] return [(idx, scores[idx]) for idx in top_indices]

normalize_embeddings=True这个参数很关键,它把向量归一化到单位长度,这样点积就等于余弦相似度。省去了手动算余弦的步骤,也避免了数值问题。

嵌入模型的选择上,多语言模型适合中英混合的技术文档。如果你的文档全是中文,可以选专门的中文嵌入模型,效果通常更好。但要注意,换模型意味着所有向量都要重新编码,所以选型阶段要多花点时间。

3.5 RRF 融合与重排序

两路检索各自返回结果后,用 RRF 融合。

def rrf_fusion(bm25_results, vector_results, k=60): scores = {} for rank, (idx, _) in enumerate(bm25_results): scores[idx] = scores.get(idx, 0) + 1 / (k + rank + 1) for rank, (idx, _) in enumerate(vector_results): scores[idx] = scores.get(idx, 0) + 1 / (k + rank + 1) sorted_items = sorted(scores.items(), key=lambda x: x[1], reverse=True) return sorted_items

融合后取前 20 个候选,送入交叉编码器重排序。交叉编码器把查询和文档拼在一起输入模型,直接输出相关性分数,精度比双编码器高很多,但计算量大,所以只对少量候选做。

cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2') def rerank(query, candidates, top_k=5): pairs = [(query, all_chunks[idx]) for idx, _ in candidates] scores = cross_encoder.predict(pairs) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return ranked[:top_k]

重排序这一步是提升精度的关键。实测下来,经过重排序后,Top-5 的准确率能比只做融合提升 10 到 20 个百分点。代价是延迟增加,交叉编码器对 20 个候选打分大概需要 100 到 300 毫秒,取决于模型大小和硬件。

3.6 完整流程串联

把上面的环节串起来,一个完整的混合检索流程是这样的:

def hybrid_search(query, top_k=5): bm25_results = bm25_search(query, top_k=30) vector_results = vector_search(query, top_k=30) fused = rrf_fusion(bm25_results, vector_results) reranked = rerank(query, fused[:20], top_k=top_k) return reranked

整个流程的延迟,在普通开发机上大概是:BM25 检索 5 到 20 毫秒,向量检索 10 到 50 毫秒(取决于数据量和索引类型),RRF 融合 1 毫秒以内,重排序 100 到 300 毫秒。总计 150 到 400 毫秒,对于大多数问答场景是可以接受的。

如果要进一步降延迟,可以把重排序模型换成更小的,或者只对 Top-10 做重排序。但我不建议完全去掉重排序,因为它是精度提升最明显的一环。

4. 实际调优中踩过的坑与排查技巧

4.1 召回率上不去,先查这三个地方

混合检索搭起来之后,如果召回率不达预期,按这个顺序排查。

第一,检查分块。十次召回问题里有六次是分块不合理。典型症状是:正确答案明明在文档里,但检索不到。这时候把分块结果打印出来看看,很可能答案被切碎了,或者关键信息正好落在重叠区之外。我的做法是,拿几个失败案例,手动定位答案在原文的位置,然后看它被分到了哪个块,块的内容是否完整。

第二,检查分词。BM25 这一路对分词敏感。如果查询里的关键词被分词工具切错了,BM25 就匹配不上。比如"向量数据库"被切成"向量"、"数据"、"库",用户搜"向量库"就匹配不上。解决办法是加自定义词典,把领域术语加进去。

第三,检查嵌入模型。如果 BM25 能召回但向量这一路召回不了,说明嵌入模型对你的领域不敏感。这时候要么换模型,要么在领域数据上做微调。微调嵌入模型的门槛比想象中低,几百条标注数据就能有明显提升。

4.2 融合后排序混乱的排查思路

RRF 融合后,有时候会出现明显不相关的结果排在前面。这种情况通常是两路检索里有一路返回了噪声。

排查方法是:把两路各自的 Top-10 打印出来对比。如果 BM25 的 Top-10 里有一半是无关的,说明查询里的词太常见,BM25 的 IDF 权重没起作用。这时候可以加停用词表,把常见但无意义的词过滤掉。

如果向量检索的 Top-10 里有一半无关,说明嵌入模型把语义空间搞混了。常见于查询很短的情况,比如只有两三个字。短查询的向量表示本身就不稳定,解决办法是查询扩展——用大模型把短查询改写成更完整的句子,再去做向量检索。

实操心得:查询扩展这招在短查询场景下效果拔群。我试过一个场景,用户查询平均长度只有 4 个字,加了查询扩展后,向量检索的召回率从 52% 提升到 71%。代价是多了一次大模型调用,延迟增加 200 到 500 毫秒。

4.3 重排序模型选型与延迟平衡

重排序模型不是越大越好。我对比过几个不同大小的交叉编码器,结论是:在候选数量 20 以内的情况下,小模型和大模型的精度差距很小(通常 2 到 3 个百分点),但延迟差距是 3 到 5 倍。

所以我的建议是:先用小模型,如果精度不够再换大的。而且重排序的候选数量不要设太大,20 到 30 足够了。候选太多,一是延迟高,二是后面的候选本来相关性就低,重排序也救不回来。

还有一个细节:重排序模型的训练数据和你的业务场景是否匹配。多数公开的重排序模型是在通用问答数据上训练的,对技术文档、法律文书这类专业文本,效果会打折扣。如果有条件,用业务数据微调一下重排序模型,提升非常明显。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
精确匹配搜不到BM25 分词错误打印分词结果加自定义词典
语义相关但排序靠后重排序模型不匹配对比重排序前后微调重排序模型
短查询效果差向量表示不稳定测试不同长度查询查询扩展
长文档总是排前面BM25 长度归一化不足检查 b 参数调低 b 值
融合后噪声多某一路召回质量差分别看两路 Top-10过滤停用词或换模型
延迟过高重排序候选太多统计各环节耗时减少候选数量或换小模型

4.5 一个容易被忽略的细节:查询预处理

用户输入的查询往往很脏:有多余空格、有特殊符号、有错别字。这些都会影响检索效果。上线前一定要做查询预处理。

最基本的:去首尾空格、统一全角半角、去掉连续重复的标点。进阶一点:做拼写纠错,但纠错要谨慎,因为技术术语里的"错误"可能是故意的,比如变量名里的下划线。

我的做法是维护一个查询改写规则表,把常见的口语化表达映射到标准术语。比如用户搜"咋配置",映射成"如何配置";搜"报错了",映射成"错误处理"。这个规则表可以人工维护,也可以用大模型自动生成候选再人工审核。

5. 混合检索的进阶优化方向

5.1 元数据过滤与结构化检索

前面提到纯向量检索无法表达结构化约束,混合检索虽然好一些,但元数据过滤还是需要单独处理。主流向量数据库都支持带过滤条件的向量检索,但过滤和检索的执行顺序会影响性能。

如果过滤条件能筛掉大部分数据,应该先过滤再检索,这样检索的数据量小,速度快。如果过滤条件很宽松,筛不掉多少数据,那就先检索再过滤,避免过滤操作本身的开销。

实际工程中,我通常把元数据分成两类:高选择性元数据(比如文档类型、部门)和低选择性元数据(比如时间范围)。高选择性的用于预过滤,低选择性的用于后过滤。

5.2 多路召回的扩展思路

混合检索是两路,但实际系统里可以扩展到多路。比如再加一路:基于大模型的查询改写检索。用大模型把用户查询改写成多个不同角度的子查询,每个子查询各自检索,最后融合。

这招在处理复杂查询时特别有效。比如用户问"对比 A 方案和 B 方案的性能差异",单路检索可能只召回 A 或只召回 B,多路改写后,一路专门检索 A,一路专门检索 B,融合后两个方案的信息都能拿到。

代价是延迟和成本增加。每多一路就多一次检索,如果每路还带大模型调用,延迟会明显上升。所以多路召回适合对精度要求高、对延迟容忍度也高的场景,比如离线分析、报告生成。

5.3 缓存与增量更新

生产环境的 RAG 系统,缓存是必须的。用户查询有大量重复,相同查询直接返回缓存结果,能省掉整个检索流程。

缓存策略上,精确匹配缓存最简单,但命中率有限。语义缓存更激进:把查询编码成向量,如果和缓存里的查询向量相似度超过阈值,就返回缓存结果。语义缓存命中率高,但有误判风险——两个查询语义相似但意图不同,返回错误结果。我的经验是阈值设高一点(0.95 以上),宁可少命中也不要误判。

增量更新是另一个工程问题。文档会更新,索引也要跟着更新。BM25 索引更新相对简单,重建或增量添加都行。向量索引更新麻烦一些,HNSW 支持增量插入,但删除需要标记删除再定期重建。如果文档更新频繁,要考虑索引重建的频率和成本。

5.4 评测体系的搭建

没有评测,调优就是盲人摸象。混合检索的评测至少要覆盖三个指标:召回率、精确率、MRR(平均倒数排名)。

召回率衡量"该找的有没有找到",精确率衡量"找到的有没有用",MRR 衡量"有用的排得够不够前"。三个指标要一起看,只看一个容易调偏。

评测集的构建,我的做法是:从真实用户查询里采样 200 到 500 条,人工标注每条查询对应的正确文档块。标注工作量大,但一次投入长期受益。后续每次调优,跑一遍评测集就知道有没有提升。

注意:评测集要定期更新,因为用户查询分布会变化。我见过一个系统,评测集是半年前建的,在上面调优到 90% 召回,上线后实际效果只有 60%,因为用户查询模式已经变了。

6. 一些个人体会

混合检索这套东西,搭起来不难,难的是调优。我踩过的最大坑是过早优化——一开始就上复杂的多路召回、大模型重排序,结果延迟高得没法用,精度也没比简单方案好多少。后来退回来,先用 BM25 加向量两路,RRF 融合,小模型重排序,把基础打牢,再逐步加东西,反而效果更好。

另一个体会是:不要迷信排行榜和论文。论文里的方法在论文的数据集上有效,你的数据可能完全不同。每次改动都要在自己的评测集上验证,数据说话。

最后,RAG 系统里检索只是其中一环,检索之后的生成环节同样重要。检索返回的上下文怎么组织、怎么塞进提示词、怎么让大模型引用来源,这些都会影响最终效果。检索做得再好,生成环节拉胯,用户体验照样差。这块内容展开又是另一个话题了,后面有机会再聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询