LangChain 系列 · 第五篇:RAG 进阶——让检索真的准

🎯 适合人群:已完成基础 RAG 搭建,检索准确率卡在 60%~70% 的工程师
⏱️ 阅读时间:约 0 分钟
💬 本文介绍四种提升 RAG 检索质量的核心技术:Multi-query、Contextual Compression、HyDE、Reranking,以及混合检索方案,并给出各方案的适用场景与效果对比


一、基础 RAG 的检索瓶颈

第四篇构建的基础 RAG 管道在实际使用中往往表现平平,原因并不复杂——向量相似度搜索有三个固有局限:

词汇鸿沟(Vocabulary Mismatch):用户用"如何加快模型推理"提问,文档里写的是"优化 inference latency",两者语义相同但词汇差异导致向量距离偏大,排名靠后。

单角度查询:用户的一个问题只是对信息需求的一种表达,而文档可能从完全不同的角度描述同一概念,单一查询向量无法覆盖所有相关内容。

检索内容含噪声k=4 返回的 chunk 并非每一条都和问题高度相关,冗余内容占据 Token 预算,还可能干扰模型生成。

下面四种进阶技术分别从不同角度解决上述问题:

技术 解决的问题 代价
Multi-query 单角度查询覆盖不全 额外 LLM 调用(生成问题变体)
HyDE 词汇鸿沟,问题与文档表述差异大 额外 LLM 调用(生成假设答案)
Contextual Compression 检索结果含噪声,上下文冗余 额外 LLM 或 Embedding 调用
Reranking 向量相似度排序不够精准 额外 Cross-encoder 推理
Ensemble(混合检索) 语义检索无法覆盖精确关键词匹配 维护多个检索索引

二、进阶 Retriever 类型全览

langchain.retrievers 及相关模块提供以下进阶检索器:

用途
MultiQueryRetriever 用 LLM 生成多个问题变体,扩大检索覆盖范围
ContextualCompressionRetriever 对检索结果进行压缩/过滤,只保留与问题相关的部分
EnsembleRetriever 融合多个 retriever 的结果,支持加权 RRF 融合
ParentDocumentRetriever 检索小 chunk 但返回其父级大 chunk,平衡检索精度与上下文完整性
SelfQueryRetriever 用 LLM 解析问题,自动生成结构化 metadata 过滤条件
TimeWeightedVectorStoreRetriever 为近期文档赋予更高权重,适合时效性强的场景
BM25Retriever 基于 BM25 算法的关键词检索,与向量检索互补

用于 ContextualCompressionRetriever 的压缩器(Compressor):

策略 速度 成本
LLMChainExtractor 用 LLM 从 chunk 中提取相关句子
LLMChainFilter 用 LLM 判断整个 chunk 是否相关(二分类)
EmbeddingsFilter 用 Embedding 相似度过滤,无需调用 LLM
DocumentCompressorPipeline 将多个 compressor 串联,如先过滤再提取 取决于组合 取决于组合

三、Multi-query Retriever:多角度扩大检索覆盖

3.1 原理

MultiQueryRetriever 的思路是:与其用一个查询去碰运气,不如让 LLM 生成同一问题的多种表述,对每种表述分别检索,再对结果取并集去重。

User Question: "LCEL 管道怎么处理错误?"
         |
         v
   LLM generates 3 variants:
   - "LangChain 链式调用的异常处理机制"
   - "with_retry with_fallbacks 用法"
   - "LCEL Runnable 错误重试策略"
         |
         v
   Vector Search x 3  (wider coverage)
         |
         v
   Union + Deduplication
         |
         v
   Larger, more complete context

为什么多个查询能提升召回率?

用概率来理解最直观。假设某个相关 chunk 被单次查询命中的概率是 60%(这在实际中并不罕见),那么:

1 query:  P(hit) = 60%
2 queries: P(at least 1 hit) = 1 - (1-0.6)^2 = 84%
3 queries: P(at least 1 hit) = 1 - (1-0.6)^3 = 93.6%
4 queries: P(at least 1 hit) = 1 - (1-0.6)^4 = 97.4%

