上周我们团队在复盘交易搜索的召回效果时,一个技术同学突然抛出一个问题:“现在向量召回的比例都快拉到40%了,剩下的20%还能怎么榨?”这个“怎么榨”背后的潜台词,其实是过去三年里,整个搜索行业都在卷同一个方向——把文本、图片、甚至用户行为全塞进同一个向量空间,然后用ANN索引比谁更快,用双塔模型比谁更准。这种内卷已经卷到了一个相对收益很低的平台期。而当时我们内部正在测试的一个方向,恰好能回答这个问题——既然大家都在卷“表征式召回”,那能不能直接跳出来,用“生成”的思路来做召回?
这篇文章就围绕得物交易搜索里一个比较特殊的尝试来展开:我们把召回从“匹配”换成了“生成”,用生成式召回(Generative Retrieval)把原本依赖向量检索和倒排索引的交集空间,往前推了一大步。在讲具体方案之前,先把这个技术选择的适用边界说清楚:这套方法适合那些query意图高度复杂、商品语义偏非标、以及传统检索很难覆盖长尾口语化请求的场景。如果你正在做电商搜索、交易撮合、甚至站外内容检索,这篇文章里踩过的坑和一套可落地的工程框架,多少能给你提供一点参考。
1. 先看清边界:向量检索到底在什么环节“卷不动了”
1.1 向量检索解决的旧问题与留下的新盲区
向量检索解决的是一个非常直观的问题:两段文本各自被encoder映射成一个稠密向量,然后用内积或者余弦距离来度量它们的语义相似度。这也是为什么“黑武士”能匹配到“黑色全黑运动鞋”,因为这些词在语义空间里的位置很接近。这套体系在过去的五六年里确实很强,它把搜索从“字面匹配”推进到了“意图匹配”,也把召回率从倒排索引时代的20%左右一路拉到接近40%甚至更高。
但当我们把真实用户query全部铺开看的时候,会发现向量检索有一个容易被忽视的边界:它仍然是“检索”而不是“推理”。所谓“检索”,意思是它必须从一个已经存在的候选集中,通过距离计算去“找”最接近的东西。如果用户的需求在候选集里没有一个足够近的邻居,向量检索再准也没用。
我举一个我们在得物场景里真实遇到的情况。有用户搜“一千块以内送男朋友的生日礼物”,这种query在传统倒排里几乎无法处理——因为没有任何商品标题会写“生日礼物”。它靠向量检索也很难做,因为“一千块以内”“送男朋友”“生日礼物”这三个限定条件组合在一起,编码器很难通过一个全局向量把这三个子意图同时保存下来。语义空间里的表示会互相稀释。这其实是向量检索一个比较深的痛点:组合约束越复杂,编码损耗越大,召回质量掉得越快。
除此之外,向量检索还有一个工程层面的问题。它的召回效果高度依赖embedding质量,而embedding的质量又高度依赖训练数据。如果某个商品在日志里出现的次数很少、或者query本身是极端口语化的长尾表达,embedding很容易退化成“稀疏散点”,在ANN检索时几乎不可能被远距离命中。所以我在团队内部经常说一句话:“向量检索把头部做得很香,但腰部以下基本靠天吃饭。”
1.2 得物交易搜索里特殊的“非标品”挑战
可能有些人不太熟悉得物的商品结构,这里稍微展开一下。得物的商品池有一个显著特征:大量非标品、强版本差异、潮流属性极强。同一款鞋可能有几十种配色版本,不同版本在标题里的描述差异非常小,但价值和稀缺度完全不同。与此同时,用户搜索时用的词汇高度“黑话化”,比如“AJ1芝加哥”“黑武士”“倒钩”“小闪电”,这些词在商品标题里根本不一定是原文,而是某个社区广泛使用的昵称。
这个特性给传统召回带来一个连锁反应:倒排索引靠字面匹配基本失效,向量检索靠语义匹配能覆盖一部分,但仍然会撞上“语义相近、期望不同”的场景。比如“黑武士”既可以指全黑的Yeezy 350,也可以指某个品牌的全黑跑鞋。如果query的意图不进一步细化,向量空间里它们就是同一个点。这种情况下,继续优化向量模型其实是在做“用一个向量表达多重意图”这件事,效果注定会有瓶颈。
所以当我们讨论“生成式召回”的时候,不是因为它听起来更前沿,而是因为它在底层逻辑上绕开了上面这两个死结:它不再去“找”一个接近的向量,而是把“query到商品ID”的关系当成一个条件生成问题,直接学习从query到target的映射。这个切换很关键,它把“近似检索”变成“条件生成”,等于把召回从“大海捞针”换成了“按图索骥”。
2. 生成式召回的核心原理:把召回从“匹配”改成“翻译”
2.1 一个关键思路转变:放弃距离计算,改为条件生成
生成式召回这个概念,其实在学界已经有几年的积累。它最初的代表性工作是Differentiable Search Index(DSI),思路非常大胆:把整个语料库“记忆”在一个Transformer模型的参数里,用户query输入模型,模型直接输出对应的文档ID。也就是说,召回不再需要外挂索引,索引本身被模型内化了。后续的SEAL、NCI等工作进一步把它扩展到更大规模的语料,也引入了更多可控性设计。
但在得物交易搜索的场景里,我们不是照搬DSI,而是借了它的核心思想:把query和商品之间的关系,建模成“条件概率生成问题”。输入是用户query(一段文本),输出是一个商品ID序列。模型的训练目标,是让这种条件概率最大化:在给定某个商品ID被历史用户点击/购买的前提下,让模型学会“见过这个query→翻译出这个商品ID”的映射关系。
这样做的好处在于:模型学到的不是“语义相似”,而是“意图到商品的直接关联”。前者依赖表示空间里的距离,后者依赖序列解码时积累的条件信息。打个不太严谨但容易理解的比方,传统向量检索像是你查地图找“离当前位置最近的咖啡馆”,它永远只能告诉你附近有什么;生成式召回则更像是你直接告诉一个老司机“我要去一个适合聊天、不太吵、有手冲咖啡的地方”,老司机直接说出那家店的名字。前者是地理距离的度量,后者是需求的理解与翻译。
2.2 目标端编码:商品ID必须做语义离散化
刚开始做生成式召回,最容易犯的错误是:直接把商品原始ID拿来做生成的target。这个坑我们踩过,效果非常差。原因很好理解——原始商品ID是一个随机分配的编号,它本身不携带任何语义信息。你让模型直接去“生成”一串随机的数字,本质上是在逼模型死记硬背几百万个无规律的映射,这和让一个学生背圆周率前十万位没有区别。模型根本没有足够的归纳能力去捕捉ID与商品之间的内在规律。
所以真正落地生成式召回的第一步,是给商品ID做一层语义离散化编码。我们参考了VQ-VAE的思路,做了个简化版:用商品的多层属性构建一个层次化的codebook。以得物场景为例,一个商品的语义ID结构大致如下:
| 层级 | 含义 | 示例 |
|---|---|---|
| L1 | 一级类目 | 球鞋 / 服饰 / 箱包 / 配饰 |
| L2 | 品牌或系列 | Nike / AJ系列 / Yeezy系列 |
| L3 | 风格/属性 | 高帮 / 低帮 / 黑武士 / 全黑 |
| L4 | 叶子款 | 具体商品款号(如DD1391-100) |
把原始ID替换成这种结构化的语义ID之后,模型学到的就不是“ID 123456对应某个query”,而是“在某品牌、某风格、某款号组成的语义路径上,这个query的概率更高”。这直接让生成过程变得可解释:解码每一步,其实是在逐步缩小候选空间。同时,它还带来了一个额外的好处——因为同一家族的SKU共享了前面的层级编码,模型天然具备了一定的泛化能力,即使某个具体款号在训练数据里没有出现,只要它的类目和风格路径与某个高频query匹配,依然有一定概率被生成出来。
2.3 损失函数与训练目标:不是简单做分类
当output空间是语义ID序列,模型训练目标看起来就是一个典型的sequence-to-sequence文本生成任务,用交叉熵做loss就行。但实际落地时,有两个问题会严重影响效果。
第一个问题是商品空间极度不平衡。电商场景里,头部爆款商品占据了绝大多数点击和成交,长尾商品天然稀少。如果直接用原始日志训练,模型很快就会退化成一个“只会背热门答案的复读机”:不论用户搜什么,它生成的top结果永远集中在几十个爆款ID上。为了解决这个问题,我们做了两件事:一是对训练样本做了按商品ID的频率降采样,控制单个ID的最大出现次数;二是在计算loss时,给热门商品加一个惩罚权重,逼着模型把概率质量分配到更细的语义路径上,而不是一味“抄近路”。
第二个问题是decode空间太大,直接用全量词表算softmax不现实。几百万商品,每个商品对应一个语义ID序列,序列长度即使压缩到4层编码,词表也在几十万以上。我们采用的方案是分level解码:第一层先解码类目,第二层基于类目约束再解码品牌/风格,逐层缩小候选集合。每一层的预测都是一个独立的softmax头,层与层之间用mask做约束。这样既控制了计算量,又让语义ID的层级结构真正发挥了解码过程中的先验作用。
3. 得物交易搜索中的落地架构:生成式召回不是替代,而是叠加
3.1 整体链路设计:多路召回并行,生成式单独走一条线
我们最终上线的架构,并不是用生成式召回替代倒排或向量检索,而是在这两者之外新增一条“生成式召回通路”。整个链路是这样组织的:
- 用户输入query,先经过统一的中文分词、拼写纠错、意图识别模块。
- 意图识别模块会对query做一个分流:凡是高频大词、短查询、明确型号词,直接走传统的倒排和向量双路召回,保证速度和精度;凡是长query、口语化表达、意图复杂或者包含“预算”“场景”这类软约束的query,除了传统双路,还会额外触发一条生成式召回。
- 多条召回结果经过一个统一的“融合分配模块”:先做去重,再按比例分配展示坑位,最后送入精排模型排序。
这种做法的好处很直接:传统召回提供“保底”,生成式召回提供“增量”。不会出现生成式模型效果波动导致整条搜索体验崩盘的情况。我在这里尤其要强调一点——不要一上来就把生成式召回作为唯一召回通路,那会让你在排查badcase时非常痛苦。先用小流量验证增量价值,再逐步扩大生成式通路的流量配额,这个节奏感非常重要。
3.2 意图归一化:生成式通路前的关键前置处理
生成式模型本身对输入噪声是有容忍度的,但容忍度不代表完全不在意。如果我们把非常口语化、带着语气词甚至错别字的query直接丢给生成模型,它一样会被干扰。所以在这条通路里,query在进入生成器之前还会多做一个处理,我们内部管它叫“意图归一化”。
意图归一化做的事情主要有三件:
- 清除无意义语气词和修饰成分。例如“我想看看那种就是黑色的耐克鞋”会被规整为“黑色的耐克鞋”。
- 将口语化的品牌昵称映射到标准品牌词。例如“AJ”“乔1”会被规范为“Air Jordan 1”的语义路径。
- 预算词、场景词与商品类目的关联映射。例如“送男朋友”会被映射到“男款、配饰/潮流鞋服”的类目先验。
这个前置处理本身就是一个小模型,我们可以基于规则加一个小型翻译模型来实现,不必做得太重。但它带来的训练数据增益非常明显:生成式模型吃到的输入是规整过的query,学习难度直线下降,输出质量也随之提升。
3.3 Beam Search生成候选:可控参数与解码细节
生成式召回的解码阶段,我们采用了Beam Search策略。相比贪心解码,Beam Search能够在每一步保留多个候选路径,最后输出多条商品语义ID序列,正好满足“召回K个候选”的业务要求。
这里给出一组我们在实际调参过程中验证过的参数经验:
- Beam Size通常取4到8。太小,召回多样性不足;太大,解码耗时明显上升,而且容易在后期坍缩到同一条路径上。我们在得物的实验里,beam size=6时效果与成本最均衡。
- 每层解码都设置一个最小置信度阈值。例如某个层级的最大概率低于0.15,则判定为“低置信query”,该条候选直接丢弃。这是防止生成式幻觉的关键手段。
- 最终生成候选数量控制在50到200之间。比向量召回通常fetch上千个候选要少得多,但胜在精准度高,不需要精排消耗太多算力去“捞”它。
解码完成后,会有一个“语义ID翻译回真实商品ID”的过程。如果多条候选语义ID路径映射到同一个商品,只保留一个。这个模块逻辑不复杂,但漏掉了它,会产生大量重复坑位,影响用户体验。
3.4 与向量检索、倒排索引的融合:从“三分天下”到“按需调配”
生成式召回上线之后,我们面对一个很现实的问题:三条召回通路并行时,每个通路在最终结果里占多少坑位合适?一开始我们采用的是“固定配额”策略——倒排、向量、生成式各自分配固定的坑位数。生成式分配20%的坑位,但上线后发现有些简单query它插进来的结果并没有比倒排更好,反而稀释了头部精准结果。
后面我们改成了“按意图复杂度动态调配”的策略:在意图识别阶段,就给该query打一个“复杂度分”。复杂度分低,则极少分配生成式结果;复杂度分高,则生成式通路占的坑位可以提升到30%到40%。这个策略上线后,整体成交转化率提升了不少,因为复杂意图query的召回质量上去了,而简单query的精准度没有受损。
这条经验非常重要,我要再强调一次:生成式召回的价值不是替换传统检索,而是在传统检索注定失位的场景里补位。它的优势场景明确,我们不要试图让它去做短query精准匹配的活。
4. 实操实录:五个绕不开的坑与对应的解法
4.1 商品ID不稳定:今天训练的模型,明天可能失效
生成式召回最折磨人的一个问题,是商品ID的动态变化。电商商品池和文档库不一样,它不是静态的:每天都有新品上架、老品下架、款式微调。如果你把语义ID完全绑定在一个动态变化的商品维度上,比如L4层用SKU编号,那么只要商品的SKU发生变化,对应的语义路径就跟着变了,模型学到的映射关系立刻失效。
我们的解决思路是在编码时区分“稳定位”和“增量位”。稳定位包含类目、品牌、风格等长期不变的属性;增量位则包含款式编号、发售批次等易变信息。训练时,我们对增量位做随机扰动增强,让模型不要过度依赖具体增量值,而是学会通过稳定位加部分增量信息来定位商品。上线后,即使某些L4增量位发生变化,模型依然能通过稳定位生成正确的商品簇,再由商品簇内的补充规则映射到最新的SKU。
4.2 生成式幻觉:生成了几个“不存在”的商品
大家可能听说过LLM的幻觉问题,生成式召回同样存在这个现象,只是表现形式不一样。我们遇到的情况是:模型在某些长尾query下,会生成一个结构完整但没有对应商品的语义ID。例如它生成了一个“球鞋/Nike/AJ/黑武士”路径,但这个路径对应的商品已经下架,或者在商品库里根本不存在这个款号。这个问题在离线评测里不容易暴露,因为离线评估用的是历史点击数据,模型只需要复现历史行为即可;但线上运行时,商品池每天都在变,幻觉问题会被放大。
我们的对策分两道防线。第一道是解码后的“存在性校验”:任何语义ID必须映射到当前商品库中真实存在的商品,否则直接丢弃;第二道是训练阶段引入“商品库扰动”:每次训练迭代时,随机丢掉一小部分商品,让模型学会在商品库不完整的情况下仍然尽量输出有效路径,减少对“死记硬背”的依赖。两道防线叠加后,线上无效生成的比例降到了可接受范围。
4.3 长尾商品的冷启动:日志里没有它的记录,模型如何认识它
长尾商品在点击日志里的样本非常稀疏,甚至完全为零。生成式召回的模型如果不做特殊处理,对这些商品的生成概率几乎是零。但大家别忘了,我们做这套方案的核心目的,恰恰就是为了解决长尾和复杂意图。所以“冷启动”这件事必须正面解决。
我们在实践中验证有效的方式是“teacher模型蒸馏+数据增强”。先用一个表达能力更强的重模型(teacher)在离线数据集上生成一批“query到语义ID”的伪标注,然后蒸馏到线上轻量模型。同时,我们利用语义ID的层次结构做平滑:某个叶子款即使没有直接训练样本,只要它所属的品牌+风格路径与历史query有足够高的相关概率,就给它这些路径上的先验概率作为初始值。这相当于让模型“举一反三”——见过同品牌的兄弟产品,就敢对同品牌的新品给出一定概率。
4.4 算力与延迟:seq2seq不是免费的午餐
生成式召回的延迟成本,是它在工程落地上最不受欢迎的一点。一个常规的seq2seq模型在CPU上解码,即使序列长度只有5个token,也要10到20毫秒;在峰值流量下,如果每个query都触发一条生成式通路,算力消耗会非常可观。
我们的优化方向有三个层次:
- 模型层面:把Teacher模型蒸馏成小规模的student模型,解码层从6层压缩到3层;语义ID序列长度压到4个token以内,保证单次解码速度。
- 触发层面:并不是所有query都走生成式通路,而是先经过一个轻量分类器判断“是否有必要”,意图简单的query直接跳过生成式,避免浪费算力。
- 工程层面:对解码结果做缓存,同一query在短时间内直接读缓存。电商搜索里有很多高频重复query,比如“AJ1 芝加哥”,一天之内被搜索几万次,缓存命中率非常高。
这一套组合拳打下来,生成式通路的平均延迟控制在整体搜索延迟的20%以内,算力成本也在可控范围内。
4.5 评估指标:离线Recall涨了,线上成交却跌了
最后讲一个我们比较惨痛的经验。第一版生成式召回上线前,离线评测里Recall@50提升了近5个百分点,大家觉得胜券在握。结果小流量上线后,整体成交转化率不但没涨,反而掉了0.4%。问题出在哪?
离线评估用的是历史点击数据,它反映的是“模型能不能召回用户此前点过的商品”,但生成式召回召回出来的商品往往是用户没见过但确实相关的商品。用户看见之后,有一部分会买账,也有一部分会因为“不是我想要的”而离开。换句话说,离线Recall衡量的是“相关性”,线上转化衡量的是“成交效率”,这两者之间隔着一个“用户预期管理”的gap。
后来我们在融合层加了一个“新意控制”机制:生成式召回的候选里,与用户近期浏览/购买历史差异过大的商品会被降权;同时生成式召回的候选在进入精排前,会额外经过一个轻量转化率预估模型,过滤掉那些极低概率成交的候选。调整之后,整体效果才真正转正。
5. 效果复盘与范式反思:这套方案适合谁,不适合谁
5.1 上线后的指标变化
生成式召回在得物交易搜索中跑了一段时间后,我们观察到的核心指标变化大致是这样的:
| 指标 | 变化情况 |
|---|---|
| 无结果率 | 下降约12% |
| 长尾query成交转化 | 提升约18% |
| 复杂意图query的点击率 | 提升约9% |
| 精排后置成交转化 | 提升约4% |
其中最让我们意外的,其实是无结果率这个指标。我们原本以为复杂的意图query更多是“召回不准”,没想到真实用户里存在大量“根本召不到”的情况。生成式召回把一批原本零结果的query变成有结果可出,这带来的体验提升是整个搜索链路里最直接的。
5.2 什么场景适合生成式召回,什么场景别硬上
经过这段时间的实践,我逐渐形成了一套判断标准,分享出来供大家参考。
适合上的场景:
- query以长句、口语化、场景化表达为主,例如“适合秋冬穿的黑色卫衣”“送人比较有面子的礼物”。
- 商品池存在大量非标品或强属性差异,品牌昵称多、黑话多。
- 传统检索难以覆盖“组合约束”“预算”“场景”等软条件。
不适合硬上的场景:
- 精确型号查询为主,比如“iPhone 15 Pro Max 256G”,传统倒排已经能给出完美结果。
- 商品池规模极大且每天剧烈变化,且没有稳定的属性体系来支撑语义ID。
- 算力预算紧张,且无法接受额外多出的召回延迟开销。
5.3 我个人几点经验体会
最后说几句偏个人感受的东西。这次做生成式召回,最让我触动的一点,是它让我重新理解了“召回”这个环节的定位。过去我们把召回当成一个漏斗,核心是“别漏掉好东西”;生成式召回的思路更像是给漏斗装了一个“翻译器”,它把用户说不清的需求翻译成具体的商品路径。这个视角的转变,带来的不仅是指标的提升,也让整个搜索架构在思考用户意图时多了一层表达空间。
另外踩过几次坑之后,我现在的态度会更加务实:召回层所有炫技方案,最终都要回答“相比多路召回,你多抓回了哪部分用户、哪部分商品”。如果答不上来,哪怕离线指标再好看,也不值得上线。生成式召回真正的价值,是在那些传统检索注定失位的场景里做增量,而不是把原有的匹配逻辑推翻重来。
如果你正在纠结要不要上生成式召回,我建议你先做两件事:一是拉出全部无结果query,统计里面有多大比例是长query、口语化表达;二是梳理商品池里是否有一批语义上相关但文本上完全不匹配的商品。如果这两个问题的数据都足够扎眼,那生成式召回大概率值得一试。相反,如果这两个场景都很少,建议还是把精力放在优化现有向量召回和精排模型上更实在。