目录 · 10 节
1它到底在解决什么问题?2整体流程3核心设计:不对称掩码4离线结果:同样的线上成本,召回更高5beam 为什么要加多样性惩罚6让宽 beam 跑得起:服务侧的三件事7线上结果8我认为它的局限9一个推荐或搜索团队可以怎么试10一句话结论生成式召回把每个物品编成一串多层的 Semantic ID(SID),再像语言模型写字一样逐层生成。它的好处是线上不再需要向量检索索引,新物品只要有了编号就能被召回;代价在输入侧,每个历史物品要占 5 个 token,同样长的上下文只装得下五分之一的历史。Pinterest 的 PinGER 在训练时把每个物品的 SID 和一个稠密向量 token 交错排列,再用一个不对称的注意力掩码保证后面的位置只读稠密 token,于是服务时可以把历史里的 SID 全部丢掉,每个物品只占 1 个 token,模型算出的结果一点不变。离线上,它只用 170 个 token 就达到了 TIGER 用 1,024 个 token 的召回率;在 Related Pins 上替换原有的向量召回 PinRec 后,保存数提升 0.62%,近 28 天新发布 Pin 的保存数提升 3.60%。
做生成式召回的团队值得读这篇,因为它把 SID 路线最大的输入成本问题解决得很干净:不是近似压缩,而是证明丢掉这些 token 前后模型计算的是同一个函数。另外三处工程设计也可以单独借用:按共享前缀深度惩罚的 beam 多样性、让所有 beam 共用一份历史的 cascade 注意力、放在 GPU 上的合法编号前缀树。它还是第一次在线上把向量召回和 SID 召回正面对比。需要留意的是,线上替换牵涉的改动很多,掩码本身的贡献无法单独看出;多样性惩罚在离线大幅压低召回率,线上却更好,所以调这个参数不能只看离线 Recall。
它到底在解决什么问题?
生成式召回有两种表示物品的方式。
| 方式 | 代表 | 输入 | 输出 | 主要代价 |
|---|---|---|---|---|
| 向量生成 | PinRec | 每个物品 1 个稠密向量 | 预测一个向量,再去向量索引里找最近邻 | 线上仍要维护向量检索服务 |
| SID 生成 | TIGER | 每个物品 L 个 SID token | 逐层生成 SID,直接对应到物品 | 输入长度是物品数的 L 倍 |
用一个小例子看输入成本(示意)。用户最近存了 3 个 Pin:一张北欧风客厅、一盏落地灯、一块羊毛地毯。每个 Pin 的 SID 有 5 层,比如客厅是 (12, 7, 301, 88, 5)。
| 做法 | 3 个历史物品要占的输入 token |
|---|---|
| TIGER | 15 个,每个物品 5 个 SID |
| PinGER 训练时 | 18 个,每个物品 5 个 SID 加 1 个稠密 token |
| PinGER 服务时 | 3 个,每个物品只留稠密 token |
历史长度确实影响效果。论文把训练上下文从 1,024 个 token 加到 6,144 个,TIGER 和 PinGER 的 Recall@100 都提高约 10%,而且还没有饱和。所以每个物品少占几个 token,意味着同样的线上成本能看到更长的历史。
整体流程
| 环节 | 做什么 |
|---|---|
| 编号 | 用 Pin 的图文内容向量 PinCLIP,经 RQ-VAE 量化成 5 层、每层 2048 个码的 SID;训练时还加一项蒸馏:在同一批 Pin 里,用基于互动的图向量 OmniSage 算出每个 Pin 与其他 Pin 的相似度分布,要求量化后的表示算出的分布与它一致。输入只用内容向量,所以 OmniSage 更新时 SID 不变 |
| 序列模型 | 单个 decoder-only Transformer,训练时 SID 和稠密 token 交错,用不对称掩码控制谁能看谁 |
| 目标条件 | 把“想要的行为、所在页面、请求时间”拼成一个目标向量,接在每一层 SID 的预测头前 |
| 解码 | beam search,加前缀多样性惩罚,并用前缀树保证每一步都只走真实存在的编号 |
| 服务 | cascade 注意力让所有 beam 共用一份历史;生成的 SID 在本机内存索引里查到物品 |
| 后训练 | 用 DPO:线上日志里存着排序模型在请求当时给每个曝光物品打的分,在同一次推荐的一屏结果里按这个分排序,前三名作为“更好”、后三名作为“更差”配成偏好对,不另训奖励模型 |
核心设计:不对称掩码
训练序列里,每个历史物品先放它的 SID token,再放一个稠密 token。注意力只按三条规则放行:
- 稠密 token 只看更早的稠密 token,从不看任何 SID。
- 某个物品第 ℓ 层的 SID token,可以看之前所有物品的稠密 token,以及本物品更浅层的 SID。
- 除此之外都不能看。SID token 既看不到别的物品的 SID,也看不到本物品的稠密 token,因为服务时下一个物品的向量正是未知的。
拿上面三个 Pin 走一遍:要预测第 3 个物品的第 2 层编号,模型能看到的是第 1、2 个物品的稠密 token,加上第 3 个物品的第 1 层编号。第 1、2 个物品的 SID token 在这里用不上。规则 1 和规则 3 合起来保证,一个物品的 SID 只会被它自己更深层的 SID 读到,任何其他物品的位置都不会读它们。服务时把历史物品的 SID 整组删掉,读它们的那几个位置也一起删了,剩下的稠密 token 的隐状态都不会变。位置编码也不受影响,因为同一个物品的所有 token 共用一个位置编号,编号数的是物品而不是 token。所以下一个编号的概率分布和训练时完全一样。
打个比方:每个物品进主干之前先做好一张资料卡(稠密 token,SID fusion 已经把它的编号揉进去了)。主干里,资料卡只翻更早的资料卡;SID 位置像贴在旁边的便签,只读资料卡和本物品更浅层的便签,没有任何位置回头读便签。所以服务时把历史物品的便签全部撕掉,资料卡上算出来的东西一点不变。

