面试知识库

M3.7 · 召回层去 sparse,改 dense + filter + 精排 —— 开发文档(面试向)#

落地 选型修订 的第 1 步:把 M3.5 加的 sparse 打分腿删掉。讲为什么、验证了什么,不讲代码。

一句话概括#

选型文档想通了「exact-match 是 filter 问题,不是打分问题」(§4 的 scoring vs filtering), 据此砍掉召回层的 sparse BM25 打分腿:召回从「dense + 稀疏 + 融合」简化成「dense + payload filter + cross-encoder 精排」,并重建索引(collection 去掉稀疏向量)。

1. 为什么删:打分腿做的不是它该做的事#

M3.5 按 hybrid 思路给词法加了 sparse(裸 md5-tf),本意是救回 dense 糊掉的精确命中(品牌/型号)。 但再想一层:精确命中要的是「有没有」,不是「软加几分」。BM25/sparse 是软打分——命中加分、不命中不 排除,查「不要塑料」它甚至表达不了「排除」。这本质是 filter 的活,交给软打分是用错了工具。 而 filter 又正好咬合 planner 已经抽出的硬约束/排除词。所以最干净的做法不是把 sparse 升级得更好, 而是整条删掉,exact-match 改走 Qdrant filter。(完整推演见选型文档 §4。)

2. 删完反而更简单——还甩掉一个坑#

M3.5 为了 hybrid 融合,踩过一个坑:带过滤的融合查询,filter 必须塞进每条 prefetch,放顶层 query_filter 会静默失效。现在没有融合、只有单路 dense 查询,顶层 query_filter 是正路、不再有这个坑。 少一条通路 = 少一份分词一致性维护、少一套稀疏编码、少一个融合权重要调、少一个过滤静默失效的雷。

随之退役的还有 M3.5 那套 CJK 字符 bigram——它当初是给 sparse 词法兜中文的,sparse 没了它也就没用了。 有点 churn(刚加又删),但这正是诚实迭代的代价:先按 hybrid 做、跑下来想通、再砍掉,比一开始就装作想清楚要诚实。

3. 验证:跨语言召回靠 dense 照样成立#

最该确认的是「删了 sparse,中文 query 还能不能召回」。真索引(真 BGE-M3,4427 件,dense-only)端到端实测:

  • 英文 running shoes + amazon 过滤 → adidas/Skechers 跑鞋 ✓
  • 中文 旅行收纳包 + shein 过滤 → travel/storage 包 ✓(跨语言:中文 query 打英文目录)
  • 中文 便宜抗造的旅行三件套 → shein 收纳套 + walmart「3 Piece」床品 ✓

结论:跨语言召回本就由 BGE-M3 dense(多语言模型)扛着,sparse 那条腿可有可无。 这反过来印证了删它的决定—— 它没在承担不可替代的活。平台过滤也照常生效(shein query 全返 shein)。

4. 交付与边界#

交付:删 text.py 分词/稀疏项、qdrant_store.py 稀疏向量与融合(search 改单路 dense + query_filter)、 build_item_index 的 manifest fusion 字段、item_search 改单路 dense;重建 dense-only 索引;清理对应测试。 自动门(ruff/format/mypy/pytest 119)全绿;真索引端到端三 query 验证(含跨语言 + 平台过滤)。

边界 / 下一步:

  • filter 当前仅平台一维 已落地item_search 现在支持 price_usd_max(预算上限)、min_rating(最低评分)、brand_exclude(排除品牌)三个参数,在 Qdrant 召回阶段直接走 payload filter(Range / MatchValue),brand_exclude 做召回后的后置过滤(品牌名大小写不敏感)。planner 拆出的结构化字段(预算、评分、排除品牌)能直接传给 item_search,从「只靠 dense」升级到「dense + 多维 filter」。建库时预折算 price_usdschemas.py / build_item_index.py),使得价格 filter 能在 Qdrant 层原生命中。
  • sparse 作为第三路补充召回的可选项仍留在选型文档 §8——端点 + ESCI 金标解锁、且证明 filter 召回不足时再议,当前不做。