diff --git a/config/config.yaml b/config/config.yaml index d1e0a1a..c468a31 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -502,7 +502,7 @@ services: engine: "vllm" max_model_len: 172 tensor_parallel_size: 1 - gpu_memory_utilization: 0.24 + gpu_memory_utilization: 0.15 dtype: "float16" enable_prefix_caching: true enforce_eager: false diff --git a/docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt b/docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt index b681ac4..5e8c57f 100644 --- a/docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt +++ b/docs/issue-2026-03-27-添加粗排精排-第2轮-done-0327.txt @@ -20,7 +20,7 @@ config.yaml 现在链路是: ES 拉取 coarse_rank.input_window 条 -> 粗排按 text/knn 融合裁到 coarse_rank.output_window -> 再做 SKU 选择和 title suffix -> 精排调用轻量 reranker 裁到 fine_rank.output_window -> 最终重排调用现有 reranker,并在最终融合里加入 fine_score。同时把 reranker client/provider 改成了按 service_profile 选不同 service_url,这样 fine/final 可以共用同一套服务代码,只起不同实例。 -你是用了./.venv/bin/pytest -q tests/test_rerank_client.py tests/test_search_rerank_window.py 进行验证,结果是 18 passed。也按要求尝试了 ./scripts/service_ctl.sh restart reranker backend 做联调,但当前环境里只有 6007 的现有 reranker,配置里的 fine 端口 6009 还没对应实例,而且服务重启后进程状态不太稳定,没能完成完整线上链路验证。当前最关键的落地前置是把轻量 bge 实例补起来,再用同样脚本重启联调。 +你用了./.venv/bin/pytest -q tests/test_rerank_client.py tests/test_search_rerank_window.py 进行验证,结果是 18 passed。也按要求尝试了 ./scripts/service_ctl.sh restart reranker backend 做联调,但当前环境里只有 6007 的现有 reranker,配置里的 fine 端口 6009 还没对应实例,而且服务重启后进程状态不太稳定,没能完成完整线上链路验证。当前最关键的落地前置是把轻量 bge 实例补起来,再用同样脚本重启联调。 因此,需要回顾这个漏斗的代码,进行进一步的优化: 1. Fine Rank环节的信息都是N/A。fine 实例也需要在config.yaml中体现,并且需要被server_ctl.sh管理,参考现在是怎么管理reranker的。fine和reranker分别调用哪个模型(两者代码功能,但是只是启动不同的实例),请你深度思考后决定。这里是需要参考当前代码、根据实际需要(原来只有一个reranker、现在多了一个精排,也要调用一个重排模型,因此带来的必要的部分重构。 diff --git a/docs/issue-2026-03-27-添加粗排精排-第3轮-done-0327.txt b/docs/issue-2026-03-27-添加粗排精排-第3轮-done-0327.txt new file mode 100644 index 0000000..46505c2 --- /dev/null +++ b/docs/issue-2026-03-27-添加粗排精排-第3轮-done-0327.txt @@ -0,0 +1,98 @@ +这是上一轮检索效果优化的需求: +参考 +searcher.py +rerank_client.py +schema.py +es_query_builder.py +config.yaml +相关性检索优化说明.md + +在ES返回到rerank期间增加一轮粗排+一轮精排。 +1. ES召回,600 +2. 粗排:600->240。配置文件增加粗排相关配置,包括输入条数(配置为700,ES拉取的条数改为粗排输入条数),然后增加粗排的融合公式配置,参考现有的reranker融合公式即可、只是去掉其中的重排模型项。 +3. 现在的sku选择、为reranker生成title后缀这一套逻辑,是放在粗排后,因为精排也是一个reranker模型(只不过是一个轻量级的,bge-reranker),需要用这个title后缀。 +4. 精排:240-80,使用bge-reranker,但是,因为reranker只能选一个backend,考虑如何重构。现在,精排也是一个独立的进程、独立提供端口,服务。但是,因为跟重排逻辑是一致的(即使有部分不一致也应该分离不一致的点进行配置化),所以共用代码,只是根据需要启动两个实例,避免代码冗余。 +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 条 -> 粗排按 text/knn 融合裁到 coarse_rank.output_window -> 再做 SKU 选择和 title suffix -> 精排调用轻量 reranker 裁到 fine_rank.output_window -> 最终重排调用现有 reranker,并在最终融合里加入 fine_score。同时把 reranker client/provider 改成了按 service_profile 选不同 service_url,这样 fine/final 可以共用同一套服务代码,只起不同实例。 + +并且,你对调试展示进行了重构。你已经把结果卡片和全局调试面板都改成按漏斗阶段取值和展示,在 app.js 里把 ES 召回、粗排、精排、最终 rerank 分开渲染了。 +现在每条结果的 debug 会按阶段展示: +ES 召回:rank、ES score、norm score、matched queries。 +粗排:rank/rank_change、coarse_score、text/knn 输入、text_source/text_translation/text_primary/text_support、text_knn/image_knn、factor。 +精排:rank/rank_change、fine_score、fine input。 +最终 rerank:rank/rank_change、rerank_score、text/knn score、各 factor、fused_score,以及完整 signals。 + +请你仔细阅读漏斗环节的这些代码,特别是关于打分、重排序、debug信息记录方面的。 + + +现在,请注意,需要优化的是: +1. Fine Rank环节似乎没有进行融合公式的计算、继而进行重排序,请修复。 +2.从软件工程的视角review代码: +因为增加了多重排序漏斗,数据的记录、传递,交互的接口,是否设计足够合理,存在哪些问题。 +请从软件工程的角度审视这些逻辑,是否有需要梳理、清理和重写的地方。 +3. Fine Rank和Final Rerank环节信息记录优化: +这两个环节都要体现融合公式的输入、关键因子、以及融合公式的得分。为了避免代码膨胀,Fine Rank和Final Rerank +都可以采用一个字符串记录这些关键信息,字符串内包括融合公式各项的名称和具体数值,以及最终结果。你也可以继续沿用当前的记录方式,需要你对比一下哪种代码量更少、更清晰简洁。 +也要仔细思考当前的代码,真实的计算和信息的记录,是否存在分离的情况,是否存在冗余和分叉。这种情况是不允许的,存在隐藏的风险,以后改了正式逻辑而没有改调试信息,将导致不一致。 +务必注意,当前已经有相关的信息记录逻辑,注意不要叠补丁,可以适当修改、或者清理重写,而不是新增,要使得代码更简洁和干净,并保证信息记录与真实逻辑一致。 + + +涉及代码较多,请耐心阅读,以上都是一些需要深度思考的任务,慢慢来,留足够多的时间来review和重新设计。 + + + + + + +因为增加了两个环节,多了很多变量。 +以这些为效果评估的测试集,调试参数。这次的调整范围是,融合公式中的各个 +falda negra oficina +red fitted tee +黒いミディ丈スカート +黑色中长半身裙 +чёрное летнее платье +修身牛仔裤 +date night dress +vacation outfit dress +minimalist top + +仔细思考这些漏斗中重要的信息如何呈现。对应的修改前端代码。 +注意包括整体漏斗信息的呈现,以及每条结构中独自的信息。 +我需要这些信息,辅助各环节融合公式的调参,根据我的需求,深度思考该如何设计,要呈现哪些信息,如何呈现。 +可以对现有的逻辑做适当的重构,重新整理。 + + + + +fine 实例也需要在config.yaml中体现,并且需要被server_ctl.sh管理,参考现在是怎么管理reranker的。fine和reranker分别调用哪个模型(两者代码功能,但是只是启动不同的实例),请你深度思考后决定。这里是需要参考当前代码、根据实际需要(原来只有一个reranker、现在多了一个精排,也要调用一个重排模型,因此带来的必要的部分重构。 + +1. Fine Rank环节的信息都是N/A,是没有配置吗。fine rank是使用bge-reranker,复用当前reranker模型的代码,但是需要单独起一个服务、单独加载一个模型。 +2. Ranking Funnel、Fusion Factors、Signal Breakdown +这些是不是整合起来、按漏斗收集、整理信息、以及进行呈现比较好。 +ES 召回的环节,展示Matched Queries各项打分、ES的总分、norm后打分、排序位置,等等关键信息 +粗排:粗排融合公式的各项输入、重要中间结果和参数、最后得分,排序位置以及上升/下降了多少。等等关键信息。 +精排:同样例举关键的输入、中间过程、输出、排序和位置变化等。 +reranker:类似 + +因为涉及的环节较多,非常要注意的一个点是:不要每次修改都在原来的基础上,为实现目标而打补丁,应该观察一下所涉及的代码现在是怎么做的,务必注意如何适当的清理掉现有逻辑,该如何对其进行修改,来达到目的,以达到代码的精简,避免冗余、分叉。 + + + + + +1. Fine Rank 这个环节没有体现融合公式的输入、关键因子、以及融合公式的得分。为了避免代码膨胀,Fine Rank和Final Rerank +都可以采用一个字符串记录这些关键信息,字符串内包括融合公式各项的名称和具体数值,以及最终结果。 + + + + + + + + + diff --git a/docs/issue-2026-03-28-添加粗排精排-第4轮-done-0328.txt b/docs/issue-2026-03-28-添加粗排精排-第4轮-done-0328.txt new file mode 100644 index 0000000..c710ebf --- /dev/null +++ b/docs/issue-2026-03-28-添加粗排精排-第4轮-done-0328.txt @@ -0,0 +1,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 的每个分数怎么出来,完整手推一遍。 \ No newline at end of file diff --git a/docs/常用查询 - ES.md b/docs/常用查询 - ES.md index e3b3af0..6a08941 100644 --- a/docs/常用查询 - ES.md +++ b/docs/常用查询 - ES.md @@ -1,642 +1,630 @@ -# 查看所有租户索引 - curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/_cat/indices/search_products_tenant_*?v' +## Elasticsearch 排查流程 -# ====================================== -# 租户相关 -# ====================================== -# -# 说明:索引已按租户拆分为 search_products_tenant_{tenant_id}, -# 一般情况下不需要在查询中再按 tenant_id 过滤(可选保留用于排查)。 +### 1. 集群健康状态 + +```bash +# 集群整体健康(green / yellow / red) +curl -s -u 'saas:4hOaLaf41y2VuI8y' 'http://127.0.0.1:9200/_cluster/health?pretty' +``` + +### 2. 索引概览 + +```bash +# 查看所有租户索引状态与体积 +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/_cat/indices/search_products_tenant_*?v' + +# 或查看全部索引 +curl -s -u 'saas:4hOaLaf41y2VuI8y' 'http://127.0.0.1:9200/_cat/indices?v' +``` + +### 3. 分片分布 + +```bash +# 查看分片在各节点的分布情况 +curl -s -u 'saas:4hOaLaf41y2VuI8y' 'http://127.0.0.1:9200/_cat/shards?v' +``` + +### 4. 分配诊断(如有异常) + +```bash +# 当 health 非 green 或 shards 状态异常时,定位具体原因 +curl -s -u 'saas:4hOaLaf41y2VuI8y' -X POST 'http://127.0.0.1:9200/_cluster/allocation/explain?pretty' \ + -H 'Content-Type: application/json' \ + -d '{"index":"search_products_tenant_163","shard":0,"primary":true}' +``` + +> 典型结论示例:`disk_threshold` — 磁盘超过高水位,新分片禁止分配。 + +### 5. 系统层检查 + +```bash +# 服务状态 +sudo systemctl status elasticsearch + +# 磁盘空间 +df -h + +# ES 数据目录占用 +du -sh /var/lib/elasticsearch/ +``` + +### 6. 配置与日志 + +```bash +# 配置文件 +cat /etc/elasticsearch/elasticsearch.yml + +# 实时日志 +journalctl -u elasticsearch -f +``` + +--- + +### 快速排查路径 + +``` +_cluster/health → 确认集群状态(green/yellow/red) + ↓ +_cat/indices → 检查索引体积与状态 + ↓ +_cat/shards → 查看分片分布 + ↓ +_cluster/allocation/explain → 定位分配问题(如需要) + ↓ +systemctl / df / 日志 → 系统层验证 +``` + +--- +以下是将您提供的 Elasticsearch 查询整理为 Markdown 格式的文档: + +--- + +# Elasticsearch 查询集合 + +## 租户相关 + +> **说明**:索引已按租户拆分为 `search_products_tenant_{tenant_id}`,一般情况下不需要在查询中再按 `tenant_id` 过滤(可选保留用于排查)。 + +--- ### 1. 根据 tenant_id / spu_id 查询 -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ + +#### 查询指定 spu_id 的商品(返回 title) +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ "size": 11, "_source": ["title"], "query": { - "bool": { - "filter": [ - { "term": {"spu_id" : 206150} } - ] - } + "bool": { + "filter": [ + { "term": {"spu_id" : 206150} } + ] + } } - }' - - -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ - "size": 100, - "_source": ["title"], - "query": { - "match_all": {} - } }' +``` - -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ - "size": 5, - "_source": ["title", "keywords", "tags"], - "query": { - "bool": { - "filter": [ - { "term": { "spu_id": "223167" } } - ] +#### 查询所有商品(返回 title) +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ + "size": 100, + "_source": ["title"], + "query": { + "match_all": {} } - } }' +``` - -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ - "size": 1, - "_source": ["title", "keywords", "tags"], - "query": { - "bool": { - "must": [ - { - "match": { - "title.en": { - "query": "Floerns Women Gothic Graphic Ribbed Strapless Tube Top Asymmetrical Ruched Bandeau Tops" - } - } +#### 查询指定 spu_id 的商品(返回 title、keywords、tags) +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ + "size": 5, + "_source": ["title", "keywords", "tags"], + "query": { + "bool": { + "filter": [ + { "term": { "spu_id": "223167" } } + ] } - ], - "filter": [ - { "terms": { "tags": ["女装", "派对"] } } - ] } - } }' +``` +#### 组合查询:匹配标题 + 过滤标签 +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ + "size": 1, + "_source": ["title", "keywords", "tags"], + "query": { + "bool": { + "must": [ + { + "match": { + "title.en": { + "query": "Floerns Women Gothic Graphic Ribbed Strapless Tube Top Asymmetrical Ruched Bandeau Tops" + } + } + } + ], + "filter": [ + { "terms": { "tags": ["女装", "派对"] } } + ] + } + } +}' +``` -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ +#### 组合查询:匹配标题 + 过滤租户(冗余示例) +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ "size": 1, "_source": ["title"], "query": { - "bool": { - "must": [ - { - "match": { - "title.en": { - "query": "Floerns Women Gothic Graphic Ribbed Strapless Tube Top Asymmetrical Ruched Bandeau Tops" - } - } - } - ], - "filter": [ - { "term": { "tenant_id": "170" } } - ] - } + "bool": { + "must": [ + { + "match": { + "title.en": { + "query": "Floerns Women Gothic Graphic Ribbed Strapless Tube Top Asymmetrical Ruched Bandeau Tops" + } + } + } + ], + "filter": [ + { "term": { "tenant_id": "170" } } + ] + } } }' +``` -Curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_analyze' -H 'Content-Type: application/json' -d '{ - "analyzer": "index_ik", - "text": "14寸第4代-眼珠实身冰雪公仔带手动大推车,搪胶雪宝宝" -}' +--- + +### 2. 分析器测试 -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_analyze' -H 'Content-Type: application/json' -d '{ - "analyzer": "query_ik", - "text": "14寸第4代-眼珠实身冰雪公仔带手动大推车,搪胶雪宝宝" +#### 测试 index_ik 分析器 +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_analyze' -H 'Content-Type: application/json' -d '{ + "analyzer": "index_ik", + "text": "14寸第4代-眼珠实身冰雪公仔带手动大推车,搪胶雪宝宝" }' +``` -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ - "size": 100, - "from": 0, - "query": { - "bool": { - "must": [ - { - "multi_match": { - "_name": "base_query", - "fields": [ - "title.zh^3.0", - "brief.zh^1.5", - "description.zh", - "vendor.zh^1.5", - "tags", - "category_path.zh^1.5", - "category_name_text.zh^1.5", - "option1_values^0.5" - ], - "minimum_should_match": "75%", - "operator": "AND", - "query": "裙", - "tie_breaker": 0.9 - } - } - ], - "filter": [ - { - "match_all": {} - } - ] - } - } +#### 测试 query_ik 分析器 +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_analyze' -H 'Content-Type: application/json' -d '{ + "analyzer": "query_ik", + "text": "14寸第4代-眼珠实身冰雪公仔带手动大推车,搪胶雪宝宝" }' +``` -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ - "size": 1, - "from": 0, - "query": { - "bool": { - "must": [ - { - "multi_match": { - "_name": "base_query", - "fields": [ - "title.zh^3.0", - "brief.zh^1.5", - "description.zh", - "vendor.zh^1.5", - "tags", - "category_path.zh^1.5", - "category_name_text.zh^1.5", - "option1_values^0.5" +--- + +### 3. 多字段搜索 + 聚合(综合分面示例) + +#### 多字段匹配 + 聚合(category1、color、size、material) +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_search?pretty' -H 'Content-Type: application/json' -d '{ + "size": 1, + "from": 0, + "query": { + "bool": { + "must": [ + { + "multi_match": { + "_name": "base_query", + "fields": [ + "title.zh^3.0", + "brief.zh^1.5", + "description.zh", + "vendor.zh^1.5", + "tags", + "category_path.zh^1.5", + "category_name_text.zh^1.5", + "option1_values^0.5" + ], + "minimum_should_match": "75%", + "operator": "AND", + "query": "裙", + "tie_breaker": 0.9 + } + } ], - "minimum_should_match": "75%", - "operator": "AND", - "query": "裙", - "tie_breaker": 0.9 - } - } - ], - "filter": [ - { "match_all": {} } - ] - } - }, - "aggs": { - "category1_name_facet": { - "terms": { - "field": "category1_name", - "size": 15, - "order": { - "_count": "desc" + "filter": [ + { "match_all": {} } + ] } - } }, - "specifications_color_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filter_by_name": { - "filter": { - "term": { - "specifications.name": "color" + "aggs": { + "category1_name_facet": { + "terms": { + "field": "category1_name", + "size": 15, + "order": { "_count": "desc" } } - }, - "aggs": { - "value_counts": { - "terms": { - "field": "specifications.value", - "size": 20, - "order": { - "_count": "desc" + }, + "specifications_color_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filter_by_name": { + "filter": { "term": { "specifications.name": "color" } }, + "aggs": { + "value_counts": { + "terms": { + "field": "specifications.value", + "size": 20, + "order": { "_count": "desc" } + } + } + } } - } } - } - } - } - }, - "specifications_size_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filter_by_name": { - "filter": { - "term": { - "specifications.name": "size" - } - }, - "aggs": { - "value_counts": { - "terms": { - "field": "specifications.value", - "size": 15, - "order": { - "_count": "desc" + }, + "specifications_size_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filter_by_name": { + "filter": { "term": { "specifications.name": "size" } }, + "aggs": { + "value_counts": { + "terms": { + "field": "specifications.value", + "size": 15, + "order": { "_count": "desc" } + } + } + } } - } - } - } - } - } - }, - "specifications_material_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filter_by_name": { - "filter": { - "term": { - "specifications.name": "material" } - }, - "aggs": { - "value_counts": { - "terms": { - "field": "specifications.value", - "size": 10, - "order": { - "_count": "desc" + }, + "specifications_material_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filter_by_name": { + "filter": { "term": { "specifications.name": "material" } }, + "aggs": { + "value_counts": { + "terms": { + "field": "specifications.value", + "size": 10, + "order": { "_count": "desc" } + } + } + } } - } } - } } - } } - } }' +``` + +--- + +### 4. 通用查询(通用索引示例) +#### 查询所有 +```bash GET /search_products_tenant_2/_search { - "query": { - "match_all": {} - } + "query": { + "match_all": {} + } } +``` - -curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products/_search?pretty' -H 'Content-Type: application/json' -d '{ +#### 按 spu_id 查询(通用索引) +```bash +curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products/_search?pretty' -H 'Content-Type: application/json' -d '{ "size": 5, "query": { - "bool": { - "filter": [ - { "term": { "spu_id": "74123" } } - ] - } + "bool": { + "filter": [ + { "term": { "spu_id": "74123" } } + ] + } } - }' +}' +``` + +--- +### 5. 统计租户总文档数 -### 2. 统计租户的总文档数 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_170/_count?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "match_all": {} - } + "query": { + "match_all": {} + } }' +``` +--- -# ====================================== -# 分面数据诊断相关查询 -# ====================================== +## 分面数据诊断相关查询 -## 1. 检查ES文档的分面字段数据 +### 1. 检查 ES 文档的分面字段数据 -### 1.1 查询特定租户的商品,显示分面相关字段 +#### 1.1 查询特定租户的商品,显示分面相关字段 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "term": { - "tenant_id": "162" - } - }, - "size": 1, - "_source": [ - "spu_id", - "title", - "category1_name", - "category2_name", - "category3_name", - "specifications", - "option1_name", - "option2_name", - "option3_name" - ] + "query": { + "term": { "tenant_id": "162" } + }, + "size": 1, + "_source": [ + "spu_id", "title", "category1_name", "category2_name", + "category3_name", "specifications", "option1_name", + "option2_name", "option3_name" + ] }' +``` -### 1.2 验证category1_name字段是否有数据 +#### 1.2 验证 category1_name 字段是否有数据 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "bool": { - "filter": [ - { "term": { "tenant_id": "162" } }, - { "exists": { "field": "category1_name" } } - ] - } - }, - "size": 0 + "query": { + "bool": { + "filter": [ + { "term": { "tenant_id": "162" } }, + { "exists": { "field": "category1_name" } } + ] + } + }, + "size": 0 }' +``` -### 1.3 验证specifications字段是否有数据 +#### 1.3 验证 specifications 字段是否有数据 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "bool": { - "filter": [ - { "term": { "tenant_id": "162" } }, - { "exists": { "field": "specifications" } } - ] - } - }, - "size": 0 + "query": { + "bool": { + "filter": [ + { "term": { "tenant_id": "162" } }, + { "exists": { "field": "specifications" } } + ] + } + }, + "size": 0 }' +``` -## 2. 分面聚合查询(Facet Aggregations) +--- -### 2.1 category1_name 分面聚合 +### 2. 分面聚合查询(Facet Aggregations) + +#### 2.1 category1_name 分面聚合 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "match_all": {} - }, - "size": 0, - "aggs": { - "category1_name_facet": { - "terms": { - "field": "category1_name", - "size": 50 - } + "query": { "match_all": {} }, + "size": 0, + "aggs": { + "category1_name_facet": { + "terms": { "field": "category1_name", "size": 50 } + } } - } }' +``` -### 2.2 specifications.color 分面聚合 +#### 2.2 specifications.color 分面聚合 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "match_all": {} - }, - "size": 0, - "aggs": { - "specifications_color_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filtered": { - "filter": { - "term": { - "specifications.name": "color" - } - }, - "aggs": { - "values": { - "terms": { - "field": "specifications.value", - "size": 50 - } + "query": { "match_all": {} }, + "size": 0, + "aggs": { + "specifications_color_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filtered": { + "filter": { "term": { "specifications.name": "color" } }, + "aggs": { + "values": { "terms": { "field": "specifications.value", "size": 50 } } + } + } } - } } - } } - } }' +``` -### 2.3 specifications.size 分面聚合 +#### 2.3 specifications.size 分面聚合 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "match_all": {} - }, - "size": 0, - "aggs": { - "specifications_size_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filtered": { - "filter": { - "term": { - "specifications.name": "size" - } - }, - "aggs": { - "values": { - "terms": { - "field": "specifications.value", - "size": 50 - } + "query": { "match_all": {} }, + "size": 0, + "aggs": { + "specifications_size_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filtered": { + "filter": { "term": { "specifications.name": "size" } }, + "aggs": { + "values": { "terms": { "field": "specifications.value", "size": 50 } } + } + } } - } } - } } - } }' +``` -### 2.4 specifications.material 分面聚合 +#### 2.4 specifications.material 分面聚合 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "match_all": {} - }, - "size": 0, - "aggs": { - "specifications_material_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filtered": { - "filter": { - "term": { - "specifications.name": "material" - } - }, - "aggs": { - "values": { - "terms": { - "field": "specifications.value", - "size": 50 - } + "query": { "match_all": {} }, + "size": 0, + "aggs": { + "specifications_material_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filtered": { + "filter": { "term": { "specifications.name": "material" } }, + "aggs": { + "values": { "terms": { "field": "specifications.value", "size": 50 } } + } + } } - } } - } } - } }' +``` -### 2.5 综合分面聚合(category + color + size + material) +#### 2.5 综合分面聚合(category + color + size + material) +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "match_all": {} - }, - "size": 0, - "aggs": { - "category1_name_facet": { - "terms": { - "field": "category1_name", - "size": 50 - } - }, - "specifications_color_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filtered": { - "filter": { - "term": { - "specifications.name": "color" - } - }, - "aggs": { - "values": { - "terms": { - "field": "specifications.value", - "size": 50 - } - } - } - } - } - }, - "specifications_size_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filtered": { - "filter": { - "term": { - "specifications.name": "size" - } - }, - "aggs": { - "values": { - "terms": { - "field": "specifications.value", - "size": 50 - } + "query": { "match_all": {} }, + "size": 0, + "aggs": { + "category1_name_facet": { "terms": { "field": "category1_name", "size": 50 } }, + "specifications_color_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filtered": { + "filter": { "term": { "specifications.name": "color" } }, + "aggs": { "values": { "terms": { "field": "specifications.value", "size": 50 } } } + } } - } - } - } - }, - "specifications_material_facet": { - "nested": { - "path": "specifications" - }, - "aggs": { - "filtered": { - "filter": { - "term": { - "specifications.name": "material" + }, + "specifications_size_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filtered": { + "filter": { "term": { "specifications.name": "size" } }, + "aggs": { "values": { "terms": { "field": "specifications.value", "size": 50 } } } + } } - }, - "aggs": { - "values": { - "terms": { - "field": "specifications.value", - "size": 50 - } + }, + "specifications_material_facet": { + "nested": { "path": "specifications" }, + "aggs": { + "filtered": { + "filter": { "term": { "specifications.name": "material" } }, + "aggs": { "values": { "terms": { "field": "specifications.value", "size": 50 } } } + } } - } } - } } - } }' +``` + +--- -## 3. 检查specifications嵌套字段的详细结构 +### 3. 检查 specifications 嵌套字段的详细结构 -### 3.1 查看specifications的name字段有哪些值 +#### 3.1 查看 specifications 的 name 字段有哪些值 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "term": { - "tenant_id": "162" - } - }, - "size": 0, - "aggs": { - "specifications_names": { - "nested": { - "path": "specifications" - }, - "aggs": { - "name_values": { - "terms": { - "field": "specifications.name", - "size": 20 - } + "query": { "term": { "tenant_id": "162" } }, + "size": 0, + "aggs": { + "specifications_names": { + "nested": { "path": "specifications" }, + "aggs": { + "name_values": { "terms": { "field": "specifications.name", "size": 20 } } + } } - } } - } }' +``` -### 3.2 查看某个商品的完整specifications数据 +#### 3.2 查看某个商品的完整 specifications 数据 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "bool": { - "filter": [ - { "term": { "tenant_id": "162" } }, - { "exists": { "field": "specifications" } } - ] - } - }, - "size": 1, - "_source": ["spu_id", "title", "specifications"] + "query": { + "bool": { + "filter": [ + { "term": { "tenant_id": "162" } }, + { "exists": { "field": "specifications" } } + ] + } + }, + "size": 1, + "_source": ["spu_id", "title", "specifications"] }' +``` -## 4. 统计查询 +--- -### 4.1 统计有category1_name的文档数量 +### 4. 统计查询 + +#### 4.1 统计有 category1_name 的文档数量 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_count?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "bool": { - "filter": [ - { "exists": { "field": "category1_name" } } - ] + "query": { + "bool": { + "filter": [ + { "exists": { "field": "category1_name" } } + ] + } } - } }' +``` -### 4.2 统计有specifications的文档数量 +#### 4.2 统计有 specifications 的文档数量 +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_count?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "bool": { - "filter": [ - { "exists": { "field": "specifications" } } - ] + "query": { + "bool": { + "filter": [ + { "exists": { "field": "specifications" } } + ] + } } - } }' +``` +--- -## 5. 诊断问题场景 +### 5. 诊断问题场景 -### 5.1 查找没有category1_name但有category的文档(MySQL有数据但ES没有) +#### 5.1 查找没有 category1_name 但有 category 的文档(MySQL 有数据但 ES 没有) +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "bool": { - "filter": [ - { "term": { "tenant_id": "162" } } - ], - "must_not": [ - { "exists": { "field": "category1_name" } } - ] - } - }, - "size": 10, - "_source": ["spu_id", "title", "category_name_text", "category_path"] + "query": { + "bool": { + "filter": [ + { "term": { "tenant_id": "162" } } + ], + "must_not": [ + { "exists": { "field": "category1_name" } } + ] + } + }, + "size": 10, + "_source": ["spu_id", "title", "category_name_text", "category_path"] }' +``` -### 5.2 查找有option但没有specifications的文档(数据转换问题) +#### 5.2 查找有 option 但没有 specifications 的文档(数据转换问题) +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X GET 'http://localhost:9200/search_products_tenant_162/_search?pretty' -H 'Content-Type: application/json' -d '{ - "query": { - "bool": { - "filter": [ - { "term": { "tenant_id": "162" } }, - { "exists": { "field": "option1_name" } } - ], - "must_not": [ - { "exists": { "field": "specifications" } } - ] - } - }, - "size": 10, - "_source": ["spu_id", "title", "option1_name", "option2_name", "option3_name", "specifications"] + "query": { + "bool": { + "filter": [ + { "term": { "tenant_id": "162" } }, + { "exists": { "field": "option1_name" } } + ], + "must_not": [ + { "exists": { "field": "specifications" } } + ] + } + }, + "size": 10, + "_source": ["spu_id", "title", "option1_name", "option2_name", "option3_name", "specifications"] }' +``` + +--- +## 重排序示例 -重排序: +```bash GET /search_products_tenant_170/_search { "query": { - "match": { + "match": { "title.en": { "query": "quick brown fox", "minimum_should_match": "90%" @@ -644,31 +632,36 @@ GET /search_products_tenant_170/_search } }, "rescore": { - "window_size": 50, - "query": { + "window_size": 50, + "query": { "rescore_query": { "match_phrase": { "title.en": { "query": "quick brown fox", - "slop": 50 + "slop": 50 } } } } } } +``` +--- -检查某个字段是否存在 +## 检查字段是否存在 + +```bash curl -u 'saas:4hOaLaf41y2VuI8y' -X POST \ - 'http://localhost:9200/search_products_tenant_163/_count' \ - -H 'Content-Type: application/json' \ - -d '{ +'http://localhost:9200/search_products_tenant_163/_count' \ +-H 'Content-Type: application/json' \ +-d '{ "query": { - "bool": { - "filter": [ - { "exists": { "field": "title_embedding" } } - ] - } + "bool": { + "filter": [ + { "exists": { "field": "image_embedding" } } + ] + } } - }' \ No newline at end of file +}' +``` \ No newline at end of file diff --git a/docs/相关性检索优化说明.md b/docs/相关性检索优化说明.md index b398c75..ee8f181 100644 --- a/docs/相关性检索优化说明.md +++ b/docs/相关性检索优化说明.md @@ -260,6 +260,238 @@ python ./scripts/eval_search_quality.py 4. 非 `zh/en` 语种字段动态拼接(如 `de/fr/es`) +# 搜索pipeline +**整体图** +这个 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 的每个分数怎么出来,完整手推一遍。 + + ## reranker方面: BAAI/bge-reranker-v2-m3的一个严重badcase: diff --git a/frontend/static/js/app.js b/frontend/static/js/app.js index 55760ca..d0e2b4f 100644 --- a/frontend/static/js/app.js +++ b/frontend/static/js/app.js @@ -546,22 +546,25 @@ function buildProductDebugHtml({ debug, result, spuId, tenantId }) { ${buildStageCard('Fine Rank', 'Lightweight reranker output', [ { label: 'rank', value: fineStage.rank ?? 'N/A' }, { label: 'rank_change', value: fineStage.rank_change ?? 'N/A' }, - { label: 'fine_score', value: formatDebugNumber(fineStage.score ?? debug.fine_score) }, - ], renderJsonDetails('Fine Input', fineStage.rerank_input ?? debug.rerank_input, false))} + { label: 'stage_score', value: formatDebugNumber(fineStage.score ?? debug.score) }, + { label: 'fine_score', value: formatDebugNumber(fineStage.fine_score ?? debug.fine_score) }, + { label: 'text_score', value: formatDebugNumber(fineStage.text_score ?? debug.text_score) }, + { label: 'knn_score', value: formatDebugNumber(fineStage.knn_score ?? debug.knn_score) }, + ], `${renderJsonDetails('Fine Fusion', fineStage.fusion_summary || debug.fusion_summary || fineStage.fusion_factors, false)}${renderJsonDetails('Fine Input', fineStage.rerank_input ?? debug.rerank_input, false)}`)} ${buildStageCard('Final Rerank', 'Heavy reranker + final fusion', [ { label: 'rank', value: rerankStage.rank ?? finalPageStage.rank ?? debug.final_rank ?? 'N/A' }, { label: 'rank_change', value: rerankStage.rank_change ?? finalPageStage.rank_change ?? 'N/A' }, + { label: 'stage_score', value: formatDebugNumber(rerankStage.score ?? rerankStage.fused_score ?? debug.score) }, { label: 'rerank_score', value: formatDebugNumber(rerankStage.rerank_score ?? debug.rerank_score) }, + { label: 'fine_score', value: formatDebugNumber(rerankStage.fine_score ?? debug.fine_score) }, { label: 'text_score', value: formatDebugNumber(rerankStage.text_score ?? debug.text_score) }, { label: 'knn_score', value: formatDebugNumber(rerankStage.knn_score ?? debug.knn_score) }, - { label: 'text_source', value: formatDebugNumber(rerankStage.signals?.text_source_score ?? debug.text_source_score) }, - { label: 'text_translation', value: formatDebugNumber(rerankStage.signals?.text_translation_score ?? debug.text_translation_score) }, { label: 'fine_factor', value: formatDebugNumber(rerankStage.fine_factor ?? debug.fine_factor) }, { label: 'rerank_factor', value: formatDebugNumber(rerankStage.rerank_factor ?? debug.rerank_factor) }, { label: 'text_factor', value: formatDebugNumber(rerankStage.text_factor ?? debug.text_factor) }, { label: 'knn_factor', value: formatDebugNumber(rerankStage.knn_factor ?? debug.knn_factor) }, { label: 'fused_score', value: formatDebugNumber(rerankStage.fused_score ?? debug.fused_score) }, - ], renderJsonDetails('Rerank Signals', rerankStage.signals, false))} + ], `${renderJsonDetails('Final Fusion', rerankStage.fusion_summary || debug.fusion_summary || rerankStage.fusion_factors, false)}${renderJsonDetails('Rerank Signals', rerankStage.signals, false)}`)} `; diff --git a/search/rerank_client.py b/search/rerank_client.py index f0c7044..560ce1c 100644 --- a/search/rerank_client.py +++ b/search/rerank_client.py @@ -239,22 +239,96 @@ def _collect_text_score_components(matched_queries: Any, fallback_es_score: floa } -def _multiply_fusion_factors( - rerank_score: float, - fine_score: Optional[float], +def _format_debug_float(value: float) -> str: + return f"{float(value):.6g}" + + +def _build_hit_signal_bundle( + hit: Dict[str, Any], + fusion: CoarseRankFusionConfig | RerankFusionConfig, +) -> Dict[str, Any]: + es_score = _to_score(hit.get("_score")) + matched_queries = hit.get("matched_queries") + text_components = _collect_text_score_components(matched_queries, es_score) + knn_components = _collect_knn_score_components(matched_queries, fusion) + return { + "doc_id": hit.get("_id"), + "es_score": es_score, + "matched_queries": matched_queries, + "text_components": text_components, + "knn_components": knn_components, + "text_score": text_components["text_score"], + "knn_score": knn_components["knn_score"], + } + + +def _build_formula_summary( + term_rows: List[Dict[str, Any]], + style_boost: float, + final_score: float, +) -> str: + segments = [ + ( + f"{row['name']}=(" + f"{_format_debug_float(row['raw_score'])}" + f"+{_format_debug_float(row['bias'])})" + f"^{_format_debug_float(row['exponent'])}" + f"={_format_debug_float(row['factor'])}" + ) + for row in term_rows + ] + if style_boost != 1.0: + segments.append(f"style_boost={_format_debug_float(style_boost)}") + segments.append(f"final={_format_debug_float(final_score)}") + return " | ".join(segments) + + +def _compute_multiplicative_fusion( + *, text_score: float, knn_score: float, fusion: RerankFusionConfig, -) -> Tuple[float, float, float, float, float]: - """(rerank_factor, fine_factor, text_factor, knn_factor, fused_without_style_boost).""" - r = (max(rerank_score, 0.0) + fusion.rerank_bias) ** fusion.rerank_exponent - if fine_score is None: - f = 1.0 - else: - f = (max(fine_score, 0.0) + fusion.fine_bias) ** fusion.fine_exponent - t = (max(text_score, 0.0) + fusion.text_bias) ** fusion.text_exponent - k = (max(knn_score, 0.0) + fusion.knn_bias) ** fusion.knn_exponent - return r, f, t, k, r * f * t * k + rerank_score: Optional[float] = None, + fine_score: Optional[float] = None, + style_boost: float = 1.0, +) -> Dict[str, Any]: + term_rows: List[Dict[str, Any]] = [] + + def _add_term(name: str, raw_score: Optional[float], bias: float, exponent: float) -> None: + if raw_score is None: + return + factor = (max(float(raw_score), 0.0) + bias) ** exponent + term_rows.append( + { + "name": name, + "raw_score": float(raw_score), + "bias": float(bias), + "exponent": float(exponent), + "factor": factor, + } + ) + + _add_term("rerank_score", rerank_score, fusion.rerank_bias, fusion.rerank_exponent) + _add_term("fine_score", fine_score, fusion.fine_bias, fusion.fine_exponent) + _add_term("text_score", text_score, fusion.text_bias, fusion.text_exponent) + _add_term("knn_score", knn_score, fusion.knn_bias, fusion.knn_exponent) + + fused = 1.0 + factors: Dict[str, float] = {} + inputs: Dict[str, float] = {} + for row in term_rows: + fused *= row["factor"] + factors[row["name"]] = row["factor"] + inputs[row["name"]] = row["raw_score"] + fused *= style_boost + factors["style_boost"] = style_boost + + return { + "inputs": inputs, + "factors": factors, + "score": fused, + "summary": _build_formula_summary(term_rows, style_boost, fused), + } def _multiply_coarse_fusion_factors( @@ -283,12 +357,13 @@ def coarse_resort_hits( f = fusion or CoarseRankFusionConfig() coarse_debug: List[Dict[str, Any]] = [] if debug else [] for hit in es_hits: - es_score = _to_score(hit.get("_score")) - matched_queries = hit.get("matched_queries") - knn_components = _collect_knn_score_components(matched_queries, f) - text_components = _collect_text_score_components(matched_queries, es_score) - text_score = text_components["text_score"] - knn_score = knn_components["knn_score"] + signal_bundle = _build_hit_signal_bundle(hit, f) + es_score = signal_bundle["es_score"] + matched_queries = signal_bundle["matched_queries"] + text_components = signal_bundle["text_components"] + knn_components = signal_bundle["knn_components"] + text_score = signal_bundle["text_score"] + knn_score = signal_bundle["knn_score"] text_factor, knn_factor, coarse_score = _multiply_coarse_fusion_factors( text_score=text_score, knn_score=knn_score, @@ -372,77 +447,81 @@ def fuse_scores_and_resort( n = len(es_hits) if n == 0 or len(rerank_scores) != n: return [] - if fine_scores is not None and len(fine_scores) != n: - fine_scores = None - f = fusion or RerankFusionConfig() fused_debug: List[Dict[str, Any]] = [] if debug else [] for idx, hit in enumerate(es_hits): - es_score = _to_score(hit.get("_score")) + signal_bundle = _build_hit_signal_bundle(hit, f) + text_components = signal_bundle["text_components"] + knn_components = signal_bundle["knn_components"] + text_score = signal_bundle["text_score"] + knn_score = signal_bundle["knn_score"] rerank_score = _to_score(rerank_scores[idx]) - fine_score = _to_score(fine_scores[idx]) if fine_scores is not None else _to_score(hit.get("_fine_score")) - matched_queries = hit.get("matched_queries") - knn_components = _collect_knn_score_components(matched_queries, f) - knn_score = knn_components["knn_score"] - text_components = _collect_text_score_components(matched_queries, es_score) - text_score = text_components["text_score"] - rerank_factor, fine_factor, text_factor, knn_factor, fused = _multiply_fusion_factors( - rerank_score, fine_score if fine_scores is not None or "_fine_score" in hit else None, text_score, knn_score, f + fine_score_raw = ( + _to_score(fine_scores[idx]) + if fine_scores is not None and len(fine_scores) == n + else _to_score(hit.get("_fine_score")) ) + fine_score = fine_score_raw if (fine_scores is not None and len(fine_scores) == n) or "_fine_score" in hit else None sku_selected = _has_selected_sku(hit) style_boost = style_intent_selected_sku_boost if sku_selected else 1.0 - fused *= style_boost + fusion_result = _compute_multiplicative_fusion( + rerank_score=rerank_score, + fine_score=fine_score, + text_score=text_score, + knn_score=knn_score, + fusion=f, + style_boost=style_boost, + ) + fused = fusion_result["score"] hit["_original_score"] = hit.get("_score") hit["_rerank_score"] = rerank_score - hit["_fine_score"] = fine_score + if fine_score is not None: + hit["_fine_score"] = fine_score hit["_text_score"] = text_score hit["_knn_score"] = knn_score hit["_text_knn_score"] = knn_components["text_knn_score"] hit["_image_knn_score"] = knn_components["image_knn_score"] hit["_fused_score"] = fused hit["_style_intent_selected_sku_boost"] = style_boost - if debug: - hit["_text_source_score"] = text_components["source_score"] - hit["_text_translation_score"] = text_components["translation_score"] - hit["_text_primary_score"] = text_components["primary_text_score"] - hit["_text_support_score"] = text_components["support_text_score"] - hit["_knn_primary_score"] = knn_components["primary_knn_score"] - hit["_knn_support_score"] = knn_components["support_knn_score"] if debug: debug_entry = { "doc_id": hit.get("_id"), - "es_score": es_score, + "score": fused, + "es_score": signal_bundle["es_score"], "rerank_score": rerank_score, "fine_score": fine_score, "text_score": text_score, + "knn_score": knn_score, + "fusion_inputs": fusion_result["inputs"], + "fusion_factors": fusion_result["factors"], + "fusion_summary": fusion_result["summary"], "text_source_score": text_components["source_score"], "text_translation_score": text_components["translation_score"], "text_weighted_source_score": text_components["weighted_source_score"], "text_weighted_translation_score": text_components["weighted_translation_score"], "text_primary_score": text_components["primary_text_score"], "text_support_score": text_components["support_text_score"], - "text_score_fallback_to_es": ( - text_score == es_score - and text_components["source_score"] <= 0.0 - and text_components["translation_score"] <= 0.0 - ), "text_knn_score": knn_components["text_knn_score"], "image_knn_score": knn_components["image_knn_score"], "weighted_text_knn_score": knn_components["weighted_text_knn_score"], "weighted_image_knn_score": knn_components["weighted_image_knn_score"], "knn_primary_score": knn_components["primary_knn_score"], "knn_support_score": knn_components["support_knn_score"], - "knn_score": knn_score, - "rerank_factor": rerank_factor, - "fine_factor": fine_factor, - "text_factor": text_factor, - "knn_factor": knn_factor, + "text_score_fallback_to_es": ( + text_score == signal_bundle["es_score"] + and text_components["source_score"] <= 0.0 + and text_components["translation_score"] <= 0.0 + ), + "rerank_factor": fusion_result["factors"].get("rerank_score"), + "fine_factor": fusion_result["factors"].get("fine_score"), + "text_factor": fusion_result["factors"].get("text_score"), + "knn_factor": fusion_result["factors"].get("knn_score"), "style_intent_selected_sku": sku_selected, "style_intent_selected_sku_boost": style_boost, - "matched_queries": matched_queries, + "matched_queries": signal_bundle["matched_queries"], "fused_score": fused, } if rerank_debug_rows is not None and idx < len(rerank_debug_rows): @@ -530,9 +609,11 @@ def run_lightweight_rerank( rerank_doc_template: str = "{title}", top_n: Optional[int] = None, debug: bool = False, + fusion: Optional[RerankFusionConfig] = None, + style_intent_selected_sku_boost: float = 1.2, service_profile: Optional[str] = "fine", ) -> Tuple[Optional[List[float]], Optional[Dict[str, Any]], List[Dict[str, Any]]]: - """Call lightweight reranker and attach scores to hits without final fusion.""" + """Call lightweight reranker and rank by lightweight-model fusion.""" if not es_hits: return [], {}, [] @@ -554,18 +635,50 @@ def run_lightweight_rerank( if scores is None or len(scores) != len(es_hits): return None, None, [] + f = fusion or RerankFusionConfig() debug_rows: List[Dict[str, Any]] = [] if debug else [] for idx, hit in enumerate(es_hits): + signal_bundle = _build_hit_signal_bundle(hit, f) + text_score = signal_bundle["text_score"] + knn_score = signal_bundle["knn_score"] fine_score = _to_score(scores[idx]) + sku_selected = _has_selected_sku(hit) + style_boost = style_intent_selected_sku_boost if sku_selected else 1.0 + fusion_result = _compute_multiplicative_fusion( + fine_score=fine_score, + text_score=text_score, + knn_score=knn_score, + fusion=f, + style_boost=style_boost, + ) + hit["_fine_score"] = fine_score + hit["_fine_fused_score"] = fusion_result["score"] + hit["_text_score"] = text_score + hit["_knn_score"] = knn_score + hit["_text_knn_score"] = signal_bundle["knn_components"]["text_knn_score"] + hit["_image_knn_score"] = signal_bundle["knn_components"]["image_knn_score"] + hit["_style_intent_selected_sku_boost"] = style_boost + if debug: row: Dict[str, Any] = { "doc_id": hit.get("_id"), + "score": fusion_result["score"], "fine_score": fine_score, + "text_score": text_score, + "knn_score": knn_score, + "fusion_inputs": fusion_result["inputs"], + "fusion_factors": fusion_result["factors"], + "fusion_summary": fusion_result["summary"], + "fine_factor": fusion_result["factors"].get("fine_score"), + "text_factor": fusion_result["factors"].get("text_score"), + "knn_factor": fusion_result["factors"].get("knn_score"), + "style_intent_selected_sku": sku_selected, + "style_intent_selected_sku_boost": style_boost, } if rerank_debug_rows is not None and idx < len(rerank_debug_rows): row["rerank_input"] = rerank_debug_rows[idx] debug_rows.append(row) - es_hits.sort(key=lambda h: h.get("_fine_score", 0.0), reverse=True) + es_hits.sort(key=lambda h: h.get("_fine_fused_score", h.get("_fine_score", 0.0)), reverse=True) return scores, meta, debug_rows diff --git a/search/searcher.py b/search/searcher.py index c490184..37f4ffe 100644 --- a/search/searcher.py +++ b/search/searcher.py @@ -720,6 +720,8 @@ class Searcher: rerank_doc_template=fine_doc_template, top_n=fine_output_window, debug=debug, + fusion=rc.fusion, + style_intent_selected_sku_boost=self.config.query_config.style_intent_selected_sku_boost, service_profile=fine_cfg.service_profile, ) if fine_scores is not None: @@ -745,6 +747,7 @@ class Searcher: "docs_out": len(hits), "top_n": fine_output_window, "meta": fine_meta, + "fusion": asdict(rc.fusion), } context.store_intermediate_result("fine_rank_scores", fine_debug_rows) context.logger.info( @@ -781,7 +784,6 @@ class Searcher: top_n=(from_ + size), debug=debug, fusion=rc.fusion, - fine_scores=fine_scores[:len(final_input)] if fine_scores is not None else None, service_profile=rc.service_profile, style_intent_selected_sku_boost=self.config.query_config.style_intent_selected_sku_boost, ) @@ -1026,18 +1028,14 @@ class Searcher: # 若存在重排调试信息,则补充 doc 级别的融合分数信息 if rerank_debug: debug_entry["doc_id"] = rerank_debug.get("doc_id") - # 与 rerank_client 中字段保持一致,便于前端直接使用 + debug_entry["score"] = rerank_debug.get("score") debug_entry["rerank_score"] = rerank_debug.get("rerank_score") debug_entry["fine_score"] = rerank_debug.get("fine_score") debug_entry["text_score"] = rerank_debug.get("text_score") - debug_entry["text_source_score"] = rerank_debug.get("text_source_score") - debug_entry["text_translation_score"] = rerank_debug.get("text_translation_score") - debug_entry["text_weighted_source_score"] = rerank_debug.get("text_weighted_source_score") - debug_entry["text_weighted_translation_score"] = rerank_debug.get("text_weighted_translation_score") - debug_entry["text_primary_score"] = rerank_debug.get("text_primary_score") - debug_entry["text_support_score"] = rerank_debug.get("text_support_score") - debug_entry["text_score_fallback_to_es"] = rerank_debug.get("text_score_fallback_to_es") debug_entry["knn_score"] = rerank_debug.get("knn_score") + debug_entry["fusion_inputs"] = rerank_debug.get("fusion_inputs") + debug_entry["fusion_factors"] = rerank_debug.get("fusion_factors") + debug_entry["fusion_summary"] = rerank_debug.get("fusion_summary") debug_entry["rerank_factor"] = rerank_debug.get("rerank_factor") debug_entry["fine_factor"] = rerank_debug.get("fine_factor") debug_entry["text_factor"] = rerank_debug.get("text_factor") @@ -1047,7 +1045,13 @@ class Searcher: debug_entry["matched_queries"] = rerank_debug.get("matched_queries") elif fine_debug: debug_entry["doc_id"] = fine_debug.get("doc_id") + debug_entry["score"] = fine_debug.get("score") debug_entry["fine_score"] = fine_debug.get("fine_score") + debug_entry["text_score"] = fine_debug.get("text_score") + debug_entry["knn_score"] = fine_debug.get("knn_score") + debug_entry["fusion_inputs"] = fine_debug.get("fusion_inputs") + debug_entry["fusion_factors"] = fine_debug.get("fusion_factors") + debug_entry["fusion_summary"] = fine_debug.get("fusion_summary") debug_entry["rerank_input"] = fine_debug.get("rerank_input") initial_rank = initial_ranks_by_doc.get(str(doc_id)) if doc_id is not None else None @@ -1081,17 +1085,32 @@ class Searcher: "fine_rank": { "rank": fine_rank, "rank_change": _rank_change(coarse_rank, fine_rank), - "score": fine_debug.get("fine_score") if fine_debug else hit.get("_fine_score"), + "score": ( + fine_debug.get("score") + if fine_debug and fine_debug.get("score") is not None + else hit.get("_fine_fused_score", hit.get("_fine_score")) + ), + "fine_score": fine_debug.get("fine_score") if fine_debug else hit.get("_fine_score"), + "text_score": fine_debug.get("text_score") if fine_debug else hit.get("_text_score"), + "knn_score": fine_debug.get("knn_score") if fine_debug else hit.get("_knn_score"), + "fusion_summary": fine_debug.get("fusion_summary") if fine_debug else None, + "fusion_inputs": fine_debug.get("fusion_inputs") if fine_debug else None, + "fusion_factors": fine_debug.get("fusion_factors") if fine_debug else None, "rerank_input": fine_debug.get("rerank_input") if fine_debug else None, + "signals": fine_debug, }, "rerank": { "rank": rerank_rank, "rank_change": _rank_change(fine_rank, rerank_rank), + "score": rerank_debug.get("score") if rerank_debug else hit.get("_fused_score"), "rerank_score": rerank_debug.get("rerank_score") if rerank_debug else hit.get("_rerank_score"), "fine_score": rerank_debug.get("fine_score") if rerank_debug else hit.get("_fine_score"), "fused_score": rerank_debug.get("fused_score") if rerank_debug else hit.get("_fused_score"), "text_score": rerank_debug.get("text_score") if rerank_debug else hit.get("_text_score"), "knn_score": rerank_debug.get("knn_score") if rerank_debug else hit.get("_knn_score"), + "fusion_summary": rerank_debug.get("fusion_summary") if rerank_debug else None, + "fusion_inputs": rerank_debug.get("fusion_inputs") if rerank_debug else None, + "fusion_factors": rerank_debug.get("fusion_factors") if rerank_debug else None, "rerank_factor": rerank_debug.get("rerank_factor") if rerank_debug else None, "fine_factor": rerank_debug.get("fine_factor") if rerank_debug else None, "text_factor": rerank_debug.get("text_factor") if rerank_debug else None, diff --git a/tests/test_rerank_client.py b/tests/test_rerank_client.py index 459b3f8..8ef8210 100644 --- a/tests/test_rerank_client.py +++ b/tests/test_rerank_client.py @@ -1,7 +1,7 @@ from math import isclose from config.schema import RerankFusionConfig -from search.rerank_client import fuse_scores_and_resort +from search.rerank_client import fuse_scores_and_resort, run_lightweight_rerank def test_fuse_scores_and_resort_aggregates_text_components_and_keeps_rerank_primary(): @@ -204,3 +204,57 @@ def test_fuse_scores_and_resort_applies_knn_dismax_weights_and_tie_breaker(): assert isclose(debug[0]["weighted_image_knn_score"], 0.5, rel_tol=1e-9) assert isclose(debug[0]["knn_primary_score"], 0.8, rel_tol=1e-9) assert isclose(debug[0]["knn_support_score"], 0.5, rel_tol=1e-9) + + +def test_run_lightweight_rerank_sorts_by_fused_stage_score(monkeypatch): + hits = [ + { + "_id": "fine-raw-better", + "_score": 1.0, + "_source": {"title": {"en": "Alpha"}}, + "matched_queries": {"base_query": 0.5, "knn_query": 0.0}, + }, + { + "_id": "fusion-better", + "_score": 1.0, + "_source": {"title": {"en": "Beta"}}, + "matched_queries": {"base_query": 40.0, "knn_query": 0.0}, + }, + ] + + monkeypatch.setattr( + "search.rerank_client.call_rerank_service", + lambda *args, **kwargs: ([0.9, 0.8], {"model": "fine-bge"}), + ) + + scores, meta, debug_rows = run_lightweight_rerank( + query="toy", + es_hits=hits, + language="en", + debug=True, + ) + + assert scores == [0.9, 0.8] + assert meta == {"model": "fine-bge"} + assert [hit["_id"] for hit in hits] == ["fusion-better", "fine-raw-better"] + assert hits[0]["_fine_fused_score"] > hits[1]["_fine_fused_score"] + assert debug_rows[0]["fusion_summary"] + assert "fine_score=" in debug_rows[0]["fusion_summary"] + assert "text_score=" in debug_rows[0]["fusion_summary"] + + +def test_fuse_scores_and_resort_uses_hit_level_fine_score_when_not_passed_separately(): + hits = [ + { + "_id": "with-fine", + "_score": 1.0, + "_fine_score": 0.7, + "matched_queries": {"base_query": 2.0, "knn_query": 0.5}, + } + ] + + debug = fuse_scores_and_resort(hits, [0.8], debug=True) + + assert isclose(debug[0]["fine_factor"], (0.7 + 0.00001), rel_tol=1e-9) + assert debug[0]["fusion_inputs"]["fine_score"] == 0.7 + assert "fine_score=" in debug[0]["fusion_summary"] -- libgit2 0.21.2