Skip to main content

混合检索与 RRF 融合

只用向量检索的 RAG,会有一类问题怎么调都调不好:用户问的是精确词——型号、错误码、函数名、人名——系统返回一堆"语义相关但不含这个词"的段落。

不是向量模型不行,是它工作的原理决定了它擅长"意思相近"而不擅长"字面相同"。补上这一半的,是关键词检索。

稠密检索向量相似度稀疏检索BM25 关键词RRF 融合按排名打分候选集50 条重排后5 条进上下文两路各自排序,融合时只看名次不看分数,因此不需要校准两边的量纲score = Σ 1 / (k + rank)k 常取 60
图:混合检索的两路召回经 RRF 融合成一份候选,再交给重排收窄

1. 两路各自解决什么​

1.1 稠密检索:找意思相近的​

把文本压成向量,用余弦相似度找最近的。它能命中"退款"和"把钱退回来"这种字面不同、意思相同的查询——这是纯关键词检索做不到的。

弱点在精确词。一个具体现象:问错误码 E1032,向量检索容易返回讲"错误处理流程"的长段落(语义高度相关),而那条专门解释 E1032 的短条目反而排在后面——它太短,语义信息太少,在向量空间里不够突出。

1.2 稀疏检索:找字面相同的​

BM25 是一套经典的词频加权算法,在 TF-IDF 基础上做了两处关键改进:词频饱和(一个词出现 10 次不比出现 3 次重要 3 倍)和文档长度归一化(长文档不会天然占优)。

它完全不看语义,只看词面匹配。所以它能精确命中 E1032、Q3-2026 财报、OrderController 这类查询,代价是完全处理不了同义改写。

1.2.1 两个可调参数​

BM25 只有两个旋钮,方向比具体数值重要:

  • k1:控制词频饱和的速度,默认 1.2,常用范围 1.2–2.0。调大 = 一个词重复出现带来的增益更晚饱和;语料里关键词重复密集时可以往上调。
  • b:控制长度归一化的强度,默认 0.75,取值范围 0–1。b = 0 完全不做长度归一化,b = 1 完全归一化。

调参方向可以按语料的文档长度分布定:短文档库把 b 调小——文档长度本来就接近,归一化只会压掉有用信号;长文档库把 b 调大——否则长文档凭词数天然占优。

两个参数都只能在自己的语料上用 Recall@K 或 nDCG 调,默认值只是起点。

1.2.2 中文场景先验证分词器​

BM25 的效果完全取决于"词"是怎么切出来的。中文没有空格,这一步的风险比英文大得多:如果分词器把 X42-2024 这类编号切碎、把 OrderController 拆成两半,稀疏那一路基本就废了——而这类编号恰恰是最需要 BM25 的查询。

上线前拿真实查询跑一遍分词,看关键编号、型号、英文标识符有没有被完整切出。切不好就加自定义词典,或者干脆改用字符 n-gram(丢掉语义边界,换取不被分词器坑)。

1.3 为什么是"和"不是"或"​

两路的能力区间几乎不重叠:语义改写归稠密,精确匹配归稀疏;长句提问归稠密,关键词提问归稀疏。真实用户的提问分布里两种都有,所以生产环境基本是两路都跑——这不是过度设计,是覆盖率问题。

2. 融合:为什么不能直接加分​

两路各返回一批结果,每批带自己的分数。想合并成一份排序,第一反应是把分数加权相加。

这个做法有个硬伤:两边的分数不可比。余弦相似度落在 0 到 1 之间且分布集中,BM25 的分数是无上界的、随语料变化的整数或浮点。把它们相加,等于让量纲大的那一方主导结果;换个语料,BM25 的分数分布变了,整个融合权重就得重调。

2.1 RRF:只用名次,不用分数​

Reciprocal Rank Fusion(Cormack 等,SIGIR 2009)绕开这个问题的方式很直接——丢弃分数,只用排名:

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

一篇文档在每一路结果里出现一次,就按它在那一路的名次贡献一次分值,最后累加。

这样做的好处不只是"不需要校准量纲",还有两条实用的:

  • 名次天然可比。 第 1 名就是第 1 名,不管那一路的分数是 0.93 还是 23.7。
  • 排名靠前的差距被放大。 从第 1 掉到第 5 的分数损失,远大于从第 50 掉到第 55。这正好符合直觉:我们关心的是头部候选的顺序。

k 是个平滑常数,常用 60。它的作用是压低"名次极高"带来的权重爆炸——没有 k 的话,第 1 名的贡献是 1,第 2 名直接减半,头部过于陡峭。k 越大,名次之间的差距越平缓。

2.2 一次具体计算​

假设 k = 60,三个文档在两路里的名次如下:

文档稠密检索名次BM25 名次RRF 得分
A141/61 + 1/64 ≈ 0.0320
B2—1/62 ≈ 0.0161
C—11/61 ≈ 0.0164

最终顺序是 A > C > B。注意 C 只在 BM25 里出现、B 只在稠密里出现,两边都还能进最终列表——这正是融合想要的效果:任何一路认为重要的东西都有机会进候选,而两路都命中的排最前。

2.3 用 SQL 直接做​

