# 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`。