免费获取学习方案
ARTICLE DETAIL

资讯详情

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

RAG多轮问答指代消解实战:让检索不再被“它”带偏

RAG多轮问答指代消解实战:让检索不再被“它”带偏 帮我查一下上周华东区的销售数据。”“它和这个月比怎么样”“为什么不理想”——这三连问放到任何一个RAG问答系统里大概率会变成一场灾难。第一问能答对第二问开始检索出一些莫名其妙的东西第三问直接让大模型开始一本正经地胡说八道。这不是检索器的问题也不是生成模型的问题而是对话状态在第二、第三轮就断了。“它”指的是什么“这个月”是跟哪个时间段比“为什么不理想”里的“不理想”评价的是哪个对象的哪个指标如果系统不能把这些问题翻译成一句自包含的、没有歧义的查询那后面无论召回多准、生成多强都等于让一个记性不好的人去图书馆找一本他早就忘了名字的书。我给RAG问答系统加了一个“多轮指代消解层”之后实测二轮以上的检索命中率从55%左右拉回到了80%。这篇文章就聊聊我在这套方案里做的设计、踩过的坑以及每一步背后的取舍。如果你正在做智能客服、知识库问答、对话式搜索这篇文章应该能帮你少走不少弯路。核心就一句话不要让大模型去猜“他/这个/那个”是谁先把指代问题解决在检索之前。1. RAG问答为什么会被一个“他”卡住1.1 三个典型的翻车现场我先还原一下真实对话里最常见的三种翻车模式。第一种是代词指代。用户先说“我上个月的理赔金额为什么这么高”接着问“它比前两个月多在哪里”。如果直接拿“它比前两个月多在哪里”去做向量检索embedding出来的结果跟“理赔金额”基本没什么语义重合因为字面上完全没有“理赔”这个词。向量检索模型能捕捉语义相关性但它不是推理引擎它不知道“它”就是“理赔金额”。第二种是省略指代。用户问“华东区的销售额是多少”下一句是“那华南区呢”。“那华南区呢”这句话本身不是一个完整的问题检索器拿到的只是一句残缺的短语去知识库里比对分数都很低。这在实际对话里比代词翻车更常见因为人说话默认对方记得上下文但检索器并不记得。第三种是主题延续型指代。用户前面聊了很久的“某款产品的用户反馈”后面突然来一句“为什么大家都这么说”。这里“都这么说”所指的对象分散在前几轮的多条信息里光靠当前轮次根本无从检索。这三种情况都属于自然语言处理里常说的“指代消解”Coreference Resolution。传统上它是NLP里的一个独立任务但在RAG场景下它的目标更明确把对话中所有依赖历史才能理解的表达改写成一串不依赖历史也能理解的、适合检索的查询语句。1.2 为什么直接拼接历史对话也不行有人可能会说把最近几轮对话拼在一起喂给检索器不就行了我最初也这么干过实测效果很差。第一拼接后的query太长。向量检索模型一般对输入长度有上限文本一长embedding向量会被稀释真正关键的实体和关系反而不突出。这就像你拿着一份三页纸的聊天记录去图书馆问管理员“帮我找找这本书”管理员根本不知道你要找哪本书。第二历史里的信息并不是都对当前问题有用。把五轮无关的闲聊拼进去会让检索结果被次要主题带跑。比如用户聊了三轮产品功能又聊了一轮物流最后问“那这个功能怎么收费”检索系统很可能把物流的信息也搜出来干扰排序。第三拼接方式本身没有结构性。谁是谁的时间、谁是谁的对象、哪个是当前问题模型分不清。大模型读这一大坨文字时注意力会被无关内容分散最后生成的答案经常是几个话题的缝合怪。所以不能靠“多给信息”来解决而要靠“精准改写”来解决。把当前问题里的指代成分替换成它们真正所指的实体、时间、指标和关系再做检索效果才会稳定。1.3 指代消解层的职责边界在做方案设计之前我先把“指代消解层”的职责边界划清楚。它不需要做完整的对话状态跟踪Dialogue State Tracking不需要维护用户意图、情绪、任务完成度这些信息。它只做两件事第一判断当前用户问题里有哪些成分是依赖上下文的第二从对话历史和会话状态里找到这些成分对应的实体信息把它们补全到当前问题里输出一句或者多句自包含的检索查询。这个边界很重要。如果你什么都想做最后会陷入槽位设计的大坑。比如你要维护用户偏好、业务约束、权限范围、多轮任务上下文这个系统的复杂度会指数级上升。但如果只聚焦在“让当前查询变得可检索”这件事上工程量和收益的比值就很划算。这也是我推荐所有RAG项目先加这一层的核心理由它做的是翻译不是记忆。2. 多轮指代消解的三条技术路线2.1 方案A让大模型改写当前问句最直接的做法就是再加一次LLM调用让大模型根据历史对话把当前用户问题改写成一个自包含的查询。比如输入历史对话和当前问题“它和这个月比怎么样”输出“华东区7月的销售额和8月的销售额相比变化趋势和差异如何”。这种方案的好处是简单、通用、上手快。任何RAG框架都能在检索之前插入一个改写节点不需要改动向量库和生成链路。成本可控一次改写调用大概几百毫秒。它很依赖大模型对上下文的阅读理解能力但现代大模型做这种短文本改写已经相当可靠。它的缺点也明显。大模型偶尔会把历史里没有出现的实体“脑补”进去比如用户没说过“华东区”但模型因为前文信息不足硬是编了一个区域名。另外如果对话历史很长模型可能会受无关信息干扰改写出一个看似通顺但方向错误的问题。2.2 方案B显式会话状态与槽位填充第二种方案是给每个会话维护一个结构化的状态对象用字段记录当前讨论的主题、实体、指标、时间范围、比较对象等信息。每来一轮新问题先做解析和槽位更新再基于最新的状态生成检索查询。比如状态对象里记录着topic: 销售数据、region: 华东区、metric: 销售额、date_range: 2025-07。当用户问“那这个月呢”系统读到状态里的主题和时间字段就能把问题补全成“2025-08华东区销售额数据和2025-07相比如何”。这个方案的优点是非常稳定可调试、可解释。槽位怎么填、字段怎么更新都是逻辑可控的不会出现模型幻觉。缺点在于槽位设计成本很高每个业务领域都要预设好字段应对不了开放域问题。如果用户问的东西不在你预设的槽位里这套机制就等于没有。对于纯文档问答、FAQ问答这类窄域场景槽位方案很香但对于开放式的知识库问答它不够灵活。2.3 方案C混合路线先用状态约束再用大模型改写我实际落地采用的是混合路线。会话状态做一层“弱约束”大模型改写做“强执行”。步骤如下先尝试从当前问题和历史里抽取结构化状态主题、实体、时间、指标、对比对象抽取失败不要紧状态可以为空然后把状态和原始对话一起注入改写Prompt让大模型参照状态信息来做改写最后如果改写结果为空或超时退还原始query不阻塞主线流程。这个方案兼顾了灵活性和可控性。结构化状态给大模型提供了确定性的上下文锚点减少幻觉概率同时又不要求状态必须完整保留了大模型的语义理解能力。实测下来纯改写方案的指代召回率大概在70%左右混合方案能到85%以上。选型时我的建议很简单先上方案A跑通链路如果业务场景窄且稳定再加方案B的槽位做约束如果业务是开放域问答直接上方案C。别一上来就做复杂状态机RAG项目里最大的成本永远是“你以为用户会这么问但用户就是不这么问”。3. 实操给RAG管道加一层指代改写3.1 基础组件准备这套方案不需要额外的重型组件复用现有RAG基础设施即可。需要的大模型接口负责改写和生成向量数据库存知识库切片embedding模型负责把改写后的查询向量化。我用的是OpenAI兼容接口加本地的bge-m3做embedding向量库用的Chroma和Qdrant。在mac上开发调试的话Chroma配sqlite后端最省事零配置直接跑起来不用单独部署服务。如果你还没搭RAG知识库最快的路径是本地起一个FastAPI服务用bge-m3生成切片向量存入Chroma查询时TopK取回后拼Prompt交给大模型。多轮指代消解就作为查询入口和向量检索中间的一个函数完全不影响现有架构。3.2 会话状态的数据结构设计我用的会话状态对象长这样from dataclasses import dataclass, field from typing import Optional, List, Dict dataclass class SessionState: topic: Optional[str] None # 当前对话主题 entities: List[str] field(default_factorylist) # 提到的实体 metrics: List[str] field(default_factorylist) # 指标名称 time_range: Optional[Dict[str, str]] None # 最近的时间范围 compare_target: Optional[str] None # 对比对象 history: List[Dict] field(default_factorylist) # 最近N轮原始对话 truncated_summary: str # 长对话的压缩摘要 turn_count: int 0字段设置的原则是“够用就好”。我不存用户画像、情绪等业务字段只存跟检索相关的核心维度。history字段我默认保留最近三轮原文超过部分用truncated_summary做摘要。因为改写层真正需要的历史信息并不长保留太多反而会让模型抓不住重点。这个结构在每个轮次结束后更新。更新逻辑也很简单从当前问题里抽实体、抽指标、抽时间如果抽到了就覆盖旧值如果没抽到就保留旧值。这保证了“那华南区呢”这种省略句能继承到上一轮的topic和metric。3.3 改写层的Prompt设计改写层的Prompt是整个方案的核心我调了好几版最终稳定在这套结构上。你是一个对话检索查询改写助手。给定对话历史和当前用户问题你的任务是把当前问题改写成一个或多个自包含的检索查询。 要求 1. 用具体实体、指标、时间段替换代词和省略部分。 2. 不要补充原文和历史中不存在的信息。 3. 如果当前问题有多个意图拆成多个查询。 4. 只输出JSON格式为 {queries: [query1, query2]} 对话历史 {formatted_history} 会话状态辅助信息 {state_summary} 当前用户问题 {user_query}这里有两个关键细节。第一state_summary不是把整个SessionState原样丢进去而是用一句话描述状态比如“当前话题销售数据已涉及实体华东区、华南区最近指标销售额时间范围2025-07”。这样给模型的信号最清晰比丢一坨JSON效果好得多。第二最后一句“只输出JSON”非常重要。我一开始让模型自由发挥结果改写结果里混进了各种解释性文字解析起来很头疼。加了JSON格式约束后解析稳定性大幅提升。为了进一步防止非JSON输出代码里还加了正则兜底如果提取不出JSON就退回原始query。调用参数上temperature我固定为0max_tokens设成200。改写这件事不需要创造性需要的是稳定复现。如果用的是OpenAI兼容接口记得在系统提示里加一条“如果你不确定指代对象保持原样输出”用来减少模型硬猜的概率。3.4 多问题拆分一句话里有两个“这个”还有一个细节很容易忽略用户一句话里可能同时包含多个需要消解的指代而且指向不同对象。比如“这个功能很好用但那个功能为什么没人用”就涉及两个实体。改写层需要支持多查询输出。我在Prompt里明确要求“如果当前问题有多个意图拆成多个查询”然后在代码里对queries列表做循环检索再把每个查询的TopK结果合并去重后送进重排器。多查询拆分的另一个好处是解决embedding模型的长度限制问题。改写后的查询如果包含完整上下文可能变得很长这时候拆成几个短查询分别检索召回率反而更高。我在测试集上验证过拆成两条查询后平均召回率比单条长查询高7%左右。3.5 在RAG链路中接入的位置与降级策略接入位置我放在“用户输入”和“向量检索”之间。顺序是用户输入 → 更新会话状态 → 调用改写层 → 循环检索 → 合并结果 → 重排 → 生成回答。步骤很简单但接入时有两个坑。第一个坑是缓存。同一个会话里用户可能反复问相似问题。我在改写层外面加了一层Redis缓存key是session_id turn_count user_query命中直接返回上一次的改写结果。实测命中率在25%左右省下的延迟和token成本挺可观。第二个坑是降级策略。改写层可能超时、可能返回空JSON、可能改写出一个完全乱来的结果。无论哪种情况都不能让整个问答流程挂掉。我的降级策略是改写失败就用原始query做检索同时在日志里打一个rewrite_failed标记。不做降级的话一次模型偶发故障就会让整个客服系统不可用这是线上系统完全不能接受的。4. 效果怎么评估先建测试集再谈调优4.1 专门为指代消解建一个评测集很多人做RAG项目最大的问题是没有测试集全靠感觉调参。多轮指代消解尤其需要测试集因为这玩意儿不建测试集你根本说不清自己改好了没有。我的测试集里每个case包含以下几列会话ID、轮次序号、用户原始问句、期望改写后的查询、期望检索命中的知识片段。下面是我测试集里的几个样例会话ID轮次用户问句期望改写期望命中的知识片段S011华东区7月的销售额是多少华东区7月的销售额是多少2025-07华东区销售月报S012它比6月涨了多少华东区7月销售额相比6月销售额的增长幅度2025-07/2025-06销售对比分析S013为什么涨这么多华东区7月销售额相比6月大幅增长的原因分析华东区7月增长归因分析S021某产品的用户投诉集中在哪些方面某产品用户投诉集中在哪些方面某产品投诉分布报告S022那售后政策呢某产品售后政策是什么某产品售后政策文档评测时我分开统计三组指标一是改写准确率拿改写后的查询和人工期望改写对比看语义一致比例二是检索命中率看Top5检索结果里是否包含期望命中的知识片段三是端到端准确率看最终生成的回答是否覆盖了标准答案里的关键信息。4.2 我测出来的真实数据我用一套30个多轮case的测试集跑了对比实验。不做任何指代消解处理时第二轮检索命中率从第一轮的83%直接掉到55%第三轮更是只有40%左右。加上改写层后第二轮检索命中率回到78%第三轮在72%左右。端到端回答准确率从44%提升到68%。这个数据说明了一个很朴素的事实多轮对话的质量衰减很大一部分不是生成模型的错而是检索入口就已经带偏了。调参过程中我发现几个规律。第一改写Prompt里如果加了“保持简洁”这句话改写质量反而下降因为模型为了简洁会丢失实体限定词。第二temperature用0不是玄学改写任务真的需要确定性。第三embedding模型的选择对改写后查询的召回有很大影响bge-m3在中文长查询上的表现明显优于一些英文占优的模型。4.3 用LLM做改写质量校验改写层另一个容易埋雷的地方是“口胡”。模型可能改写出一个既不像原文、也不符合历史事实的查询。比如原文说的是“华东区”模型改写成“华东大区”甚至“华南大区”这会造成检索结果完全错位。我在改写层后面加了一个可选的质量校验环节让另一个LLM调用判断“改写后的查询是否忠实于原始问题和历史”。如果判定不忠实就退回原始query。这一步会增加200-400ms延迟所以我只在纠错场景下开启平时通过日志抽检。如果你不想加额外调用可以设置一个简单规则改写后的查询必须至少包含一个原文或历史里出现过的实体词否则视为改写失败。这个规则能挡掉一部分模型幻觉。5. 工程化落地阶段我踩过的坑5.1 改写结果里出现幻觉实体这是最让我头疼的问题。模型在不确定指代对象时倾向于“脑补”一个看起来合理的实体。比如用户前文聊“甲产品的退货率”模型改写“为什么它这么高”时居然输出“甲产品的退货率为什么这么高分析乙产品退货率高的原因”凭空多出来一个“乙产品”。排查方法有两种一种是在Prompt里反复强调“不得补充原文和历史中不存在的信息”另一种是在历史剪裁时加入“实体白名单”。我把每轮历史里抽取到的实体存进了SessionState的entities字段改写时把这个名单传给模型并要求“改写后的查询只能引用名单里的实体”。加了白名单约束以后幻觉实体出现的概率明显下降。5.2 历史里有噪音导致改写出错对话历史不是每句话都对当前问题有用。有一次用户先问了两轮“物流配送超时率”又突然问“那这个政策现在还有效吗”这里的“这个政策”其实指的是前几轮里提过的“七天无理由退货政策”但因为中间隔了几轮无关话题模型被“物流配送”带偏改写出了“物流配送政策是否有效”这种完全错误的结果。解决办法是给history字段加一层筛选不是把所有历史都丢给改写层而是先计算历史各轮内容和当前问题的语义相似度只保留相似度最高的两三轮。这一步可以用简单向量相似度来做开销不大效果提升明显。5.3 否定歧义怎么处理“不是那个意思”“我不是说这个”这类句子天然含有指代否定。你没法通过简单替换实体来改写它们因为它们需要理解对话中的纠错意图。我对这类句子的处理是在改写层Prompt里加了一条规则“如果当前问题包含否定纠错意图输出原文并标注correctiontrue”然后在RAG流程里对这种查询走特殊分支直接结合上一轮生成结果做修正回答不经过常规检索。虽然处理得不算完美但至少不会翻车翻得太难看。5.4 长对话的上下文压缩当对话超过五轮以后原始历史拼接会越来越长。我发现改写层在这种长上下文中表现反而变差因为无关内容太多。我的处理方式是每三轮就把历史原文做成一个摘要存到truncated_summary字段之后改写时不再传原始历史只传“摘要加最近两轮原文”。摘要用一个小模型离线生成就行不用占用在线延迟。这个策略帮助我把第三轮的端到端准确率又往上提了5个百分点。5.5 排查问题必看的trace日志调试多轮指代消解最忌讳靠猜。我后来在改写层每个关键节点都打了日志原始query、会话状态、改写结果、是否降级、检索Top5片段。有了这些trace排查问题变得非常直接。比如用户反馈“第二轮回答不对”我拉日志一看发现改写层把“它”错误地指代成了历史里的某个对象几秒钟就能定位到是哪一层出了问题而不是像以前一样先去怀疑向量库、怀疑生成模型、怀疑提示词排查半天。6. 指代消解之外知识库类型的选择与后续扩展6.1 RAG知识库和结构知识库到底什么关系顺着这个项目说一个容易被忽略的点RAG知识库并不只有文本向量这一种形态。现在大家聊“RAG知识库”默认指把文档切块、embedding、存向量的文本向量库。它擅长模糊匹配和语义召回适合FAQ、规章制度、产品文档这类“说人话”的文本。但它的短板是缺乏结构化关系能力你问“哪些产品属于A类且价格高于B品牌”纯向量检索就很难精确回答。结构知识库则是指以知识图谱KG、ontology本体为核心的知识组织方式用节点和边表达实体之间的关系。它的优势是精确、可解释、支持多跳推理短板是构建成本高依赖人工整理或半自动抽取且对自然语言变体不敏感。这两者的关系不是替代是互补。文本向量库负责大规模召回结构知识库负责关系约束和精确推理。现在热门的GraphRAG、Ontology-enhanced RAG本质上就是让RAG不仅能“找到相关文本”还能“理解实体之间的关系”。回到指代消解这个场景如果在改写层引入知识图谱的实体链接能力把“这个功能”先链接到图谱中两个候选实体再结合历史判断具体指哪一个消除歧义的准确率会比纯文本判断高不少。6.2 什么时候需要给指代消解接上KG如果你处理的业务里实体特别多、而且实体之间存在容易被混淆的关系纯文本改写就不够用了。比如企业内部知识库里有“产品A”“A系列产品”“A产品线”这些相近概念用户说“它的销量”模型很难确定是哪一个。这时候我建议在改写层加一个“实体链接后验”步骤改写后先用图谱里的实体别名表做匹配如果发现改写查询中的实体名和图谱里的标准实体不一致就替换成标准实体名再检索。这是Ontology-enhanced RAG在对话场景下的典型应用。如果业务实体少、关系简单直接上KG反而增加维护成本没有必要。6.3 在mac上快速搭一个可用的RAG知识库很多朋友私信问我用mac做开发怎么搭RAG知识库。我的路径很简单先用Chroma加sqlite后端做本地向量库零服务依赖embedding模型用bge-m3通过FastAPI本地跑改写层和生成层都调到OpenAI兼容接口上本地调试时可以用小模型代替。整套东西在16G内存的mac上可以顺畅跑起来关键是不要同时加载多个大模型到内存里不然容易爆内存。6.4 后续可以做的扩展这个改写层做到现在我认为还可以往三个方向继续扩展一是做多语言指代消解中文、英文、中英混杂场景下的表现差异很大二是接入语音对话场景口语里的省略和指代比书面语更严重效果提升空间更大三是把改写层和会话记忆的长期化结合起来让跨天会话里的指代也能被正确理解。多轮指代消解不是一次性的模型能力而是一个可以持续打磨的工程模块。我个人在实际操作中的体会是RAG问答里最值钱的工作往往不在模型和向量库这些“听着高大上”的部分而在于把对话入口整理干净。加一个指代改写层看似只是多了一次LLM调用但它让检索从“瞎猜用户要什么”变成了“拿着一张写清楚问题的纸条去查资料”效果提升立竿见影。最后再分享一个实战小技巧上线前一定要把改写层的输入输出日志存下来你会惊讶地发现大量你以为是“知识库没内容”的坏case其实都是指代丢在了改写这一关。
返回列表