ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

双塔召回模型实战指南:从架构设计到线上部署全流程解析

双塔召回模型实战指南:从架构设计到线上部署全流程解析

“双塔召回模型”这几个字,在搜推广算法岗的JD里出现频率极高,但真正能在面试里讲透、在业务里用好的工程师其实不多。我在做推荐系统的这些年里,前前后后折腾过协同过滤、FM、Graph Embedding,最后在召回这个环节稳定下来的方案,就是双塔。今天这篇,我不打算科普什么是Embedding,也不讲“双塔很强大”这种空话,而是把从模型结构设计、样本构建、训练调参到上线部署、问题排查的完整链路,按我实际落地时的思考过程,一条条掰开揉碎写出来。无论是刚入门准备做召回项目的同学,还是已经在线上跑着双塔想进一步优化的朋友,这篇内容应该都能给你一些能直接抄作业的细节。

1. 双塔召回模型的整体设计与核心思路

1.1 召回问题的本质与双塔架构的解题逻辑

推荐系统里,召回面对的是百万甚至上亿规模的物料池。这个阶段的目标不是“精准”,而是“不错过”。你要在几百毫秒内,从亿级候选里捞出几百上千个用户可能感兴趣的东西,交给后面的粗排精排去慢慢挑。所以召回模型设计的核心矛盾,是表达能力和算力开销之间的取舍。

双塔模型解决这个问题的方式非常巧妙:把用户侧所有特征揉成一个向量,把物品侧所有特征揉成另一个向量,两者通过内积(或者余弦相似度)计算相关性。QR编码、CM2000这些选项先放一边,关键在于——双塔一旦定型,用户塔和物品塔就完全解耦了。物品塔可以离线把全量物料Embedding算好,建好向量索引;线上只需要跑用户塔,拿到用户向量后去向量库做近邻检索。

这个解耦带来的收益是巨大的。我在实际项目中测算过,同样量级的候选集,纯算力开销比传统的“用户x物品”交叉模型(比如FM、DeepFM直接打分)低两到三个数量级。没有这个解耦,任何复杂模型都很难直接扛住召回阶段的海量请求。这也是为什么双塔虽然在单点精度上经常打不过精排模型,但在召回这个位置,它始终是工业界最稳的底座之一。

1.2 双塔与传统召回方案的取舍:为什么不是FM也不是协同过滤

很多人有个困惑:协同过滤(ItemCF/UserCF)也是把用户和物品映射到向量空间,双塔和它有什么区别?区别大了。协同过滤的向量是从共现矩阵里学出来的,它本质是在利用“行为相似性”,但完全无法利用用户和物品自身的属性特征。一个刚上架的新品,没有任何行为记录,协同过滤对它来说就是一张白纸,永远召不回。而双塔模型可以把类目、价格、年龄、性别、统计 CTR 这类侧信息全部塞进特征,新品的ID Embedding虽然是随机初始化的,但它的类目、价格这些特征能帮助模型在一定程度上做出合理判断。这就是“归纳偏置”的差别。

对比 FM 这种浅层交叉模型,双塔的优势在于深度。FM 的特征交叉是二阶的,而双塔中的多层非线性结构能捕捉更高阶的模式。我在一个短视频项目中做过对比:同样用曝光点击样本训练,FM 线的 Recall@100 比双塔低了大概6个百分点,在长尾物品上的差距更明显。当然,FM 的线上 serving 成本非常低,召回精度要求不高的场景它仍然有生存空间。但如果目标是撑住一个千万级 DAU 产品的召回,双塔的综合性价比是最好的。

1.3 相似度函数选型:内积、余弦,以及那个经常被忽略的温度系数

双塔的最后一层,通常用内积(Dot Product)或余弦相似度(Cosine Similarity)做相似度计算。选哪个不是拍脑袋,它有实际后果。

如果直接算内积,向量的模长会影响最终分数。训练时如果没做约束,某些高热物品的向量模长会偏大,导致它“看起来”和所有用户都相关,挤压长尾物品的曝光。这时候要么在训练中对 Embedding 做 L2 正则,要么用余弦相似度强制把向量归一化到单位超球面上。工业界很多成熟的实现(比如 YouTube 那篇著名的双塔论文)都倾向用余弦相似度,再加上一个温度系数来调节相似度分布的陡峭程度。

