ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG混合检索与重排序:向量+BM25召回优化的完整实践

RAG混合检索与重排序:向量+BM25召回优化的完整实践

做 RAG 检索优化的人,大概率都遇到过这个场景:知识库已经切好块,向量索引也建好了,问答链路跑通了,但用户问一个文档里的精确型号、内部编号或者冷门词,召回结果却永远对不上。我早期做 RAG 时就被这个问题卡了很长时间,后来才逐步想明白,向量检索擅长语义相似,但天生不擅长精确匹配。这次把从纯向量检索升级到混合检索,再叠加重排序的完整落地过程、原理拆解和踩坑记录整理出来,希望能帮你少走弯路。

整套优化思路的核心一句话:召回阶段做宽,排序阶段做准。纯向量检索负责语义召回,BM25 这类稀疏检索负责字面命中,最后用重排序模型在候选集上做精排。下面按设计思路、核心原理、实操代码、参数调优和常见问题五个部分展开。

1. 整体设计思路:为什么纯向量检索不够用

1.1 向量检索的强项与天花板

向量检索的核心思路是把文本用嵌入模型映射成一个高维向量,然后通过余弦相似度或内积计算 query 和文档的语义距离。这种做法的好处很直观:即使 query 和文档没有任何相同的词,比如用户问“笔记本电池能用多久”,文档里写的是“续航时间约为8小时”,向量检索也能把两者关联起来。这也是 RAG 系统选择向量检索作为主召回方式的原因。

但向量检索的短板同样明显。第一,它对精确词、编号、型号、代码变量名这类低频 Token 不敏感,因为这些片段在文档中出现的次数少,嵌入模型赋予它们的语义权重不够;第二,当文档很长或者领域术语密集时,向量会被“平均”掉,关键信息被淹没;第三,向量检索的结果没有可解释性,查不到的时候你很难判断是模型问题还是索引问题。

我实际遇到过这样一个案例:用户问“保修条款第三年人工费怎么计算”,纯向量召回返回的是一堆售后政策泛泛介绍,而真正包含“第三年人工费”的表格内容排到了十几名开外。这种问题单靠调嵌入模型很难彻底解决,因为用户问的是字面匹配,语义模型反而被“泛化”了。

1.2 混合检索与重排序的分工

混合检索解决的是“召回不全”的问题,思路是把两类互补的召回方式组合起来。向量检索做语义召回,BM25 这类稀疏检索做词法和字面召回。比如刚才那个例子,BM25 可以直接靠“第三年”“人工费”这些关键词命中目标段落,即使整段话的语义向量区分度不高。

重排序解决的是“排序不准”的问题。混合召回的候选集本质上是一个“粗选池”,里面可能有多个相关段落,但它们的相对顺序并不理想。重排序模型会把 query 和每个候选段落拼接在一起做深度交互,输出一个相关性分数,再按这个分数重新排序。

所以整体链路是一个两阶段漏斗:召回阶段用多路检索把候选集找全,比如取 50 到 100 条;精排阶段用重排序模型把最相关的 5 到 10 条放在最前面。这个架构的好处是每一层都只做自己擅长的事,既控制延迟,又保证效果。

1.3 方案选型的三个关键判断

并不是所有 RAG 系统都必须上混合检索加重排序。我一般用三个问题来判断是否需要升级:第一,用户问题里是否频繁出现专有名词、编号、型号这类字面匹配需求?第二,纯向量召回的 Top 20 结果里是否经常出现“看起来相关但实际不相关”的段落?第三,线上场景对召回准确率的要求是否远高于对延迟的容忍度?

如果三个问题的答案都是“是”,那混合检索加重排序几乎是你绕不开的路线。如果只是做开放域闲聊,纯向量检索可能已经够用。方案选型时还要考虑工程成本:混路召回需要维护两套索引,重排序需要额外的模型推理资源,这些都会增加运维复杂度,所以不要盲目追求“全都要”。

2. 核心原理与关键参数解析

2.1 向量检索的相似度计算与参数含义

向量检索的底层是文本嵌入模型,它把文本变成一个固定维度的向量,例如 768 维或 1024 维。两个向量的相似度通常用余弦相似度计算:

sim = (A · B) / (||A|| × ||B||)

如果向量已经做了归一化,那么余弦相似度等价于内积计算,这也是很多向量库能高速检索的原因。实际操作中要注意距离度量的选择:余弦相似度适合大部分文本场景,内积则更适合已经归一化的向量,欧氏距离在低维向量或图像特征上更常见。

嵌入模型的选择直接决定召回上限。我个人会偏向使用针对中文优化过的开源嵌入模型,训练数据里包含大量中文语料,对专有名词和文档结构的理解更到位。每换一次嵌入模型,索引里的向量就必须重新生成,所以选型之前最好用一批有代表性的问题做离线评测,不要只看榜单分数。

