
总体的目的是：
1）要对原始query进行翻译（通常是en/zh的一种或者两种）
2）对原始query要有关键词提取（关键词提取可能依赖分词；中英文分词要不一样，各自寻求最优性能的方法。zh的可以保持不变，en的可以优化）
3）其他的一些任务可能依赖分词
4）获取text embedding/clip embedding


英文关键词提取：走spacy进行关键词提取（即主干分析）。提取出query中的核心词，用于搜索时候的term求交，其余的词不参与求交、只用于权重计算。


实现：

# Query 模块说明

本目录实现搜索请求侧的**查询理解与解析**：在不做 Elasticsearch 语言计划拼装的前提下，产出可供检索层、重排层与调试界面消费的**结构化事实**（规范化文本、检测语言、可选翻译、文本与 CLIP 向量、分词与关键词、可选的样式意图与标题排除配置等）。下面按**当前实现**说明策略与数据流，便于与 `search/`、`context/`、`frontend/` 对照阅读。

---

## 包内文件与职责

| 文件 | 作用 |
|------|------|
| `query_parser.py` | 入口 `QueryParser`：编排规范化、改写、语言检测、异步翻译与向量、分词、关键词、意图与排除检测；定义 `ParsedQuery`。 |
| `tokenization.py` | 轻量分词、文本规范化、`TokenizedText` 与按请求复用的 `QueryTextAnalysisCache`（模型分词与语言提示、粗细分词策略）。 |
| `keyword_extractor.py` | `KeywordExtractor`：中文走 HanLP 分词 + 词性名词串；英文走 spaCy 核心词；`collect_keywords_queries` 汇总 `base` 与各翻译语种。 |
| `english_keyword_extractor.py` | `EnglishKeywordExtractor`：`en_core_web_sm` + 依存/名词块规则，产出短字符串供检索侧关键词子句使用。 |
| `language_detector.py` | 脚本优先 + Lingua 的通用语言检测（与 `QueryParser` 的英文 ASCII 快路径配合使用）。 |
| `query_rewriter.py` | 基于配置词典的查询改写与规范化。 |
| `style_intent.py` | 从配置加载样式意图词表，对查询变体做候选匹配，产出 `StyleIntentProfile`。 |
| `product_title_exclusion.py` | 从配置加载标题排除规则，对多路查询文本做触发词匹配，产出 `ProductTitleExclusionProfile`。 |

公开符号见 `query/__init__.py`（`QueryParser`、`ParsedQuery`、`KEYWORDS_QUERY_BASE_KEY` 等）。

---

## 解析产物：`ParsedQuery`

`ParsedQuery` 是单次 `parse()` 的权威结果容器，字段含义与下游约定如下。

- **`original_query` / `query_normalized` / `rewritten_query`**：分别为原始输入、规范化后、词典改写后的主查询文本；后续翻译、向量、默认分词与 `base` 关键词均以**改写后的 `rewritten_query`（在代码变量中常名为 `query_text`）**为基准。
- **`detected_language`**：解析时认定的源语言代码；若检测为 `unknown` 或空，则回退到 `SearchConfig.query_config.default_language`。
- **`translations`**：键为**目标语言代码**（如 `zh`、`en`），值为翻译服务返回的字符串；仅包含本次请求实际需要的目标语种（见下文翻译目标推导）。
- **`query_vector` / `image_query_vector`**：分别为 BGE 类文本向量与 CLIP 文本向量（维度由各自编码服务决定）；未生成或未在超时内完成则为 `None`。
- **`query_tokens`**：对**改写后主查询**做分词后的字符串列表，供例如 KNN 参数按 token 数分支等逻辑使用；分词路径由 `QueryTextAnalysisCache` 决定（纯拉丁英文可走轻量分词，含汉字则走 HanLP）。
- **`keywords_queries`**：与「主查询 + 各翻译变体」平行的**关键词子查询**映射：键 `base`（常量 `KEYWORDS_QUERY_BASE_KEY`）对应源语言侧关键词串，其它键与 `translations` 的语种键一致。空串或无法提取的条目**不会写入**字典。
- **`style_intent_profile` / `product_title_exclusion_profile`**：可选的理解结果；是否生效完全由 `config.yaml` 中 `query_config` 的对应开关与词表/规则决定。
- **`_text_analysis_cache`**：单次解析内的分词与语言提示缓存，**不参与序列化**，仅供同一次 `parse` 内各检测器复用，避免对同一文本重复调用 HanLP。

