README.md
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()。
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, ...)。 - 线程池:
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() 之前,当前实现会:
- 通过
text_analysis_cache.get_tokenizer_result(query_text)得到分词结果,再extract_token_strings得到query_tokens; - 调用
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(适合拉丁词、数字、带连字符/撇号的英文词形)。
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_tokenstranslation_{lang}、translationskeywords_queriesquery_vector_shape、image_query_vector_shapestyle_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。