3 个变体在大多数场景下已经能将召回率从 60% 提升到 93%+,这就是 Multi-query 的核心价值。继续增加变体数量,边际收益递减,且 LLM 调用成本线性增加。

关键条件:只有当 N 个变体真正从"不同角度"表述问题时才有效。如果生成的变体高度相似(只换了几个同义词),它们的向量几乎重叠,并集与单次检索结果没有实质差别。这就是为什么自定义 Prompt 比默认 Prompt 更重要。

3.2 使用示例

from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain_openai import ChatOpenAI
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=OpenAIEmbeddings(model="text-embedding-3-small"),
)

base_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# MultiQueryRetriever:自动用 LLM 生成多个问题变体
multi_query_retriever = MultiQueryRetriever.from_llm(
    retriever=base_retriever,
    llm=ChatOpenAI(model="gpt-4o-mini", temperature=0),
)

# 开启日志,可以看到生成了哪些问题变体
import logging
logging.basicConfig()
logging.getLogger("langchain.retrievers.multi_query").setLevel(logging.INFO)

docs = multi_query_retriever.invoke("LCEL 管道怎么处理错误?")
print(f"检索到 {len(docs)} 个不重复的 chunk")

3.3 自定义问题生成 Prompt

默认 Prompt 生成的问题变体质量参差不齐,可以自定义:

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import BaseOutputParser
from langchain.retrievers.multi_query import MultiQueryRetriever

class LineListOutputParser(BaseOutputParser):
    """将 LLM 输出的多行文本解析为问题列表"""
    def parse(self, text: str):
        lines = text.strip().split("\n")
        return [line.strip() for line in lines if line.strip()]

custom_prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个检索优化专家,负责将用户问题改写为多个角度的查询语句,"
        "以便在文档库中检索到更全面的相关内容。"
    ),
    (
        "human",
        "原始问题:{question}\n\n"
        "请从以下角度各生成一个等价问题(每行一个,共 3 个):\n"
        "1. 更正式/技术化的表述\n"
        "2. 更口语化/简化的表述\n"
        "3. 从解决方案角度的反向表述"
    ),
])

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

multi_query_retriever = MultiQueryRetriever(
    retriever=base_retriever,
    llm_chain=custom_prompt | llm | LineListOutputParser(),
)

💡 适用场景:用户提问风格与文档写作风格差距较大时效果最明显,如用中文口语问技术英文文档。代价是额外增加一次 LLM 调用,对延迟敏感的场景需权衡。

3.4 参数调优

变体数量(默认 3):

3 个变体是召回率与成本的平衡点。2 个变体在已有良好 Prompt 的情况下也够用;超过 5 个变体几乎没有额外收益,却线性增加 LLM 调用次数。

base retriever 的 k 值应随变体数缩小:

# ❌ 变体数 3,每次检索 k=5,总候选池 = 最多 15 个(去重后更少)
# 但如果不同变体检索到大量重叠结果,实际有效 chunk 可能只有 5~8 个
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# ✅ 变体数 3,每次检索 k=3,总候选池 = 最多 9 个,更精准
# 去重后通常剩 6~8 个高质量 chunk,噪声更少
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

multi_query_retriever = MultiQueryRetriever.from_llm(
    retriever=base_retriever,
    llm=ChatOpenAI(model="gpt-4o-mini", temperature=0),
)

生成变体的 LLM temperature:

# temperature=0:确定性输出,每次生成相同的变体
# 优点:可复现;缺点:变体多样性有限
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# temperature=0.5~0.7:引入随机性,变体更多样
# 优点:覆盖更广;缺点:不稳定,偶尔生成低质量变体
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.5)

推荐使用 temperature=0 + 精心设计的自定义 Prompt(见 3.3),比依赖随机性产生多样性更可靠。


四、HyDE:用假设答案弥补词汇鸿沟

4.1 原理