2.2 BM25 为什么到现在还有价值

BM25 是一种基于词频和逆文档频率的排序算法。它的核心公式可以理解为:某个词在文档里出现得越多,文档得分越高;但这个次在整个语料里越常见,它的权重就会被压低;同时文档越长,得分会被归一化稀释。

这两项特性使得 BM25 特别适合精确匹配场景。比如用户输入“API-1039”这个编号,如果文档里有这个字符串,BM25 几乎一定会把它排到前面,而向量检索反而可能因为训练数据里没见过这种格式而表现不佳。

中文场景下,BM25 的效果严重依赖分词质量。如果用标准分词器把“人工费”切成“人工”和“费”,召回精度会明显下降。所以在构建关键词索引时,我会建议配置专业分词器,或者至少加载领域词典,把常见术语、产品型号和外部编号作为整体对待。代码块、日志、表格这类结构化内容,甚至可以考虑做字符级 n-gram 补充索引,进一步增强字面命中能力。

2.3 混合融合策略:RRF 与加权归一

混合检索不是简单地把两路结果拼在一起,而是需要一种稳定的融合策略。最常用的是 RRF(Reciprocal Rank Fusion),公式如下:

score(d) = Σ 1 / (k + rank(d))

其中 rank(d) 是文档 d 在某一路检索结果中的排名,k 是一个平滑常数,通常取 60。RRF 只依赖排名而不是原始分数,好处是不同检索器的打分尺度不同,比如向量分数是 0.85,BM25 分数是 20,直接加权很难对齐,但排名天然是统一的。

另一种方案是加权归一化:先把向量分数和 BM25 分数分别做 min-max 归一化,然后按权重相加。这种方式更直观,但权重需要靠评测反复调。我的习惯是先用 RRF 快速跑通基线,再对比加权方案,让数据决定最终选谁。

2.4 重排序模型为什么更“准”

向量检索使用的嵌入模型属于“双编码器”,query 和 document 分别编码成向量,再做相似度计算。这种结构的优点是向量可以预计算、检索速度快,但缺点是交互很弱,模型在最后一层才把两者比较。

重排序模型通常采用“交叉编码器”结构,直接把 query 和候选文本拼接成一个句子输入模型,让注意力机制在两者之间充分交互。这种深度交互让它能捕捉到更细粒度的匹配信号,比如否定词、条件限定、指代关系,这些都是双编码器容易忽略的。

代价是速度慢。交叉编码器无法预计算向量,必须针对每一对 query-candidate 实时推理。所以重排序模型只适合在混合召回后的几十条候选上使用,不能替代第一阶段的粗召回。通常我会把候选集控制在 50 到 100 条,重排序时用 batch 推理,一次处理多个候选,能显著提升吞吐。

3. 实操:搭建混合检索与重排序完整链路

3.1 技术选型与准备工作

先说技术栈。向量检索部分可以选用支持 ANN 的开源向量库,也可以用关系型数据库的向量插件;关键词检索部分可以用开源的全文检索引擎,也可以直接在业务库中实现一个倒排索引。重排序部分建议选一个开源的交叉编码器模型,参数规模小一点的版本在 CPU 上也能跑。

环境准备主要做三件事:安装向量库客户端、全文检索客户端和重排序模型依赖;准备嵌入模型和重排序模型;确认 GPU 或 CPU 资源足够支撑在线推理。下面是伪代码形式的依赖导入示意:

import numpy as np from typing import List, Dict, Tuple # 假设已有嵌入模型封装 def get_embedding(text: str) -> List[float]: # 实际项目里这里是嵌入模型的推理调用 pass # 假设已有重排序模型封装 def get_rerank_score(query: str, document: str) -> float: # 实际项目里这里是交叉编码器模型的推理调用 pass

这里不指定具体品牌,因为不同组件的接口差异很大,重要的是把流程抽象成统一接口,方便替换。

3.2 文档切块与索引结构设计

文档切块对召回效果的影响往往被人低估。块太大,向量语义被稀释,BM25 分数也被无关词拉低;块太小,上下文不完整,重排序模型很难判断相关性。我常用的经验值是每块 256 到 512 个 token,相邻块重叠 20 到 50 个 token,既能保证语义完整,又能让跨块的关键词不丢失。

如果是 Markdown、HTML 这类带结构的内容,优先按标题和段落边界切分,而不是按固定长度硬切。代码文件则尽量按函数或类块切分,配合行号信息保留。索引结构至少包含四个字段:

字段类型用途
doc_idkeyword文档唯一标识
chunk_idkeyword文本块唯一标识
texttext原始文本,用于展示和重排序输入
vectordense_vector语义向量,用于向量召回
keywordstext分词后的关键词,用于 BM25 召回