与重排相关的文本选择由独立函数 `rerank_query_text()` 完成：检测为 `zh` 或 `en` 时始终用原始查询；其它语言优先英译再中译，见 `query_parser.py` 中实现。

---

## `QueryParser.parse()` 的执行顺序与策略

解析主流程在 `QueryParser.parse()` 中实现。整体目标是：在**共享等待预算**下并行完成翻译与向量请求，同时尽量减少主线程上重复、昂贵的分词与 NLP 调用，并把结果写入可选的 `context`（请求上下文）供日志与 `debug_info` 使用。

### 1. 规范化与改写

- 使用 `QueryNormalizer` 得到 `query_normalized` 并可选写入 `context.store_intermediate_result('query_normalized', ...)`。
- 若配置了改写词典，则用 `QueryRewriter` 可能更新 `query_text`；改写成功时记录 `rewritten_query` 与告警。

### 2. 语言检测：通用路径与英文 ASCII 快路径

- **快路径**：当「活跃语种集合」仅为 `en` 与 `zh` 的子集时（活跃集合取 `target_languages` 归一化结果，若为空则回退到 `query_config.supported_languages`），且当前查询为**纯 ASCII、含字母、不含汉字**，则**直接判定为 `en`**，不再调用 `LanguageDetector`（避免 Lingua 等开销）。逻辑见 `_detect_query_language()` 与 `_is_ascii_latin_query()`。

```303:317:query/query_parser.py
    def _detect_query_language(
        self,
        query_text: str,
        *,
        target_languages: Optional[List[str]] = None,
    ) -> str:
        normalized_targets = self._normalize_language_codes(target_languages)
        supported_languages = self._normalize_language_codes(
            getattr(self.config.query_config, "supported_languages", None)
        )
        active_languages = normalized_targets or supported_languages
        if active_languages and set(active_languages).issubset({"en", "zh"}):
            if self._is_ascii_latin_query(query_text):
                return "en"
        return self.language_detector.detect(query_text)
```

- **通用路径**：`LanguageDetector` 先按 Unicode 脚本返回明确语种（如汉字块即 `zh`），否则用 Lingua 在一大组语言中判别，见 `language_detector.py`。

检测最终结果写入 `context.store_intermediate_result('detected_language', ...)`（若提供 `context`）。

### 3. 按请求分词缓存与语言提示

每次 `parse` 会新建 `QueryTextAnalysisCache(tokenizer=self._tokenizer)`，并对**原始串、规范化串、改写后串**调用 `set_language_hint(..., detected_lang)`，使后续对同一文本的 `get_tokenizer_result` / `get_tokenized_text` 能按语言选择**是否调用 HanLP**。

### 4. HanLP 模型（与 `KeywordExtractor` 对齐）

`QueryParser` 默认构建的 `self._tokenizer` 为 HanLP 预训练分词模型 **`FINE_ELECTRA_SMALL_ZH`**，并开启 `output_spans=True`，以便与关键词提取共用「带偏移的分词结果」。

```237:245:query/query_parser.py
    def _build_tokenizer(self) -> Callable[[str], Any]:
        """Build the tokenizer used by query parsing. No fallback path by design."""
        if hanlp is None:
            raise RuntimeError("HanLP is required for QueryParser tokenization")
        logger.info("Initializing HanLP tokenizer...")
        tokenizer = hanlp.load(hanlp.pretrained.tok.FINE_ELECTRA_SMALL_ZH)
        tokenizer.config.output_spans = True
        logger.info("HanLP tokenizer initialized")
        return tokenizer
```

`KeywordExtractor` 在未注入自定义 `tokenizer` 时同样加载 **`FINE_ELECTRA_SMALL_ZH`**，并额外加载 **`CTB9_POS_ELECTRA_SMALL`** 做词性标注；二者在「中文路径」上语义一致，便于复用 `tokenizer_result`。

