☰
双塔召回模型全解:从原理到向量检索的落地实践与踩坑指南
2026/10/27 12:00:37 网站建设 项目流程

先讲一个看起来有点反直觉的事实:点击率预测、精排模型这些算法在推荐系统里跑得好好的,为什么一到召回阶段就集体失灵?答案不是模型效果不行,而是计算量不允许。我去年排查线上延迟时统计过,主链路精排模型对单条请求推理一次大约1.2毫秒,而业务候选物品池是两个多亿。这个规模算完一遍需要接近67个小时,没有任何一个用户会在信息流前等这么久。所以召回这个环节根本绕不开。

这时候就该双塔召回模型(Two-Tower Retrieval Model)出场。严格来说,双塔并不是什么很"新"的模型,早在DSSM时代就是两个塔的检索思路,但直到最近几年它才在推荐系统里被大规模落地,原因很实在:这个架构能在十亿级候选池中,把单次检索压到几十毫秒,同时召回质量又明显优于规则和协同过滤。这篇文章我会把双塔从原理、训练、向量检索到上线踩坑完整讲一遍,适合正在从传统召回往深度召回迁移的同学,也适合刚接手召回链路、想搞懂为什么双塔能扛住大候选集的工程师。

1. 推荐系统的第一道坎:候选集不是"选不选"而是"等不起"

1.1 为什么精排模型没法直接当召回用

很多刚开始接触召回的同学都会问一个问题:既然精排模型刻画用户那么准,把精排模型的逻辑搬到召回阶段不就好了?这个想法在数据量小的时候没错,但一旦候选物品到了亿级,问题就从"效果好不好"变成了"时间够不够"。

我们简单估算一下。假设精排模型对单个"用户-物品对"推理耗时是1毫秒(实际上带复杂特征交叉的模型通常更慢,2到5毫秒都很正常),候选池有1亿个物品,那么对一个用户跑完全量精排需要:

1亿 × 1毫秒 = 10万秒 ≈ 27.8小时

也就是说,一个人想看一次推荐结果,你得先花一天多时间帮他把所有物品算一遍。这还是在假设单机无限并发,真实情况要糟糕得多。所以推荐系统里一定有召回阶段,召回要做的事情很明确:从亿级候选池里,用极轻量的模型快速挑出几千个最有可能被用户喜欢的物品,交给后面的粗排和精排去细筛。

1.2 召回模型的设计目标

召回模型和精排模型的评价标准完全不一样。精排模型拼的是单个样本的排序精度,追求AUC这类指标;召回模型拼的是三个硬性约束:

  • 速度快:整条召回链路必须控制在几十毫秒量级,包括特征读取、模型推理、向量检索、结果组装。
  • 精度尚可:不需要区分千名以内的排名,但最相关的几百个物品必须被捞回来,否则后面再怎么精排都白搭。
  • 覆盖面广:要给用户推一些非热门的、长尾的物品,不能只盯着头部爆款打转。

这三个约束同时要满足,决定了召回模型不能用精排那种"用户特征和物品特征深层次交叉"的玩法,因为特征一交叉,两边就没法解耦,在线计算量就爆炸。双塔模型之所以被推到这个位置,就是因为它在架构上做了一件非常聪明的事:把用户侧和物品侧完全拆开,各自学习自己的向量表示,线上只做一次向量内积检索。

2. 双塔架构:两个编码器各管一摊,互不打扰

2.1 一次检索请求在双塔里是怎么跑的

画过双塔结构图的人都知道,它左边一个塔、右边一个塔,底部各自接入特征,顶部输出一个固定维度的向量,最后用内积或者余弦相似度算得分。但如果只看网络结构,很容易忽略它真正厉害的地方——异步计算。

整个线上流程是这样的:

  1. 物品塔离线把全量物品的embedding算好,写入向量检索服务(比如Faiss或HNSW索引)。
  2. 用户发起请求时,用户塔在线实时算出当前用户的embedding。
  3. 拿着这个128维或64维的用户向量,到向量检索引擎里做最近邻搜索,返回Top K个物品ID。

