Skip to content
Charles Shao
Go back

双塔召回:为什么它是过去 10 年工业界召回的绝对主力

views

前面讲了召回的全景、经典的 ItemCF 和兜底的热门召回。这一篇讲工业界真正的召回主力——双塔(Two-Tower / DSSM / 向量召回)。延续「录像带出租店」的比喻,这次的主角是店里新请来的「资深选片员」:她不像老挑片员那样翻账本数共现,而是给每位顾客每部电影各画一张「气质画像」(向量),谁的画像离得近就推谁。

Table of contents

Open Table of contents

一句话理解

双塔召回 = 录像带店新请的「资深选片员」。

老挑片员(ItemCF)只会翻账本数「谁和谁经常被一起租走」——她不懂电影、不懂顾客,只认共现。新来的资深选片员不一样:

  1. 每位顾客画一张「观影性格画像」——把你的年龄、性别、最近看过的片,浓缩成一串数字(一个向量);
  2. 每部电影也画一张「气质画像」——把它的类型、年代、主创,也浓缩成同样长度的一串数字。

然后她把两张画像放进同一个坐标系里量一量距离:画像离得越近 = 越可能对上眼

关键魔法:电影的画像可以提前一次性全画好(离线建库),顾客来了只需临时画一张顾客画像,再去库里「找最近的 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:两座塔到底是什么

「塔」这个词唬人,其实就是两个独立的小神经网络,各管一摊。

双塔结构:用户塔吃用户特征(年龄/性别/职业/最近 50 部片)过 MLP 输出 128 维用户向量 u;物品塔吃电影特征(类型/年代/标题/主创)过 MLP 输出 128 维物品向量 v;两向量做内积即匹配度

两座结构相同、参数独立的 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 索引(FAISS/HNSW);在线(你刷新一次,<30ms)用户塔算 1 个向量 u,去索引里找最近的 200 个 v

命根子:物品塔不依赖用户 → 物品向量离线全算好、建 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 当小王的负样本——一次新计算都不用多做!

in-batch 负采样:一个 batch 的 4 个正样本对算出 4×4 内积矩阵,对角线是正样本(要拉大),非对角线全是白嫖来的负样本(要压小),一次矩阵乘法搞定

把 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 / τ)

为什么这个公式不会坍缩:分子是正样本(要大),分母里那一堆负样本项就提供了「推远」的力。如果分母里没有负样本、只剩分子自己,这个比例恒等于 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最省内存,先粗聚类再量化压缩,适合超大库
FAISSFacebook 开源库,工业界最常用
ScaNNGoogle 出品,在高召回率下速度领先

业务永远选「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 年里衍生出一大家子。

双塔家族演化:MF 加深度编码器+侧信息成 DSSM(2013),加行为序列+大规模 softmax 成 YouTube DNN(2016),再分叉出采样纠偏双塔(2019 加 logQ)、MIND(2019 多兴趣)、ComiRec(2020 多样性)、序列双塔(Transformer)

所有变体都没改双塔的命根子——两塔分离 + 内积 + ANN;它们只是在「塔内部怎么编码特征」上越做越花。

名字出处核心改进解决什么
DSSM微软 2013双塔的鼻祖(原本用于网页搜索 query-doc 匹配)把「匹配」统一成「双塔 + 内积」
YouTube DNNGoogle 2016建成「超大规模 softmax 多分类」,引入用户行为序列工业级召回的开山之作
采样纠偏双塔Google 2019in-batch 负采样 + logQ 修正 + 流式频率估计4.5 的流行度偏差
MIND阿里 2019用户塔输出多个兴趣向量(动态路由)命门 2 的多兴趣
ComiRec阿里 2020多兴趣 + 可控多样性多兴趣 + 推荐多样性
序列双塔(SASRec 风格)用户塔用 Transformer 建模行为序列顺序时序信号
三塔粗排工业界用户塔 + 物品塔 + 交叉塔召回太粗、精排太贵之间的折中

Level 8:在 recsys-mini 里怎么落地

recsys-mini 的召回接口设计得足够干净——任何召回路只要实现 BaseRecallerfit + 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@10Recall@50Recall@200Coverage@200
ItemCF5.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%

双塔单路就全面超过 ItemCFRecall@10 5.29% → 7.33%Recall@200 50.15% → 54.19%Coverage@200 69.89% → 72.53%。这印证了 ItemCF 的判断——ItemCF 已接近共现类方法的天花板(≈50%),靠「泛化」的双塔才能再往上顶。

关于融合反而略低于双塔单路:当前三路等权 RRF(two_tower:1.0, itemcf:1.0, popular:0.3)只看排名,把已经更强的双塔和较弱的 ItemCF/Popular 等权拉平,深层 Recall@200 被拖了一点。这是个真实且有教益的现象——当某一路明显强于其它路时,简单等权 RRF 未必最优,该调高双塔权重、或换 学习式融合

为什么双塔能突破 ItemCF 的天花板?

维度ItemCF双塔互补点
召回逻辑数共现(记忆)学特征(泛化)两者犯的错不一样
长尾片共现少 → 召不出靠特征向量 → 能召出双塔补长尾
新片零交互 → 瘫痪靠物品特征 → 能算向量双塔补冷启动
偏向偏热门、稳定偏泛化、个性化强融合后更全面

在整个推荐系统里的位置

推荐系统全链路:全库电影经多路召回(双塔 ★ / ItemCF / Popular / 内容)并行粗筛,领班 RRF 融合出 200~500 候选,老板排序精排到 Top-10,老板娘重排(去重/多样性)后呈现

双塔是多路召回里占比最大的一路(30–40%),但从不单独使用——和 ItemCF、内容、热门并行,由领班 RRF 融合,再交给能做特征交叉的「老板」精排。


TL;DR — 三句话

  1. 双塔召回 = 录像带店的资深选片员。 她给每位顾客和每部电影各画一张「气质画像」(向量),放进同一个坐标系,画像越近越匹配。本质是「加了深度编码器和侧信息的协同过滤」——把 CF 的记忆升级成了泛化,能召长尾、能召新片。

  2. 命根子是「两塔分离」:用户塔只看用户、物品塔只看物品,直到内积才碰面。 正因如此,物品向量能离线全算好建 ANN 索引,在线只算 1 个用户向量、毫秒级找最近的 200 个。训练靠 in-batch 负采样 + sampled softmax,还要 温度系数 τ 调陡峭、logQ 修正 防爆款霸屏。

  3. 它是召回主力(占比 30–40%),但只配当召回、不配当精排。 分离约束让它学不到特征交叉(命门 1),一个向量也装不下多峰兴趣(命门 2)。所以它和 ItemCF / 内容 / 热门并行,由领班 RRF 融合,再交给「老板」精排。⚠️ 切记 SSB 铁律:上了双塔,排序必须重训


三路召回都讲完了——个性化的 ItemCF双塔、兜底的热门。下一篇讲怎么把它们的名单合并成一份:多路召回融合 RRF——店里的「领班」凭什么能把打分标准完全不同的几路融到一起。


views
Share this post on:

Previous Post
创意 A/B 测试与有效性度量:从假设到增量
Next Post
DCO 动态创意优化与创意疲劳:模板、要素与频控