# 相关性检索优化说明（当前实现）

## 1. 文档目标

本文描述当前代码中的文本检索策略，重点覆盖：

- 多语言检索路由（`detector` / `translator` / `indexed` 的关系）
- 统一文本召回表达式（无布尔 AST 分支）
- 解析层与检索表达式层的职责边界
- 重排融合打分与调试字段
- 典型场景下实际生成的 ES 查询结构

> 说明：向量召回（KNN）是另一维度，本篇仅简要提及，不展开。

## 2. 核心流程

查询链路（文本相关）：

1. `QueryParser.parse()`  
   负责产出解析事实：`query_normalized`、`rewritten_query`、`detected_language`、`translations`、`query_vector`、`query_tokens`。
2. `Searcher.search()`  
   负责读取租户 `index_languages`，并将其传给 `QueryParser` 作为 `target_languages`（控制翻译目标语种）；`ESQueryBuilder` 仅根据 `detected_language` 与各条译文构建子句字段，不再接收 `index_languages`。
2. `ESQueryBuilder._build_advanced_text_query()`  
   基于 `rewritten_query + detected_language + translations + index_languages` 构建 `base_query` 与 `base_query_trans_*`；并按语言动态拼接 `title/brief/description/vendor/category_*` 的 `.{lang}` 字段，叠加 shared 字段（`tags`、`option*_values`）。
3. `build_query()`  
   统一走文本策略，不再有布尔 AST 枝路。

## 3. 能力矩阵（Detector / Translator / Indexed）

三类能力的职责边界：

- **Detector**：识别 query 源语言（`detected_language`）
- **Indexed**：租户可检索语言集合（`tenant_config.*.index_languages`）
- **Translator**：源语言到目标语言的可翻译能力及实时成功率

### 3.1 决策规则

1. 若 `detected_language in index_languages`：  
   源语言字段做主召回；其他语言走翻译补召回（低权重）。
2. 若 `detected_language not in index_languages`：  
   翻译到 `index_languages` 是主路径；源语言字段仅作弱召回。
3. 若翻译部分失败或全部失败：  
   当前实现不会再额外生成“原文打到其他语种字段”的兜底子句；系统保留 `base_query` 并继续执行，可观测性由 `translations` / warning / 命名子句分数提供。

### 3.2 翻译与向量：并发提交与共享超时

`QueryParser.parse()` 内对翻译与向量采用线程池提交 + **一次** `concurrent.futures.wait`：

- **翻译**：对调用方传入的 `target_languages` 中、除 `detected_language` 外的每个目标语种各提交一个 `translator.translate` 任务（多目标时并发执行）。
- **查询向量**：若开启 `enable_text_embedding`，再提交一个 `text_encoder.encode` 任务。
- 上述任务进入**同一** future 集合；例如租户索引为 `[zh, en]` 且检测语种**不在**索引内时，常为 **2 路翻译 + 1 路向量，共 3 个任务并发**，共用超时。

**等待预算（毫秒）**由 `detected_language` 是否属于调用方传入的 `target_languages` 决定（`query_config`）：

- **在索引内**：`translation_embedding_wait_budget_ms_source_in_index`（默认较短，如 80ms）— 主召回已能打在源语种字段，翻译/向量稍慢可容忍。
- **不在索引内**：`translation_embedding_wait_budget_ms_source_not_in_index`（默认较长，如 200ms）— 翻译对可检索文本更关键，给足时间。

超时未完成的任务会被丢弃并记 warning，解析继续（可能无部分译文或无数向量）。

## 4. 统一文本召回表达式

每个语言子句的基础形态：

```json
{
  "multi_match": {
    "_name": "base_query|base_query_trans_xx",
    "query": "<text>",
    "fields": ["title.xx^3.0", "brief.xx^1.5", "...", "tags", "option1_values^0.5", "..."],
    "minimum_should_match": "75%",
    "tie_breaker": 0.9,
    "boost": "<按策略决定，可省略>"
  }
}
```

最终按 `bool.should` 组合，`minimum_should_match: 1`。

## 5. 关键配置项（文本策略）

`query_config` 下与解析等待相关的项：

- `translation_embedding_wait_budget_ms_source_in_index`
- `translation_embedding_wait_budget_ms_source_not_in_index`

位于 `config/config.yaml -> query_config.text_query_strategy`：

- `base_minimum_should_match`
- `translation_minimum_should_match`
- `translation_boost`（所有 `base_query_trans_*` 共用）
- `tie_breaker_base_query`

