issue-2026-03-30-query分析性能优化-done-0331.md 19 KB

总体的目的是: 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 EnglishKeywordExtractoren_core_web_sm + 依存/名词块规则,产出短字符串供检索侧关键词子句使用。
language_detector.py 脚本优先 + Lingua 的通用语言检测(与 QueryParser 的英文 ASCII 快路径配合使用)。
query_rewriter.py 基于配置词典的查询改写与规范化。
style_intent.py 从配置加载样式意图词表,对查询变体做候选匹配,产出 StyleIntentProfile
product_title_exclusion.py 从配置加载标题排除规则,对多路查询文本做触发词匹配,产出 ProductTitleExclusionProfile

公开符号见 query/__init__.pyQueryParserParsedQueryKEYWORDS_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:键为目标语言代码(如 zhen),值为翻译服务返回的字符串;仅包含本次请求实际需要的目标语种(见下文翻译目标推导)。
  • 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.yamlquery_config 的对应开关与词表/规则决定。
  • _text_analysis_cache:单次解析内的分词与语言提示缓存,不参与序列化,仅供同一次 parse 内各检测器复用,避免对同一文本重复调用 HanLP。

与重排相关的文本选择由独立函数 rerank_query_text() 完成:检测为 zhen 时始终用原始查询;其它语言优先英译再中译,见 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 快路径

  • 快路径:当「活跃语种集合」仅为 enzh 的子集时(活跃集合取 target_languages 归一化结果,若为空则回退到 query_config.supported_languages),且当前查询为纯 ASCII、含字母、不含汉字,则直接判定为 en,不再调用 LanguageDetector(避免 Lingua 等开销)。逻辑见 _detect_query_language()_is_ascii_latin_query()
    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,以便与关键词提取共用「带偏移的分词结果」。

    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, ...)
  • 线程池:ThreadPoolExecutormax_workersmin(任务数, 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_cacheParsedQuery 草稿,依次调用 StyleIntentDetector.detectProductTitleExclusionDetector.detect,再把完整 ParsedQuery 返回。

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

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

分词与 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_tokensfine_tokensTokenizedText

  • fine_tokens:来自 extract_token_strings(get_tokenizer_result(...)),在中文路径上即 HanLP 分词后的词串(已按规范化键去重保序)。
  • coarse_tokens:由 _build_coarse_tokens 决定。若语言提示为 zh,或文本含汉字且已有 tokenizer_tokens,则 粗粒度 token 与 fine 一致(即采用模型分词粒度,而不用「整段 CJK 连成一项」的纯正则策略)。否则使用 simple_tokenize_query(适合拉丁词、数字、带连字符/撇号的英文词形)。
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 上合并去重,供 StyleIntentDetectorProductTitleExclusionDetector 等做子串/短语级匹配。

tokenize_text() 是对单次无缓存场景的薄封装:内部新建 QueryTextAnalysisCacheget_tokenized_text


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

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


与检索层的关系(消费方摘要)

ParsedQuery.keywords_queriessearch/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_queryquery_normalizedrewritten_query 及所有 translations 去重后分词匹配触发词,输出 ProductTitleExclusionProfile

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


可观测性与调试

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

  • query_normalizedrewritten_querydetected_languagequery_tokens
  • translation_{lang}translations
  • keywords_queries
  • query_vector_shapeimage_query_vector_shape
  • style_intent_profileproduct_title_exclusion_profile

搜索主流程在 search/searcher.py 中会把解析结果写入 QueryAnalysisResult(含 keywords_queries),并在 debug=true 时把 query_analysis 挂到响应的 debug_info;前端调试页在 frontend/static/js/app.js 中展示 TranslationsKeywords 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.pytests/test_tokenization.pytests/test_style_intent.pytests/test_product_title_exclusion.py 等文件中;修改分词或关键词策略时应同步更新或新增测试,以保持与本文描述一致。

若新增语种或改写语言检测策略,应同步审视:QueryParser._detect_query_languageQueryTextAnalysisCache._should_use_model_tokenizerKeywordExtractor.extract_keywords 中非 zh/en 分支,以及 ES 侧是否应为新语种生成 keywords_query