面试知识库

M3 · 召回基础设施 —— 开发文档(面试向)#

讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话讲出来。

一句话概括#

M3 解决一个底层问题:当 Agent 要”搜商品”时,它到底怎么从几千件货里捞出最像的那几十件。答案不是按关键词死磕字面,而是把搜索词和商品都翻译成一串数字(向量),在数字空间里比谁离得近。M3 把这套”召回引擎”搭起来,外加比价要用的汇率、关税、运费三张表。这些都是后面 M4 九大工具的弹药库——M3 只造弹药,不造枪。

打个比方:M3 是给搜索装上”语义雷达”。以前是”你说收纳袋,我只找标题里带’收纳袋’三个字的”;现在是”你说收纳袋,我连标题写’整理包’的也能捞出来”,因为它俩在含义上挨得近。


1. 为什么不用关键词搜,要用”向量”#

传统电商搜索就是关键词匹配——你搜”收纳袋”,它找标题里有这仨字的。三个绕不过的坑:

  • 同义词搜不到:你搜”收纳袋”,商家写的是”整理包”,字不一样就漏了。
  • 听不懂人话:你搜”出差带的东西”,没有哪件商品标题叫这个,直接两手空空。
  • 千人一面:俩人搜同一个词,结果一模一样,照顾不了”这人就爱小众款”。

向量召回的思路是:把词和商品都映射到同一个”含义空间”里,用距离代替字面匹配。 “收纳袋”和”整理包”在这个空间里离得很近,所以能互相搜到;“出差带的东西”会被理解成”旅行+便携+商务”这一片含义,照样能命中。

面试怎么讲:关键词搜索死在”字面”上——同义词、口语化意图、个性化全都搞不定。向量召回把”比字”换成”比含义”,这三个问题一次性解开。


2. “三塔”是什么,为什么我们只保留架构、不自己训#

教科书里的高级做法叫三塔模型,把搜索拆成三个独立的”编码器”(塔):

  • Query 塔:编码”你这次搜了啥”(即时意图)。
  • User 塔:编码”你长期爱买啥”(历史偏好)。
  • Item 塔:编码”这件货是啥”(商品特征)。

搜的时候把”你这次搜的”和”你这人的口味”两路融合成一个”请求向量”,再去和所有商品向量比近邻。这就是所谓双通道:一路保证”搜得相关”(语义),一路保证”合你胃口”(个性化),两路分开各管各的,免得互相打架。

这里有个诚实的取舍:三塔本来是要拿海量行为数据训练出来的,而我们这项目没有 GPU、明确不做任何模型训练。所以我做的是——保留三塔这套”接口和思想”,但把里面的编码器换成现成的预训练模型(BGE-M3 这类,走在线 API)。User/Query/Item 三个入口都还在、双通道融合也还在,只是底下共用同一个现成编码器,而不是三个自己练出来的。

面试怎么讲:三塔的价值在”架构思想”——意图、偏好、商品分开建模,语义和个性化双通道解耦。但训练它要 GPU 和行为数据,这俩我都没有。所以我对齐它的框架、换掉它的引擎:接口照搭,编码器用现成的。这正是这个项目从头到尾的原则——对齐框架,不照抄实现


3. 一个关键的工程现实:endpoint 没就绪,整条链路也得能跑#

真实编码器要连外部 embedding 服务。但开发、测试、CI 的时候那个服务常常不在。如果代码”非它不可”,那建索引、跑测试全得联网,又慢又脆。

我的处理:编码器做成可降级的。配了真实模型就走真实模型;没配,就退回一个本地的、确定性的”假”编码器(把文字切成小片段、哈希到固定的格子里凑成向量)。它语义不”聪明”,但有两个救命的好处——不联网、同样的输入永远给同样的输出。于是建索引→检索这条全链路在离线和 CI 下都能一键跑通,测试也能稳稳地断言结果。

面试怎么讲:我给编码器留了个”离线替身”。它不追求语义多准,只追求两件事:零依赖、可复现。这样整条召回链路不被外部服务绑架,测试也不靠网络。真实模型一接上,上层代码一行不用改——因为它俩走的是同一个接口。


4. 为什么召回层用 Faiss,而且”按平台分库”#

存商品向量、做近邻检索,业界有一堆选择(Faiss / Milvus / OpenSearch …)。这项目我分了两层来选:

  • 召回层(搜商品)用 Faiss:商品向量就是一堆纯数字,量大、要求快,召回层是”纯算力”场景。Faiss 直接吃这些数字、单机就能跑得飞快,套个数据库反而是给自己加序列化的累赘。
  • 应用层(知识库 RAG、长期记忆)后面用 OpenSearch:那是另一回事——要按标签过滤、要语义和全文混着搜、还要权重能随时调,这些 Faiss 干不了。这层留给 M5/M7。

这就是双栈分层:哪层吃算力用 Faiss,哪层吃”过滤+融合”用 OpenSearch,各用所长。