如果向量库和全文索引都在 Postgres 里(pgvector + tsvector),RRF 可以一次查询做完:

WITH dense AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM chunks
ORDER BY embedding <=> $1
LIMIT 50
),
sparse AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, plainto_tsquery($2)) DESC) AS rank
FROM chunks
WHERE tsv @@ plainto_tsquery($2)
ORDER BY ts_rank_cd(tsv, plainto_tsquery($2)) DESC
LIMIT 50
)
SELECT
COALESCE(d.id, s.id) AS id,
COALESCE(1.0 / (60 + d.rank), 0) + COALESCE(1.0 / (60 + s.rank), 0) AS score
FROM dense d
FULL OUTER JOIN sparse s ON d.id = s.id
ORDER BY score DESC
LIMIT 20;

FULL OUTER JOIN 是关键:只被一路召回的文档也要保留(对应上表里的 B 和 C),用内连接会把它们丢掉。

3. 过滤放在哪一层​

权限、语种、版本、时间范围这类条件,必须放在检索之前,不是检索之后。

原因很直接:如果先取 top-50 再过滤掉 45 条,剩下的 5 条是"在被过滤掉的那些之外排名第 41 到 50 的"——质量完全没有保证。正确做法是把元数据存储为可过滤的列(或向量库的 payload 字段),让过滤发生在候选集生成之前,两边各自过滤,各自取满 50 条。

这也意味着元数据必须在切片入库时就写好,事后补不了(见《切片决定检索上限》里关于元数据的部分)。

4. 要不要加权​

RRF 的好处是不需要校准量纲,代价是丢掉了"这一路有多可信"的信息:两路都命中的排最前,但某一路明显更强时,RRF 看不出来。要保留这个信息,就得回到分数本身做加权。

4.1 加权融合:alpha 凸组合​

把两路的分数各自归一化到同一区间,再做凸组合:

score = alpha * norm(dense_score) + (1 - alpha) * norm(sparse_score)

alpha 从 0.5 起步,往上偏稠密、往下偏稀疏。

前提是必须先各自归一化,这一步不能省。 余弦相似度通常已经在 0 到 1 且分布集中,而 BM25 分数无上界、还随语料规模漂移,直接加权等于让 BM25 主导。常用做法是各自除以本路候选集里的最大值,或者做 min-max 归一化——注意归一化要在候选集内做,跨查询的全局归一化没有意义。

4.2 按查询类型分档调 alpha​

一个固定的 alpha 通常不是最优:错误码、型号、函数名这类查询,稀疏那一路明显更强;口语化的长句恰恰相反。

做法是先给查询分档(是否含编号形态的 token、查询长度、是否命中领域词典),每档配一个 alpha。这比全局调一个值有效得多,代价是要维护分档规则,以及每档都要有足够的评测样本。

有了评测集之后,alpha 可以直接搜:在 0 到 1 之间按 0.1 步长跑一遍,取 nDCG@10 最高的那档。

两条纪律:只在自己的评测集上搜,以及留一份不参与调参的 held-out 集。只在调参集上看结果的话,alpha 会过拟合到那几十条问题上,换一批问题就失效。

4.4 第三条路:学习式稀疏​

加权也好、RRF 也好,都是在"两路各自独立"的前提下做融合。还有第三条路:让模型直接产出稀疏向量。

SPLADE 用一个语言模型给每个 token 预测权重,并做词表扩展(给"退款"补上"退钱""refund"相关的词);BGE-M3 更进一层,在一个模型里同时输出 dense、sparse(词权重)和 multi-vector(ColBERT 式)三种表示。它们产出的仍然是稀疏表示——词表空间、可用倒排索引——但权重是学出来的,不是词频统计出来的,因此能吃掉一部分同义改写,这是 BM25 做不到的。

代价也清楚:多一次模型推理,以及词表扩展带来的索引膨胀。

三者的顺序通常是:先 RRF 跑出基线 → 不够再加权 → 还想要更好再换学习式稀疏。前两步只改几行代码,第三步要动入库管线。

5. 代价与取舍​

多一次检索。 两路的耗时是并行的,所以延迟取决于较慢的一路,不是两者之和。真正的延迟增量来自后面的重排。

需要维护两套索引。 向量索引和全文索引的更新要保持同步,否则会出现"能语义搜到但关键词搜不到"的诡异现象。增量更新必须走同一个事务或同一条流水线。

调参空间变大了。 两路的返回条数、k 值、以及上一节那整个 alpha 体系,都变成了可调项。建议先固定 k = 60、两路各取 50,把基线跑出来再动,不要一上来就调参。

6. 什么时候可以只留一路​

只有一种情况值得砍掉一路:语料极小且查询模式单一。比如几百条 FAQ、用户提问基本都是完整问句,纯稠密检索加上一个好的重排通常就够了。

除此之外,包括"我们的文档都是结构化的技术文档"这种看起来适合关键词检索的场景,也建议保留两路——技术文档里的查询恰恰是错误码、参数名、API 路径这类精确词最多的场景。

融合之后还有一步:候选集通常还有几十条,真正该进上下文的只有几条。这一步由重排完成,两篇合起来看:《RAG 链路与失败模式》讲了它在整条链路里的位置,本文讲的是到它为止的候选怎么生成。