检索后优化:让 RAG 结果更精准

检索后优化:让 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

  1. 用户提出问题
  2. 让 LLM 生成一个"假设答案"(不需要是真的,只需要在语义空间中和真实答案接近)
  3. 把假设答案向量化
  4. 在向量数据库中找和假设答案最相似的 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 改写。这三个环节效果提升明显,成本和延迟增加有限。


系列