Rag中Query改写的实践方案总结

RAG Query 改写最佳实践 --- 从入门到落地

本文档系统梳理 RAG(检索增强生成)系统中 Query 改写的核心策略、工程实现、迭代路径和面试要点,基于企业知识库问答场景的实战总结。

一、为什么需要 Query 改写

RAG 的核心流程是"先检索再生成",但用户的提问方式和知识库里文档的表述方式往往存在语义鸿沟,导致检索命中率不高。典型问题有三类:

口语化 vs 书面化:用户问"东西不想要了咋弄",知识库里写的是"自签收之日起7天内可申请无理由退货"。两者语义相同但用词完全不同,向量相似度很低。

指代不明:多轮对话中用户说"它具体有哪些权益","它"指的是上一轮提到的"金卡会员",但单独拿这句话去检索,完全不知道在问什么。

复合意图:用户一句话包含多个需求,比如"我想给公司采购一批办公用品,有什么优惠?怎么付款和开发票?",需要拆解后分别检索。

Query 改写的目标就是解决这些问题,让检索用的 query 尽可能贴近知识库文档的表述方式。

二、核心改写策略

2.1 指代消解(Context Resolution)

解决的问题:多轮对话中的指代词("它""这个""那个""他")和省略的背景信息。

原理:将最近几轮对话历史与当前问题一起交给 LLM,重构为一个独立完整、无需上下文也能理解的问题。

实现代码

python 复制代码
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

# 改写用小模型,控制成本和延迟
rewrite_llm = ChatOpenAI(
    model="qwen-7b",         # 小模型即可,不需要最强模型
    temperature=0.0,         # 确定性输出
    max_tokens=512,
)

CONTEXT_RESOLVE_PROMPT = ChatPromptTemplate.from_messages([
    ("system",
     "你是一个查询改写助手。将用户在多轮对话中的最新问题,"
     "改写为一个完整、独立、无需上下文也能理解的问题。\n\n"
     "规则:\n"
     "1. 将「它」「这个」「那个」等指代词替换为具体对象\n"
     "2. 补充对话历史中隐含的背景信息\n"
     "3. 如果当前问题已经是独立的,直接原样输出\n"
     "4. 只输出改写后的问题,不要任何解释"),
    ("human",
     "对话历史:\n{chat_history}\n\n"
     "用户最新问题:{query}\n\n"
     "改写后的独立问题:"),
])

async def resolve_context(query: str, chat_history: list[dict] | None = None) -> str:
    if not chat_history:
        return query

    # 只取最近 3 轮(6条消息),够用且省 token
    history_text = "\n".join(
        f"{'用户' if m['role'] == 'user' else '客服'}: {m['content']}"
        for m in chat_history[-6:]
    )

    chain = CONTEXT_RESOLVE_PROMPT | rewrite_llm
    result = await chain.ainvoke({"chat_history": history_text, "query": query})
    rewritten = result.content.strip()
    return rewritten if rewritten else query

效果举例

对话历史 当前问题 改写结果
用户: 你们会员等级怎么分的 / 客服: 分为普通、银卡、金卡、黑金四个等级 它具体有哪些权益 金卡会员具体享有哪些权益
东西不想要了咋弄 东西不想要了咋弄(首轮直接返回)

注意事项:指代消解发生在检索之前,纯靠 LLM 做文本改写,不涉及检索。改写后的 query 已经是独立完整的,后续不管走哪条检索路径都能用。

2.2 HyDE(Hypothetical Document Embeddings)

解决的问题:用户口语化提问与知识库书面化文档之间的语义鸿沟。

原理:不直接用问题去检索,而是让 LLM 先生成一段"假想的理想答案",然后用这段虚构文本的 embedding 去匹配真实文档。因为假想答案的表述风格更接近知识库文档,向量相似度更高。

实现代码

python 复制代码
HYDE_PROMPT = ChatPromptTemplate.from_messages([
    ("system",
     "你是一个知识库文档生成助手。根据用户的问题,生成一段假想的理想答案。\n\n"
     "要求:\n"
     "1. 答案的风格要接近正式的企业知识库文档(专业、书面化)\n"
     "2. 不需要100%准确,关键是语义上覆盖可能的答案方向\n"
     "3. 长度控制在 3-5 句话\n"
     "4. 只输出假想答案,不要任何前缀或解释"),
    ("human", "问题:{query}\n\n请生成一段假想的标准答案文档:"),
])

