Reciprocal Rank Fusion (RRF) 与混合检索:RAG 系统的高级排序策略

检索增强生成(RAG)已成为现代 LLM 应用的核心,但有个鲜为人知的秘密:检索阶段往往决定了整个系统的成败。如果你的检索器无法获取相关文档,生成模型会带着确信度产生幻觉,无论怎么优化 prompt 都无济于事。

作为拥有生产经验的后端工程师,你可能已经遇到过这个问题。你可能尝试过向量搜索来理解语义,关键词搜索来匹配精确词项,甚至两者结合使用。但将这些检索方法合并成统一、相关的结果集比看起来要难得多。

本文将深入探讨 Reciprocal Rank Fusion (RRF)——这是 Azure AI Search、Elasticsearch 和 OpenSearch 中驱动混合搜索的算法。我们将探讨它的工作原理、如何实现它,以及在何时使用更高级的重排序策略如 cross-encoder 和 ColBERT。

混合检索的挑战

两个世界:密集检索 vs 稀疏检索

在 RAG 系统中,你通常有两种检索范式:

密集检索(向量搜索)

  • 使用 OpenAI 的 text-embedding-3-small 或 sentence-transformers 等模型将文档和查询转换为嵌入
  • 计算余弦相似度、点积或欧几里得距离
  • 擅长理解语义含义和上下文
  • 分数范围:余弦相似度为 0.333-1.00,其他指标为 0-1
  • 弱点:错过精确关键词匹配和罕见词项

稀疏检索(关键词搜索/BM25)

  • 使用传统倒排索引和 TF-IDF 评分
  • BM25 算法:基于词频和文档频率评分
  • 擅长精确关键词匹配和罕见词处理
  • 分数范围:无上界(没有上限)
  • 弱点:无法理解语义相似度

归一化问题

问题变得复杂的地方在于:当你运行包含向量搜索和 BM25 的混合查询时,你会得到两个结果集,它们的评分尺度完全不同:

1
2
3
4
5
6
7
8
9
向量搜索结果:
1. 文档 A: 0.95(余弦相似度)
2. 文档 B: 0.87
3. 文档 C: 0.82

BM25 结果:
1. 文档 B: 15.4
2. 文档 A: 12.1
3. 文档 C: 9.8

如何合并这些?你不能简单相加——尺度完全不同。你可以将分数归一化到 [0,1],但归一化很脆弱:它假设分数遵循可预测的分布,但往往并非如此。

这是 RRF 解决的核心问题:不试图归一化原始分数,而是使用排序——排序结果列表中的位置。

Reciprocal Rank Fusion (RRF) - 核心算法

公式

RRF 出奇地简单:

1
RRF(d) = Σ(r ∈ R) 1 / (k + r(d))

其中:

  • d 是一个文档
  • R 是排序器的集合(检索器)
  • k 是一个常数(通常为 60)
  • r(d) 是文档 d 在排序器 r 中的排名

工作原理

让我们通过两个检索器的示例来演示:

1
2
3
4
5
6
7
8
9
10
11
检索器 1(向量搜索):
1. 文档 A
2. 文档 B
3. 文档 C
4. 文档 D

检索器 2(BM25):
1. 文档 B
2. 文档 A
3. 文档 D
4. 文档 C

当 k=60 时,RRF 如何计算文档 B 的分数:

  • 在检索器 1 中的排名:2 → 1/(60+2) = 0.0161
  • 在检索器 2 中的排名:1 → 1/(60+1) = 0.0164
  • RRF 分数:0.0161 + 0.0164 = 0.0325

对于文档 C:

  • 在检索器 1 中的排名:3 → 1/(60+3) = 0.0159
  • 在检索器 2 中的排名:4 → 1/(60+4) = 0.0156
  • RRF 分数:0.0159 + 0.0156 = 0.0315

文档 B 获胜,因为它在两个列表中的排名都更高(一个第 1,一个第 2),而文档 C 在两个列表中都更低(第 3 和第 4)。

为什么有效

  1. 倒数排序:较高的排名(较小的数字)贡献更多。排名第 1 比排名第 2 更有价值,排名 1 和 2 之间的差异大于排名 100 和 101 之间的差异。

  2. 收益递减:贡献非线性递减。这捕捉了排名 1 和 2 之间的相关性差距可能大于排名 100 和 101 之间的直观理解。

  3. 排序聚合:通过对所有检索器的倒数排名求和,RRF 结合了来自多个来源的证据,使得最终排序更加稳健。

k = 60 的奥秘

为什么大家都使用 k=60?这并非任意选择:

  • 经验性能:研究表明 k=60 在各种数据集和检索任务中表现良好
  • 平衡影响:它为高排名和低排名项目之间提供了良好的平衡:
    • 排名 1:1/(1+60) ≈ 0.0164
    • 排名 10:1/(10+60) ≈ 0.0143
    • 排名 100:1/(100+60) ≈ 0.00625
  • 有效打破平局:有助于打破平局,尤其是对于排名较低的项,其中小的排名差异可能没有意义
  • 鲁棒性:在不同检索系统和数据分布上证明是鲁棒的

