别再只卷向量检索了,得物交易搜索如何用“生成式”实现召回范式跃迁?
说实话,这几年聊电商搜索召回,大家开口闭口就是双塔、向量、ANN、MIPS,仿佛把query和商品各自编码成一个embedding,然后做最大内积搜索就是出厂配置一样。我理解这种路径依赖,毕竟双塔向量检索确实解决了很多语义匹配问题,工程生态也成熟,从FAISS到各种HNSW优化,线上效果来得又快又稳。但如果你在得物这种交易搜索场景里待过一段时间,你会明显感觉到一个天花板:向量检索本质上是在做“相似度打分”,它并不能真正理解和推理用户查询背后那一串组合约束。
我举个例子,用户搜“AJ1黑红脚趾 42.5码 漆皮版本”,这里面有品牌、配色、尺码、材质版本四个硬属性。双塔模型就算你把query整体编码得再好,最终向量空间里找一个最接近的商品embedding,也很难确保四个属性同时精确命中。倒排索引倒是能精确过滤,但它对“复杂语义意图”几乎无能为力,比如用户想要“和这个鞋款风格接近但更冷门的选择”,倒排就抓瞎了。
所以最近得物交易搜索的方向开始转向“生成式召回”——把召回从“检索匹配”变成“条件生成”。一句话描述就是:给定query和用户上下文,模型直接生成一串商品ID序列,不再走“召回候选集→打分排序”的传统路径。这个改动看起来只是实现形式换了,实际上意味着整个召回范式的跃迁。这篇就聊聊我们踩坑踩出来的思考、架构设计、训练细节和那些线上才会遇到的破事。
1. 先想明白一件事:向量检索解决的是“相似”,不是“成立”
1.1 双塔模型的本质:把复杂匹配压成一个点积
双塔召回的原理不复杂:左边塔把query序列编码成一个向量,右边塔把商品侧信息(标题、类目、品牌、价格、图片特征)编码成另一个向量,训练时让点击样本的query向量和item向量在空间里靠近,不点击的样本拉远。上线时用向量索引做近似最近邻检索,把topK个商品灌给下游排序。
这个范式最大的优点大家都很熟悉:query和item的语义相似度可以被统计在向量空间里,哪怕query里没有出现商品标题的任何一个词,也能通过语义拉近召回。这是倒排索引做不到的。所以我并不是否定向量检索的价值,它上线后确实把搜索的召回丰富度拉高了一大截。
但你要意识到,双塔其实是把所有信息“压扁”成一个固定长度的向量。query侧“AJ1黑红脚趾 42.5码 漆皮”也好,“AJ1红黑配色高帮男鞋”也好,最后都变成128维或者256维的稠密向量。商品侧也一样,标题、属性、价格、销量全挤在一个向量里。这中间有多少信息损耗,绝大部分团队是不去深究的,只看最终离线指标涨不涨。
我这么说吧,双塔模型在“只要求跟query相似”的任务上表现很好,比如找长尾同义词、跨层级泛化、模糊归因;但交易搜索里面很多query根本不是“找相似的”,而是“满足条件”的。用户说“白水泥配色 40码 只要一千二以下”,这个query的正确答案不是“和这个query长得像的商品”,而是在价格、配色、尺码三个约束下同时成立的商品集合。你让双塔去区分“白水泥配色但40码断货”和“白水泥配色且有40码”,它很难学到这种精确约束关系,因为embedding空间里这两个商品被压得非常接近。
1.2 交易搜索的硬约束,恰恰是向量模型的死穴
更麻烦的是,交易搜索和普通内容搜索有个本质差异:商品是“有状态”的。库存有没有、发货快不快、价格是不是区间内、是否参与某个活动、是不是正品保障范围、尺码是否齐全……这些约束条件在以分钟甚至秒级变化。你把商品编码成embedding,这个embedding里面包含的“库存状态”是过时的,甚至根本不该编码进去——因为库存是动态的。
所以你会看到很多团队的做法是:向量召回前面套一堆过滤条件,比如“库存>0 AND 价格区间 AND 类目匹配”,先过滤再进向量检索。但这样问题就来了,如果你过滤得严,向量模型能用上的候选空间就小了,召回率掉得厉害;如果你过滤得松,向量模型又容易把那些不满足条件的商品给召回来,下游排序还要花很大力气去纠正。
倒排索引在精确匹配上反而稳妥,尤其是对SKU级别、spu级别的结构化属性。用户搜“AJ1 42.5”,倒排直接精确匹配尺码字段,既快又准。但倒排对query的自然语言变体、同义改写、组合语义就太弱了,比如“科比实战鞋 包裹好 启动快”,倒排基本无能为力。
这就形成了一个两难局面:倒排查得准但理解不了,向量理解得了但查不准。得物交易搜索在做的生成式召回,本质上就是为了打破这个两难,用一个模型同时兼顾“语义理解”和“约束满足”。
1.3 别急着否定向量,真正的问题是“召回的推理深度”
我得把话说透,生成式召回不是要干掉向量检索,而是把召回这件事的“推理深度”往上提一层。向量检索是单步匹配,生成式是多步推理。
就拿“搭配场景”来说,用户搜“Dunk熊猫”的同时,系统如果想做连带推荐,传统向量召回的做法是拿当前query的embedding去商品库里找相似,但用户刚刚浏览过一件“米白色卫衣”,现在搜“黑色工装裤”,这个“卫衣配套裤装”的横向语义关系,双塔很难表达。因为你没有办法在编码query的时候,把整个用户session的浏览序列作为上下文塞进一个query向量里——就算你硬塞,信息也被压扁了。
生成式模型不一样。它的输入可以是“query + 用户短期行为序列 + 长期偏好 + 场景上下文”,输出的是商品ID序列。这个结构天然允许你在生成过程中做推理:模型看到用户近期浏览过米白卫衣,就会把“黑色工装裤”里那些与米白色协调的款式概率抬高。这个能力不是靠“相似度”能简单刻画的,这是模型在学条件分布时自己涌现出来的组合推理能力。
2. 生成式召回到底是什么:从“检索匹配”到“条件生成”的范式翻转
2.1 核心思想:让模型直接“吐”商品ID
生成式召回这个概念最早被大规模讨论,是在雅虎团队的DSI(Differentiable Search Index)工作里。论文的思路很直接:传统搜索引擎是“索引构建—召回匹配”两个阶段,DSI试图把这些合并成一个可微分的索引,也就是把documents放进模型参数里,给定query,模型直接生成相关doc的ID。
放到电商交易搜索里,简单实现就是:用商品库里的所有商品,为每个商品分配一个“语义ID”,然后训练一个encoder-decoder或者LLM。输入是用户query和上下文,输出是一个ID序列(比如“31405-782-1234”),这个ID对应一个具体商品。解码时用beam search保留多个候选ID,每个ID去商品库里验证就能得到召回结果。
这个思路听起来有点反直觉,因为你会问:模型怎么知道“31405-782-1234”是谁?万一生成一个不存在的ID怎么办?
这两个问题正是生成式召回落地最核心的技术挑战,后面我会详细讲。先说清楚为什么值得冒这个风险。因为一旦模型能直接生成商品ID,那么query和商品之间的关联就不再依赖向量空间里那个固定相似度函数了,模型可以在生成过程中灵活地对输入上下文进行推理、组合、约束满足,这是静态双塔做不到的。
2.2 从“判别式打分”到“生成式分布”,差的不只是目标函数
双塔召回训练目标通常是一个判别式目标:给定query(q)和商品(d),模型输出一个相关性分数 s(q,d),然后用softmax或者采样负样本的NCE loss去拉近正样本、推开负样本。这个目标本质上在学一个匹配函数,模型是“判别器”。
生成式召回训练目标变成了最大似然:给定query和上下文,最大化生成正确商品ID序列的概率。这就是把他当“生成器”来训练了。这两个目标从数学形式到信息利用方式都有本质差别。
判别式目标下,你为了训练一个query和千万级商品之间的分类器,负采样策略非常关键,选不好负样本整个模型都很容易崩。生成式目标下,模型是在学习一个条件概率分布 p(item_id | query, context),你不需要显式地构造那么多负样本,只要用正样本序列去拟合即可。模型可以在训练过程中通过attention机制自己学会区分confusable的商品。
而且生成式目标有一套很自然的“排序属性”:解码时beam search出来的候选序列天然带有概率排序。也就是说,你不需要再额外接一个打分器,模型输出的生成概率本身就是序。这是一个巨大的工程便利——你可以把召回和粗排的活同时接到一个模型上。
2.3 为什么交易搜索比内容搜索更适合先做生成式召回
很多人觉得,生成式召回是个锦上添花的学术概念,离工业落地还很远。但交易搜索恰恰是生成式召回最能发挥价值的土壤,原因有三。
第一,交易搜索的候选空间是“有限商品集合”,不是开放域内容。商品ID是离散的、可枚举的、有边界的。你不需要像大模型生成文本那样面对无限词表,只需要cover住商品库里的数百万或者上千万个ID。这对模型来说是个可控的生成任务。
第二,交易搜索天然是多约束满足任务。用户会加各种筛选词、属性词、价格区间,生成式模型可以直接把这些约束编码进输入上下文,生成结果自然满足约束,比先做向量召回再filter要自然得多。
第三,电商搜索query通常很短,但商品结构信息极其丰富。生成式模型可以通过层次化的ID编码方式把商品类目、属性、品牌等结构化信息织入ID序列里,生成时按结构逐层解码,精度会比直接生成扁平ID高不少。
3. 得物交易搜索的落地架构:不是替代,而是叠加和协同
3.1 三层混合召回:生成式有主有权,其他路径兜底
我们实际搭建的架构不是“只用生成式召回”,而是三层并行:倒排索引、双塔向量、生成式召回。三个路径都产出候选集,然后在融合层做合并去重,再交给后续的精排。
为什么不直接完全依赖生成式?因为生成式召回有一个客观风险:可能存在“覆盖不到”的样本,比如训练语料中从未出现过的ID组合,或者特别冷门的商品。而且生成式模型有一定的幻觉概率,生成结果不一定100%可信。为了不把召回率做坏,我们必须让倒排和向量作为兜底路径,保证用户在最差情况下也能找回基本候选。
但主路径是谁,这个在迭代中逐渐变了。早期主路径是倒排+双塔,生成式只作为一个“增量召回”尝试;到后面我们把生成式作为主召回路径之一,倒排退到属性精确过滤,双塔则承担模糊语义扩展。这个调整的依据不是离线指标,而是线上bad case的分布——大量跟“组合约束”相关的bad case只有生成式能解。
3.2 商品ID怎么编:不能直接用原始ID,要搞“语义ID”和“层次ID”
这是生成式召回绕不过去的第一道坎。你得给每个商品一个ID作为生成目标,但直接用商品原始ID(一串无意义的数字)训练模型,模型很难学,因为它无法从ID本身推断任何信息。
我们采用的是层次化的语义ID方案。通过一个量化模型(类似RQ-VAE的思路),把商品的语义向量(由商品标题、类目、品牌、属性等组成的embedding)压缩成若干个离散编码层。比如一个商品最终被表示为“l1-l2-l3-l4”这样的4个token序列,每个token代表一层语义。第一层对应粗粒度类目(比如“鞋靴”),第二层对应品牌/系列(比如“Air Jordan”),第三层更细,对应配色或者型号,第四层逼近到具体SKU级别的尺码和库存单位。
这样做的最大好处是:模型生成的时候是“按结构逐层解码”的。它先生成“鞋靴”,再生成“Air Jordan”,再生成“黑红脚趾”,最后生成“42.5码”。这个生成过程就和人类搜索的意图拆解高度一致,准确率比直接生成扁平ID高非常多。而且一旦中间某层生成错了,后续层次可以通过beam search回退纠正,容错性也更好。
3.3 受限解码:生成出来的ID必须是合法商品
模型生成文本可以让AI自由发挥,但生成商品ID不能让模型自由发挥——如果你生成了一串“无中生有”的ID,线上用户看到的就是一个不存在的商品,这是绝对不允许的。
所以我们在推理解码阶段加了受限beam search:维护一个合法的商品ID集合,每次解码时,用这个集合对应的token来mask掉词表中所有非法的token,保证整个生成序列一定是合法商品。这个方案在实现上不算复杂,但效果至关重要。如果不加受限解码,生成式召回的准确率会惨不忍睹,用户随便搜点什么都能看到一堆幻觉商品。
具体实现上,我们维护了“商品ID合并索引”,把所有ID序列以及ID序列的前缀放到一个快速查询表里。解码时每一步只要查一下当前前缀是否存在于合法ID前缀集合中,不在就概率置零。这样解码的每一步都天然合法,相当于把倒排索引“焊死”进了生成过程里。延迟方面,用batch推理加缓存优化,线上p99可以控制在15ms以内,完全在可接受范围。
3.4 多约束注入:价格库存这种动态约束,该进prompt还是进mask
这部分是我们在实际迭代里反复调整的地方。生成式模型输入管线至少包含三块:query文本、用户上下文、场景约束。问题在于,像价格区间、库存状态这些动态约束,是放进输入让模型学着遵守,还是放进受限解码的mask里强制过滤?
我的经验是:硬约束必须放进受限解码层,不能指望模型自觉。因为模型学的是行为概率,价格和库存的变化太快了,模型如果以训练时的静态库存为准,线上一定会出乱子。所以线上把价格段、发货地区、库存状态、黑白名单全部放到mask逻辑里,解码时直接把这些不满足条件的商品ID候选从合法集合中剔掉。
软约束则放进prompt输入里,比如用户近期在浏览高价位潮品,还是平价单品;用户是偏好某个固定品牌的粉丝,还是广撒网型;甚至当前场景是来自首页搜索框,还是来自“您可能想找”的关联搜索位。这些信息以token序列或者额外embedding的方式注入生成模型,模型真正学会的是“根据上下文调整生成偏好”的能力,而不是一套死规则。
4. 训练与推理的高频踩坑:数据、损失函数、解码效率一个都不能少
4.1 训练数据的造法:不要把“曝光点击”直接当“生成目标”
生成式召回的训练数据构造和双塔完全不一样。双塔需要构造“正样本对+负样本对”,生成式召回需要构造“query→ID序列”的样本。
最直观的构造方法是,把用户点击、加购、购买的商品ID对应的语义ID序列,作为生成目标,排在前面的是用户最后成交/加购的商品,后面的是点击过的商品。但如果只这么做,会遇到一个严重的面试送命题:一个query在日志里可能对应好多个商品,而生成式模型每个样本只能有一个目标序列。你怎么决定把哪个商品作为唯一监督目标?
我们的做法是构造“多目标样本”:对于一个query,把所有正反馈商品放到一个“候选目标集合”里,训练时每个step随机采样一个目标ID去计算loss。配合课程学习策略,先让模型学主要高频关联商品,再逐渐增加长尾目标。这个训练方式和多标签分类有点像,但保持了生成式模型的序列化优势。
另一个重要细节是,训练数据里要加入“不可召回”的query样本。有些query在线上就是没有明确意图或者对应的商品都是垃圾商品,这种样本如果不特别处理,模型会把生成概率散布到一堆低质商品上。我们单独标了一个“低质query”类别,让模型对这类query生成一个特殊的空ID token(表示放弃生成),进一步减少垃圾召回。
4.2 损失函数设计:光有交叉熵远远不够
开头我们用的就是标准序列交叉熵,效果还行,但你会发现模型很快就过拟合到高频商品上,长尾商品的生成概率被严重压缩。这不仅是样本不均衡问题,更是损失函数没有显式地对“检索质量”建模。
后来我们叠加了两个辅助loss。
第一个是集合式检索loss:一个query对应的不是单个商品,而是用户反馈过的多个商品,我们希望生成器给这些商品的概率总和最大化。具体的做法是,在解码空间里找到所有正样本ID序列(可以通过前缀共享高效计算),最大化整个集合的对数概率和。这个loss假设是“用户对多个商品都有意图”,比总是押一个目标ID更符合交易搜索实际。
第二个是对比排序loss:对于每个训练batch,把query的生成正确ID概率和错误ID概率做对比,拉大两者差距。这相当于给生成器一个“判别能力”的辅助监督,特别能抑制幻觉ID的产生。两个辅助loss和主loss按比例混合,线上效果比单用交叉熵掉了不少bad case。
4.3 推理效率优化:15毫秒的生成召回是怎么抠出来的
生成式推理天然有个劣势:要一步一个token地解码,比双塔那种一次前向出所有向量要慢得多。但我们实测发现,在语义ID只有4层、beam size控制在32~64的前提下,一次batch推理在GPU上其实非常快,瓶颈反而不是模型计算,而是ID合法性检查。
合法性检查不能每个step都去查一次千万级ID哈希表,那样太慢了。我们做了两个优化:第一,把beam search的每一步剪枝提前——不是所有生成候选都留着,而是先根据softmax概率阈值砍掉低概率分支,再走合法性mask;第二,把ID前缀树预加载到GPU的显存常量里,mask运算直接在GPU上做,避免每个step都走CPU哈希查询。
线上我们还做了一个非常工程化的策略:热门query的生成结果会缓存起来,缓存时效通常设置为60秒左右。因为交易搜索的热门query非常集中,缓存命中率可以做到接近一半,这极大地缓解了生成式召回并发最高峰的GPU压力。冷门query、个性化强、约束频繁变化的query则走在线生成路径。
4.4 冷启动与新商品:生成式模型不认识新ID就是召回黑洞
交易搜索是所有电商搜索都要面对的痛:新品上架的速度远大于模型重训的速度。生成式模型又比双塔更敏感——双塔好歹能把新商品的embedding通过某个快速通道加进索引里,生成式模型如果不知道新商品的语义ID序列,它就永远不会生成这个新ID。
我们的解法是“两段式接入”:新商品先进入倒排索引和向量索引,用于兜底召回和常规召回;同时,用新商品的商品侧向量去做一个“伪语义ID”初始化,具体来说把这个新商品的向量喂进预训练好的量化器,得到四层离散编码,如果编码结果和某个已有老商品完全一致,那就用老商品的ID序列作为初始化,保证模型可以把它和相似老商品挂钩。
然后不是所有新商品都立刻进入生成式召回,而是等它积累了一定的点击曝光数据,进入下一轮增量训练后,才真正加入生成词的合法mask集合。这里宁可慢一点,也不能让生成式模型对“没见过的新品”胡编ID。
5. 评估与实验:离线指标全是幻觉,线上体感才是答案
5.1 离线评估该看哪些指标
很多人做召回评估只看Recall@K和NDCG@K,但生成式召回加两个专属指标特别关键。
第一个是“合法生成率”:所有生成的ID序列里,有多少比例能对应到当前线上合法可售的商品。如果这个指标低于95%,说明模型幻觉很严重,上线大概率出问题。第二个是“约束满足率”:生成的候选商品里,有多少满足query中文硬约束(类目、品牌、尺码等)。这个是判断你软约束注入到底有没有被模型学到手的关键。
这两个指标都很操蛋的是,离线测得好不代表线上真的好,但离线测不好线上一定不好。所以我们的策略是,把这两个指标作为“门槛指标”而不是“优化目标”——达标了再去看Recall@K和NDCG提升。
5.2 在线AB实验的坑:用query分桶而不是session分桶
搜索召回在线实验里最常犯的错误是用用户ID分桶。因为同一个用户同一个query可能在两个桶里都会出现,如果把生成式召回的生成结果只给一部分用户看,另一部分用户看到的是旧结果,那同一个query反馈数据就互相污染了,跑一段时间后指标会变得很奇怪。
我们最后用的是query关键词分桶,每个query哈希后固定进入实验桶或者对照桶。这样同一个query在实验期间只会展示同一套召回策略,反馈数据是干净的。但要小心两个问题:一是query分布不均会导致两个桶流量差异很大,需要对热门query做分层抽样;二是新query(没哈希过的)默认进对照组,保证线上稳定性。
核心指标上,除了常规的CTR、CVR、GMV,我们特别关注曝光多样性:p90商品曝光频次、曝光商品总数、搜索页翻页率。因为生成式召回很容易“变聪明到过于自信”——直接把最相关的几个商品反复推给用户,导致曝光聚集度上升,长期来看对生态是伤害。
5.3 线上bad case实录:生成式召回看起来“很有道理”,但用户不买账
我在项目中期被一个bad case搞得头皮发麻。用户搜索“AJ1”,生成式召回给出的结果里有两个商品看起来非常合理——同一品牌、同一系列、配色也接近,但实际上是“AJ1 Low”而不是用户大概率想要的“AJ1 High”。从文本相似度看,这两者几乎一致;从生成式模型的条件概率看,模型也觉得它们应该一起出现。
但用户去点击对比后,购买行为明显低于对照组的纯倒排召回结果。原因也很简单:用户搜AJ1的时候,虽然可能浏览Low版本,但购买决策时更倾向跟明确意图对齐。这就暴露了生成式召回的一个潜在问题:模型过度学到了“相关但不完全匹配”的语义关联,反而把精确匹配稀释了。
我们后续在训练时增加了“精确匹配惩罚项”:对用户query里带有的明确属性短语,如果在生成目标商品ID里缺失对应属性,这个样本的loss权重降低。线上bad case数据才开始收敛。这提醒我一件事:生成式召回不是越聪明越好,而是要在“语义扩展”和“语义精确”之间找到那个商业上最优的平衡点,这个平衡点没有一个公式,要靠bad case反复喂。
6. 工程与组织上的思考:范式跃迁从来不是改一个模型的事
最后聊点可能技术文章不爱提但真实存在的事。生成式召回在得物交易搜索里推进过程中,最大的阻力反而不是模型效果,而是组织惯性和系统架构惯性。召回团队习惯用向量检索,排序团队习惯消费固定字段的候选集,工程团队对在线解码的延迟充满警惕——这些惯性非常合理。
我们做了三件事让整个范式转换平稳落地。第一,不搞一刀切替换,而是“主路径+兜底路径”并行,先把生成式召回作为增量候选上游挂在融合层外围,让下游和用户无感知。第二,搭了一套完整的bad case回流闭环,把生成式召回产生的线上失败case自动记录、聚类、人工标注,再回流到训练数据,让模型逐渐理解交易场景里的隐规则。第三,把生成式召回的性能指标公开到团队的统一看板,让大家看到的不只是“模型涨了”,而是“搜索整体体验涨了”。
我现在回头看,生成式召回在交易搜索的真正意义,不只是多了一个花哨的召回通道,而是把“搜索引擎”从匹配工具向“意图推理器”推进了一大步。它让召回这个环节第一次有机会真正理解用户输入背后的组合意图,并且用结构化的方式把意图转化为具体商品。
如果你也在做交易搜索,或者正在纠结要不要投入生成式召回,我的建议是:先不要一上来就想着全量替换双塔,而是找一个被“属性组合类query”折磨最痛的业务场景下手,把语义ID方案和受限解码管线搭好,用bad case驱动迭代。等模型真正跑起来了,你会发现它带来的不是指标涨几个点,而是让你重新理解了“搜索召回”这件事还能怎么玩。