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

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


0. TL;DR

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

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

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


1. 一个真实的翻车现场

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

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

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

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


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

RAG 的本质只有两步:

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

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

翻车链路:

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

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


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

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

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

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

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

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

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

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

4.1 新增改写函数 rag.py

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


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

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

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

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

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

4.3 开关 config.py

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

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

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

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

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

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


5. 效果验证

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

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

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

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

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

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


6. 与 Dify 的对比

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

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

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


7. 还差什么(生产 checklist)

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

8. 一句话总结

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

相关推荐
Wx-bishekaifayuan2 小时前
springboot生活易购超市管理系统54086-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
lizhongxuan2 小时前
VirtIO:虚拟机的磁盘、网络与内存是怎样工作的
后端
计算机毕设定制辅导-无忧学长3 小时前
《基于Spring Boot的减脂轻食购物网站》
java·vue.js·spring boot·后端·减脂轻食购物网站
沐欣工作室_lvyiyi3 小时前
基于SpringBoot的博物馆预约系统设计与实现(论文+源码)
java·spring boot·后端·计算机毕业设计
拾光记3 小时前
Maven依赖与类加载机制
后端
迪丽热爱4 小时前
HTTP Error 500.30 - ASP.NET Core app failed to start
后端·asp.net
茉莉玫瑰花茶4 小时前
GO [ 函数 ]
开发语言·后端·golang
QuZhengRong4 小时前
【Spring】后端接收的请求参数多了一个逗号的处理办法
java·后端·spring
打工仔折腾 AI4 小时前
从零写一个CAD 02:实体容器、Esc取消与键盘失灵的排查
人工智能·后端·python·性能优化