RAG多轮对话检索设计:Query重写如何让"那它呢"变成完整问题
导读:用户在 RAG 系统里问完"BGE-M3 怎么做混合检索?"之后,紧接着来一句"那参数怎么调?"------检索器拿到的只有这五个字,完全不知道"那"指的是什么,召回结果全是垃圾。这是多轮 RAG 系统最常见的崩溃场景。本文从这道高频面试题出发,完整拆解多轮对话检索的核心难点、Query 重写方案的完整实现、检索与生成两个阶段为什么要用不同输入、以及工业级落地的五个工程细节。文末附完整可运行的代码和踩坑清单。
适合读者:
- 正在搭建多轮对话 RAG 系统、遇到"后续提问检索效果差"问题的开发者
- 准备 RAG 方向面试、需要系统回答"多轮对话检索设计"的同学
- 想理解 Query 重写原理和代码实现的工程师
- 需要在生产环境中做多轮上下文管理的架构师
阅读收益:
- 理解多轮 RAG 检索的核心难点:指代消解与语义补全
- 掌握 Query 重写的完整代码实现(LLM 驱动 + 历史窗口截断)
- 弄清"检索用改写后的 query、生成用原始对话"这一关键区分
- 学会对话轮次截断、历史窗口管理、拒答兜底等工程细节
- 获得一套可直接复用的 MultiTurnRAGService 完整实现
目录
- 问题背景:多轮对话为什么检索会崩
- 核心难点拆解:指代、省略、语义缺失
- Query重写:用小模型补全用户意图
- 关键区分:检索用改写query,生成用原始对话
- 完整代码实现:MultiTurnRAGService
- 对话历史窗口管理:截断策略与Token控制
- 工程落地:五个生产级细节
- 验证脚本:完整多轮对话测试
- 踩坑清单:多轮检索中的8个关键问题
- 面试速答版:30秒口述
- 总结与延伸
- 文末互动
1. 问题背景:多轮对话为什么检索会崩
1.1 一个真实的崩溃场景
假设你搭好了一个 RAG 知识库客服系统,第一轮对话很正常:
用户:BGE-M3 怎么做混合检索?
AI:BGE-M3 可以同时输出稠密向量与稀疏向量,再用 RRF 融合结果......
第二轮,用户自然地追问:
用户:那参数怎么调?
系统拿到"那参数怎么调?"这五个字,去向量库里检索。结果呢?召回的全是"参数调优""性能调优""模型参数"之类的泛化文档,跟 BGE-M3 混合检索的参数调优毫无关系。用户看到一段答非所问的回答,体验直接崩溃。
1.2 为什么会这样
检索器的每一次检索都是独立的。它没有记忆,不知道上一轮聊了什么。当你把"那参数怎么调?"丢给它时,它眼里只有这五个字的语义向量------而"那"这个代词在向量空间里几乎不携带任何信息。
检索器的视角:
"BGE-M3 怎么做混合检索?" → 向量清晰,召回精准
"那参数怎么调?" → 向量模糊,召回垃圾
问题的本质是:用户的后续提问是省略式、指代式的,直接拿当前这句话去检索会语义缺失。
1.3 为什么不能把全部历史塞进检索
一个直觉想法是:把历史对话全部拼起来去检索。但这会引入新问题:
python
# 错误做法:把全部历史拼成检索query
query = "BGE-M3怎么做混合检索?BGE-M3可以同时输出稠密向量与稀疏向量,"
"再用RRF融合结果。那参数怎么调?"
# 问题1:噪声膨胀------AI的回答也被拼进去了,检索器会被回答内容干扰
# 问题2:语义模糊------多句话拼在一起,向量语义变成多主题混合体
# 问题3:Token浪费------历史越长,embedding输入越长,成本越高
所以核心思路是:把历史对话信息合理融入检索环节,还原完整用户意图,同时不能把全部历史直接塞进检索。
2. 核心难点拆解:指代、省略、语义缺失
2.1 三种常见的问题形态
| 问题形态 | 示例 | 检索器看到什么 | 缺失什么 |
|---|---|---|---|
| 指代词 | "那参数怎么调?" | "那"+"参数"+"调" | "那"指代BGE-M3混合检索 |
| 省略主语 | "参数怎么调?" | "参数"+"调" | 主语BGE-M3混合检索 |
| 追问细节 | "ef设置多少合适?" | "ef"+"设置" | ef是HNSW参数,属于向量库配置 |
2.2 为什么检索器无法自己解决
检索器(不管是向量检索还是 BM25)本质上是一个无状态的文本匹配器:
向量检索:把query转成向量 → 算余弦相似度 → 返回TopK
BM25检索:统计query中词频 → 算TF-IDF分数 → 返回TopK
两者都不具备"理解上下文"的能力。你必须在检索之前就把用户的真实意图还原出来。
2.3 正确的解法:Query重写
原始流程(单轮):
用户问题 → 检索 → 生成
多轮流程(加Query重写):
用户问题 + 最近几轮历史 → Query重写 → 补全后的query → 检索 → 生成
Query 重写的核心是:调用一个 LLM(通常用小参数量模型,速度快、成本低),结合最近几轮对话历史,把当前问题补全为一个语义完整的独立查询。
3. Query重写:用小模型补全用户意图
3.1 重写Prompt设计
Query 重写的提示词需要满足几个要求:
python
REWRITE_PROMPT = """你是一个查询重写助手。你的任务是根据对话历史,把用户的最新提问重写为一个语义完整、可独立检索的查询。
规则:
1. 如果用户提问包含指代词(如"它""那""这个"),替换为具体对象
2. 如果用户提问省略了主语,从历史中补全
3. 重写后的查询应该是一个完整的问题,不包含对话历史的回答内容
4. 只输出重写后的查询,不输出任何解释
对话历史:
{chat_history}
用户最新提问:{question}
重写后的查询:"""
设计要点:
- 第3条规则非常关键------不能把AI的回答塞进重写后的query,否则检索器会被回答内容干扰
- 第4条规则要求模型只输出结果,方便程序直接提取,不需要再做解析
chat_history只传最近2-3轮,不是全部历史
3.2 重写效果对比
输入:
历史:用户问"BGE-M3怎么做混合检索?" → AI回答"BGE-M3可以同时输出稠密向量与稀疏向量......"
当前:"那参数怎么调?"
重写后:
"BGE-M3做混合检索时相关参数如何调优"
检索效果:
原始query "那参数怎么调?" → Top5召回全是无关文档
重写query "BGE-M3做混合检索时相关参数如何调优" → Top5召回精准命中
3.3 为什么用小模型
Query 重写是一个相对简单的语言理解任务,不需要复杂推理。用小参数量模型(如 deepseek-chat、qwen-turbo)即可:
| 对比项 | 小模型重写 | 大模型重写 |
|---|---|---|
| 延迟 | 200-500ms | 1-3s |
| 成本 | 低 | 高3-5倍 |
| 效果 | 重写质量足够 | 略好但收益有限 |
| 适用 | 生产环境首选 | 对话轮次多、指代复杂时 |
生产建议:默认用小模型,当重写质量不达标时再切大模型做兜底。
4. 关键区分:检索用改写query,生成用原始对话
4.1 最容易踩的坑
很多人在实现多轮 RAG 时,把改写后的 query 同时用于检索和生成。这是错的。
错误流程:
改写query → 检索 → 检索结果 + 改写query → 生成回答
正确流程:
改写query → 检索 → 检索结果 + 原始对话历史 → 生成回答
4.2 为什么要区分
检索阶段用改写后的 query:因为检索器需要语义完整的查询才能精准召回。
生成阶段用原始对话历史:因为大模型有足够强的上下文理解能力,可以直接理解多轮对话。把改写后的 query 丢给大模型反而会丢失对话的连贯性,让回答显得生硬。
python
# 检索阶段:用改写后的query
rewritten_query = rewrite_query(question, chat_history)
retrieved_docs = retriever.invoke(rewritten_query)
# 生成阶段:用原始对话历史 + 检索结果
# 不要用rewritten_query去生成!
messages = [
SystemMessage(content="基于检索到的资料回答用户问题。"),
# 把历史对话原样传入
*chat_history,
# 当前问题用原始question,不是rewritten_query
HumanMessage(content=question),
# 检索结果作为额外上下文
SystemMessage(content=f"参考资料:\n{format_docs(retrieved_docs)}"),
]
answer = model.invoke(messages)
4.3 一句话记忆
检索用改写后的query,生成用原始对话。检索器无记忆需要补全,大模型有记忆能力不需要。
5. 完整代码实现:MultiTurnRAGService
5.1 整体架构
MultiTurnRAGService
├── rewrite_query() # Query重写(小模型驱动)
├── retrieve() # 检索(用改写后的query)
├── generate() # 生成(用原始对话历史)
├── ask() # 完整多轮问答入口
└── _format_history() # 历史格式化
5.2 完整实现
python
import os
from dataclasses import dataclass, field
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.documents import Document
from langchain_core.messages import (
HumanMessage, AIMessage, SystemMessage
)
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
# ========== 数据结构 ==========
@dataclass
class MultiTurnAnswer:
answer: str # 最终回答
rewritten_query: str # 重写后的查询(用于调试)
retrieved_docs: list # 检索到的文档
used_history_turns: int # 实际使用的历史轮次
latency_ms: int # 总耗时
# ========== Query重写Prompt ==========
REWRITE_TEMPLATE = ChatPromptTemplate.from_messages([
("system", """你是一个查询重写助手。根据对话历史,把用户最新提问重写为语义完整、可独立检索的查询。
规则:
1. 指代词(它、那、这个)替换为具体对象
2. 省略主语时从历史补全
3. 重写后的查询是完整问题,不包含AI回答内容
4. 只输出重写后的查询,不输出任何解释
对话历史:
{chat_history}"""),
("human", "用户最新提问:{question}\n\n重写后的查询:"),
])
# ========== 多轮RAG服务 ==========
class MultiTurnRAGService:
def __init__(
self,
vectorstore: Chroma,
rewrite_model: ChatOpenAI | None = None,
generate_model: ChatOpenAI | None = None,
max_history_turns: int = 3,
retrieve_top_k: int = 5,
):
self.vectorstore = vectorstore
# 重写用小模型(快、便宜)
self.rewrite_model = rewrite_model or ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL"),
temperature=0, # 重写要确定性
max_tokens=100, # 重写不需要长输出
)
# 生成用大模型(质量好)
self.generate_model = generate_model or ChatOpenAI(
model="deepseek-chat",
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL"),
temperature=0.3,
)
self.max_history_turns = max_history_turns
self.retrieve_top_k = retrieve_top_k
# 构建重写链
self.rewrite_chain = REWRITE_TEMPLATE | self.rewrite_model | StrOutputParser()
def _format_history(self, messages: list) -> str:
"""把对话历史格式化为文本,供重写Prompt使用"""
lines = []
for msg in messages[-self.max_history_turns * 2:]: # 每轮2条(Human+AI)
if isinstance(msg, HumanMessage):
lines.append(f"用户:{msg.content}")
elif isinstance(msg, AIMessage):
# AI回答只取前100字,避免历史过长
lines.append(f"助手:{msg.content[:100]}...")
return "\n".join(lines)
def rewrite_query(self, question: str, history: list) -> str:
"""Query重写:结合历史把当前问题补全"""
if not history:
# 没有历史,不需要重写
return question
chat_history = self._format_history(history)
rewritten = self.rewrite_chain.invoke({
"chat_history": chat_history,
"question": question,
})
return rewritten.strip()
def retrieve(self, query: str) -> list[Document]:
"""检索:用改写后的query"""
docs = self.vectorstore.similarity_search_with_score(
query, k=self.retrieve_top_k
)
# 过滤低分文档
return [doc for doc, score in docs if score < 0.5]
def generate(
self, question: str, history: list, docs: list[Document]
) -> str:
"""生成:用原始对话历史 + 检索结果"""
context = "\n\n".join(
f"[{i+1}] {doc.page_content}" for i, doc in enumerate(docs)
)
messages = [
SystemMessage(content=(
"你是一个知识库问答助手。"
"请只基于下面提供的参考资料回答用户问题。"
"如果参考资料中没有相关信息,请如实说明。"
"回答时标注引用编号,如[1][2]。"
)),
]
# 原始对话历史原样传入
messages.extend(history[-self.max_history_turns * 2:])
# 检索结果作为上下文
messages.append(SystemMessage(content=f"参考资料:\n{context}"))
# 当前问题用原始question
messages.append(HumanMessage(content=question))
response = self.generate_model.invoke(messages)
return response.content
def ask(
self, question: str, history: list
) -> MultiTurnAnswer:
"""完整多轮问答入口"""
import time
start = time.time()
# 第1步:Query重写
rewritten = self.rewrite_query(question, history)
# 第2步:检索(用改写后的query)
docs = self.retrieve(rewritten)
# 第3步:生成(用原始对话历史)
if not docs:
answer = "抱歉,知识库中没有找到相关资料,请尝试换个问法。"
else:
answer = self.generate(question, history, docs)
latency = int((time.time() - start) * 1000)
return MultiTurnAnswer(
answer=answer,
rewritten_query=rewritten,
retrieved_docs=docs,
used_history_turns=min(
len(history) // 2, self.max_history_turns
),
latency_ms=latency,
)
5.3 代码结构说明
| 模块 | 职责 | 关键设计 |
|---|---|---|
rewrite_query |
把省略式问题补全为完整查询 | 无历史时直接返回原问题,省一次LLM调用 |
retrieve |
用改写后的query做向量检索 | 加分数过滤,低分文档不送入生成 |
generate |
用原始对话历史+检索结果生成回答 | 历史原样传入,不做改写 |
ask |
编排三步流程的入口 | 返回MultiTurnAnswer结构体,含调试信息 |
6. 对话历史窗口管理:截断策略与Token控制
6.1 为什么要截断
多轮对话如果不做截断,历史会无限膨胀:
第1轮:1条Human + 1条AI = 2条消息
第10轮:10条Human + 10条AI = 20条消息
第50轮:50条Human + 50条AI = 100条消息
100条消息全部传给LLM,不仅Token爆炸,还会引入大量噪声------早期对话的话题跟当前问题可能完全无关。
6.2 三种截断策略
策略一:固定轮次截断(最常用)
python
# 只保留最近3轮对话
max_history_turns = 3
recent_history = history[-(max_history_turns * 2):]
优点:简单直观,容易控制。缺点:不考虑消息长度,某轮回答特别长时Token可能超限。
策略二:Token预算截断
python
from langchain_core.messages import get_token_usage
def truncate_by_tokens(messages, max_tokens=2000):
"""按Token预算截断历史"""
total = 0
kept = []
for msg in reversed(messages):
token_count = len(msg.content) // 3 # 粗略估算
if total + token_count > max_tokens:
break
kept.insert(0, msg)
total += token_count
return kept
优点:精确控制Token。缺点:需要Token计算,略复杂。
策略三:滑动窗口+摘要(高级)
python
# 最近3轮保留原文
# 更早的对话用LLM生成摘要
def build_context_with_summary(history, summary_model):
recent = history[-6:] # 最近3轮
older = history[:-6] # 更早的
if older:
summary = summary_model.invoke(
f"请用100字总结以下对话的核心话题:\n{format(older)}"
)
return [SystemMessage(content=f"之前对话摘要:{summary}")] + recent
return recent
优点:兼顾上下文完整性和Token控制。缺点:多一次LLM调用,增加延迟。
生产建议:默认用策略一(固定3轮),当单轮回答较长时切换到策略二。
6.3 历史格式化中的细节
python
def _format_history(self, messages: list) -> str:
lines = []
for msg in messages[-self.max_history_turns * 2:]:
if isinstance(msg, HumanMessage):
lines.append(f"用户:{msg.content}")
elif isinstance(msg, AIMessage):
# AI回答只取前100字
lines.append(f"助手:{msg.content[:100]}...")
return "\n".join(lines)
为什么要截断AI回答:Query重写只需要知道"上一轮聊了什么话题",不需要AI回答的完整内容。截断到100字足够提供上下文,同时大幅减少Token消耗。
7. 工程落地:五个生产级细节
7.1 重写失败的兜底
python
def rewrite_query(self, question: str, history: list) -> str:
if not history:
return question
try:
chat_history = self._format_history(history)
rewritten = self.rewrite_chain.invoke({
"chat_history": chat_history,
"question": question,
})
rewritten = rewritten.strip()
# 兜底:如果重写结果为空或异常,回退到原始问题
if not rewritten or len(rewritten) < 3:
return question
return rewritten
except Exception:
# LLM调用失败,回退到原始问题
return question
原则:Query重写是"锦上添花",不能因为重写失败导致整个系统不可用。
7.2 重写结果缓存
python
from functools import lru_cache
import hashlib
def _cache_key(question: str, history: list) -> str:
# 把问题+历史哈希成缓存key
content = question + "".join(m.content for m in history[-6:])
return hashlib.md5(content.encode()).hexdigest()
# 高频相同问题直接命中缓存,跳过重写LLM调用
场景:用户反复问类似问题时,重写结果可以复用,省掉LLM调用成本。
7.3 第一轮不重写
python
def rewrite_query(self, question: str, history: list) -> str:
if not history:
# 第一轮对话,问题本身是完整的,不需要重写
return question
# ...后续轮次才重写
原因:第一轮没有历史上下文,用户的问题通常是完整的。此时调LLM重写是浪费。
7.4 检索与生成用不同模型
python
# 重写:小模型,temperature=0,max_tokens=100
self.rewrite_model = ChatOpenAI(
model="deepseek-chat",
temperature=0, # 确定性输出
max_tokens=100, # 重写结果不需要长
)
# 生成:可以用更大模型,temperature=0.3
self.generate_model = ChatOpenAI(
model="deepseek-chat",
temperature=0.3, # 允许一定创造性
)
原因:重写是"理解"任务,需要确定性;生成是"表达"任务,允许适度创造。
7.5 对话历史持久化
python
# 对话历史不能只存在内存里
# 服务重启后历史丢失,多轮对话直接断档
# 生产方案:持久化到数据库
class ConversationStore:
def save_message(self, session_id: str, role: str, content: str):
"""保存一条对话消息"""
# 写入conversations表
pass
def load_history(self, session_id: str, limit: int = 6):
"""加载最近N条对话"""
# 从conversations表读取
pass
关键:多轮对话的"多轮"依赖历史。历史丢了,重写就没了依据。
8. 验证脚本:完整多轮对话测试
python
"""多轮对话RAG验证脚本"""
from dotenv import load_dotenv
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_core.messages import HumanMessage, AIMessage
from multi_turn_rag import MultiTurnRAGService
load_dotenv()
def main():
# 初始化向量库(假设已建好索引)
embeddings = OpenAIEmbeddings(
model="text-embedding-v4",
api_key=os.getenv("EMBEDDING_API_KEY"),
base_url=os.getenv("EMBEDDING_BASE_URL"),
)
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
)
rag = MultiTurnRAGService(vectorstore=vectorstore)
# 模拟多轮对话
history = []
conversations = [
"BGE-M3 怎么做混合检索?", # 第1轮:完整问题
"那参数怎么调?", # 第2轮:指代词"那"
"ef设置多少合适?", # 第3轮:省略主语
"和IVF_FLAT比哪个快?", # 第4轮:省略上下文
]
for question in conversations:
print(f"\n{'='*60}")
print(f"用户:{question}")
result = rag.ask(question, history)
print(f"重写后:{result.rewritten_query}")
print(f"检索到:{len(result.retrieved_docs)} 条文档")
print(f"回答:{result.answer}")
print(f"耗时:{result.latency_ms}ms")
print(f"使用历史:{result.used_history_turns} 轮")
# 更新历史
history.append(HumanMessage(content=question))
history.append(AIMessage(content=result.answer))
# 验证点
print(f"\n{'='*60}")
print("验证要点:")
print("1. 第1轮不触发重写(无历史)")
print("2. 第2轮重写后应包含'BGE-M3混合检索'")
print("3. 第3轮重写后应包含'HNSW参数'")
print("4. 第4轮重写后应包含'BGE-M3 HNSW vs IVF_FLAT'")
print("5. 生成阶段用的是原始对话,不是重写后的query")
if __name__ == "__main__":
import os
main()
8.1 预期输出
============================================================
用户:BGE-M3 怎么做混合检索?
重写后:BGE-M3 怎么做混合检索? ← 第1轮不重写
检索到:5 条文档
回答:BGE-M3支持同时输出稠密向量和稀疏向量...
耗时:820ms
使用历史:0 轮
============================================================
用户:那参数怎么调?
重写后:BGE-M3做混合检索时相关参数如何调优 ← 指代消解
检索到:5 条文档
回答:BGE-M3混合检索主要涉及以下参数...
耗时:1150ms
使用历史:1 轮
============================================================
用户:ef设置多少合适?
重写后:BGE-M3混合检索HNSW索引ef参数推荐值 ← 主语补全
检索到:4 条文档
回答:HNSW的ef参数建议设置为...
耗时:980ms
使用历史:2 轮
9. 踩坑清单:多轮检索中的8个关键问题
| 序号 | 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|---|
| 1 | 检索用原始query | 后续轮次召回全是垃圾 | 指代词在向量空间无语义 | 必须Query重写后再检索 |
| 2 | 生成用改写query | 回答不连贯、生硬 | 改写丢失对话语境 | 生成阶段用原始对话历史 |
| 3 | 全部历史塞入重写 | Token爆炸、重写质量下降 | 历史过长噪声多 | 截断到最近2-3轮 |
| 4 | AI回答未截断 | 重写Prompt超长 | AI回答可能很长 | 重写历史中AI回答截断到100字 |
| 5 | 第一轮也重写 | 浪费一次LLM调用 | 无历史时问题已完整 | history为空时跳过重写 |
| 6 | 重写失败无兜底 | 系统直接报错 | LLM调用不稳定 | try-except回退到原始问题 |
| 7 | 历史不持久化 | 重启后多轮断裂 | 历史只存内存 | 持久化到数据库 |
| 8 | 重写和生成用同一模型 | 延迟高或质量差 | 两阶段需求不同 | 重写用小模型,生成用大模型 |
10. 面试速答版:30秒口述
多轮RAG检索最大的问题是用户存在指代、省略,直接拿当前问句检索效果很差。主流方案是Query重写:结合最近几轮对话,调用LLM把当前问题补全为完整查询,拿补全后的语句做知识库检索。注意区分:检索用改写后的query,最终大模型生成回答仍然要带上原始完整对话。同时要限制对话轮次做截断,防止上下文无限膨胀。
面试加分点(如果面试官追问):
- 重写用小模型,生成用大模型,分离关注点
- 重写失败要有兜底,回退到原始问题
- 第一轮对话不需要重写(无历史上下文)
- 高频问题加重写结果缓存,省LLM调用
- 对话历史要持久化,不能只存内存
11. 总结与延伸
11.1 核心知识点回顾
多轮RAG检索设计 = Query重写 + 检索生成分离 + 历史窗口管理
Query重写:
历史 + 当前问题 → 小模型 → 完整查询
检索生成分离:
检索用改写query(检索器无记忆,需要补全)
生成用原始对话(大模型有上下文理解能力)
历史窗口管理:
固定3轮截断(生产默认)
Token预算截断(长回答场景)
滑动窗口+摘要(高级方案)
11.2 延伸方向
- 意图识别 + 路由:先用LLM判断当前问题是否需要检索,闲聊类直接跳过RAG
- 多路召回 + Query扩展:对改写后的query再做同义词扩展,提升召回率
- LangGraph状态机:用LangGraph的条件路由替代关键词路由,实现更智能的多轮对话流程控制
- 对话摘要压缩:超过N轮后自动触发摘要,把早期对话压缩为100字摘要
12.文末互动
你在多轮对话 RAG 系统中遇到过哪些"答非所问"的情况?是用 Query 重写解决的,还是有其他方案?评论区聊聊你的实战经验。
思考题:如果一个用户在10轮对话后突然问了一个跟前面完全无关的新话题,Query 重写系统应该如何识别并处理这种"话题切换"?欢迎在评论区分享你的思路。
本文从面试高频题出发,完整拆解了多轮RAG检索的Query重写方案。如果觉得有帮助,欢迎点赞收藏。