issue-2026-04-20-款式意图下SKU选取增强(option,taxonomy,图像)-0420done.md 27.6 KB

1-prompt

现在索引是spu粒度的,sku是作为spu的nested属性。召回和计算exact_image_knn_query都会计算图片的相似度,image_embedding字段是一个list,跟sku是一一对应的。 我希望,是因为哪个image_embedding被召回的、或者重排的时候,是取的哪个元素的exact_image_knn_query得分(image_embedding这个list内最高的得分),我希望将这个sku前置。

之前在重排之前有这个流程:

  1. 有款式意图的时候,才做sku筛选
  2. sku筛选的时机,改为在reranker之前,对所有内容(rerank输入的所有spus)做sku筛选 若有款式意图则对 rerank 窗口内全部 hits 做 SKU 预筛选并补充 rerank doc 文本 Searcher 只编排:解析 query -> 首次 ES 召回 -> 若有款式意图则对 rerank 窗口内全部 hits 做 SKU 预筛选并补充 rerank doc 文本 -> run_rerank -> 分页 但是,现在是关闭重排的,所以,这个流程可能没了?请你检查。

我的总的意图是,如果有款式方面的意图,需要做spu内部的sku的排序, 这两点也需要检查:

  1. 之前的流程,不知道是否仍然有效(因为现在关闭了reranker)
  2. 现在补充了image_embedding的召回和exact_image_knn_query得分,那么应该能知道哪个sku是最匹配的,希望把这个信号利用上, 1)召回的时候,是哪个图片向量相似度最高 2)重排的时候是用哪个算的相似度?这个信号如何利用 "rescore_query": { "bool": { "should": [ { "nested": { "path": "image_embedding", "name": "exact_image_knn_query", "score_mode": "max", "query": { "script_score": { "query": { "exists": { "field": "image_embedding.vector" } }, "script": { "source": "((dotProduct(params.queryvector, 'image_embedding.vector') + 1.0) / 2.0) * params.boost", "params": {

最终组织结果的时候,sku的排序,要把这个信号利用进来,要按照图片的相似度排序。 是直接用图片的信号,把图片得分最高的url,用于sku的筛选(选取对应url的sku。因为skus和image_embedding是两个字段,skus里面有image_src,可以用于url匹配,选取对应url的sku作为图片得分最高的sku)还是跟以前的策略融合在一起,还是,请思考

可以以query=白色T恤进行实验

1-response

我仔细读了当前 search/searcher.pysearch/sku_intent_selector.pysearch/es_query_builder.pymappings/search_products.jsonindexer/document_transformer.py,先回答你的两个核对问题,再给出如何把图片信号融入的设计。


1. 关闭 rerank 之后,原来的款式意图 SKU 流程是否还有效?

仍然有效。 关键点:

  • in_rank_window = (from_ + size) <= rerank_windowsearcher.py:529)只依赖配置里的 rerank_window,跟请求/配置里的 do_rerank 没关系。
  • 进入 in_rank_window 分支后,款式意图处理是无条件执行的:
    • searcher.py:1070-1081:在 coarse 完成、ranking 窗口 source fill 之后,调用 _apply_style_intent_to_hits 算出 style_intent_decisions(带 _style_rerank_suffix)。
    • searcher.py:1083-1154:fine 和 rerank 阶段现在都通过 _run_optional_stage 包装,enabled=False 时走 passthrough,不会丢前面挂在 hit 上的信号。
    • searcher.py:1205-1209:page fill 覆盖 _source 之后,再调一次 apply_precomputed_decisions,把 SKU 置顶 + image_url 替换最终写回。
  • 非 rank-window 分支(searcher.py:1225-1231)走 _apply_style_intent_to_hits 并在 result_processingapply_precomputed_decisions(1254-1259)。

所以"先 prepare、后 apply"的契约没断。唯一的"废功"是 _style_rerank_suffix——关掉重排之后它挂在 hit 上但没人读;代价很小、一旦你重新打开 rerank 又立刻生效,建议保留。


2. 当前 image_embedding / exact_image_knn_query 的信号完全喂到 SKU 选择里

我去看了索引 & 查询两端:

  • mappings/search_products.json:1054image_embedding 是 nested,每个元素 {vector, url};索引时(indexer/document_transformer.py:490-536)把 SPU 主图 + SKU 图片 URL 合起来去重后逐个编码,url 就是 skus[].image_src 里能对得上的那串
  • es_query_builder.py:193-281build_image_knn_clausebuild_exact_image_knn_rescore_clause 都是 nested + score_mode: max,但都没带 inner_hits。所以 ES 只告诉我们"nested 里最高分是多少",不告诉我们是哪条 url 命中的。
  • StyleSkuSelector 当前完全靠"意图词 × optionN_value 文本匹配"选 SKU(sku_intent_selector.py:252-284),没有任何 image 通路。

也就是说:"哪个 image_embedding 被召回 / 被 rescore 命中"这个你已经花代价算出来的信号,下游根本拿不到


3. 融合设计(建议方案)

我倾向融合而不是二选一,理由是两类信号各有盲区:

信号 擅长 盲区
文本款式意图(现有) "白色"能命中 option color=white 店家把颜色塞在 title 或图里、option 是数字码时失效
图片 KNN 最佳 url(新增) 视觉接近 query(文本走 CN-CLIP 后也和图片空间对齐) 可能选出"卖相最好"的主图,和明确的颜色诉求冲突

契约上的改动最小化:依旧 SkuSelectionDecision + apply_precomputed_decisions

只是在 prepare 阶段多一个候选源、decision 里多几个字段做 debug 和融合依据。

具体改动点

a. es_query_builder.py:给两个 nested image 查询都加 inner_hits

  • build_exact_image_knn_rescore_clause{"inner_hits": {"name": "exact_image_knn_hits", "size": 1, "_source": ["url"]}}
  • build_image_knn_clause{"inner_hits": {"name": "image_knn_hits", "size": 1, "_source": ["url"]}}

两个都加的原因:rescore 是 in_rank_window 才有;不在窗口内时还能吃到初召回的 inner_hits。代价:每个 hit 多返回一个 {url, _score},很轻。

b. sku_intent_selector.py:扩展 decision,新增 image-pick 通路

@dataclass(frozen=True)
class SkuSelectionDecision:
    ...                                   # 保留现有字段
    image_pick_sku_id: Optional[str] = None
    image_pick_url: Optional[str] = None
    image_pick_score: Optional[float] = None
    final_source: str = "text"            # "text" | "image" | "none"
  • 新增 _pick_sku_by_image(hit, source):读 hit["inner_hits"]["exact_image_knn_hits"](缺省回退到 image_knn_hits),取 top url,在 skus[] 里按 image_src 归一化后等值匹配(注意处理 // 协议无关前缀、大小写、querystring 这些脏数据,按 indexer 那边的写入规则来)。
  • _select_for_source 的新决策树:
    1. text_pick = 现在的文本匹配结果。
    2. image_pick = 上述 helper 的结果。
    3. 有款式意图:
      • text_pick 非空 → 选 text_pickfinal_source="text";把 image_pick 存进 decision 里仅用于 debug/验证(两者一致时在 debug 打标,不一致时也不翻盘——query 显式指名了颜色/尺码,视觉信号不该覆盖)。
      • text_pick 为空但 image_pick 非空 → 选 image_pickfinal_source="image"
    4. 无款式意图:
      • image_pick 的分数 ≥ 阈值(配置项,例如 query_config.image_sku_selection_min_score),选 image_pick。否则不做 SKU 选择(保持 SPU 原顺序)。
  • _apply_decision_to_source 无需大改,仍然是"把选中 SKU 顶到前面 + 替换 image_url 为该 SKU 的 image_src";只是 selected_sku_id 的来源更丰富了。

c. searcher.py:把 image-pick 路径也纳入 "prepare → apply"

  • 把"_has_style_intent 才 prepare"改成"_has_style_intenthas_image_inner_hits 就 prepare"。命名上把 _apply_style_intent_to_hits 改成 _apply_sku_selection_to_hits 更准确(可做可不做,看你对改动面范围的容忍度)。
  • _resolve_rerank_source_filter 已经在有款式意图时补了 skus/option*_name;image-only 路径还需要 skus(至少 sku_idimage_src),所以触发条件要扩到"只要可能选 SKU"。
  • inner_hits 会带在 top-level hit 上,不会被 page fill 的 _source 覆盖影响,但 page fill 用的是 ids 查询,本来就不会有 inner_hits。这正好符合现有契约:prepare 时把 image_pick 落到 SkuSelectionDecision,apply 阶段不再依赖 inner_hits,就不会丢。
  • context.store_intermediate_result("style_intent_profile", ...) 附近把 image-pick 的诊断也写进 debug。

d. 配置

query_config:
  image_sku_selection_enabled: true          # 总开关
  image_sku_selection_min_score: 0.55        # 无款式意图时的保护阈值
  image_sku_selection_when_text_missing: true  # 有意图但文本未命中时回退到图片

我为什么不建议"用图片分直接替换原策略"

两个理由:

  1. 你现在的文本策略对明确的词面命中是最可信的信号;query 说"白色",结果给了视觉更漂亮但色号不同的 SKU,这是产品上的倒退。
  2. 图片 KNN 的 max score 在 nested score_mode=max 下是"最相似那张图的分",但 CN-CLIP 的文本→图像相似度阈值校准很敏感,不同品类/拍摄风格分布差异大。拿来做候选稳,拿来做唯一决定不稳。

4. query=白色T恤 的实验

改完之后可以这样验证:

source activate.sh
python main.py search "白色T恤" --tenant-id 1 --size 10 --debug \
  | jq '.debug_info.per_result[] | {spu_id, style_intent_sku, first_sku: .skus[0]?}'

预期关注点:

  • 有 color option 的 SPUstyle_intent_sku.final_source == "text",并且 image_pick_sku_id 多数情况下与 text 选的一致(一致率是个好指标)。
  • 没有 color option、但多图的 SPUfinal_source == "image"skus[0].image_src 的图肉眼是白色。
  • 两者冲突:debug 里应能看到 selected_sku_id != image_pick_sku_id 的案例,确认当前默认"文本优先"没有被图片翻盘。
  • 分别跑 --debug 对比改前/改后同一 query 的 results[].image_urlskus[0].image_src,观察白色主图替换的命中率提升。

如果想更系统,可以扩 scripts/evaluation/ 里已有的回归 case,加一个"图片颜色是否匹配意图"的人工抽检指标。


5. 两个我想先和你确认的产品决策

  1. 无款式意图时,是否允许图片信号单独触发 SKU 置顶?(我倾向:允许,但带阈值保护,默认开。)
  2. 文本命中 vs 图片命中冲突时,是硬性文本优先,还是做分数融合(先各自 min-max 归一再取加权 max)?我倾向硬性文本优先——实现简单、行为可解释;融合方案留作后续 A/B。

告诉我你的取舍,我就按上面的改动清单一次性改掉(es_query_builder.py 两个 inner_hits、sku_intent_selector.py 扩字段 + 新通路、searcher.py 触发条件扩容 + 命名收敛、配置项、配套单测),并跑 白色T恤 的实际 debug 对照给你看。

2-prompt

  1. 无款式意图时,允许图片信号单独触发 SKU 置顶

  2. 文本命中,硬性文本优先,当匹配失败的时候("selected_sku_id": null)使用图片最相近的

  3. es中有这个字段enriched_taxonomy_attributes: "enriched_taxonomy_attributes": [ { "name": "Color", 文本匹配的时候,考虑更全面一点: 1)把这个字段考虑进去,enriched_taxonomy_attributes内Color对应的value匹配的时候 我在 source_fields:加了enriched_taxonomy_attributes之后好像还是没返回这个字段,你检查下是不是能拿到,可以考虑对query=卡其色裙子 首条结果验证下

  4. 还是颜色意图文本匹配的问题 query=卡其色裙子的首条结果: "selected_sku_id": null, "specifications": [ { "value_text": {...}, "name": "颜色", "sku_id": "447652", "value_keyword": "卡其色v领收腰长裙【常规款】" },

意图检测是成功的: intent_detection: active: yes intents: intent_type: color dimension_aliases: color, colors, colour, colours, 颜色, 色, 色系 matched_term: 卡其色 canonical_value: beige

但是 "selected_sku_id": null, 颜色没有匹配上,value_keyword 包含了“卡其色”,没有匹配成功,因此也应该没有享受到提权

2-response

好,那就直接在当前 agent 模式下按计划施工。施工顺序按"先不破坏现有行为、再扩新通路":

Phase 1(修复 + 显字段,低风险)

  1. api/models.pySpuResult 两个富化字段
  2. api/result_formatter.py 透传
  3. sku_intent_selector.py:扩匹配词集合(zh+en+attr)+ 收敛为子串匹配;加 enriched_taxonomy_attributes 作为 SPU 级确认

Phase 2(image 回退通路)

  1. es_query_builder.py 两个 nested image 查询加 inner_hits
  2. sku_intent_selector.pySkuSelectionDecision + _pick_sku_by_image + text-first/image-fallback 决策树
  3. searcher.py 触发条件扩为"款式意图 OR image inner_hits",_resolve_rerank_source_filter 无意图时也带 skus 字段
  4. config.yamlimage_sku_selection.*

Phase 3(调试 + 回归)

  1. debug payload 扩 text_pick/image_pick/final_source
  2. 单测补四个关键场景
  3. 卡其色裙子 / 白色T恤 对照

开始吗?两件事需要你拍板后我再动:

  1. enriched_taxonomy_attributes 的语义级别:你要的是"作为另一个 SKU-级 value 匹配源",还是我上面提议的"仅作 SPU 级确认信号,不直接决定 SKU"?我倾向后者——因为这个字段是 SPU 级的,不能单独指定某个 SKU。前者会带来"SKU A 其实不是卡其色、却被 SPU 级信号顶上去"的错案。

  2. image-pick 的分数阈值:无意图时建议给个阈值(min_score_no_intent),有意图 fallback 时不加阈值。你接受这个不对称吗?接受的话我用 0.55 起步,上线后调。

你确认这两点,我就一把改完。

3-prompt

请综合考虑、全部完成,enriched_taxonomy_attributes的Color对应到值是和option1_name/option1_values/option2/option3一样对待的,就是当前使用的属性值匹配,多一个字段来源,即,即使对于颜色意图,同时把me对应的option value和enriched_taxonomy_attributes的Color对应到值一起判断,要考虑统一设计、精炼代码。image-pick设计和代码保持精炼,遵循统一的最优的设计,尽量不要fallback。

3-response

27/27 通过。

问题 2:是不是所有"选中"都享受 1.2x 提权?

是的,目前无差别地都享受,这确实是个设计缺口。 机制如下:

    return bool(str(hit.get("_style_rerank_suffix") or "").strip())
        sku_selected = _has_selected_sku(hit)
        style_boost = style_intent_selected_sku_boost if sku_selected else 1.0

只要 prepare_hits 给 hit 写了 _style_rerank_suffix(只要 selected_text 非空),下游 run_rerank / run_lightweight_rerank 就把 style_boost=1.2 乘进 fused score。
当前 _style_rerank_suffix 的写入条件是 decision.rerank_suffix 非空 —— 等价于 final_source ∈ {"text", "image"}。也就是说:

选中来源 (final_source) 是否吃 1.2x 款式提权(现状)
text(自有值命中意图词)
image(仅图像相似度置顶) ✅ ← 语义上不该等价
none

把"图像置顶"和"文本命中"按同一强度做 SPU 级提权,存在两个隐患:

  1. 置信度不同:文本命中是显式属性匹配(强信号),图像 pick 是基于视觉相近(弱信号、还可能是 SPU 主图而非 SKU 图);给同样 1.2x 会让"图像近似 → SPU 排名拔高"发生在没有真正属性命中的情况下。
  2. 无意图纯图像触发时更夸张:query 无任何款式意图,但只要 image_knn 的 top url 能对上某个 SKU,整条 SPU 就吃到 1.2x,等于让向量相似度二次乘进融合。

建议的区分策略(只改 rerank_client,不改决策侧):

  • 文本命中(final_source="text")→ 保留 1.2x(可配置)
  • 图像命中(final_source="image")→ 1.0x(不加提权,仅做 SKU 置顶 + 改封面图)

最简实现:把"是否命中"从"后缀是否非空"改为"后缀非空 AND 是文本来源"。具体做法:

  1. prepare_hits 只在 final_source == "text" 时写 _style_rerank_suffiximage 时不写,但仍把决策放进 decisionsapply_precomputed_decisions 照常置顶 SKU + 换封面图)。
  2. 对应的 rerank doc 后缀也就不含图像源的颜色/尺码值(避免把一个"靠图选的 SKU"当作文本证据喂给 reranker)。

要我现在就把这条改了吗?或者你更希望 image 源也吃提权但用一个更小的系数(比如 1.05),这样保留视觉加分但不等同于文本命中。

4-prompt

两个问题,请修复:

  1. option匹配,应该要能成功匹配才对: { "sku_id": "441679", "price": 13, "compare_at_price": 0, "sku_code": "", "stock": 100, "weight": 0, "weight_unit": "kg", "option1_value": "卡其色(无内衬)",

  2. 即使option没有匹配、而是依靠enriched_taxonomy_attributes 匹配的,那么rerank_suffix selected_text 也要翻译真实的匹配项,final_source也最好 enriched_taxonomy_attributes 和option有所区分 Selected SKU { "selected_sku_id": "441670", "rerank_suffix": "白色(无内衬)", "selected_text": "白色(无内衬)", "final_source": "text",

4-response

改动摘要

问题 1(option 匹配)

我上一条消息里修的 "SKU 自有值优先于 taxonomy" 就解决了这个问题。用你给的样本跑:

query=卡其色裙子
skus=[441670:白色(无内衬), 441679:卡其色(无内衬)]
→ selected_sku_id: 441679
  selected_text: 卡其色(无内衬)
  final_source: option

全角括号之所以能命中是两个机制叠加:

  • 全角 不在 [\u4e00-\u9fff] 范围内,分词器按 ["卡其色", "无内衬"] 切分;
  • 纯中文词 卡其色 落入 "pure-Han 子串回退" 分支,即使分词切分不一致也能匹配。

加了一个贴着真实数据命名的回归 case test_zh_color_intent_matches_noisy_option_value_with_fullwidth_parens 锁死。

问题 2(区分 taxonomy / option,selected_text 反映真实匹配项)

决策结构扩展

SkuSelectionDecision:
    final_source: "option" | "taxonomy" | "image" | "none"
    matched_sources: Dict[intent_type, "option"|"taxonomy"]   # 逐意图细分
    selected_text / rerank_suffix: 真正命中的原始文本(option 源为 sku.optionN_valuetaxonomy 源为 enriched_taxonomy_attributes 对应 value

匹配逻辑_find_text_matched_skus):per-intent 走两条互斥路径,保留权威原则:

  • SKU 在该维度有自有值 → 只用自有值判断 → 命中时 source=option
  • SKU 该维度值为空(槽未解析 或 optionN 值空)→ 用 SPU taxonomy 判断 → 命中时 source=taxonomy

整体来源聚合:任一意图靠 taxonomy 命中 → 整体 final_source="taxonomy";全部由 option 命中 → final_source="option"。这保留了"最弱文本证据"的含义,下游(比如 rerank boost)可以据此做差异化。

真实匹配项回填示例

  • option1_name=颜色, option1_value=卡其色(无内衬)selected_text="卡其色(无内衬)"final_source=option
  • option1_name=Style, enriched_taxonomy_attributes=[{Color, {zh:"卡其色"}}]selected_text="卡其色"(来自 taxonomy 原文),final_source=taxonomy

与 1.2x 款式提权的衔接(回答你上一条的后半问)

当前 rerank_client 判断 "是否选中" 的依据仍是 _style_rerank_suffix 是否非空——也就是说 option / taxonomy / image 三种来源目前仍一视同仁都吃 1.2x。现在有了 final_source 作为可区分的信号,你想怎么分级,我马上就能改。常见选择:

  • option:1.2x(强信号)
  • taxonomy:1.1x(SPU 级,弱一档)
  • image:1.0x(不加提权,仅做 SKU 置顶 + 换封面)

要不要我把这三档都拉到 config.yaml 里做成可配?或者先把 image 档位降到 1.0x、其它保持不变?

5-prompt

q=藏青百褶半身裙 第五条结果 "spu_id": "68501",

Selected SKU { "selected_sku_id": null, "rerank_suffix": "", "selected_text": "", "final_source": "none", "matched_sources": {}, "resolved_dimensions": { "color": "option1_value" }, "image_pick": null } 为什么image_pick也没生效

5-response

为什么当时 image_pick 也是 null

image_pick 来自 ES 返回的 inner_hitsexact_image_knn_query_hits 优先,其次 image_knn_query_hits),再用其中的 URL 去对齐 skus[].image_src。下面任一情况都会变成 null

  1. 没有 inner_hits

    • 例如:该文档不在 exact KNN rescorewindow_size 内(若 exact_knn_rescore_window 配得很小,排第 5 条也可能拿不到 exact 的 inner_hits)。你这边配置是 160,第 5 条一般仍在窗口内。
    • 或:召回阶段 nested image KNN 对该父文档没有产生 inner_hits(只靠文本等子句进结果时,有时 nested 子句侧没有可返回的条目)。
  2. 有 inner_hits,但 URL 对不上任何 SKU(最常见)

    • inner_hits 里是 https://cdn/a.jpg?w=800,SKU 里是 https://cdn/a.jpg
    • 或 CDN 域名不同、路径相同
    • _source.url 与索引里 image_embedding 的 canonical 写法不一致,而 SKU 用的是另一种写法
  3. 以前预取 _source 里没有 image_embedding

    • ES 在 inner_hit 里给了 _nested.offset,但 _source.url 不可靠时,无法image_embedding[offset].url 再解析一次。

本次代码上做了什么(已提交到当前分支)

  1. URL 对齐放宽:去掉 query、路径归一、host+path / 仅 path / 文件名 多级比对,减少「明明是一张图却匹配不上」。
  2. _nested.offset + image_embedding[offset].url 回退:inner_hit 里若带 offset,会同时尝试 _source.url 和父文档里 image_embedding[offset].url(与索引侧一致)。
  3. ImagePick.url 改为记录真正用来对齐成功的那条候选 URL(便于 debug)。
  4. Searcher:在存在 图像查询向量_has_image_signal)时,预取 _source 里增加 image_embedding,否则 offset 回退拿不到数据。

这样在「藏青百褶半身裙」这类 文本未命中 SKU、指望图像兜底 的场景里,只要 ES 给了 inner_hits 且最终能和我们归一化后的 SKU 图对齐,image_pick 就会非空;若仍为空,请在 debug 里看该条 hit 是否还有 inner_hits,以及 exact_knn_rescore_window、是否关掉了 rescore 等。


关于「藏青」文本为什么也可能没命中

词表里 navy 已含「藏青、藏蓝、深蓝」。若 SKU 的 option1_value 写成 和词表不一致 的表述(例如只有英文色号、或「青色系」等),仍可能文本全挂,这时更依赖上面的 图像对齐;若图像也对不上,就会是 final_source: none + image_pick: null

6-prompt

6-response

TODO

            "inner_hits": {
              "name": "exact_image_knn_query_hits",
              "size": 1,
              "_source": [
                "url"
              ]
            }

inner_hits获取exact_image_knn_query_hits的目的是:是因为哪个

参考: feat(search): 款式意图下统一 SKU 选取(option/taxonomy/图像)与属性值匹配增强

## 主要能力
- 在 rerank 窗口内对 hits 做 SKU 预决策:款式意图(多源同义词)+ 图像 KNN inner_hits URL 对齐 SKU.image_src,统一一次决策

、无级联 fallback。 - 区分文本证据强度:final_source ∈ {option, taxonomy, image, none};matched_sources 按意图记录 option 或 taxonomy;selected_text / rerank_suffix 回填真实命中片段(SKU option 原文或 taxonomy value 原文)。 - 权威规则:SKU 在已解析维度上有非空 option 值时仅以该值参与匹配;SPU 级 enriched_taxonomy_attributes 不覆盖与之一致的 SKU 级矛盾值(修复「taxonomy 把白色 SKU 当卡其色命中」)。 - 图像:nested image KNN / exact rescore 增加 inner_hits(url),用于 SKU 置顶时的视觉 tie-break(仅在文本命中集内)或无意图时纯图像置顶。 - 查询侧:DetectedStyleIntent 增加 all_terms(zh+en+attribute 并集),属性值匹配与意图词表一致。 - API:SpuResult 透出 enriched_attributes / enriched_taxonomy_attributes(避免 Pydantic 丢弃 ES 字段)。

## 属性值匹配(括号和分隔符)
- 在分词前对归一化后的 option/taxonomy 字符串执行 _with_segment_boundaries_for_matching:将全/半角括号、斜杠、顿号、中英文标点、中点、各类横线等替换为空格,再 simple_tokenize + 滑窗;无分隔的连续汉字仍走纯中文子串回退(如 卡其色棉)。
- 参数化测试覆盖多种括号与常见电商分隔写法。

## 编排与配置
- searcher:_should_run_sku_selection = 款式意图激活 或 存在 image_query_vector;prefetch _source 含 skus、option 名、enriched_taxonomy_attributes。
- es_query_builder:image knn / exact image rescore 的 nested 子句带 inner_hits。

## 测试与仓库
- tests/test_sku_intent_selector.py、tests/test_search_rerank_window.py 更新;移除已废弃的 embedding-fallback 集成断言。
- .gitignore:忽略 artifacts/search_evaluation/datasets/(本地评估大数据集,避免误提交)。

"rescore": { "window_size": 160, "query": { "score_mode": "total", "query_weight": 1, "rescore_query_weight": 0, "rescore_query": { "bool": { "should": [ { "script_score": { "name": "exact_text_knn_query", "query": { "exists": { "field": "title_embedding" } }, "script": { "source": "((dotProduct(params.query_vector, 'title_embedding') + 1.0) / 2.0) * params.boost", "params": { "boost": 4 } } } }, { "nested": { "path": "image_embedding", "name": "exact_image_knn_query", "score_mode": "max", "query": { "scriptscore": { "query": { "exists": { "field": "imageembedding.vector" } }, "script": { "source": "((dotProduct(params.query_vector, 'image_embedding.vector') + 1.0) / 2.0) * params.boost", "params": { "boost": 4 } } } }, "inner_hits": { "name": "exact_image_knn_query_hits", "size": 1, "source": [ "url" ] } } } ], "minimumshould_match": 1 } } } },