这个设计还保住了 SID 路线的监督信号。每个 SID token 都是预测目标,一次前向能得到“历史物品数 × L”个标签;如果改用“稠密编码器加 SID 解码器”的结构,一次前向只能监督一个目标物品。
训练侧的代价是序列变长。现成的 FlexAttention 只能跳过完全被遮住的块,而这个掩码下对角线以下的每一块都有稠密列,所以一块也跳不掉。作者自己写了前向和反向 kernel:kernel 把序列切成若干查询块,每块装着完整的几个物品。块之前的所有物品的稠密 token,块里每个位置都能看,而且看的是同一批,所以 kernel 先把这批稠密 token 收集起来,对它们做一次不带掩码的注意力。剩下的是块内部各 token 之间的注意力:块内靠后的物品还要看块内靠前物品的稠密 token,每个 SID 位置还要看本物品已经给出的更浅层编号,也就是上面的规则 2(例如预测第 3 层编号时,看本物品的第 1、2 层)。只有这一部分需要掩码,单独计算。两部分各自算出的注意力分数,最后用 online softmax 合并,结果和对全部键做一次 softmax 相同。按论文附录的估算,注意力计算量比 TIGER 低约 L²/(L+1) 倍,L=5 时约 4 倍;训练输入里最深一层(第 5 层)的 SID token 也被去掉:紧跟在它后面的是稠密 token,而稠密 token 不是预测目标,后面也没有任何位置读它。预测按“下一个 token”的方式排:第 1 层编号由前一个物品的稠密 token 所在位置预测,第 ℓ 层由本物品第 ℓ−1 层所在位置预测,所以第 5 层编号仍然是目标,由第 4 层的位置去预测;SID fusion 是进入主干之前单独的一层注意力,照样读本物品全部 5 层编号的向量。这样每个物品只占 5 个 token,所有监督都还在。两项改动加起来,训练吞吐比同上下文长度的 TIGER 高 94%。 Hoot 说
- 块里有可见格:照算
- 整块被遮住:才能跳过
对角线以下每块都穿过一条稠密列(深色竖条),没有一块能跳过,算量和完整的因果注意力一样。
- 别的查询块的行
- ① 块之前:每行看同一批稠密 token,收集起来无掩码算
- ② 块内部:唯一要掩码的部分
①里历史物品的 SID 列整列是空的:一个物品的 SID 只被它自己更深层的 SID 读到。
- 稠密 token(键)可见
- SID token(键)可见
- 被遮住
- ① + ② 怎么合并(示意数字):设①里各键的 exp 分数加起来 Z₁=3、加权得到的输出 o₁=0.2,②里 Z₂=1、o₂=0.8。合并要按各自的分母加权:(3×0.2+1×0.8)/(3+1)=0.35,正好等于对全部键做一次 softmax;直接取两部分平均是 0.5,错。online softmax 只多记每部分的分母(和最大分数),不用存整张注意力矩阵。
行是查询,列是键。示意:4 个物品,每个物品 2 层编号加 1 个稠密 token(论文 L=5),块大小 4 也是示意;论文随后还从训练输入里去掉最深一层 SID token,图里没画。 出处: 论文 §3.3.2 三条掩码规则;§3.3.4 稀疏感知 kernel(FlexAttention 只跳过整块被遮住的块;查询块装整物品,块前稠密 token 无掩码 + 块内带掩码,online softmax 合并);图 2(c)
离线结果:同样的线上成本,召回更高
| 设定 | 模型 | 训练上下文 | 服务时读的 token | Recall@100 |
|---|---|---|---|---|
| 同样服务成本 | PinRec | 1,024 | 1,024 | 1.01% |
| TIGER | 1,024 | 1,024 | 3.65% | |
| PinGER | 6,144 | 1,024 | 4.00% | |
| 同样召回率 | PinGER | 1,024 | 170 | 3.64% |
在服务成本相同的情况下,PinGER 可以用长得多的历史训练,召回率比 TIGER 高 9.5%,是向量召回 PinRec 的 3.9 倍。反过来,要追平 TIGER 的召回率,它只需要读 170 个 token。前者对应“同样的钱买更好的效果”,后者对应“同样的效果省下六分之五的输入”。
消融里最关键的是 SID fusion,也就是进入主干之前,把物品自己的各层 SID 向量和它的稠密 token 排成一小段,做一层因果注意力,取稠密 token 那个位置的输出当作新的稠密 token。去掉后 Recall@100 下降 27%,因为稠密 token 是后面位置了解这个物品的唯一通道,少了离散编号,模型就失去了一个稳定的物品身份。去掉目标条件下降 17%;稠密 token 的物品特征由两个预训练向量拼成:内容向量 PinCLIP 和互动图向量 OmniSage。从这个物品特征里去掉 PinCLIP(SID 照旧由 PinCLIP 生成)几乎没有影响,因为 SID 本来就是从 PinCLIP 量化来的,SID fusion 已经把这部分信息带进了稠密 token;去掉 OmniSage 只降 1.2%。
beam 为什么要加多样性惩罚
beam search 的分数是逐层累加的,一个很强的前缀下的前 W 个延续,往往比其他任何前缀的最好延续都高。结果 100 条 beam 里,第一层只有 11.9 个不同的编码,候选挤在少数几个内容方向里。常见的补救是温度采样,但论文发现它两头都输,因为采样只改变了挑选、没有改变打分,靠运气挤进来的 beam 到下一层又会被淘汰,最后一层还会生成大量不存在的编号:T=1.5 时第一层不同编码反而降到 3.5 个,前 10 个编号里只有 13% 对得上真实物品。
PinGER 改为一条一条地挑 beam:每挑一条,就给和它共享前缀的其他候选加罚分,共享得越深罚得越重。示意:三个父前缀 A=(12, 7)、B=(12, 9)、C=(40, 3),分数相近。先从 A 挑了一条后,B 和 A 共享第一层,被罚 λ×1;C 和 A 没有共享,不罚。挑下一条时,A 自己的第二好候选也在比,但它和刚选的那条前缀完全相同,罚得最重(λ×2)。所以三者扣完罚分再比:C 只要比 B 低不到 λ、又不输给扣了 2λ 的 A,下一条就是 C。
| 解码方式 | Recall@100 | 第一层不同编码数 | 前 10 个编号的有效率 |
|---|---|---|---|
| 不加惩罚(按分数取前 100) | 4.00% | 11.9 | 74.3% |
| λ=1.0 | 2.07% | 68.3 | 73.4% |
| λ=3.0 | 1.42% | 93.6 | 72.3% |
| 温度 T=1.5 | 0.60% | 3.5 | 13.2% |
离线召回率掉得很明显,因为留出的下一次互动通常就在最可能的那几个子树里,把 beam 摊开等于用这一个目标的命中率去换用户其他兴趣的覆盖。线上的选择却相反:Related Pins 上 λ=3.0 那组保存数显著提升,λ=1.0 那组保存数没有显著变化;通知场景则是 λ=1.0 更好,λ=3.0 的点击提升更小,退订还增加了。所以这个参数要按场景在线上调,离线 Recall 只能说明方向。
让宽 beam 跑得起:服务侧的三件事
第一是 cascade 注意力。同一个请求的所有 beam 共享约一千个 token 的历史,只在当前物品已生成的几个编号上不同。普通做法每条 beam 都要把整段历史读一遍,代价随 beam 宽度线性增长。PinGER 把同一请求的所有 beam 当成一批查询,一起对这段共享历史算一次注意力,历史每个请求只读一次;每条 beam 再单独对自己那几个编号算一次注意力。两次结果合起来,和每条 beam 把整段历史加后缀一起算出的注意力完全相同。

