LangChain 系列·(五):RAG 进阶——让检索真的准
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_KEY、gpt-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 是适用范围最广、副作用最小的优化手段,建议作为第一个引入的进阶技术。其余技术根据具体场景的瓶颈定向选用,避免堆砌导致延迟失控。
参考资料
- Advanced RAG 官方教程
- MultiQueryRetriever
- Contextual Compression
- Cross-encoders for Reranking
- Cohere Rerank
下期预告
搭建好 RAG 系统后,如何知道它够不够好?"感觉还行"不是答案。
第六篇《RAG 评估:你怎么知道它够好?》 将介绍 RAGAS 评估框架,用 Faithfulness、Answer Relevancy、Context Recall 等量化指标衡量 RAG 系统质量,并演示如何构建评估数据集、A/B 对比不同检索策略的效果。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)