🔍 高级 RAG:检索策略

LC5 章你写过最朴素的 RAG({context, question} | prompt | model)。但生产 RAG 远不止"检索+拼接"——召回率不够、相关性差、上下文太长都是真实痛点。本章精读 LangChain 的检索策略源码(多查询/混合/父子/重排/MMR),构建一个生产级 RAG 管道。

本章目标

  • 理解朴素 RAG 的三大痛点及对应解法
  • 精读四种文档合并策略(stuff/map_reduce/refine/map_rerank)
  • 掌握多查询检索(MultiQuery)、混合检索(Ensemble + RRF)、父子文档、Cross-encoder 重排
  • 理解 MMR 多样性检索与 Lost-in-the-middle 重排
  • 构建一个完整的高级 RAG 管道

朴素 RAG 的三大痛点

回顾 LC5 的朴素 RAG:query → embed → 向量检索 top-k → 拼进 prompt → LLM 生成。生产中三大问题:

痛点① 召回率低 用户问法多样,单一 query 可能匹配不到相关文档 → 多查询 / 混合检索 痛点② 相关性差 向量相似≠语义相关 top-k 里混入噪声 → 重排(Cross-encoder) 痛点③ 上下文太长 文档太多撑爆窗口 或关键信息在中间被忽略 → 父子文档 / MMR / 重排
图 AD3.1 · 朴素 RAG 三大痛点及本章对应解法

文档合并四种策略

检索到多份文档后,怎么"喂"给 LLM?LangChain 提供四种策略(chains/combine_documents/),是教科书级的取舍对比:

策略源码原理优缺点
stuff stuff.py:25 全部文档塞进一个 prompt ✓ 简单 ✓ 一次调用;✗ 文档多时超 context
map_reduce map_reduce.py:29 先对每份文档单独 map 处理,再 reduce 汇总 ✓ 可处理超量文档 ✓ 并行;✗ 多次调用贵
refine refine.py:33 逐份文档迭代精炼答案 ✓ 上下文连贯;✗ 串行慢、不能并行
map_rerank map_rerank.py:30 map 后让 LLM 给每份打分,取最高分 ✓ 自动选最优;✗ 依赖打分准确性
四种合并策略对比 stuff doc1 doc2 doc3 全塞一个prompt 1次调用 map_reduce map1 map2 map3 ↓ 并行 reduce refine doc1 → ans1 ↓ 精炼 doc2 → ans2 ↓ 精炼 doc3 → final 串行迭代 map_rerank map1:8 map2:9 map3:7 ↓ 取最高分 map2
图 AD3.2 · 四种文档合并策略的工作方式
💡 怎么选

默认用 stuff(简单够用)。文档多到超 context 才考虑 map_reduce(可并行)或 refine(要连贯)。map_rerank 适合"多份文档里找最相关的那份"。现代趋势:用 create_stuff_documents_chain(LCEL 版)替代旧的 Chain 类。

多查询检索:提升召回

不同用户问法可能匹配不到同一份文档。MultiQueryRetriever 用 LLM 把一个 query 改写成多个变体,分别检索后合并去重:

📄 retrievers/multi_query.py:49 · MultiQueryRetriever python
class MultiQueryRetriever(BaseRetriever):
    """用 LLM 生成多个 query 变体,扩大召回。

    流程:原始 query → LLM 生成 N 个变体 → 每个变体各自检索 → 合并去重
    - LineListOutputParser (:23) 解析 LLM 输出的多行 query
    - _unique_documents (:45) 去重(同一文档不被多次返回)
    """
原始 query "如何部署" 变体1: 部署步骤 变体2: 上线流程 变体3: 发布方法 检索→doc A 检索→doc B 检索→doc C 合并去重 doc A+B+C
图 AD3.3 · 多查询检索:一个 query 变多个,扩大召回面

混合检索 + RRF 融合