温度系数这个细节,我单独拉出来说。假设算完余弦相似度之后得到一个分数 s,实际打分是 s / τ。τ 越大,softmax 之后的概率分布越平缓,模型更“一视同仁”;τ 越小,分布越尖锐,模型对正负样本的区分越自信。实操中 τ 的取值对训练初期的稳定性影响非常大。我踩过的坑是:直接把 τ 设成 1.0,结果训练初期 loss 掉不下去,梯度也不稳定。后来参照论文建议把 τ 设成 0.05 到 0.2 这个区间,效果立刻正常了。原因在于,未归一化的余弦相似度分布在 [-1, 1] 之间,直接进 softmax,梯度在饱和区几乎消失,训练基本推不动。用小 τ 把 logits 放大,相当于人为拉开正负样本的差距,让梯度更陡。

2. 核心细节解析与实操要点

2.1 Embedding 层设计:ID 哈希、分箱与特征组合的实操经验

双塔的输入主要是类别特征和数值特征。类别特征(比如用户ID、物品ID、类目ID、城市ID)都要过 Embedding 层。这里第一个实操问题是:ID 空间太大怎么办。

拿物品 ID 举例,一个小型电商平台的 SKU 也有几百万,直接做全量 Embedding 表,内存开销非常大。我的做法是两级映射:一级是 Hash Embedding,把原始 ID 用哈希函数映射到固定大小的桶里,比如 100 万维;另一级是让模型自己在这个桶空间里学 Embedding。这样即使未来 ID 空间增长,也不影响线上 serving,哈希碰撞带来的副作用微乎其微。具体哈希方式不必复杂,murmurhash3然后用模运算落到桶内即可,我试过 CRC32,分布不均匀,效果差一些。

数值特征处理,我的标准流程是先做分箱再加 Embedding。比如“价格”这个特征,直接归一化后作为连续值输入,模型很难学到“19.9 元和 20.5 元”在用户心智里的巨大差异;而分箱之后转化为类别特征,模型就能把“9.9 元包邮”这个档位单独建模。分箱的边界怎么定,我用的是训练数据里的分位数,让每个箱里的样本数量大致相等。这样最后的 Embedding 表不至于出现某些箱样本极少、学不动的尴尬。

特征组合这里说一个重点:双塔的两个 Tower 之间不能做交叉特征,但同一个 Tower 内部是自由组合的。我见过很多新手把“用户性别 + 用户年龄段”拼成一个组合特征,哈希后过 Embedding,这完全没问题,而且往往比分别 Embedding 再拼接效果更好。因为组合特征天然帮模型完成了特征交叉的一部分工作,减少了网络深度的负担。

2.2 损失函数与采样策略:Sampled Softmax 的完整拆解

召回训练本质上是一个多分类问题:给定用户 u,要从全量物品 V 中找出正样本物品 i。直接对全量物品做 softmax,就是经典的 full softmax,但物品量级动辄千万,这头计算量谁也扛不住。

所以工业界标准做法是 sampled softmax——从全量物品里采样一部分作为负样本,用这些负样本去近似全量 softmax 的分母。具体形式是:

L = -log( exp(s(u,i)/τ) / ( exp(s(u,i)/τ) + Σ_j exp(s(u,j)/τ) ) )

其中 j 遍历所有采样到的负样本。这个公式的直觉是:把“这个正样本在所有物品中排在最前面”的目标,近似成“这个正样本在所有采样到的物品中排在最前面”。采样质量决定了近似的质量,这是双塔模型效果的生命线。

采样策略怎么选?我在项目里试过三种:随机负采样(uniform)、批内负采样(batch内其他物品当负样本)、困难负样本(hard negative)。

