☰
推荐系统检索环节:双塔模型与向量召回实战解析
2026/10/21 9:15:57 网站建设 项目流程

做推荐系统的人应该都经历过这个场景:算法同学手里有一堆用户行为数据,老板说“做个电影推荐吧”,你脑子里第一个冒出来的往往是“算相似度、取TopN”。可真到了线上,电影库几十万、用户几百万,你根本没法在用户请求到达的那几百毫秒内把所有电影挨个算一遍分数。这时候,检索(retrieval)这个环节就浮出水面了。

这个名为“电影推荐:检索”的项目,看起来只是一个入门教程,实际上讲透了推荐系统里极其关键的一步——候选集生成。它的任务很纯粹:用尽可能低的延迟,从全量电影池里先捞出一批“可能被用户喜欢”的候选,交给后续精排模型去细排。文章里用的核心方法是双塔模型加向量内积打分,配合近似最近邻索引完成在线检索。适合刚接触推荐系统、想搞懂“召回到底怎么落地”的算法工程师或学生,也适合那些已经在做排序、但对召回链路还不清楚的工程同学读。

1. 项目整体设计与思路拆解

1.1 这个项目到底在解决什么问题

先说痛点。比如某平台有十万部电影、五十万活跃用户,如果每次请求都对全量电影跑一次深度模型打分,算力成本高到离谱,延迟也会直接击穿服务指标。但你又不能完全不筛选,否则用户看到的都是热门榜,个性化等于没有。检索阶段就是要在这两者之间找平衡:用很轻量的方式,把十万部电影快速收缩到一两百部,之后再让精排模型在这几百部里慢慢排序。

这个项目把问题定义得很清晰:给定一个用户,从候选电影池中返回一个有序的候选列表。排序的标准不是“分数有多准”,而是“真正喜欢的电影有没有被捞进来”。所以评估指标自然集中在召回率、命中率这类东西上。很多人一上来就纠结精排的AUC,忽略了如果召回阶段就把好电影漏掉了,后面所有精细排序都是白搭。

另一个容易被忽视的点是:检索模型的核心不是“预测评分”,而是学习向量表示。用户和电影各自被映射到一个低维向量空间,用户向量和电影向量的内积就代表匹配程度。这和传统协同过滤里的矩阵分解本质上是同一件事,只是用神经网络包装得更灵活、能加入更多特征。明白了这一点,你就知道为什么这个项目会花大量篇幅在嵌入表、双塔结构、内积打分这些概念上。

从技术栈上看,这类项目的标准做法是训练一个双塔模型——用户塔吃用户特征,电影塔吃电影特征,两塔分别产出向量。训练完成后,把全部电影跑一遍电影塔得到向量,建立近似最近邻索引;在线时只跑用户塔算出用户向量,再到索引里查找TopK。这个方案既保证了速度,又保留了矩阵分解那种隐语义的表达能力,落地性价比极高。

1.2 为什么单独把“检索”拎出来讲

很多教程一上来就是完整的两阶段推荐系统,排序召回一起讲。但实际工程里,检索和排序面对的问题完全不同。排序关心的是几十个候选里哪个最值得放前面,讲究特征交叉、精细排序;检索关心的是百万级池子里哪些值得进入候选集,讲究覆盖率、速度、多样性。把两者拆开学,反而更容易理解每个模块的设计动机。

检索还有个特性:它是整个推荐链路的天花板。排序模型再强,如果检索环节没把用户喜欢的电影捞进候选池,最终结果一定不会好。这就像钓鱼,排序是挑大鱼,召回决定你站在哪个鱼塘——鱼塘里没鱼,你甩竿技巧再高也没用。所以单独拎出检索来讲,不是简化问题,而是让你先把地基打扎实。

这个项目还有一个很大的价值:它让你理解“候选集生成”和“最终推荐”的边界。训练时用的是用户和电影交互的正样本,但真正部署时你要面对的是全量电影池,其中绝大多数是用户从未看过、从未评分过的电影。模型必须学会在“没交互过”的向量中寻找潜在兴趣,这比单纯拟合历史评分要难得多。如果你之前只做过排序模型,刚看到检索模型时可能会觉得“这也太粗糙了”,但实际跑完一批实验后会发现,召回提升一个点对最终效果的影响,往往比精排提升两三个点还明显。

