面试知识库

召回引擎选型思路 —— 多语言电商搜索 Agent#

定位:讲「为什么这么选」,能在面试/评审里张嘴复述的工程叙事,不是 API 文档。 配合 recall-hybrid-embedding-plan.md(落地 spec)与 ../milestones/M3.5-召回层Qdrant-hybrid升级与数据清洗.md(M3.5 实际做的事)看。 本文覆盖到 千万级 商品档;亿级以上属再选型范畴,见 §8。

修订记录: 初版主线是「dense + 学习式 sparse 的 hybrid」;经 §4 的「scoring vs filtering」转念后 去掉 sparse 打分腿,改为 dense + 结构化/full-text filter + 精排。本文保留这条演进的完整理由 (不抹掉走过的弯路),§4 是修订核心。

一句话结论#

Item 召回用 Qdrant(BGE-M3 dense + 结构化/full-text filter + cross-encoder 精排,去掉 sparse 打分腿), int8 量化留给规模档;品类 KB 留 OpenSearch;两栈共用一个 BGE-M3。 引擎拓扑(Qdrant 召回 + OpenSearch KB) 本来就选对了——真正修正的是两点:(1) 词法这条腿,从「加 sparse 打分」想通成「exact-match 本是 filter 问题」, 直接砍掉打分腿用过滤;(2)「为规模」这个被夸大的论据,降为分档触发的次要判据。


1. 选型前先问对问题:什么在驱动本项目的召回?#

选型不是抽象比「谁更强」,是先认清这个项目是什么:一个对话式购物 Agent,跨平台、多语言 (中文 query 打英文为主、两成非英文的混合目录)、精确命中重要(品牌/型号/SKU)、已经有 cross-encoder 精排和 OpenSearch RAG 知识库。

据此,真正决定召回好坏的是三条轴(按对本项目的权重):

  1. 多语言相关性 —— 第一驱动。中文 query 召回 SHEIN/Shopee 商品是核心场景。
  2. 电商结构化信号 —— 价格/平台/库存/属性过滤、字段加权(标题 ≫ 描述)、精确 ID。
  3. 规模 —— 分档对待,不是越大越好的设计目标(见 §2)。

能这样讲出口:我没上来就比引擎参数,而是先问「这个项目的召回到底被什么卡住」。答案是多语言 和精确命中,不是吞吐——选型要对着真约束走,不被「听起来很大」带偏。


2. 先拆穿「规模焦虑」:规模是分档的,不是主驱动#

最容易把选型带偏的就是「真实电商数据很庞大」这句话。它对,但庞大是分档的,不同档触发完全不同的方案:

档位商品量向量(BGE-M3 1024d, int8)方案触发的复杂度
现状~4400~5 MB任何引擎单机随便跑
目标千万级~10 GB,装单节点内存单节点 + int8 量化量化开关而已,不需分布式
再上一档亿级以上数百 GB分布式 + 分片 + 多副本才轮到 Vespa/Milvus/重分片

千万级根本不触界:1000 万 dense 向量 int8 量化后约 10 GB,一台 32 GB 内存的节点全装进内存; 词法倒排在千万级更是毫无压力。所以千万档不需要 Vespa、不需要 Milvus、不需要分布式——那是杀鸡用牛刀。

成本也由 QPS 主导、不由商品量主导(千万级容量/成本测算见 plan 文档)。一次性建库编码几十美元; 稳态服务一个节点几百美元/月;真正的变量成本是 cross-encoder 精排,只跟 QPS × 精排深度走。 省钱的杠杆是「精排前把候选收窄」,不是在向量存储上抠。

教训(诚实标注):M3 当初 Faiss→Qdrant 的核心论据是「面向规模」,但在千万这个真实电商量级下 也站不住——真要逼引擎得到亿级以上。选型最大的陷阱就是这种「规模幻觉」:为一个大概率摸不到的量级 预支架构成本。本文据此把规模降为分档触发的次要判据,而非主驱动。


3. 向量召回:无争议,直接定 BGE-M3 dense#