随机负采样最简单,但效果一般。它的问题是采样出来的负样本绝大部分和用户毫无关联,模型很快就能区分,梯度信号很弱。批内负采样成本为零,因为一个 batch 里 N 个正样本对,天然能构造出 N×(N-1) 个负样本对。YouTube 论文用的就是这种。但它有个著名缺陷:热门物品在 batch 内出现的频率高,被当成负样本的次数也多,模型会误以为热门物品天然是“差”的,导致热门物品的 Embedding 质量被污染。现实里,同一个 batch 的样本通常来自同一个 session 或相邻时间段的日志,热门物品在其中的比例会异常偏高,问题会被放大。

所以我现在用的组合方案是:主负样本用批内采样,额外从全局物料池里均匀采样一部分作为补充分母项,再混入一小部分 hard negative。Hard negative 怎么构造?最省事的做法是:拿一个已经训练好的双塔模型,算出每条正样本相似度排名第 100 到 500 之间的物品,当作负样本输入给模型。这些物品是模型当前“差点犯错”的样本,梯度信息量最大。比例上,我的经验是 10%~30% 左右,太高会把训练搞崩。

2.3 温度系数与 Embedding 归一化:一个关乎训练稳定性的细节组合

上文提过温度系数,这里把它和 Embedding 归一化放在一起解释它们对训练过程的联合影响。

先说 Embedding 归一化。我们在最后一层算出 embedding 之后,习惯先做 L2 归一化,再算相似度。写成代码就是user_vec = l2_normalize(user_vec)、item_vec = l2_normalize(item_vec),然后score = tf.reduce_sum(user_vec * item_vec, axis=-1)。这一步把相似度限制在 [-1, 1],能有效抑制任意一个特征维度取值过大导致的分数失控。不过,归一化会把向量的模长信息全部丢弃。模长本身有没有信息量?在一些业务里有,比如高热物品的模长可能隐含着“大众化”的信号。这个信号丢掉后,模型对物品流行度的感知会削弱。具体取舍要看业务,如果发现长尾物品召回变多但 CTR 变差,可以试试把exp(item_vec_norm)或者一个可学习的模长乘回去。

再说温度系数和归一化的配合。归一化把相似度控制在 [-1,1] 后,如果不加温度系数,softmax 输入的分母项和正样本项之间的差值最多只有 2。在 softmax 的梯度公式里,这个差值太小会导致所有样本对的梯度都接近一致,模型虽然不会跑飞,但收敛极慢。加上一个 0.1 左右的温度系数,相当于把差值放大十倍,模型能更清晰地感知“谁才是正样本”。

2.4 特征一致性:训练与线上 serving 的头号隐患

这一节是我最想强调的。双塔模型上线后遇到的大多数“离线效果好、线上效果差”的问题,根源都是特征不一致。

常见的不一致有三类。第一类:训练时用了未来信息。比如用全天的数据统计出的“当天曝光量”作为特征,训练时它是已知的,线上预测时当天的量根本还没累积完。这种特征在离线评估里会给出非常漂亮的指标,上线就原形毕露。第二类:线上特征取值和训练时使用的窗口不一致。比如训练用的是“近 7 天点击率”,线上的实时特征服务只给了“近 1 天点击率”,导致特征值域漂移。第三类:物品 Embedding 的更新延迟。线上 serving 时,物品向量是离线算好推到向量库的,如果新物品的向量没及时更新,它就只能用初始化时的随机向量,召回效果当然差。

我的排查思路很简单:上线前逐个特征检查“训练时的取值逻辑”和“线上 serving 时的取值逻辑”,列出对照表,特征来源窗口、归一化参数、缺失值填充策略必须完全相同。不要相信“应该一样”,要写代码验证。比如线上服务里对缺失值填充了 0,训练时如果用的是均值填充,这个特征就已经在制造偏差了。

3. 实操过程与核心环节实现

3.1 样本构造:从行为日志到训练样本的完整链路

第一步是明确正样本的定义。曝光点击算正样本,这是常规操作。但要不要把“曝光未点击”当作负样本?我的建议是不要。曝光未点击的样本存在严重的选择偏差——用户看到这个物品是因为系统在某些特征上判定它和用户相关,所以你拿到的负样本本身就是被筛选过的,把它们全当成强负样本,会扭曲模型学到的决策边界。

我现在的做法是正样本用曝光且点击的(或者点击且完播的,看业务目标),负样本用全局随机采样加批内负采样。正负样本比例控制在 1:5 到 1:10 之间。比例太小,模型分不清;比例太大,正样本信号被淹没,训练时间翻倍但效果不涨。

