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_usd(schemas.py/build_item_index.py),使得价格 filter 能在 Qdrant 层原生命中。- sparse 作为第三路补充召回的可选项仍留在选型文档 §8——端点 + ESCI 金标解锁、且证明 filter 召回不足时再议,当前不做。