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 信息流 (推 / 拉 / 推拉结合)。