### 5. 异步富集：翻译、文本向量、CLIP 文本向量

- 翻译目标：`translation_targets = normalized_targets` 中**去掉与检测源语言相同**的代码后的列表（例如源为 `en` 且索引语言为 `["en","zh"]` 时只翻 `zh`）。
- 翻译模型名：由 `_pick_query_translation_model()` 根据「源语言是否在索引语言内」及 `zh↔en` 等分支从 `QueryConfig` 选取。
- 当 `generate_vector` 为真且配置开启文本嵌入时，向线程池提交 `text_encoder.encode([query_text], ...)`；当配置了 `image_embedding_field` 时提交 `image_encoder.encode_clip_text(query_text, ...)`。
- 线程池：`ThreadPoolExecutor`，`max_workers` 为 `min(任务数, 4)` 与至少 1。
- **提交顺序**：先尽可能提交所有异步任务，再在主线程上做「与异步重叠」的轻量工作（见下一节），最后 `concurrent.futures.wait(..., timeout=budget_sec)`。超时未完成的任务会记 warning，并 `shutdown(wait=False)` 不阻塞关闭线程池。

等待预算（毫秒）来自 `QueryConfig`：

- 源语言在索引语言内：`translation_embedding_wait_budget_ms_source_in_index`
- 否则：`translation_embedding_wait_budget_ms_source_not_in_index`

完成每个 future 后打 `Async enrichment task finished` 日志（含 `elapsed_ms`，为从提交到完成的大致墙钟时间）。

### 6. 主查询分词与「base」关键词（与异步重叠）

在异步任务已提交之后、`wait()` 之前，当前实现会：

1. 通过 `text_analysis_cache.get_tokenizer_result(query_text)` 得到分词结果，再 `extract_token_strings` 得到 **`query_tokens`**；
2. 调用 `KeywordExtractor.extract_keywords(query_text, language_hint=detected_lang, tokenizer_result=...)` 得到 **`keywords_base_query`**（若失败则日志告警，base 关键词可能为空）。

这样主线程在等翻译/向量时，已并行完成源侧分词与源侧关键词的大部分工作。

### 7. 等待结束后的关键词汇总与检测器

`wait()` 返回后：

- 若有翻译结果，写入 `context.store_intermediate_result("translations", translations)`，并对每条翻译 `text_analysis_cache.set_language_hint(result, lang)`，保证后续对该翻译串的分词/关键词走正确语言路径。
- `collect_keywords_queries(...)` 合并 **`base`**（可传入已算好的 `base_keywords_query` 避免重复抽取）与各翻译语种的关键词，得到 **`keywords_queries`**；成功时 `context.store_intermediate_result("keywords_queries", keywords_queries)` 并打 `Keyword extraction completed` 日志。
- 构造带 `_text_analysis_cache` 的 `ParsedQuery` 草稿，依次调用 `StyleIntentDetector.detect` 与 `ProductTitleExclusionDetector.detect`，再把完整 `ParsedQuery` 返回。

解析阶段会打聚合耗时日志 `Query parse stage timings`，字段含义为：

- **`before_wait_ms`**：从解析开始计时点到进入 `wait()` 之前的主线程耗时（含规范化、改写、语言检测、提交异步任务、主查询分词、base 关键词等）；
- **`async_wait_ms`**：`wait()` 阻塞时间；
- **`base_keywords_ms`**：base 关键词抽取耗时；
- **`keyword_tail_ms`**：`collect_keywords_queries` 及前后尾部逻辑中关键词相关部分的主要耗时；
- **`tail_sync_ms`**：`wait()` 之后整段同步尾巴（含关键词汇总、两检测器、写中间结果等）。

---

## 分词与 `QueryTextAnalysisCache`

### `get_tokenizer_result`：何时走 HanLP，何时走轻量分词

- 若未配置模型 `tokenizer`，直接返回空列表路径的轻量结果（由上层避免依赖）。
- 若根据**该文本的语言提示**与**是否含汉字**判断不需要模型：返回 `simple_tokenize_query` 的列表（字符串 token），**不调用 HanLP**。
- 否则对该文本调用一次 `self.tokenizer(text)`（HanLP），结果按文本缓存，同一次 `parse` 内重复访问同一字符串不会重复推理。