虽然 k=60 很常见,但有些系统会针对其特定用例调整此参数。

Python 实现

这是一个干净、生产就绪的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from typing import List, Dict, Tuple
from collections import defaultdict

def calculate_rrf(rankings: List[List[str]], k: int = 60) -> List[Tuple[str, float]]:
"""
从多个排序列表计算 RRF 分数。

Args:
rankings: 排序列表列表(每个列表是一个检索器的结果)
k: RRF 常数(默认为 60)

Returns:
按分数降序排列的(文档,分数)元组列表
"""
# 初始化所有文档的分数
scores: Dict[str, float] = defaultdict(float)

# 计算每个排名中每个文档的倒数排名
for ranking in rankings:
for rank, doc in enumerate(ranking, start=1):
scores[doc] += 1.0 / (k + rank)

# 按分数降序排序
return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# 示例用法
vector_results = ["doc_a", "doc_b", "doc_c", "doc_d"]
bm25_results = ["doc_b", "doc_a", "doc_d", "doc_c"]

rankings = [vector_results, bm25_results]
rrf_scores = calculate_rrf(rankings, k=60)

print("RRF 排名:")
for doc, score in rrf_scores:
print(f"{doc}: {score:.4f}")

# 输出:
# doc_b: 0.0325
# doc_a: 0.0324
# doc_d: 0.0315
# doc_c: 0.0314

加权 RRF - 精细调优检索影响

等权重的问题

标准 RRF 对所有检索器一视同仁。但在生产环境中,你经常需要优先考虑某些检索信号:

考虑餐厅搜索:

  • 查询:”pizza near me” → 位置应该占主导地位
  • 查询:”serve cacio e pepe 的意大利餐厅” → 菜系和菜单匹配应该占主导地位
  • 查询:”高评分的意大利餐厅” → 评分应该占主导地位

加权 RRF 公式

加权 RRF 扩展了标准公式:

1
Weighted_RRF(d) = Σ(r ∈ R) weight_r × 1 / (k + r(d))

每个检索器现在都有一个权重参数,可以影响其贡献。

实际示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def calculate_weighted_rrf(
rankings: List[List[str]],
weights: List[float],
k: int = 60
) -> List[Tuple[str, float]]:
"""
计算加权 RRF 分数。

Args:
rankings: 排序列表列表
weights: 每个排名的权重列表(必须为非负数)
k: RRF 常数

Returns:
按分数降序排列的(文档,分数)元组列表
"""
if len(rankings) != len(weights):
raise ValueError("排名数量必须与权重数量匹配")

scores: Dict[str, float] = defaultdict(float)

for ranking, weight in zip(rankings, weights):
if weight < 0:
raise ValueError("权重必须为非负数")

for rank, doc in enumerate(ranking, start=1):
scores[doc] += weight * (1.0 / (k + rank))

return sorted(scores.items(), key=lambda x: x[1], reverse=True)

# 示例:餐厅搜索
# 查询:"pizza near me" - 优先考虑位置
location_results = ["restaurant_a", "restaurant_b", "restaurant_c"]
cuisine_results = ["restaurant_c", "restaurant_a", "restaurant_b"]
rating_results = ["restaurant_a", "restaurant_c", "restaurant_b"]

# 位置获得 3x 权重,菜系获得 1x,评分获得 0.5x
weighted_scores = calculate_weighted_rrf(
rankings=[location_results, cuisine_results, rating_results],
weights=[3.0, 1.0, 0.5],
k=60
)

何时使用加权 RRF

  • 当你对不同查询类型有明确的信号优先级时
  • 当你需要精细调优检索而不使用自定义评分函数时
  • 当你构建特定领域搜索时(例如法律、医疗、电子商务)

高级重排序策略

RRF 功能强大,但不是唯一的选择。对于生产系统,你通常会结合 RRF 和更复杂的重排序策略。

Cross-Encoder 重排序

Cross-encoder 接受查询-文档对并输出相关性分数。它们本质上是基于相关性判断训练的 transformer 模型:

优点

  • 极高的精确度
  • 可以对复杂的查询-文档交互建模
  • 在许多任务中达到最先进的准确性

缺点

  • 计算昂贵:n 个候选需要 O(n) 次前向传播
  • 不适合大规模检索(用于重排序,而非初始检索)

典型流程

  1. 使用 BM25 或向量搜索检索前 100 个候选
  2. 使用 cross-encoder 对前 20 个候选进行重排序
  3. 返回最终的 top 10

ColBERT(Late Interaction)

ColBERT 是速度和准确性之间的折衷:

  • 分别编码查询和文档(像向量搜索一样)
  • 高效计算 token 级别的相似度分数
  • 使用”文档 token 的最大值”来找到最佳局部匹配

优点

  • 比 cross-encoder 更高效(没有完整的交叉注意力)
  • 比标准向量搜索具有更高的准确性
  • 良好的生产平衡

