issue-2026-03-28-添加粗排精排-第4轮-done-0328.txt 15.4 KB
这是上一轮检索质量优化的需求说明:

参考文件:
`searcher.py`
`rerank_client.py`
`schema.py`
`es_query_builder.py`
`config.yaml`
`相关性检索优化说明.md`

在ES返回结果与重排之间增加一个粗排阶段和一个精排阶段。

1. ES召回:600条
2. 粗排阶段:600 -> 240条。
   在配置文件中增加粗排相关配置,包括输入大小(设置为700,即从ES获取的数量应改为粗排的输入大小)。
   然后增加粗排的融合公式配置。可参考现有的重排融合公式,但需要去掉重排模型那一项。
3. 目前重排的SKU选择和标题后缀生成逻辑,应放在粗排之后,因为精排也是一个重排模型(轻量级模型,`bge-reranker`),它也需要这个标题后缀。
4. 精排阶段:240 -> 80条。
   使用`bge-reranker`,但由于目前重排只能选择一个后端,请考虑如何重构。
   现在精排也应该是一个独立的进程和服务,拥有自己的端口。
   但由于其逻辑与重排逻辑一致(即便存在差异,这些差异也应抽离并配置化),代码应该共享。按需启动两个实例即可,避免代码重复。
5. 最终重排:80条。
   仍使用当前重排代码,调用独立的实例(即当前在用的那个)。
   返回后,应用融合公式,再进行分页。
   这里也应参考当前的融合公式,但需增加一项:精排模型得分。

测试时,请使用与我相同的环境,并使用以下命令重启相关服务:
`./scripts/service_ctl.sh reranker backend`

你已经完成了整体修改,并将三级排序流水线串联起来了。
主要改动在:
`search/searcher.py`
`search/rerank_client.py`
`config/schema.py`
`config/loader.py`
`config/services_config.py`
以及 `config/config.yaml`。

现在的流程是:

ES获取 `coarse_rank.input_window` 条 ->
粗排通过文本/KNN融合裁剪至 `coarse_rank.output_window` ->
然后进行SKU选择和标题后缀处理 ->
精排调用轻量重排,裁剪至 `fine_rank.output_window` ->
最终重排调用现有重排,最终融合时也加入了 `fine_score`。

同时,重排客户端/提供者已改为通过 `service_profile` 选择不同的 `service_url`,因此精排和最终重排可以共享同一套服务代码,仅以不同实例运行。

你还重构了调试展示。
你修改了结果卡片和全局调试面板,使其按漏斗阶段读取并渲染数值,在 `app.js` 中,你现在分别渲染ES召回、粗排、精排和最终重排。

现在,每个结果的调试信息按阶段展示:

* ES召回:`rank`、ES得分、归一化得分、匹配查询
* 粗排:`rank` / `rank_change`、`coarse_score`、文本/KNN输入、`text_source` / `text_translation` / `text_primary` / `text_support`、`text_knn` / `image_knn`、`factor`
* 精排:`rank` / `rank_change`、`fine_score`、`fine input`
* 最终重排:`rank` / `rank_change`、`rerank_score`、文本/KNN得分、各因子、`fused_score` 以及完整信号

请仔细阅读这些漏斗阶段的代码,特别是涉及打分、重排和调试信息记录的部分。

现在,请注意需要优化的部分:

1. 精排阶段似乎没有计算融合公式并据此重排。请修复此问题。
2. 从软件工程的角度审视代码:
   既然引入了多级排序漏斗,数据记录、传递和交互接口的设计是否足够合理?存在哪些问题?
   请从软件工程角度审视这一逻辑,判断是否有需要重新组织、清理或重写的部分。
3. 优化精排和最终重排阶段的信息记录:
   这两个阶段都应体现融合公式的输入、关键因子以及融合公式计算出的得分。
   为避免代码臃肿,精排和最终重排都可以使用一个字符串来记录这些关键信息。该字符串可以包含融合公式中各项的名称和值,以及最终结果。
   你也可以继续使用当前的记录方式;请对比哪种方式代码更少、更清晰简洁。
   同时请仔细思考当前代码:实际的计算过程和记录的信息是否分离?是否存在冗余或分歧?
   这是不可取的,因为会引入潜在风险:如果后续修改了生产逻辑但未更新调试信息,就会导致不一致。