样本的时间窗口也值得注意。我见过有人拿最近 30 天的日志训练召回模型,这种做法隐患很大。用户的兴趣会漂移,30 天前喜欢“母婴用品”的用户,今天可能已经在看“户外露营”了。用太旧的数据训练,模型会把过期的兴趣学进去。我的经验是:主训练窗口取最近 7 天,如果数据量不足可以放宽到 14 天,但不要超过一个月。

3.2 模型结构与训练参数配置:一份可以直接用的基线配置

说了这么多理论,给一份我实测下来稳定可靠的基线配置。模型结构是:用户侧特征(用户ID、性别年龄组合、城市ID、近7天点击类目分布、搜索词 Embedding 均值)拼起来后过三层全连接,每层 512 维,激活函数用 ReLU,最后一层输出 128 维向量。物品侧特征(物品ID、类目ID、价格分箱、近7天 CTR、物品字数/时长等)同样过三层全连接,最后一层输出 128 维向量。

训练参数:batch size 1024,优化器 Adam,初始学习率 0.001,训练 3~5 个 epoch 后降到 0.0001 再训 2 个 epoch。Loss 用上文说的 sampled softmax,批内负采样数量由 batch size 决定(通常 1024 个负样本),再额外采样 2048 个全局随机负样本、混入 256 个 hard negative。Embedding 维度:用户 ID 和物品 ID 用 128 维,其余小特征用 32 维或 64 维。

这套基线配置在我的几个项目里第一次跑出来的 Recall@100 基本都能达到最终调优后的 80%~85%,如果你从零开始搭双塔,建议先按这个配置跑通全流程,再逐步调整。

3.3 向量索引构建与线上检索流程:内积和欧式距离的换算问题

模型训练完,下一步是把物品侧向量导出来建索引。我用的向量检索库是 FAISS,索引类型选 HNSW32。这里有一个经典的坑:FAISS 的IndexFlatIP、HNSW 默认走的是内积检索,但很多向量索引库(比如某些版本里切换 index 时)默认用欧氏距离。欧氏距离在向量未归一化时和内积排序不等价,会导致召回结果和离线评测完全对不上。

解决方案很粗暴:物品向量统一做 L2 归一化之后,内积排序等价于余弦相似度排序,而余弦相似度排序和欧氏距离排序在向量模长固定时恰好一致(因为||a-b||^2 = 2 - 2*cos(a,b))。所以只要保证物品向量全部归一化,用 FAISS 的 L2 距离或者内积,结果是一致的。我习惯显式地做归一化再计算,宁可多一步操作,也不留隐患。

线上检索流程大概是:用户请求进来,实时算用户向量,然后拿这个 128 维向量去 FAISS 索引里跑 ANN 检索,取 Top 500。这 500 个物品再按业务规则过滤(比如下架商品、地域不可用的物品),剩下的进粗排。整体耗时,我实测在 10 万 QPS 压测下,单次检索平均 15ms 左右,加上用户向量计算,全链路在 30ms 内能扛住。注意 ANN 检索的召回率不是 100%,HNSW 的 efSearch 参数要调,我一般设置 efSearch=256~512,在检索速度和召回率之间平衡。

3.4 离线评估与指标解读:不能只盯着 Recall

召回模型的离线评估,最常见的是 Recall@K:在每个用户的测试集里,看模型召回的 K 个物品里有多少是用户真实交互过的。但只看 Recall 会带来一个盲区——模型的打分分布是否合理,它看不出来。

举个例子。一个模型可能把所有用户的向量都学成了几乎相同的方向,它照样能出很高的 Recall,因为头部那些热门物品对所有用户都是“相关”的。这时候你需要看另一个指标:用户向量分布的平均余弦相似度,或者更直接一点——随机抽样 1000 对用户,计算他们向量的余弦相似度,如果平均相似度超过 0.9,说明模型出现了明显的“塌缩”,所有用户被挤在一个小区域,根本没有区分度。我跑过几个新手搭的双塔,这个指标经常翻车,仅次于塔的最后一层不加归一化的时候。