关键就在第二步和第三步。用户向量只要算一次,剩下的近邻搜索在索引结构上做,时间开销是毫秒级的。而物品塔的推理成本全部被离线摊掉了,线上这部分成本是零。这就是双塔能扛住亿级候选池的根本原因——不是模型本身有多快,而是它把计算解耦了。

2.2 塔内结构与特征处理

两个塔的内部结构其实可以不一样,但实践里大家用的套路很接近:Embedding层 + 简单的MLP层。以我现在线上跑的版本为例:

  • 用户塔输入:用户ID、性别、年龄、城市等静态属性,加上最近点击过的物品ID序列、最近交互过的类目序列。
  • 物品塔输入:物品ID、类目ID、品牌ID、价格分桶、标题向量等静态属性。
  • 每个特征都映射成低维稠密向量,多值特征做sum pooling或者attention pooling,然后拼接成一个大向量,再过两层带激活函数的全连接层,最后输出128维向量。

这里要特别提醒一点:塔的深度别贪。很多人总觉得网络越深效果越好,但在双塔里不是这样的。因为训练时负样本远多于正样本,梯度信号本身就稀疏,网络一旦加深,塔尾部的参数很容易学不动,甚至出现embedding范数越来越大、相似度分布失衡的问题。我试过把MLP从2层加到4层,离线指标基本没涨,训练时间倒是涨了快一倍。常规情况下,每侧一到两层MLP足够了。

关于embedding维度,64和128是最常见的两个档位。维度太低,表达不了用户复杂的兴趣;维度太高,向量检索的内存和耗时都会上来。10亿物品、128维float向量,光索引裸数据就是512GB,维度再往上加,成本是实打实的。

2.3 相似度函数与训练目标

双塔输出的是两个向量,怎么衡量"用户喜欢物品"的概率?业界最常用的是内积,其次是余弦相似度。两者区别在于是否对向量做归一化:内积对模长敏感,余弦相似度只考虑方向。

我的实测感受是:直接用内积训练,收敛更快,但容易出现某些热门物品因模长偏大而整体得分偏高的情况;余弦相似度收敛稍慢,但对热门物品更友好。一个折中做法是在训练时对两边向量做L2归一化,然后算内积,这等价于约束了向量的模长,既能加速收敛,又不会让个别物品向量"飞"得太远。

训练目标方面,双塔召回本质是一个多分类问题:给定用户u,在所有物品集合里找出最可能被用户喜欢的那个物品i。所以最理想的目标函数是softmax:

P(i|u) = exp(s(u,i)) / Σ exp(s(u,j))

其中分母要对全量物品求和。这个分母在亿级候选下根本算不出来,所以实际训练必须用近似方法,最常见的就是sampled softmax:从候选物品里随机抽一部分当负样本,用这部分估计分母。配合的loss就是大家很熟悉的交叉熵形式:

loss = -log( exp(s(u,i+)) / [ exp(s(u,i+)) + Σ_{j∈负样本} exp(s(u,j)) ] )

这个公式看着简单,但"负样本怎么抽"这一件事,基本决定了双塔模型最终效果的上限。

3. 负样本是双塔训练的"隐形方向盘"

3.1 batch内负采样为什么被广泛使用

训练双塔时,最省事的负采样方式是在同一个batch里做:把当前batch里其他用户的正样本物品,当作当前用户的正样本物品以外的负样本。这样做的直接好处是零额外成本,反正这批样本已经加载进显存了,顺手就能用。

但它有一个明显的隐患:热门物品天然出现在很多用户的正样本里,所以被当成负样本的机会也更多。模型被迫学到"热门物品要压低分数"这个信号,结果就是热门的头部物品召回量被莫名压制。这不是模型理解力的问题,是采样分布带来的系统偏差。