HyDE(Hypothetical Document Embeddings)的核心洞察是:问题的向量答案的向量 在高维空间中不一定相近,但 假设答案的向量真实答案文档的向量 通常非常接近。

Standard RAG:
  Question vector -----> (search) -----> Document vectors
  (query & doc vectors may be far apart)

HyDE:
  Question
      |
      v
  LLM generates hypothetical answer
      |
      v
  Hypothetical answer vector -----> (search) -----> Document vectors
  (both are "answer-style" text, closer in vector space)

为什么问题向量和答案向量会有差距?

Embedding 模型在训练时通常以"语义相似"作为正样本,但"问题"和"它的答案"在文本风格上差异显著:

  • 问题:“LCEL 的管道操作符是如何工作的?”(疑问句式,短而开放)
  • 文档:“LCEL 通过重载 __or__ 方法实现管道操作符,将左侧输出传递给右侧输入…”(陈述句式,长而详细)

这两段文本语义相关,但 Embedding 空间中的距离未必很小——它们属于不同的"文本类型"。HyDE 的解法是:先让 LLM 生成一段与文档风格相近的假设答案,再用这段答案的向量去检索。两者都是"陈述性技术文档"风格,向量距离自然更近。

HyDE 何时失效:

# 场景一:LLM 对该领域知识不足,生成错误的假设答案
# 假设答案的向量会偏离正确文档,检索到错误内容

# 场景二:文档是高度结构化内容(代码、表格、API 列表)
# 散文式假设答案与结构化文档的向量差异大,HyDE 不如直接检索

# 场景三:问题本身非常模糊
# LLM 无法生成有意义的假设答案

4.2 使用示例

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma

# 第一步:用 LLM 生成假设答案
hyde_prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个技术文档撰写专家。根据用户的问题,生成一段假设性的文档段落作为答案。"
        "即使你不确定答案,也要生成一段看起来像文档的专业回答。"
        "不要说'我不确定',直接生成假设文档内容。"
    ),
    ("human", "{question}"),
])

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)

# HyDE Chain:问题 -> 假设答案 -> 向量检索
hyde_chain = (
    hyde_prompt
    | llm
    | StrOutputParser()
    | (lambda hypothetical_doc: vectorstore.similarity_search(hypothetical_doc, k=4))
)

# 完整 RAG with HyDE
rag_prompt = ChatPromptTemplate.from_messages([
    ("system", "根据以下上下文回答问题,信息不足时说明无法回答。\n\n上下文:{context}"),
    ("human", "{question}"),
])

def format_docs(docs):
    return "\n\n---\n\n".join(doc.page_content for doc in docs)

rag_with_hyde = (
    {
        "context":  hyde_chain | format_docs,
        "question": RunnablePassthrough(),
    }
    | rag_prompt
    | llm
    | StrOutputParser()
)

answer = rag_with_hyde.invoke("LCEL 中如何实现错误重试?")
print(answer)

🔬 适用场景:文档使用专业术语,而用户倾向于用自然语言提问时。HyDE 在学术论文、技术规范、法律文书等领域效果尤为显著。缺点同样是增加一次 LLM 调用。

4.3 参数调优

假设答案的长度与风格:

# 假设答案越接近文档风格,检索效果越好
# 技术文档:要求生成详细的技术说明(100~200 字)
hyde_prompt_tech = ChatPromptTemplate.from_messages([
    (
        "system",
        "根据问题生成一段技术文档风格的回答,包含具体的 API 名称、参数和代码逻辑。"
        "长度约 150 字,不要说'我不确定',直接生成文档内容。"
    ),
    ("human", "{question}"),
])

# 法律/合规文档:要求生成正式条文风格
hyde_prompt_legal = ChatPromptTemplate.from_messages([
    (
        "system",
        "根据问题生成一段法律条文风格的回答,使用正式用语和条款结构。"
        "直接生成假设条文,不要加入不确定性表述。"
    ),
    ("human", "{question}"),
])

temperature 的影响:

