双塔模型核心思想:user tower 在线单次执行,item tower 离线批量预计算存向量,推理时只做一次最近邻搜索——以精度换速度,可实现 20ms 内打分千万商品。
双塔模型通常被介绍为一种神经架构。但更好的理解方式是:它是唯一能在二十毫秒内对千万量级条目打分的架构——它的一切古怪之处都源于这个约束。
一个将用户和物品一起读取的模型——交叉编码器——更准确,因为它能计算依赖于两者的特征。但它也无法用于召回:每有一个候选就要跑一次,所以对千万量级的目录打分意味着每次请求要跑一千万次前向传播。
双塔模型主动放弃了这一点。一个编码器读取用户及其上下文,另一个读取物品,分数是两个输出之间的点积:
s(u, i) = dot( f(user features), g(item features) )
f user tower -- 每次请求跑一次
g item tower -- 离线运行,每个物品跑一次
因为 g 看不到用户,每个物品向量都可以在批处理任务中计算并存储。请求时只需跑一次 f,然后把问题转化为在固定向量集上的最近邻搜索——这是一个已解决的问题,成本曲线也很好理解,在 HNSW 页面和存储成本页面有详细讨论。这就是全部的权衡:用召回时的精度换排序阶段在幸存者上的精度。召回、排序、重排的级联论证走了不同的路,但得到了相同的结果。
注意物品塔能读取什么:文本、图像、类别属性,以及物品 ID 嵌入。正是属性输入使该架构能够处理新物品——一个全新的物品即使没有有价值的 ID 嵌入,仍然可以从其文本和类别获得一个合理的向量。纯矩阵分解做不到这一点,这就是双塔模型取代它的实际原因。
训练需要负样本,而每个正样本对应一千万个负样本。显式采样成本很高,所以标准做法是复用 batch:对于每个(用户,正样本物品)对,将 batch 中的其他物品作为负样本。损失是 batch 上的 softmax:
loss = -log( exp(s(u, i+)) / SUM over j in batch of exp(s(u, j)) )
这是免费的,但也是有偏的,而且这种有偏性很重要。Batch 是从交互流中采样的,所以一个物品出现在 batch 中的频率大致与它被交互的频率成正比。因此,一个热门物品被采样为负样本的频率远高于冷门物品,模型会学习将其分数压低,以补偿这个与它是否是好推荐无关的频率。如果不加修正,系统会系统性地低估推荐那些大多数人都想要的东西。
修正方法是在 softmax 之前从 logit 中减去采样概率的对数:
s_corrected(u, j) = s(u, j) - log p_j
p_j 物品 j 作为 batch 内负样本出现的概率,
由流式频率计数器估算
推理很短。采样 softmax 通过加权采样近似完整 softmax 分母;为了让近似无偏,权重必须抵消采样分布。从 logit 中减去 log p_j 正好是在求和内部将 exp(s) 除以 p_j,这就是估计量所需的重要性权重。Yi 等人(2019)为大语料物品推荐建立了这一理论,包括一个在物品分布移动时也能工作的 p_j 流式估计器;Covington、Adams 和 Sargin(2016)早几年描述了同样的召回后排序分割,并针对视频推荐使用了采样 softmax。
如果从这篇文章中只能带走一个工程实践,那就是:当双塔模型表现得好像不喜欢你的畅销品时,第一假设是缺失了采样修正,而不是建模问题。症状很独特——在由采样负样本计算的离线指标上质量看起来不错,因为那些指标继承了同样的偏差。
离线,夜间或小时级
for each item: vector = item_tower(features)
在所有物品向量上建立 ANN 索引
在线,每次请求
user_vec = user_tower(user features + context)
candidates = ann_index.search(user_vec, k = 500)
filtered = 应用业务规则、库存、权限
ranked = cross_encoder(user, filtered) # 或 GBDT
final = 多样化处理(ranked)
两个运维细节出错时会带来大量问题。塔必须一起版本化:用版本 3 的 item tower 构建的物品索引对版本 4 的 user tower 没有意义,而且失败是静默的——你得到的是看似合理但实际上是胡说八道的结果,而不是报错。重建物品索引就是一次部署,这使得重新嵌入迁移问题成为发布流程的一部分,而不是一次性的工作。
交叉特征。"这个用户之前从这个卖家买过东西"是关于这个 pair 的事实,而两个独立计算的向量的点积无法表示它。这不是训练失败,而是架构问题。交叉特征属于排序阶段,这是排序阶段存在的主要原因之一。
硬约束。库存、地域、授权、权限。点积产生一个分数,而不是保证,一个训练来降权缺货物品的模型仍然偶尔会把缺货物品排到第一位。约束放入索引中作为过滤器或后置过滤器,绝不放入损失函数。
序列,除非你主动构建。用户塔读取一袋过去的交互会丢失顺序,而顺序携带真实的信号——五分钟前购买的物品与一年前购买的物品含义不同。用序列编码器喂入现在是标准做法,这是对用户塔的改动,而不是对架构的改动。
自我解释。输出是学习空间中的一个点积,没有可解释的轴。如果产品需要一个理由字符串,它必须来自其他地方——最近交互的物品、共享属性——而那个理由有时并非模型实际高分的真正原因。
物品塔通常是对标题和描述的文本编码器,这种情况下实际问题是选择哪个嵌入模型来运行它,以及完整目录重新编码的成本是多少。这是基于目录规模的每 token 计算——模型目录中的嵌入条目包含了计算所需的维度数和每 token 价格,维度数在这里很重要,因为它还决定了你将保存在内存中的 ANN 索引的大小。