给 RAG 问答加上「多轮指代消解」:让"他 / 这个"不再翻车

文章来源声明: 原文作者:再吃一根胡萝卜; 来源站点:掘金; 原文链接:https://juejin.cn/post/7691149921396523058; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

手撸十几行查询改写即可对齐 Dify 的「多轮问题补全」,还附带开关与前端的实际检索问句展示,调试友好,适合正在做文档问答、想让多轮追问不翻车的 RAG 开发者直接照搬。

> 适用读者:正在做文档问答(RAG)的开发者。 关键词:RAG、多轮对话、Query Rewriting、指代消解、Dify 对比
  1. TL;DR

单轮 RAG 很容易跑通,但多轮追问一上来就翻车:用户问"他厉害吗",系统根本不知道"他"是谁,于是去知识库里捞了一堆不相干的资料,最后答非所问。

根因不是大模型笨,而是 RAG 第 1 步"找资料"找错了——检索时把"他"当成字面词去搜,而文档里从没用"他"指代过任何人。

修复方法是一句行业标配的话:在检索之前,先用 LLM 把追问结合历史补全成独立问句("他厉害吗" → "佐助厉害吗"),再用补全后的问句去检索。本文给出可落地的代码、效果验证,以及与 Dify 的对比。


  1. 一个真实的翻车现场

测试文档是一篇跨世界观同人小说。对话如下:

用户:为什么佐助什么事都没有做?
AI:  (正确,引用了文档里<span>"佐助只在专有名词清单中"</span>的片段)

用户:那么他厉害吗?
AI:  根据文档片段,无法判断<span>"他"</span>具体指谁……
     文档中并未对任何角色的实力做出统一的<span>"厉害"</span>评价……

第二轮"他"指代上一轮的"佐助",但系统完全没接住,反而捞回了"缝合BOSS极其强大"之类的片段,答成了"不知道他指谁"。


  1. 根因分析:不是答错,是捞错

RAG 的本质只有两步:

  1. 先在文档里找相关的几段文字(检索)
  2. 把这几段文字交给大模型,让它看着文字回答

大模型只会回答"它手里拿到的那几段文字"里有的内容。

翻车链路:

  1. 本地哈希向量器看不懂代词:它是词袋模型,只认字面词。"他"和"佐助"在它眼里毫无关系。
  2. 重排器把"厉害"相关的片段顶上来:重排打分含"词覆盖度","厉害/强大"这些词在缝合BOSS片段里覆盖度高,于是这些片段被排到前面,佐助片段因没有"厉害"二字被挤出 top-k。
  3. 历史在消息里,但"只能依据片段回答"的限制救不了场:代码虽把历史塞进了发给大模型的消息,但系统提示强制"答案必须引用下方片段、片段没有就说明未找到"。片段里没佐助,所以历史就算看到了也没法用来作答——检索一旦捞错,历史也兜不住。

一句话:多轮对话里的代词(他/它/这个)必须先在搜索前换成真名字,否则一定捞错资料。


  1. 解法:检索前先做「查询改写」(Query Rewriting)

这是生产级多轮 RAG 的标准做法(LlamaIndex 叫 CondenseQuestionChatEngine,Dify 叫"多轮问题补全")。

核心思路:不写死任何规则(不写"他→佐助"),而是把"历史 + 当前这句不完整的话"交给 LLM,让它改写成一句独立、完整、不含代词的检索问句。

原问句:   <span>"那么他厉害吗?"</span>
历史:     <span>"为什么佐助什么事都没有做?"</span> + 上轮回答
改写输出: <span>"宇智波佐助在文档故事中厉害吗?"</span>   ← 自动把<span>"他"</span>补全成<span>"佐助"</span>

然后用"宇智波佐助在文档故事中厉害吗"去检索,就能精准捞到佐助相关片段。

为什么是通用方案而不是特例? 因为它依赖大模型的中文理解能力,对任意指代/省略都生效:

用户追问(简写)改写后(完整)
他厉害吗?佐助厉害吗?
失败的原因是什么?boss失败的原因是什么?
这个多少钱?XX产品多少钱?

  1. 落地代码(改动清单)

4.1 新增改写函数 rag.py

_REWRITE_SYSTEM = (
    <span>"你是一个问答系统的「检索问句改写器」。用户正在基于文档进行多轮对话。"</span>
    <span>"下面给出最近的对话历史与用户的最新提问。最新提问里可能含有需要结合上下文才能理解的"</span>
    <span>"代词(他/它/这个/那个)或省略。请只做一件事:把最新提问结合历史,改写成一句"</span>
    <span>"独立、完整、不含歧义的检索问句,用于去知识库检索。"</span>
    <span>"规则:\n"</span>
    <span>"1. 只输出改写后的一句话,不要回答、不要解释、不要加任何前缀;\n"</span>
    <span>"2. 必须保留用户原意;\n"</span>
    <span>"3. 若最新提问本身已独立完整,则原样返回。"</span>
)


<span>def</span> <span>_rewrite_query</span>(<span>question, history</span>):
    <span>"""多轮补全:把含指代/省略的追问结合历史改写成独立检索问句。

    无历史时直接返回原问句(不额外消耗 LLM 调用)。
    改写失败则回退到「历史+原问」拼接,保证检索仍有上下文。
    """</span>
    <span>if</span> <span>not</span> history:
        <span>return</span> question
    recent = history[-<span>6</span>:]
    hist_text = <span>"\n"</span>.join(
        <span>f"<span>{<span>'用户'</span> <span>if</span> m[<span>'role'</span>] == <span>'user'</span> <span>else</span> <span>'助手'</span>}</span>:<span>{m[<span>'content'</span>]}</span>"</span> <span>for</span> m <span>in</span> recent
    )
    messages = [
        {<span>"role"</span>: <span>"system"</span>, <span>"content"</span>: _REWRITE_SYSTEM},
        {
            <span>"role"</span>: <span>"user"</span>,
            <span>"content"</span>: (
                <span>f"对话历史:\n<span>{hist_text}</span>\n\n最新提问:\n<span>{question}</span>"</span>
                <span>f"\n\n请输出改写后的独立检索问句:"</span>
            ),
        },
    ]
    <span>try</span>:
        llm = get_llm()
        out = llm.chat(messages, temperature=<span>0.0</span>, stream=<span>False</span>)
        <span>return</span> out.strip() <span>or</span> question
    <span>except</span> Exception:
        <span># 改写失败不阻塞主流程,回退到旧的拼接方式</span>
        ctx = <span>"\n"</span>.join(<span>f"<span>{m[<span>'role'</span>]}</span>: <span>{m[<span>'content'</span>]}</span>"</span> <span>for</span> m <span>in</span> recent)
        <span>return</span> <span>f"<span>{ctx}</span>\n<span>{question}</span>"</span>

4.2 在 answer_stream 里用改写后的问句检索 + 拼 prompt

    clean_history = _to_history(history)
    <span># 多轮补全(开关可在 .env 用 QUERY_REWRITE=off 关闭):</span>
    <span># 关闭后追问不做指代消解,退回单轮 RAG,省一次 LLM 调用,但"他/这个"会捞错资料。</span>
    retrieval_query = (
        _rewrite_query(question, clean_history) <span>if</span> QUERY_REWRITE <span>else</span> question
    )

    candidates = search(retrieval_query, top_k * <span>4</span> <span>or</span> top_k, threshold, document_ids=document_ids)
    <span># ... 兜底召回、重排 ...</span>
    <span># 用改写后的独立问句拼进 prompt,保证"检索问句"与"发给大模型的问题"一致</span>
    prompt = build_prompt(retrieval_query, contexts, low_confidence=low_confidence)