2. 核心细节解析与实操要点

2.1 数据准备与输入管道设计

这个项目用的数据是一个公开电影评分数据集,大致包含几万用户、几万部电影、上百万条评分记录。原始数据不能直接拿来训练,必须先做一轮清洗。第一步是把评分转成“正反馈信号”,通常的做法是只保留评分较高的记录,比如评分大于等于4分的交互才算“喜欢”。这一步听起来简单,但直接影响模型学习目标——如果你把2分和5分一视同仁当作正样本,模型学出来的向量会非常混乱。

然后是ID规范化。用户ID和电影ID看起来都是数字,但一定要转成字符串再处理,否则在特征映射时容易产生索引错位。还有一个很多人踩过的坑:数据集里的ID通常不是连续整数,比如用户ID可能从1到100万中间只有30万个实际用户,如果直接用原始ID建嵌入表,会导致大量无效参数。正规做法是重新编码,把实际出现过的用户和电影映射成0到N-1的连续整数,这一步虽然枯燥,但能大幅减少内存占用和训练时间。

数据划分也很有讲究。很多教程随机分训练集和验证集,但在推荐场景里随机划分容易造成信息泄漏。比如某个用户的大部分历史在训练集,极小部分在验证集,模型等于“已经见过这个人”,验证结果自然虚高。更稳妥的是按用户划分,或者按时间划分——用前80%时间段的交互做训练,后20%做验证,模拟真实线上场景。这个项目虽然没写特别细,但按我的实践经验,一定要在数据划分上多花心思,否则后面调参都是在自欺欺人。

输入管道的核心是构造“用户-正样本电影”配对。每个训练样本就是一条用户看了某部电影并给了高分的记录。处理时可以用标准的数据管道API,先做shuffle打破时间顺序,再按固定batch size打包。这里有一个隐含的负样本机制:不需要显式构造负样本,而是在同一个batch内,把其他用户的正样本电影当作当前用户的负样本。这个机制叫in-batch softmax,后面详细讲。因为依赖batch内的其他样本,所以shuffle必须足够充分,否则相似时间段的电影总扎堆在一起,负样本多样性会变差。

2.2 双塔检索模型的构建逻辑

双塔结构是这个项目当之无愧的核心。左侧是用户塔,输入是用户特征,输出是用户向量;右侧是电影塔,输入是电影特征,输出是电影向量。最终匹配分数就是两个向量的内积。为什么非得用这种“两座塔”?因为在线推理时,电影侧可以和用户侧完全解耦。所有的电影向量可以离线全部算好、建好索引,用户请求来的时候只算用户向量,再到索引里去检索,整个过程不需要把用户向量和电影向量放在同一个模型里前向传播,响应时间可以压到几十毫秒。

用户塔和电影塔内部结构可以很简单,也可以很复杂。最简单的版本就是查嵌入表取向量,然后过一两个全连接层。因为输入通常是类别特征,比如用户ID、电影ID、电影类型,所以嵌入层是必不可少的。嵌入维度建议从32或64开始,太小装不下语义,太大会让矩阵分解式的内积变成在高维空间里做点积,泛化能力未必提升,存储成本却明显增加。

特征处理是双塔里很灵活的部分。用户塔除了用户ID,还可以拼上用户最近喜欢过的电影ID、用户的活跃度分桶;电影塔除了电影ID,还可以拼上电影类型的嵌入、上线时间等。在项目里,为了演示简洁,往往只用了ID特征,但你要清楚这只是骨架,真实场景里特征规模要大得多。拼特征时注意一点:所有特征经过嵌入后统一投射到同一个维度,比如256维,最后相加或拼接再压缩到目标维度。如果用户塔和电影塔的输出维度不一致,内积就没法算了。

还有一个容易被忽略的点:向量要不要做L2归一化。归一化之后,内积算出来的其实是余弦相似度,取值范围被限制在[-1,1],训练会更稳定,但也会丢掉一些“用户活跃度”之类的全局信息。这个项目里一般不做强制要求,但你在设计自己的模型时要想清楚,训练阶段用什么相似度度量,检索阶段索引也要用同一种度量,否则效果会悄悄打折扣。

