第 17 讲:[客座] 用 LLM 做推荐系统

Large Language Models: Methods and Applications · Lecture 17。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应原课:CMU 11-667 Fall 2025 · Lecture 17 · Thu 30-Oct-2025 · LLMs for Recommendation Systems(客座:Neil Shah) 原课 slides:Recommenders 阅读材料:Kang & McAuley (2018), Self-Attentive Sequential Recommendation(SASRec);BehnamGhader et al. (2024), LLM2Vec: Large Language Models Are Secretly Powerful Text Encoders;Metzler et al. (2021), Rethinking Search: Making Domain Experts out of Dilettantes;Qin et al. (2023), Large Language Models are Effective Text Rankers with Pairwise Ranking Prompting 配套代码:code/lecture17/(1 个模块 + 4 个演示脚本) 本讲的写法:这是一次客座讲座,原课的四篇阅读材料对应 LLM 进入推荐系统的四个位置:

  • 把推荐写成语言建模;
  • 把商品写成 token;
  • 把 LLM 当编码器;
  • 把 LLM 当重排序器。

我们在一个”真实偏好已知”的合成集市上把四件事各做一遍。所有数字都来自配套代码的实际输出。


目录


0. 本讲导读

0.1 这一讲要回答的问题

推荐系统大概是机器学习在工业界最赚钱的应用:短视频、电商、新闻、广告、社交网络的信息流,背后都是推荐。它和语言模型看起来是两个领域,其实有很深的共同点:

  • 用户按时间排好的点击或购买序列,就像一个句子;
  • 商品就像词;
  • “推荐下一件商品”就是”预测下一个 token”。

这一讲沿着阅读材料,看 LLM(以及 LLM 的技术)怎样进入推荐系统的四个位置:

  1. 建模:用 Transformer 建模行为序列(SASRec);
  2. 表示:商品怎样写成 token(原子 ID vs 语义 ID),推荐变成”生成下一件商品的 ID”(生成式检索);
  3. 编码:把解码器 LLM 改造成文本编码器(LLM2Vec),给没有行为数据的新商品一个向量;
  4. 排序:用 LLM 给候选重新排序(逐点、成对、列表)。

0.2 学完这一讲你应该能

  • 说出推荐系统的基本结构(召回 → 排序 → 重排)和常用的评价指标;
  • 解释 SASRec 和语言模型的对应关系,以及损失函数(全 softmax vs 负采样)的影响;
  • 比较原子 ID 和语义 ID,解释生成式检索为什么在冷启动上有困难、前缀匹配怎样缓解;
  • 说出把解码器变成编码器的步骤(池化、双向注意力、MNTP、对比学习),并理解”相似”由训练目标定义;
  • 比较逐点、成对、列表三种重排序的效果、代价和位置偏差。

0.3 本讲的实验平台:花园集市

rec_world.py:

  • 商品:144 件,是 43 个花园名词和若干形态的组合。
    • 水果:seeds、sapling、basket、jam;
    • 蔬菜:seeds、basket、soup;
    • 花:seeds、bulb、bouquet;
    • 动物:feed、toy、house。
    • 每件商品有一段描述,例如 ripe plum jam。
  • 用户类型:6 种,都是隐藏的:果园主(orchard)、厨师(kitchen)、花匠(florist)、农场主(farm)、宠物主人(pets)、送礼的人(gifts)。每种对品类和形态有不同的偏好;同一品类里的名词按 Zipf 定律有冷热。
  • 购买过程:每个用户买 5~20 件。
    • 以概率 ρ = 0.4 买”上一件的配套”:同一个名词、另一种形态,例如买了 plum seeds 接着买 plum sapling。
    • 否则按自己类型的偏好重新挑一件。

因为生成过程完全已知,看过任何一段购买历史之后,“下一件商品”的真实分布都能精确算出来:先对 6 种类型做贝叶斯后验,再按后验混合。这给出了任何推荐模型都不可能超过的上限,就像第 1 讲的真实困惑度下界。


1. 推荐系统与语言模型

1.1 推荐系统在做什么

推荐系统的输入是用户的历史行为(点击、购买、观看、点赞,通常是”隐式反馈”——只知道用户做了什么,不知道他不喜欢什么),输出是”这个用户接下来最可能感兴趣的东西”的排序。工业系统一般分几个阶段:

  1. 召回(retrieval / candidate generation):从上亿件商品里快速找出几百到几千个候选。常用双塔模型:用户向量和商品向量的内积 + 近似最近邻搜索(第 10 讲 §4、第 11 讲)。
  2. 排序(ranking):用更复杂的模型(能看到用户和商品的交叉特征)给候选打分。
  3. 重排(re-ranking):考虑多样性、新鲜度、业务规则,对最终的列表做调整。

越往后的阶段处理的候选越少、模型越贵。LLM 的计算量很大,最自然的位置是在后面的阶段,或者在离线生成特征。

1.2 三类经典方法

  • 协同过滤(collaborative filtering):
    • 思路:“和你买过同样东西的人,还买了什么”。
    • 矩阵分解把用户-商品矩阵分解成用户向量和商品向量的乘积。商品向量完全从行为里学出来,和商品是什么无关。
  • 基于内容(content-based):用商品的描述、类别、图片等内容特征,推荐和用户喜欢过的东西”相似”的商品。不需要别人的行为数据,所以能处理新商品(冷启动)。
  • 序列推荐(sequential recommendation):考虑行为的顺序。买了相机接下来可能买存储卡,而不是又一台相机。马尔可夫链、RNN(GRU4Rec)、Transformer(SASRec、BERT4Rec)都属于这一类。