async def generate_hyde(query: str) -> str:
    chain = HYDE_PROMPT | rewrite_llm
    result = await chain.ainvoke({"query": query})
    return result.content.strip()

效果举例

用户原始问题 HyDE 生成的假想文档
东西不想要了咋弄 用户可在签收7天内通过APP订单页面提交退货申请,商品需未经使用且包装完好,审核通过后48小时内寄回商品,运费由用户承担。
发票 订单完成后可在"我的订单"中申请电子发票,普通发票1个工作日内开具,增值税专用发票需5个工作日。

为什么要同时检索原始 query 和 HyDE 文档:原始问题擅长关键词精确匹配(万一知识库里有"不想要"这个词),HyDE 文档擅长语义相似匹配(表述更接近知识库风格)。两者互补,合并后召回面更广。

什么时候 HyDE 没用:如果用户提问本身就很规范(如技术文档搜索"Kafka 消费组 rebalance 机制"),或者知识库是 FAQ 问答对形式,直接检索效果就够好,HyDE 反而可能引入噪音。建议先不开 HyDE 跑基准测试,再开 HyDE 对比效果,数据说话。

2.3 多查询扩展(Multi-Query)

解决的问题:单一查询可能遗漏从不同角度表述的相关文档。

原理:让 LLM 从不同角度生成 2~3 个语义等价但表述不同的查询,分别检索后合并去重。

python 复制代码
MULTI_QUERY_PROMPT = ChatPromptTemplate.from_messages([
    ("system",
     "你是一个查询扩展助手。针对用户的问题,生成 2 个语义相同但表述不同的检索查询。\n\n"
     "要求:\n"
     "1. 每个查询从不同角度或使用不同措辞\n"
     "2. 每行输出一个查询,不要编号和解释"),
    ("human", "原始问题:{query}"),
])

注意:不要扩展太多(2~3 个就够),否则延迟和成本线性增长。

2.4 问题分解(Decomposition)

解决的问题:复合问题包含多个意图,需要分别检索再融合结果。

原理:将复杂问题拆解为多个独立子问题,分别检索后合并结果。

python 复制代码
DECOMPOSE_PROMPT = ChatPromptTemplate.from_messages([
    ("system",
     "你是一个问题分解助手。将包含多个意图的复杂问题拆分为独立的子问题。\n\n"
     "要求:\n"
     "1. 每个子问题可以独立检索\n"
     "2. 如果问题只包含一个意图,原样输出即可\n"
     "3. 每行输出一个子问题"),
    ("human", "复杂问题:{query}"),
])

举例:用户问"对比一下 A 产品和 B 竞品的价格和售后政策",拆分为:A 产品的价格是多少、B 竞品的价格是多少、A 产品的售后政策是什么、B 竞品的售后政策是什么。分别检索后综合回答。

三、检索层:向量检索 + BM25 + RRF + Reranker

Query 改写完成后,进入检索环节。检索层的设计同样影响最终效果。

3.1 向量检索

将文本通过 embedding 模型转为高维向量(如 1536 维),存入向量数据库(ChromaDB/Milvus),查询时把 query 也转向量,通过 HNSW 索引快速找到最近的 top-K 个文档向量。距离度量用余弦相似度。

python 复制代码
# 文档入库
vector = embedding_model.embed_documents([text])  # 文本 → 1536维向量
collection.add(ids=["doc_001"], documents=[text], embeddings=vector)

# 查询
query_vector = embedding_model.embed_documents([query])
results = collection.query(query_embeddings=query_vector, n_results=10)

向量数据库选型:ChromaDB 适合 POC 和小项目(pip install 即用,嵌入式,数据存本地),Milvus 适合生产环境(独立服务,支持分布式集群、多租户、多种索引类型)。项目初期用 ChromaDB,上线后数据量和 QPS 上来再迁移到 Milvus,迁移成本不高。

3.2 BM25 关键词检索

向量检索的盲区是精确标识符匹配。用户搜订单号"ORD-20260701-12345"、产品型号、错误码、手机号时,embedding 模型无法理解这些字符串的语义,向量相似度基本随机。BM25(TF-IDF 的改进版)天然擅长精确匹配。

python 复制代码
from rank_bm25 import BM25Okapi
import jieba  # 中文分词

# 索引
corpus = [doc["content"] for doc in docs]
tokenized = [list(jieba.cut(text)) for text in corpus]
bm25 = BM25Okapi(tokenized)

