多路召回融合:向量召回、协同过滤和热门召回的权重分配

📅 2026/7/22 15:59:05 👁️ 阅读次数 📝 编程学习
多路召回融合:向量召回、协同过滤和热门召回的权重分配

多路召回融合:向量召回、协同过滤和热门召回的权重分配

一、单路召回的天花板:精准度、多样性和新颖性不可兼得

推荐系统的召回阶段决定了最终给精排模型"喂"什么数据。只依赖单一召回通道,就像只用一种感官理解世界。

纯协同过滤召回(ItemCF/UserCF)的问题是马太效应——热门物品越来越热,长尾物品永无出头之日。数据驱动下这是必然结果:热门物品交互多、特征充分、相似度计算准确,长尾物品数据稀疏,协同过滤根本算不出合理的相似度。

纯向量召回(双塔模型产出的 Embedding 做 ANN 检索)在语义理解上有优势,但它的问题是缺乏行为信号的强约束。向量空间里距离近的两个物品在内容上确实相似,但用户就是不会连续点击它们——因为推荐不是内容理解,是行为预测。

纯热门召回最简单粗暴:按过去 24 小时点击量排序,Top 100 直接推。问题是千人一面——所有用户看到的都一样,个性化程度为零。在垂直领域(如新闻资讯)可以接受,但在电商和短视频领域就是灾难。

多路召回的核心不是"把几个通道的结果拼一起",而是不同通道的侧重点互补。协同过滤提供行为协同信号("和你相似的人也买了"),向量召回提供内容语义信号("这个物品和你看过的语义接近"),热门召回提供群体统计信号("大家都在看")。三路信息各司其职,才是多路召回的意义。

二、权重不是拍脑袋:AB 实验里的数据真相

权重分配是整个多路召回中最容易"拍板定案"的环节。常见做法是经验分配:协同过滤 50%、向量召回 30%、热门召回 20%,上线就行。但实际效果往往和直觉相反。

在某内容推荐场景的 AB 实验中,三组不同权重对比的数据是这样的:

方案协同过滤向量召回热门召回点击率变化多样性(Entropy)新颖性
基准50%30%20%基准基准基准
加重协同70%20%10%+3.2%-8.5%-12.3%
加重向量30%60%10%-1.8%+15.7%+22.1%
加重热门30%20%50%+8.1%-18.2%-25.4%

数据很诚实:如果想冲点击率,多给热门召回权重是最立竿见影的——这在逻辑上也成立,因为热门内容被验证过,用户点击概率更高。但多样性指标惨不忍睹——用户看到的内容会越来越窄。

如果追求长期的内容生态和用户留存,向量召回权重要提上去,因为 ANN 检索天然具备多样性——向量空间里每个方向都能找到相似但不相同的内容。代价是短期点击率的稀释。

真实场景下,权重不是固定的,应该是动态的。新用户(注册 3 天内):热门召回 40%、协同过滤 30%、向量召回 30%——新用户缺乏行为数据,协同信号弱,先用热门兜底。老用户(注册 30 天以上):协同过滤 50%、向量召回 35%、热门召回 15%——行为数据充分,个性化信号为主。这是一个"用户生命周期权重衰减"策略。

三、召回合并去重:不是简单的 union

多路结果合并时,同一个物品可能出现在多路召回中。直接去重取前 N 个是最简单的做法,但会丢失召回来源信息——这个物品是被协同过滤找到的,还是被向量召回挖掘的?来源信息对精排模型有重要价值。

实践中采用带来源标记的加权合并:每个物品除了物品 ID 外,附带回召通道标签和通道内评分。精排模型可以根据来源通道配置不同的特征权重。Fan-in 的架构下,合并流程如下:

type RecallItem struct { ItemID string Score float64 // 通道内归一化分数 Channel string // "cf" | "vec" | "hot" RawScore float64 // 通道内原始分数 } func MergeMultiRecall(cfItems, vecItems, hotItems []RecallItem, totalQuota int) []RecallItem { seen := make(map[string]*RecallItem, totalQuota*2) // 按通道权重交错插入,而非通道间排序插入 // 这样保证了每个通道的结果都有机会进入候选池 cfIdx, vecIdx, hotIdx := 0, 0, 0 for len(seen) < totalQuota { // 交替从三个通道取出一个未重复的物品 if len(seen) < totalQuota && cfIdx < len(cfItems) { if _, exists := seen[cfItems[cfIdx].ItemID]; !exists { seen[cfItems[cfIdx].ItemID] = &cfItems[cfIdx] } cfIdx++ } if len(seen) < totalQuota && vecIdx < len(vecItems) { if _, exists := seen[vecItems[vecIdx].ItemID]; !exists { seen[vecItems[vecIdx].ItemID] = &vecItems[vecIdx] } vecIdx++ } if len(seen) < totalQuota && hotIdx < len(hotItems) { if _, exists := seen[hotItems[hotIdx].ItemID]; !exists { seen[hotItems[hotIdx].ItemID] = &hotItems[hotIdx] } hotIdx++ } // 全部耗尽则退出 if cfIdx >= len(cfItems) && vecIdx >= len(vecItems) && hotIdx >= len(hotItems) { break } } result := make([]RecallItem, 0, len(seen)) for _, item := range seen { result = append(result, *item) } return result }

交错插入而非排序插入是这个算法的关键。如果用全局分数排序合并,热门通道的高分物品会霸占 Top 位置,向量召回的"潜力股"根本挤不进候选池。交错插入保证了每个通道的配额——协同过滤占 50% 的位置,向量召回占 35%,热门召回占 15%,与权重策略对齐。

四、多路召回的边界:更多通道不等于更好

多路召回本质是在计算成本召回覆盖之间做交易。加一路召回,服务延迟增加一次 RPC 调用的时间(约 5-10ms),召回池扩大但精排压力也增大。

三路召回是比较均衡的方案。超过五路,边际收益急剧下降——第六路、第七路召回能多捞出来的有效物品极少(通常不超过 1%),但延迟和复杂度持续增长。某内容平台实测:从 3 路加到 5 路,点击率提升 2.1%;从 5 路加到 7 路,点击率仅提升 0.3%,但 P99 延迟增加 18ms。

另一个陷阱是比例失调导致的覆盖偏斜。如果协同过滤召回 500 个、向量召回 200 个、热门召回 500 个,合并去重后热门通道的物品会占据超过一半的候选池——因为热门通道产生 500 个候选已经覆盖了大量常见物品。解决方案是控制每路召回的配额上限,而非产出数量。每路都产出上限量的候选,但只取最大配额。

五、总结

多路召回融合的核心不是把各种算法结果拼在一起,而是让不同通道各司其职:协同过滤提供行为信号、向量召回提供内容语义、热门召回提供先验统计。权重分配必须有 AB 实验数据支撑,不同用户生命周期阶段使用不同的权重策略,而非一刀切。

合并策略采用交错插入而非全局排序,确保每个通道的召回结果都能进入候选池。带来源标记的召回结果把通道信息传递到精排层,让精排模型可以差异化地利用不同来源的信号。

三路召回是投入产出比最优的组合。超过五路,边际收益极低。多路召回的本质是一场精确度、多样性和计算成本三者之间的持续博弈——没有一劳永逸的权重公式,只有跟着数据持续迭代的调优过程。