召回引擎选型思路 —— 多语言电商搜索 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 知识库。
据此,真正决定召回好坏的是三条轴(按对本项目的权重):
- 多语言相关性 —— 第一驱动。中文 query 召回 SHEIN/Shopee 商品是核心场景。
- 电商结构化信号 —— 价格/平台/库存/属性过滤、字段加权(标题 ≫ 描述)、精确 ID。
- 规模 —— 分档对待,不是越大越好的设计目标(见 §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,手搓 tf | Qdrant md5-tf(M3.5 现状) | 管道齐、单引擎;权重弱、无分析器 |
| ③ 学习式稀疏 | 模型学每个 term 权重 + 词项扩展 | SPLADE / ELSER / BGE-M3-sparse | 字面可解释 + 语义扩展,打分路线最优 |
学习式稀疏(BGE-M3 sparse)是这条路线的最优解(语言无关、学习权重、和 dense 同模型对齐)。但它有两个
现实阻塞:① 当前 SiliconFlow /embeddings 实测只回 dense(官方文档确认 encoding_format 仅
float/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 |
| pgvector | Postgres 扩展 | ✗ 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,生态偏一体化。次选,非首选。
排除完,千万档真正的较量只在 Qdrant 与 ES/OpenSearch 之间——一个是「向量原生 hybrid」代表, 一个是「搜索引擎 hybrid」代表。这也正好对应本项目要不要把 items 也并进已有的 OpenSearch。
5.2 两候选加权决策#
候选收敛成两个:
- A. 单 OpenSearch(items + KB 全包)
- B. Qdrant(items)+ OpenSearch(KB)
按对本项目加权的判据评:
| 判据 | 权重 | A 单 OpenSearch | B 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:
- 团队明确要「一个引擎全包、不养两套」,且接受把 items 也搬进 OpenSearch(dense kNN + filter);
- 产品转向重 faceting/聚合的货架式电商(而非对话式 Agent);
- 出现「items 也要吃 OpenSearch 词干/同义词分析器」的硬需求(即重新走打分路线 ① 档)。
目前三条都不成立,故维持 Qdrant。
6. 精排 + 召回形态#
- 精排:BGE-reranker-v2-m3 cross-encoder,对粗召回 top-N 精排。多语言最准一档,保留——sparse 砍掉后召回是单路 dense,精排地位反而上升(由它把序排准)。已在 M3.6 接入商品路径。
- 召回形态:过滤优先漏斗(无 sparse,无融合)——
过滤优先:电商 query 几乎总带约束(planner 抽出的硬约束/排除词),永远只在过滤后的小切片上做 dense 召回——这既兜住 exact-match(§4),又是扩展性杠杆。千万档可省掉 ColBERT 中段(dense+精排够),亿级再加。
plaintextStage0 结构化/full-text 过滤 平台/价格/排除词/MatchText → 大幅收窄 ← exact-match 与扩展性都靠它 Stage1 dense 粗召回 BGE-M3(规模档 int8) → top-300~500 Stage2 cross-encoder 精排 → top-k
7. 冻结的选型清单#
| 层 | 选定 | 一句话理由 |
|---|---|---|
| Item 召回引擎 | Qdrant(单节点,千万级) | dense + 原生 filter,filtered-ANN 主场 |
| Dense | BGE-M3 1024d(规模档 int8 量化) | 跨语言语义;量化后 ~10 GB 装内存 |
| 词法 / 精确命中 | Qdrant 结构化/full-text filter | exact-match 与硬约束是 filter 问题,不是打分(§4) |
| 砍掉(降为可选/远期) | 软打分对 exact-match 用错工具;端点 + 金标解锁后再议 | |
| 精排 | BGE-reranker-v2-m3 | 粗召回 top-N 精排,多语言最准(M3.6 已接) |
| 召回形态 | 过滤优先漏斗 | 过滤 → dense 粗召回 → cross-encoder |
| 品类 KB | OpenSearch(不动) | 小语料吃丰富分析器 |
| 长期记忆 | Redis / 本地 JSON(不动) | 与向量栈解耦 |
| 编码器不变量 | 两处共用一个 BGE-M3 | item 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)。按「收益大、改动小」排序:
- ✅ 已完成(M3.6):cross-encoder 精排接入商品路径——sparse 砍掉后由它扛排序。
- 去 sparse,落地 dense + filter 召回:删
qdrant_store/text.py/build_item_index的稀疏向量与 分词、item_search改单路 dense + filter 入参脚手架(MatchText/must_not/range)→ 重建索引。 改动局部、立刻简化召回。 - planner 硬约束 → Qdrant filter 端到端:把
planner抽出的硬约束/排除词/价格接成item_search的 结构化/full-text filter(must/must_not/range)。这一步真正兑现 §4 的「exact-match 走过滤」。 - 规模档再做:dense int8 量化 + 分片 + ColBERT 中段——千万级以下不需,留到真上量级。
能这样讲出口:这条迁移路径记录了一次诚实的设计修正——先按 hybrid 加了 sparse,跑下来想通「精确命中 是 filter 不是打分」,于是砍掉打分腿、把精确命中交给过滤,反而更简单更对症。精排已先行(M3.6),接着去 sparse、再把 planner 的硬约束接成 filter,规模化的量化/分片留到真到量级。每步独立验收,不一次性掀桌子。