☰
RAG多轮对话指代消解实战:用LLM改写Query提升召回准确率
2026/10/8 16:37:14 网站建设 项目流程

1. 先搞清楚问题:多轮问答里的指代,为什么让RAG“失忆”

我最早做RAG问答系统的时候,上线没多久就收到一个让人头疼的反馈:用户连着问几轮,第三四轮开始答案就飘了。比如用户问“张三最近的公开行程有哪些”,系统回答了一堆会议安排,用户接着问“他下周还会去深圳吗”,结果检索模块直接拿“他下周还会去深圳吗”去知识库里搜,标题里全是“他”,向量检索更是不知道“他”是谁,召回结果自然一塌糊涂。

这个问题说白了就是多轮指代消解没做。RAG的标准流程是把用户问题向量化,去向量库里做相似度检索,再拼接上下文喂给大模型生成答案。问题在于,多轮对话里用户的表达习惯是能省就省,“他”“她”“这个”“那家”“这种方案”满天飞,这些词对字符串匹配不友好,对向量检索也不友好。你检索“这个方案的成本是多少”,知识库里的文档标题大概率不会带“这个”两个字。检索阶段就偏了,后面生成阶段再强也救不回来。

我测试的时候还发现一个更隐蔽的问题:有些RAG系统把整段对话历史都拼进query里,希望模型能自己“脑补”出指代对象。这个思路理论上没问题,但实际效果很差。一方面是对话历史越长,向量检索被无关内容干扰得越厉害;另一方面,很多商用大模型的接口并不总能把历史里的实体和当前问题准确关联,尤其是当历史里同时出现多个人名、多个项目名的时候,模型容易张冠李戴。比如历史里同时聊了“张三”和“李四”,用户问“他上周的方案通过了吗”,模型可能选错对象。

这个问题的本质是:RAG的召回粒度是“当前问题”,而不是“完整意图”。多轮对话里的指代词,恰恰是把当前问题和历史上下文绑定的黏合剂,一旦黏合剂丢了,整个链路的准确率直线下降。所以我在第二个版本里专门加了一个前置模块,把“他/这个”这一类指代先翻译成明说的实体或事件,再做检索。

我梳理了几个最典型的翻车场景,方便对照自查:

场景用户输入RAG直接检索的结果问题根源
人称指代“他下周的日程发我一下”召回一堆带“他”的垃圾片段不知道“他”是谁
指示代词“这个方案的预算是多少”召回“这个方案”做关键词,几乎无效没有把“方案”映射到历史实体
省略主语“后续怎么推进?”检索出来的是通用的“怎么推进”,和前面的项目毫无关联整句都缺少核心实体
跨轮实体混用历史聊了客户A和客户B,“那家的报价单呢”模型在历史里随机选一个缺少显式指代绑定

这几类问题如果不在召回之前解决,后面无论怎么调prompt、怎么换embedding模型,都只是修修补补。我得先做一步“人话翻译”,把用户当前的短问题,翻译成带上下文实体的完整问题,再送进检索器。

2. 方案选型:为什么我选了“LLM改写”而不是硬上NLP模型

想解决指代消解,路径不是只有一条。我大概比较了三种主流做法,分别是在学术界和工程界都有人用的方案,各有各的适用场景。

2.1 三套方案:规则、专用模型、LLM改写

第一套是规则匹配。用正则和词典把“他/她/它/这个/那个/这家/该公司”这类词替换成上一轮出现的实体。好处是零成本、速度快、完全可控。坏处也很明显:指代消解本身是个语义问题,不是说出现“他”就往前找一个人名就完事了。比如用户说“他来了吗”,上一轮聊的是“张三公司的王总”还是“张三本人”?光看词性根本判断不了。更复杂的是“那个方案比这个好”,这里两个指代都指向历史中的不同对象,规则一次只能替换一个。我一开始也试过规则,维护到后面就是无底洞,各种边界case能把人逼疯。

第二套是微调专用的指代消解模型,比如经典的BERT-based核心指代解析模型。这套方案在学术评测集上效果不错,但有几个工程上的硬伤。一是通用的指代消解模型是面向“篇章级”设计的,它擅长处理“前文有充分主语、后文出现代词”这种长文本,但对话场景的省略式指代、跨轮跳跃式指代,效果并不稳定。二是中文本体里这类开源模型本来就少,效果又参差不齐,想要好用还得自己标数据微调,成本直接拉满。三是引入一个独立模型意味着多维护一个推理服务,性能和运维负担都上去了。对一个小团队来说,性价比不高。