# temperature=0:每次生成相同的假设答案,稳定但覆盖单一
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# temperature=0.3:轻微随机性,在同一方向上略有变化
# 适合大多数场景,生成质量与多样性的平衡点
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)

# ❌ temperature 过高(>0.7)会让假设答案出现幻觉,反而降低检索质量

验证 HyDE 是否有效:

# 打印假设答案,判断其与实际文档的相似程度
test_question = "LangChain 中如何处理 API 超时?"
hypo_answer = (hyde_prompt | llm | StrOutputParser()).invoke({"question": test_question})
print("假设答案:", hypo_answer)

# 同时打印直接检索 vs HyDE 检索的结果,人工比较
direct_results = vectorstore.similarity_search(test_question, k=3)
hyde_results = vectorstore.similarity_search(hypo_answer, k=3)

print("\n=== 直接检索 ===")
for r in direct_results:
    print(r.page_content[:100])

print("\n=== HyDE 检索 ===")
for r in hyde_results:
    print(r.page_content[:100])
# 如果 HyDE 结果质量明显更高,则 HyDE 适用于该场景;否则不必引入

五、Contextual Compression:过滤检索噪声

5.1 原理

即使检索到了正确的 chunk,该 chunk 也往往只有一部分内容与问题相关,其余内容是噪声。ContextualCompressionRetriever 在向量检索之后增加一个"精炼"步骤:

Base Retriever
      |
      v
Retrieved Chunks (may contain noise)
      |
      v
[Compressor]  <-- filter or extract relevant parts
      |
      v
Compressed, high-signal context

5.2 EmbeddingsFilter:快速低成本过滤

EmbeddingsFilter 用 Embedding 相似度判断 chunk 是否与问题相关,无需调用 LLM,速度快、成本低:

from langchain.retrievers.document_compressors import EmbeddingsFilter
from langchain.retrievers import ContextualCompressionRetriever
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 6})

# EmbeddingsFilter:过滤相似度低于阈值的 chunk
embeddings_filter = EmbeddingsFilter(
    embeddings=embeddings,
    similarity_threshold=0.76,  # 低于此相似度的 chunk 被过滤
)

compression_retriever = ContextualCompressionRetriever(
    base_compressor=embeddings_filter,
    base_retriever=base_retriever,
)

# 检索时自动过滤低相关 chunk
docs = compression_retriever.invoke("LCEL 管道操作符的原理?")
print(f"过滤后剩余 {len(docs)} 个高相关 chunk")

5.3 LLMChainExtractor:精准提取相关句子

LLMChainExtractor 用 LLM 从 chunk 中只提取与问题直接相关的句子,精度最高但成本也最高:

from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
extractor = LLMChainExtractor.from_llm(llm)

compression_retriever = ContextualCompressionRetriever(
    base_compressor=extractor,
    base_retriever=base_retriever,
)

docs = compression_retriever.invoke("什么是 RunnablePassthrough?")
for doc in docs:
    print(doc.page_content)  # 只包含与问题直接相关的句子
    print("---")

5.4 DocumentCompressorPipeline:组合多个压缩器

先用 EmbeddingsFilter 快速粗过滤,再用 LLMChainExtractor 精细提取,兼顾速度与精度:

from langchain.retrievers.document_compressors import DocumentCompressorPipeline
from langchain_text_splitters import CharacterTextSplitter

# 管道:切分 -> Embedding 过滤 -> LLM 提取
pipeline_compressor = DocumentCompressorPipeline(
    transformers=[
        CharacterTextSplitter(chunk_size=300, chunk_overlap=0, separator=". "),
        EmbeddingsFilter(embeddings=embeddings, similarity_threshold=0.76),
        LLMChainExtractor.from_llm(llm),
    ]
)

compression_retriever = ContextualCompressionRetriever(
    base_compressor=pipeline_compressor,
    base_retriever=base_retriever,
)

六、Reranking:精排提升相关性排序

6.1 原理

向量相似度搜索(Bi-encoder)是"粗排":把 query 和 document 分别编码,比较两个向量的距离。速度快但精度有限。

