RAG 查询优化实战:历史感知改写 + 两步检索,召回率提升 30%

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 改写的理由:

  1. 项目已有 DeepSeek API,不需要额外依赖
  2. 语义理解能力强,能处理错别字和同义转换
  3. 实现简单,一个函数 + 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 系统用户表述模糊
  • 多轮对话中后续问题省略主语
  • 需要提升检索召回率
  • 对系统稳定性要求高

学习建议

  1. 先实现基础 LLM 改写,观察效果
  2. 加入历史上下文,处理多轮对话
  3. 实现两步检索,提升鲁棒性
  4. 设计降级机制,保证系统稳定

文末结语

Query Rewriting 是 RAG 系统优化的重要环节。通过历史感知改写 + 两步检索 + 降级机制,我实现了检索质量的显著提升,同时保证了系统稳定性。

在实际项目中,不要追求一步到位。先实现基础版本,观察效果,再逐步优化。这种渐进式的优化思路,比一开始就设计复杂架构更实用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值