单轮 RAG 演示人人都会,真实产品一接入聊天窗口就露怯。用户自然的问法是接续式的:第一轮「你们的企业版多少钱」,第二轮「那它支持私有化部署吗」,第三轮「和上一个方案比呢」。第二轮里那个「它」、第三轮里那个「上一个方案」,直接拿去向量检索,检索到的内容与用户真正想问的毫不相干——embedding 模型面对一个代词,无从谈起语义匹配。多轮对话 RAG 要解决的核心问题就一个:在检索之前,把依赖对话历史的追问改写成「独立成篇」的查询。这篇讲清标准做法、几个隐蔽的坑,以及检索结果在多轮里的角色。
标准动作:condense,先把问题问完整
社区里把这一步叫 condense question(压缩/凝聚问题):用大模型读「对话历史 + 当前追问」,输出一个脱离对话也能看懂的完整问题。第二轮的「那它支持私有化部署吗」,读入历史后应被改写成「企业版支持私有化部署吗」——「它」被解析成「企业版」,省略的主语补全。第三轮的「和上一个方案比呢」,改写成「企业版和标准版在价格与部署方式上的对比」。
实现上就是一个专门的 prompt 调用,要点是让改写模型只做「翻译」不做「回答」:
你是查询改写器。根据对话历史,把用户的最新问题改写成一个
独立、完整、可理解的检索查询。要求:
1. 解析所有代词(它/这个/那个)为具体实体;
2. 补全省略的主语与宾语;
3. 只改写,不回答,不添加用户没问过的内容;
4. 输出改写后的查询,不要任何解释。
对话历史:
{history}
最新问题:{question}
这个调用应该用一个便宜快速的模型(主模型的小一档),因为它每轮都要跑,延迟敏感;而它的输出质量决定检索命中率,所以值得用《评测集构建》的方法单独造一组「追问改写」的测试用例来盯它。
三个隐蔽的坑
坑一:改写偏移。 改写模型偶尔会「脑补」——把「它支持私有化吗」改写成「企业版私有化部署的价格是多少」(多了「价格」)。检索跟着偏,答案答非所问,用户一头雾水。缓解:prompt 里明确「不添加用户没问的内容」;更结构化的做法是让模型同时输出「改写依据」(引用历史中的哪句话解析出了哪个代词),便于排查坏例。坑二:历史无限膨胀。 对话到第十轮,历史已几千 token,condense 调用越来越贵越来越慢。标准解法是历史压缩:滚动摘要——只保留最近两三轮原文,更早的轮次压成一段摘要;或只保留与当前话题相关的轮次(用相似度筛)。坑三:实体漂移。 用户中途换话题(问了三题部署,突然问发票),改写时上一话题的实体可能被错误带入。缓解:condense prompt 里要求识别话题切换,切换时退化为直接用原问题检索。
检索结果也要进「记忆」
多轮系统有一个比改写更容易被忽略的细节:前几轮检索到的片段,这一轮还要不要用? 场景:用户第一轮问「企业版价格」检索到了定价表,第二轮问「那部署方式呢」——新检索拿回部署文档,但定价表的上下文还在对话历史里,模型回答「部署方式」时未必需要定价表,这条不需要处理;但如果第二轮问的是「它比标准版贵多少」,改写后的查询是「企业版和标准版的价格对比」,新检索未必能命中标准版定价(可能在另一个文档里),而第一轮的检索结果里有企业版那一半——把近几轮的检索片段连同来源一起保留在上下文里,往往能补上新检索的缺口。
工程做法是把「检索片段缓存」作为会话状态的一部分:每轮新检索结果入缓存(带来源与轮次标记),拼装上下文时新结果在前、旧结果按相关性截断保留(《上下文组装》的预算算法在这里通用)。注意缓存片段的失效:话题切换或文档更新时清掉,避免过期证据污染新答案。
会话状态的数据结构
把前面各件合起来,一个多轮 RAG 会话的状态由四部分组成,工程上就是一个清晰的会话对象:轮次历史(role/content 列表,原始对话);滚动摘要(超过保留窗口的旧轮次被压缩成的段落,随对话更新);检索缓存(近 N 轮的检索片段,各带 chunk id、来源、轮次标记与得分);实体表(对话中出现的核心实体及最新指代——「它」最近指向谁、话题锚点是什么,供 condense prompt 与话题切换检测使用)。四部分各有保鲜期:轮次历史留最近 2~3 轮原文;摘要每轮重算(增量式追加比全量重算省得多);检索缓存按话题切换清空;实体表由 condense 的副产物顺带更新。这个结构的最大好处是可观测:多轮答错时,能对着四份状态定位是「改写错了」「检索空了」「缓存过期了」还是「摘要丢了关键信息」——排查多轮坏例没有状态切片,基本靠猜。
多轮的评测怎么做
单轮的评测集(《评测集构建》)量不了多轮,多轮要专门造「追问链」测试用例:一条用例是一串有依赖的对话(「企业版多少钱?」→「那它支持私有化吗?」→「和标准版比呢?」),期望输出挂在每一轮上。三层检查:改写层——每轮的 condense 输出是否与人工标注的「正确独立查询」一致(这一层最便宜也最该先测);检索层——改写后的查询是否命中黄金片段;答案层——每轮的最终答案是否覆盖要点。多轮用例的标注成本比单轮高(一条链抵五条单轮题),起步时 10~15 条覆盖典型追问模式(指代、省略、对比、话题切换、回到旧话题)就够用——它们护住的是多轮系统里最脆弱的改写环节。
延迟与成本的预算账
多轮比单轮多出的开销要摊在桌面上:condense 一次调用(快模型,几百毫秒)、可能的实体解析、拼装更长的 prompt(历史 + 摘要 + 检索缓存)。控制预算的三个抓手:condense 用小模型(它做的是「翻译」不是「思考」,小模型足够,且这层调用每轮都发生,是最大的成本乘数);检索缓存复用(上一轮刚检索过的片段本轮不重复检索——按 chunk id 去重,省检索费用也省拼装预算);历史压缩有上限(摘要超过 500 字就该再压——它是辅助信息不是主体,无限膨胀的摘要会在生成端反噬预算)。三抓手落地后,多轮相对单轮的边际成本通常能压到 1.3 倍以内,而不是朴素的翻倍。
交互层面的两件小事
多轮体验不只靠检索侧。其一,展示改写结果:在界面上把改写后的查询以「正在搜索:企业版支持私有化部署吗」的形式露出,用户能立刻发现「系统理解的不是我想要的」,纠偏成本从一轮投诉降为一次点击。其二,允许引用上一轮证据:用户追问「上面第二条的依据呢」时,应直接定位到上一轮答案里引用的片段(《引用与溯源》一篇的 chunk id 体系在这里派上用场),不要再检索一遍——「对已给出答案的追问」是完全可以本地解决的高频流量。
迁移清单:单轮改多轮的落地顺序
把单轮 RAG 升级为多轮,按依赖顺序四步走。第一步,先上 condense 改写——它是多轮的地基,独立于其他机制,单独可测可回滚;上线时加一层保护:改写失败(超时或输出异常)就退回原查询检索,多轮功能静默降级为单轮。第二步,历史窗口管理——原始轮次保留最近两三轮,更早的滚动摘要,摘要的重算频率与预算上限在这里定死。第三步,检索片段缓存——近几轮片段进会话状态,拼装时新证据优先、旧证据按相关性截断保留,话题切换清空。第四步,交互层收尾——改写结果露出、引用上一轮答案的快捷路径。每一步独立可回滚,合在一起才是完整的多轮体验;一上来四件套全上,坏例归因会变成噩梦——分层上线让每一步的质量都能单独归因。
多轮中的检索预算分配
多轮对话的每一轮都在花钱,预算要按轮次性质分配。信息递进轮(「那它支持私有化吗」):完整跑 condense 加检索,预算正常。延续追问轮(「详细说说」「再展开一点」):上一轮的检索结果大概率仍覆盖,condense 判断为「延续型」时跳过新检索、直接扩写上一轮证据——省一次检索还避免了「扩写时证据悄悄换了」的断裂感。话题切换轮:清缓存、全量重检索,condense 的退化为直用原问题。判别「延续还是新问」不需要再调一次模型——condense 改写的输出长度差异就能当信号(延续型改写后与原查询几乎相同),规则判别加置信度兜底即可。三类轮次的成本差异是数倍的:延续轮几乎零边际成本,切换轮全额——预算的优化空间就在「把延续轮识别出来」。
小结
多轮对话 RAG 的骨架可以压成一句话:改写在检索之前,记忆分三层——对话原文(近期轮次)管改写与衔接,滚动摘要管长程话题,检索片段缓存管证据复用。改写要防偏移、历史要防膨胀、话题切换要防实体漂移,三防做齐,多轮体验就从「演示能用」跨到「产品可用」。而这一切的质检基础,还是那句话:为「追问改写」单独建一组测试用例,改写模型是整个多轮链路里最脆弱也最关键的一环。
参考资料
- LangChain: Conversational RAG —— condense question 的标准流水线示例
- LlamaIndex: Chat Memory & Condense —— 会话记忆模块与历史压缩策略
- 本系列前篇:《查询改写》《上下文组装》《引用与溯源》《评测集构建》
读者留言
COMMENTS 暂无还没有留言,来说第一句?