向量这条腿是主流成熟实现,没什么可纠结的:BGE-M3 dense,跨语言语义召回,现成模型(无 GPU,API/CPU 推理),规模上用 int8 量化扩展。保留三塔「query/user 双通道」的个性化思想,编码器换现成模型。 这条直接定,不展开。


4. 关键字召回:从「加 sparse 打分」到「其实是 filter 问题」(修订核心)#

本节是全文最重要的修订。 M3.5 先按 hybrid 思路给词法加了 sparse 打分(裸 md5-tf),也一度准备 升级成学习式稀疏。但再想一层发现:本项目的词法需求本质是 filter,不是 scoring——故砍掉 sparse 打分腿,改用 dense + 结构化/full-text filter。下面把这条弯路和转念都讲清楚。

4.1 先说「打分」路线:三档实现(留作背景与对照)#

如果坚持给词法做打分(scoring),业界有三档:

做法代表强弱
① 经典词法真 BM25 + 分析器(词干/停用词/同义词/语言分词)ES/OpenSearch/Solr字面金标准;但分析器按语言配
② 向量库原生稀疏稀疏向量 + IDF,手搓 tfQdrant md5-tf(M3.5 现状)管道齐、单引擎;权重弱、无分析器
③ 学习式稀疏模型学每个 term 权重 + 词项扩展SPLADE / ELSER / BGE-M3-sparse字面可解释 + 语义扩展,打分路线最优

学习式稀疏(BGE-M3 sparse)是这条路线的最优解(语言无关、学习权重、和 dense 同模型对齐)。但它有两个 现实阻塞:① 当前 SiliconFlow /embeddings 实测只回 dense(官方文档确认 encoding_formatfloat/base64,传 sparse 直接 400),要拿 lexical_weights 得自托管 TEI/Infinity 或本地 FlagEmbedding; ② 没有召回金标(ESCI)没法证明它比裸 dense 强多少。先记下这两条,后面会发现根本不必走这条路。

4.2 转念:exact-match 是 filter,不是 scoring#

我们给词法腿立的 KPI 是「救回 dense 糊掉的精确命中(品牌/型号/SKU/硬约束)」。再追问一句——精确命中 要的是「有没有」,不是「软加几分」。而 BM25/sparse 是软打分:命中加分、不命中不排除,查「不要塑料」 它甚至表达不了「排除」。这是用错了工具:

sparse / BM25(软打分)Qdrant keyword / full-text filter(硬过滤)
性质命中加分,杂货照进候选命中才留 / must_not 直接踢掉
「型号 Kinvara 13」也只是给分,共享常见词的货照样进MatchText 必须含才留,干净利落
「不要塑料」表达不了「排除」must_not 正中下怀

对 exact-match 与硬约束,filter 比打分对症。 sparse 那条软打分腿,做的恰恰不是它该做的事。

4.3 而且 filter 正好咬合 Agent 的脑子#

planner 本就把意图拆成 硬约束 / 软偏好 / 检索关键词 / 排除关键词——这套划分天生为 filter 准备: 硬约束 → must filter;排除词 → must_not;软偏好 → dense 通路 + item_picker 加分;检索意图 → dense 语义。 硬的交给 filter、软的交给 dense,一旦硬约束走了 filter,sparse 打分腿要承担的活就所剩无几了。

4.4 决定 + 诚实边界#

决定:去掉 sparse 打分腿。召回 = dense(语义)+ Qdrant 结构化/full-text filter(精确/硬约束)+ cross-encoder 精排。 全程在 Qdrant 单引擎单请求内(filter 就是 query 的一部分),不破坏选型、反而更简单。