2.3 损失函数与负采样机制

检索模型的训练目标可以理解成一个多分类问题:对每个用户,希望他真正喜欢的那部电影在“全量电影池”里的得分最高。但电影数量太多,直接算完整的softmax不现实。于是最常见的做法是in-batch softmax:每个batch内假设有B个用户和B部电影,对当前用户来说,batch里除了他自己的正样本电影,其余B-1部电影全部当作负样本。这样就构建了一个规模为B的分类问题,计算量小得多。

用交叉熵损失来训练这个batch内的分类任务。具体实现时,把用户向量和电影向量做内积得到B×B的相似度矩阵,矩阵对角线上是正样本,其余位置是负样本,然后在电影这个维度上做softmax,再取对角线的交叉熵。这个方案最大的优势是负样本不需要额外构造,训练效率极高,但缺点也很明显:热门电影经常出现在batch里,被频繁当作负样本,模型会“惩罚”热门电影,导致它们的向量被压缩到离用户向量很远的地方,最终推荐结果容易偏向冷门内容。

解决这个问题有几种常见手段。一是加温度系数,把内积结果除以一个温度τ,当τ较小时softmax分布更尖锐,模型更关注难分样本;当τ较大时更平滑。调温度相当于权衡“精确度”和“鲁棒性”,项目里可以先用默认值1,再根据验证召回率微调。二是引入logQ correction,即根据每个负样本在训练数据中出现的概率做修正,抵消热门物品被过度采样的偏差。三是用流式负采样,维护一个候选池的频率估计器,让负样本出现频率尽量接近真实分布。

有些人可能会问:为什么不能像排序模型一样用固定的负样本?能是能,但固定负样本会导致模型只知道少数负样本不好,对绝大多数没见过的电影完全没有区分能力。In-batch softmax相当于免费送了你大量随机负样本,而且这些负样本是来自训练batch的自然分布,在语义上更接近真实全量池。实际跑下来,这个机制在召回任务里表现很好,属于性价比最高的默认方案。

3. 实操过程与核心环节实现

3.1 从数据加载到特征工程

下面是一份可以直接参考的伪代码实现。我刻意没有绑定具体框架的API,因为这类模型在主流深度学习框架里都有现成组件,你按同样的逻辑替换就行。

# 1. 读取原始数据,只看正反馈 import pandas as pd ratings = pd.read_csv("ratings.csv") ratings = ratings[ratings["rating"] >= 4.0] ratings = ratings[["user_id", "movie_id"]].copy() # 2. 将ID转成连续整数并映射 user_ids = ratings["user_id"].astype(str).unique() movie_ids = ratings["movie_id"].astype(str).unique() user_id_map = {id_: i for i, id_ in enumerate(user_ids)} movie_id_map = {id_: i for i, id_ in enumerate(movie_ids)} ratings["user_idx"] = ratings["user_id"].astype(str).map(user_id_map) ratings["movie_idx"] = ratings["movie_id"].astype(str).map(movie_id_map) # 3. 按用户维度切分训练与验证集 users = ratings["user_idx"].unique() train_users = set(users[: int(len(users) * 0.8)]) train_df = ratings[ratings["user_idx"].isin(train_users)] valid_df = ratings[~ratings["user_idx"].isin(train_users)]

这里有一个极易踩坑的细节:切分时不要简单按行随机切。假设一个用户有30条记录,随机切分后训练集和验证集都会有这个用户,模型很容易通过用户ID记忆住他的偏好,验证集指标会虚高到让你误以为模型很厉害。我建议按用户维度切分,或者按时间排序后切分,这样验证集里的用户对模型来说基本是“半生不熟”的,更能反映线上新增用户的效果。

特征工程部分,这个项目最简版只需要用户索引和电影索引。如果想让效果更好,可以给电影塔加一个“类型”特征。电影类型属于类别特征,做嵌入即可,注意类型不是单值,一部电影可能同时属于“动作”和“冒险”两类,这时候可以先做多值编码,再过一个可学习的聚合层,比如直接把几个类型的嵌入向量取平均。这是一个简单有效又不太容易出bug的做法。