另一个决定是按平台分库——6 个平台各建各的索引,而不是一锅炖。原因:召回层的”过滤”主要就是按平台分。而”跨平台同时搜”这件事,我们在 M2 已经用”分身并行”解决了(一个克隆搜一个平台)。所以向量库这层根本不用操心跨平台,按平台切开最干净:搜单平台查一个库,搜全平台就各查各的再合流排序。

面试怎么讲:召回层是纯算力,Faiss 吃数字最快,上数据库是负优化;应用层要过滤要融合,才上 OpenSearch。这叫双栈分层。索引按平台切,是因为跨平台并行已经交给上层的分身机制了,向量库这层只管单平台,职责更纯。

距离度量上我用内积——因为向量都先归一化成单位长度了,这时候内积就等于余弦相似度,省一步还更快。


5. 脏数据是这一关真正的体力活#

6 个平台的原始 CSV 字段各写各的——同样是价格,amazon 叫 final_price、ebay 叫 price;分类有的存成 JSON 数组,有的是面包屑文本。所以建索引前要做一遍字段对齐,把六家拍平成一套统一的商品结构(标题、品牌、价格、币种、评分、分类、链接…)。

这里踩到一个真实的坑,也是 code review 抓出来的:amazon 的价格有近 14% 是科学计数法写的,比如 1.795e+01 其实是 17.95。我一开始抽数字的规则只认普通小数,把指数部分丢了,于是 17.95 被读成了 1.795——整整差了一个数量级。价格是后面比价、算到手价的命根子,这种错会让”便宜的”和”贵的”彻底排反。修掉之后我专门补了条回归测试把它钉死,免得以后又被改回去。

面试怎么讲:多源数据对齐是召回的脏活,字段命名、嵌套格式全不一样。最坑的是 amazon 价格用科学计数法,正则漏了指数会让价格差十倍——这种错不显眼但后果严重,我修完加了回归测试锁住。这也说明评审和测试得打在真实数据上,光跑通不等于跑对。


6. 汇率/关税/运费:为什么用”静态表 + 简化模型”#

比价绕不开三件事:不同平台报价币种不同(得归一才能比)、跨境买可能收关税、还有运费。这三块我都用简化模型,理由是项目定位明确写了”不做精细汇率对账、不做真实物流报价”——给用户一个”大概到手多少钱”的判断就够,不必接实时行情。

  • 汇率:一张静态表(各币种≈多少美元),先折成美元基准再折去目标币。
  • 关税:按品类给个近似税率,再叠一个”免征额”——货值太低的免税(比如美国 800 美元以下)。不查 HS 编码那套繁琐流程。
  • 运费:起步价 + 按重量加价,再按发货地分档(东南亚发欧美更贵),加个”满额包邮”的常见规则。

面试怎么讲:这三张表是有意做轻的。项目目标是帮用户”估到手价做决策”,不是做财务对账或真实物流。所以我用静态表和近似模型把链路打通,把复杂度花在主链路上,而不是陷进汇率/关税的无底洞。代价我也讲清楚:数字是近似的,真要上生产得换实时源。


7. 这一关交付了什么、边界在哪#

交付:一套能查的召回引擎(三塔编码 + 双通道融合 + Faiss 分平台检索)、一个可复现的建索引脚本(6 平台共 6000 件商品入库)、汇率/关税/运费三张表,外加把这些都钉住的测试。

后续演进(已落地)

  • Faiss → Qdrant(M3.5/M3.7):召回存储从 Faiss 升级到 Qdrant,支持 dense + 多维 payload filter(platform / price_usd / rating),不再按平台分库(Qdrant 的 payload filter 能做、且更灵活)。docker-compose 增加了 Qdrant 容器。
  • price_usd 预折算(后续增强):建库时每条商品用 fx.to_base_or_none 预折算 USD 价格存入 Qdrant payload,使得 item_search 的 price_usd_max 参数能在 Qdrant 层原生 Range filter,省去召回后大量超预算商品的浪费。
  • RAG 数据扩充(A 路):amazon 额外并入 RAG 抽样扩充源(amazon_rag.jsonl,48,860 条),build_item_index.py 自动合并两个来源。清洗逻辑抽出 admit_and_dedupe() 保证 CSV 平台源与 RAG 扩充源走同一套入库标准与去重口径。

边界(诚实标注,更新)

  • 编码器默认走离线替身 → 已接真 BGE-M3 远程 API(本地回退仍在、仅断网触发)。
  • 三塔是”形似神在、引擎换现成”,没有自训——这是无 GPU 约束下的主动取舍。
  • 汇率/关税/运费是近似模型,给决策用,不做对账。

面试怎么讲:M3 是基础设施层,定位是”把召回和算钱的能力备齐,等 M4 来调”。我把能跑通、可复现、被测试覆盖的最小可用版做扎实,同时把”哪里是替身、哪里是简化”标得清清楚楚——这样后面要升级,一眼就知道动哪儿。从 Faiss 到 Qdrant 的升级验证了这个分层的收益:上层 item_search 工具的接口一字没改,底下换了整个存储引擎。