检索后优化:让 RAG 结果更精准
前两篇文章讲了文档切分和向量检索。假设你已经有了切分好的 chunk、选好了 Embedding 模型、搭好了混合检索。现在用户问了一个问题,你拿到了 top-k 个检索结果——然后呢?
很多人以为检索结束就直接把结果塞进提示词让模型回答。实际上,在"检索结果"和"塞进提示词"之间,还有一系列优化环节。这些环节往往决定了 RAG 系统的最终效果。
一、Query 改写
用户的问题往往不是最优的检索查询。
- 表述模糊:"那个东西怎么用?"——"那个东西"是什么?
- 过于宽泛:"介绍一下 LangChain"——要介绍哪方面?
- 包含多个子问题:"LangChain 和 LlamaIndex 的区别是什么,分别适合什么场景?"——这其实是两个问题。
- 措辞和文档不一致:用户说"怎么申请休假",文档里写"请假流程"。
Query 改写(Query Rewriting)在检索前对用户的问题做优化,让检索更精准。
1.1 基础改写:让 LLM 优化查询
最简单的做法:让 LLM 把用户的问题改写成更适合检索的形式。
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
rewrite_prompt = ChatPromptTemplate.from_template(
"你是一个搜索优化助手。将用户的问题改写成一个更精准、更具体的搜索查询。"
"只输出改写后的查询,不要其他内容。\n\n用户问题:{query}"
)
rewrite_chain = rewrite_prompt | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser()
# 原始查询
original_query = "那个框架怎么处理外部数据?"
# 改写后的查询
rewritten_query = rewrite_chain.invoke({"query": original_query})
# → "LangChain 框架如何接入外部数据源(PDF、数据库、API)进行检索增强生成(RAG)"
# 用改写后的查询检索
results = retriever.invoke(rewritten_query)
1.2 多查询改写:从多个角度提问
同一个问题,从不同角度改写,生成多个查询,合并检索结果。
from langchain.retrievers import MultiQueryRetriever
# 自动生成 3 个不同角度的查询
multi_query_retriever = MultiQueryRetriever.from_llm(
retriever=base_retriever,
llm=ChatOpenAI(model="gpt-4o-multi"),
prompt=ChatPromptTemplate.from_template(
"用户的问题是:{query}\n"
"请生成 3 个不同角度的搜索查询,每个查询一行。"
)
)
# 原始查询:"LangChain 的核心模块有哪些?"
# 可能生成:
# 1. "LangChain 六大核心模块 LLM Chain LCEL RAG Agent Memory"
# 2. "LangChain 模块架构和功能介绍"
# 3. "LangChain 各模块解决什么问题 使用场景"
results = multi_query_retriever.invoke("LangChain 的核心模块有哪些?")
1.3 问题拆解:Step-back Prompting
对于复杂问题,先"退一步",拆解成多个子问题,分别检索,再合并结果。
# 原始问题(复杂)
query = "LangChain 和 LlamaIndex 在处理大规模文档检索时的性能差异是什么?"
# Step-back:拆解成子问题
sub_queries = [
"LangChain 如何处理大规模文档检索?",
"LlamaIndex 如何处理大规模文档检索?",
"LangChain 和 LlamaIndex 的性能基准测试对比",
]
# 分别检索
all_results = []
for sub_query in sub_queries:
results = retriever.invoke(sub_query)
all_results.extend(results)
# 去重、合并
final_results = deduplicate(all_results)
1.4 Query 改写的适用场景
| 场景 | 改写方式 | 效果 |
|---|---|---|
| 表述模糊 | 基础改写 | 让查询更具体 |
| 过于宽泛 | 多查询改写 | 从多个角度覆盖 |
| 包含多个子问题 | 问题拆解 | 分别检索,合并结果 |
| 措辞不一致 | 同义词扩展 | 匹配文档中的表述 |
我的建议:先加基础改写,效果不够再加多查询改写。问题拆解适合复杂查询,但会增加延迟和成本。
二、HyDE:假设文档嵌入
HyDE(Hypothetical Document Embeddings)是一种巧妙的检索优化技术。它的核心思路是:不让问题和 chunk 匹配,而是让"假设答案"和 chunk 匹配。
2.1 传统检索 vs HyDE
传统检索:把用户问题向量化 → 在向量数据库中找最相似的 chunk。
问题在于:问题和 chunk 的表述方式往往不同。用户问"怎么申请休假",文档里写"员工请假流程如下"。两个文本语义相关,但向量不一定很近。
HyDE:
- 用户提出问题
- 让 LLM 生成一个"假设答案"(不需要是真的,只需要在语义空间中和真实答案接近)
- 把假设答案向量化
- 在向量数据库中找和假设答案最相似的 chunk
为什么有效?因为假设答案和文档 chunk 都是"陈述性文本",它们的表述方式更接近,向量空间中的距离也更近。
2.2 HyDE 的实现
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
# HyDE 提示词
hyde_prompt = ChatPromptTemplate.from_template(
"用户问了一个问题。请生成一个假设性的回答段落,"
"这个回答应该包含用户问题可能的答案。"
"只输出假设回答,不要其他内容。\n\n问题:{query}"
)
# 生成假设答案
hyde_chain = hyde_prompt | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser()
hypothetical_answer = hyde_chain.invoke({"query": "LangChain 的 LCEL 是什么?"})
# → "LCEL(LangChain Expression Language)是 LangChain 的表达式语言,
# 使用管道符号 | 连接组件来定义 AI 处理流程。例如 prompt | model | parser
# 定义了一个完整的 AI 处理链。LCEL 支持流式输出、批处理、异步执行。"
# 用假设答案检索
results = retriever.invoke(hypothetical_answer)
2.3 HyDE 的效果
HyDE 在以下场景特别有效:
- 用户问题和文档表述差异大
- 查询很短、很模糊
- 文档是专业领域的,用户用日常语言提问
但 HyDE 也有代价:多一次 LLM 调用(增加延迟和成本),而且假设答案的质量直接影响检索效果。
三、多路召回
不同的检索器擅长不同的东西。多路召回用多个检索器并行检索,合并结果。
3.1 为什么需要一路召回
| 检索器 | 擅长 | 不擅长 |
|---|---|---|
| 向量检索 | 语义匹配 | 精确关键词 |
| BM25 | 精确关键词 | 语义理解 |
| 图谱检索 | 实体关系 | 自由文本 |
| 知识图谱 | 结构化知识 | 非结构化文本 |
3.2 多路召回的实现
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
# 路 1:向量检索
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
# 路 2:BM25 关键词检索
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5
# 路 3:HyDE 检索
# (用假设答案检索,见上一节)
# 合并三路结果
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.5, 0.5]
)
results = ensemble_retriever.invoke("LangChain 的 LCEL 是什么?")
3.3 去重和融合
多路召回的结果需要去重。同一个 chunk 可能被多个检索器返回,需要合并分数。
def merge_results(retriever_results_list):
"""合并多路检索结果,去重并重新排序"""
chunk_map = {} # {chunk_id: {chunk, scores: []}}
for results in retriever_results_list:
for chunk in results:
chunk_id = chunk.metadata.get("chunk_id", hash(chunk.page_content))
if chunk_id not in chunk_map:
chunk_map[chunk_id] = {"chunk": chunk, "scores": []}
chunk_map[chunk_id]["scores"].append(chunk.metadata.get("score", 0))
# 合并分数(取最大值或平均值)
merged = []
for item in chunk_map.values():
max_score = max(item["scores"])
item["chunk"].metadata["score"] = max_score
merged.append(item["chunk"])
return sorted(merged, key=lambda x: x.metadata["score"], reverse=True)
四、上下文压缩
检索到的 chunk 可能包含大量和查询无关的内容。上下文压缩提取出和查询最相关的部分,减少噪声,节省 token。
4.1 LLM 压缩
用 LLM 从 chunk 中提取和查询相关的部分:
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_core.prompts import ChatPromptTemplate
# 压缩提示词
compress_prompt = ChatPromptTemplate.from_template(
"给定以下文档片段和用户问题,提取出和用户问题最相关的部分。"
"只保留直接回答问题所需的内容,删除无关信息。\n\n"
"用户问题:{question}\n\n文档片段:{context}\n\n提取结果:"
)
compressor = LLMChainExtractor.from_llm_and_prompt(
llm=ChatOpenAI(model="gpt-4o-mini"),
prompt=compress_prompt
)
# 压缩检索器
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=base_retriever
)
# 检索并压缩
compressed_results = compression_retriever.invoke("LangChain 的 LCEL 是什么?")
# 返回的是压缩后的 chunk,只包含和查询相关的部分
4.2 截断压缩
更简单的方式:只保留 chunk 的前 N 个 token。
def truncate_chunks(chunks, max_tokens_per_chunk=300):
"""简单截断:只保留每个 chunk 的前 N 个 token"""
truncated = []
for chunk in chunks:
tokens = chunk.page_content.split()[:max_tokens_per_chunk]
chunk.page_content = " ".join(tokens)
truncated.append(chunk)
return truncated
4.3 压缩的权衡
| 方式 | 优点 | 缺点 |
|---|---|---|
| LLM 压缩 | 精准,只保留相关内容 | 慢,贵,增加延迟 |
| 截断压缩 | 快,便宜 | 可能丢失关键信息 |
| 不压缩 | 保留完整上下文 | 噪声多,浪费 token |
建议:如果 chunk 平均长度超过 500 token,考虑用 LLM 压缩。如果 chunk 本身就很短(200-300 token),不需要压缩。
五、幻觉抑制
RAG 最大的痛点之一是幻觉——模型不基于检索结果回答,而是编造信息。
5.1 提示词约束
最简单也最有效的方法:在提示词中明确要求模型只基于检索结果回答。
prompt = ChatPromptTemplate.from_messages([
("system", """你是一个基于检索结果的问答助手。
规则:
1. 只基于提供的上下文回答问题
2. 如果上下文不包含相关信息,明确说明"根据提供的信息,我无法回答这个问题"
3. 不要编造信息,不要结合你的训练数据回答
4. 如果上下文中的信息有矛盾,指出矛盾之处
"""),
("human", "上下文:{context}\n\n问题:{question}")
])
5.2 引用溯源
要求模型在回答时标注信息来源,方便用户验证:
prompt = ChatPromptTemplate.from_messages([
("system", """基于提供的上下文回答问题。
在回答的最后,用 [1] [2] [3] 的形式标注信息来源。
每个标注对应上下文中提供的文档片段编号。"""),
("human", "上下文:\n{context}\n\n问题:{question}")
])
5.3 一致性验证
用一个独立的 LLM 验证生成回答是否和检索结果一致:
def verify_answer(answer, context):
"""验证回答是否基于上下文"""
verify_prompt = ChatPromptTemplate.from_template(
"给定以下上下文和回答,判断回答是否完全基于上下文。
如果回答包含了上下文中没有的信息,标记为'幻觉'。\n\n"
"上下文:{context}\n\n回答:{answer}\n\n"
"判断结果(一致/幻觉):"
)
result = llm.invoke(verify_prompt.format(context=context, answer=answer))
return "幻觉" not in result.content
5.4 幻觉抑制的层次
| 层次 | 方法 | 效果 | 成本 |
|---|---|---|---|
| 提示词约束 | 明确要求只基于上下文 | 基础防护 | 零 |
| 引用溯源 | 标注信息来源 | 方便验证 | 低 |
| 一致性验证 | 独立 LLM 验证 | 最强防护 | 高(多一次 LLM 调用) |
建议:至少做到提示词约束 + 引用溯源。如果对幻觉零容忍(如医疗、法律场景),加上一致性验证。
六、完整的检索后优化流程
把上面所有环节串起来:
用户提问
│
▼
┌─────────────────────┐
│ 1. Query 改写 │ LLM 优化查询
│ "那个框架怎么用?" │
│ → "LangChain 框架 │
│ 如何接入外部数据" │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 2. 多路召回 │ 向量 + BM25 + HyDE
│ 并行检索三路 │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 3. 去重融合 │ RRF 融合排序
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 4. Reranking │ 交叉编码器重排序
│ top-20 → top-5 │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 5. 上下文压缩 │ LLM 提取相关内容
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 6. 生成回答 │ 提示词约束 + 引用溯源
│ + 幻觉抑制 │
└─────────────────────┘
不是每个环节都需要。根据你的场景选择:
- 基础方案:Query 改写 + Reranking + 提示词约束
- 进阶方案:多查询改写 + 多路召回 + Reranking + 上下文压缩 + 引用溯源
- 完整方案:全部环节 + 一致性验证
七、效果提升的量化
根据实际经验,每个优化环节对效果的提升大致如下:
| 优化环节 | 效果提升 | 延迟增加 | 成本增加 |
|---|---|---|---|
| Query 改写 | +5-10% | +1-2s | 低 |
| 多查询改写 | +5-15% | +2-4s | 中 |
| HyDE | +10-20% | +1-3s | 低 |
| 混合检索 | +10-15% | +100ms | 零 |
| Reranking | +10-15% | +200-500ms | 低 |
| 上下文压缩 | +5-10% | +1-3s | 中 |
| 幻觉抑制 | +5-10% | +0-5s | 低-高 |
(效果提升是相对于没有该环节的基线,具体数值因数据和场景而异)
性价比最高的三个优化:混合检索、Reranking、Query 改写。这三个环节效果提升明显,成本和延迟增加有限。
系列:
