混合检索与 RRF
混合检索(Hybrid Search)让不同检索器分别找候选,再把结果合并。常见组合是 BM25 加稠密向量检索(dense retrieval):BM25 根据词项匹配打分,dense 根据向量相似度找语义相关的内容。两路互相补充,减少单一路径遗漏证据的情况。
RRF 是结果融合的方法之一。它接收各路的有序候选列表,按候选在每一路的名次计算总分,输出一个合并后的列表。混合检索描述整套检索方式,RRF 负责其中的融合步骤。
例如,差旅知识库中的 TR-2026-03 是境内差旅制度编号。用户直接问这个编号时,系统应找到对应文件;用户问“出差回来最晚什么时候交报销”时,又需要把口语映射到“差旅结束后 30 个自然日内提交”。前一种查询依赖精确词,后一种查询依赖语义对应。两路使用同一个问题检索,再融合各自的结果:
┌─ BM25 检索 ── 候选列表一 ─┐
问题与过滤条件 ──┤ ├─ RRF ── 合并候选 ── 可选精排
└─ dense 检索 ─ 候选列表二 ─┘两路检索各自怎样找到候选
BM25 通过查询词在文档中的出现情况打分。一个词在整个语料中越少见,通常越有区分能力;同一个词在一篇文档中重复出现会增加贡献,但增幅逐渐减小;文档长度也会参与修正,避免长文仅凭词多占优势。
因此,制度编号、产品型号、专有名词等具有区分度的词项适合用词法检索查找。BM25 本身不会自动学到“拿到钱”与“支付”的语义关系,这类表达差异需要同义词、查询扩展或其他检索方法补充。
单向量 dense 检索分别将问题和文本块编码为向量,再按点积、余弦相似度或模型指定的距离检索。“多久能收到钱”因此有机会找到“审批通过后 5 个工作日内支付”。
建库时,同一批文本块分别进入词法索引和向量索引,并使用一致的 chunk_id。查询时,两路接收相同的问题及过滤条件,各自返回前若干个候选。两路可能找到同一个块,也可能各自找到独有候选;融合层用 ID 识别它们是否为同一条记录。
怎样合并不同尺度的分数
假设某条结果的 dense 余弦分数是 0.82,BM25 分数是 17.4。直接求和会让 BM25 的数值尺度主导排序,即使代码给两路都乘了 0.5。
RRF(Reciprocal Rank Fusion)绕开原始分数,只利用每路的名次:
RRF(d) = sum(1 / (c + rank_r(d)))
对所有包含候选 d 的检索分支 r 求和名次从 1 开始,没有出现的候选在该路贡献为零。常数 c 越大,相邻名次的差距越小。一个候选在多路都靠前,往往能超过只被一路找到的候选;这也是为什么多个高度相似的检索分支可能放大同一偏好。
例如取 c = 60,某条款在 BM25 排第 2,在 dense 排第 1,总分就是 1/62 + 1/61 ≈ 0.032522。另一条款只在 dense 排第 3,总分为 1/63 ≈ 0.015873。前者获得了两路的贡献,在合并结果中更靠前。
一个可以直接运行的融合示例
给定两路检索排名,代码先在每路按 chunk_id 去重并重新编号,再跨路累计分数。这样,某一路重复返回同一文本块不会多投一票,也不会挤占后续候选的有效名次。
from collections import defaultdict
def rrf(branches, rank_constant=60):
if rank_constant < 0:
raise ValueError("rank_constant 必须非负")
scores = defaultdict(float)
ranks = defaultdict(dict)
for branch, chunk_ids in branches.items():
# dict 保留首次出现顺序;每路先去重,再计算有效名次。
unique_ids = dict.fromkeys(chunk_ids)
for rank, chunk_id in enumerate(unique_ids, start=1):
scores[chunk_id] += 1.0 / (rank_constant + rank)
ranks[chunk_id][branch] = rank
return [
{"chunk_id": chunk_id, "score": scores[chunk_id],
"ranks": ranks[chunk_id]}
for chunk_id in sorted(scores, key=lambda x: (-scores[x], x))
]
branches = {
"bm25": ["submit-2026", "submit-2026", "pay-2026"],
"dense": ["pay-2026", "submit-2026", "faq-overview"],
}
result = rrf(branches)
for hit in result:
print(hit["chunk_id"], f'{hit["score"]:.6f}', hit["ranks"])
assert len(result) == 3
assert result == rrf({
"bm25": ["submit-2026", "pay-2026"],
"dense": branches["dense"],
})输出为:
pay-2026 0.032522 {'bm25': 2, 'dense': 1}
submit-2026 0.032522 {'bm25': 1, 'dense': 2}
faq-overview 0.015873 {'dense': 3}前两条分数相同,示例按 ID 排序来固定输出。这种平局规则没有语义判断能力,不能把排在前面解释成更能回答问题。
融合层可以只处理 ID,再从原记录读取正文。不同版本的文本块应使用不同 ID,避免新旧条款被误合并;保留各路排名,才能检查某条结果为什么被选中。文本近似去重与来源保留是后续证据处理的问题。
RRF 不读取正文,因此无法判断两条款分别谈的是提交期限还是支付期限。上例两条结果同分时,若问题是“审批后多久支付”,可以继续交给精排模型,比较正文与问题的匹配程度。
分词、过滤与融合配置
BM25 的词项必须能正确匹配。TR-2026-03 如果被分成几个常见数字,编号的区分度就可能丢失。中文检索需要查看实际分词结果,确认领域词、连字符、大小写等如何处理。要求编号完全一致的业务,还应把编号存成可精确匹配的字段,不能只依赖全文排序。
Dense 的语义接近也会制造误召回。提交期限与支付期限都属于报销时间,2025 年和 2026 年的制度表达也很相似。两路应使用一致的权限和版本过滤条件:查当前制度时排除不适用版本,比较历史制度时保留所需年份。RRF 只合并已有结果,无法补上遗漏的过滤逻辑。
除 RRF 外,也可以先对两路原始分数做 min-max 或 z-score 归一化,再加权融合。这种方式保留了分数差距,但更依赖分数分布和权重设置,需要用目标语料比较效果;归一化后的值仍然不是相关概率。
候选深度会改变融合结果
每路取多少条、融合后留下多少条,是两个不同预算。假如支付条款在 dense 第 12 位,dense 只取前 10 条时,RRF 无法利用这条证据。即使两路各取 50 条,融合后立即截成 5 条,也可能在精排前丢掉它。候选数量应根据召回率和计算预算调整。
比较 BM25、dense 和两路融合时,需要固定最终候选数与评测问题,观察正确证据是否进入候选集,以及每路贡献了哪些独有证据。RRF 会丢掉原始分数的差距信息,也没有保证融合必然优于单路。如果 BM25 已经能覆盖目标问题,增加 dense 可能只增加成本;若 dense 经常漏掉编号类证据,词法通道就有明确用途。