Reranking(Cross-encoder)是"精排":将 query 和每个 document 拼接在一起输入模型,让模型直接输出相关性分数。精度更高,但计算量是 O(k)。

Stage 1 - Bi-encoder (fast, retrieve wide)
  Query + Document -> [Embed separately] -> Cosine similarity -> Top-K

Stage 2 - Cross-encoder (slow, rerank narrow)
  [Query + Doc_1] -> Model -> Score_1
  [Query + Doc_2] -> Model -> Score_2
  ...
  [Query + Doc_K] -> Model -> Score_K
  -> Sort by score -> Top-N (N < K)

为什么 Cross-encoder 比 Bi-encoder 更准确?

根本原因在于注意力机制的工作方式:

  • Bi-encoder:Query 和 Document 分别独立编码,两者的表示在最终比较时才"相遇"。Query 的 Embedding 不知道 Document 里有什么,Document 的 Embedding 也不知道 Query 在问什么——相似度是两个"盲人"的握手。

  • Cross-encoder:将 [Query + Document] 拼接后一起输入,Transformer 的自注意力层让 Query 的每个 token 都能"看到"Document 的每个 token。“错误处理” 可以直接注意到 “with_retry”,"并行执行"可以直接注意到 “RunnableParallel”——这种深度交互使相关性判断精准得多。

代价是 Bi-encoder 只需编码一次 Query(O(1)),而 Cross-encoder 每个候选 Document 都要与 Query 联合推理(O(k))。这就是为什么 Reranking 只能作用在已经粗筛后的小候选集上,而不能直接用于全库检索。

6.2 可用 Reranker 一览

模型 语言 延迟(k=20) 成本
CrossEncoderReranker cross-encoder/ms-marco-MiniLM-L-6-v2 英文 ~150ms 无 API 费用
CrossEncoderReranker cross-encoder/ms-marco-MiniLM-L-12-v2 英文 ~300ms 无 API 费用
CrossEncoderReranker BAAI/bge-reranker-v2-m3 中英文 ~400ms 无 API 费用
CohereRerank rerank-multilingual-v3.0 多语言 ~300ms API 按调用计费
FlashrankRerank ms-marco-MultiBERT-L-12 英文 ~80ms 无 API 费用

6.3 Cross-encoder 本地 Reranking

from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain.retrievers import ContextualCompressionRetriever
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

# 加载本地 Cross-encoder 模型
model = HuggingFaceCrossEncoder(model_name="cross-encoder/ms-marco-MiniLM-L-6-v2")

# 先用向量检索取 20 个候选
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})

# 再用 Cross-encoder 精排,只保留 top-3
reranker = CrossEncoderReranker(model=model, top_n=3)

reranking_retriever = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=base_retriever,
)

docs = reranking_retriever.invoke("LCEL 如何实现并行执行?")
for doc in docs:
    print(doc.page_content[:150])
    print("---")

6.4 Cohere Reranker(中文效果更好)

from langchain_cohere import CohereRerank
from langchain.retrievers import ContextualCompressionRetriever

# Cohere 多语言 Reranker,对中文支持好
cohere_reranker = CohereRerank(
    model="rerank-multilingual-v3.0",
    top_n=3,
)

reranking_retriever = ContextualCompressionRetriever(
    base_compressor=cohere_reranker,
    base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20}),
)

💡 Reranking 的收益最为稳定——它不改变索引阶段的任何东西,只在检索后增加一个重排步骤,几乎对所有场景都有提升。如果只能选一种进阶技术,优先选 Reranking。

6.5 参数调优

k(粗排候选数)与 top_n(精排保留数)的比例:

k=5,  top_n=3  -> 筛选比例 60%,意味着粗排质量本来就不差
k=10, top_n=3  -> 筛选比例 30%,标准配置,适合大多数场景
k=20, top_n=3  -> 筛选比例 15%,适合粗排效果较差的情况
k=20, top_n=5  -> 适合需要更多上下文的复杂问题

