双塔召回模型实战:特征工程、训练优化与向量检索落地 1. 双塔在我这里的定位先想清楚它解决什么问题1.1 召回阶段的两个本质约束我入行做推荐系统第一年都在跟召回打交道。很多人一开始容易把召回和排序混为一谈觉得都是“从候选里挑好的”只是召回粗一点、排序精一点。这个理解不能说全错但它掩盖了召回最核心的矛盾——算不过来。线上排序面对的是几百个候选模型可以做得很大交叉特征、Transformer序列、多目标打分怎么复杂怎么来。但召回面对的是几千万甚至上亿的物料库要在几十毫秒内把所有候选都过一遍模型再把分数最高的几百个捞出来。如果沿用排序那种把所有特征全塞进一个网络的做法算一遍全库就要几十秒根本没法用。所以召回模型从设计的第一天起就受两个约束一是候选体量极大二是延迟要求极严。任何结构设计都必须先回答这两个问题否则再花哨的模型也上不了线。1.2 双塔的结构先验把“算得快”前置到模型设计里双塔召回模型Two-Tower Retrieval Model之所以能成为行业标配核心在于一个非常朴素的设计把用户和物品分别编码成两个独立的向量然后用內积或余弦相似度表示匹配程度。因为两个塔互相独立物品这一侧完全可以离线算好把几千万个物品全量预计算成embedding向量建好索引线上请求来了只需要算一次用户向量然后去向量索引里做最近邻检索。这样一来全库打分的复杂度从“每个候选过一遍模型”降成了“建索引一次 线上查索引一次”延迟直接降了一个数量级。这个思路在很多领域都能看到本质上是把模型的计算量从实时路径挪到离线路径用空间换时间、用预计算换实时性。我第一次搭双塔的时候有个资深同事跟我说了一句后来一直受用的话双塔不是模型设计出来的是性能逼出来的。你先把线上必须实时算的部分和可以提前算的部分切清楚模型结构自然就往两塔方向走了。1.3 哪些场景不适合双塔双塔好归好但不是什么场景都能硬套。我的经验是如果对特征交叉的精度要求极高且候选体量不大双塔往往不如单模型。双塔在编码阶段就把用户和物品隔离开了两者的特征在这个阶段完全没有交互只能最后在向量空间里用内积或余弦去拟合相关性。这个“信息瓶颈”会让模型的上限受限。比如电商场景里“用户最近买了手机”和“物品是充电宝”之间的关联如果是用户塔和物品塔分开编码需要各自学到足够抽象的表示最后才能在空间里碰到。如果换成排序模型里直接做二阶交叉这种模式学起来要容易得多。所以我的经验是候选量在千万级以上、延迟要求高、需要全库检索的场景双塔是最佳选择候选量只有几万、可以全量打分的小场景不要为了用双塔而用双塔。2. 用户塔和物品塔特征怎么进、网络怎么搭2.1 用户侧特征怎么处理确定了双塔结构之后接下来最花时间、也最影响效果的是特征工程。我见过不少团队一上来就狂堆特征结果离线指标没涨多少线上反而变差了。特征不是越多越好关键是每一类特征跟“用户意图”的相关性强不强。我一般把用户侧特征分成三层基础画像层年龄、性别、城市、注册时长、消费力档位这类静态属性。它们变化很慢但能刻画出稳定的兴趣偏好是用户塔的底座。短期行为层最近点击、收藏、加购、下单的物品ID序列最近搜索词。召回模型里短期行为往往比长期画像更重要因为召回看的是“此刻用户可能想要什么”。实时上下文层当前时间、星期几、所处场景首页/详情页/搜索页还有当前请求的会话ID。这里有一个很多新人容易忽略的点双塔的特征并不需要全部实时进塔。用户侧短期行为序列里的物品ID我会先映射到物品embedding里做序列聚合得到一个“兴趣向量”再拼上画像特征一起进MLP。让行为序列走在物品embedding共享的空间里比单独建一个大embedding参数表效果更稳。2.2 物品侧特征怎么处理物品侧相对简单常见的几类内容属性类目、品牌、价格带、标题分词后的关键词ID、图文/视频标签。统计量点击率、收藏率、销量、好评率、过去7天曝光量这些都要做分位数归一化。动态画像物品最近被哪些偏好人群点击过可以做一个简单的“物品侧用户画像向量”本质是反向聚合对冷门物品的召回尤其有用。还有一个细节需要注意统计类特征很容易出现泄漏。比如把“当天的点击率”直接塞进特征离线训练的时候你拿到的当天数据是完整的但线上在线推理时当天数据根本还没统计完特征值对不上离线指标和线上效果就会严重偏离。我的处理方式是所有统计特征必须延迟至少一天用T-1的数据来算。宁可特征旧一点也要保证离线在线特征分布一致。2.3 embedding维度、层数和激活函数双塔的网络结构说实话不需要太复杂。我的默认起步配置是每个塔两到三层MLP每层从256到128到64这样的递减结构激活函数用ReLU或LeakyReLU最后一层输出embedding向量的维度选32到64之间。embedding维度这个参数值得认真调一下。维度过小表达力不够用户和物品的语义区分不开维度过大一是内存压力变大几千万物品 × 64维 × 4字节开销不小二是容易过拟合训练集的共现模式。我常用的做法是先调到32维跑一版看hitrate500然后64维、128维各跑一版画出维度-指标曲线。很多情况下64维就够用了再往上提升非常有限。2.4 为什么不共享塔的结构有人问过我既然用户塔和物品塔都要编码能不能共用一套参数让两边互相补充信息这个问题看起来合理实际上一旦共享就会出现一个致命问题线上推理时物品侧虽然可以离线算用户侧必须实时算但两个塔用了同一套参数物品embedding更新就必须依赖用户塔的参数同频更新离线预计算就失去意义了。更重要的是用户特征空间和物品特征空间是异构的前者偏行为序列后者偏内容属性强行让它们共用一套非线性映射反而会损失各自的特征表达效率。所以双塔的两个塔不共享参数不是设计上的偷懒而是工程和表达上的双重必然。真正需要让用户和物品“对齐”的地方靠的是损失函数和向量空间约束不是靠参数共享。3. 训练细节样本、损失和温度决定双塔质量的三板斧3.1 正样本定义与负样本配比双塔训练最常用的框架是in-batch softmax一个batch里有若干用户每个用户有一个正样本物品batch里的其他物品充当负样本。听起来简单但正负样本的定义直接影响模型学到的东西。正样本我用的是“点击且停留时长超过阈值”或者“转化行为”这样的强正样本尽量避免把单纯曝光当正样本。曝光未点击不一定是用户不感兴趣可能是位置太偏根本没看到用它当负样本会引入大量噪声。负样本的需求量非常大因为召回面对的候选空间动辄千万级模型必须见过足够难的负样本才能把边界学出来。我的配比经验是batch内负采样为主每32个batch再额外添加一组被难负样本——具体做法是从全库随机抽一批物品和当前batch的用户交叉算相似度选其中最容易被误判为正样本的那部分物品显式地进loss。这个做法能让模型快速学会“这种和用户相似但又不匹配的物品不能给高分”对提升召回精度非常明显。3.2 batch内负采样为什么默认被采用batch内负采样最大的好处是简单、省事。别的负样本方案要么需要额外的负样本队列要么需要定期从全库随机采样都得额外维护。in-batch做法天然就是“当前batch内互相作为负样本”正负样本都来自同一个数据分布训练效率很高。但有个大坑要提前说in-batch负采样会给热门物品开挂。热门物品出现的频次高它被当作负样本参与更新的次数也远多于冷门物品模型会倾向把几乎所有物品的embedding往热门物品方向压缩导致冷门物品的向量空间被挤成一团。业界通用解法是“频率加权修正”——在softmax里用物品出现频率对logit做调整压低热门物品的负样本贡献。我第一次跑双塔没加这个修正离线hitrate看着还行线上一看新物品召回率掉了三分之一排查了很久才意识到是热门物品压制的问题。3.3 温度系数的调参实验温度系数temperature是softmax里的关键超参直接控制相似度分布的“锐利程度”。温度越低softmax越接近argmax模型会越激进地拉近正样本距离、推开负样本温度越高分布越平滑模型学到的区分度就越弱。这个参数调不好双塔的效果能差出一大截。我做过一组对照实验数据是500万用户、2000万物品batch size4096embedding维数64正样本为点击且停留超过30秒温度系数离线hitrate500平均相似度相似度方差0.010.3410.520.310.050.3680.440.280.10.3820.380.240.20.3710.310.190.50.3390.220.12可以看到温度0.1附近效果最好。温度太低虽然离线hitrate不至于崩但从embedding分布上看向量都趋向集中在一个小区域泛化能力很可疑温度太高则向量分布太散相似度区分度不够。我的建议是从0.1起步每0.05一档去扫别跳太大因为在0.05到0.2这个区间里指标变化非常敏感。3.4 做不做L2归一化直接改变embedding分布训练完了之后物品embedding不是直接丢给检索引擎的。我强烈建议在最后一层对embedding做L2归一化让所有物品向量都落在单位超球面上然后用余弦相似度或内积检索。归一化有两大好处避免embedding模长干扰不做归一化时热门物品的模长通常偏大内积检索很容易被模长主导最后捞出来的全是“长向量”物品而不是真正语义相关的物品。让相似度范围可控单位化之后相似度落在[-1, 1]线上阈值的语义更清晰调参也方便。归一化还有一个附带优势我在做模型更新时可以先算用户塔的初步向量除以它的模长得到真正的embedding。这样用户向量的实时计算链路更简单稳定不用每次都考虑数值范围波动的问题。4. 上线工程化从模型产物到稳定服务的完整链路4.1 生成item embedding与索引构建模型训练完成后第一步是离线批量生成物品embedding。这个过程可以做得很重把全量物品的特征重新组装一遍跑一遍物品塔推理同时把模型导出成固化格式。我一般习惯把生成的embedding表按日期分区存储比如item_embedding/dt2025-01-15.parquet每一行是item_id和64维float向量。拿到embedding表之后要建向量索引这一步的核心是“近似最近邻”ANN检索不是精确检索。千万级体量下精确最近邻的查询代价太高业界普遍用HNSW或PQ类算法把“找到最相近的几百个”变成“大概率找到较相近的几百个”。4.2 向量检索引擎选型这块我先后对比过几个方案简单说说结论本地浮点暴力检索适合百万级以内的探索阶段代码逻辑最简单但数据量翻几倍之后内存和耗时都顶不住。FAISS带IndexIVFPQ量化百万到千万级都适用检索速度快内存控制好但要自己处理索引更新和版本管理。ES dense vector插件适合团队已有ES基础、想让向量检索和业务过滤条件合在一起写的场景但索引更新延迟稍高吞吐上限不如专门的向量库。专用向量数据库高并发、低延迟、运维省心适合规模较大且团队人力有限的阶段。我的建议是如果刚开始从零搭双塔先用FAISS把链路跑通数据量和业务形态稳定下来之后再评估要不要迁到专用向量库。不要一开始就引入重型基建双塔的效果验证永远是第一步工程优化排在后面。4.3 user embedding实时计算链路物品侧可以做全量离线预计算用户侧必须实时算因为用户兴趣是动态的刚才点的那个物品下一次请求就该影响召回结果。我的做法是请求进来后先把用户画像特征和最近行为序列从特征服务里取出来行为序列里的物品ID在共享Embedding表里映射成向量做序列聚合拼上画像特征走一遍用户塔推理得到用户向量。这里有个性能优化点用户塔的MLP层数很少embedding聚合又是矩阵运算完全可以放在GPU或CPU的高性能推断引擎里做单次延迟控制在1到2毫秒内。如果行为序列里物品量很大可以提前把高频物品的embedding缓存在本地避免每次都要远程查表。4.4 索引更新节奏与一致性问题物品侧不可能只建一次索引就完事新物品持续入库老物品特征也在变。索引更新有两个主流策略全量重建每天凌晨用最新的物品embedding表重建索引优点是简单透明缺点是当天上线的新物品要等到第二天才能被召回。增量更新新物品和一些特征变化的物品即时算向量、单独建一个小索引线上查询时“大索引 增量小索引”合并检索新物品几分钟内即可被召回。我现阶段是两种策略配合大索引一天重建一次增量索引每5分钟跑一轮。有一件特别容易踩的坑是向量索引里的embedding版本和当前线上的模型版本要严格对应。如果模型更新了用户塔参数变了但物品embedding还是老版本等于用户和物品不在同一个向量空间里召回质量会莫名其妙下降。我见过一次线上的诡异case模型升级后热门query召回结果全体漂移最后定位到就是忘了同步重建物品索引。5. 我踩过的双塔的坑以及怎么验证效果5.1 热门Item压制与退火策略前文说了in-batch负采样会带来热门物品压制。当时现象很典型线上召回结果里销量Top 100的物品占据了几乎一半的候选位新发布和长尾物品大量牺牲。我做三件事解决在softmax logits里加入-alpha * log(p_item)的频率惩罚项p_item用物品在训练样本里的出现概率估算alpha从0.3开始调把过曝光曝光量极高但正反馈率偏低的物品在训练时降采样减少它们当负样本的频率训到后期把温度系数稍微调大零点零几让模型对高频负样本不再那么敏感。这套组合拳打完之后长尾物品的召回比例明显回升整体hitrate反而涨了说明之前确实是热门压制掩盖了真实质量。5.2 冷启动和已读过滤双塔召回在冷启动上天然有短板。新物品没有任何历史行为物品塔输出的embedding基本由内容属性特征决定但内容属性太稀疏的时候向量会掉到空间里一个很偏的位置检索时大概率捞不出来。我的补救办法是给新物品额外配一个“探索性报名”通道凡是发布时间在24小时内的物品不管embedding相似度如何固定分配一定比例的召回位对上线初期曝光不满意的新物品用同类别热门物品的embedding做插值平滑把它往热点方向拉一拉等有了自己的行为统计再逐步回归原向量用户侧已读过滤别放在召回模型里做先让排序或规则层去过滤。把过滤塞进召回会严重影响向量检索的召回率因为过滤条件没法直接在最近邻检索里表达。5.3 从embedding空间分布判断模型健康度训练完模型不要只看指标一定要抽看embedding空间。我的习惯是做两件事一是取一些已知语义相似的物品组比如同一品牌的热销单品、同系列视频算它们的向量平均相似度看是不是明显高于随机物品组。如果连这一点都做不到说明训练没收敛或特征没学好。二是画出全量物品embedding的分布用降维后可视化或者只看模长和方差统计如果绝大多数向量挤在一个小簇里大概率是温度太低或者频率修正没加如果分布均匀到完全没有聚合那可能是特征太弱、信息量不够。这个基于空间分布的体检习惯帮我提前躲过了两次线上事故。5.4 离线指标和线上A/B的搭配观察最后说一下效果验证。双塔召回我首选的离线指标是hitratek——把线上某个时期的真实日志拿来用当时候选池做评估看目标物品是否出现在模型召回的前k个结果里。k我一般取500或1000这和线上召回下发数量绑在一起。这里的坑是拿当天日志直接评估会有数据分布和回溯窗口的重叠问题我后来改成用T-2的日志做评估T-1的数据完全隔离出来作为验证集。线上A/B我会同时观察几个维度分开列出来更清楚指标观察目的召回候选点击率召回质量有没有提升长尾物品曝光占比是否被热门压制单次请求召回延迟工程链路有没有退化新物品召回新鲜度索引更新是否正常这几个指标要一起看单看点击率容易被热门物品拉高掩盖长尾恶化单看延迟又会忽略模型质量变化。我习惯把A/B指标做成一个看板连续观察一周再下结论别第一天就急着切换全量流量。双塔召回模型的完整落地链路大致如此从特征梳理到结构选型从训练细节到工程部署每一环都有隐藏的坑。我花了很长时间才意识到双塔的真正难点不在模型本身而在把“离线预计算”和“在线实时计算”这条线切得足够干净并且在训练目标里始终保持对全库候选分布的正确建模。做到这一层它才能真正稳定地支撑起一个推荐系统的召回底座。