倒数排名融合(Reciprocal Rank Fusion,RRF)不使用分值,只比较文档在各自结果列表中的名次,因此能合并 BM25 与向量检索这类分值尺度不同的召回结果。本文重新梳理 RRF 的基础认知,并结合此前 Milvus 的混合检索实践巩固相关概念。
RRF 的典型场景是 BM25 与向量嵌入(embeddings)两路召回的结合。两者的召回能力互补:BM25 依赖关键词的字面匹配,能精确锁定专有名词与术语;向量检索依赖语义相似度,能在缺少字面重合时按含义命中。查询"接口响应超时如何优化"时,向量检索可以命中《下游依赖调用耗时治理实践》这类标题的文档,BM25 则因缺少共同词元而难以命中;查询包含具体错误码 ECONNREFUSED 时,BM25 能精确锁定出现该字符串的文档,向量检索则可能返回其他网络错误相关但不含该错误码的内容。两种能力都值得保留,但两路结果无法直接合并。BM25 的打分原理与参数调节见配套文章 BM25 检索算法:基本原理、中文分词适配与 Elasticsearch 实践。
原因在于分值的尺度差异:
| 检索路径 | 分值来源 | 典型范围 | 合并时的表现 |
|---|---|---|---|
| BM25 | 词频、逆文档频率与长度归一化项的加权累加 | 2 至 8 以上 | 数值较大,主导总分 |
| 向量检索 | 查询向量与文档向量的余弦相似度 | 0 至 1 | 数值较小,贡献被稀释 |
BM25 分值没有固定上界,上限取决于查询词的数量与稀有度;余弦相似度的取值范围为 -1 至 1,在多数嵌入模型下实际输出集中在 0.5 至 1.0 的窄区间内。两者没有公共标度,直接相加或加权求和会让 BM25 的分值量级主导最终排序。合并两路结果需要一种不依赖分值本身的机制。
RRF 不使用分值的数值,只使用排序位置,也就是文档在某一路结果中排第几位,即名次。给定文档集合 与 组排序结果 ,文档 的融合分值为:
公式中的变量含义如下:
该式先把各路的原始分值转化为名次,再把名次映射为 以下的贡献值,相当于完成了一次由名次驱动的归一化。多路贡献相加后,被多路同时召回且名次靠前的文档获得更高分值。
是 RRF 的默认取值,主流检索系统也多沿用这一数值。该参数并不敏感,取值在 20 至 100 之间时对排序结果的影响有限。
控制单路排名中名次贡献的衰减速度: 越小, 随名次下降越快,靠前名次的相对影响力越强; 越大,曲线越平缓,靠后名次的贡献占比越高。
以下对比两个 取值下第 1 名与第 10 名的贡献(名次从 0 开始计数):
| k 值 | 第 1 名贡献 | 第 10 名贡献 | 贡献比值 |
|---|---|---|---|
| 10 | 0.1000 | 0.0526 | 1.90 |
| 60 | 0.0167 | 0.0145 | 1.15 |
时第 1 名的贡献仅为第 10 名的 1.15 倍,单路内部的名次差异被显著压缩,融合结果更偏向"被多路共同召回"这一信号; 时比值升至 1.90,单路头部名次具备更强的决定权。该取值对最终排序的影响将在后文结合具体实现验证。
定义结果条目类型与融合函数,并预留标识提取与常数配置入口:
// 统一的检索结果条目结构
export type Ranked<T> = {
item: T;
score: number;
};
export const DEFAULT_RRF_K = 60;
export type FusionOptions<T> = {
// 平滑常数,缺省为 60
k?: number;
// 从条目中提取稳定标识,用于跨结果列表对齐同一文档
getId?: (item: T) => string;
};
export function reciprocalRankFusion<T>(
rankings: Ranked<T>[][],
options: FusionOptions<T> = {},
): Ranked<T>[] {
const k = options.k ?? DEFAULT_RRF_K;
const getId = options.getId ?? ((item: T) => String(item));
const scores = new Map<string, number>();
const hitCounts = new Map<string, number>();
const items = new Map<string, T>();
rankings.forEach((ranking) => {
// 每一路先按分值降序确定名次
const ordered = [...ranking].sort(
(a, b) => b.score - a.score,
);
ordered.forEach((entry, index) => {
const id = getId(entry.item);
// 名次从 1 开始计数
const rank = index + 1;
scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank));
hitCounts.set(id, (hitCounts.get(id) ?? 0) + 1);
if (!items.has(id)) {
items.set(id, entry.item);
}
});
});
return [...scores.entries()]
.map(([id, score]) => ({
id,
score,
hitCount: hitCounts.get(id) ?? 0,
}))
.sort(
(a, b) =>
b.score - a.score ||
// 分值相同时,优先被更多路召回的条目
b.hitCount - a.hitCount ||
a.id.localeCompare(b.id),
)
.map(({ id, score }) => ({ item: items.get(id)!, score }));
}
实现中的三个关键处理:
score 降序重新确定名次,避免调用方传入乱序数组导致名次错位。getId 提取稳定标识,跨列表按标识累加贡献,而非按对象引用或数组下标比对。Map 的插入顺序。构造一组双路召回数据(BM25 与向量检索),验证分值与名次的映射过程:
type Doc = { id: string; title: string };
const docs: Record<string, Doc> = {
A: { id: 'A', title: 'Redis 缓存击穿与互斥锁重建策略' },
B: { id: 'B', title: '分布式缓存集群的故障转移' },
C: { id: 'C', title: '高并发场景下的缓存防护实践' },
D: { id: 'D', title: '数据库连接池参数调优' },
};
// 第一路:BM25 关键词检索,分值无上界
const bm25Ranking: Ranked<Doc>[] = [
{ item: docs.A!, score: 4.5 },
{ item: docs.B!, score: 3.2 },
{ item: docs.C!, score: 1.8 },
];
// 第二路:向量检索,分值为余弦相似度
const vectorRanking: Ranked<Doc>[] = [
{ item: docs.C!, score: 0.91 },
{ item: docs.A!, score: 0.84 },
{ item: docs.D!, score: 0.62 },
];
const fused = reciprocalRankFusion(
[bm25Ranking, vectorRanking],
{
getId: (doc: Doc) => doc.id,
},
);
console.table(
fused.map((entry) => ({
文档: entry.item.title,
RRF分值: Number(entry.score.toFixed(6)),
})),
);
按 展开各文档的贡献项:
| 文档 | BM25 名次 | 向量名次 | 贡献项 | RRF 分值 |
|---|---|---|---|---|
| A | 1 | 2 | 1/61 + 1/62 | 0.032522 |
| C | 3 | 1 | 1/63 + 1/61 | 0.032266 |
| B | 2 | 未召回 | 1/62 | 0.016129 |
| D | 未召回 | 3 | 1/63 | 0.015873 |
推演结果呈现两个行为特征:
RRF 的名次从 1 开始计数,而多数编程语言的自然迭代索引从 0 开始。两种写法对应的贡献值为:
// 索引从 0 开始计数
const atZeroBased = (index: number) => 1 / (60 + index);
// 名次从 1 开始计数
const atOneBased = (rank: number) => 1 / (60 + rank);
console.log(atZeroBased(0).toFixed(6)); // '0.016667'
console.log(atOneBased(1).toFixed(6)); // '0.016393'
// 等价于 1 基写法 k = 59、rank = 60
console.log(atZeroBased(59).toFixed(6)); // '0.008403'
与 在数值上等价,说明 0 基索引配合 相当于把有效常数降为 59。由于同一批结果列表中所有名次都偏移了相同的量,文档之间的相对顺序不受影响,但融合分值会整体偏大,跨实现的参数对齐需要考虑这一偏移。
RRF 用名次替换分值,代价是丢弃了名次之间的分差。向量相似度 0.99 与 0.51 的差距,与 0.60 与 0.55 的差距,在 RRF 中同样表现为第 1 名与第 2 名的差距。
这一取舍在不同场景下影响不同:
两种选择不存在普遍优劣,取决于两路分值是否可比。判断依据在下文结合具体实现给出。
RRF 分值存在理论上界:当 路结果中某文档均排在第 1 位时,其分值为 。该性质带来两点使用约束:
RRF 分值的粒度受 与结果集规模限制,出现平局的概率高于原始分值排序。例如两路召回中,文档 X 在 A 路排第 2、在 B 路排第 5,与文档 Y 在 A 路排第 5、在 B 路排第 2,融合分值完全相同。
平局处理需要显式的确定性规则。JavaScript 的 Array.prototype.sort 自 ES2019 起为稳定排序,若仅按分值比较,平局顺序会退化为 Map 的键插入顺序,即最先被遍历的结果列表中先出现的文档排在前。这一行为依赖遍历顺序,在多路数量或调用顺序变化时会改变输出。基础实现中的排序键依次为融合分值、召回路数、标识字典序,使结果与输入顺序解耦。
各路召回的返回条数不一致是常态,需要明确两点:
limit 应对齐到同一量级。以上从机制、实现与边界三个层面建立了 RRF 的基础认知。以下结合此前的实战 向量度量基础与混合检索开发实战 ,用 Milvus 的 PyMilvus SDK 逐项验证和巩固这些概念在混合检索中的落地方式。稠密与稀疏向量的度量差异、特征生成与建表流程。
Milvus 通过 AnnSearchRequest 分别声明稠密与稀疏两路子请求,再由 hybrid_search 统一执行并在服务端完成融合。子请求的 limit 决定各路进入融合的候选窗口。稀疏通道的分值来自内积度量,其尺度特征与前述 BM25 类稀疏表示一致:无固定上界,且随文本长度与分词器设计变化。
构造两路子请求:
from pymilvus import (
AnnSearchRequest,
MilvusClient,
RRFRanker,
WeightedRanker,
)
milvus_client = MilvusClient(uri="./milvus_test.db")
collection_name = "travel_hybrid_docs"
# 查询文本对应的稠密与稀疏特征
query_dense = [0.1] * 1024
query_sparse = {101: 0.85, 202: 0.65}
# 稠密通道:余弦度量
req_dense = AnnSearchRequest(
data=[query_dense],
anns_field="dense_vector",
param={"metric_type": "COSINE"},
limit=20,
)
# 稀疏通道:内积度量
req_sparse = AnnSearchRequest(
data=[query_sparse],
anns_field="sparse_vector",
param={"metric_type": "IP"},
limit=20,
)
指定 RRFRanker 执行融合检索:
results = milvus_client.hybrid_search(
collection_name=collection_name,
reqs=[req_dense, req_sparse],
ranker=RRFRanker(k=60), # 平滑常数 k,默认 60
limit=5,
output_fields=["text_content"],
)
关键参数说明:
k:平滑常数,默认 60,允许范围 (0, 16384),推荐工作区间 [10, 100]。完整说明见 RRF Ranker 文档。limit:各路进入融合的候选条数,应大于最终需要的条数,为融合阶段保留候选空间。limit:融合后返回的条数,可以小于子请求的 limit。前述各节的概念在 Milvus 的接口上都有对应位置,逐项对照如下:
| 基础概念 | Milvus 中的对应 |
|---|---|
| 名次从 1 开始计数 | 文档示例按 1/(60+1) + 1/(60+2) 计算 |
| 平滑常数 k 控制头部名次的影响力 | RRFRanker(k=...),默认 60,推荐工作区间 [10, 100] |
| 各路结果集长度影响融合覆盖面 | 子请求的 limit,决定各路进入融合的条数 |
| 融合分值不可作为相似度阈值 | RRF 分值仅用于确定顺序,不存在对应的阈值参数 |
| 分数梯度在名次融合中丢失 | 需要保留梯度时改用 WeightedRanker |
其中名次起点一项可以直接验证:Milvus 文档给出的示例分值为 1/(60+1) + 1/(60+2) = 0.03252247,与"两路召回的融合推演"中文档 A 的结果一致,说明两处实现遵循相同的计数约定。k 的取值行为同样对应:默认 60 与通用取值一致,推荐区间 [10, 100] 覆盖了该参数不敏感的范围。
将 ranker 替换为 WeightedRanker 即可切换到分数融合:
results = milvus_client.hybrid_search(
collection_name=collection_name,
reqs=[req_dense, req_sparse],
ranker=WeightedRanker(0.7, 0.3, norm_score=True),
limit=5,
output_fields=["text_content"],
)
norm_score:控制加权前是否用 arctan 归一化原始分值。两种融合方式在前述几个维度上的差异如下:
| 维度 | 加权分数融合 | 倒数排名融合 |
|---|---|---|
| 输入 | 归一化后的分值 | 各路的名次 |
| 分数梯度 | 保留 | 丢弃 |
| 量纲敏感性 | 依赖归一化函数与分值分布 | 不敏感 |
| 可调参数 | 各路权重 | 平滑常数 k |
选择规则如下:
Milvus 的 RRFRanker 只暴露 k 一个参数,不支持按路加权。若需要按路加权同时保持名次融合的量纲无关性,可在应用侧实现带权形式:
在 Milvus 中,多路检索针对同一集合的同一批主键执行,标识天然一致。当检索路径跨越多个系统时(例如自建 BM25 索引与 Milvus 各返回一份结果),需要额外注意:
Map 按严格相等比较键,1 与 '1' 会被判为不同文档,应在 getId 中统一转换。RRF 的输出是召回阶段的融合排序,后续通常交给重排序模型(Reranker)做精排。两者解决的问题不同:
两者的衔接方式是:RRF 先把多路召回的结果合并并裁剪到可控规模,再交给重排序模型做统一尺度的精确打分。重排还弥补了 RRF 的一处短板——RRF 分值只用于确定顺序,无法作为相关性阈值;重排分数经归一化后具备跨查询可比性,可以支撑阈值卡点与动态截断。输入规范、分数归一化与截断策略见配套文章 重排序模型:从基础应用到动态截断流水线设计。
典型的检索管线分工如下:
融合与重排的顺序不应颠倒。先融合可以借助多路共识筛掉单路误召回,缩小重排阶段的输入规模;先重排则需要对每一路的结果分别执行模型推理,成本与召回路径数成正比。