issue-2026-03-28-添加粗排精排-第4轮-done-0328.txt
15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
这是上一轮检索质量优化的需求说明:
参考文件:
`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 的每个分数怎么出来,完整手推一遍。