缺点

  • 仍然比简单的向量搜索更昂贵
  • 需要专门的基础设施

Learning to Rank(LTR)

基于相关性判断训练的机器学习模型:

1
2
3
4
5
6
7
8
9
10
# 伪代码:使用 XGBoost 的 Learning to Rank
import xgboost as xgb

def train_ltr_model(query_doc_pairs, relevance_labels):
"""训练一个 Learning-to-Rank 模型。"""
# 特征:BM25 分数、向量相似度、文档长度、查询长度等
features = extract_features(query_doc_pairs)
model = xgb.XGBRanker(objective='rank:pairwise')
model.fit(features, relevance_labels)
return model

优点

  • 可以结合任意特征
  • 高度可定制以适应你的用例
  • 在大规模生产中已验证

缺点

  • 需要训练数据(相关性判断)
  • 维护更复杂

决策矩阵

场景 推荐策略
生产系统,低延迟 标准 RRF
需要精细控制 加权 RRF
精确度是关键 RRF + Cross-encoder 重排序
准确性和效率的平衡 RRF + ColBERT
有相关性训练数据 RRF + Learning to Rank

生产环境考虑

性能特征

策略 延迟 准确性 复杂度
仅 BM25 ~10ms 中等
仅向量 ~50ms 中等
RRF(BM25 + 向量) ~60ms 中等
RRF + Cross-encoder ~200ms+ 非常高
RRF + ColBERT ~150ms 非常高

缓存策略

重排序缓存:为频繁的查询-文档对缓存 cross-encoder 结果:

1
2
3
4
5
from functools import lru_cache

@lru_cache(maxsize=10000)
def cached_cross_encoder_rerank(query: str, doc: str) -> float:
return cross_encoder_model.predict(query, doc)

嵌入缓存:缓存文档嵌入以避免重新计算。

监控指标

在生产环境中跟踪这些指标:

  • 延迟:检索 + 重排序的 p50、p95、p99
  • 准确性:NDCG@10、MRR、Precision@k
  • 覆盖率:重排序改进结果的查询百分比
  • 缓存命中率:对于嵌入和重排序缓存

常见陷阱

  1. 忽略查询类型:不要对所有查询应用相同的排序策略。位置查询的处理方式与内容查询不同。

  2. 过度调优 k:k=60 的 RRF 在许多用例中是鲁棒的。不要过度拟合到狭窄的数据集。

  3. 忘记冷启动:没有嵌入或 BM25 统计信息的新文档可能会被遗漏。构建适当的索引管道。

  4. 忽视评估:使用离线指标(NDCG)和在线指标(点击率、停留时间)。

实现最佳实践

选择正确的策略

从 RRF 开始。只有在你测量到问题时才增加复杂性:

1
2
3
4
1. 基线:仅 BM25
2. 升级 1:添加向量搜索 + RRF
3. 升级 2:添加加权 RRF(如果需要)
4. 升级 3:添加重排序(如果精确度是关键)

A/B 测试框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 伪代码:A/B 测试排序策略
def ab_test_strategy(query: str, strategy: str):
if strategy == "rrf":
return rrf_rank(query)
elif strategy == "weighted_rrf":
return weighted_rrf_rank(query)
elif strategy == "reranked":
return cross_encoder_rerank(rrf_rank(query))

# 跟踪指标
metrics = {
"rrf": collect_metrics("rrf"),
"weighted_rrf": collect_metrics("weighted_rrf"),
"reranked": collect_metrics("reranked")
}

渐进式发布

  1. 影子模式:与现有策略一起运行新策略,不服务结果
  2. 10% 流量:逐渐转移流量,监控指标
  3. 50% 流量:如果指标稳定,增加流量
  4. 100% 流量:完整发布并监控

衡量改进

NDCG(归一化折损累积增益)

  • 考虑相关性等级和位置
  • 越高越好(0-1)

MRR(平均倒数排名)

  • 关注第一个相关结果的位置
  • 第一个相关结果的排名的倒数

Precision@k

  • top-k 结果中相关的分数
  • 简单且可解释

结论

Reciprocal Rank Fusion 是一个看似简单但解决了一个难题的算法:将具有不同评分尺度的多种检索方法合并成统一、相关的结果集。

关键要点:

  1. RRF 使用排序,而非分数:这完全绕过了归一化问题
  2. 从简单开始:k=60 的 RRF 在许多用例中是鲁棒的
  3. 仅在需要时增加复杂性:加权 RRF、cross-encoder 和 ColBERT 各有其所用
  4. 衡量一切:使用离线指标(NDCG、MRR)和在线指标(点击、停留时间)
  5. 迭代渐进:A/B 测试新策略并渐进式发布

对于生产 RAG 系统,RRF 应该是你的默认选择。为特定领域需求添加加权 RRF,并在精确度关键时考虑 cross-encoder 重排序。

随着检索技术的不断发展,我们看到了越来越复杂 late-interaction 模型和神经重排序器。但 RRF 的基本见解——通过排序聚合结合来自多个来源的证据——仍然与以往一样相关。

进一步阅读