1.3 和语言建模的对应

语言模型序列推荐
词表(几万个 token)商品库(几万到几十亿件商品)
一篇文档一个用户的行为序列
预测下一个 token预测下一件商品
词嵌入(word2vec)商品嵌入(item2vec:把会话当句子训练 word2vec)
上下文推断”这是什么文档”历史推断”这是什么用户”

这个对应不只是比喻。

  • SASRec(Kang & McAuley 2018)几乎就是第 2 讲的 GPT 式模型,只是 token 换成了商品 ID。
  • Meta 的生成式推荐模型 HSTU(Zhai et al. 2024, Actions Speak Louder than Words)把行为序列当作”语言”,训练到上万亿参数,并观察到了和 LLM 类似的规模定律(第 12 讲)。

1.4 怎样评价

留一法(leave-one-out):每个用户的最后一个行为作为答案,用之前的历史去预测。常用指标(答案在推荐列表里的名次记为 r):

  • HR@10(hit rate):答案在前 10 名里的比例。
  • NDCG@10:答案在第 r 名时得 1/log⁡2(r+1)1/\log_2(r+1) 分(第 1 名得 1,第 3 名得 0.5),r > 10 得 0;只有一个答案时这就是 NDCG。
  • MRR:1/r1/r 的平均。

一个常见的陷阱是抽样评测:只把答案和 100 个随机负例排序,而不是和全部商品排序。Krichene & Rendle (2020) 证明,这样算出的指标会改变不同方法之间的相对名次(练习 3)。本讲的实验都在全部 144 件商品上排序。


2. 序列推荐:预测下一件商品

2.1 SASRec

SASRec 的结构就是第 2 讲的因果 Transformer:

  • 输入是商品 ID 的序列,每个位置用因果自注意力看之前的所有位置,输出这个位置的隐藏状态。
  • 隐藏状态和所有商品的嵌入做内积,就是”下一件商品”的打分。输入嵌入和输出嵌入共享(第 1 讲 §4.4 的权重共享)。

原文的训练用负采样:每个位置一个正例(真实的下一件)和 1 个随机负例,二元交叉熵。语言模型的做法则是全 softmax:在全部商品上做 softmax,交叉熵。

2.2 实验:离上限有多远

代码 01 §1 先看贝叶斯后验怎样随购买更新。一个真实类型是 pets 的测试用户:

买的商品后验最大的两种类型
hungry hen feedfarm 0.55,pets 0.41
hungry duck toypets 0.86,farm 0.08
little duck housepets 0.87,farm 0.08
hungry hen housepets 0.94,farm 0.05
……
hungry cat toypets 1.00

第一件”鸡饲料”农场主和宠物主人都会买;买了”鸭子玩具”之后,几乎可以确定是宠物主人。

代码 01 §2:训练 6000 个用户,测试 2000 个用户(留一法)。

方法HR@10NDCG@10MRR
流行度(不看历史)0.2890.1500.131
一阶马尔可夫(只看最后一件)0.6540.4300.370
SASRec(d = 64、2 层)0.7100.4520.383
贝叶斯最优(真实分布)0.7330.4800.412
  • 即使知道真实分布,HR@10 也只有约 0.73(期望值 0.729)。下一件商品本来就有很大的随机性——这是推荐的”困惑度下界”。只看指标的绝对值,没法判断一个模型好不好;真实数据集没有这把标尺,所以要和强基线比。
  • 马尔可夫抓住了”买配套”。SASRec 还利用了更早的历史来推断用户类型,离上限只差一点。

2.3 实验:损失函数比结构更重要

代码 01 §3(NDCG@10):

训练用户数马尔可夫SASRec + 1 个负例 BCESASRec + 全 softmax
5000.4040.2900.382
20000.4260.3710.434
60000.4300.4200.452
  • 全 softmax 在每个数据量上都更好,数据越少差距越大:1 个随机负例给的信号太弱,大多数随机负例很容易区分(“一个爱宠物的人不会买番茄汤”),模型学不到细微的差别。Klenitskiy & Vasilev (2023) 发现,把 SASRec 的损失换成全 softmax 交叉熵后,它追上甚至超过了 BERT4Rec。很多”新模型更好”的结论,其实来自损失函数的差别。
  • 商品有上亿件时全 softmax 太贵,于是有了各种折中:采样 softmax(用多个负例并做概率校正)、批内负例、困难负例挖掘。
  • 只有 500 个用户时,简单的马尔可夫胜过两种 SASRec:数据少时,“只看最后一件”这个强假设是优点(偏差-方差权衡,前置讲 P2)。

2.4 实验:历史越长,越需要看整段历史

代码 01 §4(NDCG@10):

历史长度人数马尔可夫SASRec贝叶斯
4~7 件5150.4050.4100.437
8~12 件6690.4250.4560.490
13~19 件8160.4480.4750.498

历史越长,类型越确定,上限越高。马尔可夫只看最后一件,用不上这些信息;SASRec 和马尔可夫的差距随历史变长而拉大。

2.5 实验:SASRec 在隐藏状态里推断用户类型

代码 01 §5:

  • 探针:在另外 3000 个用户上,用 SASRec 最后一个位置的隐藏状态训练一个线性探针(第 14 讲 §3),预测用户的类型。
  • 结果:探针的准确率 94.4%,贝叶斯后验的 argmax(能做到的最好)97.3%,瞎猜 16.7%。