诚实边界(别把话说满):

  • filter 是硬切,脆:MatchText("Kinvara 13") 遇拼写/格式不一致会直接零结果。所以它该是 Agent 按 planner 抽出的硬约束主动施加,不是对所有 query 无脑套;软的部分仍由 dense 兜底,不会一刀切空。
  • 数据现实卡脖子:爬来的数据没有干净的 brand/型号/SKU 字段(brand 有时是卖家名)。短期能 filter 的 现实是 title/描述的 MatchText + 平台 + 排除词 + 价格 range;品牌/型号精确字段要等数据补齐。
  • Qdrant full-text 仍是 token 匹配,CJK 分词同样有限——但作为过滤,「含/不含」语义比打分清楚,不会「糊」。
  • sparse 升级降为可选,不是错路:它对本项目非必需,而非「做不了」。将来若 full-text filter 被证明不够 (有 ESCI 金标能量化)、且 sparse 端点解锁,可作为补充召回加回来。当前明确不做。

5. 引擎选型:唯一真正要拍板的决策#

模型层、精排、量化、漏斗都已定,「选型」其实只剩 item 召回用哪个引擎。先把主流候选摆全(§5.1), 按本项目逐个排除,收敛到两个候选再加权决出(§5.2)。

5.1 主流向量数据库横向对比#

维度按本项目的选型判据排:原生 hybrid(dense+稀疏)、带过滤 ANN、词法/结构化成熟度、规模档、 部署与离线模式。✅ 强 / ◯ 够用 / ✗ 弱或无。

引擎类型 / 部署原生 hybrid
(dense+稀疏)
带过滤 ANN词法分析器 /
结构化 faceting
规模档离线/本地模式对本项目
Qdrant向量库 / Rust,单机+分布式✅ dense+稀疏+ColBERT,
一次融合(RRF/DBSF)
✅ filtered-HNSW 主场✗ 无真分析器
(payload 过滤够用)
十亿级(分片+量化)✅ 纯 Python 本地模式✅ 选中(item)
Elasticsearch /
OpenSearch
搜索引擎 / JVM 集群◯ BM25+kNN+学习式稀疏
(ELSER/neural-sparse)+RRF
◯ 支持 pre-filter分析器/faceting 金标准数亿级
(词法更高)
◯ 单节点 docker✅ 选中(KB)
Vespa搜索引擎 / Java+C++,重运维✅ BM25+向量+张量后期交互
+原生多级 ranking
✅ 强真 BM25+结构化属性十亿+高 QPS,
真大盘验证
✗ 部署重亿级以上技术最优,
千万档杀鸡用牛刀
Milvus向量库 / 重架构
(etcd+消息队列+对象存储)
◯ dense+稀疏,2.5 加 BM25 函数◯ 支持✗ 弱(无 Lucene 分析器)十亿~百亿,规模王◯ milvus-lite千万档过重,
亿级再考虑
Weaviate向量库 / Go◯ 内建 BM25F hybrid
(alpha 加权)
◯ 支持◯ 自带 BM25,无丰富分析器十亿级◯ docker可选,hybrid 灵活度
不如 Qdrant
Pinecone托管 SaaS(只云)◯ dense+稀疏(SPLADE/BM25)◯ 支持✗ 无大规模托管无自托管❌ 违背离线默认
+ vendor lock-in
pgvectorPostgres 扩展✗ dense only
(hybrid 靠 PG 全文应用层拼)
◯ 一般◯ PG 全文(弱于 Lucene)数百万舒适✅ 随 PG已有 PG 才划算,
无原生 hybrid
Chroma / Faiss嵌入式库 / ANN 库✗ 无(Faiss 纯 dense ANN)✗ 需自写✗ 无原型 / 单机内存✅ 进程内仅原型;Faiss 即 M3 退役那套

按本项目逐个排除:

  • Pinecone —— 只云、不可自托管,违背项目「离线默认」原则,且 vendor lock-in。出局。
  • Vespa / Milvus —— 技术能打,但都是亿级以上才回本的重器;千万档用它们是杀鸡用牛刀,运维成本 预支给一个大概率摸不到的量级(同 §2 的规模幻觉)。留作亿级演进选项,本档出局。
  • pgvector / Chroma / Faiss —— 无原生 hybrid(都得应用层手拼词法那条腿),正是项目要摆脱的形态。 不够格,出局。
  • Weaviate —— 够格,内建 hybrid;但 hybrid 灵活度与 filtered-ANN 不如 Qdrant,生态偏一体化。次选,非首选。

