这两年做搜索召回的谁没在向量检索上卷过。双塔、Graph Embedding、SimCSE、diffusion召回一个接一个上。可等你真上了线特别是做交易搜索这种重结构化约束的场景会慢慢发现一个别扭的地方向量路召回的东西越来越“像”却越来越不“准”。我指的是那种“看起来语义相关、但根本不是用户想要的那一单”的候选。后来我们换了思路用生成式的方式重新设计召回路——把“匹配”换成“推演”这才算是跳出匹配范式摸到了召回范式跃迁的一点边。这篇就把这段探索的完整过程、方案细节和踩过的坑按实操视角整理出来给同样在交易搜索里折腾召回的朋友参考。1. 向量检索很香但别把它当成召回终点1.1 向量检索解决了真问题也养成了路径依赖先说清楚我不是向量检索的反对者。恰恰相反在语义召回这件事上向量检索是过去五年里最值得投入的方向之一。它解决了倒排索引最头疼的问题词不一致。用户搜“实战篮球鞋”商品标题里写的是“ indoor court shoes ”靠字面匹配怎么都拉不上关系但向量能把它们在语义空间里拉近。这个能力在长尾 query、口语化表达上尤其值钱。但问题也出在这里。当一套方案从“有效手段”变成“默认配置”团队很容易停止追问一个根本问题召回的终点是让候选“像”用户说的还是让候选“是”用户要的这俩在大多数场景下是等价的但在交易搜索里不是。交易搜索的用户 query 通常不是一篇完整的文章也不是一个明确的知识点而是一个不完整的购物愿望——“送男朋友的鞋”“两百块以内的实战鞋”“看起来贵的通勤包”。这些 query 有一个共同点它们不是“描述一个东西”而是“描述一堆约束”。约束的组合构成了真正的购买意图但向量检索对约束的表达能力天然偏弱。我举个例子你就明白了。用户搜“五百以内的AJ”假设库里只有两件商品一件是“AJ入门款折扣价499”另一件是“AJ联名单品原价1599现价1299”。按语义相似度第二件可能排更靠前因为它标题更长、品牌词更突出、描述更丰富。可用户要的是第一件。这种错误不是 embedding 没训练好而是 embedding 空间里根本没法同时编码“品牌”这种分类型属性、“价格”这种数值型约束、“折扣”这种行为型偏好。硬塞进去只会互相打架。1.2 交易搜索的硬约束恰恰是 embedding 的软肋进一步拆交易搜索和网页搜索、内容搜索有本质区别。网页搜索的答案是一个点——一条链接、一篇文章、一段定义用户要的是“最相关的那一个”。交易搜索的答案是整张网——一批商品、一组款式、一个价格带里的多个选择用户要的是“最合适的那一堆”。这意味着交易搜索天然需要硬约束类目必须在哪个分支、价格区间必须落在哪段、品牌是否要排除、尺码是否要匹配、颜色是否有要求。这些约束是可以逐条枚举、逐条判断的结构化条件。倒排索引在这类条件上很强类目过滤、价格区间过滤都是毫秒级的事。但倒排索引看不明白语义。向量检索正好反过来语义它懂但你要它按价格卡一个 500 到 1500 的边界它做不到因为价格是连续数值你在 loss 里加再重的价格特征embedding 空间里也切不出一个干净的超平面。于是传统方案只能走上一条打补丁的路倒排一路、向量一路、属性过滤一路、价格裁剪一路最后用规则或者粗排把多路结果合起来。路数越多每路单独看都合理但链路越来越重问题越来越多。比如向量路召回的结果里混进了价格不符的得靠后面的过滤逻辑砍掉过滤逻辑砍掉太多覆盖率又不够又要加召回……这个循环我跑了很久最后想明白一件事与其在后链路不断补救不如在召回源头就换一种表达方式——把“匹配相似文本”替换成“生成用户的完整检索指令”。2. 生成式召回到底在换什么范式2.1 从“匹配”到“推演”的思维转变传统召回链路里我们默认的思考方式是“给定一个 query从索引里找和它匹配的 doc”。这里的关键词是“匹配”。匹配的对象是文本、是 embedding但本质上匹配的是“表达”——用户怎么说的我们就怎么找。而交易搜索里用户的表达往往严重不完整。我做过一个抽样统计得物搜索场景里有相当比例的长尾 query 是“短语式”的比如“性价比鞋”“通勤”“排面”。这些短语根本不是商品描述更像是一个“意图的压缩包”。你拿这个压缩包去匹配商品文本天然吃亏因为商品文本是“完整展开的”而用户输入是“省略过的”。补全中间断层光靠扩大语义召回范围解决不了你得靠“推演”。生成式召回的核心就是用模型基于 query 以及用户行为上下文直接生成一份“完整的、结构化的检索指令”这份指令里包含了类目路径、价格带、风格标签、品牌倾向、人群属性等信息然后你用这份指令去执行一次精准的召回。换句话说召回过程从“找和你说的一样的”变成了“先想清楚你要的究竟是什么再去库里有目的地拿”。我用一个生活化的类比。传统召回是你走进书店店员听你说“我要那本蓝色封皮讲历史的书”然后他在书架上找。生成式召回的思路是你先跟店员描述你的场景“我出差路上想读一本不厚的历史随笔”店员在脑子里推演出“轻型纸、300页以内、历史类、随笔风格”这几个条件再去挑书。前者是匹配表达后者是推演意图。2.2 生成式召回不是“生成商品”而是“生成检索指令”这里要澄清一个容易踩的误区。很多人一听生成式召回第一反应是“让模型直接生成商品ID”或者“生成商品标题”。学术界确实有这条线Google 的 DSIDifferentiable Search Index就是尝试让模型直接生成 doc 的标识符但它在工业界落地有几个硬伤一是商品库的量级太大而且每天都在变模型内化全部商品标识符不现实频繁增量更新会把训练和部署成本推高到不可接受。二是生成商品 ID 对用户来说是个黑盒出了问题你没法解释为什么生成的是这个而不是那个排查和调优都无从下手。所以我们在工程上做了一个更务实的拆解生成式召回模型的输出不是商品本身而是“检索指令”。指令由结构化字段和非结构化关键词组成。举个例子输入 query“送男朋友的实战鞋”模型生成指令类目运动鞋/篮球鞋 价格带400-1200 风格实战、耐磨 场景礼物 额外关键词高帮、包裹好拿到这份指令后面接的还是传统索引类目过滤、价格区间过滤、关键词召回、向量召回——但是这里的向量召回不是拿原始 query 去做的而是拿指令里的“意图表示”去做的。也就是说生成式召回不是要替换掉所有旧组件而是要在链路的起点把“用户的模糊表达”翻译成“系统可执行的高精度指令”让后面的每一路召回都在更准的输入上工作。这个做法的收益我总结成三点可控性。指令是显式结构类目错了可以针对类目分支修价格带偏了可以直接调区间问题定位从“玄学”变成“逻辑”。可解释性。线上返回结果给用户之前你可以看到模型生成的指令长什么样也能基于指令做干预和兜底。组合泛化。训练数据里没出现过的约束组合只要每个字段生成得够准组合出来的指令大概率也是对的。这一点向量检索很难做到。3. 得物交易搜索的生成式召回落地实录3.1 检索指令的 Schema 设计第一步不是选模型、调参而是设计指令的 Schema。指令长什么样直接决定后面的模型结构、训练数据构造和在线执行方式。我们当时把检索指令拆成四类字段类目字段CATE必选单值或多值来自商品后台的统一类目树。这决定了召回的商品池在哪个分类分支里。比如“篮球鞋”和“足球鞋”绝不能混在一个池子里。数值字段PRICE价格带区间左闭右开。允许模型输出范围也允许输出“不限”。枚举标签TAG风格、场景、人群、款式等标签多值可选。这些标签有一部分来自商品属性有一部分来自运营人工标注还有一部分来自用户行为聚合比如“热门”“送礼”这类场景标签。自由词FREE_TEXT模型从 query 里抽出来的关键修饰词比如“高帮”“磨砂皮”“白色”后续送到倒排或向量路去做二次匹配。Schema 要满足三个原则一是字段都必须在商品侧有现成的结构化字段对应不能模型生成了一个属性但商品数据里根本查不到二是字段要足够“正交”类目和价格之间不能有逻辑冲突否则后链路执行时会出现空结果三是尽可能压缩指令长度每个字段都对应词表里的一个或几个 token方便自回归解码时做约束。我们最早的版本字段设计得很细连“鞋底材质”都单列了字段。结果训练语料里这个字段大量缺失模型学出来的预测分南辕北辙最后这路字段在评估时拖了整体召回率。这个教训很直接指令字段必须基于数据情况收敛不是拍脑袋拍得越细越好。后来我们只保留了覆盖率超过 90% 的字段其他信息一律丢进 FREE_TEXT 兜底。3.2 训练数据从行为日志中“反推”生成式召回的训练数据本质上是一堆“query 到检索指令”的翻译对。但用户不会告诉你他的完整意图是什么所以我们需要从用户行为日志里反推。具体做法分四步第一步把商品数据规范成“商品档案”。每件商品有一份标准化的属性描述包括类目路径、价格、品牌、风格标签、材质、场景标签、热门程度等。这些字段全部来自商品后台。第二步抽取搜索 session。一个 session 内用户输入 query然后有点击、有成交、有加购。我们把点击和成交的商品作为“用户认可的候选集合”把这些商品对应的“商品档案”拉出来当作用户这次搜索背后隐含意图的近似表达。第三步做模板化转换。拿到商品档案后把它反推成一条检索指令。因为有模板这一步是确定性转换不需要模型参与。比如某件商品是“运动鞋/篮球鞋”类目价格 899标签带有“实战”“耐磨”那模板就会产出指令CATE运动鞋/篮球鞋PRICE800-1000TAG实战耐磨。第四步形成训练对。把用户原始 query 作为源文本把反推的指令作为目标序列。多个用户对同一个 query 点击了不同商品那就形成多条候选指令我们按点击/成交权重做采样和过滤同一个 query 最终保留 top 几条指令作为监督目标。这四步走下来模型学到的能力就是给定一个口语化的、不完整的 query预测用户群体会接受什么类目、什么价位、什么风格的商品。这里有个关键点——我们用“点击/成交商品反推指令”而不是用“人工标注指令”核心原因是人工标注的主观偏差太大而且覆盖不了长尾 query。行为信号虽然噪声大但胜在量大、真实、贴近交易意图。提一下数据清洗的细节。行为日志里最怕的是“误点”和“随便看看”。我们按每次点击的停留时长、成交状态、加购状态加了一套置信度权重只有置信度高于阈值的样本才进入训练集。否则模型会被误点数据带偏生成出大量脱离真实购买意图的指令。3.3 模型结构与解码策略模型主体我们用的是中小规模的 encoder-decoder底层参数共享一个训练好的 query 语义编码器解码部分是 6 层 transformer decoder。输入侧除了原始 query还会带上用户的最近行为序列摘要比如最近浏览过的类目偏好、价格带偏好这部分用双塔的 query 塔输出做 chunk embedding拼在输入序列尾部。关键在解码侧。指令是结构化的直接自由生成会出各种非法组合比如生成一个词表里根本不存在的类目或者价格带的左边界大于右边界。这里必须上约束解码constrained decoding。我的做法是给 decoder 维护一个基于前缀的状态机。每生成一个字段的 token状态机就检查当前字段的类型把下一步可选的 token 集合限制在合法范围内。比如当前正在生成 CATE 字段那每一步只能从类目词表里选生成完 CATE 的终止符后状态机才允许切到 PRICE 字段生成 PRICE 时强制先左后右左边界不能大于右边界。这样从根上杜绝了非法指令。解码策略上我们尝试过贪心、beam search 和采样。最终线上用的是 beam size 为 4 的 beam search加一点长度惩罚保证生成结果稳定。采样在离线评估里多样性更好但在线搜索场景对稳定性要求高同一个 query 不能这次召回这个、下次召回那个所以我们放弃了采样只在离线做探索实验用。3.4 在线链路与性能控制生成式召回不是替换掉原有召回路而是新增一路“意图召回”和其他几路并行。在线链路大致是这样用户输入 query同时进传统检索链路倒排、向量、属性过滤和意图生成服务。意图生成服务在 GPU 上跑模型生成检索指令整个过程需要控制在 20 毫秒以内。指令生成后拆解成结构化的过滤条件和向量查询条件去执行商品池过滤和向量召回。把生成式召回的结果与原始几路结果做去重、合并喂给粗排。性能是这个方案落地时最大的阻力。自回归解码即使只有 10 个 token单个 query 的耗时也比一次向量检索高一个量级。我们做了几个优化小模型加蒸馏。先用大模型离线蒸馏出 6 层小模型在线推理只跑蒸馏模型。效果损失控制在可接受范围延迟降下来近一半。词表裁剪。解码时每次只从字段相关的候选集合里选大幅缩小 softmax 的计算量。缓存。对于高频 query 和热门类目指令生成结果直接缓存LRU 淘汰命中率可以做到相当高整体平均延迟能回到传统召回的同一水平线。超时保护。如果意图生成服务在限定时间内没有返回就自动跳过这一路不阻塞主链路。必须先保证主链路稳定再谈增量收益。4. 实操中踩过的坑与排查记录4.1 生成结果不稳定复现性翻车我们第一个版本上线前离线测得好好的一到线上灰度就发现同一个 query 多次请求生成的指令偶尔会不一样。排查后发现是推理框架里默认开了采样相关的随机性加上 beam search 的 score 存在浮点精度抖动导致排序不稳定。这个问题的排查过程很曲折。一开始以为是模型参数没固定检查了一遍不是。后来把线上请求日志拉下来对比了同一 query 前后两次的完整解码路径才发现是浮点计算顺序导致的 token 排序微调但在 beam 宽度下足以改变最终结果。解决方法分两层一是推理阶段强制设置固定随机种子并关闭采样全部走确定性解码二是在服务入口做 query 级别的结果缓存同一 query 在缓存时效内直接返回上一次的指令。两层都加上之后稳定性问题彻底解决。这里也提醒一句生成式模型上线前一定要把“稳定复现”当成一个独立的验收项不能只看离线指标。4.2 类目词表越想越厚生成越来越慢第二个坑来自词表设计。类目树在业务侧是很细的叶子节点有几千个而且运营经常加新类目。一开始我们把所有叶子类目都放进解码候选集结果每次解码的 softmax 都要算几千个类目的分数延迟直接爆表。后来我们把类目层级改成了“先粗后细”。解码时第一步只生成一级/二级类目比如“运动鞋”“潮流服饰”等一级类目定了之后再在二级类目子集里生成具体分支。这样每一步的候选集都控制在几十到几百个 token解码速度大幅提升同时也没有丢掉细分类目的表达能力。这个做法本质上就是层级 softmax 的思路在结构化生成场景里特别实用。还有个连带问题新类目上线后商品池里可能还没有足够的行为数据模型学不会生成这个新类目。我们的兜底策略是在新类目冷启动阶段用规则把老类目和同类新类目做个映射在模板转换时自动加一条“老类目转新类目”的改写样本保证模型在少量数据下也能学会。4.3 召回率涨了转化却掉了这个坑最值得讲。生成式召回上线后离线召回率提升明显点击率也算正常但成交转化出现下滑。我们一开始完全懵了后来拆了一层才发现问题出在哪生成式召回把商品池收得太“准”了准到把用户犹豫和比较的空间也收没了。举个例子用户搜“运动鞋”模型生成的指令是“类目运动鞋价格带300-800TAG实战”。这个指令非常准确地覆盖了用户的核心意图但它漏掉了一个重要事实用户在搜索场景下经常是“模糊探索”他搜“运动鞋”只是想看看有什么而模型却把他理解成了“实战鞋目标用户”。结果召回的商品全是实战鞋那些“好看但不实战”的运动鞋全被排除在外用户觉得选择变少了转化反而不如原来。这个教训让我重新理解了交易搜索的召回定位召回不是要“一剑封喉”而是要给用户“足够相关、也有一定宽度”的候选集合。生成式召回的优势在于把意图理解得更深但如果把指令执行得太严格相当于把搜索变成了“完成式问答”反而失去了浏览和发现的空间。我们的解法是给指令加“意图置信度”。当模型对整体指令的置信度很高时按完整指令执行置信度一般时放宽价格带边界或者只过滤类目不加 TAG 约束置信度低时退回传统召回不做强约束。这样既保留了生成式召回的精准优势又保住了搜索的探索属性。4.4 离线评估指标和线上效果对不上生成式召回的离线评估不是简单地比“召回率”。我们一开始拿标准 RecallK 来评估离线看起来提升不少但上线后体感不明显。原因在于标准召回率计算时用的是全量商品池做分母但交易搜索真正重要的指标是“有效候选覆盖率”——即最终进入排序阶段并且有成交可能的候选占比。我们最后把离线评估指标拆成了一套组合指令准确率生成指令和用户实际点击/成交商品反推的指令字段级的一致性。意图召回率生成式召回结果中落在正确类目/价格区间内的商品占比。有效多样性召回结果中类目/价格带的离散度防止上面说的“收太窄”问题。端到端转化模拟用小规模在线 A/B 预估或者用行为日志回放的方式评估生成式召回对成交的边际贡献。这几个指标组合起来才能比较真实地反映生成式召回的价值。单看任何一个都容易误判。5. 范式跃迁之后搜索链路还要跟着变5.1 粗排的角色变了从“打分”到“校验与路由”生成式召回上线后我对整条搜索链路的理解也变了。传统链路里粗排就是给召回的候选打一个分剪掉质量差的。但当你有了“生成指令”这个中间产物后粗排的位置其实变得更像“校验与路由”。具体来说粗排阶段可以拿到生成式召回使用的指令字段用这些字段对结果做一次一致性校验如果召回结果连指令的基本约束都不满足说明生成式召回这次的质量可能有异常需要标记出来给下游如果生成指令和用户历史行为偏好有明显冲突比如用户过去半年都在看千元以上的商品模型却生成了百元价格带那就要触发规则干预重新放宽约束。粗排从一个独立的打分环节变成了连接“意图理解”和“结果选择”的缓冲区。这个调整带来的好处是整个链路不再是一条单向管道而是有反馈、有校验的闭环。5.2 搜索日志要开始记录“意图”了还有一件容易被忽略的事日志体系。以前我们记录的是 query、曝光、点击、成交。做了生成式召回后我们开始把模型生成的指令也写进日志包括类目预测、价格带预测、TAG 预测还有对应的置信度。这批“意图日志”的价值非常大。第一它可以做离线的指令质量分析哪些 query 类型下指令生成得准哪些类型下不准趋势是什么样的。第二它可以反哺后续的排序模型让排序模型知道用户的意图结构而不是只看到原始 query 文本。第三它可以用来做用户意图迁移分析同一用户在不同时间搜索同一 query意图是否发生了变化这对个性化搜索和推荐联动都很有用。我建议任何打算做生成式召回的朋友尽早把日志设计纳入方案而不是等模型稳定了再回头补。日志体系晚建一天就少一天的分析素材。最后再分享一个实际感受。这个项目做到后期最让我觉得“范式跃迁”成立的时刻不是离线指标涨了多少而是排查问题的方式变了。以前向量召回的坏例你只能说“embedding 没学好”然后去挖数据、调损失很难说清楚模型到底错在哪。但生成式召回的错误是能定位的是类目错了还是价格带错了还是 TAG 约束太紧了。每一步都能输出、能 check、能干预。这个能力对交易搜索这种对稳定性和可控性要求极高的场景来说可能比那点效果提升更有价值——它把召回从一个“黑盒子”变成了一个“可以对话的模块”。如果你也在交易搜索、电商搜索这类重约束场景里折腾召回我真心建议你别只盯着向量检索停下来想想生成式这条路的可能性也许就是你不卷向量之后的下一站。