Skip to main content

查询改写与多路变体

Written: 2026.06
第 07 章跟敲代码:codealong/chapters/ch07_query_rewrite_variants。 这部分代码是本章跟敲版,用来先跑通核心闭环;完整项目源码仍以本讲后文标注的 qa_core/scripts/ 等路径为准。
上一讲检索策略与动态计划
下一讲Milvus 混合检索深度解析

1. 本讲目标

  • 理解多轮对话中追问问题的处理机制
  • 掌握查询改写(Query Rewrite)的触发条件和工作原理
  • 理解查询变体(Query Variants)如何提升召回覆盖率

2. 本讲项目交付闭环

第 6 讲已经生成了检索计划,但真实对话里用户经常不会把问题说完整。本章要解决两个召回前的问题:一是把“那审批呢”这类追问改写成能独立检索的问题,二是在需要时生成少量同义检索表达,提高 Milvus 召回覆盖率。 本讲实现完成后的代码结构: 闭环验证方式:
验证时重点看:追问是否能改写成带上下文的独立问题,完整问题是否保持原样,短结构化问题是否跳过 LLM 扩展,查询变体是否去重并受数量上限控制。

3. 前置知识 — 多轮对话中的指代消解

3.1 为什么追问需要特殊处理

在真实对话中,用户的后续问题往往依赖于前文的上下文:
如果直接把”那审批需要多久”发给 Milvus 做向量检索:
  • 检索到的可能是”请假审批”、“报销审批”、“采购审批”……
  • 因为向量只看到”审批”和”多久”,不知道上下文是”入职流程”
这就是追问改写的必要性:把依赖上下文的问题补全为独立的检索问题。

3.2 指代消解的概念

指代消解(Anaphora Resolution) 是 NLP 的一个经典问题:确定代词或省略的主体指什么。

4. 查询改写(Query Rewrite)

4.1 触发条件

改写不是对所有问题都执行,有两个条件:
  1. should_rewrite == True:由意图识别结果中的 requires_rewrite 控制
  2. history_messages 不为空:没有历史上下文就无法改写
哪些情况 requires_rewrite 为 True?

4.2 改写实现

4.3 改写 Prompt 设计

4.4 为什么要限制历史长度

  • 效率:发送给 LLM 的 token 数减少,改写延迟降低
  • 聚焦:只取最近的对话,让改写聚焦当前追问主题
  • 防止跑题:如果用户 14 轮之前问的是”入职”,现在问的是”报销”,取全部历史反而会让改写混淆

4.5 完整问题不改写的原则

对于完整、自包含的问题(如”入职流程有哪些步骤”、“API 密钥怎么生成”),保持原样是最好的做法。让 LLM 改写清晰的问题可能会导致”改偏”——原本明确的问题被改成模糊的。

5. 查询变体(Query Variants)

5.1 为什么需要查询变体

用户的问题表述方式可能和知识库中的表述不一致。例如:
虽然语义相近(Embedding 能找到),但关键词完全不同(BM25 找不到了)。 查询变体的思路:把用户的原始问题扩展成多个等价表达,每个都去检索,提高命中率。

5.2 两种生成方式