SASRec 从没被告知”用户有类型”,却在隐藏状态里形成了接近最优的类型推断:要预测下一件商品,就得先弄清楚”这是什么样的用户”。这和语言模型在上下文里推断”这是什么样的文档”(第 9 讲 §2:上下文学习是隐含的贝叶斯推断)是同一回事。


3. 商品怎样变成 token:语义 ID 与生成式检索

3.1 原子 ID 的问题

SASRec 给每件商品一个独立的 token:原子 ID。它有三个问题:

  1. 嵌入表巨大:10 亿件商品 × 64 维 = 640 亿个参数,比很多 LLM 还大,而且绝大多数商品的嵌入只被训练过几次(长尾,和第 11 讲的冷门知识一样)。
  2. 没有共享:plum jam 和 pear jam 的 ID 之间没有任何关系,一件商品的数据帮不了另一件。
  3. 冷启动:新上架的商品从没出现在训练数据里,它的嵌入是随机的。

3.2 语义 ID 与生成式检索

“模型即索引”(Metzler et al. 2021, Rethinking Search):

  • 传统检索:建倒排索引或向量索引,模型只负责给文档打分。
  • 设想:一个模型直接记住所有文档,给定查询就生成相关文档的 ID。
  • 实现:DSI(Tay et al. 2022, Transformer Memory as a Differentiable Search Index)实现了这个设想。它发现文档 ID 怎么写很重要:随机的整数 ID 不如有语义结构的 ID——先对文档的嵌入做层次聚类,ID 就是聚类树上的路径。

TIGER(Rajput et al. 2023)把同样的想法用在推荐上:

  • 语义 ID:用 RQ-VAE(残差量化)把商品的内容嵌入量化成几个离散的码 (c1, c2, c3)。
    • 第 1 层码表示粗的类别,后面的码是对残差的细化。
    • 内容相似的商品共享前缀。
  • 推荐:变成”给定历史里各件商品的语义 ID 序列,生成下一件商品的语义 ID”,用束搜索、并限制在有效 ID 的前缀树(trie)上解码,避免生成不存在的商品。

3.3 实验:三种写法

代码 02 §1:

方案每件商品词表大小例子
原子 ID1 个 token144sweet apple jam → i3
商品名的词2 个 token(名词、形态)53sweet apple jam → apple jam
残差 k-means 码3 个 token29sweet apple jam → a8 b2 c0

残差 k-means 是 RQ-VAE 的”不训练编码器”版本。内容向量是品类、形态、名词、形容词的加权 one-hot。

  • 第 1 层的 13 个簇几乎正好对应(品类, 形态):a8 = fruit-jam,a12 = animal-feed……
  • 第 2 层对残差聚类,却把名词随意地分组(例如 b0 = bean、cabbage、duck、kiwi、leek、lettuce……):内容向量里不同名词的 one-hot 互相正交,没有”哪些名词相似”的信息。
  • 第 3 位是前缀相同时的消歧编号,最多到 7。

真实系统用文本编码器(TIGER 用 Sentence-T5)得到内容向量,名词之间的语义相似会让后几层的码也有意义——语义 ID 的质量取决于内容向量的质量(§4)。

3.4 实验:冷启动

代码 02 §2:

  • 新商品:随机挑 24 件商品作为”刚上架的新商品”。训练用的 3000 个用户来自一个还没有它们的集市。
  • 测试用户:1000 个,来自完整的集市。
    • 217 人的答案是新商品;
    • 83% 的人的历史里有新商品——新商品不只是”要推荐的东西”,也出现在输入里。
  • 上限:贝叶斯最优对老商品答案的 NDCG@10 是 0.478,对新商品答案的 HR@10 是 0.797。

三个模型的结构相同,打分 = 商品的全部 token 在历史之后的 log 概率之和:

方案老商品答案 NDCG@10新商品答案 HR@10
原子 ID0.4010.037
商品名的词0.3970.000
残差 k-means 码0.3880.046
  • 老商品上三种方案差不多:原子 ID 并不吃亏。README 还记录了一个原型实验:只有 500 个训练用户时,原子 ID 反而最好。“语义 ID 更省数据”在这里没有得到支持。
  • 新商品上全都很差,包括语义 ID。plum jam 从没出现过,模型学到的恰恰是”plum 后面不会跟 jam”——商品名的词这一方案对新商品的概率几乎是 0。生成式的模型把训练里见过的组合当作全部可能的商品,它记住了目录。

3.5 实验:前缀匹配

TIGER 处理冷启动的办法:模型生成前缀,前缀相同的新商品也放进候选。代码 02 §3 用一个等价的打分:

  • 老商品仍用完整的 log 概率;
  • 新商品用”前缀的 log 概率 − log(同前缀的商品数)“,相当于假设最后一个 token 在同前缀的商品里均匀分布。
方案新商品答案 HR@10老商品答案 NDCG@10全部答案 NDCG@10
原子 ID0.0370.401(原 0.401)0.319(原 0.319)
商品名的词0.4790.380(原 0.397)0.348(原 0.311)
残差 k-means 码0.2400.375(原 0.388)0.311(原 0.307)
  • 原子 ID 没有前缀可用,新商品永远推荐不出来。
  • 商品名的词让新商品和老商品共享 plum:模型知道用户接下来很可能买 plum 的什么东西,新的 plum jam 就有了位置。残差码的第 2 层是随意分组的,前缀提供的信息少得多。
  • 前缀匹配要付代价:新商品的分数是”猜”出来的,会挤掉一些老商品(老商品的 NDCG 略降)。但新商品答案的收益更大,全部答案的 NDCG 上升了。新商品越多,这笔交易越划算。

