Skip to content
Charles Shao
Go back

多路召回融合 RRF:只看排名不看分数,一招破解「各路分数没法比」

views

前四篇讲了召回全景、个性化主力 ItemCF、兜底的热门召回和工业主力双塔召回。这一篇讲把它们合并成一份最终候选的那道工序——多路召回融合(Multi-Route Recall Fusion)。延续「录像带出租店」的比喻,这次的主角是店里的「领班」:他不亲自挑片,只负责把好几个挑片员各自交上来的名单合并、排序,产出一份最终候选。

Table of contents

Open Table of contents

一句话理解

多路召回融合 = 录像带店的「领班」。

店里有好几个挑片员——老挑片员(ItemCF)懂你的口味,热门榜小哥(Popular)管大众爆款,还有看气质的(双塔)、看标签的(内容召回)……每人独立挑出一份候选名单

但他们各挑各的,名单会重复、会冲突、打分标准还各不相同。于是需要一个领班:把所有名单收上来,按一套统一的规则合并、排序,产出一份最终候选,再交给后面的老板(排序模型)。

领班自己从不挑片——他只做「合并裁决」。而 recsys-mini 用的裁决规则叫 RRF:只看每个挑片员把你排第几名,不看他打了多少分。


Level 1:为什么要好几个挑片员 — 单路召回的盲区

为什么不能只靠老挑片员一个人?因为每个挑片员都有自己的盲区,谁都没法独当一面:

你的真实需求单靠老挑片员(ItemCF)会怎样谁能补上
我是新客,没租过任何片没历史可查 → 交白卷热门榜小哥(兜底)
给我来点这个月最火的个性化召回可能漏掉新晋爆款热门榜小哥
这部刚上架的冷门片我想看还没人租过它 → 算不出相似度内容召回(看标题/简介)
我关注的导演出新片了ItemCF 不看「关注关系」社交/关注召回

核心洞察:没有任何单一召回路能覆盖所有场景。每路召回都是一个「偏科生」——擅长某类需求,对其它需求束手无策。所以工业界的做法是 「多请几个偏科生,各管一摊,再把他们的答案合并」

这就引出两个问题,正是领班要解决的:

  1. 怎么把多份名单合并?(Level 2)
  2. 各路打分标准不一样,凭什么合并?(Level 3–4)

召回家族一共有 8 路(口碑老媒人、热门兜底、标签速配……),详见召回全景。recsys-mini 目前上线了其中 2 路:ItemCF + Popular。


Level 2:领班的工作流程 — 三步走

领班拿到一个用户后,干的事可以拆成清清楚楚的三步:

领班三步工作流:用户 user_id 分别流向 ItemCF 和 Popular 两路各召回 top-200,汇入 RRF 融合(按排名打分+加权),再排序取 top-k_final,输出最终候选(含 item、融合分、各路明细)

领班不生产候选,只搬运和裁决:收名单 → RRF 融合 → 排序截断。所有「挑片」的脏活都在各路召回器自己的 recall() 里干完了。

干啥对应代码
① 各路独立召回让每个挑片员各交一份 top-k_each 名单per_source[name] = r.recall(user_id, k_each)
② 融合打分用 RRF 把所有名单按「排名」折算成统一分数,并加权累加fused[item_id] += w / (rrf_k + rank)
③ 排序截断按融合分从高到低排,取前 k_finalsorted(...)[:k_final]

注意两个参数的区别:


Level 3:融合的真正难题 — 各路分数没法比

合并名单,最朴素的想法是:把每个物品在各路的分数加起来,谁分高谁靠前。听起来很合理,但有个致命问题——各路的分数根本不是一个量纲。

看一眼各路的「打分尺度」有多乱

挑片员给《好家伙》打的分它的原始分数范围
老挑片员(ItemCF)1.50(亲密度累加)[0, ∞) 上不封顶
热门榜小哥(Popular)0.85(归一化热度)[0, 1]
资深选片员(双塔)0.70(向量余弦)[-1, 1]
标签整理员(内容召回)4.20(标签匹配 5 分制)[0, 5]

四个人都觉得《好家伙》不错,但 1.50、0.85、0.70、4.20 这四个数压根没法比较——单位都不一样。

直接相加会发生什么

