适用读者:正在做文档问答(RAG)的开发者。 关键词:RAG、多轮对话、Query Rewriting、指代消解、Dify 对比
0. TL;DR
单轮 RAG 很容易跑通,但多轮追问一上来就翻车:用户问"他厉害吗",系统根本不知道"他"是谁,于是去知识库里捞了一堆不相干的资料,最后答非所问。
根因不是大模型笨,而是 RAG 第 1 步"找资料"找错了------检索时把"他"当成字面词去搜,而文档里从没用"他"指代过任何人。
修复方法是一句行业标配的话:在检索之前,先用 LLM 把追问结合历史补全成独立问句("他厉害吗" → "佐助厉害吗"),再用补全后的问句去检索。本文给出可落地的代码、效果验证,以及与 Dify 的对比。
1. 一个真实的翻车现场
测试文档是一篇跨世界观同人小说。对话如下:
arduino
用户:为什么佐助什么事都没有做?
AI: (正确,引用了文档里"佐助只在专有名词清单中"的片段)
用户:那么他厉害吗?
AI: 根据文档片段,无法判断"他"具体指谁......
文档中并未对任何角色的实力做出统一的"厉害"评价......
第二轮"他"指代上一轮的"佐助",但系统完全没接住,反而捞回了"缝合BOSS极其强大"之类的片段,答成了"不知道他指谁"。
2. 根因分析:不是答错,是捞错
RAG 的本质只有两步:
- 先在文档里找相关的几段文字(检索)
- 把这几段文字交给大模型,让它看着文字回答
大模型只会回答"它手里拿到的那几段文字"里有的内容。
翻车链路:
- 本地哈希向量器看不懂代词:它是词袋模型,只认字面词。"他"和"佐助"在它眼里毫无关系。
- 重排器把"厉害"相关的片段顶上来:重排打分含"词覆盖度","厉害/强大"这些词在缝合BOSS片段里覆盖度高,于是这些片段被排到前面,佐助片段因没有"厉害"二字被挤出 top-k。
- 历史在消息里,但"只能依据片段回答"的限制救不了场 :代码虽把历史塞进了发给大模型的消息,但系统提示强制"答案必须引用下方片段、片段没有就说明未找到"。片段里没佐助,所以历史就算看到了也没法用来作答------检索一旦捞错,历史也兜不住。
一句话:多轮对话里的代词(他/它/这个)必须先在搜索前换成真名字,否则一定捞错资料。
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)
- ✅ 多轮查询改写(本文)
- ✅ 开关
QUERY_REWRITE(对齐 Dify 可配置行为) - ⏳ 语义 embedding :本地哈希向量器太弱,建议换通义
text-embedding-v3等真实模型,让稀疏提及也能被捞回 - ⏳ 可选增强:改写问句 + 原问句各搜一次后合并去重;或引入 Query Decomposition 处理"比较 A 和 B"类复杂问
- ⏳ 可选:把开关从
.env挪到系统配置 UI(仿 Dify 的界面化体验)
8. 一句话总结
多轮 RAG 翻车的根因几乎都在"检索捞错资料",而检索前用 LLM 把追问补全成独立问句是成本最低、收益最高的一招------它和 Dify、LlamaIndex 等主流框架的默认行为同源。加上它,你的项目就追平了生产级多轮问答的基本盘。