4. 把解码器 LLM 变成文本编码器

4.1 为什么需要文本编码器

§3 的冷启动还有另一条路:按内容推荐。把商品描述编码成向量,推荐和用户买过的东西相似的商品——新商品只要有描述,就有向量。这需要一个好的文本编码器。过去用 BERT 式的双向编码器(Sentence-BERT、E5);而现在最强的语言模型都是解码器(GPT 式)。能不能直接拿来用?

解码器当编码器有两个天生的问题:

  1. 因果注意力:第 i 个 token 的表示只看到前面的 token。句子开头的词”不知道”后面说了什么,所以单个位置的表示都是”不完整的”。
  2. 池化:取最后一个 token 的表示(只有它看过整句),还是取所有位置的平均(每个位置都只看过一部分)?

LLM2Vec(BehnamGhader et al. 2024)用三步把解码器变成编码器:

  1. 打开双向注意力:把因果掩码换成全可见的掩码,参数不变。
  2. 掩码下一个 token 预测(MNTP):随机遮住一些 token,用前一个位置的输出预测被遮住的 token。这结合了 BERT 的掩码语言建模和 GPT 的”位置 i−1 预测 i”,让模型在很少的训练里适应双向注意力。
  3. 无监督对比学习(SimCSE,Gao et al. 2021):同一个句子经过两次不同的随机 dropout 得到两个表示,作为正例拉近;和批里其他句子推远。

此外常用的还有:用有标注的检索数据做有监督对比学习(E5-Mistral 等);“回声嵌入”(把句子重复两遍,取第二遍的表示,这样每个 token 都”看过”整句)。

4.2 实验:一步步把解码器变成编码器

代码 03:

  • 预训练:在集市的文本上预训练一个小解码器(d = 64、2 层,训练 4 遍)。
    • 商品目录:全部 144 件商品的描述,包括 24 件新商品;
    • 3000 个用户的购物日记,例如 I bought pretty poppy bulb . then I bought sweet apple sapling . …。
    • 新商品只出现在目录里,从没出现在日记里。
  • 评价:商品向量的余弦相似度和”真实亲和度”的 Spearman 相关。真实亲和度是同一个用户按偏好挑时买两件商品的 PMI:花匠既买 tulip bulb 也买 rose bouquet,它们的亲和度高。只看名词不同的商品对(名词相同的”配套”太容易),以及名词和形态都不同的商品对(词袋在这里只剩”品类”一个线索)。
商品向量名词不同名词、形态都不同
词袋(one-hot)0.5100.345
解码器,最后一个 token0.4950.343
解码器,平均池化0.5160.416
直接打开双向注意力(不训练)0.4400.330
+ MNTP 适应(双向)0.5520.463
+ 无监督 SimCSE0.4530.316
+ 有监督对比(共同购买的商品对)0.6920.672
  • 平均池化好于最后一个 token:因果模型里最后一个 token 的表示主要关于”描述的最后一个词”(形态)。
  • 直接打开双向注意力反而变差:模型从没在双向的条件下训练过,突然看到”未来”的 token,表示就乱了。这正是 LLM2Vec 观察到的现象。
  • MNTP 只用 2 遍训练就让模型适应了双向注意力,表示比原来的解码器更好。
  • 无监督 SimCSE 在这里变差了——和 LLM2Vec 原文在语义相似度(STS)任务上的结果相反。它的目标是”把同一段文本的两个噪声版本拉近、和所有其他文本推远”,plum jam 和 pear jam 也被推远了。它学到的相似是”是不是同一段文字”,不是”会不会被一起买”。
  • 用共同购买的商品对做有监督的对比学习(推荐版本的对比学习,例如 UniSRec、Recformer 的思路)直接优化了推荐需要的相似,提升最大。

“相似”是由训练目标定义的。 同一个编码器,用不同的对比目标,学出的是不同意义的相似。

4.3 实验:按内容给新商品做推荐

代码 03 §3:用户向量 = 历史里各件商品向量的平均,按余弦相似度给全部商品排序。

商品向量新商品答案 HR@10全部答案 NDCG@10
词袋0.4100.201
解码器,最后一个 token0.2580.111
解码器,平均池化0.3590.134
直接打开双向注意力0.3360.126
+ MNTP0.3870.124
+ 无监督 SimCSE0.4190.166
+ 有监督对比(共同购买)0.4880.163
对照:语义 ID + 前缀匹配(§3.5)0.479—
  • 按内容推荐对新商品很公平:新商品和老商品的向量用同样的方法算出来,不需要购买记录。
  • 词袋在这里出奇地强:
    • “配套”就是同名词的另一种形态,词袋的名词重合正好捕捉了它。
    • 解码器的几种表示在亲和度上更好,在这个任务上却不如词袋。表示的”好”要看下游任务。
  • 有监督对比学习的向量对新商品推荐得最好,和语义 ID + 前缀匹配相当(217 个新商品答案,差距在误差之内)。新商品的描述和老商品共享词,于是继承了”哪些东西会被一起买”的知识。
  • 按内容推荐对全部答案很弱(NDCG 远低于 SASRec 的 0.452):它不知道顺序,也不知道”配套”的方向。实际系统把两者结合:序列模型负责老商品,内容向量负责新商品,或者把内容向量作为序列模型的输入特征。

5. 用 LLM 重排序:逐点、成对、列表

5.1 三种问法

第二阶段的排序模型可以是一个 LLM:把用户的历史(或搜索的查询)和候选写成文本,让 LLM 判断。Qin et al. (2023) 总结了三种”问法”:

问法提示的样子(原理)调用次数问题
逐点(pointwise)“这件商品和用户相关吗?“取”是”的概率;或者用”根据这件商品生成查询 / 历史”的似然(查询似然,UPR,Sachan et al. 2022)n不同候选的分数不一定可比(校准问题);回答”是 / 否”的格式要求模型输出概率
成对(pairwise,PRP)“A 和 B 哪个更相关?”全部比较 O(n²);排序算法 O(n log n);滑动窗口取前 k 名 O(kn)模型可能偏向某个位置;比较结果可能不传递(A > B、B > C、C > A)
列表(listwise,RankGPT,Sun et al. 2023)一次给出全部候选,让模型输出排好的列表1(候选太多时用滑动窗口)长上下文;对候选的初始顺序敏感;可能漏掉或编造候选

PRP 的发现是:在中等规模的开源 LLM 上,成对的问法明显好于逐点和列表,因为比较两个比给出绝对分数容易;每一对正反各问一次,可以抵消位置偏差。

5.2 实验设置

代码 04:

  • 第一阶段:一个弱的召回——和用户最后买的那件商品内容最相似的 20 件。它便宜,也不看整段历史。
    • 在 600 个测试用户上,答案落在 20 个候选里的比例是 68.2%,这是任何重排序都无法超过的上限。
    • 第一阶段自己的顺序 NDCG@10 只有 0.148;按真实分布重排这 20 个候选能达到 0.439。
  • 重排序器:三个模型都从预训练的 SASRec(6000 个用户)出发微调 5 遍,像拿一个预训练的 LLM 来做重排序。训练数据是另外 6000 个用户。
  • 三种问法:都只在最后一个位置预测一个”答案 token”。
逐点  <bos> 历史 <sep> 候选 → <yes> / <no>
成对  <bos> 历史 <sep> A <sep2> B → <A> / <B>
列表  <bos> 历史 <sep> 候选1 … 候选20 <sep2> → 说出最可能的那件商品

5.3 实验:效果与代价

排序方法NDCG@10调用次数(每个用户)
第一阶段的顺序0.148—
逐点(yes 的 logit − no 的 logit)0.28120
成对:全部比较,正反各一次0.279380
成对:全部比较,只问一个方向0.267190
成对:滑动窗口 10 趟(只要前 10)0.274290
列表:一次给出全部候选0.3441
贝叶斯最优重排0.439—
  • 三种重排序器都把第一阶段的 0.148 提高到了 0.27~0.34。
  • 在这个设置里,列表最好,而且只要 1 次调用(但输入长度是逐点的好几倍)。
  • 成对正反各问一次比只问一个方向好,但调用次数多得多。滑动窗口只要前 10 名,省了约四分之一的调用,效果接近。

这和 PRP 在 LLM 上”成对最好”的结论不同,原因可能有两个:我们的列表模型是专门微调过的,而 PRP 讨论的是零样本提示;我们的候选只有 20 个,一个上下文放得下。

5.4 实验:位置偏差

代码 04 §3:

  • 成对:把同一对 (A, B) 正反各问一次,两次结论一致的比例是 81.9%;平均”选第一个”的概率是 0.535(无偏差应为 0.5)。模型略偏向排在前面的那个。
  • 列表:
列表模型候选顺序NDCG@10
按第一阶段的顺序训练第一阶段顺序0.344
按第一阶段的顺序训练倒过来0.325
按第一阶段的顺序训练随机打乱0.334
用随机顺序训练第一阶段顺序0.343
用随机顺序训练倒过来0.344

按第一阶段顺序训练的列表模型学到了一点”排在前面的更可能是答案”的捷径,候选顺序一倒过来就掉了约 2 个点;用随机顺序训练就对顺序不敏感。

LLM 的列表重排序同样对初始顺序敏感。Tang et al. (2024) 的”排列自洽性”对多个随机顺序的结果取平均;PRP 正反各问一次也是同样的思路。位置偏差是从训练数据里学来的捷径(第 7 讲、第 9 讲反复出现的主题)。

5.5 实验:不微调,直接用预训练模型的概率

代码 04 §4:不做任何微调,直接用预训练 SASRec 给每个候选的”下一件商品”概率排序,NDCG@10 = 0.432,几乎达到上限 0.439,比三种微调过的重排序器都好。

  • 为什么:预训练的目标(预测下一件商品)本来就是排序需要的东西。把模型改造成”回答 yes/no、A/B”的新格式,要用有限的数据重新学会格式,反而丢掉了一部分预训练的知识。把学习率降低到 3e-4 也没有改善(README)。
  • 和真实 LLM 的对应:用似然打分的零样本方法常常是很强的基线(例如 UPR 用”由文档生成查询”的似然给文档排序)。微调的价值在于数据充足、或者排序标准和预训练目标差别很大的时候(例如复杂的业务规则、多目标的权衡)。

6. LLM 在推荐系统里的其他角色与挑战

6.1 其他角色

  • 直接让 LLM 推荐:把用户历史写成文本,让 LLM 推荐。零样本的效果通常不如专门训练的推荐模型(它不知道商品库里有什么、哪些商品流行),但在没有行为数据的冷启动场景、或者需要解释推荐理由时有用。
  • 生成特征:
    • 用 LLM 离线生成商品的摘要、标签,以及用户的兴趣画像,作为传统推荐模型的输入特征。
    • 这把 LLM 的高成本放到了离线,线上仍然用便宜的模型。
  • 对话式推荐:用户用自然语言描述需求(“给喜欢园艺的朋友买个礼物,100 块以内”),LLM 负责理解需求和多轮澄清,推荐模型负责检索。
  • 用户模拟器:用 LLM 扮演用户来评估推荐系统或生成训练数据。第 8 讲讨论过用模拟用户评估的陷阱:模拟用户是否像真实用户?