一个常见的修正思路是logQ correction,根据物品在训练数据里的出现频率做重要性校正,把采样偏差从梯度里扣掉一部分。不过这个修正实现起来有点绕,我自己的项目里第一批双塔没有做修正,上线后头部物品覆盖率确实掉了三四个点,后来加了修正才恢复。所以如果你决定用batch内负采样,一定要关注头部物品的召回占比变化。

3.2 难负样本:让模型学会区分"差不多"的物品

随机负样本的问题在于太简单。模型很快就学会"用户点过的类目比随机类目得分高",但它分不清"同样是零食,用户为什么偏爱这个牌子的薯片而不是那个牌子的饼干"。这时候就需要难负样本(hard negative)。

难负样本的采集思路不复杂:用上一版已上线的召回模型,对某个用户的正样本物品,去召回池子里捞一批得分较高、但又不是正样本的物品,把这些当作负样本喂给新模型。这样新模型被迫去学那些"模型已经觉得有点相关、但用户实际没选"的边界信息,判别能力提升非常明显。

但这里有个很容易翻车的点:难负样本比例不能太高。我刚开始训练时把hard negative加到20%,结果模型直接变得过度保守,只敢推和用户历史行为几乎一模一样的物品,多样性崩了。后来按经验调到10%左右,配合随机负样本和batch内负样本一起用,效果才稳定下来。合理的比例区间一般是5%到15%,具体取决于你手里难负样本的质量。

3.3 一个可参考的负样本组合方案

我在两个项目里验证下来比较稳的组合方式是这样的:

  • 每个batch内部,用本batch其他样本的物品作为一组负样本;
  • 再从全量物品里均匀随机采样一组负样本;
  • 最后再加一组由上一版模型生成的难负样本。

三部分的比例大概控制在6:3:1左右。训练初期可以先不用难负样本,等模型基础loss降下来之后再加入,相当于一个简单的课程学习思路。这样做的好处是:随机负样本保证基础的排序能力,batch内负样本补充热点打压,难负样本负责拉高边界精度。三者缺哪个,离线召回率指标都会有不同的下降幅度。

我踩过的一个教训是:负样本总数不是越多越好。早期我的batch size拉到8192,负样本已经足够多,但训练速度骤降,而且指标提升越来越小。后来发现把batch size控制在2048到4096之间,配合上面的负样本组合,性价比最高。

4. 从模型到服务:向量检索上线的完整链路

4.1 在线推理链路怎么搭

模型训练完只是第一步,真正难的是把它变成一套稳定的在线服务。双塔在线链路通常包含三个模块:用户向量计算服务、物品向量检索引擎、结果组装与输出。

用户向量计算服务一般是一个轻量级的模型推理服务,输入用户实时特征,输出用户embedding。这里要注意的是实时特征怎么来。如果只用离线预计算的用户画像,向量时效性会很差,用户刚刚看完一个视频、马上又点了另一个,行为变了但向量没变,召回结果自然陈旧。所以用户塔的输入里一定要拼上实时行为序列,通过缓存服务把最近N次交互实时传给推理服务。

整个链路里最容易被忽视的是特征服务的超时控制。用户塔推理本身只要几毫秒,但如果实时特征接口超时了,整条请求就会被拖死。我的做法是给特征读取设置单独的熔断阈值,一旦超过30毫秒就降级用离线缓存特征,宁可损失一点时效性,也不让整个链路挂掉。

4.2 ANN索引的参数与精度取舍

拿到用户向量之后,要在亿级物品向量里找出Top K,精确最近邻是做不到的,必须用近似最近邻(ANN)。业界用的基本都是Faiss或HNSW,核心指标是"召回率"——注意,这里的召回率指的是ANN检索结果和暴力精确搜索结果的交集比例,和模型里的召回指标不是一回事。

以HNSW为例,线上常用的核心参数是M、efConstruction和efSearch。M控制每个节点的连接数,越大精度越高但内存也越大;efSearch控制查询时的候选集合大小,直接影响检索精度和耗时。我目前线上的配置是M=16、efConstruction=200、efSearch=64,单次检索耗时在3到8毫秒,ANN召回率大约在95%以上。如果你想降延迟,优先调低efSearch;如果想提精度,先调高M而不是盲目调efSearch,这俩作用阶段不一样,别搞混。

