面试知识库

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 贴「别重试」
7picker 七阶段,三分口径归一→硬过滤→品类门→语义→打分→选品→上报;分数只判别不排序

30 秒版(面试开场)#

检索我拆成三段:召回、精排、精挑。召回是 item_search,BGE-M3 编码 + Qdrant dense 近邻 + payload filter,不做 BM25 混合;精排只有一处,在合流后的 item_picker 里用 cross-encoder 判「品类对不对」;精挑是确定性加权求和,最多出 3 件。工程上后来补了三件:两级检索缓存(L1 进程内 + L2 Redis)、Qdrant/OpenSearch 各一个断路器、依赖挂了给模型打「别重试」的错误分级。

3 分钟版#

  1. planner 拆意图(唯一一次 LLM):品类、预算、三个词桶(must/prefer/exclude)写进会话态 P_t。
  2. item_search 召回:query 文本拼上本轮域内的 like 词 → 编码 → Qdrant dense + platform/price_usd/rating filter。
  3. 召回后确定性过滤(_apply_filters):相关度下限 RELEVANCE_FLOOR=0.45 → 品牌排除 → 记忆硬排除 → target_name 型号。
  4. 不够就工具自己补:命中 < 3 且带 min_rating 就去掉评分重搜一次;带硬过滤仍不足就打一次不带过滤的探测召回(PROBE_LIMIT=8),差集作 filtered_out 回给模型,分清「没货」和「被预算挡了」。
  5. 缓存包住第 2 步:cached_recall 缓存编码 + 召回结果;key = 索引版本 + 词 + top_k + 平台 + 价/评分过滤(search_cache.make_key)。
  6. harness 自动精挑:item_search 成功后武装,下一次模型调用前自动跑 price_compare + item_picker,结果以 hint 注入。
  7. 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,搬走就读不到被改的值。

机制怎么跑#

  1. planner 用快档 LLM 出结构化 P_t:category / keywords / must_have / prefer_keywords / exclude_terms / budget_usd / bundle_slots(app/tools/planner.py)。
  2. 同轮 batch 发检索:跨平台或多槽位由主环同轮多发 item_search(platform=… / slot=…),框架按 is_concurrency_safe=True 并发(commit ff023f4)。
  3. item_search 定池大小:单平台 SINGLE_PLATFORM_POOL_K=30,多平台 min(top_k, MAX_TOP_K)(item_search.py:_load_params)。
  4. 召回走缓存:_recall() 把「编码 + QdrantRecall.search」包成闭包交给 cached_recall(item_search.py:398-426);L1 LRU → L2 Redis → singleflight(进程内共用 Future,跨进程 SET NX 抢锁,没抢到最多轮询 0.5s 就自己回源)。
  5. 确定性过滤:_apply_filters(相关度下限 → 品牌 → 记忆 term_hits → 型号);不足则放宽评分重搜一次,再不足打探测召回。
  6. 登记与渲染分开:全池 register 进会话登记表(app/tools/_candidates.py),只渲染前几条进模型上下文;槽位轮由 register_slot(slot) 解析规范槽名后逐件盖章(item_search.py:511-518)。
  7. harness 自动接力:harness/autopick.py 在下一次模型调用前跑 price_compare + item_picker,结果以 hint 注入(套装轮 / AUTOPICK=0 不自动)。
  8. picker ①②③:_prepare_inputs 归一词表与本轮预算 → _hard_filter 排除词/超预算出局并定下便宜度归一 → _relevance_gate 送 cross-encoder,低于门 _RERANK_FLOOR 降权沉底不剔除。
  9. picker ④⑤⑥:_semantic_scores 三路余弦(prefer / must / attenuate)→ _score_candidates 加权求和 → _combine_slots(槽位轮)或 _picks_from_pool(普通轮:近重复合并 + 展示相对门 PICK_REL_SHOW_RATIO + 封顶 PICK_DISPLAY_CAP)。
  10. 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-07a113ec7数值规格走 parse_spec_term/spec_verdict,不进关键词与语义要 16 寸反而给 14 寸语义加分(旧版核对过)
2026-07ad3801ererank query 只用品类词 + must_have,不拼软偏好同一候选两遍 0.783 / 0.254,清单件数随机翻(旧版核对过)
2026-07caa55bf槽内绝对阈值逐出门默认关贴纸 0.30~0.60 vs 真笔袋 0.055,任何阈值都错(旧版核对过)
2026-075b33f65每批精排封顶 PICK_RERANK_K=15登记表按轮累积(实测 8 次检索 240 件),rerank 延迟线性涨
2026-09-16ff023f4跨平台 / 槽位改主环同轮 batch item_search,槽表为空时自动建槽收敛到单环,派发那条腿删掉
2026-09-164ddefb5 → d6be5de先让槽 id 跨轮按名继承,再整个删掉 id:槽只活一轮、槽名即身份id 是给派发用的身份;没有派发后它只剩维护成本(_bundle.py 947 行 → 764 行)
2026-09-21d40b205检索依赖错误分级到工具结果 metadata让模型区分「参数错该重试」和「依赖挂了别重试」
2026-09-21e64038d两级检索缓存(L1 LRU + L2 Redis)省掉重复的 embedding 往返 + Qdrant 一跳
2026-09-21191b817Qdrant / OpenSearch 各挂一个断路器同轮 batch 并发时别每条都等满超时
2026-09-2197bb384item_picker() 420 行按七阶段拆具名函数纯结构重构,行为不变,1462 passed
2026-09-22(进行中、未提交)—品类知识库 RAG 从 OpenSearch 改成进程内 Hybrid,删 OpenSearch 容器工作区改动,手册现状以 HEAD 634f4ab 为准

数字与证据#

数字指什么来源状态
138 万点 × 1024 维 ≈ 5.4 GB全量召回库规模,故 on_disk=Trueqdrant_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正常结果 / 空结果缓存 TTLsearch_cache.py:_ttl、_empty_ttl✓ 代码核实
锁 TTL 5s、等同伴 0.5ssingleflight 参数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)。

坑与易混点#

  1. 「精排」在这条链上只有一处:item_picker._relevance_gate,且是判别用途(品类对不对),不是排序用途——所以自训 reranker 的排序增益线上没地方消费(commit 97f518b)。
  2. picker 的阶段函数不能搬到别的文件:tests 与后台参数热更新都对本模块属性 setattr(commit 97bb384)。
  3. RELEVANCE_FLOOR=0.45 不能当「有没有货」的判据:注释记录真实英文 query 普遍 ≥0.48,它只挡乱码;判「没货还是被挡」靠探测召回。
  4. collection 名对不上:代码与 .env.example 都是 shoppingx_items,旧版手册里的评测产物写 globex_items(未验证哪个是线上实际值)。
  5. 工作区当前有未提交改动:品类知识库正在从 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 章。