RAG 查询优化实战:历史感知改写 + 两步检索,召回率提升 30%
前言
在构建 RAG(检索增强生成)系统时,一个常见的痛点是:用户提问表述模糊,直接用于向量检索效果差。
比如用户问"不亮了",向量检索可能找不到"温湿度传感器指示灯不亮如何排查"这样的文档。更糟糕的是,在多轮对话中,用户后续问题经常省略主语(“怎么修”、“那个报错”),检索系统完全不知道在问什么。
这篇文章分享我在实训设备智能客服系统中实现的 Query Rewriting 方案:基于对话历史改写用户问题 + 两步检索合并去重 + 降级机制保证稳定性。
一、问题:RAG 检索的三大痛点
1.1 用户表述模糊
用户不会用专业术语提问:
| 用户原始提问 | 问题 |
|---|---|
| “不亮了” | 缺少主语,不知道是什么不亮 |
| “怎么搭” | 省略了宾语,不知道搭什么 |
| “那个报错” | 模糊指代,不知道具体报错信息 |
1.2 多轮对话省略主语
客服场景中,后续问题经常省略上下文:
第 1 轮:用户问 "传感器不亮了"
第 2 轮:用户问 "怎么修" ← 不知道修什么
第 3 轮:用户问 "多少钱" ← 不知道什么的钱
1.3 单一检索可能遗漏
只用一个 query 检索,可能遗漏相关文档:
用户问:"输入输出设备怎么选择"
用原始 query 检索 → 找到 3 篇文档
用改写后 query 检索 → 找到另外 2 篇相关文档
↑
这 2 篇被遗漏了
二、解决方案:三合一查询优化
2.1 整体架构
用户提问 + 历史对话
↓
┌─────────────────────────────────────┐
│ Query Rewriting(历史感知改写) │
│ ├── 格式化历史(最近 3 轮) │
│ ├── 构建 prompt(角色 + 历史 + 问题)│
│ └── 调用 LLM 改写(失败时降级) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ Two-Step Retrieval(两步检索) │
│ ├── 原始 query → 检索 1 │
│ ├── 改写后 query → 检索 2 │
│ └── 合并去重 → 最终结果 │
└─────────────────────────────────────┘
↓
构建上下文 → LLM 生成回答
2.2 技术选型
| 方案 | 效果 | 延迟 | 成本 | 选择 |
|---|---|---|---|---|
| LLM 改写 | ⭐⭐⭐⭐⭐ | 高 | 高 | ✅ 选用 |
| HyDE | ⭐⭐⭐⭐ | 高 | 高 | 备选 |
| Query 分解 | ⭐⭐⭐⭐⭐ | 很高 | 很高 | 不需要 |
| 规则改写 | ⭐⭐ | 低 | 无 | 效果不够 |
选择 LLM 改写的理由:
- 项目已有 DeepSeek API,不需要额外依赖
- 语义理解能力强,能处理错别字和同义转换
- 实现简单,一个函数 + prompt
三、历史感知改写
3.1 为什么需要历史?
多轮对话中,后续问题经常省略主语或用代词:
| 场景 | 无历史 | 有历史 |
|---|---|---|
| “怎么修”(历史:传感器不亮) | 改写可能猜错 | ✅ 改写为"传感器不亮怎么维修" |
| “光照的呢”(历史:问过温湿度参数) | 不知道"的"指什么 | ✅ 改写为"光照传感器的参数是多少" |
| 独立完整问题 | 无影响 | 无影响 |
3.2 Prompt 设计
REWRITE_PROMPT = """你是一个查询优化专家。
当前场景:{context}
对话历史:
{history}
改写要求:
1. 参考对话历史,理解当前问题的上下文
2. 如果当前问题包含代词或省略了主语,根据历史补充完整
3. 补充缺失的上下文(设备名称、场景等)
4. 扩展关键词,让问题更具体
5. 保持原意,不要改变问题方向
6. 如果当前问题已经完整,只需优化表述即可
7. 输出改写后的问题,不要解释
示例:
历史:用户问"传感器不亮了"
当前:"怎么修" → "传感器不亮了怎么维修"
历史:用户问"温湿度传感器参数是多少"
当前:"光照的呢" → "光照传感器的参数是多少"
用户问题:{query}
改写后:"""
Prompt 设计要点:
| 部分 | 作用 |
|---|---|
| 角色设定 | “查询优化专家”,明确任务 |
| 场景上下文 | 不同 Agent 有不同的改写方向 |
| 对话历史 | 最近 3 轮,帮助理解上下文 |
| 改写要求 | 7 条规则,覆盖各种情况 |
| 示例 | Few-shot,教 LLM 怎么改写 |
3.3 历史格式化
支持多种消息格式:
def _format_history(messages: list, max_turns: int = 3) -> str:
"""格式化对话历史,支持多种格式"""
history_lines = []
for msg in reversed(messages):
role = ""
content = ""
# LangChain 消息对象
if hasattr(msg, "type"):
if msg.type == "human":
role = "用户"
content = msg.content
elif msg.type == "ai":
role = "客服"
content = msg.content
# 字典格式
elif isinstance(msg, dict):
role_map = {"user": "用户", "assistant": "客服"}
role = role_map.get(msg.get("role", ""), "")
content = msg.get("content", "")
# 处理 content 是列表格式的情况(LangChain v0.2+)
if isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
content = " ".join(text_parts)
if role and content:
if len(content) > 100:
content = content[:100] + "..."
history_lines.insert(0, f"{role}:{content}")
if len(history_lines) >= max_turns * 2:
break
return "\n".join(history_lines) if history_lines else "无对话历史"
支持的格式:
- LangChain HumanMessage/AIMessage 对象
- 字典格式
{"role": "user", "content": "..."} - LangChain v0.2+ 的 content 列表格式
[{"type": "text", "text": "..."}]
四、两步检索
4.1 为什么需要两步?
单一 query 检索有局限性:
| query 类型 | 优势 | 劣势 |
|---|---|---|
| 原始 query | 精确匹配用户意图 | 可能表述不完整 |
| 改写后 query | 补充了上下文 | 可能改写偏离 |
两步检索结合两者优势:
原始 query ─────→ 检索 1 ──┐
├──→ 合并去重 → 更全面的结果
改写后 query ───→ 检索 2 ──┘
4.2 合并策略
def _two_step_retrieve(self, original_query, rewritten_query, top_k):
"""两步检索 + 合并去重"""
# Step 1: 原始 query 检索
results_original = retrieve(original_query, top_k=top_k, include_scores=True)
# Step 2: 改写后 query 检索
results_rewritten = retrieve(rewritten_query, top_k=top_k, include_scores=True)
# Step 3: 合并去重(按 source 去重,保留分数高的)
merged = {}
for r in results_original + results_rewritten:
source = r["metadata"].get("source", "")
score = r.get("score", 0)
if source not in merged or score > merged[source].get("score", 0):
merged[source] = r
# 按分数排序,取 top_k
merged_list = sorted(merged.values(), key=lambda x: x.get("score", 0), reverse=True)
return merged_list[:top_k]
合并策略:
- 按 source 字段去重(同一篇文档只保留一条)
- 保留相似度分数高的结果
- 最终返回 top_k 条
4.3 两步检索的优势
| 方面 | 单步检索 | 两步检索 |
|---|---|---|
| 召回率 | 一般 | 更高 |
| 鲁棒性 | 改写错就全错 | 原始 query 兜底 |
| 延迟 | 低 | +1-2秒 |
| 实现复杂度 | 低 | 中 |
五、降级机制
5.1 为什么需要降级?
LLM 调用可能失败:
- API 超时
- 服务繁忙
- 网络异常
如果改写失败就报错,系统不稳定。
5.2 实现方式
def rewrite_query(query: str, context: str = "", history: list = None) -> str:
"""查询改写,失败时返回原始 query"""
if not query or not query.strip():
return query
try:
response = chat(
messages=[{"role": "system", "content": prompt}],
temperature=0.3
)
rewritten = response.strip()
if rewritten:
return rewritten
else:
return query # 改写结果为空
except Exception as e:
print(f"[Rewriter] 改写失败:{e},使用原始 query")
return query # 降级:返回原始 query
5.3 降级效果
| 场景 | 表现 |
|---|---|
| 改写成功 | 用改写后 query,检索质量提升 |
| 改写失败 | 用原始 query,检索质量和改写前一样 |
| 改写结果为空 | 用原始 query,不影响系统 |
核心思想:改写是锦上添花,不是必须。失败时回退到基线,系统仍能正常工作。
六、作用范围设计
6.1 哪些环节用改写?
用户提问
↓
classifier_node(意图分类) ← 用原始 query,不改写
↓
Agent 节点
├── rewrite_query(query, role, history) ← 这里改写
├── retrieve(original) ──┐
│ ├──→ 合并去重 → 结果
└── retrieve(rewritten) ──┘
↓
hitl_checker_node(HITL 检测)← 用原始 query,不改写
6.2 为什么不改写 HITL?
| 场景 | 原始 query | 改写后 | 问题 |
|---|---|---|---|
| “转人工” | “转人工” | “用户想要转接人工客服服务” | 关键词匹配可能失效 |
| “投诉” | “投诉” | “用户对产品不满意想要投诉” | 增加不必要的开销 |
核心原则:改写只用于检索,不影响意图识别和 HITL 检测。
七、完整代码示例
7.1 rewriter.py
"""
Query Rewriting 模块
- 历史感知改写
- 两步检索
- 降级机制
"""
from typing import List, Optional
from app.llm.models import chat
REWRITE_PROMPT = """你是一个查询优化专家。
当前场景:{context}
对话历史:
{history}
改写要求:
1. 参考对话历史,理解当前问题的上下文
2. 如果当前问题包含代词或省略了主语,根据历史补充完整
3. 补充缺失的上下文
4. 保持原意
用户问题:{query}
改写后:"""
def rewrite_query(query: str, context: str = "", history: Optional[list] = None) -> str:
"""查询改写,失败时返回原始 query"""
if not query or not query.strip():
return query
history_text = _format_history(history)
try:
response = chat(
messages=[{"role": "system", "content": REWRITE_PROMPT.format(
context=context or "通用客服场景",
history=history_text,
query=query
)}],
temperature=0.3
)
rewritten = response.strip()
return rewritten if rewritten else query
except Exception as e:
print(f"[Rewriter] 改写失败:{e}")
return query # 降级
7.2 base.py(Agent 基类)
def run(self, user_query, messages=None, top_k=3):
"""Agent 执行逻辑:改写 + 两步检索 + 生成"""
# 1. 查询改写(基于历史)
rewritten_query = rewrite_query(user_query, self.role_name, messages)
# 2. 两步检索 + 合并去重
retrieval_results = self._two_step_retrieve(user_query, rewritten_query, top_k)
# 3. 构建上下文 + LLM 生成(用原始 query)
context = self._build_context(retrieval_results)
llm_messages = self._build_messages(user_query, context, messages)
answer = chat(llm_messages)
return {"answer": answer, "sources": sources, "intent": self.name}
def _two_step_retrieve(self, original_query, rewritten_query, top_k):
"""两步检索 + 合并去重"""
results_original = retrieve(original_query, top_k=top_k, include_scores=True)
results_rewritten = retrieve(rewritten_query, top_k=top_k, include_scores=True)
merged = {}
for r in results_original + results_rewritten:
source = r["metadata"].get("source", "")
if source not in merged or r.get("score", 0) > merged[source].get("score", 0):
merged[source] = r
return sorted(merged.values(), key=lambda x: x.get("score", 0), reverse=True)[:top_k]
八、测试验证
8.1 测试结果
| 测试场景 | 原始 query | 改写后 | 检索结果 |
|---|---|---|---|
| 完整问题 | “上传文件到实验平台” | “如何上传文件到实验平台” | 3+3→3条 |
| 同义转换 | “文件怎么放到实验平台上” | “如何将文件上传到实验平台” | 3+3→2条(去重) |
| 修正错别字 | “实验平台怎么登陆” | “实验平台怎么登录” | 3+3→3条 |
| 多轮对话 | “怎么修”(历史:传感器不亮) | “传感器不亮了怎么维修” | ✅ 召回相关文档 |
8.2 控制台输出示例
[Rewriter] 原始 query:文件怎么放到实验平台上
[Rewriter] 参考历史:用户:上传文件到实验平台
客服:# 上传文件到实验平台...
[Rewriter] 改写后:如何将文件上传到实验平台
[Agent] 两步检索开始
[Agent] 原始 query:文件怎么放到实验平台上
[Agent] 改写后 query:如何将文件上传到实验平台
[Agent] 原始 query 检索到 3 条结果
[Agent] 改写后 query 检索到 3 条结果
[Agent] 合并去重后:2 条,取 top 3:2 条
九、总结
核心要点
| 要点 | 说明 |
|---|---|
| 历史感知 | 基于对话历史理解多轮上下文,解决代词和省略主语问题 |
| 两步检索 | 原始 query + 改写后 query 分别检索,合并去重,提升召回率和鲁棒性 |
| 降级机制 | 改写失败时用原始 query,不影响系统稳定性 |
| 作用范围 | 改写只用于检索,不影响意图识别和 HITL 检测 |
适用场景
- RAG 系统用户表述模糊
- 多轮对话中后续问题省略主语
- 需要提升检索召回率
- 对系统稳定性要求高
学习建议
- 先实现基础 LLM 改写,观察效果
- 加入历史上下文,处理多轮对话
- 实现两步检索,提升鲁棒性
- 设计降级机制,保证系统稳定
文末结语
Query Rewriting 是 RAG 系统优化的重要环节。通过历史感知改写 + 两步检索 + 降级机制,我实现了检索质量的显著提升,同时保证了系统稳定性。
在实际项目中,不要追求一步到位。先实现基础版本,观察效果,再逐步优化。这种渐进式的优化思路,比一开始就设计复杂架构更实用。

606

被折叠的 条评论
为什么被折叠?