6.2 挑战

  • 延迟和成本:推荐系统每天要处理数十亿次请求,每次只有几十毫秒。LLM 只能用在少量候选上,或者离线使用,或者蒸馏成小模型(第 19 讲的高效推理)。
  • 编造商品:生成式的推荐可能生成不存在的商品。解决办法是约束解码(只允许商品库里的 ID 或标题)。
  • 流行度偏差和位置偏差:LLM 偏向它在预训练语料里见得多的东西(热门商品、知名品牌);重排序有位置偏差(§5.4)。
  • 反馈循环:推荐系统决定了用户能看到什么,用户的行为又成了下一轮的训练数据。偏差会自我强化(第 15 讲的偏见放大)。
  • 隐私:行为序列高度敏感;第 16 讲的记忆与成员推断同样适用于推荐模型。

7. 小结、常见误区、练习

7.1 小结

  1. 推荐与语言建模是同一个问题:
    • 行为序列就像句子,商品就像 token;SASRec 是商品上的因果 Transformer。
    • 在花园集市上它离贝叶斯最优(HR@10 0.733)只差一点(0.710),还在隐藏状态里学会了推断隐藏的用户类型(探针准确率 94%)。
    • 全 softmax 交叉熵在各个数据量上都好于 1 个负例的 BCE。
  2. 商品的写法:
    • 原子 ID 没有共享,新商品是陌生的 token;语义 ID 让商品共享 token。
    • 生成式的模型会学到”没见过的组合不存在”,要靠前缀匹配才能推荐新商品(新商品 HR@10 从 0.000 到 0.479)。
    • 语义 ID 的价值取决于内容向量的质量。
  3. 解码器变编码器:
    • 平均池化优于最后一个 token;直接打开双向注意力会变差,MNTP 适应后变好。
    • “相似”由训练目标定义:无监督 SimCSE 在推荐的亲和度上变差,用共同购买对的有监督对比学习提升最大(0.552 → 0.692)。
    • 按内容推荐能自然地处理新商品。
  4. 重排序:
    • 逐点、成对、列表各有代价:调用次数、校准、位置偏差。
    • 位置偏差是学来的捷径,正反各问一次或用随机顺序训练能消除它。
    • 预训练目标和任务一致时,零样本的似然打分可能比微调成新格式更好。

7.2 常见误区

  • “推荐指标 HR@10 只有 0.7,模型还很差”:下一件商品本来就有随机性,上限可能就在附近(§2.2)。
  • “新的结构更好”:先检查损失函数、负采样、评测方式是否相同(§2.3、§1.4)。
  • “用 100 个随机负例评测和全量排序结论一样”:抽样评测会改变方法之间的名次(练习 3)。
  • “语义 ID 自动解决冷启动”:生成式的模型会学到”没见过的组合不存在”,需要前缀匹配等额外的机制(§3.4)。
  • “解码器的最后一个 token 就是句子向量”:平均池化通常更好,双向注意力需要适应训练(§4.2)。
  • “对比学习总能让文本向量更好”:取决于正例的定义是否符合下游任务(§4.2)。
  • “微调过的重排序器一定好于零样本”:格式转换可能丢掉预训练的知识(§5.5)。

7.3 练习

练习 1(NDCG):四个测试用户的答案分别排在第 1、3、12、2 名。算 HR@10、NDCG@10、MRR。

参考答案
  • HR@10 = 3/4 = 0.75(第 12 名不算)。
  • NDCG@10 = [1 + 1/log₂4 + 0 + 1/log₂3] / 4 = [1 + 0.5 + 0 + 0.631] / 4 = 0.533。
  • MRR = [1 + 1/3 + 1/12 + 1/2] / 4 = 1.917 / 4 = 0.479。

NDCG 对名次的惩罚比 MRR 温和:第 2 名在 NDCG 里得 0.63,在 MRR 里只得 0.5。

练习 2(类型后验):只有两种用户类型,先验各 0.5。某件商品 x 在”重新挑”时被类型 A 买的概率是 0.02,被类型 B 买的概率是 0.005。

(a) 一个用户的第一件商品是 x,他是 A 类型的后验概率是多少? (b) 接着他买了 y(不是 x 的配套),两种类型买 y 的概率分别是 0.01 和 0.03(ρ = 0.4),后验是多少?

参考答案

(a) P(A | x) = 0.5 × 0.02 / (0.5 × 0.02 + 0.5 × 0.005) = 0.01 / 0.0125 = 0.8。

(b) y 不是 x 的配套,所以”买配套”那一项是 0,每种类型的似然是 (1 − ρ) g_k(y):

  • A:0.6 × 0.01 = 0.006;
  • B:0.6 × 0.03 = 0.018。

P(A | x, y) = 0.8 × 0.006 / (0.8 × 0.006 + 0.2 × 0.018) = 0.0048 / (0.0048 + 0.0036) = 0.571。

每一件商品都按”不同类型买它的概率之比”更新后验,这就是 type_posterior 做的事。§2.2 的表里,“鸭子玩具”让 pets 的后验从 0.41 跳到 0.86,因为农场主几乎不买玩具。

练习 3(抽样评测的陷阱):全部 144 件商品里,某个测试用户的答案排第 12 名(前面有 11 件)。