另一个值得关注的是分维度 Recall。比如按物品的类目维度拆开看 Recall@100,你能发现模型是不是在某个小众类目上完全召不回。长尾物品的召回率单独算一下,如果一个模型在头部物品 Recall 90%、长尾物品 Recall 只有 20%,那说明负采样或者特征设计出了问题。

4. 常见问题与排查技巧实录

4.1 召回效果差?按这个顺序查

我总结了一套排查流程,踩坑多次后固化成自己的标准动作。

第一步,查样本。正样本是不是对的、时间窗口是不是太旧、正负样本比例是否失衡。我见过一个诡异案例:某天开始召回指标突然大跌,查了一圈发现是埋点变动导致点击日志里混入了大量自动播放的无意义点击,正样本被污染了。

第二步,查特征。看训练和线上特征一致性,这个在 2.4 节已经重点说了。特别提醒:新加的特征尤其容易出问题,上线前最好先跑几天 shadow 验证。

第三步,查向量分布。按照上文说的“用户向量平均相似度”方法,看模型有没有塌缩。如果塌缩了,检查是不是最后一层没做归一化、学习率过大、或者温度系数太小。

第四步,查索引。确认线上检索用的索引和离线评测时用的索引参数一致。HNSW 的 efSearch 大小、IVF 的 nprobe 设置,都会影响线上召回结果。这类问题有个特点:离线指标高,线上 Case 分析时发现召回的物品在向量空间里确实近,但远没有离线跑出来的那么“近”,大概率是索引参数缩水了。

4.2 冷启动物品召回不到,怎么办

双塔模型对冷启动物品天然不友好,因为它的 ID Embedding 没有足够的样本去学。这个问题我一般用三个手段叠加解决。

第一个手段:在特征里强化“物品静态属性”的权重。类目、品牌、价格带这些特征不要只做一个 Embedding,可以尝试把这些特征单独拼一路,加大它们在最终向量中的占比。相当于人为告诉模型:ID 你没见过没关系,你可以先根据类目和价格来猜。

第二个手段:训练时加辅助 loss,让物品的 Embedding 和它自身的内容向量(比如 BERT 算出的文本向量)尽量靠近。做法是给物品塔的最后一层输出加一个 MSE loss,让它去拟合内容向量。这样新物品虽然没有行为 ID Embedding,但内容向量已经能撑起它的向量表达。

第三个手段是工程对策:新物品在冷启动期内,直接用物品属性特征算一遍向量存进索引,不要等 ID Embedding 学好。我甚至会为冷启动物品单独建一个小索引,检索时把常规索引和冷启动索引的结果合并去重,用业务规则调一下权重。三步一起上,冷启动召回率大概能提升 15 个百分点。

4.3 离线在线不一致的经典案例复盘

最后分享一个真实的案例。一个电商推荐项目,双塔模型离线 Recall@100 做到 0.42,比线上老模型高 4 个点,结果上线之后业务指标不升反降。我们排查了整整两天。

最终定位到的问题在于:训练样本里有一个“用户是否为会员”的特征,离线训练时,这个特征取自样本生成时刻的实时会员状态;但线上 serving 时,这个特征来自用户画像服务的一个 T+1 更新缓存。也就是说,一个用户昨天刚开的会员,离线训练时特征是“会员”,线上服务拿到的还是“非会员”。特征值在训练和推理时不一致,模型在这个维度上学到的规律在线上完全失灵。

修正方案是:把“用户是否为会员”改成离线特征同样使用 T+1 的画像更新逻辑,让训练和线上用同一份数据口径。改完再上线,业务指标恢复了预期,离线在线也基本对齐了。这个案例我每次培训新人都要讲一遍:双塔模型这个架构本身不复杂,出问题几乎都出在数据链路的一致性上。

从我个人的实际经验来说,双塔召回模型的优化天花板,其实不在模型结构,而在你对业务样本和特征口径的理解深度。把样本做干净、特征做对齐、负采样做到位,哪怕模型结构简单一点,效果都能超过那些堆了很多花活的复杂模型。希望这篇能帮你少踩几个我当年踩过的坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进