向量检索擅长语义匹配,关键词检索(BM25)擅长精确匹配。EnsembleRetriever 把两者结合,用 RRF(Reciprocal Rank Fusion)融合排名:

📄 retrievers/ensemble.py:53/304 · EnsembleRetriever + RRF python
class EnsembleRetriever(BaseRetriever):
    """混合检索:多个 retriever 结果加权融合。

    典型用法:向量检索(语义)+ BM25(关键词)混合。
    weighted_reciprocal_rank (:304) 是融合算法核心。
    """
🧭 RRF 算法原理

Reciprocal Rank Fusion(倒数排名融合):对每个文档,在各个检索结果里的排名取倒数求和。score = Σ 1/(rank + c)(c 常数,通常 60)。这样不需要分数归一化(向量分数和 BM25 分数量纲不同),只看排名。简单有效,是混合检索的事实标准。

父子文档:解决上下文长度

检索时用小 chunk(精准匹配),但返回大父文档(完整上下文)。ParentDocumentRetrieverparent_document_retriever.py:11):

大文档(父) 完整段落,上下文全 但不直接检索它 小chunk1 小chunk2 ↓ 检索这些(精准) 命中chunk2 → 映射回父文档 返回完整父文档 上下文完整
图 AD3.4 · 父子文档:小 chunk 检索,大父文档返回

Cross-encoder 重排:提升相关性

向量检索(双塔模型)快但粗。检索后用 Cross-encoder(把 query 和 doc 一起喂给模型算精确相关性)重排 top-k:

📄 retrievers/document_compressors/cross_encoder_rerank.py:16 · 重排器 python
class CrossEncoderReranker(BaseDocumentCompressor):
    """用 cross-encoder 对检索结果重新打分排序。

    与向量检索(双塔,query/doc 分别编码)不同,
    cross-encoder 把 query+doc 拼一起输入,算精确相关性分数。
    更准但更慢,所以只对 top-N 重排(两阶段检索)。
    """
query ① 向量检索(快/粗) doc1 0.8 doc2 0.7 doc3 0.6 doc4 0.5 ② cross-encoder 重排(慢/精) doc3→9.2 doc1→8.1 doc2→6.5 doc4→3.0 重排后 top-2 doc3, doc1(顺序变了!) 注意 doc3 从第3升到第1——向量粗排可能漏掉它
图 AD3.5 · 两阶段检索:向量粗排 → cross-encoder 精排

MMR:多样性检索

top-k 全是相似文档会信息冗余。MMR(Maximal Marginal Relevance)在相关性和多样性间平衡:

📄 langchain_core/vectorstores/base.py:659 · MMR python
class VectorStore(ABC):
    def max_marginal_relevance_search(self, query, k=4, fetch_k=20, lambda_mult=0.5):
        """MMR 检索:既相关又多样。

        先 fetch_k 个候选(相关),再迭代选 k 个:
        每次选「与 query 相关 + 与已选文档不相似」综合最高的。
        lambda_mult: 0=最多样, 1=最相关(退化为普通检索)。
        """

Lost in the middle 重排

研究表明 LLM 对prompt 中间位置的信息容易忽略("lost in the middle"现象)。所以检索结果应把最相关的放首尾

📄 document_transformers/long_context_reorder.py · 重排 python
# LongContextReorder:把最相关的文档放到首尾位置
# [最相关, 次相关, ..., 倒数第二, 倒数第一相关]
# 避免"重要信息埋在中间被忽略"

实战:完整高级 RAG 管道

🚀 组合多种策略

pip install langchain langchain-openai chromadb rank_bm25。下面组合混合检索 + 重排 + Lost-in-middle。

📄 ad3_advanced_rag.py · 生产级 RAG 管道 python
# pip install langchain langchain-openai chromadb rank_bm25
# export OPENAI_API_KEY="sk-..."
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
from langchain.retrievers.document_compressors import DocumentCompressorPipeline
from langchain_community.document_transformers import (
    EmbeddingsRedundantFilter, LongContextReorder,
)
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# ① 准备文档(实际从文件加载)
docs = [
    Document(page_content="LangChain 是一个 LLM 应用开发框架...", metadata={"source": "intro"}),
    Document(page_content="LCEL 用管道 | 组合组件...", metadata={"source": "lcel"}),
    # ... 更多文档
]

