这是上一轮检索质量优化的需求说明：

参考文件：
`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 的每个分数怎么出来，完整手推一遍。