面试知识库

M3.5 · 召回层 hybrid 升级(Qdrant)+ 数据清洗 —— 开发文档(面试向)#

讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话讲出来。 本轮是对 M3(召回基础设施)的一次升级,发生在 m-data-clean 分支。

一句话概括#

M3 把召回引擎用 Faiss 纯向量搭起来了。这一轮做两件事:一是把召回从”只比含义”升级成”含义 + 字面”双路(hybrid),二是把喂给模型的商品文本认真洗了一遍。顺带把召回层的存储从 Faiss 换成了 Qdrant——不是赶时髦,是”加了字面检索 + 要面向规模”这两个新约束,把原来选 Faiss 的理由推翻了。

打个比方:原来的搜索是个”只懂意思、不认字”的人——你说”收纳袋”它能联想到”整理包”,但你报一个精确型号”Kinvara 13”它反而发懵。这一轮给它配了第二只眼睛:一只看意思(向量),一只认死字(BM25),两只眼睛投票


1. 起点:为什么要动 M3 已经做好的召回层#

M3 当时按教材(refdocs 04-1)定的是”召回层纯向量 Faiss”。但教材那个结论有个前提——它假设你有一个训练好的语义塔。而本项目从头就明确:无 GPU、不训练任何模型,编码器用现成的 BGE-M3

前提不成立,结论就松动了。没经过领域训练的纯语义向量,有个天生的弱点:对”精确字面”不敏感——品牌、型号、规格(Kinvara 13ANSI Type R Class 2、SKU)会被它”糊”成一片近义,而电商搜索恰恰到处都是这种要精确命中的词

面试怎么讲:教材给召回层定纯向量,是建立在”自训语义塔”的前提上。我没这个前提(无 GPU、用现成模型),所以那个结论我不能照抄。未训练的 dense 模型对 exact-match 不敏感,而电商搜索是 exact-match 重灾区——这就是我要给召回层加一条字面通路的根本原因。


2. 为什么是 hybrid:让两只眼睛各看各的#

解法是 hybrid 检索:dense(向量,管语义)+ BM25(词法,管字面),分数归一后加权融合。这不是我发明的,是业界对”未微调 dense 模型”的标准答案——一堆评测(BEIR 之类)都表明,没在领域内微调过的向量模型,配上 BM25 普遍比纯向量强。我恰好就是”没微调”那种情况。

还有个被我抓到的细节:教材给召回层”不需要 hybrid”的理由,其实说的是标量过滤(按平台、按价格)——它说”过滤靠按平台分库 + fork 解决,不用 hybrid”,这话对,但它根本没回答”要不要 BM25 来补字面召回”。这俩是两码事。教材用一个关于”过滤”的理由,顺手否决了一个关于”相关性”的需求。我把这层窗户纸捅破了。

面试怎么讲:hybrid 不是我拍脑袋,是”未微调 dense + BM25 普遍更优”这个公认结论的直接应用。而且我发现教材否决 hybrid 的理由其实答非所问——它谈的是标量过滤,不是字面相关性。识别出这个逻辑缺口,是这次决策的关键。


3. 选型:为什么从 Faiss 换成 Qdrant#

加了 BM25 之后,“存哪、怎么搜”要重新选。我把候选摆开,一条条排除:

  • Faiss + 进程内 BM25:最省事,但 BM25 那条腿是单机内存的,几十万件就扛不住;一旦要面向规模,你被迫把字面检索挪到真引擎,就退化成”Faiss + ES 两套库手工拼分”,而跨库归一、改权重要重启这些正是教材痛批的坑。“考虑规模”这一条,直接把它判出局。
  • 全上 OpenSearch:向量侧过千万级会被拖累,还要给主链路(每条购物 query 都走的 item_search)加 docker 硬依赖,违背”离线默认”。
  • Milvus:亿级的终局答案,但部署最重;这项目大概率摸不到那个量级,运维成本是预支给一个用不上的未来。
  • Qdrant(选中):单引擎原生 dense + 稀疏(BM25)hybrid,能分片量化扩到十亿级,有纯 Python 本地模式(无 docker)守住离线默认,运维远轻于 Milvus。

真正把天平压定的是规模这一条。小数据下,“Faiss+BM25” 和 “Qdrant” 本来是”省事 vs 漂亮”的平局;但一旦把”商品可能上规模”当设计目标,Faiss 那条进程内 BM25 腿会先断,而 Qdrant 是唯一”现在本地跑 4427 件、将来同一套代码指向集群跑千万级”——抽象层规模不变,不会出现”小数据能跑、上规模要重写”的撕裂。