3.2 模型训练与超参数设置

模型结构定义可以参考下面的伪代码。用户塔和电影塔都从嵌入层开始,最终都输出32维向量。

EMBED_DIM = 32 def user_tower(user_idx): # 用户ID嵌入查询 user_embedding = Embedding(num_users, EMBED_DIM)(user_idx) user_vector = Dense(EMBED_DIM, activation="relu")(user_embedding) return user_vector def movie_tower(movie_idx, genre_idx): movie_embedding = Embedding(num_movies, EMBED_DIM)(movie_idx) genre_embedding = Embedding(num_genres, EMBED_DIM)(genre_idx) # 多值类型取平均 genre_vec = Average()(genre_embedding) combined = Concatenate()([movie_embedding, genre_vec]) movie_vector = Dense(EMBED_DIM, activation="relu")(combined) return movie_vector def compute_scores(user_idx, movie_idx, genre_idx): user_vec = user_tower(user_idx) movie_vec = movie_tower(movie_idx, genre_idx) # 内积匹配 return ReduceSum(user_vec * movie_vec, axis=-1)

训练过程最核心的代码是in-batch softmax损失。我给出一个具体计算思路:

def in_batch_softmax_loss(user_vectors, movie_vectors, temperature=1.0): # user_vectors: [batch_size, embed_dim] # movie_vectors: [batch_size, embed_dim] logits = Matmul(user_vectors, Transpose(movie_vectors)) / temperature # 对角线是正样本 labels = IdentityMatrix(batch_size) loss = CrossEntropy(logits, labels) return loss

这个矩阵的每一行代表一个用户对所有batch内电影的相似度,对角线是应该被命中的那部电影。不要小看这么简单的逻辑,它把“负采样”的复杂度降到了最低,而且天然支持大规模batch并行。

超参设置上我列一份常用范围,方便你起步:

超参数推荐取值范围说明
嵌入维度32~256太小语义不足,太大训练变慢且收益递减
Batch size256~4096检索任务偏大batch,负样本更多样
学习率1e-4~1e-3配合Adam类优化器使用,从1e-3起步较快
温度系数τ0.05~1.0越小越集中,越大越平滑,建议从1开始
训练轮数5~20小数据集10轮内基本收敛,多了容易过拟合

这里特别说一下batch size的作用。In-batch softmax的负样本数量取决于batch size,batch越大,每个用户看到的负样本电影越多,训练信号越充分。所以如果你的显存允许,把batch size从256调到1024往往比改模型结构更能提升效果。代价是训练时间变长,而且温度系数可能需要跟着调小,因为负样本多的时候logits整体偏大,需要更低的温度来维持样本区分度。

训练过程中我建议边训练边在验证集上算召回率,不要只盯训练损失。如果损失在降但验证召回率不涨,大概率是过拟合或特征设计有问题,早停能帮你省下不少试错时间。

3.3 候选集的索引构建与在线检索

训练结束后,模型要真正上线还得过一道工序:把所有电影向量化并建索引。操作很简单,把电影塔固定住,对电影池里的每一部电影跑一次前向,得到m个电影向量,然后写入近似最近邻索引。为什么不用暴力线性扫描?十万部电影暴力扫描倒也能扛,但到了千万级就必须索引化,而且线上延迟要求越高,索引越重要。

主流的近似最近邻索引有两种风格:一种基于乘积量化(PQ),把向量拆成子空间做量化,压缩率高;另一种基于HNSW风格的图索引,适合追求高召回、内存充足的场景。具体选型取决于你的部署环境:内存紧张选PQ,召回率敏感选HNSW。这里有一个关键原则:训练阶段用什么相似度度量,索引检索阶段也必须用同一种。如果训练用内积,检索却默认算欧氏距离或余弦,排序结果会和训练目标偏离。

在线推理时,用户请求进来,只用用户塔算一次用户向量,然后去索引里查TopK。整个过程可以用下面这个流程描述:

# 离线阶段 all_movie_vectors = movie_tower(all_movie_ids) build_ann_index(all_movie_vectors, metric="dot") # 在线阶段 user_vector = user_tower(current_user_id) candidates = ann_index.search(user_vector, k=200)

