4. 检索管道:召回、精排、精挑#
一句话结论:一条 query 走三段——
item_search只做 dense 召回 + 确定性过滤,合流后item_picker用 cross-encoder 判品类、加权排序、最多出 3 件;全链只有 planner 一次 LLM 调用,其余全是可复现的计算。
速背卡#
| # | hint(≤15 字) | 展开一句(≤40 字) |
|---|---|---|
| 1 | 三段:召回 → 精排 → 精挑 | 召回只挂 dense,精排(cross-encoder)只在 picker 里跑一次,精挑加权封顶 3 件 |
| 2 | 精确需求当 filter,不当分 | 平台/价/评分走 Qdrant payload filter,品牌/排除词/型号走召回后确定性过滤 |
| 3 | 两级缓存:L1 进程 + L2 Redis | 缓存「编码 + Qdrant 召回」这两步,不缓存 item_search 产出 |
| 4 | 空结果 60s,正常 900s | 空要挡穿透但一扩库就不成立(search_cache._empty_ttl) |
| 5 | 断路器只对远程依赖计数 | 本地/内存 Qdrant 不挂断路器,否则把配置错误压成「服务不可用」 |
| 6 | 错误分级 = 告诉模型别重试 | DependencyDown → metadata code=dependency_down → adapter 贴「别重试」 |
| 7 | picker 七阶段,三分口径 | 归一→硬过滤→品类门→语义→打分→选品→上报;分数只判别不排序 |
30 秒版(面试开场)#
检索我拆成三段:召回、精排、精挑。召回是 item_search,BGE-M3 编码 + Qdrant dense 近邻 + payload filter,不做 BM25 混合;精排只有一处,在合流后的 item_picker 里用 cross-encoder 判「品类对不对」;精挑是确定性加权求和,最多出 3 件。工程上后来补了三件:两级检索缓存(L1 进程内 + L2 Redis)、Qdrant/OpenSearch 各一个断路器、依赖挂了给模型打「别重试」的错误分级。
3 分钟版#
- planner 拆意图(唯一一次 LLM):品类、预算、三个词桶(must/prefer/exclude)写进会话态 P_t。
item_search召回:query 文本拼上本轮域内的 like 词 → 编码 → Qdrant dense +platform/price_usd/ratingfilter。- 召回后确定性过滤(
_apply_filters):相关度下限RELEVANCE_FLOOR=0.45→ 品牌排除 → 记忆硬排除 →target_name型号。 - 不够就工具自己补:命中 < 3 且带
min_rating就去掉评分重搜一次;带硬过滤仍不足就打一次不带过滤的探测召回(PROBE_LIMIT=8),差集作filtered_out回给模型,分清「没货」和「被预算挡了」。 - 缓存包住第 2 步:
cached_recall缓存编码 + 召回结果;key = 索引版本 + 词 + top_k + 平台 + 价/评分过滤(search_cache.make_key)。 - harness 自动精挑:
item_search成功后武装,下一次模型调用前自动跑price_compare+item_picker,结果以 hint 注入。 item_picker七阶段:归一 → 硬过滤 → 品类门(cross-encoder,每批封顶PICK_RERANK_K=15)→ 语义三路 → 加权求和 → 选品(普通轮封顶 3 + 展示相对门;槽位轮走跨槽组合)→ 上报。
它解决什么问题#
1)精确约束和语义相关度混在一起调不动
- 坏法:把预算、评分、型号一起塞进打分公式。权重一改就全盘漂移,且「超预算但分高」还会出现在清单里,看不出是哪一项造成的。
- 修法:精确需求一律当 filter——Qdrant
Range/MatchAny(qdrant_store.py:search),或召回后的确定性过滤(item_search.py:_apply_filters)。 - 代价:冷门型号只能靠
target_name的字符串匹配,配件标题也含宿主型号,得再加一道品类名余弦门。
2)「库里没货」和「有货但被挡了」返回体一模一样
- 坏法:不分,模型直接对用户说「没找到」,用户放宽预算就能买到的货被藏了。
- 修法:带硬过滤且命中不足时,用同一个请求向量不带过滤再查 8 条,差集按品牌 → 排除词 → 评分 → 价格判原因(
_blocked_reason、_probe_filtered_out)。 - 代价:多一次 Qdrant 查询。
3)同一个词每轮都要编码一次 + 打一次 Qdrant
- 坏法:不缓存。同轮 batch 跨平台搜同一个词必然重复,embedding 那一跳是链路上更贵的一段。
- 修法:
app/recall/search_cache.py两级缓存,回源闭包由调用方传入,编码在缓存边界之内;item_search 那边做成惰性,三次召回(主/放宽/探测)真回源时只编码一次(commit e64038d)。 - 代价:缓存只到召回结果这一层——P_t 硬排除、记忆、槽位盖章每轮都可能变,连它们一起缓存就会「用户刚说不要塑料,下一轮又原样端回来」,那是把已生效的约束静默回滚。
4)依赖挂了,每条 batch 各等满自己的超时
- 坏法:超时值钉死(Qdrant 5s、OpenSearch 10s),同轮 3~5 条
item_search并发时各等各的。 - 修法:各挂一个断路器(commit 191b817)。Qdrant 阈值 3 次 / 恢复 30s(
_remote_breaker),熔断后直接抛DependencyDown;OpenSearch 不改降级语义,照旧静默返回空,断路器只省那 10 秒。 - 代价:只有「按 env 自己连的远程 server」才挂——本地/内存模式没有网络往返,熔断会把「collection 没建」这类配置错误压成一句「服务不可用」。
5)模型把「Qdrant 连不上」当成「词没搜对」
- 坏法:工具壳把任何异常压成同一种
[error] ...文本,模型换个检索词重试三次,把三次超时叠成一次超长等待,最后照样收不了尾。 - 修法:
app/utils/dependency.py的DependencyDown+ERROR_CODE_KEY/DEPENDENCY_DOWN_CODE两个共用常量,_shell写 metadata、adapter的 ERROR 分支读并贴「别重试,直接如实告知用户,可走chat_fallback」(commit d40b205)。 - 代价:分级范围要克制——只包主检索通路(Qdrant 客户端调用那几行、query embedding);payload →
RecallCandidate的构造失败是该修的 bug,翻成「依赖挂了」会把它藏起来。
6)picker 一个 420 行函数,改一处不知道碰了谁
- 坏法:继续往里加分支。副作用顺序(回写登记表、上报、诊断侧信道)看不清,改错了测试也未必红在原地。
- 修法:按七个阶段拆具名函数,阶段间用 NamedTuple(
_PickInputs/_Filtered/_Relevance/_Semantic/_Scored)传值,行为不变、测试零改动(commit 97bb384)。 - 代价:阶段函数必须留在同一文件——tests 与后台参数热更新都对本模块属性
setattr,搬走就读不到被改的值。
机制怎么跑#
- planner 用快档 LLM 出结构化 P_t:
category/keywords/must_have/prefer_keywords/exclude_terms/budget_usd/bundle_slots(app/tools/planner.py)。 - 同轮 batch 发检索:跨平台或多槽位由主环同轮多发
item_search(platform=… / slot=…),框架按is_concurrency_safe=True并发(commit ff023f4)。 item_search定池大小:单平台SINGLE_PLATFORM_POOL_K=30,多平台min(top_k, MAX_TOP_K)(item_search.py:_load_params)。- 召回走缓存:
_recall()把「编码 +QdrantRecall.search」包成闭包交给cached_recall(item_search.py:398-426);L1 LRU → L2 Redis → singleflight(进程内共用 Future,跨进程SET NX抢锁,没抢到最多轮询 0.5s 就自己回源)。 - 确定性过滤:
_apply_filters(相关度下限 → 品牌 → 记忆term_hits→ 型号);不足则放宽评分重搜一次,再不足打探测召回。 - 登记与渲染分开:全池
register进会话登记表(app/tools/_candidates.py),只渲染前几条进模型上下文;槽位轮由register_slot(slot)解析规范槽名后逐件盖章(item_search.py:511-518)。 - harness 自动接力:
harness/autopick.py在下一次模型调用前跑price_compare+item_picker,结果以 hint 注入(套装轮 /AUTOPICK=0不自动)。 - picker ①②③:
_prepare_inputs归一词表与本轮预算 →_hard_filter排除词/超预算出局并定下便宜度归一 →_relevance_gate送 cross-encoder,低于门_RERANK_FLOOR降权沉底不剔除。 - picker ④⑤⑥:
_semantic_scores三路余弦(prefer / must / attenuate)→_score_candidates加权求和 →_combine_slots(槽位轮)或_picks_from_pool(普通轮:近重复合并 + 展示相对门PICK_REL_SHOW_RATIO+ 封顶PICK_DISPLAY_CAP)。 - picker ⑦:
register_updates(picks)回写入选理由、set_last_picks(picks)记下本轮精选,_report_picks推商品卡、_report_diag走诊断侧信道;shopping_summary收尾按 id 从登记表 hydrate 取回 url / 图片。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| (早期,无单独 commit) | — | Faiss → Qdrant;删 user 塔向量融合 | dense + payload filter 一个库搞定;向量画像无法归因 |
| 2026-07 | a113ec7 | 数值规格走 parse_spec_term/spec_verdict,不进关键词与语义 | 要 16 寸反而给 14 寸语义加分(旧版核对过) |
| 2026-07 | ad3801e | rerank query 只用品类词 + must_have,不拼软偏好 | 同一候选两遍 0.783 / 0.254,清单件数随机翻(旧版核对过) |
| 2026-07 | caa55bf | 槽内绝对阈值逐出门默认关 | 贴纸 0.30~0.60 vs 真笔袋 0.055,任何阈值都错(旧版核对过) |
| 2026-07 | 5b33f65 | 每批精排封顶 PICK_RERANK_K=15 | 登记表按轮累积(实测 8 次检索 240 件),rerank 延迟线性涨 |
| 2026-09-16 | ff023f4 | 跨平台 / 槽位改主环同轮 batch item_search,槽表为空时自动建槽 | 收敛到单环,派发那条腿删掉 |
| 2026-09-16 | 4ddefb5 → d6be5de | 先让槽 id 跨轮按名继承,再整个删掉 id:槽只活一轮、槽名即身份 | id 是给派发用的身份;没有派发后它只剩维护成本(_bundle.py 947 行 → 764 行) |
| 2026-09-21 | d40b205 | 检索依赖错误分级到工具结果 metadata | 让模型区分「参数错该重试」和「依赖挂了别重试」 |
| 2026-09-21 | e64038d | 两级检索缓存(L1 LRU + L2 Redis) | 省掉重复的 embedding 往返 + Qdrant 一跳 |
| 2026-09-21 | 191b817 | Qdrant / OpenSearch 各挂一个断路器 | 同轮 batch 并发时别每条都等满超时 |
| 2026-09-21 | 97bb384 | item_picker() 420 行按七阶段拆具名函数 | 纯结构重构,行为不变,1462 passed |
| 2026-09-22(进行中、未提交) | — | 品类知识库 RAG 从 OpenSearch 改成进程内 Hybrid,删 OpenSearch 容器 | 工作区改动,手册现状以 HEAD 634f4ab 为准 |
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 138 万点 × 1024 维 ≈ 5.4 GB | 全量召回库规模,故 on_disk=True | qdrant_store.py:138 注释 | 仅口径 |
RELEVANCE_FLOOR=0.45 | 相关度下限;BGE-M3 对任何真实英文 query ≥0.48,只挡乱码 | item_search.py:_load_params 注释 | 仅口径 |
单平台池 30 / 多平台 min(top_k,10) / MAX_TOP_K | 召回池与硬封顶 | item_search.py:_load_params | ✓ 代码核实 |
PROBE_LIMIT=8、filtered_out ≤5 | 探测召回条数与回给模型的上限 | item_search.py:_load_params、_probe_filtered_out | ✓ 代码核实 |
| 900s / 60s | 正常结果 / 空结果缓存 TTL | search_cache.py:_ttl、_empty_ttl | ✓ 代码核实 |
| 锁 TTL 5s、等同伴 0.5s | singleflight 参数 | search_cache.py:_try_lock、_wait_for_peer | ✓ 代码核实 |
| Qdrant 阈值 3 次 / 恢复 30s;超时 5s | 断路器与查询超时(reranker 阈值 5 次) | qdrant_store.py:_remote_breaker、QUERY_TIMEOUT_SEC | ✓ 代码核实 |
| OpenSearch 超时 10s、KNN 0.7 / BM25 0.3 | 品类知识库那条旁路(HEAD 版本) | git show HEAD:app/recall/kb_client.py | ✓ HEAD 核实 |
PICK_RERANK_K=15 | 每批送 cross-encoder 的上限 | item_picker.py:118 | ✓ 代码核实 |
| 误杀 38.5% / 挡住 88.6% | 品类门阈值标定(现成 v2-m3 + 0.20) | item_picker.py:_load_params 注释 | 仅口径(旧版核对过) |
| recall@20 0.2472 / MRR 0.1765 / NDCG@20 0.1492 | 商品召回离线评测,ESCI golden 11364 条 | data/eval/product_recall_report.json | 旧版核对过,本次未复跑 |
| +3.10pt vs +0.59pt(recall@8,n=868) | rerank query「品类 + 约束词」vs 纯品类词 | commit 740051c 正文 | 旧版核对过 |
| .5951 / .0303 vs .3512 / .3609 | 原版 vs 自训 reranker 对真背包 / 刺绣贴片 | commit 97f518b 正文 | 旧版核对过 |
追问 10 题#
Q1. 召回为什么只用 dense,不做 BM25 混合?
A:这个场景里精确需求是约束不是相关度:平台/价/评分走 Qdrant Range/MatchAny,品牌/排除词/型号走召回后确定性过滤。混合检索只用在品类知识库那条旁路(KNN 0.7 + BM25 0.3)。证据:qdrant_store.py:search、item_search.py:_apply_filters、kb_client.py:HYBRID_WEIGHTS。
Q2. 两级缓存缓存的到底是什么? ⚠
A:只缓存「编码 query → Qdrant 召回」这两步的 RecallCandidate 列表,不缓存 item_search 的产出——产出还要过 P_t 硬排除、记忆、槽位盖章,这些每轮都变。证据:search_cache.py 模块 docstring、commit e64038d 正文。
Q3. 为什么把 embedding 一起包进缓存边界?
A:命中时省的不只是 Qdrant 一跳,还有一次远程 embedding 往返(链路上更贵的那段)。所以回源闭包由调用方传入,item_search 那边做成惰性:主/放宽/探测三次召回真回源时只编码一次(item_search.py:398-426)。
Q4. 缓存 key 里放了什么,没放什么? ⚠
A:放索引版本 + 词(小写去空白)+ top_k + 平台(归一后排序)+ 价格/评分过滤;brand_exclude 与记忆排除不进 key——它们是事后过滤,不参与 Qdrant 查询。top_k 进 key 不截断复用:ANN 的 limit 变了拿到的不是同一个前缀。证据:search_cache.py:make_key。
Q5. 空结果为什么也缓存,还只缓存 60s?
A:缓存是为了挡穿透(冷门词一遍遍打 Qdrant);只给 60s 是因为库一扩、索引一重建,「这个词没货」就不成立,而用户对「搜不到」的重试往往就在几十秒内(search_cache.py:_empty_ttl)。
Q6. 回源失败为什么不吞成空列表? A:吞了之后「服务挂了」和「库里没货」长得一样,正好抵消掉 4-3 刚做的错误分级。所以回源异常原样抛(commit e64038d 正文)。
Q7. 两个断路器为什么处理得不一样? ⚠
A:交付后果不同。Qdrant 熔断抛 DependencyDown(这轮没商品,必须让模型停手);OpenSearch 熔断后照旧静默返回空、上层给低置信度结果,断路器只省那 10 秒(品类知识空了只是少一层锦上添花)。证据:commit 191b817 正文、kb_client.py:_os_breaker。
Q8. 为什么只有远程 Qdrant 挂断路器?
A:本地 / 内存模式没有网络往返,失败是「collection 没建」这类配置问题,熔断会把该暴露的错误压成「服务不可用」;外部传进来的 client 同理——离线脚本和测试不该共享熔断状态(qdrant_store.py:QdrantRecall.__init__)。
Q9. 错误分级的提示为什么挂在 adapter 而不是 result_nudges? ⚠
A:工具内部报错本来就不跑 post_tool_call,挂 result_nudges 等于不生效。所以挂 adapter 的 ERROR 分支,且优先级高于 LoopDetector 的循环提示——后者要撞够阈值才说话,依赖不可用第一次就该停(commit d40b205 正文)。
Q10. 压力题:为什么槽位先做了 id、半个月后又删了?
A:id 是给派发用的身份——跨 worker 要认同一个槽才需要稳定 id(4ddefb5 还专门做了「重拆槽表按名继承旧 id」)。改成单环同轮 batch 后没有跨进程身份问题了,槽只活一轮、槽名即身份,_bundle.py 从 947 行 / 33 函数降到 764 行 / 27 函数(commit d6be5de)。
坑与易混点#
- 「精排」在这条链上只有一处:
item_picker._relevance_gate,且是判别用途(品类对不对),不是排序用途——所以自训 reranker 的排序增益线上没地方消费(commit 97f518b)。 - picker 的阶段函数不能搬到别的文件:tests 与后台参数热更新都对本模块属性
setattr(commit 97bb384)。 RELEVANCE_FLOOR=0.45不能当「有没有货」的判据:注释记录真实英文 query 普遍 ≥0.48,它只挡乱码;判「没货还是被挡」靠探测召回。- collection 名对不上:代码与
.env.example都是shoppingx_items,旧版手册里的评测产物写globex_items(未验证哪个是线上实际值)。 - 工作区当前有未提交改动:品类知识库正在从 OpenSearch 改成进程内 Hybrid(
kb_client.py/category_kb.py已改、scripts/etl/os_setup.py已删)。本章讲的 OpenSearch 旁路是 HEAD 634f4ab 的状态。
本章和别章的接口#
- 两级检索缓存的延迟收益与网关/断路器全貌在第 9 章;本章只讲它缓存什么、key 怎么定。
- 自动精挑(
harness/autopick.py)、补搜闸、检索预算的执法面在第 6 章。 - embedding / reranker 微调的离线结论在第 11 章。