- TL;DR
单轮 RAG 很容易跑通,但多轮追问一上来就翻车:用户问"他厉害吗",系统根本不知道"他"是谁,于是去知识库里捞了一堆不相干的资料,最后答非所问。
根因不是大模型笨,而是 RAG 第 1 步"找资料"找错了——检索时把"他"当成字面词去搜,而文档里从没用"他"指代过任何人。
修复方法是一句行业标配的话:在检索之前,先用 LLM 把追问结合历史补全成独立问句("他厉害吗" → "佐助厉害吗"),再用补全后的问句去检索。本文给出可落地的代码、效果验证,以及与 Dify 的对比。
- 一个真实的翻车现场
测试文档是一篇跨世界观同人小说。对话如下:
用户:为什么佐助什么事都没有做?
AI: (正确,引用了文档里<span>"佐助只在专有名词清单中"</span>的片段)
用户:那么他厉害吗?
AI: 根据文档片段,无法判断<span>"他"</span>具体指谁……
文档中并未对任何角色的实力做出统一的<span>"厉害"</span>评价……
第二轮"他"指代上一轮的"佐助",但系统完全没接住,反而捞回了"缝合BOSS极其强大"之类的片段,答成了"不知道他指谁"。
- 根因分析:不是答错,是捞错
RAG 的本质只有两步:
- 先在文档里找相关的几段文字(检索)
- 把这几段文字交给大模型,让它看着文字回答
大模型只会回答"它手里拿到的那几段文字"里有的内容。
翻车链路:
- 本地哈希向量器看不懂代词:它是词袋模型,只认字面词。"他"和"佐助"在它眼里毫无关系。
- 重排器把"厉害"相关的片段顶上来:重排打分含"词覆盖度","厉害/强大"这些词在缝合BOSS片段里覆盖度高,于是这些片段被排到前面,佐助片段因没有"厉害"二字被挤出 top-k。
- 历史在消息里,但"只能依据片段回答"的限制救不了场:代码虽把历史塞进了发给大模型的消息,但系统提示强制"答案必须引用下方片段、片段没有就说明未找到"。片段里没佐助,所以历史就算看到了也没法用来作答——检索一旦捞错,历史也兜不住。
一句话:多轮对话里的代词(他/它/这个)必须先在搜索前换成真名字,否则一定捞错资料。
- 解法:检索前先做「查询改写」(Query Rewriting)
这是生产级多轮 RAG 的标准做法(LlamaIndex 叫 CondenseQuestionChatEngine,Dify 叫"多轮问题补全")。
核心思路:不写死任何规则(不写"他→佐助"),而是把"历史 + 当前这句不完整的话"交给 LLM,让它改写成一句独立、完整、不含代词的检索问句。
原问句: <span>"那么他厉害吗?"</span>
历史: <span>"为什么佐助什么事都没有做?"</span> + 上轮回答
改写输出: <span>"宇智波佐助在文档故事中厉害吗?"</span> ← 自动把<span>"他"</span>补全成<span>"佐助"</span>
然后用"宇智波佐助在文档故事中厉害吗"去检索,就能精准捞到佐助相关片段。
为什么是通用方案而不是特例? 因为它依赖大模型的中文理解能力,对任意指代/省略都生效:
| 用户追问(简写) | 改写后(完整) |
|---|---|
| 他厉害吗? | 佐助厉害吗? |
| 失败的原因是什么? | boss失败的原因是什么? |
| 这个多少钱? | XX产品多少钱? |
- 落地代码(改动清单)
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)在顶部用醒目标签展示:
🟠 实际检索问句:
宇智波佐助在文档故事中厉害吗
这样就能直观看到"他"被补全成了什么,验证改写是否生效。
- 效果验证
开启改写后,调试面板出现:
实际检索问句:宇智波佐助在文档故事中厉害吗
说明"他"已正确解析为"宇智波佐助"。同一文档里另一组追问验证了机制的健壮性:
用户:为什么boss失败了 → 答得对
用户:失败的原因是什么 → 答得对,且与上一轮一致 ✅
因为文档里真的有 BOSS 的详细内容,检索能捞对、大模型有料可答。这反过来说明:佐助那次之所以仍不理想,是"文档本来就没写佐助实力 + 本地弱向量器"的问题,而非改写逻辑失效。
残留问题:本地哈希向量器是词袋模型,即使问句补全为"佐助厉害吗","厉害"二字仍可能让它去匹配文档里讲"强大"的段落。根治需要把 embedding 换成真实语义模型(如阿里通义
text-embedding-v3)。改写负责"问句补全",语义 embedding 负责"捞得准",两者配合才完整。
- 与 Dify 的对比
结论:逻辑完全一致,都是"检索前用 LLM 把追问补全成独立问句"。 这不是自创技巧,而是多轮 RAG 的通用范式。
| 维度 | 本文实现(手撸项目) | Dify |
|---|---|---|
| 核心技术 | LLM 查询改写(Query Rewriting) | 同名能力("多轮问题补全" / 结合历史检索) |
| 是否写死规则 | 否,通用改写 | 否,通用改写 |
| 开关 | `.env` 里 `QUERY_REWRITE=on/off` | 知识库检索设置里的 UI 开关 |
| 额外 LLM 调用 | 有 1 次(改写),失败自动回退 | 同理,平台内部封装 |
| 调试可见性 | 前端调试面板直接展示"实际检索问句" | 平台有日志/调试视图 |
| 代价 | 自己维护代码 | 引入一整套低代码平台 |
| 控制权 | 完全自己掌控 | 受平台约束 |
本质区别不在"算法",而在交付形态:Dify 是把这套能力做成"开箱即用的平台 + UI 开关";本文是"在自家代码里手写十几行"。效果等价,取舍在维护成本与掌控力。
- 还差什么(生产 checklist)
- ✅ 多轮查询改写(本文)
- ✅ 开关
QUERY_REWRITE(对齐 Dify 可配置行为) - ⏳ 语义 embedding:本地哈希向量器太弱,建议换通义
text-embedding-v3等真实模型,让稀疏提及也能被捞回 - ⏳ 可选增强:改写问句 + 原问句各搜一次后合并去重;或引入 Query Decomposition 处理"比较 A 和 B"类复杂问
- ⏳ 可选:把开关从
.env挪到系统配置 UI(仿 Dify 的界面化体验)
- 一句话总结
多轮 RAG 翻车的根因几乎都在"检索捞错资料",而检索前用 LLM 把追问补全成独立问句是成本最低、收益最高的一招——它和 Dify、LlamaIndex 等主流框架的默认行为同源。加上它,你的项目就追平了生产级多轮问答的基本盘。
手撸十几行查询改写即可对齐 Dify 的「多轮问题补全」,还附带开关与前端的实际检索问句展示,调试友好,适合正在做文档问答、想让多轮追问不翻车的 RAG 开发者直接照搬。