63.2 召回 / 排序 / 重排
一句话
召回解决“别漏”(多路并行、各管一段分布),排序解决“排准”(单条打分),重排解决“能看”(多样性、业务约束、去重)——三者目标不同,不能用同一个指标衡量。
三段的职责
| 段 | 目标 | 关键手段 | 失败表现 |
|---|---|---|---|
| 召回 | 高覆盖、低延迟 | 多路(协同/向量/热门/规则/关注)、并行 | 好内容压根没进候选 |
| 排序 | 单条精准 | 特征工程 + 模型(LR/GBDT/DNN) | 排在前面的用户不点 |
| 重排 | 列表整体体验 | 多样性(MMR/DPP)、打散、业务规则、去重 | 一屏全是同类,用户划走 |
代码:RRF 融合 + MMR 重排
package main
import (
"fmt"
"sort"
)
// 三路召回的结果(itemID 已按各路分数排序)
var channels = map[string][]string{
"协同": {"i1", "i2", "i3", "i4", "i5"},
"向量": {"i3", "i6", "i1", "i7", "i8"},
"热门": {"i9", "i2", "i1", "i10", "i11"},
}
// 物品的类目(用于多样性)
var category = map[string]string{
"i1": "手机", "i2": "手机", "i3": "手机", "i4": "手机", "i5": "配件",
"i6": "耳机", "i7": "耳机", "i8": "配件", "i9": "手机", "i10": "配件", "i11": "耳机",
}
// RRF:score = Σ 1/(k + rank),rank 从 1 开始
func rrf(k float64) map[string]float64 {
s := map[string]float64{}
for _, list := range channels {
for rank, id := range list {
s[id] += 1 / (k + float64(rank+1))
}
}
return s
}
func topN(scores map[string]float64, n int) []string {
type kv struct {
id string
s float64
}
var list []kv
for id, s := range scores {
list = append(list, kv{id, s})
}
sort.Slice(list, func(i, j int) bool {
if list[i].s == list[j].s {
return list[i].id < list[j].id
}
return list[i].s > list[j].s
})
out := make([]string, 0, n)
for i := 0; i < n && i < len(list); i++ {
out = append(out, list[i].id)
}
return out
}
// MMR:score = λ·rel − (1−λ)·maxSim(已选)
func mmr(cands []string, rel map[string]float64, lambda float64, n int) []string {
selected := []string{}
used := map[string]bool{}
for len(selected) < n && len(selected) < len(cands) {
best, bestScore := "", -1e9
for _, c := range cands {
if used[c] {
continue
}
maxSim := 0.0
for _, s := range selected {
if category[c] == category[s] {
maxSim = 1.0
}
}
sc := lambda*rel[c] - (1-lambda)*maxSim
if sc > bestScore {
best, bestScore = c, sc
}
}
selected = append(selected, best)
used[best] = true
}
return selected
}
func cats(list []string) string {
s := ""
for _, id := range list {
s += category[id] + " "
}
return s
}
func main() {
fmt.Println("=== 三路召回 ===")
for name, list := range channels {
fmt.Printf("%-6s %v\n", name, list)
}
rel := rrf(60)
byScore := topN(rel, 10)
fmt.Printf("\n=== RRF 融合后 top10 ===\n%v\n类目:%s\n", byScore, cats(byScore))
for _, lambda := range []float64{1.0, 0.7, 0.5} {
out := mmr(byScore, rel, lambda, 10)
uniq := map[string]bool{}
for _, id := range out {
uniq[category[id]] = true
}
// 前 3 位的类目数:用户第一眼看到的多样性
top3 := map[string]bool{}
for i := 0; i < 3 && i < len(out); i++ {
top3[category[out[i]]] = true
}
// 相邻同类对数:越小说明打散越好
sameAdj := 0
for i := 1; i < len(out); i++ {
if category[out[i]] == category[out[i-1]] {
sameAdj++
}
}
fmt.Printf("\nλ=%.1f:%v\n 类目:%s\n 前3位类目数 %d,相邻同类对 %d,全列表类目数 %d\n",
lambda, out, cats(out), len(top3), sameAdj, len(uniq))
}
}
本机实测(macOS,Apple M1,Go 1.27):
=== 三路召回 ===
协同 [i1 i2 i3 i4 i5]
向量 [i3 i6 i1 i7 i8]
热门 [i9 i2 i1 i10 i11]
=== RRF 融合后 top10 ===
[i1 i3 i2 i9 i6 i10 i4 i7 i11 i5]
类目:手机 手机 手机 手机 耳机 配件 手机 耳机 耳机 配件
λ=1.0:[i1 i3 i2 i9 i6 i10 i4 i7 i11 i5]
类目:手机 手机 手机 手机 耳机 配件 手机 耳机 耳机 配件
前3位类目数 1,相邻同类对 4,全列表类目数 3
λ=0.7:[i1 i6 i10 i3 i2 i9 i4 i7 i11 i5]
类目:手机 耳机 配件 手机 手机 手机 手机 耳机 耳机 配件
前3位类目数 3,相邻同类对 4,全列表类目数 3
λ=0.5:[i1 i6 i10 i3 i2 i9 i4 i7 i11 i5]
类目:手机 耳机 配件 手机 手机 手机 手机 耳机 耳机 配件
前3位类目数 3,相邻同类对 4,全列表类目数 3
读数:
| 配置 | 前 3 位类目数 | 相邻同类对 | 说明 |
|---|---|---|---|
| RRF 融合 | — | — | 三路都命中的 i1 排第一:多路共识 = 更强的信号 |
| λ=1.0(只按相关性) | 1(全是手机) | 4 | 相关性最高,但首屏单调 |
| λ=0.7 | 3(手机/耳机/配件) | 4 | 首屏立刻拉开,相关性几乎不变 |
| λ=0.5 | 3 | 4 | 与 0.7 相同——候选池只有 3 个类目,再降 λ 也没有额外收益 |
最后一行是本节最实用的一条:多样性的上限由候选池的类目数决定。如果召回只捞回 3 个类目,MMR 再强也变不出第 4 个类目——这时候要改的是召回(加类目探索/多路),而不是继续调 λ。
🔑 RRF 的价值是不用归一化各路的分数(协同的分数、向量的距离、热门的计数根本不可比),只比排名;MMR 的价值是把“列表级目标”(别一屏同类)写进排序,而不是靠事后打散。
重排的业务约束
| 约束 | 做法 | 副作用 |
|---|---|---|
| 去重 | 同一 SPU 不同 SKU 只留一个 | 可能丢掉变体 |
| 打散 | 同类目最多连续 2 个 | 相关性略降 |
| 强插 | 运营位/新内容固定位置 | 伤害体验,要限量 |
| 多样性 | MMR/DPP | 转化可能下降,需 A/B |
| 时效 | 老内容降权 | 长尾内容失去曝光 |
反模式
| 反模式 | 后果 |
|---|---|
| 只做单路召回 | 覆盖不足,冷门好内容永不出现 |
| 直接把各路分数相加融合 | 量纲不同,某一路会主导 |
| 重排只做“打散”不看相关性 | 列表变杂,转化下降 |
| 多样性参数拍脑袋定 | 要么没效果,要么伤转化 |
| 召回与排序用不同的 item 集合 | 排序想推的召回没有,白搭 |
| 不做去重 | 一屏多个同款 |
自检
Q1 为什么 RRF 比“把各路分数加权相加”更适合多路召回融合?
▸ 答案:因为各路分数量纲不可比:协同过滤给相似度 0–1、向量召回给距离(越小越近)、热门给曝光计数(几万到几百万)。直接加权会被数值大的那一路主导。RRF 只看排名(1/(k+rank)),天然抹平量纲差异,且对“某一路排名靠前”给予稳定奖励。代价是丢掉了“分数差距”的信息(第一名 0.99 与第二名 0.98 被当成一样)。
Q2 MMR 的 λ 调小会发生什么?为什么不能一直调小?
▸ 答案:λ 越小,多样性权重越大,列表越分散;但相关性会被牺牲,可能把“类目不同但用户根本不想点”的内容排上来,导致 CTR 下降。实际调参要 A/B:λ 从 1.0(纯相关性)往下试,找到“多样性提升但 CTR 不降”的点,通常落在 0.6–0.8。
Q3 召回和排序的指标为什么不能混着看?
▸ 答案:召回的目标是“覆盖率/召回率”(好的候选有没有进池子),排序的目标是“排序质量”(池子里好的有没有排前面)。召回差 → 排序再准也没用(池子里没有);排序差 → 召回到位但用户看不到。所以召回看 Recall@K 与覆盖率,排序看 NDCG/AUC,最终都汇到线上 CTR/CVR(63.5)。
关联
候选池从哪来(推还是拉)见 63.7 信息流 (推 / 拉 / 推拉结合)。