说明：

- `phrase_query` / `keywords_query` 已从当前实现中移除，文本相关性只由 `base_query`、`base_query_trans_*` 两类子句组成。

## 6. 典型场景与实际 DSL

以下示例来自当前 `ESQueryBuilder` 生成结果（已按当前代码验证）。

### 场景 A：源语种已在索引语言中，且翻译成功

- `detected_language=de`
- `index_languages=[de,en]`
- `rewritten_query="herren schuhe"`
- `translations={en:"men shoes"}`

策略结果：

- `base_query`：德语字段，**不写** `multi_match.boost`
- `base_query_trans_en`：英语字段，`boost=translation_boost`（默认 0.4）

### 场景 B：源语种不在索引语言中，部分翻译缺失

- `detected_language=de`
- `index_languages=[en,zh]`
- 只翻译出 `en`，`zh` 失败

策略结果：

- `base_query`（德语字段）：**不写** `multi_match.boost`（默认 1.0）
- `base_query_trans_en`（英文字段）：`boost=translation_boost`（如 0.4）
- 不会生成额外中文兜底子句

### 场景 C：源语种不在索引语言中，翻译全部失败

- `detected_language=de`
- `index_languages=[en,zh]`
- `translations={}`

策略结果：

- `base_query`（德语字段，**无** `boost` 字段）
- 不会生成 `base_query_trans_*`

这意味着当前实现优先保证职责清晰与可解释性，而不是继续在 Builder 内部隐式制造“跨语种原文兜底”。

## 7. QueryParser 与 Searcher / ESBuilder 的职责分工

- `QueryParser` 负责“解析事实”：
  - `query_normalized`
  - `rewritten_query`
  - `detected_language`
  - `translations`
  - `query_vector`
  - `query_tokens`
- `Searcher` 负责“租户语境”：
  - `index_languages`
  - 将其传给 parser 作为 `target_languages`
- `ESQueryBuilder` 负责“表达式展开”：
  - 动态字段组装
  - 子句权重分配
  - `base_query` / `base_query_trans_*` 子句拼接
  - 跳过“与 base_query 文本和语言完全相同”的重复翻译子句

这种分层让 parser 不再返回 ES 专用的“语言计划字段”，职责边界更清晰。

## 8. 融合打分（ES + Text + KNN + Model）

当前融合逻辑位于 `search/rerank_client.py`。

### 8.1 文本相关性大分

文本大分由两部分组成：

- `base_query`
- `base_query_trans_*`

聚合方式：

1. `source_score = base_query`
2. `translation_score = max(base_query_trans_*)`
3. 加权：
   - `weighted_source = source_score`
   - `weighted_translation = 0.8 * translation_score`
4. 合成：
   - `primary = max(weighted_source, weighted_translation)`
   - `support = weighted_source + weighted_translation - primary`
   - `text_score = primary + 0.25 * support`

如果以上子分都缺失，则回退到 ES `_score` 作为 `text_score`，避免纯文本召回被误打成 0。

### 8.2 向量相关性大分

向量不是两路分别进入最终公式，而是**先融合成一个统一的 `knn_score`**。

当前实现位于 `search/rerank_client.py` 的 `_collect_knn_score_components()`：

1. `text_knn_score = matched_queries["knn_query"]`
2. `image_knn_score = matched_queries["image_knn_query"]`
3. 分别乘权重：
   - `weighted_text_knn_score = knn_text_weight * text_knn_score`
   - `weighted_image_knn_score = knn_image_weight * image_knn_score`
4. 再做一层 dismax 融合：
   - `primary_knn_score = max(weighted_text_knn_score, weighted_image_knn_score)`
   - `support_knn_score = 另一侧较弱信号`
   - `knn_score = primary_knn_score + knn_tie_breaker * support_knn_score`

当前默认配置在 [config.yaml](/data/saas-search/config/config.yaml) 中是：

- `knn_text_weight = 1.0`
- `knn_image_weight = 1.0`
- `knn_tie_breaker = 0.1`

也就是说：

- 现在确实是“文本 KNN + 图片 KNN 先融合成一项 `knn_score`”
- 但**图片权重目前并没有略高于文本权重**
- 当前两路权重是相等的，只是通过 dismax 机制保留“主路 + 辅助路”

如果业务上希望 image 语义更主导，可以把 `knn_image_weight` 调成略高于 `knn_text_weight`，例如 `1.1 ~ 1.3` 这一类小幅领先值，再观察 query 分布与 bad case。

