Blame view

query/README.md 18.5 KB
1c5366f5   tangwang   query分析性能优化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
  # 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`