(a) 如果只把答案和 100 个从其余 143 件里随机抽的负例排序,答案前面期望有几件?这次”命中前 10”吗? (b) 另一个答案排第 30 名呢? (c) 为什么这会改变不同方法之间的比较?

参考答案

(a) 排在前面的 11 件里,期望被抽中 11 × 100/143 ≈ 7.7 件,答案期望排第 8.7 名,大多数情况下算命中。全量排序时它不在前 10。

(b) 29 × 100/143 ≈ 20.3 件,期望排第 21 名左右,不命中。

(c) 抽样评测把”全量排序里的名次”非线性地压缩了:

  • 排在 11~15 名的答案大多变成了”命中”;
  • 排在前 10 的答案名次也被往前挪,NDCG 会变高。

两个方法如果一个擅长”把答案排进前 5”、另一个擅长”把答案排进前 20”,抽样评测可能颠倒它们的名次。Krichene & Rendle (2020) 证明抽样指标和全量指标在名次上可能不一致,建议使用全量排序或做校正。

练习 4(负采样的信号):SASRec 的 BCE 损失每个位置用 1 个随机负例。

(a) 对一个”宠物主人”用户,144 件商品里大约 24 件是动物用品。随机抽的负例有多大概率是动物用品(真正难区分的负例)? (b) 全 softmax 的梯度和 1 个负例的 BCE 有什么不同? (c) 为什么数据越少,差距越大?

参考答案

(a) 约 24/144 ≈ 17%。83% 的时候负例是”宠物主人根本不会买”的东西,模型很快就能把它们的分数压下去,之后这些负例几乎不提供梯度。

(b) 全 softmax 的梯度把每个商品的分数按它当前的预测概率往下推。模型越”误以为”会买的商品被推得越多,所以难的负例(其他动物用品)自动得到最多的关注——这相当于自动的”困难负例挖掘”。1 个随机负例的 BCE 只推一个随机商品。

(c) 数据少时,总的梯度步数少。1 个随机负例的 BCE 浪费了大部分步数在容易的负例上,在 500 个用户时 NDCG 差了 0.09(0.290 vs 0.382);数据多时,即使信号稀疏,最终也能学到(6000 个用户时只差 0.03)。

练习 5(嵌入表的大小):一个平台有 10 亿件商品,嵌入维度 64。

(a) 原子 ID 的嵌入表有多少参数?用 fp32 存要多大? (b) 用 3 层、每层 256 个码的语义 ID,码的嵌入表有多少参数?3 层码能区分多少种组合? (c) 不够的话怎么办?

参考答案

(a) 10⁹ × 64 = 640 亿参数,fp32 下 256 GB。这比很多 LLM 都大,而且需要专门的分布式嵌入存储。

(b) 3 × 256 × 64 ≈ 4.9 万参数。3 层能区分 256³ ≈ 1678 万种组合,比 10 亿少得多。

(c) 有两个办法:

  • 加层数或码本大小:4 层就是 43 亿种组合。
  • 加消歧位:像 TIGER 和本讲的 c3,区分前缀相同的商品。

前缀相同的商品越多,消歧位越大,生成最后一个 token 也越难(它没有语义可言)。实际系统常用”语义 ID + 少量原子 ID 嵌入”的混合方案。

练习 6(约束解码):用语义 ID 做生成式推荐时,模型可能生成一个不存在的 (c1, c2, c3) 组合。

(a) 怎样避免? (b) 本讲 §3.4 的”商品名的词”方案里,新商品的概率几乎是 0。约束解码能解决这个问题吗?

参考答案

(a) 把商品库里所有有效的 ID 组织成前缀树(trie)。解码每一步时,只允许选择”当前前缀在树上的子节点”(把其他 token 的概率设成 0 再归一化),束搜索就只会产生有效的商品。LLM 生成商品标题时也一样,约束在商品库的标题集合上。

(b) 不能直接解决。约束解码保证生成的是存在的商品,但新商品在模型的分布里本来就概率很低(“plum 后面不会跟 jam”),约束之后仍然排在后面。要把新商品放进 trie,并且像 TIGER 那样按前缀给它们分配候选位置(§3.5 的前缀匹配),或者用内容向量单独召回(§4.3)。

练习 7(重排序的调用次数):有 100 个候选,要前 10 名。

(a) 逐点要多少次调用? (b) 成对全部比较(正反各一次)要多少次? (c) 成对滑动窗口(每趟从后往前比较相邻两个,正反各一次)要多少次? (d) 列表滑动窗口(窗口 20、步长 10,从后往前一趟)要多少次?

参考答案

(a) 100 次。

(b) 100 × 99 = 9900 次。

(c) 第 p 趟(p = 0..9)比较 99 − p 对,每对 2 次:2 × (99 + 98 + … + 90) = 2 × 945 = 1890 次。(代码 04 里 20 个候选、10 趟是 2 × 145 = 290 次。)

(d) 窗口从 [80, 100) 开始,每次往前移 10,到 [0, 20) 为止,共 (100 − 20)/10 + 1 = 9 次调用。每次调用的输入有 20 个候选,长得多。一趟只能保证最好的 10 个被”带”到前面(RankGPT 的做法),要更多的前 k 名需要多趟。

这些数字说明了为什么 LLM 重排序通常只用在最后几十个候选上。

练习 8(抵消位置偏差):假设成对比较时,模型给出 P(选 A) = σ(s_A − s_B + β),β > 0 是对第一个位置的偏好。

