RRF 倒数排名融合:RAG 里那个看起来土、却一直没被换掉的小公式
Posted on 日 19 4月 2026 in Journal
| Abstract | RRF 倒数排名融合:RAG 里那个看起来土、却一直没被换掉的小公式 |
|---|---|
| Authors | Walter Fan |
| Category | Journal / RAG 方法论 |
| Version | v1.1 |
| Updated | 2026-04-20 |
| License | CC-BY-NC-ND 4.0 |
RRF 倒数排名融合
一、一个尴尬的 RAG 现场
先说个咱们这行都见过的场景。
某天晚上十点,产品群里冒出来一条吐槽:
"搜
OAuth refresh token 过期处理,返回的第一条是《JWT access token 的过期与续期设计》。我要的是 refresh,它给我的是 access,这知识库还能用吗?"
打开日志一看,问题不新鲜:
- 走 BM25(关键词检索):那篇《JWT access token 的过期与续期设计》里 "token" "过期" "续期" 三个词密集出现,TF-IDF 算出来分数贼高,排第一。但它讲的是 access token,不是 refresh token。
- 走 向量检索(embedding):排第一的是一篇讲 OAuth 授权码流程的总览文,语义上跟 "OAuth" 沾亲带故,却几乎没提 refresh token 到底怎么续。
- 两边各自都错得很有道理——BM25 被关键词骗,向量被"大主题"骗——合起来却没人说得清该信哪一边。
这种时候,你会很自然地想问一句:
有没有一个不需要我调一堆权重、也不用再训一个模型的办法,把这两个列表揉到一起?
有的。RRF。
一个长得像数学作业题、却已经默默在 Elasticsearch、Weaviate、LangChain、LlamaIndex 里跑了好几年的算法。
二、RRF 到底在干什么
RRF 全称 Reciprocal Rank Fusion,中文通常译作 "倒数排名融合" 。
先顺手把这个翻译澄清一下,省得有人望文生义。 "倒数"不是"倒着数",是数学意义上的 reciprocal,也就是 (1/x) ——一个数的倒数就是 1 除以它。3 的倒数是 (1/3) ,排名第 5 的倒数就是 (1/5) 。所以 "倒数排名融合" 的意思是 "用排名的倒数作为分数来做融合" ,不是把排名反过来——那是另一码事。英文单词 reciprocal 比中文 "倒数" 清楚,可惜约定俗成就这么译了,咱们记个正确意思就行。
一句话说清楚它干啥:
不管各路检索器给的分数是多少,只看它们把文档排在第几名,然后把每个排名取倒数 (\frac{1}{rank})(再加个小调料 (k))加起来。
公式长这样:
[ score(d) = \sum_{i=1}^{n} \frac{1}{k + rank_i(d)} ]
字母不多,一个个拆:
- ( d ):某一篇文档。
- ( i ):第 ( i ) 个检索器,比如 BM25 一个,向量检索一个。
- ( rank_i(d) ):这篇文档在第 ( i ) 个检索器里的排名,从 1 开始。没出现就当它不在。
- ( k ):一个平滑常数,原论文给的是 60,基本没人动过。
核心就两条直觉:
- 排第 1 的比排第 10 的值钱,但没值钱到十倍。加个 (k) 把曲线压平,不让第一名独吞分数。
- 在多个列表里都露过脸的文档,加起来自然就高。一个检索器说它好是偶然,两个都说它好,大概率是真的好。
就这么点东西。没有训练,没有超参调优,没有 "需要 GPU 才能跑" 的前置条件。
三、手算一遍:看它到底怎么 "投票"
光看公式没感觉,咱算一遍。
假设两个检索器,各返回 Top 3:
| 排名 | BM25 | 向量检索 |
|---|---|---|
| 1 | Doc A | Doc B |
| 2 | Doc B | Doc D |
| 3 | Doc C | Doc A |
取 ( k = 60 ),一个个文档算:
- Doc A:BM25 第 1,向量第 3 → ( \frac{1}{60+1} + \frac{1}{60+3} = 0.01639 + 0.01587 = 0.03226 )
- Doc B:BM25 第 2,向量第 1 → ( \frac{1}{60+2} + \frac{1}{60+1} = 0.01613 + 0.01639 = 0.03252 )
- Doc C:只在 BM25 出现,第 3 → ( \frac{1}{60+3} = 0.01587 )
- Doc D:只在向量出现,第 2 → ( \frac{1}{60+2} = 0.01613 )
融合之后排序:
- Doc B(0.03252)—— 两边都上榜,而且一边是第 1,妥妥的黑马。
- Doc A(0.03226)—— 两边都上榜,差一点点。
- Doc D(0.01613)—— 向量看见了。
- Doc C(0.01587)—— BM25 看见了。
注意看 Doc B 这一行:它在 BM25 里只是老二,但在向量里是老大;反过来 Doc A 是 BM25 老大、向量老三。RRF 给出的结论是—— "两边都认你" 比 "一边最认你" 更稳。这在真实检索里,往往就是对的那条。
用代码跑一下,一屏能放下的量:
from collections import defaultdict
from typing import Dict, Hashable, List, Sequence, Tuple
def rrf_fusion(
ranked_lists: List[Sequence[Hashable]],
k: int = 60,
) -> List[Tuple[Hashable, float]]:
"""Reciprocal Rank Fusion.
Args:
ranked_lists: 多个检索器返回的结果,每个列表按 "相关性从高到低" 排好序。
例如 [[\"docA\", \"docB\"], [\"docB\", \"docC\"]]。
k: 平滑常数,常用 60。
Returns:
按融合分数从高到低的 [(doc_id, score), ...]。
"""
scores: Dict[Hashable, float] = defaultdict(float)
for ranked in ranked_lists:
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] += 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
if __name__ == "__main__":
bm25 = ["docA", "docB", "docC", "docE"]
vector = ["docB", "docD", "docA", "docF"]
title = ["docB", "docA", "docG"]
for doc_id, score in rrf_fusion([bm25, vector, title], k=60):
print(f"{doc_id}: {score:.6f}")
跑出来大致是这样:
docB: 0.048916 # 三个列表都有它,且都排得靠前
docA: 0.048395 # 三个列表也都有,略差一点
docD: 0.016129
docC: 0.015873
docG: 0.015873
docE: 0.015625
docF: 0.015625
docB 稳稳排第一, 不是因为它在任何一路拿到最高分,而是因为三路都认它 。这就是 RRF 核心的那点朴素哲学——多数票比单边冠军更可信。
几条代码层面的小讲究:
defaultdict(float)省事:同一份文档在多个列表里出现时,直接+=累加,省掉get(doc_id, 0.0)那类模板代码。enumerate(..., start=1)不能省:RRF 的 rank 从 1 开始 ,改成 0 会让第一名变成1/k——行为微妙漂移,下游所有人都会被坑。Hashable类型提示:文档 id 既可以是字符串也可以是 tuple(比如(doc_id, chunk_id)),Hashable比写死str灵活。sorted(...)稳定排序:分数相同的文档相对顺序保持不变,debug 时好定位。
一个晚上就能接到线上,不骗你。
四、为什么偏偏是它,一直没被换掉
RAG 这几年卷得厉害,从 BM25 到 ColBERT,从 cross-encoder 到 LLM-as-a-reranker,新花样一茬接一茬。可到融合这一步,大家回头一看,用的还是 RRF。原因其实很朴素。
第一,它不挑分数尺度。
BM25 的分数可能是 18.7,embedding 的余弦相似度是 0.83,有的 reranker 直接吐 logits。你要是直接加权平均,就得先 min-max 归一化、再调权重,再写一堆 "某一路分数异常大时别让它吃掉整场" 的兜底逻辑。RRF 直接一句 "我不看分数,只看排名",这些麻烦全绕开了。
第二,它对噪声抗揍。
加权平均最怕一件事:某条路突然给一篇无关文档打了超高分。一颗老鼠屎能毁一锅汤。RRF 只看排名,哪怕这篇文档分数爆表,它在那一路也就是第 1,多算一个 ( \frac{1}{61} ),翻不了天。
第三,它对 "多路召回" 天然友好。
BM25、向量、多查询扩展(multi-query)、HyDE、元数据过滤……只要你能排出个顺序,都能丢进 RRF 里投票。加一路检索,不用重调权重,只要把 rank list 接进来就行。工程上这个优点,怎么夸都不过分。
第四,它不用训练。
对比一下 Learning to Rank:要标注数据,要训练,要随着数据漂移而重训,还要搞一套离线评估。而 RRF 就是一个函数,确定性输出,没状态。
把这几条摆在一起,你大概就明白为什么老项目不换、新项目照用——它不是最优的,但它是『默认就不会出大错』的那个。
五、那个 k 到底是来干嘛的?—— 为什么不是 1/rank
这是每次讲 RRF 都会被人追问的问题:既然是 "排名的倒数" ,为什么不直接 1/rank 一了百了,非要多塞一个 k 进去?
直接算给你看。假设不加 (k),直接 1/rank:
| 排名 | 1 / rank |
|---|---|
| 1 | 1.000 |
| 2 | 0.500 |
| 3 | 0.333 |
| 10 | 0.100 |
| 100 | 0.010 |
第一名直接比第二名大一倍, 整个融合几乎被"每一路的第一名"主导 。某一路检索器抽风把无关文档排第一,这颗雷立刻就炸到最终结果里。
再看一下 ( k=60 ) 时的曲线:
| 排名 | 1 / (60 + rank) |
|---|---|
| 1 | 0.01639 |
| 2 | 0.01613 |
| 3 | 0.01587 |
| 10 | 0.01429 |
| 100 | 0.00625 |
第一名和第二名的差距从 "翻倍" 压到 "差 1.6%" 。整条曲线被 (k) 按得很平。
这样带来三个显而易见的好处:
- 削弱单路第一名的霸权 。没人能仅凭 "我把它排第一" 一票决定融合结果,必须要其他路也承认。
- 更强调"跨列表重复" 。同一个文档在两路都排第 3,合起来就是 (2 \times 0.01587 = 0.03174) ,直接超过 "只在一路排第一" 的 (0.01639) 。 投两张第三名的票,比一张第一名的票更值钱 ——这正是 RRF 想要的。
- 抗噪 。某一路被关键词骗,把一篇"隔壁主题"的文档送到第 1 名(比如前面 BM25 把 access token 误判成 refresh token),它贡献的那点 (1/61) 很容易被其他路合力压下去。
至于 (k) 为什么非得是 60?原论文跑了一圈,60 在他们的数据集上表现最稳,就这么定下来了。后来大家一用也没啥毛病,就没人动。 这是工程上那种"默认值恰好够好就别动它"的典型案例 。真要动,也先去看数据,别把它当超参数调优的主战场。
六、一张图把它放进 RAG 的全景里
光说 RRF 本身有点抽象。它在 RAG 流水线里大概是这个位置:
@startuml
skinparam defaultFontName "PingFang SC"
skinparam ranksep 25
skinparam nodesep 30
left to right direction
rectangle "用户问题" as Q
rectangle "查询改写 / 多路展开" as MQ
rectangle "BM25 检索" as BM25
rectangle "向量检索" as VEC
rectangle "元数据过滤" as META
rectangle "RRF 融合\n(粗排汇总)" as RRF
rectangle "可选: Reranker\n(cross-encoder / LLM)" as RR
rectangle "上下文拼装" as CTX
rectangle "LLM 生成" as LLM
rectangle "回答" as ANS
Q --> MQ
MQ --> BM25
MQ --> VEC
MQ --> META
BM25 --> RRF : Top N 排名
VEC --> RRF : Top N 排名
META --> RRF : Top N 排名
RRF --> RR
RR --> CTX
CTX --> LLM
LLM --> ANS
@enduml