第三套是LLM改写,也就是让大模型充当“翻译官”,把用户当前的问题结合对话历史改写成一句自包含的话。这里说的LLM不一定是最终做问答生成的那个大模型,也可以是一个单独的、更小更快的模型。我的选择就是这一版。具体到产品里,可以是开放式接口的模型,也可以是本地部署的6B/7B模型,只要它有基本的指令跟随能力就行。

2.2 我为什么倾向“LLM改写”这套思路

理由很务实。第一,大模型天然理解语义,它不仅能做人称指代替换,还能做省略补全。比如用户问“后续怎么推进”,大模型结合历史里“关于XX项目落地”的上下文,能把这句话改写成“针对XX项目的后续推进方案是什么”,补全了主语和事件背景。这种能力是规则和专用模型很难同时具备的。

第二,改写模块的输入输出都是纯文本,不需要额外的模型服务,也不依赖结构化标注数据。这意味着它可以插在RAG流水线的任意位置,既能放在召回前,也能放在历史管理模块里,灵活性非常高。

第三,从效果上看,LLM改写之后的query去做向量检索,质量和直接用原问题去检索相比,提升是肉眼可见的。我做过一组对比测试,在同样的知识库、同样的embedding模型下,加了改写模块之后,召回Top5的准确率大概提升了20个百分点左右,关键指标是“检索出来的是不是真正相关的文档”。这点提升直接决定了生成质量。

2.3 改写模块在RAG流水线里的位置

很多人以为多轮指代消解是“对话管理”的活,放在生成阶段才处理,这个理解其实是错的。我把话说清楚:在RAG架构里,改写必须发生在召回之前。整个流水线的顺序是:对话历史 + 当前问题 → 改写模块 → 生成自包含query → 向量检索 → 重排 → 拼接上下文 → LLM生成。如果你把指代消解放在生成阶段,比如直接让最终LLM从历史里“猜”实体,那召回阶段还是拿原始短句去搜的,前面照样跑偏。

我实际项目里是把它做成一个独立函数,在进入检索器之前先调用。逻辑上很简单,但这一步是整个多轮增强的基石。

3. 落地实现:改写模块怎么接到RAG流水线上

这节我直接讲代码实现。我用Python顺手写了一个rewrite函数,核心思路是:把对话历史压缩成一段上下文文本,加上当前用户问题,送进LLM,让它输出一个改写后的自包含问题。

3.1 对话历史的存储与截断策略

先说过说历史。要实现多轮改写,不能只拿上一轮来拼,因为指代有时跨好几轮。比如第一轮问“某公司的财报怎么样”,第二轮问“那他们的毛利率呢”,第三轮问“今年和去年比是升是降”,这里“他们”要追溯到第一轮的公司。所以对话历史至少得留最近N轮的文本。

但也不能无限留,长篇历史的token开销非常大,而且指代对象可能早就在前几轮出现过,大模型处理超长历史时反而容易忽略早期内容。我的做法是:保留最近6轮对话,并且每轮的“问题+答案”都截断到固定长度,比如单轮总共不超过300个字符。为什么要截?因为答案往往很长,但改写真正需要的是“这个问题在聊什么”,而不是完整答案。我把答案压缩成前100字或者用“……”省略,实测对改写质量的影响很小。

还有一个细节:如果你用的是带消息列表的模型接口,可以把历史按user/assistant的结构传进去;如果是纯文本接口,就得自己拼成带角色标识的文本。我下面的代码示例是拼文本的方式,兼容性更好,也方便调试。

from typing import List, Dict import json def build_history_text(history: List[Dict[str, str]], max_rounds: int = 6) -> str: """ history: [{"user": "...", "assistant": "..."}, ...] 取最近 max_rounds 轮,拼成带标识的上下文文本 """ recent = history[-max_rounds:] lines = [] for i, turn in enumerate(recent): user_part = turn.get("user", "")[:200] assistant_part = turn.get("assistant", "")[:100] lines.append(f"[对话第{i+1}轮] 用户: {user_part}") if assistant_part: lines.append(f"助手: {assistant_part}") return "\n".join(lines)

