13.人工智能实战:RAG 多轮对话越问越偏?Query Rewrite、历史压缩与会话记忆的工程化方案

人工智能实战: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 多轮问答的质量,取决于你能不能把一句"不完整的问题",还原成一个"完整的检索问题"。
相关推荐
郝学胜-神的一滴1 小时前
二分类任务核心:BCE 损失函数从原理到 PyTorch 实战
人工智能·pytorch·python·算法·机器学习·分类·数据挖掘
财经资讯数据_灵砚智能1 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月2日
人工智能·python·信息可视化·自然语言处理·ai编程
sali-tec1 小时前
C# 基于OpenCv的视觉工作流-章58-相机标定
图像处理·人工智能·数码相机·opencv·算法·计算机视觉
一水鉴天1 小时前
同构异质三表总装体系确立与入表机制闭环验证 20260502(腾讯元宝)
人工智能·算法·机器学习
kalvin_y_liu1 小时前
人体动作理解和人机共享控制两个研究方向的核心内容
人工智能·具身数据模型
浔川python社1 小时前
AI 生成视频盛行,会带来哪些利与弊
人工智能
AI科技星1 小时前
《全域数学》第一部:数术本源·第二卷《算术原本》之十四附录(二)全域数学体系下三大数论猜想的本源推演与哲学阐释【乖乖数学】
人工智能·线性代数·机器学习·量子计算·agi
qcx231 小时前
拆解 Warp AI Agent(一):类型即协议——23 种 Action 的编译期安全设计
人工智能·安全·ai·agent·源码解析·warp
蔡俊锋1 小时前
AI进阶运营:从信息爆炸到智能掌控
人工智能·chatgpt·ai进阶运营