beam 宽 10 时它比分页注意力快 4.25 倍,宽 400 时快 32 倍;宽 30 时 KV 显存少 12.6 倍。宽 beam 因此从实验配置变成了可以上线的配置。
第二是约束解码。不加约束时,只有约 66% 的生成编号在第 5 层能对上真实物品,剩下的只能退回更粗的前缀,也就是用前 4 层甚至前 3 层编号去索引里查,取出这个更大范围里的物品,等于浪费了模型在最后几层上的选择。作者把合法前缀树以压缩稀疏行的形式放在 GPU 上,每一步一次批量查表,不需要回到 CPU,所以代价很小:beam 宽 100 时每次解码只多 0.30 毫秒。
第三是本机索引。编号到物品的映射每天重建一次,内存映射只读,同机所有副本共用一份,取代了远程向量检索调用。端到端看,PinRec 的 p50 延迟是 79 毫秒,PinGER 在 beam 宽 60 时是 51 毫秒;省下的时间主要来自去掉了 p99 要 31 毫秒的远程检索。
线上结果
Related Pins 上为期两周的 A/B,与 PinRec 在相同候选预算下对比,所有指标在 5% 水平显著:
| 范围 | 指标 | 变化 |
|---|---|---|
| 该页面 | 保存数 | +0.62% |
| 该页面 | 网格点击 | +0.94% |
| 全站 | 保存数 | +0.61% |
| 新内容 | 近 28 天发布的 Pin 的保存数 | +3.60% |
| 系统 | 端到端延迟 | -0.7% |
新内容涨得最多,原因在编号方式:SID 来自内容向量,一个新 Pin 发布后马上有编号、马上能被召回,而 PinRec 依赖的互动向量要几周才稳定。分群分析里,过去七天保存最少的那批 Pin 在候选中的占比提高 20%,保存率还高 13%,过去被低估的内容得到了曝光,热门内容越推越热的循环被打破了一部分。DPO 后训练让离线召回再提高 2.3%,线上候选进入最终展示列表的比例提高 10%。
我认为它的局限
第一,线上对比的是整套系统,不是掩码本身。从 PinRec 换到 PinGER,同时改变了输出形式、编号来源、解码方式和检索服务。0.62% 的保存提升里有多少来自交错掩码让历史变长,有多少来自内容编号对新 Pin 更友好,论文没有拆开。掩码的直接收益只有离线证据:同样召回率下服务 token 少 6 倍。
第二,多样性惩罚的离线代价很大。λ=3.0 时 Recall@100 从 4.00% 降到 1.42%,降了约 64%,线上却是这个设置更好,而通知场景又偏好 λ=1.0。别的团队无法靠离线实验选参数,只能每个场景都开线上实验。
第三,规模和内存需要自己核算。主干只有 4 层、隐藏维 768,换成更大的模型时,cascade 和约束解码的收益比例未必一样。前缀树在 1 亿物品时占 1,368 MiB 显存,编号到物品的索引还要再大约 10 倍;物品库更大或更新更频繁的业务,需要重新评估每天重建索引的成本。
一个推荐或搜索团队可以怎么试
- 先确认历史长度是瓶颈:把现有 SID 召回模型的上下文按物品数翻倍,看召回率是否还在涨。不涨的话,压缩输入的价值有限。
- 在现有 SID 模型上改序列布局:每个物品后面加一个稠密 token,按三条规则写掩码,先用标准注意力实现验证“丢掉历史 SID 前后输出一致”,再考虑写专用 kernel。
- 确认物品稠密 token 里折进了它自己的 SID 向量,这一步在论文消融里影响最大。
- 解码先加前缀树约束,统计生成编号的有效率;再加前缀多样性惩罚,λ 取两三档直接上线小流量对比,不要只看离线召回。
- 如果 beam 宽度受显存或延迟限制,再引入共享历史的 cascade 注意力,并把编号到物品的查找移到本机内存。
一句话结论
这篇最值得借鉴的是“训练时多看、服务时少读、两者计算同一个函数”的掩码思路,它让 SID 召回在不牺牲监督信号的前提下拿到了向量召回的输入成本。
| 维度 | 评价 |
|---|---|
| 问题重要性 | 高。SID 召回的输入成本直接限制了可用的历史长度,是这条路线落地的主要障碍之一 |
| 方法创新性 | 中高。交错布局并非首创,但不对称掩码保证服务时精确等价、按前缀深度的 beam 惩罚和 cascade 解码组合在一起是新的 |
| 实验证据 | 中高。有离线对比、消融、两个场景的线上 A/B 和分群分析;不足是线上收益无法归因到单个组件 |
| 工程成本 | 高。需要自定义注意力 kernel、GPU 前缀树、本机索引和宽 beam 服务栈 |
| 是否值得尝试 | 值得,尤其是已有 SID 召回、受上下文长度限制的团队;掩码改造可以先单独做,服务侧优化按需引入 |
读完这篇,你觉得
夜读鸮记下了,谢谢你的反馈。
点一下即可,无需登录;再点一次撤回。只统计匿名次数,不记录你是谁。
讨论
欢迎补充实验细节、复现经验或不同看法。需 GitHub 登录。
正在加载讨论…