Day 13 Hard Recommendation Two-Tower Multi-stage Ranking Cold Start

推荐系统 — 从十亿候选到二十条的漏斗Recommendation Systems: Retrieval, Multi-stage Ranking, Cold Start, Generative Rec

问题场景 + 需求约束

给一个 2 亿 DAU 的短视频 / 电商 discovery feed(抖音的「推荐」流、Instagram Explore、淘宝「猜你喜欢」)设计后端:用户每次下拉刷新,要从 10 亿量级的 item 库里挑出最可能让 ta 停留的 20 条,端到端 < 100ms。难点不是「训个 CTR 模型」,而是算力与延迟的漏斗——你不可能给 10 亿候选每个都跑一遍重模型。

高层架构(多阶段漏斗)

graph LR
    U["用户请求
user features"] --> RT["实时特征
近期行为"] RT --> REC["召回 Retrieval
双塔+ANN / CF / 规则
10亿 → 数千"] REC --> PRE["粗排 Pre-rank
轻量蒸馏模型
数千 → 数百"] PRE --> RANK["精排 Ranking
重模型·多目标
数百 → 数十"] RANK --> RR["重排 Re-rank
多样性·打散·业务规则"] RR --> OUT["Top-20 feed"] FS[("Feature Store
embedding / 统计特征")] -.-> REC FS -.-> PRE FS -.-> RANK classDef u fill:#1a2530,stroke:#64c8ff,color:#e8eef5 classDef stage fill:#1a1a30,stroke:#ffb450,color:#e8eef5 classDef store fill:#2a1530,stroke:#ff7ab6,color:#e8eef5 class U,RT,OUT u class REC,PRE,RANK,RR stage class FS store

每一级把候选量砍一个数量级,模型越往后越重、特征越精细

组件职责召回用极廉价的方式(向量点积+ANN、协同过滤、运营规则)从十亿粗筛几千,要高召回率非精确;粗排用轻量模型把几千砍到几百,是召回与精排间的算力缓冲;精排跑最贵的多目标深度模型逐个精打;重排处理精排管不了的全局约束——多样性、同作者打散、广告插入。Feature Store 统一供给 embedding 和统计特征,保证训练与服务用同一份。

关键技术点

1. 召回:协同过滤 vs 内容 vs 双塔

一句话 trade-off:用「能否泛化到没共现过的 item / 冷启 item」换「能否离线预计算 + ANN 加速」。

原理:召回要在十亿里毫秒级捞几千,核心是把用户和 item 都映射成同一空间的向量,相关 = 距离近,再用 ANN(HNSW,见 Day 12)检索。三类做法:协同过滤(CF / 矩阵分解)靠「看了 A 的人也看了 B」的共现,纯 ID、无内容特征;内容召回用 item 的文本/图像/类目特征,天然能召回冷启 item;双塔(two-tower)是工业主流——user tower 和 item tower 各自吃任意特征生成向量,训练时拉近正样本、推开负样本,serving 时 item 向量离线灌进 ANN 索引,user 向量在线算一次点积

方法泛化/冷启特征serving典型
item-CF / MF差(要共现)纯 ID预算相似度Amazon「买了还买」
内容召回好(冷启友好)内容特征向量 ANN新 item 兜底
双塔任意特征离线 item 向量 + ANNYouTube / Instagram
Trade-off:
# 双塔 in-batch softmax 召回(PyTorch 风格 pseudo-code)
u = user_tower(user_feats)        # [B, d]
v = item_tower(item_feats)        # [B, d]  batch 内 B 个正样本 item
logits = u @ v.T / temperature    # [B, B] 对角线是正样本对
logits -= log_item_freq           # ⚠️ 采样偏差纠正:热门 item 当负样本要打折
loss = cross_entropy(logits, labels=arange(B))  # 把同 batch 其余当负样本
# serving: item_tower 离线算好所有 item 向量 → 灌 HNSW;
#          线上只算一次 user_tower,ANN 取 top-k
现实案例:

2. 多阶段漏斗:为什么不能一个模型搞定

一句话 trade-off:每一级都在「模型精度」和「能处理的候选量」之间选点,逐级收窄。

原理:精排模型动辄几百万参数、吃几百个特征还做 user×item 的 target attention,单个候选打分要毫秒级——给 10 亿候选打分是 10⁶ 倍预算,绝无可能。所以分级:召回用点积(O(1) 近似检索)从十亿到数千;精排用重模型从数百到数十;中间夹一个粗排缓冲——它通常是精排的蒸馏小模型,精度介于召回与精排之间,把数千砍到数百,避免召回直接灌爆精排。漏斗每级的目标函数也不同:召回求不漏(高 recall),精排求排得准(高 AUC / 校准的 pCTR)。

Trade-off:
现实案例:

3. Cold Start:新用户与新 item 的双向冷启

一句话 trade-off:用「探索新内容的短期体验损失」换「积累反馈数据的长期收益」。

原理:CF 类方法对没有交互的 item/user 束手无策——这是结构性缺陷。两个方向:item 冷启靠内容特征(双塔的 item 塔吃文本/图像/类目,新 item 也能算向量进召回),但光有 embedding 不够,还得给曝光机会去采集反馈user 冷启靠 onboarding 选兴趣、人口属性、设备/地理等旁路特征,外加快速试探。核心是 explore-exploit:纯 exploit(只推已知高分)会让新内容永远拿不到数据、新用户被推爆款。用 multi-armed bandit(如 Thompson sampling / UCB)按「不确定性」分配探索预算——对估计方差大的 item 多给曝光。

# Thompson sampling 给新 item 分配探索(pseudo-code)
# 每个 item 维护 Beta(α, β):α=点击数+1, β=未点击数+1
def pick(items):
    return max(items, key=lambda it: beta_sample(it.alpha, it.beta))
# 新 item α=β=1(先验均匀)→ 采样方差大 → 有机会被选中探索;
# 拿到反馈后更新 α/β,估计收敛,自然从 explore 过渡到 exploit
Trade-off:
现实案例:

4. 生成式推荐:Semantic ID 与 LLM

一句话 trade-off:用「生成式的新范式与冷启泛化」换「成熟双塔+ANN 的工程确定性与低延迟」。

原理:传统召回是「学 embedding → ANN 查最近邻」。生成式检索换个思路:给每个 item 一个 Semantic ID——把内容 embedding 用 RQ-VAE 量化成一串语义 token(语义相近的 item 共享前缀 token),然后训一个 Transformer 自回归地「生成」下一个该推荐 item 的 Semantic ID,把检索变成序列生成,Transformer 本身就是索引。好处:语义相近 item 共享 token,对冷启和长尾 item 天然友好(新 item 只要内容相似就落到邻近 token 空间)。另一支是把 LLM 当排序器/特征提取器,用其世界知识理解 item 语义、做可解释推荐。

Trade-off:
现实案例:

扩展与优化

常见陷阱 + 面试问题

深入资源

深入思考(点击展开答案)

1. 双塔召回为什么不能在打分前做 user×item 交叉特征?这个限制的根源是什么?精排为什么能?

根源是 serving 的预计算需求。双塔之所以能从十亿 item 里毫秒级召回,是因为 item 向量可以离线全部算好、灌进 ANN 索引,线上只算一次 user 向量再做最近邻查询。一旦在打分前引入 user×item 交叉特征(比如「该用户对该 item 类目的历史点击率」),item 向量就依赖于具体 user,无法离线独立计算——十亿 item 你得为每个 user 实时重算,预算爆炸,ANN 也用不了(ANN 要求 item 向量固定)。

精排能做交叉,是因为它只面对几百个候选——可以承受为每个 (user, item) 对实时拼特征、跑 target attention。这正是漏斗分级的意义:把昂贵的交叉计算留到候选足够少的阶段。「双塔召回、cross 模型精排」是算力约束推导出的必然结构,不是习惯。

2. 召回模型的负样本怎么选?为什么「曝光未点击」是个陷阱?in-batch negative 又有什么坑?

「曝光未点击」的陷阱:召回训练的目标是「从全库十亿里区分相关 vs 不相关」,但曝光未点击的 item 是已经被整条召回→排序漏斗筛选过的——它们都是「还不错但没被点」的困难负例,分布跟「全库随机一个 item」天差地别。只用它们训练,模型学到的是「在好 item 里挑」,到线上面对十亿真随机 item 时判别力崩塌(样本选择偏差)。

正解:以全库随机/in-batch 负样本为主(模拟真实召回分布),少量困难负例提精度。in-batch negative 的坑热门 item 出现在 batch 的概率高,被反复当负样本压制而低估。修正就是 Yi et al 2019 的采样偏差纠正——logits 减 log(item 采样频率),按热度打折,还原无偏估计。

3. 用点击当 label 训精排,会被 position bias 自我固化。怎么纠?为什么置之不理会越来越糟?

机制:用户点第 1 位,部分原因只是它在第 1 位(更容易被看到),不代表它最相关。若直接把点击当「相关」label,模型学到的是「排在前面的会被点」的同义反复。上线后它把这些 item 排得更靠前 → 拿到更多点击 → 下一轮训练更确信 → 正反馈固化,相关但曾排后的 item 永无翻身,多样性和长期满意度持续下滑。

纠偏:① position as feature——训练把展示位置当特征,serving 置成固定值(如 0),让模型输出「与位置无关的相关性」;② IPS——按位置检视概率给样本加权,越靠后点击权重越高;③ 随机化流量打散位置采集无偏数据。核心心智:点击是相关性 × 检视概率的混合,必须剥掉检视那部分。

4. 估算:10 亿 item、双塔 64 维 embedding,item 向量索引要多大内存?这对架构意味着什么?

数量级估算(别背精确值):10⁹ item × 64 维 × 4 字节(float32)≈ 256GB 仅原始向量。HNSW 还要存图的邻接结构,通常再叠 1.5~3 倍 → 0.5~1TB 量级。单机内存放不下,意味着 ANN 索引必须分片(按 item 哈希切到多个检索节点,scatter-gather,呼应 Day 12 搜索)。

架构含义:① 维度不是越高越好——128 维比 64 维内存翻倍、ANN 变慢,要权衡;② 省内存就上量化(PQ,Day 12 的 IVF-PQ),精度换内存;③ 索引要支持增量插入 + 周期重建;④ 这也解释了生成式检索(TIGER)的吸引力——Semantic ID 用离散 token 表示 item,潜在绕开「十亿稠密向量全驻内存」的成本墙。

5. 推荐系统的反馈回路会制造信息茧房。这个二阶效应具体怎么发生?为什么纯优化离线指标会加剧它?怎么破?

怎么发生:模型只能从它自己推过的内容上学到反馈——没推过的 item 永远没有点击数据。于是高分 item 拿到更多曝光、更多正反馈、下轮更高分;冷门/新内容拿不到曝光、永远是「未知」、被当低分。数据分布越来越窄,用户被锁进越来越同质的内容,创作者侧(双边市场)则只有头部能活,供给枯萎。

为什么离线指标加剧:离线评估用的是历史日志,而日志本身就是旧模型的产物、带着旧模型的偏好。一个「更激进 exploit 历史热门」的模型在离线 AUC 上往往更高——因为它和生成日志的逻辑更一致——但它恰恰是茧房的加速器。离线指标好 ≠ 线上长期生态健康。

怎么破:① 主动探索(bandit)给低置信内容曝光预算,持续补充多样数据;② 重排显式注入多样性约束(MMR、DPP、同作者打散);③ 用线上 A/B 的长期留存、多样性、创作者基尼系数等生态指标兜底,而非只看单次 CTR;④ off-policy 评估估计「换个策略会怎样」,跳出旧日志的自证循环。