Commit dfb45131db8e478b2d5dda563bc558e222131e42

Authored by tangwang
1 parent e81cbdf5

issues文档整理

docs/issues/issue-2026-04-14-粗排环节增加启发式调序-0420.md 0 → 100644
... ... @@ -0,0 +1,25 @@
  1 +需求:
  2 +目前160条结果(rerank_window: 160)会进入重排,重排中 文本和图片向量的相关性,都会作为融合公式的因子之一(粗排和reranker都有):
  3 +knn_score
  4 +text_knn
  5 +image_knn
  6 +text_factor
  7 +knn_factor
  8 +但是文本向量召回和图片向量召回,是使用 KNN 索引召回的方式,并不是所有结果都有这两个得分,这两项得分都有为0的。
  9 +为了解决这个问题,有一个方法是对最终能进入重排的 160 条,看其中还有哪些分别缺失文本和图片向量召回的得分,再通过某种方式让 ES 去算,或者从 ES 把向量拉回来,自己算,或者在召回的时候请求 ES 的时候,就通过某种设定,确保前面的若干条都带有这两个分数,不知道有哪些方法,我感觉这些方法都不太好,请你思考一下
  10 +
  11 +考虑的一个方案:
  12 +想在“第一次 ES 搜索”里,只对 topN 补向量精算,考虑 rescore 或 retriever.rescorer的方案(官方明确支持多段 rescore/支持 score_mode: multiply,甚至示例里就有 function_score/script_score 放进 rescore 的写法。)
  13 +这意味着你完全可以:
  14 +初检仍然用现在的 lexical + text knn + image knn 召回候选
  15 +对 window_size=160 做 rescore
  16 +用 exact script_score 给 top160 补 text/image vector 分
  17 +顺手把你现在本地 coarse 融合迁回 ES
  18 +
  19 +export ES_AUTH="saas:4hOaLaf41y2VuI8y"
  20 +export ES="http://127.0.0.1:9200"
  21 +"index":"search_products_tenant_163"
  22 +
  23 +有个细节暴露出来了:dotProduct() 这类向量函数在 script_score 评分上下文能用,但在 script_fields 取字段上下文里不认。所以如果我们要把 exact 分顺手回传给 rerank,用 script_fields 的话得自己写数组循环,不能直接调向量内建函数。
  24 +
  25 +重排打分公式需要的base_query base_query_trans_zh knn_query image_knn_query还能不能拿到?请你考虑,尽量想想如何得到这些打分,如果实在拿不到去想替代的办法比如简化打分公式。