这段代码的作用是把历史整理成一眼能看懂的纯文本。注意我这里对assistant答案做了截断,只留前100个字符。为什么这么干后面会讲,这里先记住一个原则:改写的核心是“找到指代对象的线索”,不是把整段答案背下来。

3.2 改写Prompt的工程细节

prompt设计是这套方案里最值得打磨的部分。我踩了不少坑,总结出一个比较稳的模板,核心是五条约束:

  1. 只在存在指代时才改写。如果没有指代、没有省略,原样返回,不要画蛇添足。
  2. 必须从对话历史中寻找实体,不能自己编造历史里不存在的信息。
  3. 保留原问题。输出结构里同时带上original和rewritten,方便后期排查。
  4. 不要解释,直接给JSON。
  5. 如果历史中没有明确指代对象,保持原问题,宁可漏改也不要错改。
REWRITE_PROMPT = """你是一个对话理解助手。你的任务是根据对话历史,将用户当前问题中的指代词(比如"他/她/它/这个/那个/这家公司/这个方案/后续"等)替换为明确的实体或事件描述,使得改写后的问题可以被独立理解,无需依赖对话历史。 要求: 1. 仅当存在指代或省略时进行改写,否则原样返回。 2. 必须使用对话历史中明确出现过的实体、事件、背景,禁止编造。 3. 如果对话历史中没有足够的指代信息,则保持原问题不变。 4. 改写后的问题要完整、具体、适合用于知识库检索。 5. 必须是JSON格式输出,字段为 original 和 rewritten。 对话历史: {history} 当前用户问题:{question} 请输出JSON:""" def rewrite_question(question: str, history: List[Dict[str, str]], llm_func) -> str: history_text = build_history_text(history) prompt = REWRITE_PROMPT.format(history=history_text, question=question) raw = llm_func(prompt) try: data = json.loads(raw) rewritten = data.get("rewritten", question) except Exception: rewritten = question return rewritten

这里llm_func是我抽象出来的一个调用函数,你换成自己的模型请求就行。OpenAI、通义或者其他国产模型的SDK都可以,关键是这个函数输入prompt,返回模型输出的字符串。我给几个具体例子演示效果。

示例一:人称指代

历史: 用户:张三今年第一季度的营收数据出了吗? 助手:出了,营收同比上涨12%,主要受新品发布拉动。

当前问题:净利润呢?

改写结果:张三今年第一季度的净利润数据是什么?

示例二:指示代词+省略

历史: 用户:我们在评估A方案和B方案,你觉得哪个更适合我们这种初创团队? 助手:从成本角度A方案更合适,但从长期扩展性来看B方案更好。

当前问题:这个方案的扩展性具体体现在哪些方面?

改写结果:B方案在长期扩展性方面的具体体现是什么?

示例三:跨轮跳跃

历史: 用户:帮我查一下华为在2023年发布了哪些旗舰手机? 助手:发布了Mate 60系列、P60系列等。

当前问题:那款折叠屏是哪年发布的?

改写结果:华为在对话历史中提及的折叠屏手机(Mate X系列)是哪年发布的?

可以看到,改写后的query携带了明确的检索实体,向量检索能直接命中知识库里跟“净利润”“B方案”“折叠屏”相关的文档,而不是去匹配“净利润呢”这种无意义短语。

3.3 改写之后,检索策略要不要换

改完之后,检索本身可以不动,还是走原来的向量TopK召回。但有几个运维层面的细节必须注意。

第一,改写后的query和原query可以同时用。有些场景里改写可能引入一点噪声,比如历史里的实体其实和当前问题无关,模型把它硬塞进来,反而干扰了向量相似度。我的做法是:改写后的query作为主召回,原query作为辅助召回,两者各取TopK之后合并去重,再交给重排模块。这样即使改写有微小偏差,原问题也能兜底,不会一错错到底。

第二,关键词稀疏问题。改写后的问题虽然实体更明确,但可能变得很长、很啰嗦,embedding模型对长句子的语义压缩能力不是无限的。所以我有时候会把改写后的问题拆成短句,比如用“张三”“第一季度”“净利润”这几个关键片段分别做检索,再做结果融合。这种“多路召回”策略是提高上限的常用手法,但也要看你知识库的文档粒度。