一个很现实的问题是:用户向量每天都在变,电影索引多久更新一次?我的建议是电影索引可以小时级或天级重建,因为电影池变化慢;但用户向量务必实时算,因为它依赖当前用户特征。如果为了省算力把用户向量也缓存,那新用户的推荐会直接失效,老用户的兴趣变化也捕捉不到。

索引重建本身也是个不小的工程点。全量重建耗时较长,可以考虑分片重建或双缓冲切换:新索引在后台构建,构建完成后原子切换线上索引,避免重建期间服务不可用。项目里虽然没展开优化,但你要知道离线算向量、建索引、切换索引这条链路,能做到自动化,才算是真正具备上线条件。

4. 常见问题与排查技巧实录

4.1 训练不收敛或效果差怎么办

最常遇到的情况是训练损失降了,但验证召回率几乎不涨。先别急着换模型,按顺序排查这几项。

第一,检查正样本定义。如果你把用户看过的所有电影都当正样本,包括只看了几秒钟就关掉的电影,训练信号会很脏。建议只保留强反馈,比如观看完成度高、评分高、有收藏行为的交互。弱反馈当成正样本,等于给模型注入噪声。

第二,检查嵌入表是否有足够容量。用户侧几十万ID,嵌入维度给到8,那就太小了,学不出丰富兴趣。反过来,只有几万用户却给1024维嵌入,不仅训练慢,还极容易过拟合。一个粗略经验:嵌入维度取ID数量的开根号左右,比如10万用户用32维,100万用户用64维,实测基本不会差太多。

第三,检查温度系数。如果训练 loss 下降但召回不涨,很可能是温度过高,softmax分布太平滑,模型没有动力把正样本分数拉得足够高。试着把温度从1.0往下降,比如0.3或0.05,效果往往会立刻改善。但温度太低也不好,模型会过度关注困难负样本,导致嵌入空间非常“尖锐”,泛化变差。

第四,看看是不是模型直接记住了用户ID。双塔模型里用户ID嵌入是最强特征,如果训练集和验证集存在相同用户,模型会偷懒只靠记忆用户ID匹配电影,而不是学习更多泛化特征。解决方法是按用户划分验证集,或者在线下评估时专门评估“新用户”子集。

4.2 检索结果总是热门电影怎么办

热门偏差是检索模型最典型的顽疾。症状是:线上推荐结果大量出现高热度电影,用户个性化反馈不明显。原因前面已经说过,in-batch softmax中热门电影频繁作为负样本出现,模型把它们“打压”得太狠,导致热门电影向量被推向远离所有用户的方向,但线上索引检索时,热门电影因为数量多、邻居密,又容易被捞回来,两个机制纠缠在一起。

针对这个问题,轻量级解法是调整负采样分布。比如说,对batch内的负样本按出现频率做对数修正(logQ correction),在计算logits时减去负样本出现概率的对数,相当于抵消频率偏差。更直接的做法是显式维护一个采样表,让负样本不完全来自batch内,而是额外从全量电影池按均匀分布采样一部分。混入均匀负样本后,模型对热门电影的“偏见”会小很多。

另一个实用技巧:在评估时不要让热门电影全部参与召回。你可以把候选集限制为“用户没有看过的电影”,并且按一定规则屏蔽掉过于热门的内容。这样评估出来的召回率更能反映模型的个性化能力,而不是“热门电影本来就容易被推荐出来”的假象。线上也可以加类似的去热门策略,但注意不要误伤真正适合用户的热门好片。

4.3 训练与检索不一致的问题

很多同学把模型训练完,向量导出,随便拿一个索引就开始测效果,结果发现验证时明明Recall@50不错,线上却很拉胯。这一般不是模型问题,而是训练-检索链路里出现了度量不一致或索引参数不合理。

最常见的坑就是度量不一致。训练时用户向量和电影向量做内积,导出索引时却选了余弦距离,或者在索引里做了向量的L2归一化。数学上,内积和余弦相似度虽然都衡量“相似”,但在高维空间里排序结果可以差别很大,尤其是用户和电影向量模长差异大的时候。解决方案只有一个:在训练代码里就明确你最终要用什么度量,然后把同样的预处理和度量方式复制到索引构建脚本里,不要靠记忆。