# 检索
query_tokens = list(jieba.cut(query))
scores = bm25.get_scores(query_tokens)
top_indices = scores.argsort()[::-1][:top_k]

如果场景只有查订单号这种精确标识符,BM25 一路检索就够了,不需要向量检索、HyDE、RRF 和 Reranker。

3.3 RRF(Reciprocal Rank Fusion)

当有多路检索结果(向量 + BM25,或 resolved_query + HyDE 文档)需要合并时,各路分数不在同一量级(余弦相似度 vs BM25 分),没法直接比较大小。RRF 只看排名不看分数,解决这个问题。

公式score(doc) = Σ 1 / (k + rank),k 通常取 60。

python 复制代码
rrf_scores = {}
k = 60

for query_results in all_retrieval_paths:
    for rank, doc in enumerate(query_results):
        rrf_scores[doc.id] = rrf_scores.get(doc.id, 0) + 1.0 / (k + rank + 1)

# 按 RRF 分数排序
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)

一篇文档被多路都召回且排名靠前,RRF 分数就高。RRF 放在初筛之后、Reranker 之前。如果只有一路检索,不需要 RRF。

3.4 Reranker(Cross-Encoder 精排)

向量检索用的是 Bi-Encoder(query 和 doc 分别编码再算余弦),速度快但精度一般。Reranker 用 Cross-Encoder(query 和 doc 拼在一起同时编码),能捕捉更细粒度的语义关系,打分更准但计算量大,所以只用于重排少量候选。

python 复制代码
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-base")

# 构造 (query, document) pair
pairs = [(query, f"{doc.title} {doc.content}") for doc in candidates]
scores = reranker.predict(pairs)

# 用新分数排序,取 Top-3
for doc, score in zip(candidates, scores):
    doc.score = float(score)
candidates.sort(key=lambda d: d.score, reverse=True)
final_docs = candidates[:3]

模型选择:bge-reranker-base(约 270M 参数,中文效果好,10 篇文档重排约 200ms)。也试过 bge-reranker-large,精度高 1 个点但延迟翻倍,性价比不高。

四、意图路由:根据 query 类型走不同路径

不是所有 query 都需要全部策略,意图路由器根据 query 特征动态选择检索路径。

4.1 话题切换检测

多轮对话中用户可能突然换话题,强制拼历史上下文会改坏。用 embedding 相似度判断:

python 复制代码
import numpy as np

TOPIC_THRESHOLD = 0.65  # 低于此值认为是新话题

def is_topic_continued(query: str, chat_history: list[dict]) -> bool:
    last_user_msg = None
    for msg in reversed(chat_history):
        if msg["role"] == "user":
            last_user_msg = msg["content"]
            break
    if not last_user_msg:
        return False

    embeddings = embedding_model.embed_documents([query, last_user_msg])
    a, b = np.array(embeddings[0]), np.array(embeddings[1])
    similarity = float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
    return similarity >= TOPIC_THRESHOLD

4.2 完整路由逻辑

python 复制代码
import re

def route_query(query: str, chat_history: list[dict] | None) -> dict:
    # ── 第一步:话题检测 → 是否走指代消解 ──
    need_resolve = False
    if chat_history and len(chat_history) >= 2:
        if is_topic_continued(query, chat_history):
            need_resolve = True  # 同一话题,需要消解指代
        # 新话题不消解,当首轮处理

    # ── 第二步:复杂任务检测 → 是否走 Agentic RAG ──
    agent_keywords = ["对比", "比较", "趋势", "汇总", "报表", "图表", "分析"]
    if any(kw in query for kw in agent_keywords) and len(query) > 15:
        return {"need_resolve": need_resolve, "mode": "agentic"}

    # ── 第三步:精确标识符检测 → 走 BM25 ──
    exact_patterns = [
        r'[A-Z]{2,}[-_]\d{4,}',   # ORD-12345, SKU-A8832
        r'ERR[_-]\w+',             # ERR_CONN_TIMEOUT
        r'1[3-9]\d{9}',            # 手机号
        r'\d{6,}',                 # 纯数字编号
    ]
    if any(re.search(p, query) for p in exact_patterns):
        return {"need_resolve": need_resolve, "mode": "keyword"}

    # ── 第四步:语义类 query → 按长度决定是否 HyDE ──
    if len(query) <= 8:
        mode = "vector_hyde"   # 短且模糊,HyDE 收益最大
    else:
        mode = "vector"        # 正常问题,直接向量检索

    return {"need_resolve": need_resolve, "mode": mode}