### 8.3 各阶段融合公式

```python
coarse_score = (
    (es_score + es_bias) ** es_exponent
    * (text_score + text_bias) ** text_exponent
    * (knn_score + knn_bias) ** knn_exponent
)

fine_stage_score = (
    (es_score + es_bias) ** es_exponent
    * (fine_score + fine_bias) ** fine_exponent
    * (text_score + text_bias) ** text_exponent
    * (knn_score + knn_bias) ** knn_exponent
    * style_boost
)

final_score = (
    (es_score + es_bias) ** es_exponent
    * (rerank_score + rerank_bias) ** rerank_exponent
    * (fine_score + fine_bias) ** fine_exponent   # 仅当 fine rank 打开且有分数时参与
    * (text_score + text_bias) ** text_exponent
    * (knn_score + knn_bias) ** knn_exponent
    * style_boost
)
```

当前默认配置下：

- `coarse`: `es_exponent=0.05`, `text_exponent=0.35`, `knn_exponent=0.2`
- `fine/final`: `es_exponent=0.05`, `text_exponent=0.25`, `knn_exponent=0.2`
- `final`: 额外有 `rerank_exponent=1.15`

设计意图可以概括成：

- `es_score` 不再只做 debug，而是作为全阶段都保留的弱先验
- `text_score` 是稳定主干信号
- `knn_score` 是统一的语义信号入口
- `fine_score` / `rerank_score` 是越往后越贵、越强的模型因子
- `style_boost` 只在命中已选 SKU 时乘上去

### 8.4 调试字段

开启 `debug=true` 后，`debug_info.per_result` 会暴露：

- `es_score`
- `es_factor`
- `rerank_score`
- `retrieval_plan`（query 级）
- `ltr_summary`（query 级）
- `ltr_features`（doc/stage 级稳定特征块）

### 8.5 面向调参与 LTR 的新日志视角

为了让 bad case 分析和后续 LTR 更直接，当前调试信息建议按下面三层来读：

1. **Query 级：`retrieval_plan`**
   看这次请求到底走了哪套 KNN 计划：
   - `text_knn.k / num_candidates`
   - `image_knn.k / num_candidates`
   - 是否命中长查询分支

2. **Top-N 汇总：`ltr_summary`**
   看最终第一页/前 20 个结果中，信号分布是否异常：
   - `translation_match_docs`
   - `text_knn_docs`
   - `image_knn_docs`
   - `text_fallback_to_es_docs`
   - 各类 score 的均值

3. **Doc 级漏斗：`per_result[*].ranking_funnel.*.ltr_features`**
   用稳定特征字段来判断“为什么这个 doc 上去了/没上去”：
   - 文本主召回还是翻译召回在主导
   - text KNN / image KNN 哪一路在抬分
   - rerank 是否足够强，能否纠正上游 lexical 噪声

推荐的诊断顺序：

- 先看 `missing_relevant`，确认是不是**召回缺失**。
- 若召回到了但没进最终页，先看 `coarse_rank` 是否提前裁掉。
- 若进了 rerank 窗口但排序差，再比较 `rerank_score` 与 `text_score/knn_score` 谁在压制谁。
- 对多语言 query，优先确认 `source_score` 是否为 0；如果是，说明当前结果主要吃翻译召回。
- `text_score`
- `text_source_score`
- `text_translation_score`
- `text_primary_score`
- `text_support_score`
- `knn_score`
- `fused_score`
- `matched_queries`

`debug_info.query_analysis` 还会暴露：

- `translations`
- `detected_language`
- `rewritten_query`

这些字段用于检索效果评估与 bad case 归因。

## 9. 兼容与注意事项

1. 当前文本主链路已移除布尔 AST 分支。  
2. 文档中的旧描述（如 `operator: AND` 固定开启）不再适用，当前实现未强制设置该参数。  
3. `HanLP` 为必需依赖；当前 parser 不再提供轻量 fallback。  
4. 若后续扩展到更多语种，请确保：
   - mapping 中存在对应 `.<lang>` 字段
   - `index_languages` 配置在支持列表内
   - 翻译 provider 对目标语种可用

## 10. 评估与复现

建议使用项目根目录虚拟环境：

```bash
cd /data/saas-search
source ./activate.sh
python -m pytest -q tests/test_rerank_client.py tests/test_es_query_builder.py tests/test_search_rerank_window.py tests/test_query_parser_mixed_language.py
./scripts/service_ctl.sh restart backend
sleep 3
./scripts/service_ctl.sh status backend
./scripts/evaluation/start_eval.sh batch
```