面试怎么讲:这不是”Qdrant 比 Faiss 好”的简单结论,是约束驱动的。加了 BM25,Faiss 方案的字面腿在规模上会断;考虑到这点,选择就收敛成”一个能原生 hybrid 又能扩的引擎”,Qdrant 在”够格 + 运维轻 + 有离线模式”上正好卡位。Milvus 留给真到亿级再说。


4. 没有推翻教材,是把双栈做对了#

换 Qdrant 之后有人会问:那教材的”双栈(召回 Faiss / 应用层 OpenSearch)“不就废了?恰恰相反——这是把双栈做对,只是把召回那条腿从 Faiss 升级成了 Qdrant。

最终三层各司其职:召回层 Qdrant(商品 hybrid,要规模)/ 应用层 OpenSearch(品类知识库 RAG,要丰富的全文分析器和可调权重,M5 已建好不动)/ 长期记忆 本地文件或 Redis。三处共用同一个 BGE-M3 编码器——这条不变量我专门核过(M5 知识库和商品召回都走同一个编码客户端),保证一次 query 编码能同时打两个空间、不漂移。

为什么知识库不也塞进 Qdrant 图省事?因为 OpenSearch 的真 BM25(带分析器/词干)在小而精的知识库上比 Qdrant 的”BM25-lite”更强,而且 M5 已经建好测过了。各用所长,不为了”统一”而牺牲质量。

面试怎么讲:换引擎不等于推翻架构。双栈的精神是”哪层吃规模 vs 哪层吃融合,各用所长”,我反而强化了它——召回层换成更能扛规模的 Qdrant,应用层留 OpenSearch。关键是守住”两栈共享一个编码器”这条不变量,否则向量空间会漂。


5. 数据从哪来、留下多少、长什么样(数据集构成)#

这一节把”到底拿什么数据、洗成什么样”讲细,因为召回质量的天花板首先是数据质量

原始数据:6 个平台的爬取商品 CSV(Amazon / Lazada / SHEIN / Shopee / Walmart + eBay),各家 schema 完全不同——列名、价格格式、品类表示法都不一样,所以清洗第一步就是把异构列名映射到统一字段

eBay 整体剔除:它的样本卖家名近乎全是 掩码、描述列全空、库存销量列也空,清洗性价比太低, 整条不要。所以最终是 5 个平台,不是 6 个。

清洗后留存(5000 → 4427):

平台原始保留留存率主要丢弃
Amazon1000995100%5 无图
SHEIN100097998%21 重复
Walmart100096997%9 缺货 · 22 重复
Shopee100087888%121 stock=0 缺货 · 1 重复
Lazada100060661%56 垃圾价 · 338 近重复刷量 listing
合计50004427

Lazada 为什么只留六成?它有大量同标题同价的”刷量” listing(比如同一个手机壳 56 个颜色款各发一条), 按”归一标题 + 价格”折叠成 1 张代表卡。对召回去冗余是有意为之——代价是不保留颜色变体,但避免了 搜一次返回 56 张几乎一样的卡。

统一后的 schema(15 列):7 个必填(质量闸保证非空有效:平台/主键/标题/价格/币种/主图/链接)+ 8 个可空(品牌/品类/评分/评论数/销量/描述/描述语言/原价)。

几个直接影响下游的数据事实(都是真实统计):

  • 币种 14 种,且未折算 USD:USD 2910、MYR 430、IDR 247、MXN 217、CLP/COP 各 169、THB 79…… 清洗只规整币种拼写、不折算汇率。跨平台比价前必须先折成 USD(留给 recall/fx),否则会把一条 IDR 标价当 USD 比,差几个数量级。
  • 21% 的描述是非英文(es 520、id 166、th 73、vi 55……),集中在 Shopee 和 Lazada。这正是中文 query 能召回 SHEIN/Shopee 商品的底气——BGE-M3 是跨语言模型,这些非英文描述照样编进同一个向量空间。
  • 销量(sold)只对 Lazada(483 条)和 Amazon(204 条)有效,其余三平台源数据根本不提供,全是 None。 这不是清洗丢的,是源数据决定的;0/空一律置 None,免得”零销量”污染人气排序。
  • 84% 有品牌(Shopee 仅约 22% 拉低均值)、描述平均 363 字截到 ≤500品类面包屑平均 2.5 层、最深 9 层

