黑白梦黑白梦

  • 文章
  • 专栏
  • 文章
  • 专栏
全部文章

倒数排名融合(RRF):基于名次的多路检索结果合并机制

发布于 2026-09-14约 18 分钟

倒数排名融合(Reciprocal Rank Fusion,RRF)不使用分值,只比较文档在各自结果列表中的名次,因此能合并 BM25 与向量检索这类分值尺度不同的召回结果。本文重新梳理 RRF 的基础认知,并结合此前 Milvus 的混合检索实践巩固相关概念。

核心概念与工作模型

BM25 与向量检索的分值尺度差异

RRF 的典型场景是 BM25 与向量嵌入(embeddings)两路召回的结合。两者的召回能力互补:BM25 依赖关键词的字面匹配,能精确锁定专有名词与术语;向量检索依赖语义相似度,能在缺少字面重合时按含义命中。查询"接口响应超时如何优化"时,向量检索可以命中《下游依赖调用耗时治理实践》这类标题的文档,BM25 则因缺少共同词元而难以命中;查询包含具体错误码 ECONNREFUSED 时,BM25 能精确锁定出现该字符串的文档,向量检索则可能返回其他网络错误相关但不含该错误码的内容。两种能力都值得保留,但两路结果无法直接合并。BM25 的打分原理与参数调节见配套文章 BM25 检索算法:基本原理、中文分词适配与 Elasticsearch 实践。

原因在于分值的尺度差异:

检索路径 分值来源 典型范围 合并时的表现
BM25 词频、逆文档频率与长度归一化项的加权累加 2 至 8 以上 数值较大,主导总分
向量检索 查询向量与文档向量的余弦相似度 0 至 1 数值较小,贡献被稀释

BM25 分值没有固定上界,上限取决于查询词的数量与稀有度;余弦相似度的取值范围为 -1 至 1,在多数嵌入模型下实际输出集中在 0.5 至 1.0 的窄区间内。两者没有公共标度,直接相加或加权求和会让 BM25 的分值量级主导最终排序。合并两路结果需要一种不依赖分值本身的机制。

名次计分与融合公式

RRF 不使用分值的数值,只使用排序位置,也就是文档在某一路结果中排第几位,即名次。给定文档集合 DDD 与 mmm 组排序结果 RRR,文档 ddd 的融合分值为:

RRF(d)=∑r∈R1k+r(d)RRF(d) = \sum_{r \in R} \frac{1}{k + r(d)}RRF(d)=r∈R∑​k+r(d)1​

公式中的变量含义如下:

  • r(d)r(d)r(d):文档 ddd 在排序结果 rrr 中的名次,从 1 开始计数。
  • kkk:平滑常数,默认取 60。
  • 未出现在某个排序结果中的文档:该项贡献为 0,不参与求和。

该式先把各路的原始分值转化为名次,再把名次映射为 1/(k+1)1/(k+1)1/(k+1) 以下的贡献值,相当于完成了一次由名次驱动的归一化。多路贡献相加后,被多路同时召回且名次靠前的文档获得更高分值。

k=60k = 60k=60 是 RRF 的默认取值,主流检索系统也多沿用这一数值。该参数并不敏感,取值在 20 至 100 之间时对排序结果的影响有限。

k 常数的调节作用

kkk 控制单路排名中名次贡献的衰减速度:kkk 越小,1/(k+r)1/(k+r)1/(k+r) 随名次下降越快,靠前名次的相对影响力越强;kkk 越大,曲线越平缓,靠后名次的贡献占比越高。

以下对比两个 kkk 取值下第 1 名与第 10 名的贡献(名次从 0 开始计数):

k 值 第 1 名贡献 第 10 名贡献 贡献比值
10 0.1000 0.0526 1.90
60 0.0167 0.0145 1.15

k=60k = 60k=60 时第 1 名的贡献仅为第 10 名的 1.15 倍,单路内部的名次差异被显著压缩,融合结果更偏向"被多路共同召回"这一信号;k=10k = 10k=10 时比值升至 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)),
  })),
);