第三,改写判断的置信度。我在工程上给rewrite函数加了一个小分支:如果模型判断没有指代(original等于rewritten),就直接走原始的整段检索;只有在确实改写的情况下,才启用“改写query+原query双召回”模式。这个分支的好处是省了一截不必要的检索开销。

3.4 让改写模块的性能扛住线上请求

很多人一开始对这个方案有疑虑,觉得多一次LLM调用会拖慢响应速度。这个担心是对的,毕竟在RAG链路里,生成阶段已经调了一次大模型,再加一个改写调用,延迟确实会上升。但有几个办法可以压下来:

  • 用一个小模型做改写。改写任务本身不需要100B以上的超大模型能力,7B级别的本地模型足够。甚至某些场景里,一个蒸馏过的3B模型都能改得像模像样。这样改写阶段的耗时能压在300毫秒以内。
  • 加一层“代词触发”判断。如果当前问题里根本不包含任何指代词(“他、她、它、这个、那个、该、这、那、其”等),直接跳过改写调用,原样返回。我统计过很多真实对话,大约40%的追问是直接带实体的,比如“张三的净利润是多少”,这类根本不需要改写。这个简单判断能省下大量without成本。
  • 缓存复用。连续两轮用户问题相似度极高时,可以用一个简单的字符串哈希做缓存,但要注意对话历史变了,改写结果可能不同,所以缓存键要包含“历史最后两轮+当前问题”。这个缓存命中率不高,但也值得做。

我线上用的是本地部署的一个7B模型做改写,加上代词触发和限制历史长度,平均改写耗时就300毫秒左右。对问答体验来说完全能接受。

4. 踩坑实录:那些文档里没写的问题

这部分是我最想分享的。因为网上教程一般到“加个rewrite模块”就结束了,但真正跑起来,你会发现一堆细节问题,每个都能让你的准确率掉几个点。

4.1 改写过头:把不该替换的也替换了

我遇到最频繁的问题是过度改写。大模型在“尽量满足用户要求”的驱动下,很容易把一些本来就明确的问题改得面目全非。比如用户问“这个方案的优点和缺点分别是什么”,历史里聊过A、B两个方案,模型可能自作聪明把“这个方案”强行指定为A方案,但实际上用户上下文里“这个方案”指的是他最新在看的B方案。

这类问题的根源是指代对象在历史中不唯一。解决方法是两招:第一,在prompt里明确要求“如果不能确定指代对象,保持原问题”;第二,在后处理里做一个“只替换代词、不重写整句”的约束。我试过一个办法:把改写结果里跟原问题完全不同的部分用diff标出来,如果改动面积超过一半,就退回原问题。这个启发式在很多场景都能止住误改写。

第二个过度改写场景更恶心:模型把历史里的某个实体强行写进问题里,但这个实体在当前问句里根本不该出现。比如用户问“那后来呢”,历史里聊了项目A的进度,又聊了项目B的延期,模型可能会改成“项目B后来怎么样了”,但用户其实是在问项目A。这类case单靠prompt治理很难根除,只能靠“改写后query与原query双召回”来兜底,两条路都召回,重排时再决胜负。

4.2 历史太长,改写的信号反而被稀释

好多人以为历史留得越多越全,其实不是。我把最近6轮历史全部传入时,发现模型在长上下文里找指代对象的准确率反而下降了。尤其是当历史里同时出现“某公司”“某团队”“某项目”多个候选实体时,模型容易把较早出现的实体认成指代对象,因为长上下文里模型对早期信息的注意力权重不够。

我后来做的优化是:历史文本里按时间倒序排列,最早的放最后;同时把“最近的指代候选实体”显式标出来。比如我会在拼历史时额外加一行“对话中提到的关键实体:张三、B项目、某公司财报”。这个显式实体列表能大幅提升指代匹配的准确率。说白了就是帮模型把候选集合缩小。

4.3 改写结果很好,但检索还是不准

有一个阶段我特别困惑:改写后的问题肉眼看着没毛病,实体都对,但向量召回的效果就是上不去。后来一排查发现,原因是改写后的问题里夹杂了太多对话历史的修饰词,比如“之前讨论过的”“刚才提到的”,这些词在embedding里占据了不少权重,干扰了核心实体的语义表达。