4.3 主流程调度

python 复制代码
async def rag_main(query: str, chat_history: list[dict] | None = None):
    route = route_query(query, chat_history)

    # Step 1: 指代消解(需要时)
    resolved = await resolve_context(query, chat_history) if route["need_resolve"] else query

    # Step 2: 根据路由走不同检索路径
    if route["mode"] == "agentic":
        return await agent_rag.run(resolved)

    elif route["mode"] == "keyword":
        docs = bm25_search(resolved)  # 只跑 BM25,不走 Reranker

    elif route["mode"] == "vector_hyde":
        hyde_doc = await generate_hyde(resolved)
        candidates = vector_retrieve([resolved, hyde_doc])  # 双路检索
        merged = rrf_merge(candidates)                       # RRF 合并
        docs = rerank(resolved, merged, top_k=3)             # 精排

    elif route["mode"] == "vector":
        candidates = vector_retrieve([resolved])
        docs = rerank(resolved, candidates, top_k=3)

    # Step 3: LLM 生成回答
    return await generate_answer(resolved, docs)

五、Pipeline 迭代演进路径

实际落地不是一步到位,而是根据数据反馈逐步演进:

阶段一:基础版 --- 纯向量检索

只做向量检索 + LLM 生成,跑通最小闭环。上线后收集真实用户 query 和 badcase。此时 Hit@5 大概 70~75%。

阶段二:加指代消解

分析 badcase 发现多轮对话中检索偏了,加上指代消解。Hit@5 提升到 80% 左右。

阶段三:加 HyDE

分析 badcase 发现口语化问题召回不到,加上 HyDE 双路检索 + RRF 合并。Hit@5 提升到 88% 左右。

阶段四:加 Reranker

分析 badcase 发现初筛召回了但排序不准(相关文档排在后面),加上 Cross-Encoder Reranker。Hit@5 提升到 92% 左右。

阶段五:加意图路由和 BM25

发现精确标识符查不到、复合问题处理不好,加上意图路由动态选路、BM25 关键词检索、问题分解。Hit@5 到 95% 左右。

阶段六:复杂场景引入 Agentic RAG

固定 pipeline 处理不了跨数据源多步推理(如"对比去年和今年营收并生成图表"),对这类复杂需求走 Agent 自主规划。

每个阶段都应该有数据支撑:先跑基准测试看当前指标,改完后对比效果,提升明显才合并。不要凭感觉加组件。

六、Agentic RAG vs 固定 Pipeline

固定 Pipeline Agentic RAG
流程 写死顺序:路由→改写→检索→精排→生成 Agent 自主规划:决定用哪些工具、走几步
可控性 高,每步输入输出可追踪 低,同样问题可能走不同路径
延迟 可预期(3~4秒) 不可预期(可能多轮循环)
成本 固定(2~3次 LLM 调用) 动态(可能 5~8 次 LLM 调用)
适用场景 文档类型统一、问题可预期 多数据源、复杂分析、跨系统查询
框架 LangChain + ChromaDB/Milvus LangGraph(推荐)/ LlamaIndex Workflow

推荐策略:固定 pipeline 兜底日常 80% 的简单问答,Agentic RAG 处理 20% 的复杂分析需求。在意图路由层做分流。

七、完整 Pipeline 架构图

markdown 复制代码
用户提问
  │
  ▼
┌──────────────────────────────────────┐
│  意图路由器                           │
│  ├─ 话题检测 → 是否走指代消解         │
│  ├─ 精确标识符检测 → BM25            │
│  ├─ 复杂度检测 → Agentic RAG        │
│  └─ 长度判断 → 是否走 HyDE           │
└──────────┬───────────────────────────┘
           │
     ┌─────┼──────────────────┐
     ▼     ▼                  ▼
   BM25  向量检索          Agentic RAG
     │     │              (LangGraph)
     │     ├─ HyDE 双路检索
     │     └─ RRF 合并
     │     │
     ▼     ▼
   Reranker 精排(BM25路径可跳过)
     │
     ▼
   LLM 生成回答(带来源标注)

八、面试要点速查

8.1 文档量怎么说

别说"几万篇"这种笼统数字,要精确到分片后的 chunk 数量。参考话术:

原始文档约 2000 多篇(企业制度、产品手册、FAQ 等),按 512 token 分片加 50 token overlap,切完约 1.2 万个 chunk。向量库用的 Milvus,embedding 维度 1536,索引类型 HNSW(ef_construction=200, M=16),索引占用约 200MB。

