人工智能实战:RAG 多轮对话越问越偏?Query Rewrite、历史压缩与会话记忆的工程化方案
一、问题场景:第一轮答得很好,第二轮开始跑偏
做 RAG 知识库问答时,单轮问题往往比较容易处理。
例如用户问:
text
一线城市住宿费最多报销多少?
系统可以直接检索:
text
差旅报销制度
然后回答。
但一旦进入多轮对话,问题就变复杂了。
真实用户不会每次都把问题说完整。
他们会这样问:
text
用户:销售去一线城市拜访客户,住宿费最多多少?
助手:最多650元/天。
用户:那二线城市呢?
第二轮问题:
text
那二线城市呢?
如果直接拿这句话去检索,系统很可能召回不到正确内容。
因为它缺少关键信息:
text
销售
客户拜访
住宿费
二线城市
这就是 RAG 多轮对话里最常见的问题:
text
用户问题依赖上下文,但检索系统只看到当前句子。
二、真实问题表现
多轮 RAG 常见错误包括:
text
1. 第二轮、第三轮问题召回不到资料
2. 模型把上一轮答案当成事实继续发挥
3. 历史对话越长,Prompt 越长,成本越来越高
4. 用户一句"那这个呢",系统完全不知道指什么
5. 多轮追问后回答偏离原始主题
一开始我也尝试过一个简单做法:
text
把所有历史对话全部拼进 Prompt
结果问题更多:
text
1. token 成本暴涨
2. 噪声变多
3. 检索仍然不准
4. 模型容易被旧问题干扰
后来才意识到,多轮 RAG 不能只靠"拼历史"。
它需要三件事:
text
1. Query Rewrite:把当前问题改写成完整问题
2. History Compression:压缩历史对话
3. Session Memory:保留关键会话状态
三、核心原因分析
多轮 RAG 失败的根因是:
text
检索需要完整问题,但用户输入经常是不完整问题。
例如:
text
用户当前输入:那二线城市呢?
直接检索关键词:
text
二线城市
召回结果可能很多:
text
差旅报销
城市补贴
销售制度
办公地点
但如果改写成:
text
销售部门因客户拜访去二线城市出差,住宿费最多报销多少?
检索命中率会明显提升。
所以多轮 RAG 的第一步不是检索,而是:
text
问题改写。
四、目标架构
text
用户输入
↓
读取会话历史
↓
Query Rewrite
↓
RAG 检索
↓
Rerank
↓
上下文压缩
↓
LLM 生成
↓
更新会话记忆
与单轮 RAG 相比,多了:
text
1. 会话历史管理
2. 问题改写
3. 记忆更新
五、可复现项目结构
text
multi-turn-rag-demo/
├── app.py
├── memory.py
├── rewrite.py
├── retriever.py
├── rag.py
└── requirements.txt
安装依赖:
bash
pip install fastapi uvicorn pydantic
这里为了方便复现,检索部分先用简单关键词模拟,真实项目可以替换成向量数据库。
六、实现会话记忆 memory.py
python
from collections import defaultdict
class SessionMemory:
def __init__(self, max_turns: int = 6):
self.sessions = defaultdict(list)
self.max_turns = max_turns
def add_message(self, session_id: str, role: str, content: str):
self.sessions[session_id].append({
"role": role,
"content": content
})
if len(self.sessions[session_id]) > self.max_turns:
self.sessions[session_id] = self.sessions[session_id][-self.max_turns:]
def get_history(self, session_id: str):
return self.sessions.get(session_id, [])
def build_history_text(self, session_id: str):
history = self.get_history(session_id)
lines = []
for msg in history:
lines.append(f"{msg['role']}: {msg['content']}")
return "\n".join(lines)
这个版本只保留最近 N 轮,避免历史无限膨胀。
七、Query Rewrite 实现 rewrite.py
生产环境建议用 LLM 改写,这里先写一个可替换结构。
python
def build_rewrite_prompt(history: str, current_query: str):
return f"""
你是一个问题改写助手。
请根据历史对话,将用户当前问题改写成一个完整、独立、适合检索知识库的问题。
要求:
1. 保留用户真实意图
2. 补全省略的主语、对象和条件
3. 不要引入历史中没有的信息
4. 只输出改写后的问题
【历史对话】
{history}
【当前问题】
{current_query}
"""
本地模拟改写:
python
def mock_rewrite(history: str, current_query: str):
if "二线城市" in current_query and "销售" in history:
return "销售部门因客户拜访去二线城市出差,住宿费最多报销多少?"
return current_query
真实项目中替换为:
python
def rewrite_query(llm, history: str, current_query: str):
prompt = build_rewrite_prompt(history, current_query)
return llm(prompt)
八、检索模块 retriever.py
python
docs = [
{
"id": "policy_001",
"title": "通用差旅报销制度",
"content": "一线城市住宿费每天不超过500元,二线城市住宿费每天不超过350元。"
},
{
"id": "policy_002",
"title": "销售部门客户拜访报销制度",
"content": "销售部门因客户拜访产生的住宿费,一线城市每天不超过650元,二线城市每天不超过450元。"
},
{
"id": "policy_003",
"title": "实习生差旅制度",
"content": "实习生住宿费每天不超过200元。"
}
]
def keyword_retrieve(query: str):
results = []
for doc in docs:
score = 0
for word in ["销售", "客户拜访", "二线城市", "住宿费", "报销"]:
if word in query and word in doc["content"] + doc["title"]:
score += 1
if score > 0:
item = doc.copy()
item["score"] = score
results.append(item)
return sorted(results, key=lambda x: x["score"], reverse=True)
九、RAG 生成模块 rag.py
python
def build_context(docs):
blocks = []
for doc in docs:
blocks.append(f"""
[资料ID: {doc["id"]}]
标题: {doc["title"]}
内容: {doc["content"]}
""".strip())
return "\n\n".join(blocks)
def build_answer_prompt(query: str, context: str):
return f"""
请严格根据资料回答问题。
如果资料中没有答案,请回答:根据现有资料无法确定。
【资料】
{context}
【问题】
{query}
【回答格式】
直接答案:
依据:
"""
模拟生成:
python
def mock_answer(query: str, docs: list[dict]):
if "销售" in query and "二线城市" in query:
return """
直接答案:
销售部门因客户拜访去二线城市出差,住宿费最多报销450元/天。
依据:
资料ID: policy_002。
"""
return "根据现有资料无法确定。"
十、FastAPI 主流程 app.py
python
from fastapi import FastAPI
from pydantic import BaseModel, Field
from memory import SessionMemory
from rewrite import mock_rewrite
from retriever import keyword_retrieve
from rag import mock_answer
app = FastAPI(title="Multi-turn RAG Demo")
memory = SessionMemory(max_turns=6)
class ChatRequest(BaseModel):
session_id: str = Field(..., min_length=1)
query: str = Field(..., min_length=1, max_length=1000)
@app.post("/chat")
def chat(req: ChatRequest):
history_text = memory.build_history_text(req.session_id)
rewritten_query = mock_rewrite(history_text, req.query)
retrieved_docs = keyword_retrieve(rewritten_query)
answer = mock_answer(rewritten_query, retrieved_docs)
memory.add_message(req.session_id, "user", req.query)
memory.add_message(req.session_id, "assistant", answer)
return {
"origin_query": req.query,
"rewritten_query": rewritten_query,
"answer": answer,
"references": [doc["id"] for doc in retrieved_docs]
}
启动:
bash
uvicorn app:app --port 8000
十一、验证多轮效果
第一轮:
bash
curl -X POST "http://127.0.0.1:8000/chat" \
-H "Content-Type: application/json" \
-d '{
"session_id": "u001",
"query": "销售去一线城市拜访客户,住宿费最多多少?"
}'
第二轮:
bash
curl -X POST "http://127.0.0.1:8000/chat" \
-H "Content-Type: application/json" \
-d '{
"session_id": "u001",
"query": "那二线城市呢?"
}'
返回:
json
{
"origin_query": "那二线城市呢?",
"rewritten_query": "销售部门因客户拜访去二线城市出差,住宿费最多报销多少?",
"references": ["policy_002"]
}
这说明系统没有拿"那二线城市呢?"直接检索,而是先改写成完整问题。
十二、为什么不能保存所有历史?
很多人做多轮对话时,会直接:
python
history = all_messages
然后全部塞进 Prompt。
问题很明显:
text
1. token 成本越来越高
2. 历史噪声越来越多
3. 旧话题干扰新问题
4. 响应速度越来越慢
正确做法:
text
短期记忆:最近几轮对话
长期记忆:结构化关键信息
例如保存:
python
session_state = {
"current_topic": "销售部门客户拜访报销",
"current_city_type": "一线城市",
"current_policy": "policy_002"
}
而不是保存所有废话。
十三、会话状态提取
可以在每轮回答后更新状态:
python
def update_session_state(state: dict, query: str, answer: str):
if "销售" in query or "销售" in answer:
state["department"] = "销售部门"
if "客户拜访" in query or "客户拜访" in answer:
state["scene"] = "客户拜访"
if "二线城市" in query:
state["city_type"] = "二线城市"
return state
真实项目可以让 LLM 输出结构化 JSON:
json
{
"department": "销售部门",
"scene": "客户拜访",
"city_type": "二线城市",
"topic": "差旅报销"
}
这样下一轮改写会更稳定。
十四、多轮 RAG 的关键指标
单轮 RAG 重点看:
text
召回命中率
回答准确率
多轮 RAG 还要看:
text
1. Query Rewrite 准确率
2. 多轮上下文继承正确率
3. 历史压缩后信息保留率
4. 会话漂移率
其中"会话漂移"很常见。
例如用户本来问报销,几轮后系统开始回答年假。
这说明历史上下文管理失控。
十五、踩坑记录
坑 1:直接用当前问题检索
对于多轮对话,这是最常见错误。
用户说:
text
那这个呢?
检索系统根本不知道"这个"是什么。
坑 2:把所有历史塞进 Prompt
短期看省事,长期一定出问题。
历史越长,噪声越多,成本越高。
坑 3:改写时引入新信息
Query Rewrite 必须只补全上下文,不能编造条件。
Prompt 里要明确:
text
不要引入历史中没有的信息。
坑 4:不返回 rewritten_query
调试多轮 RAG 时,一定要返回或记录:
text
origin_query
rewritten_query
retrieved_docs
否则你不知道系统到底检索了什么。
坑 5:会话不分 session_id
如果所有用户共享历史,结果会灾难。
必须按:
text
session_id
user_id
conversation_id
隔离。
十六、适合收藏的多轮 RAG Checklist
text
问题改写:
[ ] 是否对省略问题做 rewrite
[ ] 是否记录 rewritten_query
[ ] 是否禁止引入新信息
[ ] 是否基于历史补全主语和条件
历史管理:
[ ] 是否限制历史轮数
[ ] 是否压缩历史
[ ] 是否提取结构化状态
[ ] 是否按 session_id 隔离
检索:
[ ] 是否用 rewritten_query 检索
[ ] 是否记录召回文档
[ ] 是否处理多轮主题漂移
生成:
[ ] 是否严格基于资料回答
[ ] 是否引用资料ID
[ ] 是否允许回答无法确定
评估:
[ ] 是否有多轮测试集
[ ] 是否评估 rewrite 准确率
[ ] 是否评估上下文继承正确率
十七、经验总结
多轮 RAG 的核心不是"记住所有历史",而是:
text
在当前问题中恢复用户真实意图。
如果用户问:
text
那二线城市呢?
系统真正要理解的是:
text
销售部门因客户拜访去二线城市出差,住宿费最多报销多少?
所以多轮 RAG 的关键链路是:
text
历史理解 → 问题改写 → 检索 → 生成
一句话总结:
text
多轮 RAG 不是把历史越塞越多,而是把问题改写得越来越准。
十八、后续优化建议
可以继续增强:
text
1. 使用 LLM 做 Query Rewrite
2. 建立多轮问答评测集
3. 引入结构化 Session State
4. 对历史对话做摘要压缩
5. 对改写问题做置信度判断
6. 低置信度时反问用户
7. 区分任务型对话和知识型对话
8. 对不同 session 做隔离和过期
最后一句经验:
text
RAG 多轮问答的质量,取决于你能不能把一句"不完整的问题",还原成一个"完整的检索问题"。