简介:本资源是一份面向机器学习与人工智能方向初学者及进阶从业者的推荐系统核心知识课件,聚焦召回阶段四大主流策略的原理、优劣与工程实践。内容系统梳理热度排行榜、分类器模型、关联规则挖掘(含共现矩阵与Jaccard相似度归一化)、矩阵分解等关键技术,深入剖析冷启动、场景上下文、用户兴趣演化等现实挑战,并给出数据收集、特征工程、模型评估到反馈优化的完整落地路径。资源为单个PPT文件(5.17MB),结构清晰、图文并茂,含LOGO页、定义阐释、多场景应用对比(视频/商品/音乐)、算法流程图与典型问题分析,适合作为课堂讲义、技术分享素材或自学提纲。目前已有123人学习下载,内容覆盖理论基础、典型缺陷与改进思路,可帮助读者快速建立推荐系统召回模块的系统性认知与实操判断力。
1. 召回不是“猜你喜欢”,而是从千万级物品池里快速筛出几百个候选——它决定推荐系统的吞吐上限和冷启动能力
很多人第一次接触推荐系统时,以为“召回”就是模型打分排序的前半段:把用户历史行为喂进去,跑个模型,输出 top-N。但真实工业场景中,一个电商 App 每天要为 5000 万用户生成推荐,商品库有 2 亿 SKU,如果每次请求都对全量商品做向量相似度计算,单次召回延迟会突破 3 秒,服务直接雪崩。召回的本质是降维+剪枝+索引化:它不追求绝对精准,而是在毫秒级内把候选集从 2 亿压缩到 200~500 个,再交给排序模块精排。这个阶段选错策略,后续所有深度学习模型都白调参;参数设偏一点,线上 CTR(点击率)可能掉 15%。本文面向已掌握协同过滤、逻辑回归等基础算法的工程师,聚焦“召回篇1”中最常落地的三类方法——基于行为的 Item-CF 召回、基于向量的双塔召回、基于图结构的 GraphSAGE 召回,逐层拆解它们在真实数据流中的构建逻辑、关键参数取值依据、以及上线前必须验证的三个边界指标。
2. 用 Item-CF 在本地跑通最小可行召回服务:从日志解析到实时相似度更新
Item-CF(Item-based Collaborative Filtering)仍是多数业务初期最稳的召回基线。它不依赖特征工程,仅靠用户-物品交互矩阵就能产出高相关性候选,且天然支持冷启动物品的“关联曝光”。但直接套用经典公式sim(i,j) = |N(i) ∩ N(j)| / √(|N(i)| × |N(j)|)会在线上失效——因为用户行为存在长尾分布,热门物品会淹没长尾关系。实际生产中,我们采用加权 Jaccard + 截断 + 缓存预热的组合方案。
2.1 行为日志清洗与共现矩阵构建
原始日志通常为user_id,item_id,timestamp,action_type四元组。关键不是“有没有点击”,而是行为强度建模。例如:
# 基于 action_type 赋予权重(非简单 0/1) action_weight = { 'click': 1.0, 'cart': 2.5, 'buy': 5.0, 'fav': 1.8 } # 构建加权共现矩阵(稀疏存储,避免内存爆炸) from scipy.sparse import coo_matrix import numpy as np # 假设 df_log 已加载,含 user_id, item_id, action_type df_log['weight'] = df_log['action_type'].map(action_weight) # 映射 item_id 到连续整数索引(加速矩阵运算) item_to_idx = {item: idx for idx, item in enumerate(df_log['item_id'].unique())} df_log['item_idx'] = df_log['item_id'].map(item_to_idx) # 构造 coo_matrix:行=user_id,列=item_idx,值=weight rows = df_log['user_id'].astype('category').cat.codes cols = df_log['item_idx'] data = df_log['weight'] coo_mat = coo_matrix((data, (rows, cols)), shape=(len(df_log['user_id'].unique()), len(item_to_idx)))提示:此处
coo_matrix仅为中间表示,最终需转为csr_matrix进行行向量点积。若用户数超千万,建议用dask分块处理或改用implicit库的ALS模块直接训练隐式反馈模型。
2.2 加权相似度计算与 Top-K 剪枝
标准余弦相似度对高频物品敏感。我们采用TF-IDF 式加权 Jaccard:
$$ \text{sim}(i,j) = \frac{\sum_{u \in N(i) \cap N(j)} w_{ui} \times w_{uj}}{\sqrt{\sum_{u \in N(i)} w_{ui}^2} \times \sqrt{\sum_{u \in N(j)} w_{uj}^2}} $$
其中 $w_{ui}$ 是用户 $u$ 对物品 $i$ 的行为权重。该公式本质是将共现向量做 L2 归一化后再点积,抑制热门物品主导相似度。
from sklearn.metrics.pairwise import cosine_similarity from scipy.sparse.linalg import svds # 对 item-item 矩阵做归一化(按行 L2) item_item_mat = coo_mat.T @ coo_mat # shape: (n_items, n_items) item_item_norm = normalize(item_item_mat, norm='l2', axis=1) # 计算相似度矩阵(仅保留 top-k,节省内存) k = 100 similarity_mat = cosine_similarity(item_item_norm, dense_output=False) # 提取每行 top-k 非零值 topk_sim = [] for i in range(similarity_mat.shape[0]): row = similarity_mat[i].tocoo() if len(row.data) == 0: topk_sim.append(np.array([])) continue # 按相似度降序取 top-k idx = np.argsort(row.data)[::-1][:k] topk_sim.append(np.column_stack([row.col[idx], row.data[idx]]))参数说明:
k=100是经验阈值——召回侧通常只需为每个物品维护 50~200 个强关联物品;超过此数,后续倒排索引查询耗时增长快于收益提升。若业务存在强品类隔离(如“手机”和“猫粮”几乎无交集),应在计算前按类目分桶,避免跨域噪声。
2.3 实时更新机制与缓存策略
线上环境不能容忍“每天离线跑一次”的延迟。我们采用双缓冲 + 增量更新:
- 主缓存(Redis Hash):
item_sim:{item_id}存储{sim_item_id: score},TTL 设为 24h - 备缓存(同结构):用于更新期间无缝切换
- 增量触发:当某 item 新增 50 条以上行为,触发局部重算(只更新该 item 及其 top-200 相似 item 的相似度)
# Redis 中存储示例(使用 hmset) HMSET item_sim:123456 "789012" "0.92" "345678" "0.87" "901234" "0.76" EXPIRE item_sim:123456 86400注意:不要用
ZSET存相似度——ZRANGEBYSCORE在分数密集时性能劣于 Hash 的 O(1) 查找。实测 10 万 item 下,Hash 查询 P99 延迟 < 2ms,ZSET 达 15ms。
3. 双塔召回模型的 PyTorch 实现:从特征编码到 ANN 检索的端到端链路
当行为数据稀疏或需融合多源特征(如商品标题、图像 embedding、用户画像)时,向量召回成为必然选择。双塔模型(User Tower + Item Tower)因其结构解耦、便于离线预计算 item 向量,成为工业界主流。但很多团队卡在“训完模型却无法部署”——问题不在模型本身,而在向量质量评估缺失、ANN 库选型失当、线上 query 与 offline train 不一致。
3.1 特征工程与塔结构设计原则
双塔不是“把用户和物品扔进两个 MLP 就完事”。核心约束是:User Tower 输出必须与 Item Tower 输出在同一向量空间可比。这意味着:
- 输入特征必须对齐语义粒度:用户侧用近期行为序列(而非统计聚合),物品侧用原始属性(而非 ID embedding)
- Dropout 必须关闭:线上 inference 时 dropout 会引入方差,导致同一用户多次请求返回不同向量
- 最后一层激活函数禁用 ReLU:它产生大量零值,破坏余弦相似度几何意义;改用
tanh或sigmoid(后者需 scale)
import torch import torch.nn as nn class UserTower(nn.Module): def __init__(self, user_feat_dim, hidden_dim=128, output_dim=64): super().__init__() self.mlp = nn.Sequential( nn.Linear(user_feat_dim, hidden_dim), nn.LayerNorm(hidden_dim), nn.GELU(), # 替代 ReLU,保留负值 nn.Linear(hidden_dim, output_dim), nn.Tanh() # 强制输出 [-1,1],利于余弦相似度 ) def forward(self, x): return self.mlp(x) class ItemTower(nn.Module): def __init__(self, item_feat_dim, hidden_dim=128, output_dim=64): super().__init__() self.mlp = nn.Sequential( nn.Linear(item_feat_dim, hidden_dim), nn.LayerNorm(hidden_dim), nn.GELU(), nn.Linear(hidden_dim, output_dim), nn.Tanh() ) def forward(self, x): return self.mlp(x) # 损失函数:Batch Softmax Loss(更稳定) def batch_softmax_loss(user_emb, item_emb, temperature=0.05): # user_emb: (B, D), item_emb: (B, D) logits = torch.mm(user_emb, item_emb.t()) / temperature # (B, B) labels = torch.arange(logits.size(0), device=logits.device) return nn.CrossEntropyLoss()(logits, labels)参数说明:
temperature=0.05是关键调节点——值越小,正样本 logits 越突出,但梯度越稀疏;实测在 0.03~0.07 区间效果最佳。若线上发现“热门物品向量聚集”,说明 temperature 过小,需增大。
3.2 ANN 检索引擎选型与量化配置
训练完模型,需将 2 亿 item 向量构建成可检索索引。FAISS 是当前最成熟方案,但默认 Flat 索引不可用——10 亿向量下查询延迟超 100ms。必须启用 IVF_PQ(Inverted File + Product Quantization):
| 配置项 | 推荐值 | 说明 |
|---|---|---|
nlist | 4096 | 倒排文件聚类中心数,nlist ≈ sqrt(N),N 为 item 总数 |
m | 16 | PQ 分段数,m=16对应 64 维向量分 4 段,每段 4 维 |
bits | 8 | 每段码本大小,8-bit 即 256 个 centroid,平衡精度与内存 |
import faiss import numpy as np # 假设 item_embs 为 (N, 64) numpy array index = faiss.IndexIVFPQ( faiss.IndexFlatL2(64), # 量化前 base index 64, # 向量维度 4096, # nlist 16, # m 8 # bits ) index.train(item_embs) # 必须先 train 再 add index.add(item_embs) # 查询:user_emb 为 (1, 64) 向量 D, I = index.search(user_emb, k=200) # D: 距离, I: item_id indices提示:
index.train()耗时长但只需一次;index.add()可增量调用。若 item 库每日新增 10 万,建议每小时执行一次index.add(new_embs)并重建部分 IVF 结构,而非全量 retrain。
3.3 线上 Serving 的 Query-Item 对齐校验
最大陷阱:offline 训练用的 user 特征,与线上 real-time query 构造方式不一致。例如:
- 训练时 user 向量用“最近 100 条行为平均”,线上却用“最近 1 小时行为 LSTM 编码”
- 物品侧训练用 title+image embedding,线上只传了 category_id
必须建立特征一致性检查 pipeline:
# 在线上服务入口处插入校验 def validate_user_features(user_id, user_features): expected_keys = {'seq_len', 'last_click_time', 'embedding_dim'} if not set(user_features.keys()).issuperset(expected_keys): raise ValueError(f"Missing keys for user {user_id}: {expected_keys - set(user_features.keys())}") if user_features['embedding_dim'] != 64: raise ValueError(f"Embedding dim mismatch: got {user_features['embedding_dim']}, expect 64")4. GraphSAGE 召回的图构建与邻居采样:解决长尾物品曝光不足的核心路径
协同过滤和双塔在热门物品上表现优异,但对新上架商品、小众品类(如“手工皮具”“古籍修复工具”)召回率极低——因为它们缺乏足够用户交互。GraphSAGE 通过将用户、物品、类目、品牌构建成异构图,利用图神经网络聚合邻居信息,让冷启动物品也能获得结构化表征。但直接套用论文代码会失败:图结构噪声大、邻居采样偏差、聚合函数过平滑是三大雷区。
4.1 构建高质量异构图:边权重与节点过滤策略
图的质量决定 GraphSAGE 效果上限。我们定义四类节点:user,item,category,brand,三类边:
| 边类型 | 权重计算方式 | 过滤条件 |
|---|---|---|
user→item | 行为权重 × 时间衰减因子exp(-t/86400)(t 为秒级距今) | 仅保留近 90 天行为 |
item→category | 类目归属置信度(平台标注 or NLP 分类 score) | score > 0.85 |
item→brand | 品牌官方认证标识 or 销量占比 | brand_sales_ratio > 0.05 |
import networkx as nx G = nx.MultiDiGraph() # 添加 user-item 边(带权重) for _, row in df_log.iterrows(): t_diff = (pd.Timestamp('now') - pd.to_datetime(row['timestamp'])).total_seconds() weight = row['weight'] * np.exp(-t_diff / 86400) if weight > 0.1: # 过滤弱信号 G.add_edge(row['user_id'], row['item_id'], type='interaction', weight=weight) # 添加 item-category 边(无向,因类目归属稳定) for _, row in df_item.iterrows(): if row['category_confidence'] > 0.85: G.add_edge(row['item_id'], row['category_id'], type='belongs_to', weight=1.0)注意:不要添加
user→user或item→item同质边——异构图中跨类型边才提供互补信息。实测加入同质边会使冷启动物品 embedding 方差降低 40%,反而削弱区分度。
4.2 采样器设计:控制信息泄露与计算开销
GraphSAGE 的核心是邻居采样。标准实现torch_geometric.loader.NeighborSampler默认均匀采样,会导致:
- 热门 item(如 iPhone)被过度采样,挤压长尾 item 出现概率
- 用户节点邻居数波动极大(有的 1 条行为,有的 10 万条),batch 内 padding 浪费显存
我们改用基于边权重的分层采样:
from torch_geometric.loader import NeighborSampler # 定义每层采样数:layer0(直接邻居)采 10 个,layer1(邻居的邻居)采 5 个 num_neighbors = [10, 5] # 按边权重加权采样(非均匀) sampler = NeighborSampler( data=edge_index, # 图结构 sizes=num_neighbors, node_idx=train_mask, shuffle=True, num_workers=4, # 关键:自定义采样器,按 edge_weight 加权 transform=lambda batch: weighted_sample(batch, edge_weight) ) def weighted_sample(batch, edge_weight): # 对 batch 中每个节点,按连接边的 weight 概率采样邻居 # 实现略,核心是 torch.multinomial(weight[edges], num_samples) pass4.3 聚合函数选择与冷启动增强技巧
GraphSAGE 原始聚合(mean/max/pool)易导致“邻居信息过平滑”。我们采用GATv2 风格注意力聚合,并为冷启动物品注入先验:
import torch import torch.nn.functional as F class GATv2Conv(torch.nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.lin_q = nn.Linear(in_channels, out_channels) self.lin_k = nn.Linear(in_channels, out_channels) self.lin_v = nn.Linear(in_channels, out_channels) def forward(self, x, edge_index): # x: (N, D), edge_index: (2, E) q = self.lin_q(x)[edge_index[0]] # query for source node k = self.lin_k(x)[edge_index[1]] # key for target node v = self.lin_v(x)[edge_index[1]] # value for target node alpha = (q * k).sum(dim=-1) # attention score alpha = F.softmax(alpha, dim=0) # normalize per source node out = scatter(v * alpha.unsqueeze(-1), edge_index[0], dim=0, reduce='sum') return out # 冷启动增强:对无交互 item,用 category embedding 初始化 def init_cold_item_embedding(item_id, category_emb_dict): if item_id not in interaction_count or interaction_count[item_id] < 5: return category_emb_dict.get(get_category(item_id), torch.zeros(64)) else: return None # 由 GNN 正常学习验证指标:上线前必须检查冷启动物品的召回覆盖率——定义“上架<7天且无购买行为的 item”,统计其在 24 小时内被召回的次数 / 总曝光次数。目标值 ≥ 35%(行业基准),低于 20% 说明图结构或采样策略需调整。
5. 召回效果验证的三个硬性指标:不只是看 Recall@K
模型离线指标(如 Recall@100)与线上业务指标(如 CTR、GMV)常脱节。真正决定召回模块是否健康的,是以下三个可监控、可归因、可定位的硬指标:
5.1 候选集多样性(Diversity@K)
定义:对单次请求返回的 K 个物品,计算其两两之间的品类/品牌/价格区间距离均值。值过低说明召回陷入“信息茧房”。
def calc_diversity(items, feature_df): # feature_df: item_id -> [category_id, brand_id, price_bin] vectors = feature_df.loc[items][['category_id', 'brand_id', 'price_bin']].values # 使用汉明距离(类别型特征) from sklearn.metrics import pairwise_distances dist_matrix = pairwise_distances(vectors, metric='hamming') return dist_matrix[np.triu_indices_from(dist_matrix, k=1)].mean() # 监控阈值:Diversity@200 > 0.65(0~1 区间)5.2 长尾物品曝光占比(Tail Exposure Ratio)
定义:统计所有被召回的物品中,属于销量排名后 50% 的 item 占比。该值 < 15% 说明召回过度偏向头部。
-- 在数仓中每日跑 SELECT COUNT(*) FILTER (WHERE item_rank_percentile > 0.5) * 1.0 / COUNT(*) AS tail_exposure_ratio FROM recall_log rl JOIN item_stats is ON rl.item_id = is.item_id WHERE rl.dt = CURRENT_DATE;5.3 召回响应 P99 延迟与失败率
定义:P99 延迟 > 120ms 或失败率 > 0.1% 时,立即熔断并切回降级策略(如 fallback 到 Item-CF)。
| 指标 | 告警阈值 | 降级动作 |
|---|---|---|
recall_latency_p99_ms | > 120 | 切换至备选召回通道(如规则召回) |
recall_failure_rate | > 0.001 | 返回空列表 + 上报异常日志 |
ann_index_load_ratio | < 0.95 | 触发索引重建任务 |
关键操作:在服务启动时,强制执行一次
index.search()并记录耗时,若首次查询 > 500ms,说明索引未 warmup,需预加载部分 cluster centroid 到内存。FAISS 提供index.reset()后调用index.search()即可触发 warmup。
真正的召回工程,始于对业务瓶颈的清醒认知——不是堆模型,而是用最简方案守住底线,再用可解释的指标驱动迭代。当你能稳定控制 Diversity@200 > 0.65、Tail Exposure Ratio > 25%、P99 延迟 < 80ms 时,才算真正把“召回”从概念变成了可交付的基础设施。
本文还有配套的精品资源,点击获取