核心判断在 `_should_use_model_tokenizer`：**语言提示为 `zh` 时，仅当文本含汉字才用模型**；非 `zh` 提示时，仅当文本含汉字才用模型。因此纯英文主查询在提示为 `en` 时走轻量分词；中文翻译串在 `set_language_hint(..., "zh")` 且含汉字时走 HanLP。

### `coarse_tokens` 与 `fine_tokens`：`TokenizedText`

- **`fine_tokens`**：来自 `extract_token_strings(get_tokenizer_result(...))`，在中文路径上即 HanLP 分词后的词串（已按规范化键去重保序）。
- **`coarse_tokens`**：由 `_build_coarse_tokens` 决定。若语言提示为 **`zh`**，或文本含汉字且已有 `tokenizer_tokens`，则 **粗粒度 token 与 fine 一致**（即采用模型分词粒度，而不用「整段 CJK 连成一项」的纯正则策略）。否则使用 `simple_tokenize_query`（适合拉丁词、数字、带连字符/撇号的英文词形）。

```92:103:query/tokenization.py
def _build_coarse_tokens(
    text: str,
    *,
    language_hint: Optional[str],
    tokenizer_tokens: Sequence[str],
) -> List[str]:
    normalized_language = normalize_query_text(language_hint)
    if normalized_language == "zh" or (contains_han_text(text) and tokenizer_tokens):
        # Chinese coarse tokenization should follow the model tokenizer rather than a
        # regex that collapses the whole sentence into one CJK span.
        return list(_dedupe_preserve_order(tokenizer_tokens))
    return _dedupe_preserve_order(simple_tokenize_query(text))
```

- **`candidates`**：在 fine、coarse、两类 n-gram 短语（上限由 `max_ngram` 控制）以及整句 `normalized_text` 上合并去重，供 `StyleIntentDetector`、`ProductTitleExclusionDetector` 等做子串/短语级匹配。

`tokenize_text()` 是对单次无缓存场景的薄封装：内部新建 `QueryTextAnalysisCache` 再 `get_tokenized_text`。

---

## 关键词提取：`KeywordExtractor` 与 `collect_keywords_queries`

### 路由规则

`extract_keywords` 根据 `language_hint` 分支：

- **`en`**：完全交给 `EnglishKeywordExtractor`（spaCy），**不使用** HanLP 分词结果做 POS 名词筛选（即使调用方传入 `tokenizer_result` 也会被忽略在该路径内）。
- **`zh`**：使用 HanLP 分词结果（优先复用传入的 `tokenizer_result`），再对词序列跑 `CTB9_POS_ELECTRA_SMALL`，保留**长度 ≥ 2 且词性以 `N` 开头**的词；非连续名词之间插入空格拼接成一条字符串（与 ES 侧 `keywords_query` 的用法一致）。
- **其它非空语言码**：当前实现返回空串，即**不为该语种生成关键词子句**（由调用方决定是否跳过）。

### `collect_keywords_queries`

- 键 **`base`**：对应 `rewritten_query` 的关键词；若调用方已预先计算 `base_keywords_query` 则直接写入，避免重复抽取。
- 其它键：与 `translations` 中每个非空语种一一对应，语言码归一化为小写。
- 全程可传入 `text_analysis_cache`，以便 `get_tokenizer_result` 命中缓存并与检测器共享分词结果。

常量 `KEYWORDS_QUERY_BASE_KEY` 的值为字符串 **`"base"`**，与检索构建里读取的字段一致。

---

## 英文关键词：`EnglishKeywordExtractor`

- 依赖 **spaCy** 模型 **`en_core_web_sm`**，加载时关闭 `ner`、`textcat` 以减轻开销；加载失败时记录 warning 并走基于 `simple_tokenize_query` 的回退策略。
- 主路径用依存句法与名词块规则收集一小组「核心词」候选（如直接宾语名词、部分 ROOT 名词/专有名词、INTJ 结构下的宾语等），并处理价格/目的介词宾语降级、人口学名词（如 `women`）弱化、尺寸类 ROOT 与主语搭配等边界情况。
- 使用 `_project_terms_to_query_tokens` 将 spaCy 词形映射回查询中的**表面分词**（例如复合词 `t-shirt`），避免在关键词串中出现被错误切断的片段。

