推荐系统是互联网产品中技术复杂度最高、业务价值最直接的系统之一。字节跳动的推荐算法让 TikTok 成为全球现象;淘宝的个性化推荐让"千人千面"成为电商行业标配;YouTube 的推荐引擎贡献了超过 70% 的观看时长。
大规模推荐系统的挑战在于:它需要在数十毫秒内,从数十亿个候选物品中,为每个用户找到最可能感兴趣的几十个结果——同时还要保证系统的高可用、可扩展、可迭代。本文从架构、算法、工程三个维度系统梳理。
一、整体架构:层级漏斗
大规模推荐系统不可能对所有物品逐一精细计算,必须用层级漏斗逐步缩小候选集:
flowchart TD
Pool[全量物品库\n数十亿候选]
Recall[召回层\n多路并行召回\n缩减到数千候选]
Coarse[粗排层\n轻量模型快速打分\n缩减到数百候选]
Rank[精排层\n深度模型精细打分\n缩减到数十候选]
Rerank[重排层\n多样性 时效性 业务规则\n最终几十个结果]
User[用户]
Pool --> Recall
Recall --> Coarse
Coarse --> Rank
Rank --> Rerank
Rerank --> User
style Pool fill:#f8f9fa,stroke:#6c757d
style Recall fill:#fff3cd,stroke:#f9a825
style Coarse fill:#ffe0b2,stroke:#ef6c00
style Rank fill:#fce4ec,stroke:#e91e63
style Rerank fill:#e8f5e9,stroke:#4caf50
style User fill:#e3f2fd,stroke:#1976d2
| 阶段 | 候选数量 | 延迟预算 | 核心目标 |
|---|---|---|---|
| 召回 | 数十亿 → 数千 | ~50ms | 高召回率,不遗漏好内容 |
| 粗排 | 数千 → 数百 | ~10ms | 快速过滤低质量候选 |
| 精排 | 数百 → 数十 | ~30ms | 精确预测用户偏好 |
| 重排 | 数十 → 最终结果 | ~5ms | 多样性、时效性、业务目标 |
每层都有明确的职责分工。这种分层设计让不同层可以独立优化、独立迭代,是工业界推荐系统的标准架构。
二、召回层:多路并行,高覆盖
召回层的核心矛盾是:需要在极短时间内从海量候选中找到相关物品,但精确计算来不及。解决方案是多路并行召回——用多种不同的召回策略并行运行,取并集。
graph LR
User[用户请求]
User --> CF[协同过滤召回\nUser-CF Item-CF]
User --> TwoTower[双塔模型召回\nANN近似检索]
User --> Seq[序列行为召回\nI2I相似物品]
User --> Hot[热门召回\n实时热榜]
User --> Graph[图召回\nSwing EGES]
CF --> Merge[候选合并去重]
TwoTower --> Merge
Seq --> Merge
Hot --> Merge
Graph --> Merge
style User fill:#e3f2fd,stroke:#1976d2
style Merge fill:#e8f5e9,stroke:#4caf50
协同过滤:经典但仍有效
基于"相似用户喜欢相似物品"的直觉:
- User-CF:找到与目标用户行为相似的用户群,推荐他们喜欢但目标用户未看过的物品
- Item-CF:找到与用户历史交互物品相似的物品("看了A还看了B")
协同过滤的问题是无法处理冷启动(新用户/新物品没有历史数据),以及在物品库极大时存在稀疏性问题。
双塔模型:工业界主流召回
双塔(Two-Tower)模型是目前工业界最主流的深度学习召回方案:
graph LR
subgraph UserTower[用户塔]
UF[用户特征\nID 历史行为\n画像标签] --> UE[用户Embedding\n64~256维]
end
subgraph ItemTower[物品塔]
IF[物品特征\nID 类目 标签\n内容特征] --> IE[物品Embedding\n64~256维]
end
UE -->|点积相似度| Score[相关性得分]
IE -->|点积相似度| Score
Score --> ANN[ANN近似最近邻检索\nFAISS Milvus]
ANN --> TopK[Top-K 候选]
style UserTower fill:#e3f2fd,stroke:#1976d2
style ItemTower fill:#fce4ec,stroke:#e91e63
双塔的核心思路:用户和物品分别通过独立的神经网络编码成向量,推理时物品 Embedding 离线算好存入向量数据库,在线只需计算用户 Embedding,再做 ANN 检索找到最相似的 Top-K 物品。
训练目标通常是对比学习:用户与其交互的物品作为正样本,随机负采样或 Hard Negative(相似但未交互)作为负样本,最大化正样本相似度、最小化负样本相似度。
ANN 检索:向量召回的基础设施
全量物品 Embedding 存入向量数据库(FAISS、Milvus、Proxima),在线服务时:
# 离线:将所有物品 Embedding 建库
import faiss
import numpy as np
d = 128 # Embedding 维度
item_embeddings = np.load("item_embeddings.npy") # shape: [N_items, d]
# 使用 IVF+PQ 索引:倒排文件 + 乘积量化,速度/精度均衡
nlist = 1024 # 倒排表数量
m = 8 # PQ 子空间数
index = faiss.IndexIVFPQ(faiss.IndexFlatL2(d), d, nlist, m, 8)
index.train(item_embeddings)
index.add(item_embeddings)
faiss.write_index(index, "item_index.faiss")
# 在线:用用户 Embedding 检索 Top-K 物品
index = faiss.read_index("item_index.faiss")
index.nprobe = 64 # 搜索的倒排表数量,越大越准确但越慢
user_emb = get_user_embedding(user_id) # shape: [1, d]
distances, item_ids = index.search(user_emb, k=500) # 返回 Top-500
代码详解
为什么要离线建索引?
物品库有几亿条,每次请求都实时计算相似度根本来不及。提前把所有物品 Embedding 组织成索引结构存到磁盘,在线查询时直接加载。
faiss.IndexIVFPQ:IVF + PQ 两个技术的组合
IVF(Inverted File,倒排文件):把所有向量空间先用 K-Means 聚类成 nlist=1024 个桶。查询时不扫描全部向量,只扫描最近的几个桶——大幅减少计算量。
全量向量空间
↓ K-Means 聚类(nlist=1024)
[桶0: 约1万个向量] [桶1: 约1万个向量] ... [桶1023: 约1万个向量]
查询时:先判断用户向量离哪几个桶最近,只搜那几个桶(由 nprobe 控制)
PQ(Product Quantization,乘积量化):把每个向量压缩存储。m=8 表示把 128 维向量切成 8 段(每段 16 维),每段用码本压缩成 1 字节:
原始存储:128维 × 4字节(FP32) = 512 字节/向量
PQ压缩后:8字节/向量(压缩64倍)
代价:精度略有损失,但内存节省极大,检索速度更快
index.train():做两件事——对全量向量跑 K-Means 确定桶中心,以及训练 PQ 码本。只需执行一次,之后新增物品直接 add 即可。
index.nprobe = 64:查询时搜索多少个桶。nlist=1024, nprobe=64 意味着只搜索 6.25% 的数据。nprobe 越大结果越准但越慢,实践中通过测试召回率来选值(通常让召回率 ≥ 95% 的前提下尽量小)。
index.search(user_emb, k=500):返回两个数组:
distances # shape: [1, 500],每个结果与用户向量的距离(越小越相似)
item_ids # shape: [1, 500],对应物品的索引编号
# 取出物品 ID
top_items = item_ids[0] # [物品ID_1, 物品ID_2, ..., 物品ID_500]
注意 user_emb 的 shape 是 [1, d] 而不是 [d],因为 FAISS 支持批量查询([batch_size, d]),这里 batch=1。
三、排序模型:从 LR 到深度学习
排序模型负责对召回候选精细打分,预测用户对每个物品的点击/转化概率。
排序模型的演进
flowchart LR
LR[LR\n逻辑回归\n2010s早期] --> FM[FM\n因子分解机\n特征交叉]
FM --> Wide[Wide and Deep\n记忆+泛化] --> DNN[深层DNN\n自动特征学习]
DNN --> DIN[DIN\n注意力机制\n用户兴趣建模]
DIN --> DIEN[DIEN\nGRU序列建模\n兴趣演化]
DIEN --> Trans[Transformer\n多头注意力\n长序列]
style LR fill:#f8f9fa,stroke:#6c757d
style Trans fill:#e8f5e9,stroke:#4caf50
DIN:注意力机制建模用户兴趣
DIN(Deep Interest Network,阿里 2018)的核心创新是引入注意力机制:用户对不同物品的兴趣强度,取决于候选物品与历史行为的相关性。
graph TD
Candidate[候选物品\nEmbedding] --> Attention[注意力打分]
History[用户历史行为序列\n物品1 物品2 ... 物品N] --> Attention
Attention --> |加权求和| UserRep[用户兴趣表示]
UserRep --> Concat[特征拼接]
UserProfile[用户画像特征] --> Concat
ItemFeature[物品特征] --> Concat
ContextFeature[上下文特征] --> Concat
Concat --> MLP[多层MLP]
MLP --> CTR[CTR预估]
style Candidate fill:#fce4ec,stroke:#e91e63
style UserRep fill:#e3f2fd,stroke:#1976d2
style CTR fill:#e8f5e9,stroke:#4caf50
传统方法把用户历史行为做 sum pooling(简单平均),丢失了"哪些历史行为与当前候选更相关"的信息。DIN 用候选物品对历史行为做 attention,让模型学会"看什么广告时,过去买过什么更重要"。
多目标学习:同时优化点击和转化
工业级推荐系统通常需要同时优化多个业务指标:点击率(CTR)、完播率、点赞率、购买转化率……
class MultiTaskModel(nn.Module):
def __init__(self):
super().__init__()
# 共享底层特征提取
self.shared_bottom = nn.Sequential(
nn.Linear(input_dim, 256),
nn.ReLU(),
nn.Linear(256, 128)
)
# 各任务独立的 Tower
self.ctr_tower = nn.Linear(128, 1) # 点击率
self.cvr_tower = nn.Linear(128, 1) # 转化率
self.play_tower = nn.Linear(128, 1) # 完播率
def forward(self, x):
shared = self.shared_bottom(x)
return {
'ctr': torch.sigmoid(self.ctr_tower(shared)),
'cvr': torch.sigmoid(self.cvr_tower(shared)),
'play': torch.sigmoid(self.play_tower(shared))
}
# 最终排序分 = CTR × CVR × 权重1 + 完播率 × 权重2 + ...
# 权重根据业务目标调整
阿里的 ESMM(Entire Space Multi-Task Model)解决了 CVR 训练中的样本选择偏差问题:CVR 只有点击用户才有标签,用 CTR×CVR = CTCVR 在全空间训练,避免偏差。
四、特征工程:推荐系统的燃料
好的特征工程往往比复杂的模型更重要。推荐系统的特征分为四大类:
特征体系
graph TD
Features[推荐特征体系]
Features --> UserFeature[用户特征]
Features --> ItemFeature[物品特征]
Features --> ContextFeature[上下文特征]
Features --> CrossFeature[交叉特征]
UserFeature --> U1[基础画像\n年龄 性别 地域]
UserFeature --> U2[行为序列\n最近N次点击/购买]
UserFeature --> U3[统计特征\n7日点击率 偏好类目]
UserFeature --> U4[实时特征\n当前会话行为]
ItemFeature --> I1[基础属性\n类目 标签 价格]
ItemFeature --> I2[内容特征\n文本/图片Embedding]
ItemFeature --> I3[统计特征\n全局CTR 曝光量]
ItemFeature --> I4[实时特征\n近1小时点击量]
ContextFeature --> C1[时间\n时段 星期 节假日]
ContextFeature --> C2[设备\n机型 网络 App版本]
ContextFeature --> C3[位置\n城市 POI]
CrossFeature --> X1[用户×物品交叉\n该用户对该类目的历史偏好]
CrossFeature --> X2[用户×上下文\n该用户在该时段的行为模式]
实时特征:推荐系统的核心竞争力
静态特征(用户画像、物品属性)T+1 更新即可,但实时特征(当前会话行为、近 1 小时统计)需要秒级甚至毫秒级更新:
sequenceDiagram
participant User as 用户行为
participant Kafka as Kafka
participant Flink as Flink 实时计算
participant Redis as Redis 特征存储
participant RankService as 排序服务
User->>Kafka: 点击/曝光事件
Kafka->>Flink: 消费事件流
Flink->>Flink: 滑动窗口统计\n近1h/6h/24h点击率
Flink->>Redis: 写入实时特征
RankService->>Redis: 查询特征
Redis-->>RankService: 返回特征值
实时特征的典型例子:
- 物品近 1 小时点击量(热点检测)
- 用户当前会话已看过的物品类目(避免重复推荐同类)
- 用户近 10 分钟的行为序列(捕捉即时兴趣漂移)
- 用户与某类目的近期交互率(实时个性化)
特征存储:在线服务的关键路径
推荐服务每次请求需要拉取数百个特征,对特征存储的要求极高:
| 特征类型 | 存储方案 | 读延迟 | 原因 |
|---|---|---|---|
| 用户实时特征 | Redis Cluster | ~1ms | 写频繁,读延迟敏感 |
| 物品实时统计 | Redis / Aerospike | ~1ms | 并发高,需要原子操作 |
| 用户画像(稠密) | Doris / Hologres | ~5ms | 字段多,宽表查询 |
| Embedding 向量 | Milvus / FAISS | ~10ms | 高维向量,需要 ANN 检索 |
| 历史行为序列 | HBase / Redis | ~3ms | 序列长,按用户分区 |
特征拼接服务通常会用并行查询——同时向多个存储发起请求,等所有结果返回后再拼接,而不是串行查询,把总延迟控制在最慢的那个请求的延迟上(通常是 5-10ms)。
五、Embedding 系统:大规模稀疏特征的核心
推荐系统中,用户 ID、物品 ID、类目 ID 等都是高基数的稀疏特征,需要通过 Embedding 转换为稠密向量参与计算。在工业界,Embedding 参数量通常占模型总参数的 90% 以上。
工业级 Embedding 的挑战
一个电商平台可能有数十亿个物品 ID,每个物品一个 64 维 FP32 Embedding,存储量约为:
10亿物品 × 64维 × 4字节(FP32) = 256 GB
这远超单台 GPU 的显存(80GB),需要分布式 Embedding 存储
Parameter Server 架构
graph LR
subgraph Workers[训练 Worker 集群]
W1[Worker 1]
W2[Worker 2]
W3[Worker 3]
end
subgraph PS[Parameter Server 集群]
PS1[PS Shard 1\nEmbedding 0-3亿]
PS2[PS Shard 2\nEmbedding 3-6亿]
PS3[PS Shard 3\nEmbedding 6-10亿]
end
W1 -->|Pull 参数\nPush 梯度| PS1
W2 -->|Pull 参数\nPush 梯度| PS2
W3 -->|Pull 参数\nPush 梯度| PS3
style Workers fill:#e3f2fd,stroke:#1976d2
style PS fill:#fce4ec,stroke:#e91e63
Worker 负责前向计算和反向传播,PS 负责存储 Embedding 参数和接收梯度更新。训练时 Worker 先从 PS Pull 需要的 Embedding,计算完后把梯度 Push 回 PS 更新参数。
在线学习:让模型跟上用户兴趣变化
用户兴趣会随时间变化,离线训练的模型会很快过时。在线学习让模型持续接收实时数据更新:
在线学习流程:
用户行为 → Kafka → 实时样本构造 → 增量训练 → 模型更新推送
关键挑战:
1. 样本延迟:用户点击了但真正转化可能在几小时后,如何处理
2. 特征穿越:用未来信息训练历史时刻的模型(严重问题,见下方详解)
3. 模型稳定性:频繁更新可能导致模型震荡
4. 负反馈处理:曝光未点击是负样本,但实时采样比例需要控制
业界方案:
- 每小时增量训练一次(全量 Retrain 太慢)
- 只更新 Embedding,固定 DNN 权重(折中方案)
- FTRL 在线优化算法(稀疏友好,Google 广告系统使用)
特征穿越:推荐系统最隐蔽的陷阱
特征穿越(Feature Leakage)几乎是推荐系统里最危险的问题——模型离线指标看起来非常好,上线之后效果却大幅下降,找不到原因。
用一个例子说清楚
你在做"预测用户会不会购买这个商品"的模型。
有一条训练样本:张三在1月15日上午10:00浏览了一双运动鞋。
你用的特征之一是:"张三最近30天买了多少件运动类商品"。
现在是1月20日,你在整理训练数据,打开数据库查张三的购买记录,查到了3件。但其中2件是1月16日和1月18日买的——在张三浏览鞋子的那一刻(1月15日10:00),这2件还没发生。
训练时(1月20日整理数据):
张三10:00浏览运动鞋这条样本,特征"最近购买数" = 3件
→ 模型学到:买过3件的用户,很可能继续买
上线后(实时服务):
某用户现在正在浏览,"最近购买数"只能查到他"当前"的历史
大多数用户的值远小于训练时"看到"的值
→ 模型打分严重偏低,效果很差
问题的本质:训练时用了"当时不可能知道"的未来信息。模型在训练集上学到的规律,在真实推断时根本不成立。
为什么特别难发现
离线评估时,训练集和测试集都来自同一批历史数据,存在同样的穿越问题。所以测试集上的 AUC 也很好看——因为测试集的特征同样包含了"未来"信息。
训练和测试都在"作弊",所以离线评估发现不了任何问题。穿越越严重,模型越能作弊,离线指标越高——这反而是一个危险信号。
只有真正上线做 A/B 测试,才会发现线上效果远不如离线预期。
最严重的情况:特征就是标签本身
更极端的例子:你的特征里有一项——"张三今天有没有购买运动类商品"。标签是——"张三浏览这双鞋之后有没有购买"。
如果张三当天下午2点真的买了,那"今天有没有购买"这个特征在训练样本里就是"是"。但在上午10:00浏览的那一刻,答案应该是"不知道,还没发生"。
这个特征实际上就是标签本身,只是时间提前了几小时。模型的离线准确率接近100%,上线效果接近0。
正确做法:每条样本只看事件发生那一刻之前的信息
核心原则很简单:构造"张三在10:00的行为"这条训练样本时,所有特征的值都必须只看10:00之前发生的事情。
工程上的标准做法叫 Point-in-time Correct Join(按事件时间精确对齐):
# 错误:直接 join 当前的特征表(特征值包含了"未来"数据)
train_data = events.join(user_features, on='user_id')
# 正确:只取事件发生时刻之前最近的特征快照
train_data = events.join(
user_features,
on='user_id',
how='asof', # asof join:找时间上最近但不晚于事件时间的值
left_by='event_time',
)
在线学习场景下还有另一个时序问题——Kafka 消费有延迟:
用户在 10:00 点击 → 事件进入 Kafka
消费服务在 10:05 才处理到这条事件
→ 这时候去 Redis 取特征,拿到的是 10:05 的值,不是 10:00 的值
正确做法:用户点击时,把当时的关键特征值一起打进事件消息里,随事件一起流转
事件消息 = {
user_id: 张三,
item_id: 运动鞋,
event_time: "10:00:00",
features_snapshot: { 最近购买数: 1, ... } ← 10:00 时刻的快照
}
一句话总结:训练时你是"上帝视角",看到了未来;上线时你只是"普通人",只能看到现在。这个视角差就是特征穿越。解决它需要在数据管道层面强制时间对齐,不能靠工程师"小心一点"来避免。
六、重排:业务规则与多样性的平衡
精排给出的是按 CTR 预估排序的列表,但直接展示会有问题:
- 同一类目的物品可能连续出现(缺乏多样性,用户体验差)
- 新物品没有足够历史数据,总是排在后面(马太效应,探索不足)
- 业务需要插入广告、活动推广等(业务干预)
MMR:最大边际相关性
MMR(Maximal Marginal Relevance)在保证相关性的同时最大化列表多样性:
def mmr_rerank(candidates, already_selected, lambda_=0.5):
"""
candidates: 未选中的候选物品列表,每个有 (score, embedding)
already_selected: 已选中物品的 embedding 列表
lambda_: 相关性和多样性的权衡参数
"""
if not already_selected:
# 第一个物品直接选分最高的
return max(candidates, key=lambda x: x['score'])
best_item, best_mmr = None, -float('inf')
for item in candidates:
# 相关性:模型打分
relevance = item['score']
# 多样性:与已选物品的最大相似度(越小越好)
max_sim = max(
cosine_similarity(item['embedding'], sel['embedding'])
for sel in already_selected
)
# MMR 分 = 相关性 - 多样性惩罚
mmr_score = lambda_ * relevance - (1 - lambda_) * max_sim
if mmr_score > best_mmr:
best_mmr = mmr_score
best_item = item
return best_item
探索与利用(Exploration vs Exploitation)
推荐系统存在一个固有矛盾:利用已知用户偏好(exploitation)会让用户越来越局限在小圈子里;探索新兴趣(exploration)可能短期降低点击率,但长期提升用户价值。
常见的解决方案是 ε-greedy(以 ε 的概率随机推荐新内容)或 UCB(Upper Confidence Bound)(对置信度低的物品给更高分数,主动探索)。字节等公司也尝试用强化学习建模长期用户价值。
七、A/B 测试体系:推荐系统的科学验证
推荐系统的改进必须通过严格的 A/B 测试验证,因为直觉往往是错的。
graph TD
Traffic[用户流量] --> Splitter[流量分桶\n用户 ID Hash]
Splitter --> A[A 组 对照组\n当前算法]
Splitter --> B[B 组 实验组\n新算法]
A --> MetricsA[指标收集\nCTR 时长 留存]
B --> MetricsB[指标收集\nCTR 时长 留存]
MetricsA --> Analysis[统计显著性检验\np-value 置信区间]
MetricsB --> Analysis
Analysis --> Decision{决策}
Decision -->|显著提升| Deploy[全量上线]
Decision -->|无显著差异| Rollback[回滚]
Decision -->|有害| Rollback
style Deploy fill:#e8f5e9,stroke:#4caf50
style Rollback fill:#ffebee,stroke:#f44336
推荐系统 A/B 测试的关键要点:
- 流量隔离:同一用户在同一实验中只属于一个桶,不同实验之间需要正交分层
- 指标体系:短期指标(CTR)不一定反映长期价值(用户留存),需要同时观测
- 统计显著性:变化量太小时可能是噪声,需要足够的样本量和置信度(通常 p<0.05)
- Novelty Effect:新算法上线初期用户可能因为新鲜感表现更好,需要观测足够长时间
- Holdout:保留一个不受任何实验影响的对照组,用于检测全局效果
八、冷启动:新用户与新物品的处理
冷启动是推荐系统最难的问题之一:
新用户冷启动
- 注册时收集偏好:引导用户选择感兴趣的类目(知乎、微博的新手引导)
- 利用可用信号:设备型号、网络、地理位置、时间等上下文特征
- 快速探索:前几次曝光故意多样化,快速收集用户反馈,用于实时个性化
- 迁移学习:从相似用户的行为初始化新用户的 Embedding
新物品冷启动
- 内容特征 Embedding:用物品的文本/图片内容生成 Embedding,不依赖行为数据
- 流量配额:给新物品强制分配一定比例的曝光机会(类似广告竞价中的 quality floor)
- 相似物品迁移:用与新物品内容相似的老物品的 Embedding 做初始化
- Publisher 画像:作者/商家的历史数据可以为新物品提供先验信号
九、关键点总结
- 层级漏斗是工业标准:召回→粗排→精排→重排,每层独立优化,总延迟控制在 100ms 以内
- 召回要多路并行:协同过滤、双塔模型、序列召回、热门召回互补,任何单一召回都有盲区
- 双塔模型是主流召回方案:离线建 Embedding 库 + 在线 ANN 检索,兼顾精度和速度
- DIN 的注意力机制是精排的关键创新:用候选物品对历史行为做 attention,比简单 pooling 好得多
- 实时特征是差异化竞争力:近 1 小时的用户行为比历史画像对当前推荐更有价值
- 特征存储是性能瓶颈:并行查询、分级缓存是控制特征拉取延迟的关键
- 在线学习不能停:用户兴趣漂移很快,模型需要持续接收实时数据更新
- 多样性与相关性是根本矛盾:MMR 和探索策略是缓解而非解决,最终需要业务判断权衡
- 一切改动必须 A/B 验证:看起来合理的改动在真实用户上未必有效,数据说话
- 冷启动是系统成熟度的试金石:处理好新用户和新物品,才能实现正向的数据飞轮