经验法则:top_n 保持 3~5,k = top_n × 4~6。过大的 k 会线性增加 Cross-encoder 的推理时间,收益递减。

# 标准配置(延迟约 200ms,质量良好)
reranker = CrossEncoderReranker(model=model, top_n=3)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 12})

# 高质量配置(延迟约 400ms,质量更高)
reranker = CrossEncoderReranker(model=model, top_n=4)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20})

# 低延迟配置(延迟约 80ms,使用 Flashrank)
from langchain_community.document_compressors import FlashrankRerank
reranker = FlashrankRerank(top_n=3)
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

中文场景的模型选择:

# ❌ 英文 Cross-encoder 对中文效果差
model = HuggingFaceCrossEncoder(model_name="cross-encoder/ms-marco-MiniLM-L-6-v2")

# ✅ 中英双语,中文场景首选
model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3")

# ✅ 或使用 Cohere 多语言 Reranker(API 方案)
cohere_reranker = CohereRerank(model="rerank-multilingual-v3.0", top_n=3)

验证 Reranking 是否真的有提升:

def compare_with_without_reranking(query: str, k: int = 10, top_n: int = 3):
    """对比加 Reranking 前后的检索结果"""
    # 粗排结果
    raw_results = vectorstore.similarity_search(query, k=k)

    # 精排结果
    reranked_results = reranking_retriever.invoke(query)

    print(f"=== Query: {query} ===\n")
    print(f"--- Without Reranking (top {top_n} of {k}) ---")
    for i, doc in enumerate(raw_results[:top_n]):
        print(f"[{i+1}] {doc.page_content[:100]}")

    print(f"\n--- With Reranking (top {top_n} of {k}) ---")
    for i, doc in enumerate(reranked_results):
        print(f"[{i+1}] {doc.page_content[:100]}")

compare_with_without_reranking("LCEL 并行执行的原理")
# 人工判断精排后的顺序是否比原始顺序更合理

七、EnsembleRetriever:混合检索

向量检索擅长语义匹配,BM25 关键词检索擅长精确匹配(如产品型号、专有名词、代码函数名)。两者互补,融合使用效果通常优于单独使用任何一种。

为什么两种检索互补?

  • 向量检索:将"如何加快模型推理"和"optimize inference latency"识别为相关——语义层面的理解。但对精确字符串(如 OPENAI_API_KEYgpt-4o-mini)匹配能力弱。

  • BM25 关键词检索:对精确词汇匹配极为敏感,pip install langchain-openai 这样的命令能精准命中含有该字符串的文档——但无法理解语义近义词。

RRF(Reciprocal Rank Fusion)融合算法:

EnsembleRetriever 使用 RRF 算法合并多个检索结果,而不是简单的分数加权:

RRF score(doc) = Σ  weight_i / (k_rrf + rank_i(doc))

where:
  rank_i(doc)  = doc 在第 i 个 retriever 结果中的排名(从 1 开始)
  k_rrf        = 60(平滑常数,降低高排名文档的统治性)
  weight_i     = 各 retriever 的权重

直观理解:排名第 1 的文档得分约为 w/(60+1) ≈ 0.016w,排名第 10 的得分约为 w/(60+10) ≈ 0.014w——RRF 有意压缩了排名之间的差距,让"在两个检索器里都排中等"的文档有机会胜过"在一个检索器里排第一但另一个里不出现"的文档。

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

# BM25 关键词检索(需要原始 chunks)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4

# 向量检索
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=OpenAIEmbeddings())
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# 融合:RRF(Reciprocal Rank Fusion)算法加权合并
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6],  # BM25 权重 0.4,向量检索权重 0.6
)

docs = ensemble_retriever.invoke("RunnableParallel 怎么用?")
print(f"融合检索到 {len(docs)} 个 chunk")

7.1 权重调优

起点:对半开始

# Step 1:先用对等权重跑基准测试
ensemble = EnsembleRetriever(retrievers=[bm25, vector], weights=[0.5, 0.5])

根据查询类型调整:

# 场景一:查询包含大量专有名词、代码片段、精确字符串
# BM25 更重要 -> 提高 BM25 权重
weights_for_exact = [0.7, 0.3]

# 场景二:查询是自然语言概念性问题
# 向量检索更重要 -> 提高向量权重
weights_for_semantic = [0.3, 0.7]

# 场景三:混合型查询(大多数实际场景)
weights_balanced = [0.4, 0.6]  # 稍微偏向向量检索

通过测试集量化调参效果:

def evaluate_ensemble(test_cases, weights):
    """评估不同权重配置的命中率"""
    retriever = EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=weights,
    )
    hit = 0
    for case in test_cases:
        results = retriever.invoke(case["question"])
        retrieved_text = " ".join(r.page_content for r in results)
        if case["expected_keyword"] in retrieved_text:
            hit += 1
    return hit / len(test_cases)

# 对比不同权重配置
for w_bm25 in [0.3, 0.4, 0.5, 0.6, 0.7]:
    w_vec = round(1 - w_bm25, 1)
    hit_rate = evaluate_ensemble(test_cases, [w_bm25, w_vec])
    print(f"BM25={w_bm25}, Vector={w_vec}: hit_rate={hit_rate:.0%}")

⚠️ BM25Retriever 在服务重启后需要重新从 chunks 构建,不像向量库可以持久化。生产中推荐将 BM25 索引序列化保存,或使用原生支持混合检索的向量库(如 Qdrant、Weaviate)。


八、ParentDocumentRetriever:小块检索,大块返回

切块越小,检索精度越高;但块太小上下文又不够。ParentDocumentRetriever 两全其美:用小块做检索,返回对应的大块(父文档)作为上下文

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

# 子 chunk:用于检索(小而精确)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)

# 父 chunk:检索命中后返回给 LLM(大而完整)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)

vectorstore = Chroma(
    collection_name="child_chunks",
    embedding_function=OpenAIEmbeddings(),
)
store = InMemoryStore()  # 存储父文档(生产中用 Redis 或数据库)

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=store,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 索引时同时存储父块和子块
retriever.add_documents(docs)

# 检索时找小块,返回对应大块
results = retriever.invoke("LCEL 并行执行的原理")
print(f"返回 {len(results)} 个父文档(每个约 2000 字符)")

九、组合方案推荐

单一技术的提升有限,生产中通常组合使用。以下是两种推荐组合:

方案 A:高质量优先(延迟容忍)

# Multi-query 扩大召回 + Reranking 精排 + Compression 去噪
from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker, EmbeddingsFilter
from langchain.retrievers import ContextualCompressionRetriever
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
from langchain.retrievers.document_compressors import DocumentCompressorPipeline

base_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

# Step 1: Multi-query 扩大召回
mq_retriever = MultiQueryRetriever.from_llm(
    retriever=base_retriever,
    llm=ChatOpenAI(model="gpt-4o-mini", temperature=0),
)

# Step 2: EmbeddingsFilter 粗过滤 + CrossEncoder 精排
pipeline = DocumentCompressorPipeline(transformers=[
    EmbeddingsFilter(embeddings=embeddings, similarity_threshold=0.7),
    CrossEncoderReranker(
        model=HuggingFaceCrossEncoder(model_name="cross-encoder/ms-marco-MiniLM-L-6-v2"),
        top_n=4,
    ),
])

final_retriever = ContextualCompressionRetriever(
    base_compressor=pipeline,
    base_retriever=mq_retriever,
)

方案 B:低延迟优先(速度优先)

# 混合检索 + EmbeddingsFilter(无 LLM 调用)
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.3, 0.7],
)

embeddings_filter = EmbeddingsFilter(
    embeddings=embeddings,
    similarity_threshold=0.75,
)

fast_retriever = ContextualCompressionRetriever(
    base_compressor=embeddings_filter,
    base_retriever=ensemble_retriever,
)

十、各方案效果与成本对比