请特别注意:现在已经存在相关的信息记录逻辑。不要只是层层打补丁。
你可以适当修改,或者清理重写,而不仅仅是增加代码。
目标是让代码更简单、更干净,同时确保记录的信息始终与实际逻辑保持一致。

涉及代码较多,请耐心阅读。
以上所有任务都需要深入思考。请慢慢来,为全面的审查和重新设计留出足够空间。






**整体图**
这个 pipeline 现在可以理解成一条“先广召回,再逐层收窄、逐层加贵信号”的漏斗:

1. Query 解析
2. ES 召回
3. 粗排:只用 ES 内部文本/KNN 信号
4. 款式 SKU 选择 + title suffix
5. 精排:轻量 reranker + 文本/KNN 融合
6. 最终 rerank:重 reranker + fine score + 文本/KNN 融合
7. 分页、补全字段、格式化返回

主控代码在 [searcher.py](/data/saas-search/search/searcher.py),打分与 rerank 细节在 [rerank_client.py](/data/saas-search/search/rerank_client.py),配置定义在 [schema.py](/data/saas-search/config/schema.py) 和 [config.yaml](/data/saas-search/config/config.yaml)。

**先看入口怎么决定走哪条路**
在 [searcher.py:348](/data/saas-search/search/searcher.py#L348) 开始,`search()` 先读租户语言、开关、窗口大小。
关键判断在 [searcher.py:364](/data/saas-search/search/searcher.py#L364) 到 [searcher.py:372](/data/saas-search/search/searcher.py#L372):

- `rerank_window` 现在是 80,见 [config.yaml:256](/data/saas-search/config/config.yaml#L256)
- `coarse_rank.input_window` 是 700,`output_window` 是 240,见 [config.yaml:231](/data/saas-search/config/config.yaml#L231)
- `fine_rank.input_window` 是 240,`output_window` 是 80,见 [config.yaml:245](/data/saas-search/config/config.yaml#L245)

所以如果请求满足 `from_ + size <= rerank_window`,就进入完整漏斗:
- ES 实际取前 `700`
- 粗排后留 `240`
- 精排后留 `80`
- 最终 rerank 也只处理这 `80`
- 最后再做分页切片

如果请求页超出 80,就不走后面的多阶段漏斗,直接按 ES 原逻辑返回。

这点非常重要,因为它决定了“贵模型只服务头部结果”。

**Step 1:Query 解析阶段**
在 [searcher.py:432](/data/saas-search/search/searcher.py#L432) 到 [searcher.py:469](/data/saas-search/search/searcher.py#L469):
`query_parser.parse()` 做几件事:

- 规范化 query
- 检测语言
- 可能做 rewrite
- 生成文本向量
- 如果有图搜,还会带图片向量
- 生成翻译结果
- 识别 style intent

这一步的结果存在 `parsed_query` 里,后面 ES 查询、style SKU 选择、fine/final rerank 全都依赖它。

**Step 2:ES Query 构建**
ES DSL 在 [searcher.py:471](/data/saas-search/search/searcher.py#L471) 开始,通过 [es_query_builder.py:181](/data/saas-search/search/es_query_builder.py#L181) 的 `build_query()` 生成。

这里的核心结构是:
- 文本召回 clause
- 文本向量 KNN clause
- 图片向量 KNN clause
- 它们一起放进 `bool.should`
- 过滤条件放进 `filter`
- facet 的多选条件走 `post_filter`

KNN 部分在 [es_query_builder.py:250](/data/saas-search/search/es_query_builder.py#L250) 之后:
- 文本向量 clause 名字固定叫 `knn_query`
- 图片向量 clause 名字固定叫 `image_knn_query`

而文本召回那边,后续 fusion 代码约定会去读:
- 原始 query 的 named query:`base_query`
- 翻译 query 的 named query:`base_query_trans_*`

也就是说,后面的粗排/精排/最终 rerank,并不是重新理解 ES score,而是从 `matched_queries` 里把这些命名子信号拆出来自己重算。

**Step 3:ES 召回**
在 [searcher.py:579](/data/saas-search/search/searcher.py#L579) 到 [searcher.py:627](/data/saas-search/search/searcher.py#L627)。

这里有个很关键的工程优化:
如果在 rerank window 内,第一次 ES 拉取时会把 `_source` 关掉,只取排序必需信号,见 [searcher.py:517](/data/saas-search/search/searcher.py#L517) 到 [searcher.py:523](/data/saas-search/search/searcher.py#L523)。

原因是:
- 粗排先只需要 `_score` 和 `matched_queries`
- 不需要一上来把 700 条完整商品详情都拉回来
- 等粗排收窄后,再补 fine/final rerank 需要的字段

这是现在这条 pipeline 很核心的性能设计点。

**Step 4:粗排**
粗排入口在 [searcher.py:638](/data/saas-search/search/searcher.py#L638),真正的打分在 [rerank_client.py:348](/data/saas-search/search/rerank_client.py#L348) 的 `coarse_resort_hits()`。

粗排只看两类信号:
- `text_score`
- `knn_score`

它们先都从统一 helper `_build_hit_signal_bundle()` 里拿,见 [rerank_client.py:246](/data/saas-search/search/rerank_client.py#L246)。

文本分怎么来,见 [rerank_client.py:200](/data/saas-search/search/rerank_client.py#L200):
- `source_score = matched_queries["base_query"]`
- `translation_score = max(base_query_trans_*)`
- `weighted_translation = 0.8 * translation_score`
- `primary_text = max(source, weighted_translation)`
- `support_text = 另一路`
- `text_score = primary_text + 0.25 * support_text`

这就是一个 text dismax 思路:
原 query 是主路,翻译 query 是辅助路,但不是简单相加。

向量分怎么来,见 [rerank_client.py:156](/data/saas-search/search/rerank_client.py#L156):
- `text_knn_score`
- `image_knn_score`
- 分别乘自己的 weight
- 取强的一路做主路
- 弱的一路按 `knn_tie_breaker` 做辅助

然后粗排融合公式在 [rerank_client.py:334](/data/saas-search/search/rerank_client.py#L334):
- `coarse_score = (text_score + text_bias)^text_exponent * (knn_score + knn_bias)^knn_exponent`

配置定义在 [schema.py:124](/data/saas-search/config/schema.py#L124) 和 [config.yaml:231](/data/saas-search/config/config.yaml#L231)。

算完后:
- 写入 `hit["_coarse_score"]`
- 按 `_coarse_score` 排序
- 留前 240,见 [searcher.py:645](/data/saas-search/search/searcher.py#L645)

**Step 5:粗排后补字段 + SKU 选择**
粗排完以后,`searcher` 会按 doc template 反推 fine/final rerank 需要哪些 `_source` 字段,然后只补这些字段,见 [searcher.py:669](/data/saas-search/search/searcher.py#L669)。

之后才做 style SKU 选择,见 [searcher.py:696](/data/saas-search/search/searcher.py#L696)。

为什么放这里?
因为现在 fine rank 也是 reranker,它也要吃 title suffix。
而 suffix 是 SKU 选择之后写到 hit 上的 `_style_rerank_suffix`。
真正把 suffix 拼进 doc 文本的地方在 [rerank_client.py:65](/data/saas-search/search/rerank_client.py#L65) 到 [rerank_client.py:74](/data/saas-search/search/rerank_client.py#L74)。

所以顺序必须是:
- 先粗排
- 再选 SKU
- 再用带 suffix 的 title 去跑 fine/final rerank

**Step 6:精排**
入口在 [searcher.py:711](/data/saas-search/search/searcher.py#L711),实现是 [rerank_client.py:603](/data/saas-search/search/rerank_client.py#L603) 的 `run_lightweight_rerank()`。

它会做三件事:

1. 用 `build_docs_from_hits()` 把每条商品变成 reranker 输入文本
2. 用 `service_profile="fine"` 调轻量服务
3. 不再只按 `fine_score` 排,而是按融合后的 `_fine_fused_score` 排

精排融合公式现在是:
- `fine_stage_score = fine_factor * text_factor * knn_factor * style_boost`

具体公共计算在 [rerank_client.py:286](/data/saas-search/search/rerank_client.py#L286) 的 `_compute_multiplicative_fusion()`:
- `fine_factor = (fine_score + fine_bias)^fine_exponent`
- `text_factor = (text_score + text_bias)^text_exponent`
- `knn_factor = (knn_score + knn_bias)^knn_exponent`
- 如果命中了 selected SKU,再乘 style boost

写回 hit 的字段见 [rerank_client.py:655](/data/saas-search/search/rerank_client.py#L655):
- `_fine_score`
- `_fine_fused_score`
- `_text_score`
- `_knn_score`

排序逻辑在 [rerank_client.py:683](/data/saas-search/search/rerank_client.py#L683):
按 `_fine_fused_score` 降序排,然后留前 80,见 [searcher.py:727](/data/saas-search/search/searcher.py#L727)。

这就是你这次特别关心的点:现在 fine rank 已经不是“模型裸分排序”,而是“模型分 + ES 文本/KNN 信号融合后排序”。

**Step 7:最终 rerank**
入口在 [searcher.py:767](/data/saas-search/search/searcher.py#L767),实现是 [rerank_client.py:538](/data/saas-search/search/rerank_client.py#L538) 的 `run_rerank()`。

它和 fine rank 很像,但多了一个更重的模型分 `rerank_score`。
最终公式是:

- `final_score = rerank_factor * fine_factor * text_factor * knn_factor * style_boost`

也就是:
- fine rank 产生的 `fine_score` 不会丢
- 到最终 rerank 时,它会继续作为一个乘法项参与最终融合

这个逻辑在 [rerank_client.py:468](/data/saas-search/search/rerank_client.py#L468) 到 [rerank_client.py:476](/data/saas-search/search/rerank_client.py#L476)。

算完后写入:
- `_rerank_score`
- `_fused_score`

然后按 `_fused_score` 排序,见 [rerank_client.py:531](/data/saas-search/search/rerank_client.py#L531)。

这里你可以把它理解成:
- fine rank 负责“轻量快速筛一遍,把 240 缩成 80”
- 最终 rerank 负责“用更贵模型做最终拍板”
- 但最终拍板时,不会忽略 fine rank 结果,而是把 fine score 当成一个先验信号保留进去

**Step 8:分页与字段补全**
多阶段排序只在头部窗口内完成。
真正返回给用户前,在 [searcher.py:828](/data/saas-search/search/searcher.py#L828) 之后还会做两件事:

- 先按 `from_:from_+size` 对最终 80 条切片
- 再按用户原始 `_source` 需求补回页面真正要显示的字段,见 [searcher.py:859](/data/saas-search/search/searcher.py#L859)

所以这条链路是“三次不同目的的数据访问”:

- 第一次 ES:只要排序信号
- 第二次按 id 回填:只要 fine/final rerank 需要字段
- 第三次按页面 ids 回填:只要最终页面显示字段

这也是为什么它性能上比“一次全量拉 700 条完整文档”更合理。

**Step 9:结果格式化与 debug funnel**
最后在 [searcher.py:906](/data/saas-search/search/searcher.py#L906) 进入结果处理。
这里会把每个商品的阶段信息组装成 `ranking_funnel`,见 [searcher.py:1068](/data/saas-search/search/searcher.py#L1068):

- `es_recall`
- `coarse_rank`
- `fine_rank`
- `rerank`
- `final_page`

其中:
- coarse stage 主要保留 text/translation/knn 的拆分信号
- fine/rerank stage 现在都保留 `fusion_inputs`、`fusion_factors`、`fusion_summary`
- `fusion_summary` 来自真实计算过程本身,见 [rerank_client.py:265](/data/saas-search/search/rerank_client.py#L265)

这点很重要,因为现在“实际排序逻辑”和“debug 展示逻辑”是同源的,不是两套各写一份。

**一句话总结这条 pipeline**
这条 pipeline 的本质是:

- ES 负责便宜的大范围召回
- 粗排负责只靠 ES 内置信号先做一次结构化筛选
- style SKU 选择负责把商品文本改造成更适合 reranker 理解的输入
- fine rank 负责用轻模型把候选进一步压缩
- final rerank 负责用重模型做最终判定
- 每一层都尽量复用前一层信号,而不是推翻重来

如果你愿意,我下一步可以继续按“一个具体 query 的真实流转样例”来讲,比如假设用户搜 `black dress`,我把它从 `parsed_query`、ES named queries、coarse/fine/final 的每个分数怎么出来,完整手推一遍。