内存也是一个大坑。前面算过,10亿条128维float向量,裸数据就要512GB,加上HNSW的图结构,内存占用直接翻倍。实务上通常会对向量做量化压缩,比如用SQ8把维度从float压成8bit整数,内存直接降到四分之一,精度损失在可接受范围内。或者用PQ分桶量化,省得更多,但召回率损失也更大。建议先上SQ8,别一上来就上PQ,除非服务器内存实在紧张。

4.3 分桶、冷启动与索引更新

除了ANN参数,工程上还有两个几乎必踩的坑。

第一个是分桶检索。全局只建一个索引,看起来简单,但不同类目物品的向量分布差异很大,单索引检索容易出现"头部类目通吃"的情况。我后期改成了按一级类目分桶建索引,用户请求时先粗判用户兴趣分布,挑两到三个类目桶去检索,再合并结果。这样既缩短了检索范围,又让长尾类目有了曝光机会,覆盖率和多样性指标都涨了。

第二个是冷启动。新物品没有历史交互,模型给它算出的向量可信度低,如果直接进索引,可能很长时间都召不回。常规做法是新物品先不进ANN索引,而是放入一个冷启动池,由规则或近邻策略兜底,等积累了一定交互量之后再计算embedding、写入索引。这个过程要尽量自动化和定时化,不能靠人工维护。否则冷启动物品沉淀成死数据,新品覆盖率会非常难看。

5. 双塔也有天花板:与粗排、精排的分工才是完整解法

5.1 特征交叉不足是双塔的原罪

双塔能把用户侧和物品侧解耦,这是它的优势,但也是它最大的限制。因为两个塔在线下是分开计算的,所有特征交叉只发生在塔内的特征之间,跨侧特征没办法做交叉。

举个例子:"用户喜欢科幻片"和"电影评分在8分以上"这两个信号,如果都是塔内特征,模型能学到;但"这个用户对科幻片的8分以上电影特别敏感"这种交互式的偏好,双塔就非常难表达。因为它需要把用户偏好和物品属性放到同一个空间里去组合判断,而双塔的结构决定了两边只在最后的内积那一步碰一次面,碰完就结束了,没有更深层次的互动。

理解了这一点,就会明白为什么双塔召回在线下看召回率很高,线上却总感觉"推得不够精准"。这不是双塔的bug,是架构的天性。所以现在的工业界标准做法是让双塔回归它本职——把候选池从亿级压到万级,真正的精细排序交给粗排和精排去做。

5.2 三阶段漏斗的分工逻辑

我的推荐链路是典型的"3-2-1"漏斗:

  • 召回阶段:双塔模型从全量候选池里召回2000到5000个物品。这一层拼的是速度和覆盖,要求把"I"级的相关物品尽量不漏掉。
  • 粗排阶段:一个轻量模型,比如简单的多层感知机或者带少量交叉特征的双塔变体,把5000个压缩到500个。粗排的模型结构介于召回和精排之间,不能用纯双塔,也不能用完整的精排,否则性能和精度都不合适。
  • 精排阶段:上完整的、带特征交叉的深度模型(类似DCN、DeepFM、MMoE这类),把500个排序后输出Top 50。这一层拼精度,算力完全花在刀刃上。

很多人容易犯的错误是过度优化召回模型本身的AUC,总想着让双塔自己多学点交叉特征。实际上双塔召回只要保证召回率和覆盖率稳定,剩下的交叉活儿让给粗排和精排,链路整体效果会更好。我做过一个对照实验:把双塔的结构从2层加到4层、加了一些跨侧特征,离线AUC涨了1.5%,但线上整体点击率基本没动,反而延迟涨了20%。费力不讨好。

5.3 双塔的评估指标要单独设计

评测双塔模型,不能只看AUC。因为它本质上是一个检索模型,AUC只能反映正负样本的相对排序,反映不了"该捞回来的到底捞没捞回来"。