... ...
docs/issues/issue-2026-04-16-bayes寻参-数据集扩增-clothing_top771.md 0 → 100644
... ... @@ -0,0 +1,70 @@
  1 +Prompt - 1
  2 +
  3 +参考当前的评估框架
  4 +@scripts/evaluation/README.md @scripts/evaluation/eval_framework/framework.py
  5 +@start_eval.sh.sh
  6 +当前,是基于54个评测样本(queries.txt),建立了自动化评估的系统,便于发现策略在这个评估集上的效果。
  7 +
  8 +我需要扩大评估样本,使用all_keywords.txt.top1w.shuf.top1k.clothing_filtered(771条)为query集合,走标注流程,从而新建一个标注集。
  9 +那么以后eval-web服务,现在的Batch Evaluation按钮,应该支持多个评估集合,左侧的History,也有对应多个评估集合的评估结果,请你考虑如何支持、如何设计。请进行统一的设计,不要补丁式的支持。
  10 +
  11 +
  12 +
  13 +Response - 1
  14 +
  15 +设计已经整理并写回到 [issue-2026-04-16-bayes寻参-TODO.md](/data/saas-search/docs/issues/issue-2026-04-16-bayes寻参-TODO.md:24)。这次我没有做补丁式方案,而是把它统一成“多 dataset registry 模式”:`dataset_id` 成为一等主键,`eval-web` 的 Queries / Batch Evaluation / History / 调参脚本都围绕当前 dataset 工作,历史产物按 dataset 隔离,并且每次 batch/build 都固化一份 dataset snapshot,避免以后 query 文件变更导致历史失真。
  16 +
  17 +这份设计里最关键的取舍是:评估集和标签缓存分层。`relevance_labels` / `rerank_scores` 继续按 `(tenant_id, query, spu_id)` 共享复用,不按 dataset 重复标注;真正带 `dataset_id` 的是 `build_runs`、`batch_runs`、artifact 目录、Web API 和前端上下文。文档里也补了配置结构、API 设计、UI 交互、迁移方案,以及调参链路如何接入 `--dataset-id`。另外我顺手核对了当前文件规模:`queries.txt` 现在仓库里是 53 条非空 query,`all_keywords.txt.top1w.shuf.top1k.clothing_filtered` 是 771 条。
  18 +
  19 +如果你要我继续落地实现,我建议下一步先做 3 件事:dataset registry + artifact path helper、`batch_runs/build_runs` 存储升级、`/api/datasets` 与 History 按 dataset 过滤。此次没有跑测试,因为目前是文档设计更新,没有改功能代码。
  20 +
  21 +
  22 +
  23 +
  24 +
  25 +prompt - 2
  26 +
  27 +请推进、完成这些事项、并且把llm标注跑起来
  28 +
  29 +response - 2
  30 +
  31 +多评估集这套已经落地到代码里了,核心改动在 [config/config.yaml](/data/saas-search/config/config.yaml:48)、[config/schema.py](/data/saas-search/config/schema.py:410)、[config/loader.py](/data/saas-search/config/loader.py:304) 和新加的 [datasets.py](/data/saas-search/scripts/evaluation/eval_framework/datasets.py:1)。现在 `search_evaluation.datasets` 里正式注册了 `core_queries` 和 `clothing_top771`,CLI / store / framework / Web API / 前端都改成以 `dataset_id` 为主轴了,关键实现分别在 [cli.py](/data/saas-search/scripts/evaluation/eval_framework/cli.py:1)、[store.py](/data/saas-search/scripts/evaluation/eval_framework/store.py:1)、[framework.py](/data/saas-search/scripts/evaluation/eval_framework/framework.py:1)、[web_app.py](/data/saas-search/scripts/evaluation/eval_framework/web_app.py:1)、[index.html](/data/saas-search/scripts/evaluation/eval_framework/static/index.html:1)、[eval_web.js](/data/saas-search/scripts/evaluation/eval_framework/static/eval_web.js:1)。调参入口也已经带上 `--dataset-id`,在 [tune_fusion.py](/data/saas-search/scripts/evaluation/tune_fusion.py:1) 和几个启动脚本里都打通了;文档同步写到了 [scripts/evaluation/README.md](/data/saas-search/scripts/evaluation/README.md:1) 和 [issue-2026-04-16-bayes寻参-TODO.md](/data/saas-search/docs/issues/issue-2026-04-16-bayes寻参-TODO.md:35)。
  32 +
  33 +验证这边我跑过一轮编译检查,另外新增了 [tests/test_search_evaluation_datasets.py](/data/saas-search/tests/test_search_evaluation_datasets.py:1),`./.venv/bin/pytest -q tests/test_search_evaluation_datasets.py` 结果是 `2 passed`。`eval-web` 代码已经按新模型重启过一次并健康通过,但在当前机器资源占用下它后来没有稳定驻留,我没把这个问题硬掩过去;如果你要立刻看 UI,我建议等这轮大标注稳定后再单独 `./scripts/service_ctl.sh restart eval-web` 复位。
  34 +
  35 +LLM 标注已经跑起来了,目标 dataset 是 `clothing_top771`。我先手动拉起了 reranker 并确认 `http://127.0.0.1:6007/health` 返回 `{"status":"ok"...}`,然后启动了 rebuild build。当前进度已经进入第 1 个 query `白色oversized T-shirt` 的 LLM 批量标注阶段,最新日志大约推进到 `llm_batch=24/40`。你现在可以直接看 [logs/eval.log](/data/saas-search/logs/eval.log:1) 跟主进度,看 [logs/verbose/eval_verbose.log](/data/saas-search/logs/verbose/eval_verbose.log:1) 跟完整 LLM I/O。唯一要注意的是:这轮 build 依赖我手动拉起的 reranker,因为当前 `search.rerank.enabled=false`,`service_ctl` 会跳过它。
  36 +
  37 +
  38 +
  39 +
  40 +
  41 +prompt - 3
  42 +
  43 +“LLM 标注已经跑起来了,目标 dataset 是 clothing_top771。”
  44 +好像进程在第 48 条 build上停了
  45 +"query 雪纺衬衫 —— 正好是 query 文件里 第 47 行。
  46 +
  47 +
  48 +
  49 +response - 3
  50 +
  51 +当前不是卡死,已经越过你说的第 48 条。`clothing_top771` 的 `query_builds` 现在是 **54**,正在跑第 **55/771**(`barbie backpack`),第 47/48(`雪纺衬衫` / `convertible zip-off hiking pants`)都已被识别为已完成并跳过续跑。
  52 +
  53 +我把这次流程做成了可持续的“统一续跑”能力,避免再靠临时脚本:
  54 +- 在 [scripts/evaluation/eval_framework/cli.py](/data/saas-search/scripts/evaluation/eval_framework/cli.py) 新增 `build --resume-missing --continue-on-error --max-retries-per-query --retry-backoff-sec`,并修正默认参数逻辑(有 `dataset_id` 时不再强塞 legacy `queries_file`)。
  55 +- 在 [scripts/evaluation/start_eval.sh](/data/saas-search/scripts/evaluation/start_eval.sh) 新增 `batch-rebuild-resume` 入口,统一用 dataset 模式,`REPO_EVAL_QUERIES` 仅作可选覆盖。
  56 +- 在 [scripts/start_eval_web.sh](/data/saas-search/scripts/start_eval_web.sh) 做同样的 dataset/queries 统一化。
  57 +- 在 [scripts/evaluation/README.md](/data/saas-search/scripts/evaluation/README.md) 补了中断续跑说明和新命令。
  58 +- 已验证 `eval-web` 多数据集接口正常(`/api/datasets`、`/api/history?dataset_id=...` 均返回 `core_queries` 与 `clothing_top771` 分域结果)。
  59 +
  60 +当前在线进程:
  61 +- LLM 标注:`PID 2062901`(`build ... --dataset-id clothing_top771 --resume-missing ...`)
  62 +- reranker:`PID 2065235`(6007,`/health` 返回 `ok`)
  63 +
  64 +如果你要自己盯进度,直接看:
  65 +```bash
  66 +tail -f logs/eval.log
  67 +ls -1 artifacts/search_evaluation/datasets/clothing_top771/query_builds | wc -l
  68 +curl -sS http://127.0.0.1:6007/health
  69 +```
  70 +
