查询改写与多路变体
Written: 2026.06第 07 章跟敲代码:上一讲:检索策略与动态计划codealong/chapters/ch07_query_rewrite_variants。 这部分代码是本章跟敲版,用来先跑通核心闭环;完整项目源码仍以本讲后文标注的qa_core/、scripts/等路径为准。
下一讲:Milvus 混合检索深度解析
1. 本讲目标
- 理解多轮对话中追问问题的处理机制
- 掌握查询改写(Query Rewrite)的触发条件和工作原理
- 理解查询变体(Query Variants)如何提升召回覆盖率
2. 本讲项目交付闭环
第 6 讲已经生成了检索计划,但真实对话里用户经常不会把问题说完整。本章要解决两个召回前的问题:一是把“那审批呢”这类追问改写成能独立检索的问题,二是在需要时生成少量同义检索表达,提高 Milvus 召回覆盖率。| 项目交付项 | 说明 |
|---|---|
| 核心模块 | qa_core/pipeline/rewrite.py、qa_core/pipeline/query_variants.py |
| 核心函数 | rewrite_query_if_needed()、generate_query_variants()、_heuristic_variants()、_looks_like_short_structured_question() |
| 输入条件 | 第 5 讲 IntentResult.requires_rewrite、第 6 讲 RetrievalPlan.use_query_variants |
| 输出结果 | 独立检索问题、查询变体列表 |
| 下游衔接 | 第 8 讲 Milvus Hybrid Search 的 search_many() |
| 验证入口 | tests/test_retrieval_and_prompt.py、tests/test_memory_history.py |
3. 前置知识 — 多轮对话中的指代消解
3.1 为什么追问需要特殊处理
在真实对话中,用户的后续问题往往依赖于前文的上下文:- 检索到的可能是”请假审批”、“报销审批”、“采购审批”……
- 因为向量只看到”审批”和”多久”,不知道上下文是”入职流程”
3.2 指代消解的概念
指代消解(Anaphora Resolution) 是 NLP 的一个经典问题:确定代词或省略的主体指什么。4. 查询改写(Query Rewrite)
4.1 触发条件
改写不是对所有问题都执行,有两个条件:should_rewrite == True:由意图识别结果中的requires_rewrite控制history_messages不为空:没有历史上下文就无法改写
requires_rewrite 为 True?
4.2 改写实现
4.3 改写 Prompt 设计
4.4 为什么要限制历史长度
- 效率:发送给 LLM 的 token 数减少,改写延迟降低
- 聚焦:只取最近的对话,让改写聚焦当前追问主题
- 防止跑题:如果用户 14 轮之前问的是”入职”,现在问的是”报销”,取全部历史反而会让改写混淆
4.5 完整问题不改写的原则
5. 查询变体(Query Variants)
5.1 为什么需要查询变体
用户的问题表述方式可能和知识库中的表述不一致。例如:5.2 两种生成方式
方式 1:规则生成(本地,不用 LLM) 对于能从 scene TOML 中推断出 source 的短问题,使用关键词替换生成变体:_heuristic_variants() 是本地关键词替换规则,覆盖高频同义表达:
generate_query_variants() 在调用本地启发式之前,先通过 _looks_like_short_structured_question() 判断问题是否已经足够结构化,避免对清晰短问题做无收益的 LLM 扩展:
[cleaned],不再走 LLM 扩展路径。
方式 2:LLM 生成(Pydantic 结构化输出,适用于本地规则未命中的情况)
5.3 什么时候不生成变体
- 问候/直接答案/人工客服:不需要检索,自然不需要变体
- FAQ 查询:FAQ 的标准问题通常较短且固定,变体可能引入噪音
- 知识咨询:域广,多角度检索有收益
- 追问:改写后的问题可能丢失了一些原问题的角度,变体可以补充
6. 历史消息的压缩策略
6.1 为什么不把全部历史发给 LLM
假设用户已经和系统对话了 50 轮:- 全部历史可能有好几千个 token
- 每次请求(意图识别、改写、生成)都带完整历史 → 成本高、延迟高
- 对话时间跨度长,早期的主题和当前问题可能已经无关
6.2 摘要 + 最近消息 策略
history_summary_after_messages(默认 14 条),由 refresh_summary_if_needed() 在每轮回答结束后异步触发摘要刷新。实际代码拆分为两个方法:
get_summary(session_id)— 从 MySQL 摘要表读取已有摘要refresh_summary_if_needed(session_id)— 判断消息数是否达标,达标则调用 LLM 生成摘要并通过save_summary()写入 MySQL
6.3 上下文窗口管理全景
7. 改写+变体的完整流程
7.1 在 RAG 链路中的位置
流程图中每个节点的代码定位:| 流程图节点 | 对应函数路径 | 本讲对应章节 |
|---|---|---|
| 意图识别 | qa_core/intent/classifier.py::classify_intent() | 第 5 讲 |
| requires_rewrite? | qa_core/intent/classifier.py::IntentResult.requires_rewrite | 第 5 讲 |
| 查询改写 (LLM) | qa_core/pipeline/rewrite.py::rewrite_query_if_needed() | 第二部分 |
| 检索计划 | qa_core/retrieval/strategy.py::build_retrieval_plan() | 第 6 讲 |
| use_query_variants? | qa_core/retrieval/strategy.py::RetrievalPlan.use_query_variants | 3.3 节 |
| 生成查询变体 | qa_core/pipeline/query_variants.py::generate_query_variants() | 第三部分 |
| 多查询并行检索 | qa_core/retrieval/store.py::MilvusHybridStore.search_many() | 5.3 节 |
| 合并去重 → Rerank | qa_core/retrieval/ranking.py::merge_hits_by_document()、qa_core/retrieval/ranking.py::rerank_hits() | 第 7 讲 |
| 构建上下文 → LLM 生成 | qa_core/pipeline/context.py::select_context_docs()、qa_core/pipeline/steps.py::stream_llm_answer() | 第 9 讲 |
阅读建议:对照上表,先在流程图中理解数据流向(“改写后的问题去哪里了""变体是在哪个节点生成的”),再按”对应章节”列跳转到具体代码。不要试图一次性读懂全部代码——按流程图节点逐个击破。
7.2 历史压缩策略
这张图解决了一个实际问题:LLM 的上下文窗口不是无限的,但对话可以无限进行下去。 左半部分(会话历史管理)展示了两阶段策略:- 第 1-14 轮:所有消息完整保留。这时候对话还短,全部历史加起来不过几千 token,LLM 完全可以消化。
- 第 15 轮开始:前 N 轮压缩为一段 200-1200 字符的摘要,只保留最近 8 轮完整消息。压缩的触发条件是
refresh_summary_if_needed(),它在每轮问答结束后检查消息数——超过history_summary_after_messages(默认 14 条)就用非流式 LLM 生成摘要,存到 MySQL 的摘要表。
get_context_messages()— 每次 RAG 请求开始时调用(在prepare_retrieval()内部),为意图识别和查询改写提供上下文refresh_summary_if_needed()— 每轮问答结束后异步调用(通过_schedule_summary_refresh()在后台线程执行),不阻塞用户看到答案
7.3 检索时的用法
merge_hits_by_document(merged: dict, hits: list) 不是一个返回新列表的纯函数——它原地修改 merged 字典,以 chunk_id(或 faq_id)为 key,遇到同一文档的重复命中时只保留分数更高的那次。
8. 本讲实践闭环
| 项目 | 内容 |
|---|---|
| 本讲类型 | 项目实现 |
| 实践产物 | pipeline/rewrite.py、pipeline/query_variants.py、历史摘要能力 |
| 是否进入最终项目 | 是 |
| 验收方式 | 用“审批呢”这类追问验证可改写为独立问题;清晰问题保持原样 |
| 后续落点 | 第 8 讲对多个 query variants 执行合并检索 |
8.1 本讲从 0 到 1 实现闭环
本讲位于“意图识别之后、检索之前”。它解决两个问题:追问太短时先改写成独立问题;普通问题召回不稳时生成少量等价变体。 实现完成后,相关代码结构应该是下面这张图:8.1.1 :只在必要时改写追问
目标:把“审批呢”这类省略问题改写成独立检索问题,但完整问题保持原样。 来源:真实代码逻辑压缩版,对应qa_core/pipeline/rewrite.py::rewrite_query_if_needed()。
should_rewrite=True 且有历史时才做;如果 LLM 返回空字符串,项目选择硬失败暴露问题,而不是静默回退原问题导致检索偏题。
8.1.2 :构造改写 Prompt
目标:让 LLM 只做“指代消解”,不要扩写成另一个问题。 来源:简化骨架,对应qa_core/pipeline/rewrite.py 中的改写 Prompt 构造。
8.1.3 :生成查询变体
目标:对清晰问题生成 2-3 个等价表达,提高召回覆盖率。 来源:真实代码逻辑压缩版,对应qa_core/pipeline/query_variants.py::generate_query_variants()。
retrieval_variant_max + 1 控制。
8.1.4 :管理历史摘要
目标:长会话中保留“摘要 + 最近 N 条”,避免把全部历史塞进 Prompt。 来源:真实代码调用点,见qa_core/memory/history.py。
8.1.5 :接入多查询检索
验收命令: 来源:命令行验收,对应tests/test_memory_history.py 和 tests/test_retrieval_and_prompt.py。
| 验证项 | 输入条件 | 期望结果 |
|---|---|---|
| 追问改写 | 历史中提过“新人入职流程”,当前问 审批呢 | 改写成包含“新人入职”和“审批”的独立问题 |
| 完整问题保护 | 新人入职需要完成哪些流程? | 保持原样,不强行改写 |
| 无历史保护 | 当前问 审批呢 但没有历史 | 不调用改写,避免乱补上下文 |
| 空改写保护 | LLM 返回空字符串 | 抛出异常暴露问题 |
| 变体禁用 | enabled=False | 只返回原问题 |
| 短结构化问题 | 流程怎么走 | 跳过 LLM 扩展 |
| 启发式变体 | Webhook 怎么配置 | 生成“回调”等本地同义变体 |
| 变体数量控制 | 清晰知识问题 | 生成结果去重,受配置上限控制 |
| 多查询检索衔接 | 多个 variants | search_many() 能合并结果并保留最高分 |
审批呢能结合历史改写成完整问题。- 完整问题不会被强制改写。
- query variants 去重且数量受控。
search_many()能接收多个变体并按文档合并结果。
9. 重点掌握
| 优先级 | 内容 | 原因 |
|---|---|---|
| ★★★ 必会 | 查询改写(Query Rewrite)的触发条件:requires_rewrite=True + 有历史消息 | 多轮对话中指代消解的实现方式 |
| ★★★ 必会 | 查询变体(Query Variants)的作用:将原问题扩展为多个等价表达提高召回覆盖率 | 召回增强的核心手段 |
| ★★★ 必会 | 历史压缩策略:“摘要(200-1200 字符)+ 最近 8 条完整消息”,第 15 轮开始触发压缩 | 管理 LLM 上下文窗口的关键设计 |
| ★★ 理解 | rewrite_query_if_needed() 的实现:非流式 LLM,聚焦最近 8 条历史,改写为独立检索问题 | 理解改写模块的具体代码 |
| ★★ 理解 | 变体生成的两种方式:本地启发式(关键词替换,快、免费)vs LLM 结构化输出(灵活) | 理解性能与灵活性的平衡 |
| ★★ 理解 | _looks_like_short_structured_question() 的判断逻辑:短问题且命中高频句式标记时跳过 LLM 扩展 | 避免对清晰问题做无收益的 LLM 调用 |
| ★★ 理解 | 完整问题不改写的原则:清晰的问题保持原样,防止”改偏” | 重要的设计约束 |
| ★ 了解 | 历史摘要的异步刷新机制(refresh_summary_if_needed) | 了解实现细节 |
| ★ 了解 | search_many() 的多查询合并流程 | 本讲 5.3 节展开 |
10. 本讲小结
- 追问改写只在
requires_rewrite=True且有历史时执行,避免对所有问题增加 LLM 调用 - 改写聚焦最近 8 条历史,使用非流式 LLM 生成独立的检索问题
- 完整问题不改写:清晰的问题保持原样,防止改写”改偏”
- 查询变体将问题扩展为多个等价表达,提高召回覆盖率
- 变体生成有本地启发式和 LLM 结构化输出两种方式,最多生成 3 个变体控制检索成本
- 历史压缩采用”摘要 + 最近 8 条”策略,在召回质量和成本之间平衡