按 k=60k = 60k=60 展开各文档的贡献项:

文档 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

推演结果呈现两个行为特征:

  • 单路高分不构成优势:文档 B 的 BM25 分值 3.2 高于文档 C 的 1.8,但 C 同时出现在两路结果中,融合后 C 排在 B 之前。
  • 头部名次差异被压缩:文档 A 与文档 C 的原始分值跨度覆盖了两套度量体系,但融合分值仅相差 0.000256。

名次起点的差异验证

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'

1/(60+index)1/(60 + \text{index})1/(60+index) 与 1/(59+rank)1/(59 + \text{rank})1/(59+rank) 在数值上等价,说明 0 基索引配合 k=60k = 60k=60 相当于把有效常数降为 59。由于同一批结果列表中所有名次都偏移了相同的量,文档之间的相对顺序不受影响,但融合分值会整体偏大,跨实现的参数对齐需要考虑这一偏移。


边界条件与典型差异

分数间距信息的丢失

RRF 用名次替换分值,代价是丢弃了名次之间的分差。向量相似度 0.99 与 0.51 的差距,与 0.60 与 0.55 的差距,在 RRF 中同样表现为第 1 名与第 2 名的差距。

这一取舍在不同场景下影响不同:

  • 分差具备真实区分度时:向量检索中 0.99 与 0.51 往往意味着强相关与弱相关的分野,按名次视为等距会损失信息。
  • 分差不可靠时:当某一路的分值高度聚集,或两路分值分布差异悬殊,名次反而比原始分差更稳定。

两种选择不存在普遍优劣,取决于两路分值是否可比。判断依据在下文结合具体实现给出。

分值的可比范围

RRF 分值存在理论上界:当 mmm 路结果中某文档均排在第 1 位时,其分值为 m/(k+1)m/(k+1)m/(k+1)。该性质带来两点使用约束:

  • 不能作为跨查询的相似度阈值:上界随参与融合的路数与 kkk 取值变化,同一分值在不同配置下代表不同的共识强度。
  • 可作为召回共识强度的度量:分值接近上界说明该文档在多数召回路径中均处于头部位置,适合作为后续精排阶段的输入筛选条件。

平局与排序稳定性

RRF 分值的粒度受 kkk 与结果集规模限制,出现平局的概率高于原始分值排序。例如两路召回中,文档 X 在 A 路排第 2、在 B 路排第 5,与文档 Y 在 A 路排第 5、在 B 路排第 2,融合分值完全相同。

平局处理需要显式的确定性规则。JavaScript 的 Array.prototype.sort 自 ES2019 起为稳定排序,若仅按分值比较,平局顺序会退化为 Map 的键插入顺序,即最先被遍历的结果列表中先出现的文档排在前。这一行为依赖遍历顺序,在多路数量或调用顺序变化时会改变输出。基础实现中的排序键依次为融合分值、召回路数、标识字典序,使结果与输入顺序解耦。

结果集长度与截断

各路召回的返回条数不一致是常态,需要明确两点:

  • 短结果集的劣势:某一路仅返回 3 条时,该路第 3 名之后的文档不产生任何贡献,融合结果会偏向长结果集覆盖的文档。各路的 limit 应对齐到同一量级。
  • 窗口截断:若某一路在进入融合前已被截断,被截断的文档等同于未召回。融合前的召回窗口应大于最终需要的条数,为融合阶段保留足够的候选空间。

结合 Milvus 混合检索实践巩固概念

以上从机制、实现与边界三个层面建立了 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 的接口上都有对应位置,逐项对照如下:

基础概念 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"],
)
  • 权重:取值范围 [0, 1],按子请求声明顺序按位置匹配,数量必须与子请求数量一致。
  • norm_score:控制加权前是否用 arctan 归一化原始分值。
  • 文档:参见 Weighted Ranker 文档。

融合策略的选择依据

两种融合方式在前述几个维度上的差异如下:

维度 加权分数融合 倒数排名融合
输入 归一化后的分值 各路的名次
分数梯度 保留 丢弃
量纲敏感性 依赖归一化函数与分值分布 不敏感
可调参数 各路权重 平滑常数 k