解决方案有两个,我建议都做。一是改写后清洗:把“刚才”“之前”“刚刚”“提到的”这类时序衔接词直接删掉。二是让改写结果更偏“检索友好”:我改prompt的引导语,要求输出“适合关键词检索的简明问句”,而不是完整的口语问题。比如“张三今年第一季度的净利润数据是多少”比“我想知道张三在刚才我们讨论的那个季度里净利润表现如何”更合适。

4.4 评测怎么做才可信

这个改动到底有没有用,不能靠感觉。我建议搞一个专门针对多轮指代场景的评测集,50条左右就够了,覆盖人称指代、指示代词、省略主语、跨轮跳跃这四类。每条case包含“历史多轮对话 + 当前问题”,然后人工标注“改写后应该是什么”。评测时跑一遍改写模块,把改写结果和人工标注做对比,计算“改写准确率”。

只有改写准了,才能继续测下游的召回准确率和生成准确率。我在做第二轮优化时,就是先单独卡这个改写准确率指标,提到95%以上之后,整个系统的多轮问答稳定性才有了质的提升。如果没有这个评测集,所有的优化都是盲人摸象,你不知道改的是好是坏。

评测集里我会故意放一些“陷阱”case:历史里同时出现两个公司、两个方案;当前问题的指代词出现在中间轮次而不是上一轮;用户提问是极度省略的“那价格呢”。这些case最能检验改写模块的语义理解能力。

5. 还可以怎么扩展:从改写走向“全链路多轮增强”

在多轮指代消解跑通之后,我发现这套“改写前置”的思路还能继续延伸,不只是消解指代,还能解决其他跟多轮上下文相关的问题。这里分享几个我验证过的方向。

第一个是意图补全。除了指代词,用户还经常省略动词或者宾语,比如“那退款呢”,光看这句根本不知道要检索什么,但结合历史“某商品有质量问题,怎么申请退款”,改写模块可以把这句话补成“某商品质量问题的退款流程是什么”。这类补全其实和指代消解是同一套机制,prompt稍微调整一下就能覆盖。

第二个是实体别名归一。知识库里同一个实体可能有多种叫法,比如“某公司”“某集团”“某控股”其实指向同一个主体。多轮对话里用户可能今天叫“某公司”,明天叫“某集团”。改写模块结合历史,能把“他们”统一成“某公司”,甚至可以顺便做一轮实体归一,把query里的别名统一到知识库标准名。这个对检索精度的提升也很明显。

第三个是答案一致性增强。如果最终的QA模型比较弱,比如本地小模型,在生成时对“他怎么办”这种问题容易产生幻觉。把改写结果同时传给生成阶段,让它“基于改写后的问题作答”,能显著减少答非所问的情况。整个链路变成“改写模块先翻译成人话,生成模型再输出答案”,逻辑上更顺。

第四个是多层次缓存与快速路径。对于那些“明确带实体、不需要改写”的问题,系统可以直接跳过LLM改写调用,直接走检索。通过一个二元分类器(有无指代/是否省略)做前置判断,能减少很多无效的模型调用。这个分类器可以是一个轻量级的规则+词典,也可以是一个BERT分类器,看你的资源情况。

6. 一点实战心得,以及最后想说的

整套方案做下来,我最大的体会是:多轮指代消解不是RAG的锦上添花,而是多轮问答场景下的必需品。你可以在技术选型上有不同偏好,但一定要把“当前问题重写成语义自包含”这一步放在心上,否则向量检索的性能上限被卡死,怎么调embedding都没用。

我也要坦白说,这个方案不是万能的。它要求改写模型具备基本的指令跟随能力,如果是很差的小模型,改出来的句子可能比原来还糟。所以真的落地前,务必先跑一个小规模评测,看看改写质量再决定。另外,改写的收益在“知识库检索强依赖实体词”的场景下最大,比如企业知识库、文档问答、客服助手。如果你的场景是开放闲聊,用户根本不在乎准确实体,那这步优化可以不投入太多精力。

最后分享一个小技巧:上线之后,把“改写前后的query”记到日志里,定期抽检。我几乎每次都能从中发现新的典型case,比如某种指代是模型系统性改错的,某个实体是历史里频繁出现的。这个日志驱动迭代的循环,比闷头调prompt高效得多。多轮对话的坑就是这么一个个填平的,没有银弹,只有持续打磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询