做RAG做到一定阶段,你迟早会遇到一个特别憋屈的问题:检索器配置没毛病、知识库质量也说得过去,可用户问出来的话,检索结果就是南辕北辙。我第一次认真把查询重写(Query Rewriting)当成RAG管线里的独立环节,是在某知识库问答项目的线上反馈里:用户问“那个权限申请的流程现在还能线上走吗”,召回的全是旧版线下流程文档,新版内容一篇都没进top 5。当时我第一反应是换embedding模型,后来把query和文档拉出来逐条对比才意识到,问题根本不在模型,而在用户这句口语化、带着指代和时态的话,压根不是检索系统该直接吃的输入。
从那时起,我开始系统性地研究查询重写:把用户query转成更适合检索的形式,再进入向量检索和重排环节。这一篇是Advanced RAG系列里关于查询重写的探索记录,覆盖策略选型、提示词设计、工程改造和上线评测,适合已经跑通RAG基础链路、想继续拉高检索上限和答案质量的开发者。下面的内容都来自我自己的项目踩坑经历,方案未必放之四海皆准,但思路和排坑路径是通用的。
1. 先定位问题:查询重写到底在弥合哪条“表达鸿沟”
要理解查询重写,首先要接受一件事:检索器不是理解器。很多人觉得“我已经用了很强的embedding模型,语义检索应该很聪明”,但实际跑起来就会发现,embedding模型对“表达方式”的敏感程度远超你的预期。
1.1 检索器不是理解器:原始query直接进检索的隐患
embedding检索的本质,是把query和文档都映射进同一个向量空间,然后用余弦相似度或内积排序。这套机制对“表达方式”极其敏感。同一个意思,换一种说法,向量可能就偏到另一个区域。最典型的例子是电商场景里用户说“不要白色的”,文档里写的是“黑色/蓝色”,否定表达在embedding里几乎不起作用。技术文档问答里更常见,用户习惯说“这功能咋关闭”,文档正文写的却是“如何停用该模块”,两者之间不是简单的同义词关系,而是句式和语域的差异。
短query是另一个麻烦。用户只输入四五个词,向量被压到一片模糊区域,top k里混进大量主题沾边但不精确的内容。再加上用户的query多是聊天式口语,而知识库文档是结构化书面语,两者天然存在分布差异。这种鸿沟不是靠换一个更强的embedding模型就能完全解决的,因为问题发生在输入端——query这个“句子”本身信息密度不够。
所以查询重写的角色,可以理解成在用户query和检索器之间加一个适配层:把一句口语化、短小、带指代的话,翻译成更接近文档表达习惯、信息更完整的检索式。它不是为了让query变得更“像人话”,而是为了让query在向量空间里离目标文档更近。这个视角一旦建立,后面所有策略的选择逻辑都会变得很清晰。
1.2 三类最典型的“问非所想”场景
我做过的项目里,检索效果崩掉的query基本能归成三类。
第一类是指代与口语化。比如用户在先前的对话里问过“上传发票的步骤”,下一句紧跟“那上传之后多久能审核”,系统如果只看后一句,根本不知道“上传”指什么。这种query重写必须结合对话历史,把指代还原成“上传发票之后,审核需要多长时间”。这类问题在客服机器人、企业内部门户里最普遍,也是大家最容易忽略的一类——因为单看每一句query你总觉得“人话没问题啊”,但检索器眼里全是漏洞。
第二类是复合问题。一个提问里揉了两三个意图,比如“怎么查日志以及怎么通过日志定位超时”,前半句在问操作,后半句在问诊断方法。直接检索时两个意图互相干扰,召回结果哪边都不精准。这类query要做子问题分解,拆开检索再合流。比较麻烦的是,有些复合问题拆开之后单独看都成立,但用户真正想要的是两件事之间的关联,后续生成环节需要额外处理。
第三类是具体问题背后其实是抽象需求。用户问“为什么我们召回率比之前低了3个点”,直接拿“召回率 低 3个点”去检索,什么都查不到。他真正需要的是“RAG评测指标有哪些、召回率下降通常由哪些环节导致”的背景知识。这类query适合做step-back式抽象重写。还有一类隐藏场景是query本身包含术语变体,比如用户说“卡顿”,文档里写“延迟”和“性能劣化”,这种同义改写问题,用多查询扩展通常能覆盖。
1.3 重写的前提约束:别把原意也改没了
查询重写不是让LLM自由发挥,不加约束就上生产,等于往系统里埋雷。我自己定了四条铁律,每一句都会写进提示词里。
第一,保留原始查询中的专有名词、数字、缩写,绝不允许替换。“ES”不能改成“搜索引擎”,“V2”不能改成“第二版”。第二,不添加外部知识,重写只能基于query本身和对知识库结构的理解,不能擅自塞入query里没有的事实。第三,保持问题类型,疑问句还是疑问句,陈述式需求还是陈述式。第四,也是我后来加上的一条:如果原始查询已经足够清晰,直接原样输出。
这条“如果足够清晰就原样输出”特别重要。不加这条约束,LLM会把很多本来没问题的query也重写一遍,改完反而引入噪声。我在提示词里加了这一句之后,负面改写率肉眼可见地降了下来。所谓负面改写,就是重写结果比原文更差、检索效果反而下降的情况。别小看这个问题,它在真实线上环境里出现的频率远比你想象得高。
2. 五种主流查询重写手段的适用边界与提示词设计
重写不是只有一种做法。我按自己的项目经验,把实践下来真正有效的手段分成五类:Multi-Query扩展、子问题分解、Step-Back抽象、HyDE假设文档,以及不依赖LLM的轻量规则重写。每一类都有明确的适用场景,用错了地方还不如不重写。
2.1 Multi-Query扩展:一路变多路,召回率最先见效
Multi-Query是最好上手、收益最稳的一种。思路很简单:让LLM基于原始query生成多个不同角度的检索query,每路query跑一次召回,最后合并。
举个例子。用户问“平台支持哪些登录方式”,原始query在向量空间里只对应一个点。扩展成“邮箱登录的说明”“手机验证码登录方式”“第三方统一认证登录流程”之后,三路query分别落在文档库不同的语义区域,召回互补性很强。我自己的测试里,很多原本只能命中一两篇相关文档的问题,用3路Multi-Query之后可以稳定覆盖到三四篇。
提示词我通常这样写:
请基于用户的原始查询,生成3-5个从不同角度表述的搜索查询,用于在知识库中检索相关资料。 要求: 1. 保留原始查询中的专有名词和实体; 2. 每个查询是独立、完整的疑问句; 3. 覆盖同义改写、具体化、场景化等不同角度; 4. 如果原始查询已经清晰,可以只输出原始查询的一个加强版本; 5. 直接输出JSON数组,不要解释。 原始查询:{query}N取多少?我的经验是3-5路比较合适。中小规模知识库里5路以上收益明显递减,而且多路召回合并时的去重成本会上升。Multi-Query最适合query短、实体抽象、同一个意思有多种说法的场景。如果你的query本身已经是精确的术语或编号,比如“KT-2024-0912”这种,就别多加,精确匹配反而更快更准。
2.2 子问题分解:多跳问题拆成单跳再合流
子问题分解针对的是复合型、多跳型问题。典型例子是:“A人员的申请和B人员的申请在同一个流程里,为什么状态不一致?”直接检索这句话,向量检索大概率会召回流程状态流转的通稿,但回答不了“为什么不一致”。
我的做法是先让LLM判断能否拆分、怎么拆,再吐出一个结构化的拆分结果。提示词核心部分:
判断下面的用户问题是否可以拆分为多个可以独立检索的子问题。 如果可以,输出JSON数组,每个元素包含: - sub_query:独立的检索问句 - reason:为什么需要这个信息 如果无法拆分,直接输出原始问题。 问题:{query}上面那个例子拆出来就是三条:A人员申请的流程状态流转规则是什么;B人员申请的流程状态流转规则是什么;同一流程下不同人员状态不一致通常由哪些原因导致。三路检索分别命中不同文档,生成时再按主问题汇总。
这里有个工程细节值得注意:子问题并行检索通常更快,但有些子问题之间存在依赖。比如先要知道“日志文件在哪个目录”,才能检索“怎么分析这个目录里的日志”。我的做法是给子问题拆解加一个最大深度限制,默认两层,如果第一轮检索到的新文档里没有足够信息,再基于已有信息补一次子问题检索。拆解不是越细越好,拆到单个子问题无法独立检索就该停。
2.3 Step-Back抽象:先抽象一层,再回到细节
Step-Back和Multi-Query的区别在于方向。Multi-Query是横向撒网,Step-Back是纵向抽象:先从具体问题退到一个更一般化的问题,检索原理性、概念性文档,再回到原始细节问题补一轮精确召回。
我遇到的一个真实场景是用户问“为什么我们切换模型之后,首字延迟变高了”。直接检索“首字延迟 切换模型”效果很差,因为文档里根本没有这么具体的组合描述。Step-Back之后变成“LLM服务延迟评估有哪些指标、模型切换对推理性能的影响因素有哪些”,一下子就命中了方法论文档。拿到这些背景之后再拿原始query做第二轮精确检索,两轮结果合并给生成,答案完整度明显更高。
Step-Back的prompt不用太复杂:
请把下面的具体问题,改写成一个更一般化、更抽象的搜索问题, 以便在技术知识库中找到相关背景知识。 保持问题的核心主题不变,抽象到领域概念或方法论层面。 问题:{query}这个策略最适合那种“问得太具体、背后依赖领域概念文档”的场景。但如果你的知识库里只有操作手册、没有原理文档,Step-Back收益会比较有限,因为它检索的是抽象概念层。另外要注意,Step-Back通常不能单独用,它产出的抽象query结果更偏背景资料,需要再配合原始query的精确检索,两轮结果一起喂给生成器才完整。
2.4 HyDE:用“假设答案”去撞向量相似度
HyDE的思路很有意思:让LLM不检索就先生成一个假设性答案,然后用这个假设答案的embedding去检索相关文档。它跟Multi-Query、Step-Back不一样,后两者改的是“问法”,HyDE改的是“和检索目标之间的句子形态”。
为什么有效?用户query往往很短,短句的embedding和长文档的embedding天然有形态差。但LLM生成的假设回答是一段完整的、类似文档口吻的文字,在向量空间里和真实文档距离更近。等于先把用户意图“翻译”成文档的语言,再去做相似度匹配。
但必须清晰一点:HyDE的假设答案是不可信的内容,它只用来做检索的key,绝对不能混进生成阶段的上下文。我让LLM按“如果我在文档里看到这个问题的答案,内容可能会这样写”的口吻,生成一段100-150字的段落,然后取它的embedding走一遍召回。
实测下来,HyDE对概念性、综述性问题效果明显;对强实体、强事实型查询反而可能帮倒忙。比如用户问“今年预算审批截止日期”,假设答案可能写出一个错误的日期,把检索结果带偏。这类查询用轻量关键词扩展更稳。所以在我的方案里,HyDE不是默认开启的,而是只针对检测到“综述性、比较性、概念解释”意图时才启用。
2.5 轻量级重写:不调LLM也一样能救召回
不是所有场景都值得为查询重写多调一次LLM。数据量大、延迟敏感、离线批处理成本受限的时候,规则与NER结合的轻量重写方案同样有效。
我能落地的轻量重写包括:同义词词典替换,比如“卡顿”替换为“延迟 性能问题”;语气词清理,“咋”“呢”“那个”直接删除;实体与缩写标准化,把“权限系统V2”这种说法归一成标准写法;基于对话历史的指代消解,先用规则把“它”“这个功能”替换成前文实体。
这套做法的好处是零幻觉、稳定、可控、几乎没有额外延迟;缺点是覆盖面窄,同义词词典需要长期维护。实际生产里我更推荐“两级重写”:第一级用规则先清理和标准化,第二级只对规则处理不了的复杂query调用LLM做扩展或分解。这样既控制成本,又保留了LLM重写的弹性。很多人一上来就上LLM重写,忽略了规则层,导致每一条query都多一次模型调用,成本上去不说,那些本来很清晰的query反而被无谓地改了一遍。
3. 重写链路嵌入RAG管线后的工程改造点
策略选好后,重写不是一个独立模块,它会牵动检索、合并、生成、缓存一整条链路。这里说几个我改管线时踩过的坎。
3.1 多路召回合并:RRF与去重要一起做
多查询和子问题分解都会产生多路召回,于是合并策略就变成了一个不能绕开的问题。最简单直觉是把各路分数相加排序,实测会发现行不通:不同路线的分数分布差异巨大,embedding的余弦相似度和稀疏检索的score根本不可比,直接相加等于默认它们权重相同。
我用的是RRF(Reciprocal Rank Fusion)加去重。RRF不看原始分数,只看文档在每路结果里的排名:
def rrf_fusion(rankings, k=60): scores = {} for ranking in rankings: for rank, doc_id in enumerate(ranking): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)k取60是常见默认值,实验效果也比较稳。RRF天然对“多路里都排得靠前”的文档友好,这正是我们想要的结果:一个文档如果在三路query里都出现在前10,它大概率真的相关。
但RRF不去重。多路query本身语义接近,召回的文档大量重叠,我会先做文本归一化后的精确去重,再用embedding相似度做一遍相似去重,阈值大概在0.9左右。不然高频重叠内容会挤占上下文窗口,生成时全在复读同一段话。合并之后的排序结果还会再进一轮重排模型,但那是另一个话题,这里不多展开。
3.2 重写后的query拿去检索,还是也进生成?
这是我在项目里被反复问到的问题。我的实验结论是:重写query应当参与检索,但生成阶段要以原始query为主,重写query为辅。
如果只用重写query做生成输入,风险是LLM给出一个改得面目全非的query,答案跟着跑偏。我有一次把“怎么申请服务器资源”重写成了“服务器资源申请流程和审批细则”,检索没问题,但生成回答变成了审批细则科普,偏离用户真正想要的操作步骤。这种“检索对了、回答偏了”的case,端到端体验非常奇怪。
折衷方案是生成提示词里同时放“原始query”和“重写后的query”,并明确告诉模型“优先按原始问题回答,重写问题仅作为补充上下文”。这样既享受了重写带来的更全召回,又不会被重写方向带偏。这个方案在评测里,忠实度分数比“只喂重写query”高出一截。
3.3 延迟、成本与兜底策略
查询重写本质上是多了一次LLM调用,延迟和成本必须纳入设计。
延迟敏感的线上服务,我的做法是“异步重写加兜底”:请求进来先用原始query马上跑一版召回,同时后台把query送去重写,重写结果回来后再做一轮补充召回,两轮结果合并。用户首包延迟几乎不增加,只有最终答案会因为补召回稍微变好。如果重写环节超时或者失败,直接放弃补充召回,绝不阻塞主流程。这个兜底逻辑一定要写死,否则重写一旦抖动,整个RAG服务都会跟着雪崩。
成本控制上,重写任务比生成简单,没必要每次都上大模型。一个专门蒸馏的小模型完全够用,我自己用7B量级模型跑重写,质量和100B以上模型在生产场景里差异很小,主要差距在复杂子问题分解上。另外重写结果可以做缓存,相同query或者高相似度query直接命中缓存,可以省掉大量重复调用。对于企业知识库这类query分布相对稳定的场景,缓存命中率能到三成以上,成本优化效果很明显。
4. 从指标到上线:查询重写的评测方法与排坑心得
查询重写很容易陷入“体感有效但说不上来有效在哪”的状态。我后来用了一套两段式评测,一段看检索层,一段看端到端答案质量,两边一起定策略。只做检索层评测容易自嗨,只做端到端评测又很难定位问题出在重写还是生成。
4.1 检索层指标:命中率、Recall@k与MRR
评测检索层需要先准备一组query和对应的gold文档。从线上日志抽几百条真实query,人工标注每条query应该在知识库里命中哪些文档,然后分别跑三条链路:原始query、重写后query单路、重写后多路召回合并。
关注三个指标。命中率看gold文档有没有进top k;Recall@k看gold文档在top k里的覆盖率;MRR看gold文档首次出现的位置,它比命中率更能反映排序质量。为了说清楚趋势,我放一张自己项目里整理出来的示意数据,数字不一定适用于你的场景,但趋势是典型的:
| 链路/策略 | 命中率 | Recall@3 | MRR | 适用query类型 |
|---|---|---|---|---|
| 原始query直接检索 | 52% | 0.41 | 0.38 | 全量 |
| Multi-Query三路扩展 | 68% | 0.55 | 0.47 | 短query、口语化 |
| 子问题分解 | 76% | 0.62 | 0.53 | 复合/多跳问题 |
| Step-Back抽象 | 61% | 0.48 | 0.44 | 概念依赖型问题 |
| HyDE假设文档 | 58% | 0.46 | 0.42 | 综述/概念型问题 |
关键是要分场景看,而不是只看整体均值。整体均值会掩盖“这个策略只对某类query有效”的事实。我建议按query类型分桶统计,否则你会在一个不合适的桶里得出错误结论。比如子问题分解在全量query上平均提升有限,但在复合问题子集上优势拉开得非常大。
4.2 端到端评测:答案质量才是最终裁判
检索指标好不代表最终答案好。我见过命中率从50%拉到70%、但用户主观回答满意度反而下降的情况,原因是多路召回引入了大量相关但不关键的片段,生成模型被碎片信息带偏。
所以端到端评测不可省。我从业务侧收集了一条200条的评估集,每条包含query、gold答案和“策略标签”,比如这个query适合Multi-Query还是适合Step-Back。评测时用LLM裁判按相关性、忠实度、完整性三项打分,每批再抽20%做人工复核,防止LLM裁判自带偏好。LLM裁判的绝对分数参考价值有限,但同一批数据跑不同链路之后的相对差异,可信度还是够的。
这里必须提醒一点:单独看“用重写平均分更高”不够,我会额外看“重写后分数下降的query”有多少,也就是负面率。正向率和负面率一起看才公平。如果一个策略把某些query的分从9拉到10,同时把另外一些从7拉到4,那负面样本里的问题必须优先修。与其追求平均分最高,不如追求“没有明显变差的case”。
4.3 上线后被现实教育出来的三个坑
最后说三个我实际踩过、修复成本都不低的坑,希望你能绕开。
第一个坑是实体丢失。我在重写prompt里没写“不得替换专有名词和缩写”,结果LLM把“ES”改写成“搜索引擎”,把“Grafana”改写成“监控面板”,检索结果全部偏到通用文档上。修复方案就是我在1.3节写的四条铁律,把它写死在prompt顶层。这个坑之所以隐蔽,是因为单看重写后的query,每一句都通顺合理,只有对照检索结果才发现路径全歪了。
第二个坑是重复片段挤占上下文。多路召回合并之后忘了去重,top 5里出现三篇内容几乎一样的文档,整个上下文窗口有一半在复读。修复方案是RRF排序之前先做一轮精确去重和相似去重。这个坑在Multi-Query场景里特别容易出,因为三路query本身讲的是同一件事的不同说法。
第三个坑是子问题分解完成后丢失问题间关联。子问题各自检索都很精准,但生成阶段模型只看到三堆子答案,回答逻辑是断的。修复方案是生成提示词中同时重述完整主问题,然后把子问题之间的逻辑关系按顺序写清楚,让生成器知道自己在回答一个整体性问题。这个问题我一开始没意识到,直到有用户反馈“你们回答里每个部分都对,但连起来不像在回答我的问题”,才回头去看生成链路。
另外一个小习惯:上线后持续抽检线上query的重写结果,看有没有出现“把query改得更糟”的case。这类case攒下来,反向补充到prompt约束或规则层里,重写效果会越滚越好。我每个月会导出一次重写日志,随机抽200条人工过一遍,成本不高,但能拦住好多模型升级后突然冒出来的行为漂移。
最后留一个我沿用了很久的小习惯:重写prompt里永远放一句“如果原始查询已经足够清晰,请原样输出”。这一句让负面改写率掉了不少,算是全篇里性价比最高的一行字。查询重写不是一次性做完就结束的组件,它的策略组合会跟着知识库内容、用户query分布的变化而漂移。我目前的存量配置是:规则清理打底,Multi-Query做通用扩展,复杂query走子问题分解,概念依赖型query补一次Step-Back,HyDE只在综述型场景下单独开启。如果你也想给RAG提上限,我建议别一上来就铺开所有策略,先跑通Multi-Query加RRF加去重这一组最小闭环,拿线上真实query对比命中率。确认有增益之后,再逐步加入别的策略——每一步都能单独回滚,才不会改乱了没法收场。