面试怎么讲:我能把数据构成说出具体数字,而不是”几千条商品”含糊带过。关键的几条:14 种币种没折算 (比价前必须先归一)、两成描述非英文(跨语言召回的底气)、销量只两个平台有效(源数据决定,不能假装有)。 这些不是清洗的副产品,而是会直接影响下游工具怎么写的约束。


6. 数据清洗:为什么单独抽成一道前置工序#

原来建索引脚本自己一边读 CSV、一边做字段对齐、价格解析、品类归一、去重——清洗逻辑和建索引揉在一起。这一轮把清洗抽成独立前置步骤:原始 CSV → 一道严格清洗 → 干净商品表(统一 schema、过质量闸、平台内去重)→ 建索引只管”读干净表 + 编码 + 灌库”。

为什么值得抽出来?因为清洗的产物不止喂建索引,比价、精挑、给用户看的文案都要用。揉在建索引里,等于让别的消费者拿不到干净数据。抽出来,一处清洗、全员受益,而且能单独测、单独复现。质量闸也定得”宁缺毋滥”:7 个必填字段缺一即丢、价格必须正常(剔掉 1e-02 这种占位垃圾)、明确缺货的才丢。5000 件原始数据洗出 4427 件干净的,留存率最低的 Lazada 因为大量刷量重复 listing 只留了 61%——这是有意为之。

面试怎么讲:清洗本是横切关注点,所有下游都要用。我把它从建索引里拆出来独立成步,一处清洗多处受益、可单测可复现。质量闸的原则是宁缺毋滥——脏数据进了索引,后面再聪明的精挑也救不回来。


7. 喂模型的文本:两条通路喂的字段故意不一样#

这是这一轮最考究的地方:dense 和 BM25 喂的字段是分叉的,因为它俩擅长的事不同。

  • dense(向量)喂:标题 + 品牌 + 品类(尾3层) + 描述片段。描述有语义价值,归向量这条语义通路吃。
  • BM25 喂:标题 + 品牌 + 品类(尾3层),不含描述

拿一条真实 Amazon 商品看两条通路到底吃进什么(同一条货,喂出两份不同的料):

为什么 BM25 不要描述?因为我们用的是单字段 BM25,没法给字段压权重。把整段营销文案塞进去,会发生两件坏事:一是稀释标题里那些真正要紧的型号/规格词,二是某个查询词碰巧在某商品文案里出现一次,这条不相关的货就被捞进来了——这正是”大量无关召回”的来源。描述的语义价值已经由 dense 那条腿吃掉了,BM25 只啃”身份字段”(标题/品牌/品类)做精确命中,各干各最擅长的。

还有两个小而关键的处理:

  • 品类只取尾 3 层。完整面包屑像 服饰鞋包 > 男 > 鞋 > 运动 > 跑步 > 路跑,顶上那一两层是”服饰鞋包”这种泛词,塞进向量会把语义往”平庸中心”拽、稀释掉”路跑”这种真正有区分度的信号。取尾 3 层,留下叶子和它的上下文,甩掉泛词。但元数据里仍存完整面包屑(展示和过滤要用),只在”喂检索”时截尾。
  • 描述截断防半句。喂模型前把描述截到 ~300 字,但不是硬切——优先在句末标点处断,其次在词边界断,避免留个半截单词。

面试怎么讲:让两条通路各喂各最擅长的字段,是这次设计的精髓。描述归 dense 吃语义,BM25 只啃标题品牌品类做 exact-match——因为单字段 BM25 压不了权重,塞描述只会稀释信号、引杂散召回。品类截尾、描述按句截断,都是为了”喂给模型的每个字都得是有效信号”。


8. 文本得先洗干净:乱码、emoji、全角、半句#

光选对字段不够,字段里的字本身是脏的。我扫了一遍真实数据:28% 的描述带 emoji、39% 有连续空格/换行、7% 有 HTML 实体,还有少量 HTML 标签、URL、乱码(mojibake)、全角半角混排、中英标点混用。这些直接喂模型是在浪费 token、注入噪声。

所以做了一道全面的轻量归一:修乱码(用 ftfy)、解 HTML 实体、剥标签和 URL、全角转半角、中文标点统一成半角、清 emoji 和装饰符、折叠重复标点和混乱空白。有一条铁律:只动符号、emoji、控制字符、标点宽度,绝不删任何文字——泰文、中文、变音符号(café 的 é)、商标符全都原样保留。多语言安全是底线,不能为了清洗把非英文内容误伤了。

