多轮对话里用户问“那它呢”,RAG 检索前怎么做指代消解?
简化版
多轮对话里,第二轮之后的问题经常省略主语、用代词指代前文,比如「那超期了呢」「它支持私有化吗」,直接拿去检索几乎等于随机。做法是检索前带上最近几轮对话历史,让模型把当前这句改写成一个不依赖上下文、能独立理解的完整问题(也叫 Query Condensation),再用它去检索。要点有四个:第一轮没有历史就跳过;历史只取最近几轮;模型返回空或跑偏时退回原问题;改写结果只用于检索,不当事实,也要记录下来方便排查。
详细版
处理流程:
- 判断有没有历史:第一轮直接用原问题检索,不做无谓的模型调用;
- 取最近几轮对话,按「用户:…… / 助手:……」的格式拼成历史;
- 用专门的改写模板,要求模型只输出改写后的完整问题,不回答、不解释;
- 校验输出:为空就退回原问题,并去掉模型偶尔加上的前缀;
- 用改写后的问题去检索,把改写结果和检索结果一起记入日志。
和其他改写方式的区别:多查询扩展要保留原问题一路兜底,而指代消解的原问题本身没有信息量,检索只用改写后的那一条。
完整版教学
一、为什么「那它呢」不能直接检索
看一段真实的多轮对话:
用户:GTE-3000 的保修期是多久?
助手:GTE-3000 整机保修 24 个月。
用户:那超期了呢?
第三句拿去检索,送进向量模型的只有「那超期了呢」5 个字:没有型号,没有「保修」这个主题词。向量检索编码出的向量指向不明,召回的可能是任何提到「超期」的片段,比如逾期付款、借阅超期;BM25 能匹配上的只有「超期」一个词,同样找不准。
多轮对话里,第二轮之后的问题大多不能直接拿去检索。 它们的完整含义分散在历史里,检索器看不到历史。
二、指代消解要产出什么
目标是把当前问题改写成一个独立问题(standalone question):只看这一句,不看历史,也能知道在问什么。
| 原问题 | 历史里的关键信息 | 改写后 |
|---|---|---|
| 那超期了呢? | GTE-3000、保修期 24 个月 | GTE-3000 超过 24 个月保修期后如何处理? |
| 它支持私有化吗? | 上一轮在聊「企业版」 | 企业版是否支持私有化部署? |
| 第二种方案呢? | 上一轮列出了三种部署方案 | 第二种部署方案(容器化部署)的具体要求是什么? |
注意改写和回答是两件事:改写只负责把问题补完整,不能顺手把答案写进去。一旦模型在改写里写上「超期后收费维修」这样的推测,检索就会朝着这个推测去找,结果被带偏。
三、改写模板怎么写
一个可用的模板:
下面是一段对话历史,请把用户最后这句话改写成一个不依赖上下文、可以独立理解的完整问题。
只输出改写后的问题本身,不要输出任何解释。
对话历史:
用户:GTE-3000 的保修期是多久?
助手:GTE-3000 整机保修 24 个月。
用户最后这句话:那超期了呢?
三个细节:历史要标明「用户」和「助手」,模型才分得清谁说了什么;明确要求只输出问题本身,否则模型常会加上「改写后的问题是:」这类前缀;不要求模型回答,避免把推测混进检索词。
四、历史取几轮
历史不是越多越好:
| 取的历史 | 好处 | 问题 |
|---|---|---|
| 最近 1 轮 | 最快、最省 Token | 用户跨两轮指代时补不全,比如「回到刚才那个型号」 |
| 最近 3 轮(6 条消息) | 覆盖大多数指代 | 多一点 Token,一般可以接受 |
| 全部历史 | 信息最全 | 越聊越长,改写越来越慢,早期话题还会干扰当前问题 |
按每轮对话 200 字估算,3 轮历史约 1200 字,加上模板本身,改写一次的输入在 1500 字左右;如果带上 20 轮完整历史,就要 8000 多字,而其中大部分和当前问题无关。取历史时要注意拿的是最近的几条:按时间倒序取、再反转成正序,而不是取出最早的几条。
易错点:历史取得越多,不只是成本变高,模型也更容易把早期话题里的实体错误地套到当前问题上。
五、什么时候跳过,怎么兜底
指代消解要多调一次模型,通常几百毫秒,所以要避免白调:
- 第一轮没有历史,直接用原问题检索;
- 模型返回空字符串,退回原问题,不能让检索拿着空字符串去跑,向量模型编码空文本要么报错、要么返回无意义的向量;
- 模型输出带了编号、引号或说明文字,先清洗再用;
- 改写结果明显偏离,比如出现了历史和当前问题里都没有的实体,可以丢弃改写结果、退回原问题。
六、要不要保留原问题一起检索
多查询扩展会把原问题放在第一条,和改写出的几条一起检索,原因是扩展可能跑偏,原问题是兜底。指代消解的情况不一样:
多查询扩展 原问题「能装在自己机房吗」本身有完整含义 → 保留,作为兜底
指代消解 原问题「那超期了呢」本身没有信息量 → 保留它只会引入噪声
所以指代消解一般只用改写后的问题检索,靠第五节的兜底规则应对改写失败。反过来,如果用户的追问本身就是完整的(比如「GTE-5000 的保修期呢」),模型改写后的问题和原问题几乎一样,这一步也不会造成损失。
七、改写结果怎么用、怎么查
改写后的问题只用于检索。生成答案时,仍然要把原问题和对话历史交给模型,让回答自然地衔接上下文,而不是生硬地复述改写句。
排查问题时,改写结果是第一个要看的地方:检索结果不对,先看改写有没有把问题补对。所以每条回答都应该记下「实际拿去检索的问题」,评测时也可以专门准备一批多轮对话用例,检查改写后的问题是否完整、是否引入了不存在的信息。
八、常见误区与追问
- 误区:把全部对话历史拼进去一起检索就行。 历史里大量内容和当前问题无关,拼进检索词会稀释语义,还会让检索命中早期话题的片段。
- 误区:改写时顺便让模型把答案写出来。 改写只负责补全问题,一旦混进模型的推测,检索会朝着推测的方向找,结果被带偏。
- 误区:指代消解也要保留原问题一路兜底。 「那超期了呢」本身没有信息量,保留它只会引入噪声;兜底应该放在「改写为空或明显跑偏时退回原问题」上。
- 误区:每一轮都要做指代消解。 第一轮没有历史,白调一次模型只是多花几百毫秒和一次调用额度。
- 误区:历史取得越多,改写越准。 历史越长越慢,早期话题里的实体还可能被错误地套到当前问题上,一般取最近几轮就够。
- 追问:指代消解和多查询扩展能不能同时开? 可以组合,但检索次数会继续翻倍,而且两者产出形态不同,工程上常做成单选,先用评测数据确认哪种对自己的场景更有效。
- 追问:生成答案时用改写后的问题还是原问题? 检索用改写后的问题;生成时仍然给模型原问题和对话历史,回答才能自然衔接上下文。
九、加强记忆
多轮对话的追问往往缺主语、靠代词,「那超期了呢」拿去检索等于随机,所以检索前要先做指代消解:带上最近几轮「用户 / 助手」格式的历史,让模型只输出一个不依赖上下文的完整问题,不回答、不解释。四条规则记住:第一轮没历史就跳过,历史只取最近几轮并按时间正序排好,模型返回空或跑偏就退回原问题,改写结果只用于检索并记入日志。和多查询扩展不同,指代消解的原问题没有信息量,检索只用改写后的那一条;生成答案时仍用原问题加历史,回答才自然。
项目实战落地
项目里怎么做的
《AI Agentic RAG高级企业知识库平台》把指代消解做成查询改写的三种方式之一,检索策略里把 rewrite_type 选为「指代消解」即可启用:
- 改写模板。 Prompt 模板
COREFERENCE_RESOLVE在后台维护:「下面是一段对话历史,请把用户最后这句话改写成一个不依赖上下文、可以独立理解的完整问题。只输出改写后的问题本身,不要输出任何解释。」 - 历史来源。 多轮问答时从当前会话取最近几条消息,条数由问答应用上的
max_history控制,默认 6 条;历史按「用户:…… / 助手:……」拼进模板。 - 跳过与兜底。 没有历史时直接返回原问题,不调模型;模型返回空内容时退回原问题。
- 只产出一条查询。 和多查询扩展返回多条不同,指代消解只返回改写后的一条,拿它走完整的混合检索链路。
- 留痕。 每条回答的消息记录里存下实际拿去检索的问题
rewritten_query,排查时能直接看到改写结果。
为什么这样取舍
- 改写方式做成单选。 三种改写的产出形态不同,叠加起来检索次数会继续翻倍,开哪一种由评测数据决定。
- 历史条数配在问答应用上。 不同应用的对话习惯不一样,客服类对话追问多,内部知识库问答单轮居多。
面试官还会追问
- 从会话里取「最近几条」历史消息时,为什么要按 id 倒序取出来再反转?
- 开了 Agentic 模式后,指代消解在状态图的哪一步做?召回不相关、回到改写节点重试时还会再做一次指代消解吗?
学完《AI Agentic RAG高级企业知识库平台》,上面这些追问你都会迎刃而解。