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 约 10
30(企业内部系统),端到端 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 对 | 持续增长 |