电影        ItemCF   热门    选片员   标签员    直接相加
─────────────────────────────────────────────────────
《好家伙》  1.50  +  0.85  +  0.70  +  4.20  =  7.25
《老炮儿》  0.00  +  0.95  +  0.90  +  5.00  =  6.85
《阿凡达》  0.00  +  1.00  +  0.30  +  2.00  =  3.30

                  标签整理员的分数尺度最大(0~5) → 他一个人就把结果带跑了

问题暴露:标签整理员的分数天然比别人大 3–5 倍,融合时他一个人说了算,其他挑片员的意见全被淹没。

归一化能救吗?只能缓解,还剩两个坑

很自然会想:那让每路都除以自己的最高分,把分数压到 [0,1] 不就行了? 这确实解决了上面「标签员 0–5 尺度过大」的量纲问题——大家现在最高分都是 1.0。但「都在 [0,1]」≠「能公平比较」,还剩两个隐患。

隐患一:假设各路分数分布相似

归一化只拉平了最大值,没拉平分数的分布形状。如果两路分布不同,归一化后同样的数字含金量也不同:

A 路(分数很"陡")          B 路(分数很"平")
  第1名  1.00               第1名  1.00
  第2名  0.95 ← 紧咬第一     第2名  0.98
  第3名  0.30 ← 突然断崖     第3名  0.96

同是归一化后的高分,含金量天差地别。直接相加就默认了「两路的 0.95 代表同样的好」——而 ItemCF 的分布通常很陡、双塔余弦往往很平,根本不相似

隐患二:对分数的绝对大小敏感(怕离群值)

归一化是线性缩放,一个畸高的离群分(刷量 / bug)会污染整条尺度

归一化前:           除以最高分(1000)后:
  怪物片  1000        怪物片  1.00
  好片A    50         好片A   0.05  ← 本来 50/48/45 区分明显
  好片B    48         好片B   0.048    现在全挤在 0.05,几乎没法区分!
  好片C    45         好片C   0.045

一个 1000 的怪物当了分母,好片 A/B/C 的区分度就被压扁了,这一路基本「废了」。

两个坑的共性:归一化对齐了最大值,却对齐不了分布、也挡不住离群值。有没有办法彻底绕开「分数」这个麻烦?有——那就是只看排名(Level 4 的 RRF)。排名是天然对齐的:A 的第 2 名 = B 的第 2 名;怪物片再畸高也只是「第 1 名」,对其它名次毫无影响。


Level 4:RRF 登场 — 只看排名,不看分数

RRF(Reciprocal Rank Fusion,倒数排名融合) 的思想一句话就能说清:

不管你给某个物品打了多少分,我只看你把它排第几名。

排名是天然统一的量纲——不管 ItemCF 的分是 1.50 还是 150,只要它把《好家伙》排第 1,那就是「第 1 名」,和热门榜小哥的「第 1 名」完全可比。

RRF 公式

score(i) = Σ   w_s · 1/(rrf_k + rank_s(i))
          每一路 s
符号含义
rank_s(i)物品 i 在第 s 路里的排名(第 1 名 rank=1,第 2 名 rank=2…)
rrf_k平滑常数,recsys-mini 默认 60(业界经验值,见下)
w_ss 路的权重(Level 5 讲)

每个物品的最终分 = 它在各路的「排名得分」加权求和。排得越靠前,1/(60+rank) 越大;被越多路同时召回,加的笔数越多。

排名怎么折算成分数(rrf_k=60)

排名 rank1/(60+rank)直觉
第 1 名1/61 ≈ 0.01639最高
第 2 名1/62 ≈ 0.01613和第 1 名几乎一样
第 10 名1/70 ≈ 0.01429
第 200 名1/260 ≈ 0.00385仍有微弱贡献

rrf_k=60 这个「60」是干嘛的

它是个平滑常数,作用是压平头部排名之间的差距

不加 rrf_k(即 1/rank):     加了 rrf_k=60(即 1/(60+rank)):
  第 1 名 = 1.000              第 1 名 = 0.01639
  第 2 名 = 0.500  ← 差一倍!   第 2 名 = 0.01613  ← 几乎相等
  第 3 名 = 0.333              第 3 名 = 0.01587

rrf_k=60 是 RRF 原论文(Cormack et al., SIGIR 2009)的经验值,绝大多数场景直接用 60 即可,几乎不用调。

用 RRF 重新融合 Level 3 的例子(完整手算)

还记得 Level 3 那张「直接相加被标签员带跑」的表吗?现在我们把分数换成排名,重算一遍(先假设四路权重都相等 w=1,把「加权」留给 Level 5)。

第 1 步:把各路的「分数」翻译成「排名」。 各路按自己的分数从高到低排,得到每部片在每一路的名次( 表示该路没召回它):

物品ItemCF热门选片员标签员
《好家伙》rank 1rank 3rank 2rank 2
《老炮儿》rank 2rank 1rank 1
《阿凡达》rank 1rank 3rank 3

注意:ItemCF 只召回了《好家伙》一部(另两部它给 0 分 = 没召回)。

第 2 步:每个名次套公式 1/(60+rank),横着加起来。

物品ItemCF热门选片员标签员RRF 总分
《好家伙》1/61=.016391/63=.015871/62=.016131/62=.016130.06452
《老炮儿》1/62=.016131/61=.016391/61=.016390.04892
《阿凡达》1/61=.016391/63=.015871/63=.015870.04814

第 3 步:对比两种融合的结果。

物品直接相加(Level 3)RRF
《好家伙》7.25(但仅微弱领先)0.06452(明显领先)
《老炮儿》6.85(靠标签员的 5.0 紧追)0.04892
《阿凡达》3.300.04814

关键变化

这就是 RRF 的四大好处:免疫量纲(不管你打 4.2 还是 5000)、免疫离群分数(一个超大分带不跑结果)、实现简单(几行代码)、还不用调参rrf_k=60 通用)。


Level 5:加权 RRF — 给资深挑片员更大话语权

光有 RRF 还不够。店里的挑片员水平有高低:老挑片员(ItemCF)个性化强、最懂你,应该话语权大;热门榜小哥(Popular)只会推大众爆款,话语权该小一点。

于是给每一路乘一个权重 w_s

最终分(i) = 1.0 × [ItemCF 里的排名得分]  +  0.3 × [Popular 里的排名得分]
            └── 权重高,主力 ──┘            └── 权重低,兜底 ──┘

recsys-mini 的权重配在 conf/config.yaml

recall:
  weights:
    itemcf: 1.0      # 个性化主力,话语权最大
    popular: 0.3     # 兜底补位,话语权小conf/config.yaml
权重设置效果
popular 权重高(如 1.0)候选里热门占比大 → 个性化被稀释 → 体验「千人一面」
popular 权重低(如 0.3)热门只在「个性化不足时」悄悄补位 → 既兜了底又不抢戏

0.3 就是「补位但不抢戏」的平衡点。关于这个权重为什么是 0.3,以及 1 + 0.3 > 1 的融合增益实测,详见热门召回


Level 6:recsys-mini 代码逐行走读

multi_recall.py 全文只有 44 行。我们逐块拆。

6.1 构造函数 — 收下「挑片员名册」和「权重表」

class MultiRecaller:
    def __init__(self, recallers: Dict[str, BaseRecaller], weights: Dict[str, float]):
        self.recallers = recallers
        self.weights = weightssrc/recall/multi_recall.py
参数类型装的是举例
recallers{路名: 召回器}所有要融合的召回路{"itemcf": ItemCFRecaller(...), "popular": PopularRecaller(...)}
weights{路名: 权重}每路的话语权{"itemcf": 1.0, "popular": 0.3}

两个字典的 key(路名)必须对得上——weights["itemcf"] 要能找到对应召回器的权重。所有召回器都遵守同一个 BaseRecaller 接口(都实现 fit + recall(user_id, k)),所以领班根本不关心每路内部怎么挑片,只管调用统一的 recall()。这就是「面向接口」的好处:新增一路召回,领班代码一行都不用改