# ② 切分(父子文档思路:小 chunk 检索)
splitter = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50)
chunks = splitter.split_documents(docs)

# ③ 构建两个检索器:向量(语义)+ BM25(关键词)
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(chunks, embeddings)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5

# ④ 混合检索(Ensemble + RRF 自动融合)
ensemble_retriever = EnsembleRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    weights=[0.5, 0.5],   # 向量和 BM25 各半
)

# ⑤ 检索后处理管道:去冗余 → 重排(关键内容放首尾)
compressor_pipeline = DocumentCompressorPipeline(
    transformers=[
        EmbeddingsRedundantFilter(embeddings=embeddings),  # 去冗余
        LongContextReorder(),                               # 重要内容放首尾
    ]
)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor_pipeline,
    base_retriever=ensemble_retriever,
)

# ⑥ 组装 RAG 链(LCEL,LC5 学过)
model = ChatOpenAI(model="gpt-4o-mini")
prompt = ChatPromptTemplate.from_messages([
    ("system", "根据以下上下文回答。不知道就说不知道。\n上下文:{context}"),
    ("human", "{question}"),
])

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

rag_chain = (
    {"context": compression_retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | model
    | StrOutputParser()
)

# ⑦ 测试
print(rag_chain.invoke("LCEL 是什么?"))
# 流程:混合检索(向量+BM25) → RRF融合 → 去冗余 → 首尾重排 → 生成
💡 这个管道的价值

对比 LC5 的朴素 RAG,这个管道多了:混合检索(召回↑)、RRF 融合(排名↑)、去冗余(噪声↓)、首尾重排(利用率↑)。每一步都解决一个具体痛点。生产 RAG 就是这种"流水线优化",没有银弹,靠组合。

索引同步:避免重复写入

文档更新后重新入库,怎么避免重复写入?Indexing APIlangchain_core/indexing/api.py:296):

📄 索引同步 API python
from langchain_core.indexing import index

# 基于 RecordManager 的时间戳+内容哈希,自动增量同步
result = index(
    docs,                      # 新文档
    vectorstore,               # 目标向量库
    record_manager,            # 记录管理器(追踪已入库)
    cleanup="incremental",     # 增量:只加新的、改的,删过期的
    source_id_key="source",    # 用哪个 metadata 字段做去重键
)
print(result)
# → IndexingResult(num_added=2, num_updated=1, num_deleted=0, num_skipped=10)
# 自动跳过没变的、更新变了的、删除消失的

与生产实践对照

RAG 策略OpenCode 对应
多查询/混合检索grep + glob 多工具组合检索文件
父子文档(上下文完整)read 工具按行读 + instruction.resolve 沿目录收集相关指令
重排/筛选相关LLM 自己判断哪些文件相关(agentic)
Lost-in-middle 重排compaction 压缩 + reminders 注入关键信息
索引同步无(每次实时检索,不入库)
🧭 OpenCode 不用传统 RAG

有趣的是,OpenCode 作为编码 Agent 不用向量检索 RAG——它用 grep/glob/read 工具实时搜代码,由 LLM 决定搜什么、读什么。这是 Agentic RAG(AD4 详讲)——把检索本身变成 agent 的工具调用。传统 RAG(向量库)适合静态文档库;Agentic RAG 适合动态、结构化、可操作的数据源(如代码库)。

小结

下一章

下一章 AD4 · Agentic RAG:把 RAG 升级为会自我反思的 Agent——CRAG/Self-RAG/Adaptive RAG,检索后判断够不够好,不够就改写 query 重试。精读 LangGraph 官方 RAG notebook。