最终返回**最多三个词**的空格连接字符串，用于检索侧第二层 `combined_fields` 的紧凑查询（见下节）。

---

## 与检索层的关系（消费方摘要）

`ParsedQuery.keywords_queries` 由 `search/es_query_builder.py` 读取：在构建某一语言的 lexical 子句时，除主 `combined_fields`（完整 `query`）外，若存在非空的 `keywords_query` 且与主查询不同，会追加第二个 `combined_fields`，使用单独的 `minimum_should_match`（由 builder 的 `keywords_minimum_should_match` 配置）和较低 boost，从而在**不替代全文查询**的前提下加强核心词匹配。

`query_tokens` 在同文件中间接影响例如带文本向量时的 KNN 分支参数（按 token 数量选用长查询的 k / num_candidates 等）。具体字段与 boost 以 `ESQueryBuilder` 当前实现为准。

---

## 样式意图与标题排除（简要）

- **`StyleIntentRegistry` / `StyleIntentDetector`**：从 `QueryConfig.style_intent_terms` 等加载意图定义；`detect` 时按中英变体取查询文本，经 `tokenize_text` 或缓存得到 `TokenizedText`，在 `candidates` 上与配置同义词表匹配，输出 `StyleIntentProfile`（含 `query_variants` 与命中意图列表）。
- **`ProductTitleExclusionRegistry` / `ProductTitleExclusionDetector`**：从 `QueryConfig.product_title_exclusion_rules` 加载规则；对 `original_query`、`query_normalized`、`rewritten_query` 及所有 `translations` 去重后分词匹配触发词，输出 `ProductTitleExclusionProfile`。

二者均依赖 `tokenization` 与可选的 HanLP，启用与否由配置项控制。

---

## 可观测性与调试

当 `QueryParser.parse(..., context=...)` 传入请求上下文时，典型中间结果包括：

- `query_normalized`、`rewritten_query`、`detected_language`、`query_tokens`
- `translation_{lang}`、`translations`
- `keywords_queries`
- `query_vector_shape`、`image_query_vector_shape`
- `style_intent_profile`、`product_title_exclusion_profile`

搜索主流程在 `search/searcher.py` 中会把解析结果写入 `QueryAnalysisResult`（含 **`keywords_queries`**），并在 `debug=true` 时把 `query_analysis` 挂到响应的 `debug_info`；前端调试页在 `frontend/static/js/app.js` 中展示 **Translations** 与 **Keywords Queries** 等块，便于与翻译结果并列查看。

---

## 依赖与环境提示

- **HanLP**：分词与中文词性标注；模型名以本文与源码为准（`FINE_ELECTRA_SMALL_ZH` + `CTB9_POS_ELECTRA_SMALL`）。
- **spaCy**：英文关键词路径需要可导入的 **`en_core_web_sm`**（若缺失则英文关键词退化为轻量规则）。
- **Lingua**：通用语言检测（在英文 ASCII 快路径不适用时参与拉丁语系判别）。

运行与测试时请使用项目约定的虚拟环境（见仓库根目录 `CLAUDE.md` / `activate.sh`），避免系统 Python 缺少上述依赖。

---

## 扩展与测试

- 单元测试中与解析、分词、意图相关的用例分布在 `tests/test_query_parser_mixed_language.py`、`tests/test_tokenization.py`、`tests/test_style_intent.py`、`tests/test_product_title_exclusion.py` 等文件中；修改分词或关键词策略时应同步更新或新增测试，以保持与本文描述一致。

若新增语种或改写语言检测策略，应同步审视：`QueryParser._detect_query_language`、`QueryTextAnalysisCache._should_use_model_tokenizer`、`KeywordExtractor.extract_keywords` 中非 `zh`/`en` 分支，以及 ES 侧是否应为新语种生成 `keywords_query`。