我日常用的评估指标有这么几个:

  • Recall@K:正样本物品在召回的Top K里出现的比例,K一般取500、1000、2000。
  • R-Precision:召回结果里相关物品的比例,比Recall@K更严格。
  • 覆盖率:召回结果覆盖了多少不同物品/类目,过低说明双塔在"视野狭窄"。
  • 长尾召回率:单独统计非头部物品的召回情况,防止模型被头部带偏。

上线前还要做一次"离线模拟在线"的评估:拿上一版线上日志里的真实用户请求,用新模型生成召回结果,再交给粗排和精排模拟打分,看最终的排序结果指标有没有提升。这一步能提前拦下很多"离线涨、线上掉"的坑。

6. 复盘记录:从1.0到3.0的迁移过程与踩坑

6.1 第一次离线涨点线上没涨,问题出在哪

我最早把双塔召回模型推到线上时,离线Recall@1000比原来的协同过滤召回升了将近30个点,团队内部一片看好。结果AB测试跑了一周,线上点击率只比对照组高了0.1%,根本不显著。

后来复盘,核心原因出在评估和真实环境不一致。离线评估用的正样本是用户在完整候选池里产生的点击行为,但在线上,用户已经被召回结果限制了视野,只能看到双塔召回出来的物品。离线指标高,只说明双塔对这些"曾经被看到过并点击"的物品排序不错;但线上如果召回的候选整体质量不行,用户根本点不到好东西,点击率自然上不去。这就是典型的survivorship bias问题。

解决办法是把评估口径改成"级联评估":新双塔召回结果 + 现有粗排 + 现有精排,完整跑一遍,看最终Top推荐列表的离线点击预估增益。这一步准备工作量不小,但能省下大量无效AB时间,强烈建议纳入标准流程。

6.2 调整负采样比例翻车,指标掉得莫名其妙

还有一次印象很深的教训:想优化长尾召回,我把随机负样本和难负样本的比例从6:1调到了3:1,结果长尾召回率确实涨了一些,但整体点击率反而掉了。

翻训练日志才发现,难负样本比例抬高后,模型对"头部和腰部物品"的区分越来越保守——原来敢给用户推一些有点关联但用户还没看过的腰部物品,现在因为这些物品经常出现在难负样本里,模型默认它们是负例,不敢推了。这就是负样本比例调整的连锁反应:你以为是只拉长了尾,实际上把整个打分分布都压偏了。

从那以后,我改负样本比例时一定同时盯三个指标:整体Recall@K、长尾召回率、线上点击率预估。只要其中一个出现反向变化,就说明比例调过头了。

6.3 模型更新节奏与用户向量一致性

最后说一下更新节奏。双塔模型我采用的是"每日全量训练+增量更新"结合的方式:每天凌晨用全量日志重新训练一次模型,更新物品塔索引和用户塔参数;白天则通过实时特征通道让用户向量跟着行为走,不重新训练模型。

这里有一个向量一致性隐患需要注意。用户向量用实时行为增量更新,但物品向量是离线算好的,两边特征分布可能随着时间漂移,导致内积得分失真。我的经验是每4到6个小时用最新的用户行为增量同步重算一次头部活跃用户的embedding,全量用户的最低频次不能超过一天,否则实时向量和离线向量会越走越偏。

踩过这个坑之后,我把"用户向量新鲜度监控"加到了报警系统里:如果某个batch的用户向量和前一天同批次的向量余弦相似度低于阈值,立刻告警查特征链路。这套机制看起来不起眼,但对线上效果稳定性的贡献不比模型调参小。

最后再分享一个我自己的实战习惯:双塔这种召回模型,最终效果好坏往往不取决于模型的网络结构有多精巧,而是取决于你把多少精力花在了负采样、向量检索、冷启动和数据链路这些"看不见的地方"上。模型结构现在大家都看得到,论文也不难懂,真正拉开差距的,恰恰是这些需要自己踩坑才能摸透的细节。希望你读完之后,至少能避开我上面摔过的这几个跟头。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询