索引参数也会显著影响召回。HNSW风格的索引有几个参数:最大连接数、搜索候选宽度等。参数设得太小,索引构建快、内存低,但召回率会下降;参数设大,检索精度高,但查询变慢。我的经验是先按默认参数建索引,然后用一小批真实用户向量做召回率对比,调整参数直到召回率与暴力搜索接近且延迟可接受。这个“索引校准”步骤看似耗时间,但它能帮你避免上线后才发现的“静默损失”。

最后一个工程注意点:索引里的电影ID必须和训练时的ID映射表保持一致。我见过不止一次因为离线导出向量时用了另一套ID,导致线上命中不到正确物品的案例。建议在构建索引后写一个冒烟测试:随机抽20个用户,手工检查检索结果里是否包含他们明确喜欢过的电影,能尽早暴露这类低级错误。

5. 调优与后续扩展方向

5.1 从单路召回走向多路召回

双塔模型训练好之后,你其实只拥有了一路召回。线上推荐系统通常不会只靠这一路,因为双塔模型对稀疏行为覆盖好,但对一些只有少数交互的长尾电影可能无能为力。成熟的工程方案是“多路召回”:双塔向量召回作为一路,再加上按类型召回、按导演召回、按热门度召回等规则路,各路各自取TopK后合并、去重、做融合排序。

多路召回的价值不仅在于覆盖率,还在于容错。某一路召回模型出问题或数据有洞,其他路还能顶上,不会让推荐结果瞬间崩成空白。融合的方式可以很简单,就是按固定权重合并候选集,也可以复杂一点,用一个小模型对多路候选打分。起步阶段不要一上来就搞复杂融合,先固定几路,人工调一遍权重,观察用户点击和时长指标,再逐步自动化。

如果想把双塔召回做到更好,可以考虑在用户塔输入中加入用户最近交互过的电影序列。做法也很成熟:把用户最近看过的N部电影ID分别取嵌入,做平均或加一个简单的注意力权重,得到序列向量,再接全连接层。这种改动通常能显著提升召回效果,因为用户的当前兴趣往往由近期行为主导,而不是全量历史。

5.2 进阶改进方向

模型层面的进阶方向主要有三个:特征交叉、多模态、蒸馏。

特征交叉方面,双塔模型的劣势是用户塔和电影塔内部各自独立,特征在最终内积前没有充分交叉。你可以在塔内加一些简单的交叉特征,比如把用户最常看的类型和电影类型做对位相乘;也可以用交叉网络做特征交互,但注意不要破坏双塔结构,否则在线推理时电影侧无法独立算向量。

多模态方向适合电影这种富内容场景。电影的海报、简介、预告片都带着强信号,把图像特征或文本特征作为电影塔的附加输入,能明显改善新电影冷启动。比如用预训练模型把海报编码成视觉向量,和电影ID嵌入拼接后一起送到电影塔。这一块最大的成本不是模型本身,而是特征离线管道和存储。

蒸馏方向则是为了解决“模型规模受限”的问题。线上要求低延迟,模型不能太大。常规做法是训练一个大容量双塔模型作为教师,再蒸馏出一个小的双塔模型作为线上服务使用。蒸馏损失可以同时约束小模型的输出向量接近教师模型,以及在排序层面接近教师模型的TopK顺序。这样能在保持召回率的前提下,把线上模型参数量压到十分之一甚至更小。

如果你打算在这条线上深入,我建议的顺序是:先把双塔召回的基础链路跑通,再补多路召回和冷启动策略,最后再做特征和模型结构优化。不要在第一步就上多模态、蒸馏、大batch并行这些花活,因为推荐系统里“数据质量”和“评估口径”对效果的贡献,往往比模型结构本身更大。

根据我个人的实操体验,这个检索项目最大的价值不是教你调出一个SOTA模型,而是让你完整走一遍“数据—模型—索引—评估”的闭环。很多人做推荐算法一上来就钻研排序模型,忽略了候选生成这层天花板。等你在真实数据上把双塔召回、in-batch softmax、索引校准这些环节都踩过一遍坑,再回头看精排问题,会发现整个系统在脑子里清晰了很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询