很多检索系统支持在一个索引里同时定义向量字段和文本字段,但如果你用的是两个独立组件,需要保证两边的 doc_id 和 chunk_id 一致,否则融合时会关联不上。

3.3 向量召回与 BM25 召回实现

搭建流程的第一步是生成向量索引和关键词索引。所有文档切块后,逐块产生向量,同时把文本交给全文检索引擎建立倒排索引。这一步是离线流程,可以一次性批量完成,也可以在文档更新时增量处理。

在线检索时,两路召回可以设计成并行执行,减少总体延迟:

def vector_search(query: str, top_k: int = 100) -> List[Dict]: query_vec = get_embedding(query) hits = vector_index.search(query_vec, top_k=top_k) # 返回格式统一为 [{"chunk_id": ..., "score": ...}, ...] return hits def keyword_search(query: str, top_k: int = 100) -> List[Dict]: # 这里调用全文检索接口,使用 BM25 算法 hits = keyword_index.search(query, top_k=top_k) return hits

这里有两个容易踩的细节:一是向量检索前要检查 query 向量是否需要做归一化,二是 BM25 查询要对用户输入做和文档同样的分词处理。如果两边分词配置不一致,很多关键词会白白命中不了。

3.4 RRF 融合与重排序接入

两路召回返回后,先做融合。我最多使用的还是 RRF,因为它简单稳定,不需要维护额外的权重参数:

def rrf_fusion( vector_hits: List[Dict], keyword_hits: List[Dict], k: int = 60 ) -> List[Dict]: fusion_scores: Dict[str, float] = {} for rank, hit in enumerate(vector_hits): chunk_id = hit["chunk_id"] fusion_scores[chunk_id] = fusion_scores.get(chunk_id, 0.0) + 1.0 / (k + rank + 1) for rank, hit in enumerate(keyword_hits): chunk_id = hit["chunk_id"] fusion_scores[chunk_id] = fusion_scores.get(chunk_id, 0.0) + 1.0 / (k + rank + 1) sorted_hits = sorted( fusion_scores.items(), key=lambda item: item[1], reverse=True ) return [ {"chunk_id": chunk_id, "score": score} for chunk_id, score in sorted_hits ]

融合后取前 50 到 100 条,交给重排序模型精排。重排序这一步需要把候选文本和 query 拼接,再批量推理:

def rerank_search( query: str, fused_hits: List[Dict], top_n: int = 5 ) -> List[Dict]: candidate_ids = [hit["chunk_id"] for hit in fused_hits[:100]] candidate_texts = [get_chunk_text(cid) for cid in candidate_ids] pairs = [[query, text] for text in candidate_texts] rerank_scores = rerank_model.predict(pairs, batch_size=16) results = [] for chunk_id, score in zip(candidate_ids, rerank_scores): results.append({"chunk_id": chunk_id, "score": float(score)}) results.sort(key=lambda x: x["score"], reverse=True) return results[:top_n]

注意重排序模型的输入长度有限,如果候选文本太长,需要做截断或分段处理,否则超出长度会被模型直接忽略。实际项目中,我一般会把文本截到模型允许长度内,并在截断时尽量保留开头和结尾,因为关键信息往往出现在这两个位置。

4. 参数调优与效果评估

4.1 评估指标与测试集构造

没有评测就没有优化方向。做混合检索升级之前,我建议先构造一个离线测试集:从真实用户日志里挑 50 到 100 条有代表性的问题,每条标注出理想命中的文档块 ID。线上问题稀缺时,可以人工模拟业务人员会问的表述,但一定要覆盖专有名词、同义改写、长句、多条件限定等类型。

评测指标至少看三个:Recall@K 表示正确答案是否进入前 K 个候选;MRR 衡量第一个正确答案的排名;nDCG 能体现排序整体质量。我一般用 Recall@5 和 MRR 作为核心指标,因为 RAG 生成效果依赖最顶端那几条候选。

表格对比是观察优化效果最直观的方式:

方案Recall@5MRR单次查询延迟
纯向量检索0.610.52150 ms
混合检索0.820.67190 ms
混合检索 + 重排序0.890.81360 ms

这套数据来自我实际项目中的一次对比,不是通用标准,但趋势很典型:混合检索提升召回,重排序提升排序,两者叠加最明显。

4.2 候选集大小与融合权重调优

融合候选集大小对效果影响很大。候选集太小,比如只取 20 条,重排序模型可选的余地就很小;候选集太大,比如取 300 条,重排序推理时间成倍增长,而且长尾候选多为噪音,贡献有限。我一般从 50 到 100 开始测,优先固定 100。