追问分片策略时可以说:一开始用固定长度切,发现有些文档把完整的政策条款切断了,召回的 chunk 上下文不完整。后来改成按段落边界切,表格和列表做特殊处理不跨块切分。

8.2 QPS 和延迟怎么说

峰值 QPS 约 1030(企业内部系统),端到端 P95 约 34 秒。其中指代消解 500ms、HyDE 500ms(都用小模型)、向量检索 50ms、Reranker 200ms、LLM 生成 1.5s。优化措施:改写用小模型、相同 query 短期缓存、SSE 流式输出。

追问 QPS 到 100 怎么办:改写和 Reranker 可水平扩实例,瓶颈在生成侧大模型调用,方案是接推理服务的 batching 或对高频问题做结果缓存。

8.3 召回指标怎么说

评测集是人工标注的 200 条 QA 对(客服团队从真实用户提问中抽取),标注了每个问题应命中的文档。Hit@5 约 92%,Hit@3 约 85%,MRR 约 0.78。

追问优化过程:上线第一周 Hit@5 只有 75%,分析 badcase 发现三类问题------口语化严重召回不到(加 HyDE 提升 8 个点)、多轮指代没消解(加指代消解提升 5 个点)、短文档信息不完整(对短文档做标题+正文拼接再入索引)。三轮优化后从 75% 到 92%。

8.4 高频追问怎么接

"评测集怎么建的?" --- 前期让客服从真实提问里抽 200 条人工标注,每周从线上日志抽 badcase 补充,测试集持续增长。

"HyDE 有没有引入噪音?" --- 约 5% 的 case HyDE 假想文档和知识库风格差异太大反而拉低召回。处理方式是 HyDE 和原始 query 都做检索,RRF 合并后 Reranker 精排,即使 HyDE 那路偏了原始 query 那路还在。

"Reranker 为什么选 bge-reranker-base?" --- 中文效果好、模型小(270M 参数)、推理快(10 篇重排 200ms 内)。试过 large 版本精度高 1 个点但延迟翻倍,不划算。

"为什么用 Milvus 而不是 pgvector?" --- 评估过 pgvector,文档量到 50 万 chunk 后检索性能下降,且不支持 HNSW 之外的索引类型。项目初期用 ChromaDB 做 POC,数据量上来后迁移到 Milvus。

"为什么用了三个数据库?" --- MySQL 做业务数据和权限,Milvus 做语义检索,Redis 做热查询缓存和会话管理。不是非要三个,初期两个(MySQL + Milvus)就能跑,Redis 是 QPS 上来后为降低延迟加的。

8.5 关键数字速记

指标 参考值 备注
原始文档量 ~2000 篇 企业制度/产品手册/FAQ
分片后 chunk 数 ~12000 512 token + 50 overlap
Embedding 维度 1536 text-embedding-3-small
向量库 Milvus(生产)/ ChromaDB(POC) HNSW 索引
峰值 QPS 10~30 企业内部系统
端到端 P95 延迟 3~4 秒 含改写+检索+精排+生成
Hit@5 92% 优化后
Hit@3 85% 优化后
MRR 0.78 优化后
Reranker bge-reranker-base 270M 参数,200ms/10篇
评测集 200 条 QA 对 持续增长
相关推荐
阿部多瑞 ABU1 小时前
论“轻小说”之异化
人工智能
墨染天姬1 小时前
【AI】opencode 使用手册
人工智能
2601_956319881 小时前
2026年下半年AI量化学习,分清表达开发和验证
人工智能·python
2601_956865771 小时前
AI企业内训的“效果转化”密码:从“学AI”到“用AI”的机构能力拆解
大数据·人工智能
sunywz1 小时前
【AI RAG知识库】02.模块流程设计
人工智能
刘海东刘海东1 小时前
我的新论文的构思
人工智能
天佑木枫1 小时前
AI:AI 开车撞了人,谁赔钱?——自动驾驶的法律黑洞
人工智能·机器学习·自动驾驶
云飞云共享云桌面1 小时前
智能装备制造数字化实测:10人SolidWorks云桌面部署,云飞云方案替代传统单机工作站
运维·服务器·网络·人工智能·制造
江华森1 小时前
人工智能 AI 大语言模型 多模态 — 从 API 调用到 Agent 实战
人工智能·语言模型·自然语言处理