这是上一轮检索质量优化的需求说明: 参考文件: `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 的每个分数怎么出来,完整手推一遍。