技术 准确率提升 额外延迟 额外成本 推荐优先级
Reranking(本地 Cross-encoder) ⭐⭐⭐⭐ 中(+200~500ms) 无 API 成本 🥇 最优先
EnsembleRetriever(混合检索) ⭐⭐⭐ 低(+50ms) 🥈 次优先
Multi-query ⭐⭐⭐ 高(+1~2s) 1 次额外 LLM 调用 按场景选用
EmbeddingsFilter ⭐⭐ 低(+100ms) Embedding API 搭配其他方案使用
HyDE ⭐⭐⭐ 高(+1~2s) 1 次额外 LLM 调用 专业文档场景
ParentDocumentRetriever ⭐⭐⭐ 文档结构层次分明时

十一、常见坑与最佳实践

坑一:Multi-query 生成的问题变体质量差

# ❌ 用默认 Prompt,生成的变体过于相似,没有真正扩展检索覆盖
# 默认行为:生成 3 个几乎一样的问题

# ✅ 自定义 Prompt,明确要求从不同角度生成变体
# 例如:技术角度 / 应用角度 / 背景知识角度

坑二:EmbeddingsFilter 的 similarity_threshold 过高导致结果为空

# ❌ 阈值设 0.9,大量相关 chunk 被过滤
compression_retriever = ContextualCompressionRetriever(
    base_compressor=EmbeddingsFilter(embeddings=embeddings, similarity_threshold=0.9),
    ...
)
# 结果:context 为空,模型回答"文档中未找到相关信息"

# ✅ 从 0.7 开始,打印保留的 chunk 数量,逐步调高
# 建议范围:0.7 ~ 0.8

坑三:Reranking 模型与文档语言不匹配

# ❌ 中文文档用英文 Cross-encoder
model = HuggingFaceCrossEncoder(model_name="cross-encoder/ms-marco-MiniLM-L-6-v2")
# 该模型在英文 MS MARCO 数据集上训练,中文效果差

# ✅ 中文场景用多语言模型
model = HuggingFaceCrossEncoder(model_name="cross-encoder/msmarco-MiniLM-L6-en-de-v1")
# 或使用 Cohere 多语言 Reranker
cohere_reranker = CohereRerank(model="rerank-multilingual-v3.0", top_n=3)

坑四:组合多种技术时延迟叠加超出接受范围

# ❌ Multi-query (+1.5s) + HyDE (+1.5s) + LLM Extractor (+1s) = +4s 延迟
# 用户体验极差

# ✅ 根据延迟预算选择组合:
# < 500ms 额外延迟:EnsembleRetriever + EmbeddingsFilter
# < 1s 额外延迟:本地 Reranker + EmbeddingsFilter
# 延迟不敏感:Multi-query + Reranker

十二、总结

技术 核心思路 最适合的场景
Multi-query 多角度提问,扩大召回覆盖 用户问法多样,词汇差异大
HyDE 用假设答案的向量替代问题向量检索 专业文档,问题与文档表述风格差异大
EmbeddingsFilter Embedding 相似度快速过滤噪声 chunk 所有场景,低成本去噪
LLMChainExtractor LLM 精准提取相关句子 对精度要求极高,延迟不敏感
CrossEncoder Reranking 精排候选集,提升相关性排序精度 所有场景,性价比最高的进阶方案
EnsembleRetriever 语义检索 + 关键词检索融合 含专有名词、代码、产品型号的场景
ParentDocumentRetriever 小块检索,大块返回 文档结构清晰,需要完整上下文

🎯 RAG 进阶没有"银弹"。Reranking 是适用范围最广、副作用最小的优化手段,建议作为第一个引入的进阶技术。其余技术根据具体场景的瓶颈定向选用,避免堆砌导致延迟失控。


参考资料


下期预告

搭建好 RAG 系统后,如何知道它够不够好?"感觉还行"不是答案。

第六篇《RAG 评估:你怎么知道它够好?》 将介绍 RAGAS 评估框架,用 Faithfulness、Answer Relevancy、Context Recall 等量化指标衡量 RAG 系统质量,并演示如何构建评估数据集、A/B 对比不同检索策略的效果。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