选择规则如下:

  • 分值可比且需要保留梯度时:使用加权分数融合。检索通道的分值经过标定、跨查询分布稳定时,分数梯度承载了相关程度的信息,按名次抹平会损失区分度。
  • 分值不可比或分布漂移时:使用 RRF。当两路分值来自不同模型、不同查询下的分布差异悬殊,或难以获得稳定的标定基准时,名次是更稳定的输入。

Milvus 的 RRFRanker 只暴露 k 一个参数,不支持按路加权。若需要按路加权同时保持名次融合的量纲无关性,可在应用侧实现带权形式:

RRF(d)=∑r∈Rwrk+r(d)RRF(d) = \sum_{r \in R} \frac{w_r}{k + r(d)}RRF(d)=r∈R∑​k+r(d)wr​​

工程实践建议

多路结果的标识对齐

在 Milvus 中,多路检索针对同一集合的同一批主键执行,标识天然一致。当检索路径跨越多个系统时(例如自建 BM25 索引与 Milvus 各返回一份结果),需要额外注意:

  • 同一文档的多条记录:文档可能在一侧按段落切分、另一侧按整篇索引,需要提取可对齐的父级标识,否则融合阶段会被视为独立文档。
  • 标识类型统一:主键可能在一侧以数值类型返回、另一侧以字符串类型返回,Map 按严格相等比较键,1 与 '1' 会被判为不同文档,应在 getId 中统一转换。

与重排序模型的衔接

RRF 的输出是召回阶段的融合排序,后续通常交给重排序模型(Reranker)做精排。两者解决的问题不同:

  • RRF 完成多路结果的名次合并,只依赖名次,不涉及向量运算与模型推理,计算开销主要来自排序,可在大候选集上低成本执行。
  • 重排序模型 基于 Cross-Encoder 架构,把查询与候选文档拼接后逐条送入模型做前向计算,输出该文档相对于查询的相关性分数。打分无法离线预计算,单条成本显著高于 RRF,因此只适合作用在少量候选上。

两者的衔接方式是:RRF 先把多路召回的结果合并并裁剪到可控规模,再交给重排序模型做统一尺度的精确打分。重排还弥补了 RRF 的一处短板——RRF 分值只用于确定顺序,无法作为相关性阈值;重排分数经归一化后具备跨查询可比性,可以支撑阈值卡点与动态截断。输入规范、分数归一化与截断策略见配套文章 重排序模型:从基础应用到动态截断流水线设计。

典型的检索管线分工如下:

  • 多路召回各自返回 top 50 至 100 条候选。
  • 通过 RRF 融合多路结果,按融合分值取 top 20 至 50 条。
  • 交给重排序模型做精排,按分数卡点与截断,输出 top 5 至 10 条。
  • 将精排结果作为上下文交给生成模型。

融合与重排的顺序不应颠倒。先融合可以借助多路共识筛掉单路误召回,缩小重排阶段的输入规模;先重排则需要对每一路的结果分别执行模型推理,成本与召回路径数成正比。

目录
核心概念与工作模型BM25 与向量检索的分值尺度差异名次计分与融合公式k 常数的调节作用核心机制实现与行为验证基础实现两路召回的融合推演名次起点的差异验证边界条件与典型差异分数间距信息的丢失分值的可比范围平局与排序稳定性结果集长度与截断结合 Milvus 混合检索实践巩固概念双路召回的子请求构造倒数排名融合的调用基础概念与 Milvus 实现的对应加权分数融合的对照实现融合策略的选择依据工程实践建议多路结果的标识对齐与重排序模型的衔接

本文收录于专栏

AI 应用开发笔记

沉淀大模型智能体等 AI 应用的开发与落地笔记

0 篇文章更新于 2026-08-23
上一篇BM25 检索算法:基本原理、中文分词适配与 Elasticsearch 实践

©2015-2026 黑白梦 粤ICP备15018165号

联系: heibaimeng@foxmail.com