评估产物在 `artifacts/search_evaluation/`（如 `search_eval.sqlite3`、`batch_reports/` 下的 JSON/Markdown）。流程与参数说明见 [scripts/evaluation/README.md](../scripts/evaluation/README.md)。

## 11. 建议测试清单

建议在 `tests/` 增加文本策略用例：

1. 源语种在索引语言，翻译命中缓存  
2. 源语种不在索引语言，翻译部分失败（验证仅保留 `base_query` + 成功翻译子句）  
3. 源语种不在索引语言，翻译全部失败（验证无 `base_query_trans_*` 时仍可正常执行）  
4. 非 `zh/en` 语种字段动态拼接（如 `de/fr/es`）


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

1. Query 解析
2. ES 召回
3. 粗排：ES 原始总分 + 文本大分 + 统一 KNN 大分
4. 款式 SKU 选择 + title suffix
5. 精排：轻量 reranker + ES/text/KNN 融合
6. 最终 rerank：重 reranker + fine score + ES/text/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()`。

粗排现在看三类信号：
- `es_score`
- `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` 做辅助
- 产出一个统一的 `knn_score`

然后粗排融合公式在 [rerank_client.py:346](/data/saas-search/search/rerank_client.py#L346)：
- `coarse_score = es_factor * text_factor * knn_factor`
- `es_factor = (es_score + es_bias)^es_exponent`
- `text_factor = (text_score + text_bias)^text_exponent`
- `knn_factor = (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 = es_factor * fine_factor * text_factor * knn_factor * style_boost`

具体公共计算在 [rerank_client.py:286](/data/saas-search/search/rerank_client.py#L286) 的 `_compute_multiplicative_fusion()`：
- `es_factor = (es_score + es_bias)^es_exponent`
- `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 = es_factor * rerank_factor * fine_factor * text_factor * knn_factor * style_boost`

也就是：
- ES 原始总分也会继续保留到最终阶段
- 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 保留 es/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)
- 当 `fine_rank` 关闭时，`rerank.rank_change` 会继承 `coarse_rank` 作为上游阶段，不会错误地全部显示为 0

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

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

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

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

## 12. 值得优先探索的相关性实验方向

下面这些方向按我对当前 rank 体系的判断，优先级大致是“先做低风险高收益，再做结构性升级”。

### 12.1 Query 分桶，而不是所有 query 共用一套融合参数

当前问题：

- 所有 query 基本共用同一套 exponent / bias
- 但“强词法 query”、“泛类目 query”、“风格词 query”、“图搜触发 query”、“中英混输 query”的最优信号配比通常不同

建议实验：

- 先做轻量 query 分桶：
  - 精准实体词
  - 泛类目词
  - 风格/属性词
  - 中英混输
  - 带强图片语义的 query
- 每个桶单独调：
  - `text_translation_weight`
  - `knn_text_weight / knn_image_weight`
  - `es_exponent / text_exponent / knn_exponent`

为什么值得先做：

- 不改主架构
- 容易上线灰度
- 往往比“全局调一个 exponent”稳定得多

### 12.2 把 image KNN 设成略高于 text KNN，但只在合适 query 上生效

当前问题：

- 现在 `knn_text_weight = 1.0`，`knn_image_weight = 1.0`
- 对鞋、服饰款式、图案、轮廓类 query，image embedding 往往比 text embedding 更接近用户真实意图
- 但不是所有 query 都适合直接全局抬高 image 权重

建议实验：

- 离线先试：
  - `knn_image_weight = 1.1 / 1.2 / 1.3`
  - `knn_text_weight = 1.0`
- 再进一步试 query gating：
  - 若 query 命中款式词、形状词、鞋包词、图案词，则抬高 image weight
  - 若 query 是明确品类词或强属性词，则维持中性

为什么我不建议一上来全局大幅抬高：

- 会把一些“文本很明确，但图像泛相似”的结果抬上来
- 容易让高视觉相似、低语义准确的商品误冲前排

### 12.3 不只融合“分数”，还要融合“排名证据”

当前问题：

- 现在所有阶段都高度依赖 score 级别的乘法融合
- 不同信号源的 score 标度未必天然可比
- reranker 分数、ES score、named query score、KNN score 的数值空间差异很大

建议实验：

- 增加 rank-based 特征：
  - `es_rank`
  - `text_rank`
  - `knn_rank`
  - `rerank_rank`
- 试两类简单方法：
  - RRF（Reciprocal Rank Fusion）
  - score-rank 混合：先做 rank 融合，再乘少量 score 因子

为什么值得做：

- 对异常 score 分布更稳
- 对模型偶发极端分更鲁棒
- 很适合拿来做基线对照

### 12.4 将 `base_query` 和 `translation_query` 从“单点 max”升级为“更完整的 lexical 证据”

当前问题：

- 文本大分现在只抓：
  - `base_query`
  - `max(base_query_trans_*)`
- 这很干净，但可能过于压缩文本证据
- phrase 命中、best_fields 命中、多语言字段命中、字段质量差异，没有更细粒度地进入后续 rank

建议实验：

- 把 lexical 证据拆得更细：
  - exact / phrase
  - best_fields
  - title 命中
  - category 命中
  - brand/vendor 命中
- 后续不一定都入主公式，但可以先做 debug / feature log

这样做的收益：

- 更容易解释“为什么这条词法上明明更准却没排上来”
- 为后续 learning-to-rank 或规则门控准备特征

### 12.5 增加“类目先验”和“商品类型约束”

当前问题：

- 现在体系更偏“文本/向量相似度驱动”
- 对“牛仔裤 vs 连裤袜”这种 bad case，问题常常不只是分数融合，而是**商品类型约束太弱**

建议实验：

- query 侧先做轻量商品类型识别：
  - 裙子
  - 裤子
  - 上衣
  - 鞋
- doc 侧取：
  - category_path
  - taxonomy leaf
  - 类目 embedding / one-hot
- 然后试：
  - 作为 hard filter 候选约束
  - 作为 coarse/final 的 boost 因子
  - 作为 rerank 输入字段增强

这是我认为对明显 bad case 最有价值的一类结构性修复。

### 12.6 把“负证据”纳入体系，而不只是累加正证据

当前问题：

- 当前乘法体系主要是在积累正向因子
- 但很多错误结果不是“正向不够强”，而是“存在明显负证据”
- 例如 query 是“半身裙”，doc 却强命中“上衣”“打底衫”“连裤袜”

建议实验：

- 抽取轻量负词特征：
  - 商品类型冲突词
  - 性别/人群冲突词
  - 长度/版型冲突词
- 方式可以先很简单：
  - penalty factor
  - blacklist term penalty
  - query-doc type mismatch penalty

这是当前体系里非常缺的一块。

### 12.7 把 KNN 从“单一总分”升级为“多语义子通道”

当前问题：

- 现在 KNN 最终会被压成一个 `knn_score`
- 这对工程简单很好，但损失了“这条向量信号到底为什么相似”的信息

建议实验：

- 分通道记录和使用：
  - text semantic similarity
  - image appearance similarity
  - category-aware similarity
  - style-aware similarity
- 即使最终仍合成一个总分，也建议先保留分通道特征

这样未来才能回答：

- 这条结果是“外观像”
- 还是“描述语义像”
- 还是“类目像但款式不对”

### 12.8 从纯手工公式，逐步过渡到轻量 LTR

当前问题：

- 目前公式已经比较清晰，但本质还是手工 feature engineering + 手工 exponent
- 一旦信号变多，靠手调很难长期维护

建议实验：

- 先不引入复杂在线模型
- 先做离线 LTR baseline：
  - LambdaMART / XGBoost ranker
  - 输入现成特征：
    - es_score
    - text_score
    - text_source_score
    - translation_score
    - text_knn_score
    - image_knn_score
    - coarse_rank
    - rerank_score
    - category match
    - style intent match

为什么这一步值得准备：

- 你们现在的 debug 字段已经很接近 feature log 了
- 其实已经具备往 LTR 过渡的土壤

### 12.9 先把评估体系补齐，再谈大改

当前问题：

- 很多相关性讨论容易停留在个例
- 但融合改动经常存在 query 分布层面的 tradeoff

建议实验配套：

- 建立 query slice 指标：
  - 鞋靴
  - 裙装
  - 裤装
  - 中英混输
  - 图像语义强 query
  - 属性词强 query
- 每次实验至少看：
  - overall
  - top 1
  - top 3
  - slice breakdown
  - bad case 回归集

### 12.10 我对当前体系的几个核心判断

1. 当前体系最大的优点不是公式本身，而是已经把信号拆成了可解释的层级，这非常适合继续做实验。
2. 当前体系最大的短板不是“knn exponent 还不够准”，而是缺少 query 分桶、类目先验和负证据。
3. 只调融合公式还能继续拿到一部分收益，但中期最值得投入的是：
   - query-aware 参数
   - 类型/类目约束
   - score + rank 混合融合
   - 为 LTR 做特征沉淀



## reranker方面：
BAAI/bge-reranker-v2-m3的一个严重badcase：
q=黑色中长半身裙

Rerank score: 0.0785
title.zh: 2026款韩版高腰显瘦雪尼尔包臀裙灯芯绒开叉中长款咖啡色半身裙女
title.en: 2026 Korean-style High-waisted Slimming Corduroy Skirt with Slit, Mid-Length Coffee-colored Skirt for Women

Rerank score: 0.9643
title.en: Black Half-high Collar Base Shirt Women's Autumn and Winter fleece-lined Contrast Color Pure Desire Design Sense Horn Sleeve Ruffled Inner Top
title.zh: 黑色高领半高领女士秋冬内搭加绒拼色纯欲设计荷叶边袖内衬上衣



qwen3-0.6b的严重badcase：
q=牛仔裤

Rerank score: 0.0002
title.en: Wrangler Womens Cowboy Cut Slim Fit Jean Bleach
title.zh: Wrangler 女士牛仔裤 牛仔剪裁 紧身版型 漂白色

Rerank score: 0.0168
title.en: Fleece Lined Tights Sheer Women - Fake Translucent Warm Pantyhose Leggings Sheer Thick Tights for Winter
title.zh: 加绒透肤女士连裤袜 - 仿透视保暖长筒袜 冬季厚款透肤连裤袜

Rerank score: 0.1366
title.en: Dockers Men's Classic Fit Workday Khaki Smart 360 FLEX Pants (Standard and Big & Tall)
title.zh: Dockers 男士经典版型工作日卡其色智能360度弹力裤（标准码与加大码）

Rerank score: 0.0981
title.en: Lazy One Pajama Shorts for Men, Men's Pajama Bottoms, Sleepwear
title.zh: 懒人男士睡裤，男式家居裤，睡眠服饰




q=修身牛仔裤

这些好结果得分很低：

rerank_score:0.0564
    "en": "Judy Blue Women's High Waist Button Fly Skinny Jeans 82319",
    "zh": "Judy Blue 女士高腰纽扣开叉修身牛仔裤 82319"

rerank_score:0.0790
    "en": "2025 New Fashion European and American Women's Jeans High-Waisted Slim Straight Denim Pants Popular Floor-Length Pants",
    "zh": "2025新款欧美风女式高腰显瘦直筒牛仔裤 时尚及地长裤"

rerank_score:0.0822
    "en": "roswear Women's Trendy Stretchy Flare Jeans Mid Rise Bootcut Curvy Denim Pants",
    "zh": "Roswear 女士时尚弹力喇叭牛仔裤 中腰高腰修身直筒牛仔裤"

rerank_score:0.0956
    "en": "POSHGLAM Women's Maternity Jeans Over Belly 29'' Skinny Denim Jeggings Comfy Stretch Clearance Pregnancy Pants",
    "zh": "POSHGLAM 女士孕产期高腰显瘦牛仔紧身裤 29英寸 紧身弹力孕妇裤 休闲舒适 清仓特价"

（带有 Slim Stretch Jeans，但是打分只有0.0135，极低）
rerank_score:0.0135
    "en": "European and American Export Temu American Retro Sexy Bell-Bottomed Pants Slim Slim Stretch Jeans Women's Pants",
    "zh": "欧美出口 蒂姆美国复古性感喇叭裤 修身弹力女裤"


这几个结果比较差，但是得分很高：

rerank_score:0.4692
    "en": "American Vintage Low Waist Non-Elastic Washed Straight-Leg Jeans Women's Autumn New Street Wide Leg Denim Women's Pants",
    "zh": "美式复古低腰无弹洗水直筒阔腿牛仔裤 女士秋季新款阔腿牛仔裤"


rerank_score:0.4784
    "en": "Europe and the United States cross-border foreign trade 2025 spring and summer new Amazon independent station washed waist adjustable Denim pants",
    "zh": "欧美跨境外贸2025春夏新款亚马逊独立站洗水腰 adjustable 牛仔裤"


rerank_score:0.5849
    "zh": "新款女士修身仿旧牛仔短裤 – 休闲性感磨边水洗牛仔短裤，时尚舒",
    "en": "New Women's Slim-fit Vintage Washed Denim Shorts – Casual Sexy Frayed Hem, Fashionable & Comfortable"