面试怎么讲:我先去数了脏在哪——28% 带 emoji 是最大头,这种实证比拍脑袋靠谱。然后做一道全面归一,但守住一条底线:只清符号噪声,绝不碰文字本身。数据里两成是非英文,清洗误删一个泰文字符都是 bug。


9. 几个工程现实(都值得一讲)#

  • 先 spike 再下注:Qdrant 的本地模式到底支不支持”稀疏向量 + 融合 + IDF”是有不确定性的。我没靠猜,先写了 30 行最小验证脚本跑通,把抽象争论变成既成事实,才敢动手重构。spike 还顺带抓到一个真坑——带过滤的融合查询,过滤条件必须放进每条子查询里,放在最外层会静默失效(平台过滤直接漏数据)。这种坑不 spike 永远不知道。
  • 限流按真实约束设计:embedding API 的限制是每分钟 50 万 token、2000 次请求。我算过:批量编码下请求数远不到上限,token 才是活约束。所以做了个滑动窗口节流,逼近阈值就等。全量 4427 件重建实测 ~73 秒,和预估的 1–1.5 分钟吻合。
  • 离线默认不破:Qdrant 本地模式(无 docker)给开发和 CI 用,真 server 走环境变量给生产用——和项目里 OpenSearch、Redis 一样的”双后端”套路。测试全程零 docker。

面试怎么讲:三件事体现工程习惯——下注前先用最小脚本把不确定性验掉(还顺手抓到了过滤静默失效的坑);限流要对着真实约束设计,我算清楚了是 token 而非请求数是瓶颈;以及守住”默认能离线跑”这条贯穿全项目的原则。


10. 交付了什么、边界在哪#

交付:召回层从 Faiss 纯向量升级为 Qdrant 的 dense+BM25 hybrid;数据清洗抽成独立可复现前置步;5 平台 4427 件商品用真 BGE-M3(1024 维)重建入库;真实检索冒烟通过——包括中文 query 跨语言召回 SHEIN 商品、平台过滤生效。Faiss 召回层退役,自动门(格式/类型/120 项测试)全绿。

诚实的边界:

  • Qdrant 的 BM25 是”BM25-lite”(IDF 加权,但缺 k1 饱和和长度归一)。够当字面补充,要逼近真 BM25 得把这俩烘进存储,留作后续。
  • 融合权重还没法精调:现在用 Qdrant 的分布式融合(DBSF),没有显式 α/β。要调”更看重像还是更看重字面”,得有召回的标准答案(对应待办的 ESCI 数据集),没金标盲调没意义。
  • 真 BM25 的丰富分析器(词干/同义词)是 OpenSearch 的强项,不是 Qdrant 的——这也是为什么知识库那层我留在 OpenSearch,没强行统一。
  • CJK 词法是字符 bigram,不是真分词:中文/日文连写无空格,\w+ 会把整串糊成一个 token、 让词法通路对中文形同虚设。我用「CJK 段切字符 bigram」兜底(建索引↔serve 同一函数,两侧一致), 中文 query 因此能走词法通路;但这不是真正的中文分词(没有词典/未登录词处理),够当 exact-match 补充,要更准得上 jieba 之类。拉丁/西里尔等空格语言不受影响。
  • 本地 Qdrant 是单进程独占:on-disk 本地模式带文件锁,且 item_searchasyncio.to_thread 并发跑(跨平台并行 fork 会同时打召回)。dev/CI 单进程顺序跑没问题,但要并发/生产请配 QDRANT_URL 走真 server(原生并发安全)。重建索引时也需先停掉占着本地库的 serve 进程。
  • 建/serve 编码一致性靠纪律,未做 serve 启动校验:qdrant_manifest.json 记了 model/dim,但 serve 端暂未读它做校验。已给 build_item_index.py--require-remote + 维度门禁(与 build_category_kb 同款),挡住「静默用本地假向量建库」这个最常见坑;serve 端读 manifest 比对 留作后续。

面试怎么讲:我交付的是”架构和链路搭对、换上真模型即提质”的状态。但我会主动讲清三条边界:BM25 是简化版、融合权重等有金标再调、丰富词法分析留给 OpenSearch 那层。诚实标注边界,比假装做完了更重要。