注意两点:
- RRF 不是 RAG 的 "终审",它是 "粗排汇总"。后面通常还会接一个更贵、更准的 reranker(cross-encoder、LLM rerank),只对前 Top 20~50 做精排。
- RRF 的上游越 "多样"(关键词 + 语义 + 元数据),它的作用越明显。如果你只有一路检索,RRF 等于没用。
七、什么时候该用 RRF,什么时候别用
咱这行有句老话,工具没有好坏,只有合不合适。RRF 也一样。
适合上 RRF 的场景:
- 你已经有两路或更多检索(典型是 BM25 + 向量)。
- 各路检索器的分数尺度不一样、又不想花力气调权重。
- 数据在变、查询在变,你想要一个 "不用频繁重训" 的融合层。
- 项目早期,先把召回稳住,再谈精排。
不建议只靠 RRF 的场景:
- 只有一路检索。 那就没有 "融合" 可言,RRF 等同于 identity。
- 追求极致精度的排序。 比如法律、医学、合规这类,用户只看第 1 条。这时 RRF 只能当粗排,后面必须接 reranker。
- 用户意图高度依赖语义细粒度。 比如 "不是 X 而是 Y" 这种否定/对比查询,BM25 会被关键词骗得很惨,RRF 把它请进来反而拖后腿,得先在单路做清洗。
- 文档数量极小。 几十篇的知识库,怎么排都差不多,上 RRF 是过度工程。
一句话,RRF 是『把多路召回合并得体面』的工具,不是『让答案变准』的银弹。想让答案更准,还是得靠更好的 embedding、更干净的分块、更聪明的 reranker。
八、几个容易踩的坑
真上线过就知道,RRF 公式简单,工程上还是有几块石头。
- rank 从 0 开始还是从 1 开始? 原论文是从 1 开始。从 0 开始会让第一名拿到 ( \frac{1}{k} ),相对第二名的优势被放大,行为会微妙漂移。统一用 1-based,不要混。
- Top N 截断位置。 每一路 Top 取多少,直接决定 "谁有资格投票"。取太小会漏召,取太大又让噪声进场。经验值 50~100 起步,再看线上。
- 重复文档 id。 多路检索经常返回同一份文档的不同 chunk,要先在 chunk 层还是 doc 层融合?通常先在 chunk 层融合,再按 doc 聚合,效果更稳。
- ( k ) 真的不用调吗? 大部分时候不用。真要动,也先去看数据:是不是某一路明显比另一路可信?那该做的是在上游调,不是在 ( k ) 上找补。
- Reranker 之后别再套 RRF。 见过有人在 reranker 之后又来一次 RRF,美其名曰 "再融合一次"。reranker 输出的就是精排序,再 RRF 等于把精排的信息又抹平回去,得不偿失。
九、教学模式:把每一票都打出来给你看
如果你要给团队讲一次 RRF,或者自己想看清楚每个文档是怎么一步步拿到分数的,下面这个版本比上面的生产版更适合——它把每条投票都打出来:
from collections import defaultdict
def rrf_fusion_trace(ranked_lists, k=60):
scores = defaultdict(float)
for list_idx, ranked in enumerate(ranked_lists, start=1):
print(f"--- list {list_idx} ---")
for rank, doc_id in enumerate(ranked, start=1):
contrib = 1.0 / (k + rank)
scores[doc_id] += contrib
print(
f" {doc_id} rank={rank} "
f"contrib={contrib:.6f} total={scores[doc_id]:.6f}"
)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
if __name__ == "__main__":
bm25 = ["A", "B", "C"]
vector = ["B", "D", "A"]
for doc_id, score in rrf_fusion_trace([bm25, vector], k=60):
print(f"{doc_id}: {score:.6f}")
跑出来你会看到类似:
--- list 1 ---
A rank=1 contrib=0.016393 total=0.016393
B rank=2 contrib=0.016129 total=0.016129
C rank=3 contrib=0.015873 total=0.015873
--- list 2 ---
B rank=1 contrib=0.016393 total=0.032522
D rank=2 contrib=0.016129 total=0.016129
A rank=3 contrib=0.015873 total=0.032266
B: 0.032522
A: 0.032266
D: 0.016129
C: 0.015873
一眼看明白两件事:
- B 和 A 都是 "两路都露过脸" 的文档,所以它们的分数几乎翻倍(两条 (1/(k+r)) 加起来),直接把只出现一次的 C 和 D 甩开一个身位。
- B 胜出 A 的差距来自 "谁在那一路排得更靠前" —— B 在第二路是第 1,A 在第二路是第 3,就这点细微差别把 B 顶到冠军。
把这 7 行输出放大给同事看一遍,比你讲半小时 PPT 还管用 。RRF 不是什么黑魔法,它就是一种 "投票 + 讲信用" 的打分方式,越拆越觉得朴素。
十、一句话总结 + 思维导图
RRF 不是最优解,但它是 RAG 里『默认该放的那块拼图』:让多路召回在没人调权重的情况下,体面地合在一起。你想做得更准,是在它前面放更好的检索器,在它后面放更强的 reranker,而不是把它换掉。
@startmindmap
* RRF 倒数排名融合
** 它是什么
*** 多路检索结果融合算法
*** 只看排名 不看分数
*** 公式: sum(1/(k+rank))
*** k 常用 60
** 为什么好用
*** 不挑分数尺度
*** 抗噪声
*** 多路召回天然友好
*** 不用训练 确定性
** 为什么加 k
*** 不加: 1/rank 头部差太大
*** 加 60: 第 1 vs 第 2 差 1.6%
*** 削弱单路第一名霸权
*** 两张第三票 > 一张第一票
** 在 RAG 里的位置
*** 粗排汇总层
*** 上游: BM25 + 向量 + 元数据
*** 下游: reranker + LLM
** 什么时候用
*** 两路或更多检索
*** 分数尺度不一致
*** 项目早期 稳召回
** 什么时候别用
*** 只有一路检索
*** 极致精度场景
*** 否定/对比类查询
*** 知识库极小
** 工程坑
*** rank 从 1 开始
*** Top N 截断位置
*** chunk 层还是 doc 层
*** reranker 后别再 RRF
@endmindmap

十一、扩展阅读
- 原论文:Cormack, Clarke, Büttcher. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR 2009. https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
- Elasticsearch 官方文档里的 RRF 章节:https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html
- LangChain 的 EnsembleRetriever 实现:https://python.langchain.com/docs/integrations/retrievers/ensemble
- 给代码仓库造一个 DeepWiki:Tree-sitter + Embedding + 图谱 + LLM 的方法论
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。