如果你的 RAG 管线已经能跑通,但答案经常“看似相关、实际错误”,先别急着调提示词。更常见的原因是:检索环节根本没有召回包含答案的文档。当用户查询是口语、带指代或复合意图时,原始查询和索引中的长文本文档之间存在明显的表达差距。查询重写想要解决的就是这个差距。
本文不绑定任何具体框架的 API。查询重写相关的 SDK 接口在不同版本之间变化较快,写一段某个框架的示例代码,很可能在读者本地版本中已经不适用;更重要的是,重写策略的选择与评估方法和框架无关。下面讨论的是机制、适用边界和落地评估。
重写模块放在哪里
带查询重写的 RAG 管线可以简化为:
用户查询 → 重写模块 → 检索器 → 重排序(可选) → 生成模型
重写模块的输入是原始查询,输出有三种常见形态:
- 一个新的字符串:把原始查询改写为更适合检索的表达。
- 一组字符串:生成多个改写结果,并行检索后合并召回文档。
- 一段假设性文档:不直接改写查询,而是生成一段可能的回答文档,用它的嵌入去检索。
选择哪种形态,取决于检索器的类型,也取决于当前查询的失败模式。
三种策略的原理与边界
同义改写与查询扩展
这是成本最低的方案。让 LLM 把口语化查询改写成更书面、术语更完整的表达,或者生成多个等价查询并行检索、去重合并。适用场景很明确:关键词检索(如 BM25)、或者查询与文档术语差异较大的场景。比如把“怎么退钱”改写成“退款流程”,让检索命中文档中的正式术语。
局限在于:LLM 改写时可能引入文档中不存在的概念,导致召回被带偏。多查询扩展可以缓解这个问题,因为多个改写结果同时跑偏的概率低于单个结果。
假设性文档嵌入(HyDE)
给定一个问题,先让 LLM 生成一段假设性的回答文档,再用这段文档的嵌入去做向量检索。这个策略的基本假设是:假设回答与真实文档在语义空间中更接近,而原始问题与真实文档的语义距离往往更远。向量检索的查询和文档嵌入来自同一个模型,但短查询与长文档的表示分布差异明显,HyDE 通过生成一段长文本来抹平这个差异。
它的适用边界很窄:只适合语义检索场景。如果底层是关键词检索,假设文档中包含的词汇与真实文档的关键词大概率不重合,反而会破坏匹配逻辑。另外,HyDE 的有效性依赖 LLM 生成假设文档的质量,生成质量不稳定时,检索结果波动会很大。
子问题分解
当用户提出的是复合问题时,例如“A 和 B 在部署方式上有什么区别”,直接拿整个查询去检索,得到的是同时提到 A 和 B 的文档,具体对比细节可能分散在不同文档中。把查询拆成“A 的部署方式”“B 的部署方式”分别检索,可以更精准地定位文档。
工程上有两个注意点。第一,子问题之间可能存在依赖,后一个子问题需要前一个子问题的结果才能构造,这属于更复杂的多跳逻辑,需要单独设计。第二,多个子问题的召回结果需要合并,合并策略直接影响生成上下文质量,建议用重排序环节统一打分,而不是简单拼接。
下面用一段与框架无关的伪代码示意重写模块的调度逻辑。它不是任何特定 SDK 的 API,而是说明工程上可以做的分支判断:
def rewrite_and_retrieve(query, retriever, llm):
# 路由:判断是否需要重写
if not need_rewrite(query):
return retriever.search(query)
# 复合问题:子问题分解
if is_compound(query):
sub_queries = llm.decompose(query)
return merge([retriever.search(q) for q in sub_queries])
# 语义检索:可尝试 HyDE
if retriever.type == 'dense' and hyde_enabled:
hypothetical_doc = llm.generate_hypothetical_doc(query)
return retriever.search(hypothetical_doc)
# 默认:同义改写 + 多查询扩展
rewritten = llm.rewrite_expand(query, n=3)
return merge([retriever.search(q) for q in rewritten])
这段伪代码里最值得关注的是第一个判断。不是所有查询都值得走重写链路。事实型查询直接检索通常就够了,复杂查询才需要重写。一个可行的做法是引入前置路由:让 LLM 给查询打一个复杂度标签,或者用一组规则判断查询是否包含指代词、复合动词、多实体。只有低置信度的查询才进入重写模块。这能显著降低延迟和成本,也减少重写引入噪声的机会。
评估:先看检索,再看生成
很多团队在优化 RAG 时只盯最终回答质量,回答变好或变差,无法定位是重写的作用还是生成端的变化。更合理的做法是把评估拆成两层。
检索评估:构造一组带标注的评测查询,每个查询标注“应该被召回”的文档 ID 列表。规模在 50 到 100 条左右即可,覆盖口语化表达、含指代、复合意图、术语不一致这几类难例。对比重写前后在 Recall@K 上的变化。这一步能直接判断重写是否真的提升了召回。
生成评估:在检索结果确定后,再对比生成答案与标准答案。如果检索指标提升了但生成指标没有变化,问题可能出在上下文组织或生成提示词上,而不是检索。两个层次不能混在一起看。
落地代价与最后一条判断
查询重写不是免费的。每次重写都意味着一次额外的 LLM 调用。稳妥的落地路径是:把重写模块做成可开关的组件,在评测集上做 A/B 对比,而不是默认全量开启。
另一个工程建议是兜底设计。重写和原始查询并行检索,召回的文档合并后交给重排序模型统一打分。多路召回是用召回宽度换精度,当重写产生偏差时,原始查询仍然能兜住一部分正确召回。
最后一条工程判断是:查询重写解决的是“表达鸿沟”问题,不解决索引缺失、embedding 模型能力不足、分块不合理这三类问题。在引入查询重写之前,先抽 10 个回答质量差的查询,逐个确认检索失败的具体原因。如果检索召回为空,先看索引是否覆盖;如果召回文档相关但排序不对,优先调重排序;如果召回结果里根本没有包含答案的文档,再考虑查询重写。重写是整个 RAG 链路中的一个可选项,不是默认选项。