如何构建一个大规模推荐系统:从架构到算法到工程

推荐系统是互联网产品中技术复杂度最高、业务价值最直接的系统之一。字节跳动的推荐算法让 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 验证:看起来合理的改动在真实用户上未必有效,数据说话
  • 冷启动是系统成熟度的试金石:处理好新用户和新物品,才能实现正向的数据飞轮