前面讲了召回的全景、经典的 ItemCF 和兜底的热门召回。这一篇讲工业界真正的召回主力——双塔(Two-Tower / DSSM / 向量召回)。延续「录像带出租店」的比喻,这次的主角是店里新请来的「资深选片员」:她不像老挑片员那样翻账本数共现,而是给每位顾客和每部电影各画一张「气质画像」(向量),谁的画像离得近就推谁。
Table of contents
Open Table of contents
一句话理解
双塔召回 = 录像带店新请的「资深选片员」。
老挑片员(ItemCF)只会翻账本数「谁和谁经常被一起租走」——她不懂电影、不懂顾客,只认共现。新来的资深选片员不一样:
- 给每位顾客画一张「观影性格画像」——把你的年龄、性别、最近看过的片,浓缩成一串数字(一个向量);
- 给每部电影也画一张「气质画像」——把它的类型、年代、主创,也浓缩成同样长度的一串数字。
然后她把两张画像放进同一个坐标系里量一量距离:画像离得越近 = 越可能对上眼。
关键魔法:电影的画像可以提前一次性全画好(离线建库),顾客来了只需临时画一张顾客画像,再去库里「找最近的 200 张电影画像」——几十毫秒搞定。这就是过去 10 年工业界召回的绝对主力(YouTube、淘宝、抖音都在用)。
Level 1:老挑片员的天花板 — 为什么需要资深选片员
老挑片员(ItemCF)很能干,但她有几堵撞不破的墙:
| 老挑片员(ItemCF)的墙 | 为什么撞不破 | 资深选片员(双塔)怎么破 |
|---|---|---|
| 新片召不出来 | 没人租过 → 算不出共现 | 看电影的画像特征(类型/年代/简介),哪怕零交互也能算向量 |
| 长尾片永远进不了榜 | 共现太少,挤不进任何片的 Top-100 邻居 | 向量空间里长尾片照样有自己的坐标,不靠共现 |
| 只会「看了 A 的人也看了 B」 | 死记硬背共现,学不到深层规律 | 神经网络能学到「科幻迷 + 90 后 → 偏好硬核太空片」这种泛化规律 |
| 顾客画像无法利用 | ItemCF 根本不看用户是谁 | 用户塔显式吃进年龄/性别/历史序列 |
核心区别一句话:老挑片员是记忆(memorization)——背下「谁和谁一起出现」;资深选片员是泛化(generalization)——学出「什么样的人喜欢什么样的片」。
这正是 ItemCF 演化图的终点:
ItemCF → 矩阵分解(MF) → 把相似度学成"向量内积" → 加深度编码器 + 侧信息 + 负采样 → 双塔
(数共现) (学向量) (p_u · q_i) (神经网络 + 特征) (工业标配)
重要洞见:双塔本质上就是「加了深度编码器和侧信息的协同过滤」。MF 给每个用户/物品直接学一个向量,双塔则把「用户特征 → 向量」「物品特征 → 向量」这两步交给神经网络去算——于是新物品、新特征都能用上了。
Level 2:两座塔到底是什么
「塔」这个词唬人,其实就是两个独立的小神经网络,各管一摊。
两座结构相同、参数独立的 MLP:用户塔只吃用户特征、物品塔只吃物品特征,直到最后的内积才碰面。
用户塔 — 把「你是谁」压成一个向量
| 特征类别 | 例子 | 怎么进塔 |
|---|---|---|
| 静态画像 | 年龄段、性别、职业 | 每个离散值查一个 embedding |
| 行为序列 | 最近看过的 50 部片 | 50 个物品 embedding 做平均/池化(或用注意力) |
| 上下文 | 当前时段、设备 | embedding 拼接 |
把这些 embedding 拼成一个长向量,再过几层 MLP,最后吐出一个 128 维的「用户向量 u」。
物品塔 — 把「这部片是什么」压成一个向量
物品塔结构几乎一模一样,只是吃的特征换成电影侧的:ID 类(item_id embedding)、属性类(类型/年代/片长)、内容类(标题/简介的文本向量)。同样过几层 MLP,吐出一个 128 维的「物品向量 v」。
关键:物品塔只吃电影自己的特征,完全不看是哪个用户在查询——这一点 Level 3 会反复强调,它是整个双塔能上线的命根子。
同一个「气质坐标系」— 内积就是匹配度
两座塔输出的向量维度相同(都 128 维),活在同一个空间里。匹配度就是两个向量的内积(或余弦相似度):
score(u, i) = u · v_i = Σ_{d=1..128} u_d · v_{i,d}
| 几何直觉 | 含义 |
|---|---|
| 两个向量方向一致(夹角小) | 内积大 → 强匹配 |
| 两个向量垂直 | 内积≈0 → 不相关 |
| 两个向量方向相反 | 内积为负 → 反感 |
一句话:训练的全部目标,就是让「对上眼的用户-电影对」的向量在空间里靠近,让「没对眼的」互相远离。这跟 ItemCF 的 BPR 一脉相承——只是这次坐标是神经网络从特征里算出来的,不是直接为每个 ID 死记一个。
Level 3:为什么非得是「两座」塔 — 整个设计的命根子
这是双塔最容易被忽略、却最重要的一点。很多人第一反应是:「把用户特征和电影特征一股脑塞进一个大网络,让它直接输出匹配分,不是更强吗?」
单塔确实更准。但它根本没法上线。 我们来算笔账。
假如只有一座塔会怎样
单塔(用户特征 + 电影特征一起进网络):
想知道"你"对全库 3,706 部电影的匹配度
→ 必须把 (你, 电影1)、(你, 电影2)、… (你, 电影3706)
全部喂进网络跑 3,706 次前向
→ 真实工业场景:1 亿部候选 = 每次刷新跑 1 亿次神经网络
→ 几分钟都算不完,p99 < 30ms 想都别想 ❌
问题的根源:单塔里用户和电影的特征「纠缠」在一起,你没法把电影部分提前算好——因为电影向量的计算依赖于「是谁在看」。
「两塔分离」换来的三件大事
双塔强行规定:用户塔只看用户、物品塔只看物品,两塔直到最后的内积才碰面。这个「分离」约束换来三件救命的事:
命根子:物品塔不依赖用户 → 物品向量离线全算好、建 ANN 索引;在线只算 1 个用户向量 + 1 次 ANN 搜索。
| 换来的好处 | 为什么单塔做不到 |
|---|---|
| ① 物品向量能提前全算好 | 物品塔不依赖用户 → 离线一次性把全库向量都算出来存好 |
| ② 在线只需算 1 次用户塔 | 你来了只算 1 个用户向量,剩下的全是「查表 + 找最近邻」 |
| ③ 能用 ANN 索引加速到毫秒 | 内积/余弦的最近邻搜索有成熟的 ANN 算法;单塔的「任意网络打分」没法建索引 |
一句话记住双塔的铁律
两个塔在训练和推理时都「互相不能偷看」对方的输入。
一旦让物品塔看见「是谁在查询」,你就没法离线把物品向量算好,1 亿候选每次都得重算——毫秒级响应直接崩盘。这不是设计偏好,是上线可行性的硬约束。
代价:分离也是有代价的——用户和电影特征直到最后一刻(内积)才交互,模型学不到细粒度的特征交叉。这正是双塔的头号命门,Level 6 详谈。这也是为什么双塔只配当召回,精排得交给能做特征交叉的「老板」(排序)。
Level 4:资深选片员怎么「学」— 训练
向量不是天生的,是训练出来的。训练的本质是不断调整两座塔的参数,让对上眼的 u·v 变大、没对眼的变小。
4.1 正样本从哪来
和 ItemCF 一样,正样本 = 用户真实的正反馈交互:
用户 42 看了《盗梦空间》且评分 ≥ 4
→ (user=42, item=《盗梦空间》) 就是一个正样本对
4.2 最大的坑:负样本得自己造(in-batch 负采样)
这是双塔(以及所有召回/排序模型)最反直觉的地方——和排序遇到的是同一个坑:
账本只记了「谁看了什么」(正样本),从来没记「谁讨厌什么」(负样本)。
第一层:训练想让模型干什么 —「正样本大、负样本小」
一个「样本」其实是一对 (用户, 电影),模型给每一对算一个匹配分 u·v。所谓「正样本大、负样本小」说的是这一对算出来的匹配分:
| 术语 | 这一对是什么 | 希望它的 u·v |
|---|---|---|
| 正样本「大」 | (你, 你真喜欢的片) | 高分 ✅ |
| 负样本「小」 | (你, 你不感兴趣的片) | 低分 ✅ |
因为向量都做了 L2 归一化,u·v 就是余弦相似度——本质是两张「气质画像」离得近不近。大 = 画像挨在一起,小 = 画像离得远。
第二层:只用正样本,模型会「作弊」(向量坍缩)
如果训练时只有正样本,损失函数只能写成「让每一对正样本的 u·v⁺ 尽量大」——它只说了「正样本要大」,没说「别的要小」。模型会发现一条偷懒却满分的捷径:
把所有用户向量、所有电影向量,全都指向同一个方向(挤成一团)。
向量都同向了,任意两个向量的内积都接近最大值 1——正样本满分,但你来查询时全库每部片得分都是 1,根本分不出谁更适合你。这就是 向量坍缩(embedding collapse)。
打个比方:老师出的全是判断题,标准答案永远是「对」。有个学生发现规律——不管问啥一律答「对」,照样次次满分,但他其实啥都没学会。负样本,就是那些「答案是错」的题。
解药:白嫖同 batch 的正样本当负样本(in-batch 负采样)
「去全库给每个正样本采一堆负样本」很费劲——每多一个负样本,就要多过一次物品塔算它的向量。双塔的精妙之处是它发现根本不用去全库采:
我手上不是已经算好同 batch 里 A、B、C 三部电影的向量了吗(为了正样本算的)?小王的正样本是 A,那 B、C 不是小王的菜,直接拿已经算好的 B、C 当小王的负样本——一次新计算都不用多做!
把 batch 内 U·Vᵀ 算成一个矩阵:对角线天然是正样本、其余全是免费的负样本,省掉了显式全库采样最费劲的开销。
放大到真实 batch(比如 256),batch 内 U·Vᵀ 就是个 256×256 矩阵,对角线是 256 个正样本,其余 256×255 个全是白嫖来的负样本。
| in-batch 负采样的好处 | 说明 |
|---|---|
| 零额外计算 | 负样本复用同 batch 别人的正样本电影向量,不用额外过物品塔 |
| 不用维护采样器 | 没有「去全库随机挑 + 查它是不是该用户正样本」的去重过滤 |
| 算一个矩阵就够 | U·Vᵀ 一次矩阵乘法,对角线正、其余负 |
| 天然偏向热门负样本 | 热门片更容易出现在 batch 里当负样本——这是个坑,要靠 4.5 logQ 修正 纠 |
4.3 损失函数:sampled softmax / 对比学习
有了「1 个正样本 + N 个 batch 内负样本」,目标就变成一个多分类问题:「在 u1 和这 256 个候选里,模型应该把最高分给真正的正样本 i1」。用 softmax 交叉熵(sampled softmax):
exp(u · v⁺ / τ)
L = −log ──────────────────────────
Σ_{j∈batch} exp(u · v_j / τ)
v⁺:正样本物品向量(分子,要拉大)Σ_j:整个 batch 的物品向量(分母,含 1 个正 + N 个负)τ:温度系数(下一节)
为什么这个公式不会坍缩:分子是正样本(要大),分母里那一堆负样本项就提供了「推远」的力。如果分母里没有负样本、只剩分子自己,这个比例恒等于 1、损失恒为 0,模型不学任何东西就「满分」,正好坍缩。正是分母里的负样本撑住了整个向量空间。
4.4 温度系数 τ — 把分布调「陡」还是调「平」
τ(temperature)是 softmax 里给内积整体缩放的旋钮:
| τ 取值 | 效果 | 直觉 |
|---|---|---|
| τ 小(如 0.05) | 分布很「陡」,只有最匹配的少数几个拿到几乎全部概率 | 资深选片员很挑剔 |
| τ 大(如 1.0) | 分布很「平」,匹配度差不多的都能分到概率 | 资深选片员很佛系 |
工程经验:双塔的 τ 通常调在 0.05 ~ 0.2 之间,是个敏感且重要的超参,需要在验证集上调。
4.5 流行度纠偏(logQ 修正)— 别让爆款霸屏
4.2 埋了个雷:in-batch 负采样天然偏向热门片——越热门越容易出现在 batch 里当负样本。这会扭曲 softmax 估计:模型分不清「这片得分高」是因为真的匹配,还是仅仅因为它本来就常被看到(和热门召回的马太效应异曲同工)。
解药:logQ 修正。训练算 logit 时,减去该物品被采样到的概率的对数,把「流行度带来的虚高」扣掉:
logit_corrected(u, j) = u · v_j − log p_j
其中 p_j 是物品 j 在 batch 里被采到的概率(≈ 它的流行度频率)。这一项只在训练时加进 logit,在线推理仍用原始 u·v。
| 物品 | 流行度 p_j | 校正量 −log p_j | 含义 |
|---|---|---|---|
| 爆款《阿凡达》 | 高(0.05) | 小(+3.0) | 它高频出现是「理所当然」,少给信用 |
| 长尾《某文艺片》 | 低(0.0001) | 大(+9.2) | 它居然被看到很难得,多给信用 |
工业界经典做法见 Google 的 Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendations(Yi et al., RecSys 2019)——用流式频率估计实时算
p_j,是双塔上线的标配组件。
Level 5:上线 — 离线建库 + 在线 ANN
训练好两座塔后,怎么用它做毫秒级召回?答案就是 Level 3 那张图的「离线 + 在线」两段式。
离线(每晚跑一次):
for 每部电影 i in 全库:
v_i = 物品塔(电影 i 的特征) # 算向量
把所有 v_i 灌进 ANN 索引(FAISS / HNSW),存盘
在线(你刷新一次,< 30ms):
u = 用户塔(你的特征) # 只算 1 个向量,毫秒级
candidates = ANN索引.search(u, k=200) # 找最近的 200 个电影向量
return candidates
为什么 ANN 可以「算个大概」
精确找「最近的 200 个」要拿你的向量和全库每个向量都比一遍(暴力 KNN),1 亿次内积太慢。ANN(近似最近邻) 用「先导航到大致区域、只在小范围内精比」的方式,1000 倍加速,代价是偶尔漏掉个别真正最近的。
| 主流 ANN 算法 | 特点 |
|---|---|
| HNSW | 最常用,基于图导航,查询快,内存稍大 |
| IVF + PQ | 最省内存,先粗聚类再量化压缩,适合超大库 |
| FAISS | Facebook 开源库,工业界最常用 |
| ScaNN | Google 出品,在高召回率下速度领先 |
业务永远选「99% 召得到但快 1000 倍」,而不是「100% 但慢 1000 倍」——召回阶段漏掉一两个本来就靠后的候选,影响微乎其微。
Level 6:双塔的命门 — 它干不好什么
命门 1:两个塔到最后才「见面」,学不到特征交叉
这是 Level 3 分离约束的必然代价:
用户特征 ─→ [用户塔] ─→ u ┐
├─ 内积 ← 直到这里两边才第一次交互
电影特征 ─→ [物品塔] ─→ v ┘
因为用户和电影的特征直到最后一刻才碰面,模型学不到「你这个特征 × 它那个特征」的细粒度组合。比如「周五晚上的年轻男性特别爱看动作片」这种三阶交叉,双塔学不到(用户塔不知道候选是不是动作片,物品塔不知道现在是不是周五晚上)。
而精排的「老板」(LightGBM / 深度交叉模型)能把用户特征和物品特征拼在一起做交叉,所以更准。这就是为什么双塔只配当召回、不配当精排。
命门 2:信息瓶颈 — 一个向量装不下复杂兴趣
用户塔最后只输出一个 128 维向量。但一个人的兴趣往往是多峰的(既爱硬核科幻、又爱治愈动画、还偶尔看历史纪录片)——三种兴趣被硬压成一个向量,落在三者的「平均位置」,哪个都不太像。
解药:多兴趣建模(Multi-Interest)——让用户塔输出多个向量(比如 4 个,各代表一簇兴趣),分别去 ANN 检索再合并。阿里的 MIND 就是干这个的,见 Level 7。
命门 3:SSB — 召回一改,排序必重训
详见召回总览的 SSB:
昨天:召回只有 ItemCF + Popular
→ 排序模型是基于"这些候选的分布"训练的
今天:加上了双塔
→ 候选分布大变(多了一堆双塔召回的、ItemCF 从没见过的片)
→ 排序模型还在用旧分布打分 → 质量暴跌
铁律:上线双塔(哪怕只是调它的融合权重),排序模型必须重新训练。这是 recall ↔ ranking 的耦合契约,跑不掉。
Level 7:进阶版的亲戚们
双塔是个框架,25 年里衍生出一大家子。
所有变体都没改双塔的命根子——两塔分离 + 内积 + ANN;它们只是在「塔内部怎么编码特征」上越做越花。
| 名字 | 出处 | 核心改进 | 解决什么 |
|---|---|---|---|
| DSSM | 微软 2013 | 双塔的鼻祖(原本用于网页搜索 query-doc 匹配) | 把「匹配」统一成「双塔 + 内积」 |
| YouTube DNN | Google 2016 | 建成「超大规模 softmax 多分类」,引入用户行为序列 | 工业级召回的开山之作 |
| 采样纠偏双塔 | Google 2019 | in-batch 负采样 + logQ 修正 + 流式频率估计 | 4.5 的流行度偏差 |
| MIND | 阿里 2019 | 用户塔输出多个兴趣向量(动态路由) | 命门 2 的多兴趣 |
| ComiRec | 阿里 2020 | 多兴趣 + 可控多样性 | 多兴趣 + 推荐多样性 |
| 序列双塔(SASRec 风格) | — | 用户塔用 Transformer 建模行为序列顺序 | 时序信号 |
| 三塔粗排 | 工业界 | 用户塔 + 物品塔 + 交叉塔 | 召回太粗、精排太贵之间的折中 |
Level 8:在 recsys-mini 里怎么落地
recsys-mini 的召回接口设计得足够干净——任何召回路只要实现 BaseRecaller 的 fit + recall 两个方法,就能无缝接入领班:
class BaseRecaller(ABC):
name: str = "base"
@abstractmethod
def fit(self, train_df: pd.DataFrame) -> None:
"""Build the model from training interactions."""
@abstractmethod
def recall(self, user_id: int, k: int) -> List[Tuple[int, float]]:
"""Return up to k (item_id, score) candidates for one user."""src/recall/base.py
两座塔的最小实现
两座塔就是两个结构相同的 MLP(PyTorch):
import torch
import torch.nn as nn
class Tower(nn.Module):
"""一座塔:把拼接好的特征向量压成 dim 维的输出向量。"""
def __init__(self, in_dim: int, dim: int = 128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(in_dim, 256),
nn.ReLU(),
nn.Linear(256, dim),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
v = self.net(x)
return nn.functional.normalize(v, dim=-1) # L2 归一化 → 内积=余弦
注意末尾的 L2 归一化:归一化后内积就等于余弦相似度,配合 FAISS 的内积索引(
IndexFlatIP)刚好对上。
训练循环(in-batch 负采样 + sampled softmax)
这是 Level 4 的代码化身:
def train_step(user_tower, item_tower, user_feat, item_feat, tau=0.1):
u = user_tower(user_feat) # [B, 128]
v = item_tower(item_feat) # [B, 128] 对角线是正样本对
logits = (u @ v.t()) / tau # [B, B] in-batch:非对角线全是负样本
labels = torch.arange(u.size(0)) # 正样本在对角线 → label = 行号
loss = nn.functional.cross_entropy(logits, labels)
return loss
短短几行就实现了「in-batch 负采样 + sampled softmax」——
u @ v.t()这个B×B矩阵,对角线是 B 个正样本,其余B×(B-1)个全是白嫖来的负样本。
配置长什么样
recall:
itemcf:
sim_topk: 100
user_history_len: 50
use_iuf: true
popular:
topk: 200
two_tower: # DSSM 双塔(需要 torch)
dim: 64 # 向量维度
tau: 0.07 # 温度系数(4.4)
epochs: 8 # 训练轮数
batch_size: 512 # batch 越大,in-batch 负样本越多(4.2)
lr: 0.001
history_len: 50
use_logq: true # logQ 流行度纠偏(4.5)
weights: # RRF 融合权重
itemcf: 1.0
popular: 0.3
two_tower: 1.0 # 双塔是个性化主力,权重给高conf/config.yaml
接入领班(MultiRecaller)一行代码都不用改——这正是「面向 BaseRecaller 接口编程」的红利。
Level 9:实测效果 + 在多路召回里的位置
加上双塔后,recsys-mini 在 MovieLens-1M / LOO 切分上的实测成绩:
| 召回方法 | Recall@10 | Recall@50 | Recall@200 | Coverage@200 |
|---|---|---|---|---|
| ItemCF | 5.29% | 20.08% | 50.15% | 69.89% |
| Popular(兜底) | 4.74% | 15.48% | 36.97% | 22.29% |
| 双塔(Two-Tower) | 7.33% | 24.34% | 54.19% | 72.53% |
| RRF 多路融合(三路) | 7.15% | 24.15% | 51.94% | 67.55% |
双塔单路就全面超过 ItemCF:
Recall@105.29% → 7.33%、Recall@20050.15% → 54.19%、Coverage@20069.89% → 72.53%。这印证了 ItemCF 的判断——ItemCF 已接近共现类方法的天花板(≈50%),靠「泛化」的双塔才能再往上顶。
关于融合反而略低于双塔单路:当前三路等权 RRF(
two_tower:1.0, itemcf:1.0, popular:0.3)只看排名,把已经更强的双塔和较弱的 ItemCF/Popular 等权拉平,深层Recall@200被拖了一点。这是个真实且有教益的现象——当某一路明显强于其它路时,简单等权 RRF 未必最优,该调高双塔权重、或换 学习式融合。
为什么双塔能突破 ItemCF 的天花板?
| 维度 | ItemCF | 双塔 | 互补点 |
|---|---|---|---|
| 召回逻辑 | 数共现(记忆) | 学特征(泛化) | 两者犯的错不一样 |
| 长尾片 | 共现少 → 召不出 | 靠特征向量 → 能召出 | 双塔补长尾 |
| 新片 | 零交互 → 瘫痪 | 靠物品特征 → 能算向量 | 双塔补冷启动 |
| 偏向 | 偏热门、稳定 | 偏泛化、个性化强 | 融合后更全面 |
在整个推荐系统里的位置
双塔是多路召回里占比最大的一路(30–40%),但从不单独使用——和 ItemCF、内容、热门并行,由领班 RRF 融合,再交给能做特征交叉的「老板」精排。
TL;DR — 三句话
-
双塔召回 = 录像带店的资深选片员。 她给每位顾客和每部电影各画一张「气质画像」(向量),放进同一个坐标系,画像越近越匹配。本质是「加了深度编码器和侧信息的协同过滤」——把 CF 的记忆升级成了泛化,能召长尾、能召新片。
-
命根子是「两塔分离」:用户塔只看用户、物品塔只看物品,直到内积才碰面。 正因如此,物品向量能离线全算好建 ANN 索引,在线只算 1 个用户向量、毫秒级找最近的 200 个。训练靠 in-batch 负采样 + sampled softmax,还要 温度系数 τ 调陡峭、logQ 修正 防爆款霸屏。
-
它是召回主力(占比 30–40%),但只配当召回、不配当精排。 分离约束让它学不到特征交叉(命门 1),一个向量也装不下多峰兴趣(命门 2)。所以它和 ItemCF / 内容 / 热门并行,由领班 RRF 融合,再交给「老板」精排。⚠️ 切记 SSB 铁律:上了双塔,排序必须重训。
三路召回都讲完了——个性化的 ItemCF 与双塔、兜底的热门。下一篇讲怎么把它们的名单合并成一份:多路召回融合 RRF——店里的「领班」凭什么能把打分标准完全不同的几路融到一起。