免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

RAG多轮对话指代消解实战:用LLM改写Query提升召回准确率

RAG多轮对话指代消解实战:用LLM改写Query提升召回准确率 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[对话第{i1}轮] 用户: {user_part}) if assistant_part: lines.append(f助手: {assistant_part}) return \n.join(lines)这段代码的作用是把历史整理成一眼能看懂的纯文本。注意我这里对assistant答案做了截断只留前100个字符。为什么这么干后面会讲这里先记住一个原则改写的核心是“找到指代对象的线索”不是把整段答案背下来。3.2 改写Prompt的工程细节prompt设计是这套方案里最值得打磨的部分。我踩了不少坑总结出一个比较稳的模板核心是五条约束只在存在指代时才改写。如果没有指代、没有省略原样返回不要画蛇添足。必须从对话历史中寻找实体不能自己编造历史里不存在的信息。保留原问题。输出结构里同时带上original和rewritten方便后期排查。不要解释直接给JSON。如果历史中没有明确指代对象保持原问题宁可漏改也不要错改。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(historyhistory_text, questionquestion) 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高效得多。多轮对话的坑就是这么一个个填平的没有银弹只有持续打磨。
返回列表