别的不说,先抛一个场景:你在企业知识库里查资料,第一句问“什么是项目管理里的里程碑”,系统回答得挺准,接下来你补一句“那我怎么更新它”,结果直接偏到九霄云外去了。这不是模型傻,而是系统没记住上一轮在聊“里程碑”。多轮对话里的查询重写(Query Rewriting),解决的就是这个问题——它把“那我怎么更新它”改写成“如何更新项目管理中的里程碑”,再去做召回,才会准。这篇文章我就把RAG多轮对话里查询重写这点事拆开讲透,从原理到框架再到代码实战,全程按我实际跑过的方案来说。
适合谁看?正在做RAG应用、知识库问答、对话式搜索的程序员或算法工程师,尤其卡在多轮效果差、上下文理解不准确这类问题上的人。如果你只是调过API但对召回逻辑不熟,也能看懂,我会把术语用大白话解释清楚。
1. RAG多轮对话的痛点梳理:为什么必须做查询重写
1.1 多轮对话中的指代消解与信息省略
先弄清楚问题是怎么来的。自然语言里有个很常见的现象叫“指代消解”,比如“它的价格是多少”里的“它”,得往前翻才知道指的是“刚才那款手机”。还有一个是“省略”——上轮说“我想了解XX框架”,这轮直接问“性能怎么样”,其实完整表达应该是“XX框架的性能怎么样”。
这两种情况在单轮问答里基本不存在,因为用户每次都会把话说完整。但对话一旦进入多轮,系统面对的就不是一条孤立的Query,而是多轮上下文叠加后的复合语义。如果直接把当前轮次的原始文本丢给检索器,召回来的内容大概率是残缺的、甚至错误命中的。
我在实际项目里踩过特别典型的坑:用户问“项目怎么立项”,下一句“需要多少钱”,没有重写机制的RAG会把它当成一个独立问题去搜“需要多少钱”,结果召回的全是预算编制、成本控制之类泛泛而谈的文档,完全没结合“立项”这个上下文。这就是信息省略带来的检索失效。
1.2 向量检索对“上下文敏感”的天然短板
向量检索的原理是把文本变成高维向量,在高维空间里用余弦相似度找最相近的片段。问题在于,一个短句子比如“怎么更新它”本身信息量太薄,向量化之后重点不明,在几千个文档片段里挨个算相似度,很容易找到一堆语义飘忽的内容。
打个比方:你让一个对项目一无所知的新人去档案室找资料,只告诉他“找一下更新它的文件”,他肯定懵。但如果告诉他“找项目管理里程碑的更新方法”,他立刻知道往哪个柜子走。向量召回也是这个道理——召回质量的天花板,取决于Query的表达质量,而不是向量模型本身有多强。
所以,多轮RAG的技术路线里,“查询重写”几乎是必选项,不是锦上添花。它可以发生在检索之前,把原始对话转化为适合检索的独立Query,从而把“病根”在入口处解决掉。
1.3 查询重写在RAG链路中的位置
标准的RAG流程是:用户输入 → 检索 → 重排序 → 生成。但多轮场景下,检索之前应该插入一步“对话状态理解 + 查询重写”。你可以理解为流程变成:用户输入 → 上下文拼接与解析 → 查询重写 → 检索 → 重排序 → 生成。
要特别注意,查询重写不只是一个文本改写的过程,它必须“带着上下文”去工作。上下文里哪些信息是有用的、哪些是噪音,重写时需要筛选和取舍。比如用户闲聊一句“今天天气不错”后马上转入业务问答,这种上下文就不该参与重写。所以,一个设计合理的重写模块,往往还要搭配上下文裁剪、核心实体抽取这些前置处理。
2. 查询重写的主流方案与实现细节
2.1 基于规则与统计的轻量级方案
先讲最容易落地的方案:规则和统计。逻辑很直白——把上一轮或前几轮的高频词/实体/主题词,与当前Query拼接。
具体做法上,我可以给出一套简单可跑的实验思路:把对话历史按轮次存储,每轮提取出主语、动词、宾语这类关键成分,当前Query如果缺少主语或宾语,就用对话历史上最近的完整主谓宾去补全。比如前文是“什么是里程碑”,当前是“怎么更新”,补全后就是“里程碑怎么更新”。
不过,纯规则方案有明显短板。它依赖文本结构分析,遇到口语化表达、复杂句式或隐含语义较多的场景(比如“那这个呢”带手势或指代画面),基本无能为力。而且规则库需要针对具体领域不断维护,成本不低。所以我的经验是,规则方案适合做兜底和冷启动,不适合做主力。
2.2 基于LLM的生成式查询重写
当前主流方案是直接用LLM做生成式重写,让大模型根据对话上下文生成“适合检索的完整Query”。我用过的Prompt模板大致是:
你是一个对话查询重构助手。请根据对话历史,把用户最新的提问改写成一个脱离上下文也能被检索理解的独立问题,保留所有关键实体与约束条件,只输出改写后的问题。
这个方案的优点是上限很高,大模型具备理解指代、推断省略信息和融合上下文的能力,生成结果往往接近人工标注质量。但它不便宜——每次多轮对话都要多调用一次模型,Token开销增加不少。
还有一个细节经常被人忽略——生成式重写可能会“过度发挥”,把原文没有的信息也补进去。比如用户问“A和B的区别”,LLM可能多写出一个C的对比,结果把检索带偏了。所以Prompt里必须加约束:不要添加对话历史中不存在的信息。这一点我吃过几次亏,一定要严格限制。
2.3 混合策略:规则兜底 + LLM为主 + 检索引擎反馈
不要迷信某一个方案,实际工程里最稳的是混合策略——用LLM做重写主力,用规则做兜底,再用检索结果的反馈做验证。
具体思路是这样:
- 先把当前Query与对话历史丢给LLM重写;
- 如果重写后检索结果的相关性分数很低,或返回片段为空,自动切换到规则重写方案;
- 如果两者都不理想,直接退回“用户原始Query + 历史首轮问题”作为最后手段。
这样的好处是,系统在大多数场景能用上LLM的高质量重写,同时在极端情况(LLM超时、Token超限、重写质量异常)下仍然不崩。关键是那个“相关性分数阈值”需要根据你的知识库规模和质量去压测,没有通解。
2.4 查询重写的质量评估维度
重写质量好不好,不能只靠肉眼感觉。我总结了几个硬指标:
- 召回率:重写后Query召回到相关文档的比例是否提升;
- 意图保持率:重写后的Query与原始用户意图是否一致;
- 实体保真度:关键实体(人名、产品名、编号、日期)是否全部保留,有没有替换或丢失;
- 噪声引入率:有没有添加原本不存在的条件、主体或范围。
这几项里最难平衡的是“意图保持”和“实体保真”。我见过重写结果很完整,但把“2023版”改成了“最新版”,导致检索范围扩大、精度下降。所以评测集里一定要设实体字段的核对环节。
3. 主流RAG框架的多轮对话实战:这几种方案怎么选
3.1 LangChain的ConversationRetrievalChain及其局限
LangChain里做多轮RAG,最常用的就是ConversationRetrievalChain,它维护一个chat_history,检索Query时直接把整个对话历史作为检索输入或重写输入。新手最容易被它“能用”的假象骗过去——你真跑起来会发现,对话一长,检索输入变得臃肿,相似度计算被无意义的历史内容干扰,效果反而变差。
它的另一个局限是,检索用到的Query生成逻辑对中文支持不够细致,指代消解经常做不干净。我不止一次看到社区的例子,历史里有A和B两件事,用户问“它什么时候上线”,ConversationRetrievalChain把A和B都带进检索Query,反而把混淆度拉高了。
因此,如果你用LangChain,我的建议是不要直接套默认链路,而是把chat_history先交给LLM做一个重写摘要,再传给Retriever。框架本身是工具,不是银弹。
3.2 LlamaIndex的ChatEngine与QueryTransform
LlamaIndex这边做得更聪明一些。它的ChatEngine接口天然支持多轮,底层提供了HyDEQueryTransform这类组件——它不只是简单重写Query,而是先生成一个假设性文档,再用这个假设文档做检索,显著提升召回质量。
我在一个技术文档问答项目里验证过这个思路:基础重写方案的召回Top5准确率在76%左右,换成HyDE方案后提高到83%。代价是Latency增加,因为要多生成一版假设文档,但知识库质量高、文档风格统一的情况下,这波投入很划算。
不过使用LlamaIndex时要注意,它对中文语料的分词、停用词处理不如专门的中文NLP工具精细,需要在索引构建阶段配合中文预处理。另外,重写组件的Prompts都内置为英文,直接用中文场景效果会打折,需要花时间调。
3.3 自研轻量级查询重写模块的架构设计
如果你对业务领域了解深入,其实我最推荐的是自研一个轻量级重写模块,夹在检索之前。不需要复杂架构,核心就是三层:
- 第一层:上下文收集与裁剪,保留最近2-3轮关键实体与话题标签;
- 第二层:重写执行器,可选规则、LLM或混合策略;
- 第三层:重写质量校验,用相似度、实体匹配做快速判断,不合格则回退。
这个架构我实际用在一个企业制度问答系统里,效果比直接套LangChain默认链路要稳定得多。原因是自研模块可以对业务专属实体词做精准保留——比如企业内部的项目代号、系统名,这些LLM经常改写或泛化掉。有了自定义词典和规则,重写就不会把这些关键信息带偏。
3.4 框架对比速查:选型该看哪些性能指标
| 框架/方案 | 多轮支持 | 重写能力 | 扩展性 | 工程复杂度 |
|---|---|---|---|---|
| LangChain ConversationRetrievalChain | 基础支持 | 弱,需二次开发 | 中 | 低 |
| LlamaIndex ChatEngine + QueryTransform | 完善 | 强(HyDE等) | 高 | 中 |
| 自研重写模块 | 全可控 | 按需设计 | 最高 | 高 |
选型时还要关注三个硬指标:
- P95延迟:重写 + 检索 + 生成全链路的延迟,重写不能成为瓶颈;
- QPS承载:高并发下LLM重写会不会打爆API限额;
- 失败回退率:重写模块异常(超时、空结果、格式错误)时的兜底效果。
我个人经验是,如果团队规模不大、业务以标准知识问答为主,先用LangChain或LlamaIndex快速原型验证,性能瓶颈出现后再自研重写模块不迟,不需要一上来就自造轮子。但如果你的业务重度依赖多轮对话,并且知识库的专业词汇密集,自研是迟早之路。
4. 从零实现一个轻量级查询重写服务(附可运行思路)
4.1 数据结构和上下文管理
写代码之前先把数据结构定好。以Python为例,我会用一个简单类去管理对话上下文:
from dataclasses import dataclass, field from typing import List, Dict @dataclass class DialogContext: session_id: str turns: List[Dict[str, str]] = field(default_factory=list) def add_turn(self, user_query: str, response: str): self.turns.append({"user": user_query, "assistant": response}) def recent_turns(self, k: int = 3) -> List[Dict[str, str]]: return self.turns[-k:] def to_prompt_text(self, k: int = 3) -> str: lines = [] for turn in self.recent_turns(k): lines.append(f"用户:{turn['user']}") lines.append(f"助手:{turn['assistant']}") return "\n".join(lines)注意recent_turns里我取的是最后3轮,不是全部历史。对话越长,旧轮次的参考价值越低,而且会引入干扰。这个窗口大小要根据业务实测调整,2到4一般是甜点区间。
4.2 基于LLM的重写核心函数
核心重写函数我封装成一个rewrite_query方法,接受对话历史和当前Query,返回重写后的文本:
def rewrite_query_with_llm(history_text: str, current_query: str, llm_client) -> str: prompt = f""" 你是对话查询重写助手。下面是对话历史: {history_text} 用户刚输入的问题: {current_query} 请把用户刚输入的问题改写成脱离对话历史也能独立检索的完整问题。 要求: 1. 补齐省略部分;把指代词(它、这个、这些)替换成具体对象。 2. 保留所有实体名、编号、日期。 3. 不要添加对话历史里不存在的新信息。 4. 只输出改写后的问题本身。 """ resp = llm_client.chat.completions.create( model="your-model-name", messages=[ {"role": "system", "content": "你是一个查询重写助手"}, {"role": "user", "content": prompt} ], temperature=0.2, max_tokens=100 ) rewritten = resp.choices[0].message.content.strip() return rewritten这里temperature我设置0.2——生成重写Query不是创作,而是要稳定和准确,温度越高越容易跑偏。max_tokens也不用太大,改写后的Query一般不会超过50个字,给100足够。
4.3 规则兜底与实体保护机制
在LLM重写结果基础上,再加一道“实体保护”工序。先设定一个业务实体词典,比如:
PROTECTED_ENTITIES = { "里程碑": "里程碑", "立项": "立项", "WBS": "WBS", "XX系统": "XX系统" }重写完成后,逐一检测保护实体是否被LLM改写、删除或替换。如果出现偏差,将原始实体强制还原。
def protect_entities(original_query: str, rewritten_query: str, entities: Dict[str, str]) -> str: result = rewritten_query for entity in entities: if entity in original_query and entity not in result: # 实体丢失,强制插回 fallback = original_query + " " + entities[entity] # 这里实际还需要一个更细致的插入策略,但宁可多也不能漏 result = fallback return result实体保护是自研方案里最值钱的一块——它把规则的人性化和LLM的语言能力结合起来。需要注意,上面的插入策略很粗暴,生产环境最好在重写Prompt阶段就把实体保护指令带上,并把实体词典拼进Prompt,让LLM在看到实体时主动保留。
4.4 接入RAG链路的完整示例
最后,把重写模块接进标准RAG链路,我用的方式是自定义一个Retriever包装器:
from langchain.schema import Document from langchain.vectorstores import FAISS class RewriteRetriever: def __init__(self, vectorstore, dialog_context: DialogContext, llm_client): self.vectorstore = vectorstore self.context = dialog_context self.llm_client = llm_client def get_relevant_documents(self, query: str) -> List[Document]: history_text = self.context.to_prompt_text(k=3) rewritten = rewrite_query_with_llm(history_text, query, self.llm_client) rewritten = protect_entities(query, rewritten, PROTECTED_ENTITIES) docs = self.vectorstore.similarity_search(rewritten, k=5) # 如果召回为空,降级使用原始Query if not docs: docs = self.vectorstore.similarity_search(query, k=5) # 更新上下文 self.context.add_turn(query, "") return docs代码很简略,但骨架是对的。生产环境里还要补充重写结果缓存、多轮历史检索、以及关键日志,甚至把重写前后的Query都存下来,方便评估重写质量。
4.5 重写对召回效果的一个真实对比
我跑过一个内部技术资料库的对照实验,同样20组多轮问题,分别用“原始Query”和“重写后Query”做检索,对比指标:
| 方案 | Top3召回准确率 | Top5召回准确率 | 平均检索耗时(ms) |
|---|---|---|---|
| 原始Query直接检索 | 58% | 72% | 90 |
| 重写后Query检索 | 81% | 88% | 112 |
| 重写 + 实体保护 | 84% | 91% | 115 |
可以看到重写带来约20个点的Top3提升,实体保护再带来3个点。代价是检索平均耗时增加20多毫秒,在绝大多数业务里完全可接受。这种量化数据比“感觉变准了”有说服力得多。
5. 多轮RAG的调参与避坑指南:三个特别容易翻车的地方
5.1 上下文窗口取舍:历史到底该保留几轮
多轮不是轮数越多越好。我在实践中总结出一个规律:超过4轮的对话,每多1轮历史,重写的准确率反而下降2%到3%。原因是冗余信息干扰了LLM对当前Query核心意图的判断。
建议做法是以2到3轮为主窗口,但把更早轮次中的“主题词”抽取出来单独拼接。比如第1轮提到“里程碑”,第8轮还在聊里程碑相关话题,第1轮的实体就该固定在Prompt里的“全局话题”位置,而不是把整段历史都塞进去。这样既保住了长程依赖,又减少了噪音。
5.2 中文检索的特殊处理:分词与同义词扩展
中文RAG里的查询重写,比英文多一道坎:分词。英文词天然空格分隔,中文必须依赖分词工具,而分词错误会直接带偏检索。
我在项目中用的方案是,先对历史文本做粗粒度领域分词,配合自定义词典保证业务词不被切开,再交给LLM重写。重写结束后,还要做同义词扩展,例如“里程碑”和“节点”在业务语境里可以互换,但向量模型不一定理解这种对应,需要靠同义词表显式补进去。
5.3 成本控制与延迟优化:重写环节会不会拖垮服务
多轮对话里每次提问都调一次重写LLM接口,成本累积起来很可观。我遇到过一个月Token消耗翻倍的案例,排查下来一大半是重写环节产生的。
控制手段有几个:
- 命中规则可处理的情况,不走LLM。比如当前Query已经包含完整主谓宾,且不含指代词,直接跳过重写;
- 同一会话内同一实体指代的重复改写,走缓存;
- 用小模型做重写,比如通用大模型性能过剩,换成小参数模型在重写任务上完全够用。
延迟方面,重写接口如果慢,就把它与检索并行化——先用原始Query做一次快速检索作为候选,同时重写Query再做精确检索,最后融合排序。这招在保证质量的同时,能把P95延迟压住。
6. 工具链梳理:可以省力的开源项目和库
6.1 常用构建模块:向量库、LLM接口与中文NLP工具
查询重写服务离不开一套基础工具组合。常用的构建模块如下:
- 向量库:FAISS适合中小规模知识库,几百GB也无压力,且轻量。大规模并发场景可以考虑专用的向量数据库,但中小项目FAISS足够;
- LLM接口:重写环节不需要特别大的模型,百亿参数级别的开源模型在A100上部署就能获得良好效果,量小的话直接调API更划算;
- 中文NLP:分词和实体识别是重写效果的基础,自定义词典优先于通用预训练模型,因为业务实体往往是新词、专名,通用词典里根本没有。
6.2 开源RAG框架的组合与再封装思路
与其从零搭,不如站在开源框架肩膀上再做一层业务封装。用过几套方案之后,我的建议是:用LlamaIndex的索引与检索组件,但把它的ChatEngine换成自己控制的多轮控制层;LangChain的Chain则更适合快速验证思路,不适合作为核心业务架构。
具体到查询重写,我会在业务代码里直接定义一个重写接口,适配不同后端:
class QueryRewriter: def rewrite(self, history, query): raise NotImplementedError class LLMQueryRewriter(QueryRewriter): def __init__(self, llm_client): self.client = llm_client def rewrite(self, history, query): return rewrite_query_with_llm(history, query, self.client) class RuleQueryRewriter(QueryRewriter): def rewrite(self, history, query): # 规则补全逻辑 pass class HyridQueryRewriter(QueryRewriter): def __init__(self, llm_rewriter, rule_rewriter, fallback_threshold=0.6): self.llm = llm_rewriter self.rule = rule_rewriter self.threshold = fallback_threshold def rewrite(self, history, query): rewritten = self.llm.rewrite(history, query) # 简化版质量判定 if len(rewritten) < 5 or query in rewritten: rewritten = self.rule.rewrite(history, query) return rewritten这种接口化设计的好处是后续换模型、加规则都不会动业务主代码,测试也方便。
7. 进阶玩法:查询重写之外的上下文增强手段
7.1 对话记忆压缩与摘要
查询重写解决的是“你这句话到底想问什么”,但对话很长的时候,重写还需要“记着之前聊过什么”。对话记忆压缩就能派上用场——定期把旧轮次的内容总结成记忆摘要,重写时只需把摘要和最近几轮拼在一起。这个方案对长会话特别有效,能解决历史遗忘问题。
我用过两种压缩方式:一种是把每轮提取出“主题 + 实体 + 结论”形成结构化记忆;另一种是让LLM直接生成一段摘要文字。前者可解释性强,后者更灵活。实际场景里面,结构化记忆配合业务字段(比如用户在聊哪个项目、关心哪个指标)效果更好。
7.2 重写与检索交互式迭代:RAG-Fusion思路
如果把重写当一次性预处理,上限也可见。进阶玩法是让重写与检索形成闭环——重写生成多个候选Query,分别检索,合并结果后看哪个Query候选召回的文档重叠度最高、相关性最强,再用它作为最终的检索Query。这和RAG-Fusion、Self-RAG里的一些思路相通。
我试过一次生成3个候选Query,召回效果确实比单次重写更稳,代价是延迟翻倍,而且对知识库的要求也高。如果知识库本身质量一般,多候选只会多出一堆相似的垃圾结果。说到底,重写不是魔法,它放大的是知识库的底子。
7.3 个性化与场景化重写:不同行业的知识库差异
最后补充一个容易被忽略的点:查询重写其实是可以个性化、场景化的。技术问答、医疗问答、法律问答、金融问答,对重写的要求完全不同。
医疗场景里,“吃药后头晕”这一句,重写时必须保留症状主体“吃药后头晕”和潜在药品名,绝不能把它们替换成“用药反应”这种概括词;法律场景里,“合同违约怎么办”必须保留“违约”和“合同”这些法条检索的关键实体。所以,业务侧的重写Prompt绝不是模板能一句通吃的,一定要融合行业术语表。
我的做法是,在每个业务知识库初始化时,把实体词典和否定词表注入重写模块,并在Prompt里显式声明哪些词不可替换。这个针对性工作,效果提升往往比换更大参数的模型还明显。
8. 写在最后:我给后来者的一点实操建议
这里讲的每一个方案,都是我跑过、踩过、调过之后留下的做法。按经验排序,如果只提三条最核心的实操建议:
第一,不要把查询重写当成可有可无的鸡肋步骤。评价一个RAG系统做得好不好,多轮对话里最有区分度的环节恰恰就是这个看起来不起眼的模块。Top3召回率的差距,往往就是重写做没做、做到什么的差距。
第二,重写环节的评估必须和下游任务绑定。单看重写文本像不像“正常问题”是不够的,要重写完真的丢进检索器,看召回的文档准不准、生成效果对不对。我在项目里做的评测指标就是“重写后Query的Top5召回相关性”,而不是拿文本相似度这些表面指标糊弄自己。
第三,迭代永远比替换可靠。你不需要一上来就上最强的LLM、最贵的方案,把基础链路说明白、加减规则、轮次调参,效果往往比盲目更换模型好得多。保持记录每次重写结果的优良中差,积累一段时间后,你会非常清楚瓶颈到底在哪个环节。
查询重写这个方向,短期内不会过时。只要对话系统存在,指代和省略就永远存在,把这个环节做扎实了,你的RAG应用在真实对话里的体验,会拉开和别人的差距——而且是用户明显能感知到的那种差距。