"帮我查一下上周华东区的销售数据。”“它和这个月比怎么样?”“为什么不理想?”——这三连问放到任何一个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_factory=list) # 提到的实体 metrics: List[str] = field(default_factory=list) # 指标名称 time_range: Optional[Dict[str, str]] = None # 最近的时间范围 compare_target: Optional[str] = None # 对比对象 history: List[Dict] = field(default_factory=list) # 最近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我固定为0,max_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 | 轮次 | 用户问句 | 期望改写 | 期望命中的知识片段 |
|---|---|---|---|---|
| S01 | 1 | 华东区7月的销售额是多少 | 华东区7月的销售额是多少 | 2025-07华东区销售月报 |
| S01 | 2 | 它比6月涨了多少 | 华东区7月销售额相比6月销售额的增长幅度 | 2025-07/2025-06销售对比分析 |
| S01 | 3 | 为什么涨这么多 | 华东区7月销售额相比6月大幅增长的原因分析 | 华东区7月增长归因分析 |
| S02 | 1 | 某产品的用户投诉集中在哪些方面 | 某产品用户投诉集中在哪些方面 | 某产品投诉分布报告 |
| S02 | 2 | 那售后政策呢 | 某产品售后政策是什么 | 某产品售后政策文档 |
评测时我分开统计三组指标:一是改写准确率,拿改写后的查询和人工期望改写对比,看语义一致比例;二是检索命中率,看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里加了一条规则:“如果当前问题包含否定纠错意图,输出原文并标注correction=true”,然后在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,其实都是指代丢在了改写这一关。