方式 1:规则生成(本地,不用 LLM) 对于能从 scene TOML 中推断出 source 的短问题,使用关键词替换生成变体:
其中 _heuristic_variants() 是本地关键词替换规则,覆盖高频同义表达:
上面 generate_query_variants() 在调用本地启发式之前,先通过 _looks_like_short_structured_question() 判断问题是否已经足够结构化,避免对清晰短问题做无收益的 LLM 扩展:
判断逻辑:问题长度不超过 24 个字符,且包含流程类、FAQ 类的高频句式标记。命中时直接返回单查询 [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 链路中的位置

流程图中每个节点的代码定位:
阅读建议:对照上表,先在流程图中理解数据流向(“改写后的问题去哪里了""变体是在哪个节点生成的”),再按”对应章节”列跳转到具体代码。不要试图一次性读懂全部代码——按流程图节点逐个击破。

7.2 历史压缩策略

这张图解决了一个实际问题:LLM 的上下文窗口不是无限的,但对话可以无限进行下去。 左半部分(会话历史管理)展示了两阶段策略:
  • 第 1-14 轮:所有消息完整保留。这时候对话还短,全部历史加起来不过几千 token,LLM 完全可以消化。
  • 第 15 轮开始:前 N 轮压缩为一段 200-1200 字符的摘要,只保留最近 8 轮完整消息。压缩的触发条件是 refresh_summary_if_needed(),它在每轮问答结束后检查消息数——超过 history_summary_after_messages(默认 14 条)就用非流式 LLM 生成摘要,存到 MySQL 的摘要表。
右半部分(发送给 LLM 的上下文)展示的是每次请求时拼给 LLM 的最终内容
为什么是”摘要 + 最近 8 条”而不是”全部历史”? 如果 30 轮对话后还把全部历史发给 LLM,prompt 会膨胀到上万 token,不仅成本飙升,LLM 的注意力也会被稀释(中间偏早的对话细节会干扰当前问题的判断)。摘要把早期对话浓缩成一两句话,最近 8 条保留完整上下文——在”省 token”和”不丢信息”之间取得了平衡。 为什么最近保留 8 条而不是 3 条或 14 条? 这里的 8 条是项目默认值,不是行业标准。它的依据是:多轮追问经常跨越 4-5 轮(“入职需要什么材料”→“身份证复印件可以吗”→“电子版行不行”→“多久能办好”→“提前准备可以吗”),只保留 3 条容易丢指代;保留太多又会增加 prompt 成本并引入旧话题干扰。生产环境可以通过追问改写成功率、prompt 长度和用户会话统计继续调整。 代码实现——两个核心方法对应上图的两个阶段:
两个方法的调用时机
  • 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. 本讲实践闭环

通过标准:追问能补全指代,变体数量受控且去重,完整问题不会被过度改写。

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 构造。
设计解释:改写模型必须非流式,因为下游检索需要完整 query。

8.1.3 :生成查询变体

目标:对清晰问题生成 2-3 个等价表达,提高召回覆盖率。 来源:真实代码逻辑压缩版,对应 qa_core/pipeline/query_variants.py::generate_query_variants()
设计解释:变体不是越多越好。真实代码先用低成本规则覆盖高频表达,只有规则不够时才调用 LLM;所有变体都会去重并受 retrieval_variant_max + 1 控制。

8.1.4 :管理历史摘要

目标:长会话中保留“摘要 + 最近 N 条”,避免把全部历史塞进 Prompt。 来源:真实代码调用点,见 qa_core/memory/history.py
设计解释:改写依赖历史,但历史不能无限增长。摘要用于保留早期上下文,最近消息用于保留细节。

8.1.5 :接入多查询检索

验收命令: 来源:命令行验收,对应 tests/test_memory_history.pytests/test_retrieval_and_prompt.py
闭环验证重点: 通过标准:
  • 审批呢 能结合历史改写成完整问题。
  • 完整问题不会被强制改写。
  • query variants 去重且数量受控。
  • search_many() 能接收多个变体并按文档合并结果。

9. 重点掌握

10. 本讲小结

  • 追问改写只在 requires_rewrite=True 且有历史时执行,避免对所有问题增加 LLM 调用
  • 改写聚焦最近 8 条历史,使用非流式 LLM 生成独立的检索问题
  • 完整问题不改写:清晰的问题保持原样,防止改写”改偏”
  • 查询变体将问题扩展为多个等价表达,提高召回覆盖率
  • 变体生成有本地启发式和 LLM 结构化输出两种方式,最多生成 3 个变体控制检索成本
  • 历史压缩采用”摘要 + 最近 8 条”策略,在召回质量和成本之间平衡
下一讲Milvus 混合检索深度解析 — Dense + Sparse 检索实现、BM25 原理、过滤表达式构建