(a) 只问 (A, B) 一次,按 P > 0.5 判断,什么时候会出错? (b) 正反各问一次,取 [P(A 先) + (1 − P(B 先))] / 2,偏差还在吗?

参考答案

(a) 当 0 < s_B − s_A < β 时:B 真的更好,但偏好让 σ(s_A − s_B + β) > 0.5,模型选了 A。两者越接近,越容易被位置偏差左右。

(b) 第一次 P₁ = σ(s_A − s_B + β);第二次问 (B, A),P₂ = σ(s_B − s_A + β),A 胜的概率是 1 − P₂ = σ(s_A − s_B − β)。

取平均 [σ(d + β) + σ(d − β)] / 2(d = s_A − s_B)。它关于 d 是奇对称的(d = 0 时恰好是 0.5),所以按 > 0.5 判断时,A 胜当且仅当 d > 0——位置偏差被完全抵消了,只是平均后的概率比 σ(d) 更”平”(不那么自信)。§5.4 的模型平均选第一个的概率是 0.535,正反各一次的 NDCG 比只问一个方向高(0.279 vs 0.267)。

练习 9(思考:一个没有行为数据的新平台):一个新的手工艺品集市刚上线:有 5 万件商品的标题、描述和图片,但几乎没有用户行为数据。只讨论原理,设计它的推荐系统,并说明随着数据积累应该怎样演变。

参考答案(开放)

第一阶段(几乎没有行为数据):

  • 内容向量:用现成的文本 / 多模态编码器(或 LLM2Vec 式改造的 LLM)给商品编码,做”看过这件的人可能喜欢相似的”推荐(§4)。
  • 冷启动的用户:用对话或少量问题了解兴趣,让 LLM 把回答转成查询。
  • LLM 零样本重排:用 LLM 对少量候选按用户描述的兴趣排序(§5),注意位置偏差和成本。
  • 离线生成特征:用 LLM 给商品生成标签和摘要,作为特征。

第二阶段(有了一些行为数据):

  • 用共同购买 / 共同点击的商品对做有监督的对比学习,微调内容编码器(§4.2 的最后一行),让”相似”变成”会被一起买”。
  • 开始训练序列模型(SASRec 式,全 softmax 交叉熵)。数据少时先和马尔可夫这类简单基线比较(§2.3 里 500 个用户时马尔可夫更好)。

第三阶段(数据充足):

  • 序列模型负责老商品,内容向量 / 语义 ID + 前缀匹配负责新商品。
  • 两阶段架构:便宜的召回 + 较贵的排序。
  • 持续做全量排序的离线评测和线上 A/B 测试,监控流行度偏差和反馈循环。

贯穿始终:评测要用留一法 + 全量排序;对新商品和老商品分别报告指标(§3.4);注意隐私(第 16 讲)。

7.4 延伸阅读

  • 原课阅读:
    • Kang & McAuley (2018), Self-Attentive Sequential Recommendation
    • BehnamGhader et al. (2024), LLM2Vec
    • Metzler et al. (2021), Rethinking Search
    • Qin et al. (2023), Large Language Models are Effective Text Rankers with Pairwise Ranking Prompting
  • 序列推荐:
    • Hidasi et al. (2016), GRU4Rec
    • Sun et al. (2019), BERT4Rec
    • Klenitskiy & Vasilev (2023), Turning Dross Into Gold Loss: is BERT4Rec really better than SASRec?
    • Zhai et al. (2024), Actions Speak Louder than Words: Trillion-Parameter Sequential Transducers for Generative Recommendations(HSTU)
    • Krichene & Rendle (2020), On Sampled Metrics for Item Recommendation
  • 语义 ID 与生成式检索:
    • Tay et al. (2022), Transformer Memory as a Differentiable Search Index(DSI)
    • Rajput et al. (2023), Recommender Systems with Generative Retrieval(TIGER)
    • Singh et al. (2024), Better Generalization with Semantic IDs
  • 文本编码器:
    • Gao et al. (2021), SimCSE
    • Wang et al. (2024), Improving Text Embeddings with Large Language Models(E5-Mistral)
    • Springer et al. (2024), Repetition Improves Language Model Embeddings(回声嵌入)
    • Hou et al. (2022), Towards Universal Sequence Representation Learning for Recommender Systems(UniSRec)
  • LLM 排序:
    • Sachan et al. (2022), Improving Passage Retrieval with Zero-Shot Question Generation(UPR)
    • Sun et al. (2023), Is ChatGPT Good at Search? Investigating Large Language Models as Re-Ranking Agents(RankGPT)
    • Tang et al. (2024), Found in the Middle: Permutation Self-Consistency Improves Listwise Ranking in Large Language Models
  • 本仓库的相关内容:
    • 第 2 讲(因果 Transformer)
    • 第 5 讲(排序指标)
    • 第 10 讲、第 11 讲(检索与稠密向量)
    • 第 14 讲(探针)
    • 第 21 讲(长上下文与”中间丢失”)

7.5 下一讲预告

这一讲的重排序器是用”用户真的买了什么”来训练的——有明确的答案。可是很多时候我们想让模型”更有用、更无害”,这些目标没法写成一个标准答案,只能由人来比较两个回答哪个更好。第 18 讲:基于人类反馈的强化学习(RLHF)。我们会在花园世界里定义一个真实奖励已知的”偏好”,然后:

  • 训练奖励模型(Bradley–Terry,和本讲的成对比较是同一个模型);
  • 用 REINFORCE + KL 惩罚优化策略,观察奖励被”钻空子”;
  • 推导 DPO 怎样把强化学习变成一个分类问题。
← 返回课程目录