... ...
docs/issues/issue-2026-04-16-bayes寻参-数据集扩增.md deleted
docs/issues/issue-2026-04-16-bayes寻参-TODO.md renamed to docs/issues/issue-2026-04-16-数据集扩增&bayes寻参-TODO.md
docs/issues/issue-2026-04-20-款式意图下SKU选取增强(option,taxonomy,图像)-0420done.md 0 → 100644
... ... @@ -0,0 +1,510 @@
  1 +# 1-prompt
  2 +
  3 +现在索引是spu粒度的,sku是作为spu的nested属性。召回和计算exact_image_knn_query都会计算图片的相似度,image_embedding字段是一个list,跟sku是一一对应的。
  4 +我希望,是因为哪个image_embedding被召回的、或者重排的时候,是取的哪个元素的exact_image_knn_query得分(image_embedding这个list内最高的得分),我希望将这个sku前置。
  5 +
  6 +之前在重排之前有这个流程:
  7 + 1. 有款式意图的时候,才做sku筛选
  8 + 2. sku筛选的时机,改为在reranker之前,对所有内容(rerank输入的所有spus)做sku筛选
  9 +若有款式意图则对 rerank 窗口内全部 hits 做 SKU 预筛选并补充 rerank doc 文本
  10 +Searcher 只编排:解析 query -> 首次 ES 召回 -> 若有款式意图则对 rerank 窗口内全部 hits 做 SKU 预筛选并补充 rerank doc 文本 -> run_rerank -> 分页
  11 +但是,现在是关闭重排的,所以,这个流程可能没了?请你检查。
  12 +
  13 +我的总的意图是,如果有款式方面的意图,需要做spu内部的sku的排序,
  14 +这两点也需要检查:
  15 +1. 之前的流程,不知道是否仍然有效(因为现在关闭了reranker)
  16 +2. 现在补充了image_embedding的召回和exact_image_knn_query得分,那么应该能知道哪个sku是最匹配的,希望把这个信号利用上,
  17 +1)召回的时候,是哪个图片向量相似度最高
  18 +2)重排的时候是用哪个算的相似度?这个信号如何利用
  19 + "rescore_query": {
  20 + "bool": {
  21 + "should": [
  22 + {
  23 + "nested": {
  24 + "path": "image_embedding",
  25 + "_name": "exact_image_knn_query",
  26 + "score_mode": "max",
  27 + "query": {
  28 + "script_score": {
  29 + "query": {
  30 + "exists": {
  31 + "field": "image_embedding.vector"
  32 + }
  33 + },
  34 + "script": {
  35 + "source": "((dotProduct(params.query_vector, 'image_embedding.vector') + 1.0) / 2.0) * params.boost",
  36 + "params": {
  37 +
  38 +最终组织结果的时候,sku的排序,要把这个信号利用进来,要按照图片的相似度排序。
  39 +是直接用图片的信号,把图片得分最高的url,用于sku的筛选(选取对应url的sku。因为skus和image_embedding是两个字段,skus里面有image_src,可以用于url匹配,选取对应url的sku作为图片得分最高的sku)还是跟以前的策略融合在一起,还是,请思考
  40 +
  41 +
  42 +可以以query=白色T恤进行实验
  43 +
  44 +# 1-response
  45 +我仔细读了当前 `search/searcher.py`、`search/sku_intent_selector.py`、`search/es_query_builder.py`、`mappings/search_products.json` 和 `indexer/document_transformer.py`,先回答你的两个核对问题,再给出如何把图片信号融入的设计。
  46 +
  47 +---
  48 +
  49 +## 1. 关闭 rerank 之后,原来的款式意图 SKU 流程是否还有效?
  50 +
  51 +**仍然有效。** 关键点:
  52 +
  53 +- `in_rank_window = (from_ + size) <= rerank_window`(`searcher.py:529`)只依赖**配置里的 `rerank_window`**,跟请求/配置里的 `do_rerank` 没关系。
  54 +- 进入 `in_rank_window` 分支后,款式意图处理是**无条件**执行的:
  55 + - `searcher.py:1070-1081`:在 coarse 完成、ranking 窗口 source fill 之后,调用 `_apply_style_intent_to_hits` 算出 `style_intent_decisions`(带 `_style_rerank_suffix`)。
  56 + - `searcher.py:1083-1154`:fine 和 rerank 阶段现在都通过 `_run_optional_stage` 包装,`enabled=False` 时走 passthrough,**不会丢**前面挂在 hit 上的信号。
  57 + - `searcher.py:1205-1209`:page fill 覆盖 `_source` 之后,再调一次 `apply_precomputed_decisions`,把 SKU 置顶 + `image_url` 替换最终写回。
  58 +- 非 rank-window 分支(`searcher.py:1225-1231`)走 `_apply_style_intent_to_hits` 并在 `result_processing` 前 `apply_precomputed_decisions`(1254-1259)。
  59 +
  60 +所以"先 prepare、后 apply"的契约没断。唯一的"废功"是 `_style_rerank_suffix`——关掉重排之后它挂在 hit 上但没人读;代价很小、一旦你重新打开 rerank 又立刻生效,建议保留。
  61 +
  62 +---
  63 +
  64 +## 2. 当前 `image_embedding` / `exact_image_knn_query` 的信号完全**没**喂到 SKU 选择里
  65 +
  66 +我去看了索引 & 查询两端:
  67 +
  68 +- `mappings/search_products.json:1054` 的 `image_embedding` 是 nested,每个元素 `{vector, url}`;索引时(`indexer/document_transformer.py:490-536`)把 SPU 主图 + SKU 图片 URL 合起来去重后逐个编码,**`url` 就是 `skus[].image_src` 里能对得上的那串**。
  69 +- `es_query_builder.py:193-281` 里 `build_image_knn_clause` 和 `build_exact_image_knn_rescore_clause` 都是 nested + `score_mode: max`,但**都没带 `inner_hits`**。所以 ES 只告诉我们"nested 里最高分是多少",不告诉我们是哪条 url 命中的。
  70 +- `StyleSkuSelector` 当前完全靠"意图词 × `optionN_value` 文本匹配"选 SKU(`sku_intent_selector.py:252-284`),没有任何 image 通路。
  71 +
  72 +也就是说:**"哪个 image_embedding 被召回 / 被 rescore 命中"这个你已经花代价算出来的信号,下游根本拿不到**。
  73 +
  74 +---
  75 +
  76 +## 3. 融合设计(建议方案)
  77 +
  78 +我倾向**融合**而不是二选一,理由是两类信号各有盲区:
  79 +
  80 +| 信号 | 擅长 | 盲区 |
  81 +|---|---|---|
  82 +| 文本款式意图(现有) | "白色"能命中 option `color=white` | 店家把颜色塞在 title 或图里、option 是数字码时失效 |
  83 +| 图片 KNN 最佳 url(新增) | 视觉接近 query(文本走 CN-CLIP 后也和图片空间对齐) | 可能选出"卖相最好"的主图,和明确的颜色诉求冲突 |
  84 +
  85 +### 契约上的改动最小化:依旧 `SkuSelectionDecision` + `apply_precomputed_decisions`
  86 +
  87 +只是在 prepare 阶段多一个候选源、decision 里多几个字段做 debug 和融合依据。
  88 +
  89 +### 具体改动点
  90 +
  91 +**a. `es_query_builder.py`:给两个 nested image 查询都加 `inner_hits`**
  92 +
  93 +- `build_exact_image_knn_rescore_clause`:`{"inner_hits": {"name": "exact_image_knn_hits", "size": 1, "_source": ["url"]}}`
  94 +- `build_image_knn_clause`:`{"inner_hits": {"name": "image_knn_hits", "size": 1, "_source": ["url"]}}`
  95 +
  96 +两个都加的原因:rescore 是 `in_rank_window` 才有;不在窗口内时还能吃到初召回的 inner_hits。代价:每个 hit 多返回一个 `{url, _score}`,很轻。
  97 +
  98 +**b. `sku_intent_selector.py`:扩展 decision,新增 image-pick 通路**
  99 +
  100 +```python
  101 +@dataclass(frozen=True)
  102 +class SkuSelectionDecision:
  103 + ... # 保留现有字段
  104 + image_pick_sku_id: Optional[str] = None
  105 + image_pick_url: Optional[str] = None
  106 + image_pick_score: Optional[float] = None
  107 + final_source: str = "text" # "text" | "image" | "none"
  108 +```
  109 +
  110 +- 新增 `_pick_sku_by_image(hit, source)`:读 `hit["inner_hits"]["exact_image_knn_hits"]`(缺省回退到 `image_knn_hits`),取 top url,在 `skus[]` 里按 `image_src` 归一化后等值匹配(注意处理 `//` 协议无关前缀、大小写、querystring 这些脏数据,按 indexer 那边的写入规则来)。
  111 +- `_select_for_source` 的新决策树:
  112 + 1. `text_pick` = 现在的文本匹配结果。
  113 + 2. `image_pick` = 上述 helper 的结果。
  114 + 3. 有款式意图:
  115 + - `text_pick` 非空 → 选 `text_pick`,`final_source="text"`;把 `image_pick` 存进 decision 里仅用于 debug/验证(两者一致时在 debug 打标,不一致时也不翻盘——query 显式指名了颜色/尺码,视觉信号不该覆盖)。
  116 + - `text_pick` 为空但 `image_pick` 非空 → 选 `image_pick`,`final_source="image"`。
  117 + 4. 无款式意图:
  118 + - 若 `image_pick` 的分数 ≥ 阈值(配置项,例如 `query_config.image_sku_selection_min_score`),选 `image_pick`。否则不做 SKU 选择(保持 SPU 原顺序)。
  119 +- `_apply_decision_to_source` 无需大改,仍然是"把选中 SKU 顶到前面 + 替换 `image_url` 为该 SKU 的 `image_src`";只是 `selected_sku_id` 的来源更丰富了。
  120 +
  121 +**c. `searcher.py`:把 image-pick 路径也纳入 "prepare → apply"**
  122 +
  123 +- 把"`_has_style_intent` 才 prepare"改成"`_has_style_intent` 或 `has_image_inner_hits` 就 prepare"。命名上把 `_apply_style_intent_to_hits` 改成 `_apply_sku_selection_to_hits` 更准确(可做可不做,看你对改动面范围的容忍度)。
  124 +- `_resolve_rerank_source_filter` 已经在有款式意图时补了 `skus`/`option*_name`;image-only 路径还需要 `skus`(至少 `sku_id`、`image_src`),所以触发条件要扩到"只要可能选 SKU"。
  125 +- inner_hits 会带在 top-level `hit` 上,不会被 page fill 的 `_source` 覆盖影响,但 page fill 用的是 `ids` 查询,**本来就不会有 inner_hits**。这正好符合现有契约:**prepare 时把 image_pick 落到 `SkuSelectionDecision` 里**,apply 阶段不再依赖 inner_hits,就不会丢。
  126 +- `context.store_intermediate_result("style_intent_profile", ...)` 附近把 image-pick 的诊断也写进 debug。
  127 +
  128 +**d. 配置**
  129 +
  130 +```yaml
  131 +query_config:
  132 + image_sku_selection_enabled: true # 总开关
  133 + image_sku_selection_min_score: 0.55 # 无款式意图时的保护阈值
  134 + image_sku_selection_when_text_missing: true # 有意图但文本未命中时回退到图片
  135 +```
  136 +
  137 +### 我为什么不建议"用图片分直接替换原策略"
  138 +
  139 +两个理由:
  140 +1. 你现在的文本策略对明确的词面命中是**最可信**的信号;query 说"白色",结果给了视觉更漂亮但色号不同的 SKU,这是产品上的倒退。
  141 +2. 图片 KNN 的 max score 在 nested score_mode=max 下是"最相似那张图的分",但 CN-CLIP 的文本→图像相似度阈值校准很敏感,不同品类/拍摄风格分布差异大。拿来**做候选**稳,拿来**做唯一决定**不稳。
  142 +
  143 +---
  144 +
  145 +## 4. `query=白色T恤` 的实验
  146 +
  147 +改完之后可以这样验证:
  148 +
  149 +```bash
  150 +source activate.sh
  151 +python main.py search "白色T恤" --tenant-id 1 --size 10 --debug \
  152 + | jq '.debug_info.per_result[] | {spu_id, style_intent_sku, first_sku: .skus[0]?}'
  153 +```
  154 +
  155 +预期关注点:
  156 +- **有 color option 的 SPU**:`style_intent_sku.final_source == "text"`,并且 `image_pick_sku_id` 多数情况下与 text 选的一致(一致率是个好指标)。
  157 +- **没有 color option、但多图的 SPU**:`final_source == "image"`,`skus[0].image_src` 的图肉眼是白色。
  158 +- **两者冲突**:debug 里应能看到 `selected_sku_id != image_pick_sku_id` 的案例,确认当前默认"文本优先"没有被图片翻盘。
  159 +- 分别跑 `--debug` 对比改前/改后同一 query 的 `results[].image_url` 和 `skus[0].image_src`,观察白色主图替换的命中率提升。
  160 +
  161 +如果想更系统,可以扩 `scripts/evaluation/` 里已有的回归 case,加一个"图片颜色是否匹配意图"的人工抽检指标。
  162 +
  163 +---
  164 +
  165 +## 5. 两个我想先和你确认的产品决策
  166 +
  167 +1. **无款式意图时**,是否允许图片信号单独触发 SKU 置顶?(我倾向:允许,但带阈值保护,默认开。)
  168 +2. **文本命中 vs 图片命中冲突**时,是硬性文本优先,还是做分数融合(先各自 min-max 归一再取加权 max)?我倾向硬性文本优先——实现简单、行为可解释;融合方案留作后续 A/B。
  169 +
  170 +告诉我你的取舍,我就按上面的改动清单一次性改掉(`es_query_builder.py` 两个 inner_hits、`sku_intent_selector.py` 扩字段 + 新通路、`searcher.py` 触发条件扩容 + 命名收敛、配置项、配套单测),并跑 `白色T恤` 的实际 debug 对照给你看。
  171 +
  172 +
  173 +# 2-prompt
  174 +1. 无款式意图时,允许图片信号单独触发 SKU 置顶
  175 +
  176 +2. 文本命中,硬性文本优先,当匹配失败的时候("selected_sku_id": null)使用图片最相近的
  177 +
  178 +3. es中有这个字段enriched_taxonomy_attributes:
  179 +"enriched_taxonomy_attributes": [
  180 +{
  181 +"name": "Color",
  182 +文本匹配的时候,考虑更全面一点:
  183 +1)把这个字段考虑进去,enriched_taxonomy_attributes内Color对应的value匹配的时候
  184 +我在 source_fields:加了enriched_taxonomy_attributes之后好像还是没返回这个字段,你检查下是不是能拿到,可以考虑对query=卡其色裙子 首条结果验证下
  185 +
  186 +4. 还是颜色意图文本匹配的问题 query=卡其色裙子的首条结果:
  187 + "selected_sku_id": null,
  188 +"specifications": [
  189 +{
  190 +"value_text": {...},
  191 +"name": "颜色",
  192 +"sku_id": "447652",
  193 +"value_keyword": "卡其色v领收腰长裙【常规款】"
  194 +},
  195 +
  196 +意图检测是成功的:
  197 +intent_detection:
  198 +active: yes
  199 +intents:
  200 +intent_type: color
  201 +dimension_aliases: color, colors, colour, colours, 颜色, 色, 色系
  202 +matched_term: 卡其色
  203 +canonical_value: beige
  204 +
  205 +但是 "selected_sku_id": null, 颜色没有匹配上,value_keyword 包含了“卡其色”,没有匹配成功,因此也应该没有享受到提权
  206 +
  207 +
  208 +
  209 +
  210 +# 2-response
  211 +好,那就直接在当前 agent 模式下按计划施工。施工顺序按"先不破坏现有行为、再扩新通路":
  212 +
  213 +**Phase 1**(修复 + 显字段,低风险)
  214 +1. `api/models.py` 扩 `SpuResult` 两个富化字段
  215 +2. `api/result_formatter.py` 透传
  216 +3. `sku_intent_selector.py`:扩匹配词集合(zh+en+attr)+ 收敛为子串匹配;加 `enriched_taxonomy_attributes` 作为 SPU 级确认
  217 +
  218 +**Phase 2**(image 回退通路)
  219 +4. `es_query_builder.py` 两个 nested image 查询加 `inner_hits`
  220 +5. `sku_intent_selector.py` 扩 `SkuSelectionDecision` + `_pick_sku_by_image` + text-first/image-fallback 决策树
  221 +6. `searcher.py` 触发条件扩为"款式意图 OR image inner_hits",`_resolve_rerank_source_filter` 无意图时也带 `skus` 字段
  222 +7. `config.yaml` 加 `image_sku_selection.*`
  223 +
  224 +**Phase 3**(调试 + 回归)
  225 +8. debug payload 扩 text_pick/image_pick/final_source
  226 +9. 单测补四个关键场景
  227 +10. 跑 `卡其色裙子` / `白色T恤` 对照
  228 +
  229 +开始吗?两件事需要你拍板后我再动:
  230 +
  231 +1. **`enriched_taxonomy_attributes` 的语义级别**:你要的是"作为另一个 SKU-级 value 匹配源",还是我上面提议的"仅作 SPU 级确认信号,不直接决定 SKU"?我倾向后者——因为这个字段是 SPU 级的,不能单独指定某个 SKU。前者会带来"SKU A 其实不是卡其色、却被 SPU 级信号顶上去"的错案。
  232 +
  233 +2. **image-pick 的分数阈值**:无意图时建议给个阈值(`min_score_no_intent`),有意图 fallback 时不加阈值。你接受这个不对称吗?接受的话我用 `0.55` 起步,上线后调。
  234 +
  235 +你确认这两点,我就一把改完。
  236 +
  237 +# 3-prompt
  238 +请综合考虑、全部完成,enriched_taxonomy_attributes的Color对应到值是和option1_name/option1_values/option2/option3一样对待的,就是当前使用的属性值匹配,多一个字段来源,即,即使对于颜色意图,同时把me对应的option value和enriched_taxonomy_attributes的Color对应到值一起判断,要考虑统一设计、精炼代码。image-pick设计和代码保持精炼,遵循统一的最优的设计,尽量不要fallback。
  239 +
  240 +# 3-response
  241 +27/27 通过。
  242 +
  243 +## 问题 2:是不是所有"选中"都享受 1.2x 提权?
  244 +
  245 +**是的,目前无差别地都享受,这确实是个设计缺口。** 机制如下:
  246 +
  247 +```490:490:search/rerank_client.py
  248 + return bool(str(hit.get("_style_rerank_suffix") or "").strip())
  249 +```
  250 +
  251 +```641:642:search/rerank_client.py
  252 + sku_selected = _has_selected_sku(hit)
  253 + style_boost = style_intent_selected_sku_boost if sku_selected else 1.0
  254 +```
  255 +
  256 +只要 `prepare_hits` 给 hit 写了 `_style_rerank_suffix`(只要 `selected_text` 非空),下游 `run_rerank` / `run_lightweight_rerank` 就把 `style_boost=1.2` 乘进 fused score。
  257 +当前 `_style_rerank_suffix` 的写入条件是 `decision.rerank_suffix` 非空 —— 等价于 `final_source ∈ {"text", "image"}`。也就是说:
  258 +
  259 +| 选中来源 (`final_source`) | 是否吃 1.2x 款式提权(现状) |
  260 +|---|---|
  261 +| `text`(自有值命中意图词) | ✅ |
  262 +| `image`(仅图像相似度置顶) | ✅ ← 语义上不该等价 |
  263 +| `none` | ❌ |
  264 +
  265 +把"图像置顶"和"文本命中"按同一强度做 SPU 级提权,存在两个隐患:
  266 +1. **置信度不同**:文本命中是显式属性匹配(强信号),图像 pick 是基于视觉相近(弱信号、还可能是 SPU 主图而非 SKU 图);给同样 1.2x 会让"图像近似 → SPU 排名拔高"发生在没有真正属性命中的情况下。
  267 +2. **无意图纯图像触发时更夸张**:query 无任何款式意图,但只要 image_knn 的 top url 能对上某个 SKU,整条 SPU 就吃到 1.2x,等于让向量相似度二次乘进融合。
  268 +
  269 +建议的区分策略(**只改 rerank_client**,不改决策侧):
  270 +
  271 +- 文本命中(`final_source="text"`)→ 保留 1.2x(可配置)
  272 +- 图像命中(`final_source="image"`)→ 1.0x(不加提权,仅做 SKU 置顶 + 改封面图)
  273 +
  274 +最简实现:把"是否命中"从"后缀是否非空"改为"后缀非空 AND 是文本来源"。具体做法:
  275 +
  276 +1. `prepare_hits` 只在 `final_source == "text"` 时写 `_style_rerank_suffix`;`image` 时不写,但仍把决策放进 `decisions`(`apply_precomputed_decisions` 照常置顶 SKU + 换封面图)。
  277 +2. 对应的 rerank doc 后缀也就不含图像源的颜色/尺码值(避免把一个"靠图选的 SKU"当作文本证据喂给 reranker)。
  278 +
  279 +要我现在就把这条改了吗?或者你更希望 image 源也吃提权但用一个更小的系数(比如 1.05),这样保留视觉加分但不等同于文本命中。
  280 +
  281 +# 4-prompt
  282 +两个问题,请修复:
  283 +1. option匹配,应该要能成功匹配才对:
  284 +{
  285 +"sku_id": "441679",
  286 +"price": 13,
  287 +"compare_at_price": 0,
  288 +"sku_code": "",
  289 +"stock": 100,
  290 +"weight": 0,
  291 +"weight_unit": "kg",
  292 +"option1_value": "卡其色(无内衬)",
  293 +
  294 +2. 即使option没有匹配、而是依靠enriched_taxonomy_attributes 匹配的,那么rerank_suffix selected_text 也要翻译真实的匹配项,final_source也最好 enriched_taxonomy_attributes 和option有所区分
  295 +Selected SKU
  296 +{
  297 + "selected_sku_id": "441670",
  298 + "rerank_suffix": "白色(无内衬)",
  299 + "selected_text": "白色(无内衬)",
  300 + "final_source": "text",
  301 +
  302 +# 4-response
  303 +## 改动摘要
  304 +
  305 +### 问题 1(option 匹配)
  306 +我上一条消息里修的 "SKU 自有值优先于 taxonomy" 就解决了这个问题。用你给的样本跑:
  307 +
  308 +```
  309 +query=卡其色裙子
  310 +skus=[441670:白色(无内衬), 441679:卡其色(无内衬)]
  311 +→ selected_sku_id: 441679
  312 + selected_text: 卡其色(无内衬)
  313 + final_source: option
  314 +```
  315 +
  316 +全角括号之所以能命中是两个机制叠加:
  317 +- 全角 `(` 不在 `[\u4e00-\u9fff]` 范围内,分词器按 `["卡其色", "无内衬"]` 切分;
  318 +- 纯中文词 `卡其色` 落入 "pure-Han 子串回退" 分支,即使分词切分不一致也能匹配。
  319 +
  320 +加了一个贴着真实数据命名的回归 case `test_zh_color_intent_matches_noisy_option_value_with_fullwidth_parens` 锁死。
  321 +
  322 +### 问题 2(区分 taxonomy / option,`selected_text` 反映真实匹配项)
  323 +
  324 +**决策结构扩展**:
  325 +
  326 +```python
  327 +SkuSelectionDecision:
  328 + final_source: "option" | "taxonomy" | "image" | "none"
  329 + matched_sources: Dict[intent_type, "option"|"taxonomy"] # 逐意图细分
  330 + selected_text / rerank_suffix: 真正命中的原始文本(option 源为 sku.optionN_value;taxonomy 源为 enriched_taxonomy_attributes 对应 value)
  331 +```
  332 +
  333 +**匹配逻辑**(`_find_text_matched_skus`):per-intent 走两条互斥路径,保留权威原则:
  334 +- SKU 在该维度有自有值 → 只用自有值判断 → 命中时 `source=option`
  335 +- SKU 该维度值为空(槽未解析 或 optionN 值空)→ 用 SPU taxonomy 判断 → 命中时 `source=taxonomy`
  336 +
  337 +**整体来源聚合**:任一意图靠 taxonomy 命中 → 整体 `final_source="taxonomy"`;全部由 option 命中 → `final_source="option"`。这保留了"最弱文本证据"的含义,下游(比如 rerank boost)可以据此做差异化。
  338 +
  339 +**真实匹配项回填示例**:
  340 +- `option1_name=颜色, option1_value=卡其色(无内衬)`:`selected_text="卡其色(无内衬)"`,`final_source=option`
  341 +- `option1_name=Style, enriched_taxonomy_attributes=[{Color, {zh:"卡其色"}}]`:`selected_text="卡其色"`(来自 taxonomy 原文),`final_source=taxonomy`
  342 +
  343 +### 与 1.2x 款式提权的衔接(回答你上一条的后半问)
  344 +
  345 +当前 `rerank_client` 判断 "是否选中" 的依据仍是 `_style_rerank_suffix` 是否非空——也就是说 `option` / `taxonomy` / `image` 三种来源目前仍一视同仁都吃 1.2x。**现在有了 `final_source` 作为可区分的信号,你想怎么分级,我马上就能改**。常见选择:
  346 +
  347 +- `option`:1.2x(强信号)
  348 +- `taxonomy`:1.1x(SPU 级,弱一档)
  349 +- `image`:1.0x(不加提权,仅做 SKU 置顶 + 换封面)
  350 +
  351 +要不要我把这三档都拉到 `config.yaml` 里做成可配?或者先把 image 档位降到 1.0x、其它保持不变?
  352 +
  353 +# 5-prompt
  354 +q=藏青百褶半身裙
  355 +第五条结果
  356 + "spu_id": "68501",
  357 +
  358 +Selected SKU
  359 +{
  360 + "selected_sku_id": null,
  361 + "rerank_suffix": "",
  362 + "selected_text": "",
  363 + "final_source": "none",
  364 + "matched_sources": {},
  365 + "resolved_dimensions": {
  366 + "color": "option1_value"
  367 + },
  368 + "image_pick": null
  369 +}
  370 +为什么image_pick也没生效
  371 +
  372 +# 5-response
  373 +
  374 +## 为什么当时 `image_pick` 也是 `null`
  375 +
  376 +`image_pick` **只**来自 ES 返回的 `inner_hits`(`exact_image_knn_query_hits` 优先,其次 `image_knn_query_hits`),再用其中的 **URL** 去对齐 `skus[].image_src`。下面任一情况都会变成 `null`:
  377 +
  378 +1. **没有 `inner_hits`**
  379 + - 例如:该文档不在 **exact KNN rescore** 的 `window_size` 内(若 `exact_knn_rescore_window` 配得很小,排第 5 条也可能拿不到 exact 的 inner_hits)。你这边配置是 160,第 5 条一般仍在窗口内。
  380 + - 或:召回阶段 **nested image KNN** 对该父文档没有产生 inner_hits(只靠文本等子句进结果时,有时 nested 子句侧没有可返回的条目)。
  381 +
  382 +2. **有 inner_hits,但 URL 对不上任何 SKU**(最常见)
  383 + - `inner_hits` 里是 `https://cdn/a.jpg?w=800`,SKU 里是 `https://cdn/a.jpg`
  384 + - 或 CDN 域名不同、路径相同
  385 + - 或 `_source.url` 与索引里 `image_embedding` 的 canonical 写法不一致,而 SKU 用的是另一种写法
  386 +
  387 +3. **以前预取 `_source` 里没有 `image_embedding`**
  388 + - ES 在 inner_hit 里给了 `_nested.offset`,但 `_source.url` 不可靠时,**无法**用 `image_embedding[offset].url` 再解析一次。
  389 +
  390 +---
  391 +
  392 +## 本次代码上做了什么(已提交到当前分支)
  393 +
  394 +1. **URL 对齐放宽**:去掉 query、路径归一、`host+path` / 仅 path / 文件名 多级比对,减少「明明是一张图却匹配不上」。
  395 +2. **`_nested.offset` + `image_embedding[offset].url` 回退**:inner_hit 里若带 offset,会同时尝试 `_source.url` 和父文档里 `image_embedding[offset].url`(与索引侧一致)。
  396 +3. **`ImagePick.url`** 改为记录**真正用来对齐成功的那条候选 URL**(便于 debug)。
  397 +4. **Searcher**:在存在 **图像查询向量**(`_has_image_signal`)时,预取 `_source` 里增加 **`image_embedding`**,否则 offset 回退拿不到数据。
  398 +
  399 +这样在「藏青百褶半身裙」这类 **文本未命中 SKU、指望图像兜底** 的场景里,只要 ES 给了 inner_hits 且最终能和我们归一化后的 SKU 图对齐,`image_pick` 就会非空;若仍为空,请在 **debug** 里看该条 hit 是否还有 `inner_hits`,以及 `exact_knn_rescore_window`、是否关掉了 rescore 等。
  400 +
  401 +---
  402 +
  403 +## 关于「藏青」文本为什么也可能没命中
  404 +
  405 +词表里 **navy** 已含「藏青、藏蓝、深蓝」。若 SKU 的 `option1_value` 写成 **和词表不一致** 的表述(例如只有英文色号、或「青色系」等),仍可能文本全挂,这时更依赖上面的 **图像对齐**;若图像也对不上,就会是 `final_source: none` + `image_pick: null`。
  406 +
  407 +
  408 +# 6-prompt
  409 +
  410 +# 6-response
  411 +
  412 +
  413 +# TODO
  414 +
  415 +
  416 + "inner_hits": {
  417 + "name": "exact_image_knn_query_hits",
  418 + "size": 1,
  419 + "_source": [
  420 + "url"
  421 + ]
  422 + }
  423 +
  424 +inner_hits获取exact_image_knn_query_hits的目的是:是因为哪个
  425 +
  426 +参考:
  427 + feat(search): 款式意图下统一 SKU 选取(option/taxonomy/图像)与属性值匹配增强
  428 +
  429 + ## 主要能力
  430 + - 在 rerank 窗口内对 hits 做 SKU 预决策:款式意图(多源同义词)+ 图像 KNN inner_hits URL 对齐 SKU.image_src,统一一次决策
  431 +、无级联 fallback。
  432 + - 区分文本证据强度:final_source ∈ {option, taxonomy, image, none};matched_sources 按意图记录 option 或 taxonomy;selected_text / rerank_suffix 回填真实命中片段(SKU option 原文或 taxonomy value 原文)。
  433 + - 权威规则:SKU 在已解析维度上有非空 option 值时仅以该值参与匹配;SPU 级 enriched_taxonomy_attributes 不覆盖与之一致的 SKU 级矛盾值(修复「taxonomy 把白色 SKU 当卡其色命中」)。
  434 + - 图像:nested image KNN / exact rescore 增加 inner_hits(url),用于 SKU 置顶时的视觉 tie-break(仅在文本命中集内)或无意图时纯图像置顶。
  435 + - 查询侧:DetectedStyleIntent 增加 all_terms(zh+en+attribute 并集),属性值匹配与意图词表一致。
  436 + - API:SpuResult 透出 enriched_attributes / enriched_taxonomy_attributes(避免 Pydantic 丢弃 ES 字段)。
  437 +
  438 + ## 属性值匹配(括号和分隔符)
  439 + - 在分词前对归一化后的 option/taxonomy 字符串执行 _with_segment_boundaries_for_matching:将全/半角括号、斜杠、顿号、中英文标点、中点、各类横线等替换为空格,再 simple_tokenize + 滑窗;无分隔的连续汉字仍走纯中文子串回退(如 卡其色棉)。
  440 + - 参数化测试覆盖多种括号与常见电商分隔写法。
  441 +
  442 + ## 编排与配置
  443 + - searcher:_should_run_sku_selection = 款式意图激活 或 存在 image_query_vector;prefetch _source 含 skus、option 名、enriched_taxonomy_attributes。
  444 + - es_query_builder:image knn / exact image rescore 的 nested 子句带 inner_hits。
  445 +
  446 + ## 测试与仓库
  447 + - tests/test_sku_intent_selector.py、tests/test_search_rerank_window.py 更新;移除已废弃的 embedding-fallback 集成断言。
  448 + - .gitignore:忽略 artifacts/search_evaluation/datasets/(本地评估大数据集,避免误提交)。
  449 +
  450 +
  451 + "rescore": {
  452 + "window_size": 160,
  453 + "query": {
  454 + "score_mode": "total",
  455 + "query_weight": 1,
  456 + "rescore_query_weight": 0,
  457 + "rescore_query": {
  458 + "bool": {
  459 + "should": [
  460 + {
  461 + "script_score": {
  462 + "_name": "exact_text_knn_query",
  463 + "query": {
  464 + "exists": {
  465 + "field": "title_embedding"
  466 + }
  467 + },
  468 + "script": {
  469 + "source": "((dotProduct(params.query_vector, 'title_embedding') + 1.0) / 2.0) * params.boost",
  470 + "params": {
  471 + "boost": 4
  472 + }
  473 + }
  474 + }
  475 + },
  476 + {
  477 + "nested": {
  478 + "path": "image_embedding",
  479 + "_name": "exact_image_knn_query",
  480 + "score_mode": "max",
  481 + "query": {
  482 + "script_score": {
  483 + "query": {
  484 + "exists": {
  485 + "field": "image_embedding.vector"
  486 + }
  487 + },
  488 + "script": {
  489 + "source": "((dotProduct(params.query_vector, 'image_embedding.vector') + 1.0) / 2.0) * params.boost",
  490 + "params": {
  491 + "boost": 4
  492 + }
  493 + }
  494 + }
  495 + },
  496 + "inner_hits": {
  497 + "name": "exact_image_knn_query_hits",
  498 + "size": 1,
  499 + "_source": [
  500 + "url"
  501 + ]
  502 + }
  503 + }
  504 + }
  505 + ],
  506 + "minimum_should_match": 1
  507 + }
  508 + }
  509 + }
  510 + },
... ...