RRF 中的 k 值也不是拍脑袋定死的。k 越小,排名靠前的文档权重越大;k 越大,不同排名的分数差距越平缓。代码里默认 60 是常见做法,但如果某一路召回效果明显更可信,可以适当调低该路排名权重或改用加权归一化方式。

加权方案的调参建议是从 α=0.5 开始,然后按 0.1 的步长在验证集上找最优值。注意向量分数和 BM25 分数都要先归一化,否则高分一路会直接压制另一路。我在项目里最终选择的是 α=0.6 的加权方案,因为向量召回的语义质量整体更好,但 BM25 负责兜底。

4.3 在线性能与资源优化

混合检索加精排的链路看起来复杂,但通过几个优化手段可以把延迟控制在可接受范围。第一,两路召回并行发起,而不是串行执行;第二,重排序阶段只处理融合后的前 100 条候选,不做全量精排;第三,对重排序模型做量化或者缓存常见 query 的推理结果。

此外,向量索引本身也需要注意构建参数。索引召回数量可以用较大的 top_k 保证召回率,但真正送给生成模型的只需要重新排序后的 top 5 到 10 条。缓存策略上,高频 query 可以做结果缓存,而针对用户实时输入则尽量走完整链路。

如果预算有限,重排序模型可以优先选参数量较小的版本,推理精度略降,但延迟大幅下降。我在一次压测中发现,小模型的重排延迟大约只有大模型的四分之一,而 Recall@5 只下降不到两个百分点,对业务场景完全可接受。

5. 常见问题与排查技巧实录

5.1 BM25 召回结果为空或极差

一个高频问题是:混合检索里 BM25 这一路几乎召不回任何结果。排查时先看分词是否正常。中文如果不配置领域词典,“第三代”“接口超时”这类词会被切成碎片,导致索引里根本没有完整词项。代码块、型号、产品编号要作为整体加入词典,否则 BM25 很难命中。

还有一个容易被忽略的问题是 query 预处理和文档预处理不一致。比如文档索引时做了小写归一化,但线上 query 没有做同样处理,那大小写差异就可能让 BM25 失效。解决方法是把两端的清洗逻辑抽成同一个函数,保证任何时候都同步更新。

5.2 重排序结果反而比融合前变差

重排序模型不是万能的,有时候精排结果还不如粗排,常见原因有三个。第一,候选集太小,正确结果根本没进前 20,重排自然无法补救;第二,候选文本超出模型长度限制,关键信息被截断,模型判断失真;第三,模型领域不匹配,比如用通用对话模型给法律合同文本做重排,效果自然好不起来。

遇到这种情况,先检查候选集是否包含正确答案,再检查输入截断逻辑,最后就要考虑在领域数据上微调重排序模型。领域微调并不需要太多数据,几百条标注样本就能带来肉眼可见的提升。

5.3 查询时延过高,如何优化

混合检索变慢通常卡在两处:向量检索通道本身慢,或者重排序推理太慢。向量检索慢可以先看索引类型是不是 HNSW,并调整 ef_search 参数,在召回质量和查询速度之间找平衡。重排序慢则优先从 batch 大小入手,把候选文本按 batch 批量进模型,比一条条跑快很多。

还有一个小技巧:重排序阶段可以先算一个粗略分,把分数明显很低的候选提前过滤掉,只对剩下的一半做完整精排。这个流程在候选 100 条时能省掉约三成推理时间,同时不影响最终 top 5 效果。

5.4 长文档切分造成的检索不完整

长文档切块如果按固定长度硬切,经常会把一个完整知识点切成两半,BM25 和向量召回都只能命中其中一半。解决方法是优先按语义边界切分,比如标题、段落、列表项,先找结构块再填充到规定长度。

对于确实无法避免跨块的内容,可以在相邻块之间加重叠,或者把标题信息拼到每个块开头。另一种做法是给每个块保留父级标题链,重排序模型看到“第二章 保修政策 > 3.2 人工费”,相关性判断会更准确。

写在最后的一点实践经验

这套混合检索加重排序的链路,我在不同项目里迭代过很多次,最大的感受是“先召回做宽,再精排做准”这个原则几乎没有例外。向量检索负责语义泛化,BM25 负责字面兜底,重排序负责最终质量,三个环节各司其职,缺一不可。但也不是所有业务都需要整套链路,如果 query 和文档本身匹配关系简单,纯向量加一个不错的重排序也能跑得很好。

最后分享一个小技巧:升级系统时不要一次性改动所有环节,先单独把混合检索切上去,看看召回率有没有提升,再叠加重排序,对比每条指标的变化。这样出了问题,你永远知道该回滚哪一层,而不是对着整套黑盒链路无从下手。希望这篇整理能帮你少踩几个坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进