排除完,千万档真正的较量只在 QdrantES/OpenSearch 之间——一个是「向量原生 hybrid」代表, 一个是「搜索引擎 hybrid」代表。这也正好对应本项目要不要把 items 也并进已有的 OpenSearch。

5.2 两候选加权决策#

候选收敛成两个:

  • A. 单 OpenSearch(items + KB 全包)
  • B. Qdrant(items)+ OpenSearch(KB)

对本项目加权的判据评:

判据权重A 单 OpenSearchB Qdrant + OpenSearch
多语言 dense 召回 + filter,集成摩擦(BGE-M3 原生)dense kNN + filter 可行,但 BGE-M3 向量要自接入dense + 原生 filter,一次查询最顺
带过滤的向量检索(filter-first 扩展性杠杆)Lucene kNN 够用filtered-HNSW 是主场
运维收敛(一个引擎)一个引擎全包两个引擎
电商 faceting/结构化成熟度低–中(Agent 在工具里筛,非货架 UI)一般
千万级扛得住必须

两条高权重判据都指向 B:OpenSearch 赢在「收敛」和「faceting」,但前者在千万级双引擎也不痛,后者 对一个「用工具做筛选、不是货架式 UI」的 Agent 权重本就低。

决策#

Item 召回 = Qdrant;品类 KB = OpenSearch(不动);两栈共用同一个 BGE-M3;长期记忆独立(Redis/JSON)。

这个结论的含义:现有「Qdrant 召回 + OpenSearch KB」的引擎拓扑本来就对。之前错的不是引擎,是 (1) 词法这条腿——先做成裸 md5-tf 打分,后想通「exact-match 是 filter 问题」直接砍掉打分腿(§4), (2) 把「为规模」当主论据。所以选型不是推倒重来,是坐实拓扑 + 词法改 filter + 量化留给规模档

§4 的转念反而强化了 Qdrant:词法从「sparse 打分」变成「filter」后,filtered-HNSW(带过滤的向量 检索)直接成为召回的核心能力——而这正是 Qdrant 的主场。原来靠「dense+sparse 原生融合」赢 OpenSearch, 现在靠「dense + 原生 filter」赢得更干脆。

诚实代价 + 翻盘条件#

代价:维持两个引擎,比单 OpenSearch 多一份运维。千万级判断值得——换来最顺的多语言 dense + 原生 filter 召回和最强 filtered-ANN。

满足任一即翻盘投单 OpenSearch:

  1. 团队明确要「一个引擎全包、不养两套」,且接受把 items 也搬进 OpenSearch(dense kNN + filter);
  2. 产品转向重 faceting/聚合的货架式电商(而非对话式 Agent);
  3. 出现「items 也要吃 OpenSearch 词干/同义词分析器」的硬需求(即重新走打分路线 ① 档)。

目前三条都不成立,故维持 Qdrant。


6. 精排 + 召回形态#

  • 精排:BGE-reranker-v2-m3 cross-encoder,对粗召回 top-N 精排。多语言最准一档,保留——sparse 砍掉后召回是单路 dense,精排地位反而上升(由它把序排准)。已在 M3.6 接入商品路径。
  • 召回形态:过滤优先漏斗(无 sparse,无融合)——
    Stage0 结构化/full-text 过滤  平台/价格/排除词/MatchText  → 大幅收窄   ← exact-match 与扩展性都靠它
    Stage1 dense 粗召回           BGE-M3(规模档 int8)         → top-300~500
    Stage2 cross-encoder 精排                                  → top-k
    plaintext
    过滤优先:电商 query 几乎总带约束(planner 抽出的硬约束/排除词),永远只在过滤后的小切片上做 dense 召回——这既兜住 exact-match(§4),又是扩展性杠杆。千万档可省掉 ColBERT 中段(dense+精排够),亿级再加。