4.3 开关 config.py

<span># on=开启:追问结合历史补全为独立问句后再检索(生产级多轮 RAG 标配)</span>
<span># off=关闭:追问不做补全,退回单轮 RAG(省一次 LLM 调用,但"他/这个"会捞错资料)</span>
QUERY_REWRITE = _get(<span>"QUERY_REWRITE"</span>, <span>"on"</span>).lower() <span>in</span> (<span>"on"</span>, <span>"1"</span>, <span>"true"</span>, <span>"yes"</span>)

在 .env 里写 QUERY_REWRITE=off 即可关闭,退回单轮行为。

4.4 顺手把"实际检索问句"透出到前端(调试用)

answer_stream 额外返回 retrieval_query;chat.py 的 SSE done 事件带上 retrieval_query;前端调试面板(LlmDebugPanel.vue)在顶部用醒目标签展示:

🟠 实际检索问句:宇智波佐助在文档故事中厉害吗

这样就能直观看到"他"被补全成了什么,验证改写是否生效。


  1. 效果验证

开启改写后,调试面板出现:

实际检索问句:宇智波佐助在文档故事中厉害吗

说明"他"已正确解析为"宇智波佐助"。同一文档里另一组追问验证了机制的健壮性:

用户:为什么boss失败了        → 答得对
用户:失败的原因是什么        → 答得对,且与上一轮一致 ✅

因为文档里真的有 BOSS 的详细内容,检索能捞对、大模型有料可答。这反过来说明:佐助那次之所以仍不理想,是"文档本来就没写佐助实力 + 本地弱向量器"的问题,而非改写逻辑失效。

残留问题:本地哈希向量器是词袋模型,即使问句补全为"佐助厉害吗","厉害"二字仍可能让它去匹配文档里讲"强大"的段落。根治需要把 embedding 换成真实语义模型(如阿里通义 text-embedding-v3)。改写负责"问句补全",语义 embedding 负责"捞得准",两者配合才完整。


  1. 与 Dify 的对比

结论:逻辑完全一致,都是"检索前用 LLM 把追问补全成独立问句"。 这不是自创技巧,而是多轮 RAG 的通用范式。

维度本文实现(手撸项目)Dify
核心技术LLM 查询改写(Query Rewriting)同名能力("多轮问题补全" / 结合历史检索)
是否写死规则否,通用改写否,通用改写
开关`.env` 里 `QUERY_REWRITE=on/off`知识库检索设置里的 UI 开关
额外 LLM 调用有 1 次(改写),失败自动回退同理,平台内部封装
调试可见性前端调试面板直接展示"实际检索问句"平台有日志/调试视图
代价自己维护代码引入一整套低代码平台
控制权完全自己掌控受平台约束

本质区别不在"算法",而在交付形态:Dify 是把这套能力做成"开箱即用的平台 + UI 开关";本文是"在自家代码里手写十几行"。效果等价,取舍在维护成本与掌控力。


  1. 还差什么(生产 checklist)

  1. ✅ 多轮查询改写(本文)
  2. ✅ 开关 QUERY_REWRITE(对齐 Dify 可配置行为)
  3. ⏳ 语义 embedding:本地哈希向量器太弱,建议换通义 text-embedding-v3 等真实模型,让稀疏提及也能被捞回
  4. ⏳ 可选增强:改写问句 + 原问句各搜一次后合并去重;或引入 Query Decomposition 处理"比较 A 和 B"类复杂问
  5. ⏳ 可选:把开关从 .env 挪到系统配置 UI(仿 Dify 的界面化体验)

  1. 一句话总结

多轮 RAG 翻车的根因几乎都在"检索捞错资料",而检索前用 LLM 把追问补全成独立问句是成本最低、收益最高的一招——它和 Dify、LlamaIndex 等主流框架的默认行为同源。加上它,你的项目就追平了生产级多轮问答的基本盘。