1. 召回阶段为什么必须上双塔:从向量化匹配说起
在做推荐系统的那几年,我陆续接触过不少刚入行的算法工程师,大家一上来就喜欢研究精排模型,比如DeepFM、DIN、DIEN这类,觉得召回就是拿I2I或者协同过滤随便顶一下就能跑。但你真在亿级物料、千万级用户的场景里滚过一遍,就会明白:召回才是整个推荐链路的天花板。精排再怎么调,你能看到的候选集也就那几百条;如果召回阶段把真正用户感兴趣的东西漏掉了,后面做得再精细也是白搭。在这个前提下,双塔召回模型(Two-Tower Retrieval Model)几乎是工业界最主流的解法,没有之一。
双塔模型的核心思路,说穿了就一句话:把用户和物品分别编码成两个固定维度的向量,然后在向量空间里做最近邻检索。听起来很简单,但它把过去“规则召回、标签召回、协同过滤召回”那种零散的打法统一成了一套可训练、可扩展、可线上化的框架。你不再需要手工维护几百条规则,也不需要天天盯着离线跑出来的相似度表,而是让模型自己从海量行为日志里学出“什么样的用户向量和什么样的物品向量应该靠近”。
我见过很多同学第一次接触双塔时都有一个疑问:精排模型已经能做用户和物品的交叉了,为什么召回不用同样的结构?这就要说到召回和精排的本质区别。精排面对的是几百条候选,可以上复杂的交叉结构,模型再重也能扛得住;召回面对的是全量物品池,几十万、几百万、上亿个候选,你不可能对每个物品都做一次完整的交叉计算。双塔的巧妙之处在于,它把用户特征和物品特征分别送入各自独立的网络,最后只在顶层做一次内积或余弦相似度计算。用户塔在线上一算就出用户向量,物品塔可以离线把全量物品向量都提前算好存起来,线上直接近邻检索。这种“解耦式”设计让它天然适合超大规模召回。
当然,这也引出了一连串的问题:两座塔各自该吃什么特征?塔的输出怎么做相似度比较?训练时的负样本怎么选?线上部署又该注意什么?这些问题环环相扣,任何一个地方拍脑袋,都会直接影响召回效果。下面我把自己在实践中的理解、踩过的坑和一些经验拆开讲清楚。
2. 用户塔和物品塔:结构简单但处处是坑
2.1 两座塔各自吃什么特征
双塔模型的结构虽然叫“双塔”,但塔本身不一定复杂。很多拿双塔效果不好的团队,翻来覆去调了半天模型结构,最后发现问题出在特征上。用户塔和物品塔不是随便灌特征进去就行的,特征怎么进、进哪座塔,是有讲究的。
先说要进塔的特征分为三类:ID类、类别类、数值类。ID类特征比如用户ID、物品ID,在双塔里通常不做简单的one-hot,而是直接学一个Embedding。这里有个关键经验:用户ID和物品ID的Embedding要放在塔的输入端,而不是只靠最后的内积层去交互。因为ID本质上是一个索引,如果你把用户ID嵌入直接接到内积层,那这个嵌入的语义就是“这个用户的全局偏好”,不再会和物品侧其它特征发生碰撞,表达能力会弱很多。
类别类特征(用户城市、设备类型、物品类目、品牌等)一般做多值或单值Embedding,然后做sum pooling或者attention pool。我在项目里比较推荐用sum pooling,简单稳定;attention pool在大量稀疏特征下容易过拟合,尤其是物品侧类别特征多、但每个特征出现的样本量又不均匀的时候,sum pooling反而更稳。
数值类特征是个容易被忽略的细节。很多人直接把原始数值拼在Embedding向量后面输入MLP,这样做有个问题:数值范围差异大的特征(比如价格从1到9999)在梯度更新时会压制Embedding部分的学习。更稳的做法是先做分桶。比如价格可以按“0-10、10-50、50-100、100-500、500-2000、2000+”切成桶,再对桶做Embedding。这样既保留了非线性,又避免了数值范围差异带来的训练不稳定。
还有一个常见问题是:用户塔和物品塔能不能用同一套Embedding?比如类目特征在两边都出现,是否可以共享参数?我的建议是不要共享。因为用户侧和物品侧学习到的类目语义其实不一样,用户喜欢的类目分布和物品所属的类目分布天然存在偏差,共享会让两边互相拉扯,训出来往往两头不讨好。这点和很多同学想象的不一样。
2.2 交互层的选择:内积、余弦相似度与温度系数
双塔的输出通常是一个低维稠密向量,比如64维、128维,然后通过内积得到相似度分数。工业界最常用的是内积,我自己的实践也验证了这一点。用余弦相似度还是内积,区别在于是否对向量模长敏感。
内积对模长是敏感的——两个向量方向一致但模长大,分数就高;而余弦相似度只关心方向,模长信息全部丢掉。那么哪个更好?我的经验是:不用刻意归一化,让模型自己决定要不要学出模长差异。如果你在召回的后续阶段还有粗排或精排,召回模型的绝对分数并不重要,重要的是排序的正确性。内积在这个目标下更灵活,因为它保留了模长信息,模型可以通过调整模长来表达“这个用户对这个物品的偏好强度”,这比余弦只给一个方向相似度要更自然。
不过,直接做内积有一个隐性麻烦:如果塔的输出没有做约束,向量模长在训练初期可能涨得很快,导致softmax温度被挤压,模型在负样本上的梯度变得很小。这时就轮到温度系数登场了。在softmax召回的训练框架里,温度系数扮演着“控制对比学习尖锐程度”的角色。温度越低,分布越尖锐,模型越倾向于把正样本分数拉高、把负样本分数压低;温度越高,分布越平滑,对不同负样本的惩罚越温和,模型学到的向量更均匀。
实际项目中,温度值在0.05到0.2之间是比较常见的选择。我踩过的一个坑是:因为负样本采样策略调整过,旧的温度值在换了新的困难负样本后完全不适用,表现在离线AUC掉、线上CTR也降。后来排查发现是困难负样本的加入让logits整体被拉大了,而原始温度偏高,模型对正、负样本的区分变钝。把温度从0.1调到0.05之后,效果立刻回升。所以温度和采样策略是联动变量,单独调某一个经常得不到预期效果。
2.3 Embedding维度怎么定
双塔模型的向量维度,是另一个能做文章的地方。太大:离线训练慢、线上向量检索内存压力大、容易过拟合;太小:表达容量不够,用户多兴趣和物品多侧面学不出来。我从128维开始压,一路试过64、32和16,结论是大部分业务场景64维已经够用,头部流量特别大或者行为特别丰富的业务可以用128维,中小业务32维就够。前提是你的特征工程足够扎实。
判断维度是否合适,有一个很实用的信号:看训练集上的loss和验证集上的loss之间的差距。如果训练loss降得很低但验证loss死活下不去,维度大概率偏大,模型在硬记样本而不是在学泛化;如果两边loss都高,先别怀疑维度,回头查查特征和负采样有没有问题,而不是盲目加维度。
很多同学还会问:塔的结构要不要加深?我的态度是,在召回阶段,塔不需要太深。召回模型的定位是海选,它不需要像精排那样精细刻画每一个特征交叉,它只需要把“大致相关的”捞回来。用户塔和物品塔一般2到3层MLP就够了,每层维度从输入特征维度逐步降到目标向量维度。我见过不少人把塔做得比精排还深,结果离线指标好不容易涨了零点几个点,线上延迟和超时直接冒出来了,得不偿失。
3. 训练双塔最绕不开的坎:负样本怎么选
3.1 随机负采样和它的局限
双塔训练本质上是学习“正样本和负样本在向量空间里的相对关系”。正样本一般取曝光且点击的用户-物品对,这没有争议;真正拉开差距的是负样本怎么选。
最早期的做法是随机负采样,也就是从全量物品池里均匀随机抽K个物品,配上这个用户的正样本组成一条训练数据。随机负采样实现最简单,但效果一般,原因是:随机采到的大多是长尾物品,它们和用户的实际交互分布差异很大。模型在训练里反复见到“热门用户+长尾物品”这样的负样本对,就容易把长尾物品的向量全推得很远,可线上真正需要你去区分的是“用户错过的头部物品”和“用户想要的头部物品”,长尾物品根本不会出现在召回候选的前排。
为了解决这个问题,我见过不少团队在随机负采样里加了一个小技巧:按物品点击频率的反比采样。也就是越热门的东西,作为负样本的概率越低;越冷门的东西,被选为负样本的概率越高。这样做能让采出来的负样本分布更贴近真实曝光分布。但这个做法有一个副作用——模型对热门物品的区分度会变弱,因为热门物品很少被当作负样本暴露给模型。所以单纯的随机负采样调整概率,只能算过渡方案。
3.2 Batch内负采样:省算力但引入流行度偏差
训练双塔时,最主流的负采样方式其实是batch内负采样。原理很简单:在同一个batch里,每个用户有一个正样本物品,那么除了自己的正样本之外,batch里其他人对应的物品都可以当作这个用户的负样本。这样做最大的好处是不额外增加负样本的采样开销,训练效率极高。
但batch内负采样有一个绕不开的偏差:流行度偏差。如果一个物品在batch里频繁出现(通俗点讲,就是它比较热门),那它被当成负样本的次数就多,模型学到最后会把热门物品的向量往“远离所有人”的方向推。这就出现一个矛盾——热门物品本来就是大多数用户会喜欢的东西,你却把它推远了,召回率自然受损。
解决这个问题,比较标准的做法是logQ correction(又叫batch softmax修正)。思路不复杂:给定物品i在训练batch中被采样到的概率,在计算相似度logit时把这个概率的影响减掉,让模型在优化时不会因为“这个物品被采到的次数多”而盲目加大惩罚。具体公式大概是 s(z_u, z_i) - log(p_i) 这样的形式。我建议任何用batch内负采样做双塔训练的同学,都认真看一遍这个修正项的推导,不要直接跳过去。它虽然不能100%消灭流行度偏差,但能把偏差控制在一个可接受范围内。
另外,还有一个偏工程向的技巧:同一个batch内的样本尽量在“同一场曝光”里取。什么意思?比如正样本是某次请求里用户点击的物品,那这个batch里可以多放一些同一次请求的其他曝光未点击物品。因为同一场请求里的未点击,是质量极高的负样本——它意味着“系统推给了这个用户,但用户没点”。这个信息的置信度比随机采样高得多。
3.3 困难负样本与曝光未点击样本
如果只做随机负采样或batch内负采样,模型学到的边界其实比较“粗糙”。随机负样本大多和用户八竿子打不着,模型很容易学会“把完全无关的推开”,但用户如果对两个相似物品都有一点兴趣,模型就分不清了。这时候需要困难负样本(hard negative)出场。
困难负样本的定义是:从向量检索结果里挑那些“分数很高但用户没有点击”的物品作为负样本。通俗解释就是:模型自己觉得这个物品跟用户很匹配,但用户实际上没点——这种样本正好打在模型的痛点上,逼着模型去学习更细粒度的区分。
引入困难负样本有几个注意点。第一个是不能一上来就加,最好先让模型在随机负样本上跑几天,等到基本收敛了再加,否则模型在早期会混乱。第二个是比例控制。我常用的比例是:随机负样本和困难负样本按9:1或者8:2混合。困难负样本占比太高,模型会变得过度保守,把所有让模型“犹豫”的物品都当作负样本推远,结果召回的多样性骤降。第三个是时效性。困难负样本不能太旧,因为线上物品分布和用户兴趣都是动态的,至少要用当天或者隔天产出的困难负样本。
关于曝光未点击样本,我想单独强调一下。很多团队在跑双塔时,数据表里只有“曝光+点击”的交互记录,没有记录“曝光未点击”,导致负样本只能靠随机采样。其实曝光未点击是非常宝贵的负样本来源,因为它是真正的“用户见过但不要”的证据。如果你所在的业务有曝光日志,务必把这个数据接入训练管线。我在一个社交推荐的场景里做过对比实验:仅把负样本从“全量随机采样”换成“70%随机+30%曝光未点击”,召回AUC涨了接近两个百分点,效果非常明显。
负样本采样策略的对比我整理成一个表,方便你快速参考:
| 采样策略 | 实现成本 | 负样本质量 | 主要风险 | 适用阶段 |
|---|---|---|---|---|
| 全量随机采样 | 极低 | 低,大多为易分样本 | 对头部物品区分度不足 | 冷启动、基线模型 |
| 按热度反比采样 | 低 | 中,贴近曝光分布 | 热门物品区分偏弱 | 有一定数据量后 |
| Batch内负采样 | 低,随训练自动产生 | 中,含部分困难样本 | 流行度偏差,需logQ修正 | 大多数工业场景 |
| 曝光未点击样本 | 中,需曝光日志 | 高,真实负反馈 | 依赖日志质量 | 有完整曝光日志时 |
| 在线困难负样本 | 高,需实时检索 | 高,可动态迭代 | 可能过度保守 | 模型稳定后迭代 |
4. 从离线到在线:双塔部署的实战细节
4.1 离线评估的指标选择
很多团队评估双塔模型时只盯着AUC,这是个常见误区。双塔是召回模型,它的职责是“从海量物品里捞回候选”,离线评估应该模拟这个场景,而不是拿精排的指标硬套。
我比较推荐的离线评估方法是全量物品评估:固定一批测试用户,把每个用户的真实点击物品当作正样本,再从全量物品池里采样一批物品作为候选,然后用双塔模型给所有候选物品打分,观察真正点击的物品排到了什么位置。这个测试设计尽量贴近线上真实情况——因为线上一旦部署,模型面对的就是全量物品而不是精排后的几百条。
评估指标方面,除了AUC,还建议看Recall@K、HitRate@K(前K个结果里有没有命中真实点击物品)和MRR(点击物品在召回列表中的平均倒数排名)。Recall@K最直观,直接反映召回率;MRR则能体现排序质量。我在实际项目里见过一个情况:AUC微涨,但Recall@200掉了1%,结果线上整体点击率也跟着掉。后来分析原因,AUC衡量的是“模型对正、负样本排序正确率”,但它对头部排序位置的权重和中部是一样的,而召回真正关心的是头部K个位置有没有命中,AUC和Recall之间的偏差就是这么来的。
4.2 线上推理架构:预计算Embedding + ANN检索
双塔部署的经典架构是:物品塔离线推理,全量物品的Embedding提前算好,写入向量检索库;用户塔在线实时推理,拿到用户向量后到向量库里做ANN近邻检索,取topK物品作为召回结果。
这里有一个经常被问到的问题:为什么用户塔不能也离线算好?因为用户状态是动态的,用户的实时行为(刚刚点了什么、看了什么)需要在召回的当刻反映到向量里。如果用户向量也是预先算好的,那和“靠历史兴趣猜测当前兴趣”没有区别。所以线上用户塔的输入特征要接一条实时特征通道,把最近几分钟的行为特征拼进去。我试过用Redis存用户最近行为序列,每条行为算一个特征再喂给用户塔,整体延迟控制在几十毫秒以内,效果比纯离线用户向量明显好。
向量检索库的选择,工业界用得比较多的有Faiss、Annoy、HNSW这一族。Faiss的IVF+HNSW是我比较常用的组合。索引参数有几个关键变量:nlist(粗聚类中心数量)、efSearch(搜索时的候选集大小)、M(每个向量的邻居数)。这些参数直接决定召回延迟和精度之间的平衡。我给一个大致的经验区间:nlist在100到1000之间,efSearch在64到256之间,具体要靠压测来定。压测的时候注意看P99延迟,不要只看平均延迟,向量检索的耗时波动往往很大。
4.3 模型更新与特征一致性
双塔模型上线之后,最隐蔽的问题就是训练和serving的特征不一致。什么意思?训练的时候,用户特征里“最近点击的3个物品ID”来自于历史日志里的离线拼接;serving的时候,这个特征必须从实时的点击流里实时拼接。如果两个侧面的拼接逻辑、时间窗口、数据源对不上,模型学到的规律在线上就失效了。我遇到过最典型的案例是:离线评估模型百试百灵,一上线CTR跌到脚踝,最后查下来是serving侧把“最近3次点击”拼成了“最近3次曝光”,一个词之差,特征语义完全变了。
解决办法有两个层:第一,尽量复用同一套特征拼装代码,训练和serving共用一个特征库,而不是各自写一套;第二,在serving侧做特征快照校验,随机抽一部分实时请求,把线上特征快照和离线样本的特征做对拍,定期检查分布是否漂移。
模型更新的频率也是需要考虑的问题。物品塔的Embedding可以全量离线更新,一天一次或者若干小时一次就行;用户塔因为依赖实时特征,通常是模型本身上线时更新权重,这对工程链路的容错有要求。线上要有一套完善的模型回滚机制,一旦发现新模型导致召回结果质量明显下降,能立刻切回上一个稳定版本。我见过有的团队漏了这块,新模型上线时召回列表全部坍塌,用户刷不到东西,最后手动清缓存才恢复,代价非常大。
5. 实操踩坑记录与调参经验
5.1 特征穿越:一个隐蔽的错误
双塔训练里最容易犯、也最难查的错误就是特征穿越。简单说,就是训练样本里包含了“只有发生后才能知道”的信息,导致离线指标虚高、线上完全失效。
我曾处理过的一个案例:正样本是“用户在推荐位上点击了某个物品”,我们在构造用户特征时,把“用户当前浏览会话里的行为序列”直接拼进去了。这个序列里可能包含“用户点击了某个其它物品”的信息,而这个点击发生在同一次会话的前面——这看起来没问题,但如果你把特征窗口切得不严谨,比如把整个会话里的行为都放进去,就包含了“点击这个物品之后的行为”,那就等于把答案提前告诉了模型。离线AUC漂亮得很,上线一测就知道完蛋了。
清洗的办法是:构造特征时务必做到严格的时间截断——用户侧的所有行为特征,只能取预测时刻之前、且早于当前正样本行为发生时刻的数据。这个逻辑看着简单,实际工程里因为数据落表延迟、事件时间与日志时间不一致,稍不注意就让穿越混进来了。我的习惯是写单测,用几条构造出来的脏样本来校验特征拼装函数是否有穿越,而不指望人工review能发现所有漏洞。
5.2 温度系数和L2正则的相互作用
我在前文提过,温度系数的调整不能脱离其他超参单独看,这里再展开一个具体经验:温度和L2正则的联系。
双塔模型为了控制向量模长,通常会在塔的输出层加L2正则。但如果正则项设得太大,向量被压得很扁平,模型有效表达能力下降,logits整体变小,学出来的向量差异也变小。这个时候如果你为了追求区分度去调低温度,表面上能压出一点锐利度,但实际上是在补偿正则造成的“钝化”。两个超参互相拉扯,最后很容易出现调参调了一周、模型纹丝不动的情况。
我的建议是:先固定温度(比如0.1),用L2正则把向量平均模长控制到一定范围内(比如训练完统计所有物品Embedding的平均L2范数,看分布是否稳定)。然后观察在线效果,如果发现对“相似物品”的区分不够,再考虑动温度,而不是同时乱调两个变量。这类联动调参问题,最大的教训是一次只动一个变量,否则你根本不知道效果变化是哪个因素带来的。
5.3 双塔之外:什么时候该换别的结构
不得不承认,双塔模型有它的天花板。因为它把用户和物品的特征交叉放到了最后一层内积,塔里面做不了深度的特征交叉,所以对“用户某个即时兴趣和某个特定物品属性之间的关系”建模能力有限。比如你想表达“用户最近在关注篮球,而当前物品是篮球鞋”,双塔模型可以学到一个大概的方向相似度,但精细的关联建模是做不到的。
所以当业务对召回精度要求非常高、候选物品池规模也在可控范围内时,可以考虑更复杂的召回结构。比如树模型召回(如Tree-based Deep Model,TDM),它用一棵树结构组织物品,在树节点上用模型做逐层判别,能把用户特征和物品特征在树的每一层做交叉,表达能力比双塔强,代价是训练和推理复杂度都高不少。
我的判断标准很简单:当双塔的召回指标连续两三个迭代周期都没什么提升空间,同时线上有明显的bad case集中在“用户近期意图和物品属性强相关”的场景时,就应该考虑换结构了。但在那之前,我建议先把双塔的特征、负采样、温度、正则都调一遍,因为大多数情况下双塔效果不好不是结构问题,而是这些周边环节没做到位。换结构是所有手段都用尽之后才走的路,不是遇到瓶颈就换,换结构可能引入比模型本身更多的问题。
我自己在实际项目里,一直保留着一个习惯:把双塔模型拆成“用户侧表达、物品侧表达、交互函数”三块来做实验。每次调参只改其中一块,并保存每一版模型的离线评估结果和一组固定bad case的线上效果截图。这样做的好处是,几个月之后回头看,你能清楚知道每一步改动到底带来了什么变化,而不至于整个项目跑完只有一个“调了一堆参数好像变好了”的模糊结论。双塔模型虽然结构不复杂,但它涉及的细节足够多,认真对待这些细节的人,和只是把代码跑通的人,做出来的效果差距是非常明显的。