7. 冻结的选型清单#

选定一句话理由
Item 召回引擎Qdrant(单节点,千万级)dense + 原生 filter,filtered-ANN 主场
DenseBGE-M3 1024d(规模档 int8 量化)跨语言语义;量化后 ~10 GB 装内存
词法 / 精确命中Qdrant 结构化/full-text filterexact-match 与硬约束是 filter 问题,不是打分(§4)
sparse 打分腿砍掉(降为可选/远期)软打分对 exact-match 用错工具;端点 + 金标解锁后再议
精排BGE-reranker-v2-m3粗召回 top-N 精排,多语言最准(M3.6 已接)
召回形态过滤优先漏斗过滤 → dense 粗召回 → cross-encoder
品类 KBOpenSearch(不动)小语料吃丰富分析器
长期记忆Redis / 本地 JSON(不动)与向量栈解耦
编码器不变量两处共用一个 BGE-M3item dense 与 KB 同一编码器,向量空间不漂移

8. 诚实的边界#

  • 行为信号 LTR 是真大盘最优排序的核心,但在本项目范围外。 真实电商的最终排序主要由点击/转化/毛利 等业务特征的学习排序(LTR)驱动,语义+字面只负责把「相关的」捞进候选。这要数据 + 训练,正是项目 「无 GPU、不训练」砍掉的腿。这是「真大盘最优」与「本项目约束」绕不开的分界,选型层面诚实认下。
  • exact-match 受数据字段限制(§4.4):没有干净 brand/型号/SKU 字段,filter 短期只能打在 title/描述 MatchText + 平台 + 排除词 + 价格上;精确 ID 字段要等数据补齐才能发挥。
  • 没有融合权重要调了:sparse 砍掉后召回是单路 dense,不存在 dense+sparse 的 α/β 融合调参。要靠召回 金标(ESCI)调的变成了 filter 阈值 / 精排池倍数 RERANK_POOL_MULT——同样没 ground truth 别盲调。
  • sparse 作为补充召回的可选项:若将来 full-text filter 被证明召回不足(漏掉 dense+filter 都没捞到的相关货)、 且 sparse 端点解锁,可把 BGE-M3 学习式 sparse 作为第三路召回加回来——但要金标证明它真带来增量,当前不做。
  • 本文结论只覆盖到千万档:亿级以上要重新选型(上 Vespa/分布式),届时 §2 的档位触发线给信号。

9. 现状 → 目标 迁移路径#

现状(M3.6 后):Qdrant(md5-tf 稀疏 + BGE-M3 dense,hybrid 融合)+ OpenSearch KB;商品路径已接 cross-encoder 精排;无量化;filter 仅平台一维。

目标:见 §7 清单(dense + filter + 精排,去 sparse)。按「收益大、改动小」排序:

  1. 已完成(M3.6):cross-encoder 精排接入商品路径——sparse 砍掉后由它扛排序。
  2. 去 sparse,落地 dense + filter 召回:删 qdrant_store/text.py/build_item_index 的稀疏向量与 分词、item_search 改单路 dense + filter 入参脚手架(MatchText/must_not/range)→ 重建索引。 改动局部、立刻简化召回。
  3. planner 硬约束 → Qdrant filter 端到端:把 planner 抽出的硬约束/排除词/价格接成 item_search 的 结构化/full-text filter(must/must_not/range)。这一步真正兑现 §4 的「exact-match 走过滤」。
  4. 规模档再做:dense int8 量化 + 分片 + ColBERT 中段——千万级以下不需,留到真上量级。

能这样讲出口:这条迁移路径记录了一次诚实的设计修正——先按 hybrid 加了 sparse,跑下来想通「精确命中 是 filter 不是打分」,于是砍掉打分腿、把精确命中交给过滤,反而更简单更对症。精排已先行(M3.6),接着去 sparse、再把 planner 的硬约束接成 filter,规模化的量化/分片留到真到量级。每步独立验收,不一次性掀桌子。