6.2 recall — 三步融合(核心)

    def recall(
        self, user_id: int, k_each: int, k_final: int, rrf_k: int = 60
    ) -> List[Tuple[int, float, Dict[str, float]]]:
        """返回 [(item_id, fused_score, source_scores), ...]"""
        per_source: Dict[str, List[Tuple[int, float]]] = {}
        for name, r in self.recallers.items():
            per_source[name] = r.recall(user_id, k_each)

        # RRF 融合: score(i) = sum_s weight_s * 1/(rrf_k + rank_s(i))
        fused: Dict[int, float] = defaultdict(float)
        sources: Dict[int, Dict[str, float]] = defaultdict(dict)
        for name, items in per_source.items():
            w = self.weights.get(name, 1.0)
            for rank, (item_id, score) in enumerate(items, start=1):
                fused[item_id] += w / (rrf_k + rank)
                sources[item_id][name] = score

        out = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:k_final]
        return [(int(i), float(s), sources[i]) for i, s in out]src/recall/multi_recall.py

三步一一对应:

Step ① 各路独立召回——遍历每个挑片员,各拿回一份 top-k_each 名单,按路名存进 per_source

per_source = {
    "itemcf":  [(《美国往事》, 1.0), (《华尔街之狼》, 0.81), ...],  # 200 个
    "popular": [(《阿凡达》, 1.0),   (《教父》, 0.857), ...],       # 200 个
}

Step ② RRF 融合——每路取权重、按排名累加:

细节解释
self.weights.get(name, 1.0)没在 weights 里配的路,默认权重 1.0
enumerate(items, start=1)rank1 起算(第 1 名 rank=1),契合 RRF 公式
fused[item_id] += ...同一物品被多路召回 → 多次累加 → 自然冒头(群众投票)
sources[item_id][name] = score记录「这物品被哪些路召回、各打了多少原始分」——纯为可解释/可调试,不参与 fused 计算

划重点score(各路原始分)只被存进 sources 做明细,完全不参与融合。融合只用 rank。这正是 Level 4 说的——RRF 彻底绕开了「分数没法比」的难题。

Step ③ 排序 + 截断 + 返回——按融合分降序、取前 k_final 个,拼成 (item_id, 融合分, 各路明细) 三元组返回。

一个具体例子(手算一遍 RRF)

假设领班拿到两路名单(rrf_k=60,权重 itemcf=1.0popular=0.3):

ItemCF 的名单(top-3):       Popular 的名单(top-3):
  rank 1  《阿凡达》             rank 1  《泰坦尼克号》
  rank 2  《好家伙》             rank 2  《阿凡达》
  rank 3  《教父》               rank 3  《盗梦空间》

注意:《阿凡达》两路都召回了(ItemCF 第 1、Popular 第 2),其余物品只被一路召回。

先跑 ItemCF 这一路(w=1.0):

物品计算累加后 fused
《阿凡达》1.0 / (60+1) = 0.016390.01639
《好家伙》1.0 / (60+2) = 0.016130.01613
《教父》1.0 / (60+3) = 0.015870.01587

再跑 Popular 这一路(w=0.3):

物品计算累加后 fused
《泰坦尼克号》0.3 / (60+1) = 0.004920.00492
《阿凡达》0.3 / (60+2) = 0.004840.01639 + 0.00484 = 0.02123 ← 第二次累加!
《盗梦空间》0.3 / (60+3) = 0.004760.00476

排序后的最终榜:

排名物品融合分被哪几路召回
1《阿凡达》0.02123itemcf + popular(两路都中
2《好家伙》0.01613itemcf
3《教父》0.01587itemcf
4《泰坦尼克号》0.00492popular
5《盗梦空间》0.00476popular

关键观察:《阿凡达》在 ItemCF 里只排第 1、在 Popular 里才排第 2,但因为被两路同时召回、分数累加了两次,最终融合分 (0.02123) 明显高过只被单路召回的《好家伙》(0.01613)。这就是 RRF 的「群众投票」——被越多路看好,越容易冒头,哪怕在单路里不是第一名。

另外注意:Popular 权重只有 0.3,所以它单独召回的《泰坦尼克号》《盗梦空间》分都很低,排在 ItemCF 召回的物品后面——这正是「兜底但不抢戏」在数字上的体现。

6.3 recall_batch — 批量给一群用户融合

    def recall_batch(
        self, user_ids, k_each: int, k_final: int
    ) -> Dict[int, List[Tuple[int, float, Dict[str, float]]]]:
        return {u: self.recall(u, k_each, k_final) for u in user_ids}src/recall/multi_recall.py

就是对一批用户循环调用 recall,返回 {user_id: 融合结果}。主要给离线评估用——一次性给所有测试用户算融合候选,再算 Recall@K / Coverage

6.4 k_each vs k_final 别搞混

这俩职责完全不同:

k_eachk_final
管谁每一路召回的数量(输入/原料)融合后保留的数量(输出/成品)
调大会怎样每路捞得更深,候选池更丰富,但融合更慢交给排序的候选更多,排序更慢

通常 k_eachk_final:两路各召回 200,合并去重后可能多达近 400 个,最后靠 k_final 砍回 200。生产环境常见 k_eachk_final(如每路召回 300、最终留 200),让融合有更多候选可挑。

评估时 k_eachk_final 都取 topk = max([10, 50, 200]) = 200——召回最大的 200 个,前 10、前 50 都能从这 200 里截出来,一次召回三个 K 全覆盖


Level 7:三种融合策略对比

RRF 不是唯一的融合方式。领班可以选三种裁决规则,由简到繁:

策略怎么融合优点缺点recsys-mini
加权和(Weighted Sum)各路分数归一化后加权相加直觉简单要求各路分数可比(量纲坑,见 Level 3);对离群分敏感未用
RRF(倒数排名融合)只看排名,Σ w/(60+rank)免疫量纲、免疫离群分、零调参、实现极简丢掉了「分数大小」里的信息(只保留序)当前用
学习式融合(Learning to Fuse)把各路分数/排名当特征,训一个小模型(如 LR/GBDT)输出融合分上限最高,能学到各路的最优组合要额外训练 + 样本;要防过拟合路线图

为什么 recsys-mini 从 RRF 起步:它零调参、不怕量纲、几行代码,是多路融合性价比最高的默认选择。等召回路变多、且有了点击日志后,再升级到学习式融合去突破 RRF 的上限。


Level 8:实测效果 + 在系统里的位置

实测对比(MovieLens-1M / LOO 切分)

召回方法Recall@10Recall@50Recall@200Coverage@200
ItemCF 单路5.29%20.08%50.15%69.89%
Popular 单路4.74%15.48%36.97%22.29%
RRF 多路融合6.08%19.50%50.20%69.36%

怎么读

在整个推荐系统里的位置

推荐系统全链路:全店 1 万部片经多路召回(ItemCF/Popular/双塔)各粗筛 200 部,领班 MultiRecaller 用 RRF 合并去重出 200~500 候选,老板排序模型精排到 Top-10,老板娘重排(去重/多样性)后把最终 5 部摆到你眼前

领班卡在「多路召回」和「排序」之间,是召回阶段的最后一棒。融合质量直接决定交给排序的候选池上限。

角色推荐系统术语干啥
各路挑片员多路召回(ItemCF / Popular / 双塔…)各自粗筛候选
领班多路召回融合(本文)把多份名单合并成一份
老板排序(Ranking)给候选精确打分
老板娘重排(Re-ranking)最终拍板、加多样性

⚠️ 一个隐藏铁律(SSB):每次新增/改动召回路(哪怕只是调权重),候选集的分布就变了,排序模型必须重新训练——否则排序还在用「旧候选分布」打分,质量会暴跌。详见召回全景里的 SSB 一节。


TL;DR — 三句话

  1. 多路召回融合 = 录像带店的领班。 他不挑片,只把好几个挑片员(ItemCF、Popular…)各自的名单合并成一份最终候选。44 行代码全实现了。

  2. 融合的核心难题是「各路分数没法比」,RRF 用「只看排名」破解。 score(i) = Σ wₛ/(60+rankₛ)——不管你打多少分,只看排第几。免疫量纲、免疫离群分、零调参、几行代码。再用权重(ItemCF 1.0 / Popular 0.3)给资深挑片员更大话语权。

  3. 融合是召回的最后一棒,质量决定候选池上限。 实测 RRF 把 Recall@10 从 5.29% 提到 6.08%(1+0.3>1)。比 RRF 更强的是学习式融合(路线图)。切记 SSB 铁律:召回一改,排序必重训。


召回阶段到此收尾。下一篇进入漏斗的第二段——排序:领班交上来的几百个候选,老板(排序模型)怎么给每一个精确打分、排出最终的 Top-N。


views
Share this post on:

Previous Post
推荐排序精排:账本只记了「喜欢」,最反直觉的坑是负样本得自己造
Next Post
创意 A/B 测试与有效性度量:从假设到增量