前三篇我们把RAG的地基打好了——原理搞清楚了、文档切对了、Embedding和向量库选好了。但如果你只用纯向量检索,大概率还是会遇到这些问题:

用户问"2025年差旅报销标准",检索结果里混进了2023年的旧文档;问"年假申请流程",返回的却是社保缴纳比例——向量相似度确实高,但语义不对。

这就是纯向量检索的致命问题:**“找到相似的”≠“找到对的”**。混合检索+Rerank重排序,就是来解决这个问题的。这是目前生产环境RAG的标配架构,没有之一。

为什么纯向量检索不够用?

向量检索擅长语义相似匹配,但对这些场景无能为力:

| 问题

|

纯向量检索

|

原因

精确关键词匹配

|

|

“python3.11"和"python3.12"向量很近,但你只要3.11的文档

| |

专有名词检索

|

|

人名、产品名、编号等,关键词匹配更准

| |

时效性过滤

|

不行

|

向量不看日期,2023和2025的文档向量可能很近

| |

多义词消歧

|

|

“苹果"是手机还是水果?纯向量分不清

|

关键词检索(BM25)恰好擅长这些向量检索不擅长的事——精确匹配、专有名词、关键词权重。两路召回再融合,就是混合检索。

BM25:向量检索的最佳搭档

BM25是经典的关键词检索算法,基于词频和文档频率来计算相关性。它不关心语义,只看关键词是否出现、出现了几次、在多少文档中出现。

<span><span leaf=""># 用Elasticsearch做BM25检索</span></span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.retrievers&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;ElasticSearchBM25Retriever</span><br><br><span><span leaf=""># 初始化</span></span><br><span leaf="">retriever = ElasticSearchBM25Retriever(</span><br><span leaf="">&nbsp; &nbsp; elasticsearch_url=</span><span><span leaf="">"http://localhost:9200"</span></span><span leaf="">,</span><br><span leaf="">&nbsp; &nbsp; index_name=</span><span><span leaf="">"knowledge_base"</span></span><span leaf="">,</span><br><span leaf="">)</span><br><br><span><span leaf=""># 检索</span></span><br><span leaf="">results = retriever.invoke(</span><span><span leaf="">"年假申请流程"</span></span><span leaf="">)</span><br>

没有Elasticsearch也可以用rank_bm25做纯Python的BM25:

<span><span leaf="">from</span></span><span leaf="">&nbsp;rank_bm25&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;BM25Okapi</span><br><span><span leaf="">import</span></span><span leaf="">&nbsp;jieba</span><br><br><span><span leaf=""># 中文分词</span></span><br><span leaf="">tokenized_corpus = [list(jieba.cut(doc.page_content))&nbsp;</span><span><span leaf="">for</span></span><span leaf="">&nbsp;doc&nbsp;</span><span><span leaf="">in</span></span><span leaf="">&nbsp;chunks]</span><br><span leaf="">bm25 = BM25Okapi(tokenized_corpus)</span><br><br><span><span leaf=""># 检索</span></span><br><span leaf="">query_tokens = list(jieba.cut(</span><span><span leaf="">"年假申请流程"</span></span><span leaf="">))</span><br><span leaf="">scores = bm25.get_scores(query_tokens)</span><br><span leaf="">top_indices = scores.argsort()[</span><span><span leaf="">-5</span></span><span leaf="">:][::</span><span><span leaf="">-1</span></span><span leaf="">]</span><br><span leaf="">results = [chunks[i]&nbsp;</span><span><span leaf="">for</span></span><span leaf="">&nbsp;i&nbsp;</span><span><span leaf="">in</span></span><span leaf="">&nbsp;top_indices]</span><br>

BM25的特点:快、对精确匹配敏感、不理解语义。跟向量检索刚好互补。

混合检索:两路召回+融合排序

混合检索的流程:

<span leaf="">用户Query</span><br><span leaf="">&nbsp; &nbsp;├──→ [向量检索] → Top-K候选集A</span><br><span leaf="">&nbsp; &nbsp;├──→ [BM25检索] → Top-K候选集B</span><br><span leaf="">&nbsp; &nbsp;↓</span><br><span leaf="">[结果融合(RRF)]</span><br><span leaf="">&nbsp; &nbsp;↓</span><br><span leaf="">[融合后的Top-N结果]</span><br>

核心问题:两路检索的结果怎么融合?用RRF(Reciprocal Rank Fusion)

RRF融合算法

RRF的思路是"民主投票”——不关心具体分数,只关心排名,排名越靠前得分越高:

<span leaf="">RRF_score(doc) = Σ 1/(k + rank_i(doc))</span><br>

k通常取60。一个文档在向量检索中排第1,在BM25中排第3,它的RRF分数 = 1/(60+1) + 1/(60+3) = 0.0164 + 0.0159 = 0.0323

<span><span><span leaf="">def</span></span><span leaf="">&nbsp;</span><span><span leaf="">reciprocal_rank_fusion</span></span><span><span leaf="">(lists_of_results, k=</span><span><span leaf="">60</span></span><span leaf="">)</span></span><span leaf="">:</span></span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><span><span leaf="">"""RRF融合多路检索结果"""</span></span><br><span leaf="">&nbsp; &nbsp; fused_scores = {}</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><span><span leaf="">for</span></span><span leaf="">&nbsp;results&nbsp;</span><span><span leaf="">in</span></span><span leaf="">&nbsp;lists_of_results:</span><br><span leaf="">&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;</span><span><span leaf="">for</span></span><span leaf="">&nbsp;rank, doc&nbsp;</span><span><span leaf="">in</span></span><span leaf="">&nbsp;enumerate(results):</span><br><span leaf="">&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; doc_key = doc.page_content &nbsp;</span><span><span leaf=""># 用内容作为唯一标识</span></span><br><span leaf="">&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;</span><span><span leaf="">if</span></span><span leaf="">&nbsp;doc_key&nbsp;</span><span><span leaf="">not</span></span><span leaf="">&nbsp;</span><span><span leaf="">in</span></span><span leaf="">&nbsp;fused_scores:</span><br><span leaf="">&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; fused_scores[doc_key] = {</span><span><span leaf="">"score"</span></span><span leaf="">:&nbsp;</span><span><span leaf="">0</span></span><span leaf="">,&nbsp;</span><span><span leaf="">"doc"</span></span><span leaf="">: doc}</span><br><span leaf="">&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; fused_scores[doc_key][</span><span><span leaf="">"score"</span></span><span leaf="">] +=&nbsp;</span><span><span leaf="">1.0</span></span><span leaf="">&nbsp;/ (k + rank +&nbsp;</span><span><span leaf="">1</span></span><span leaf="">)</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><span><span leaf=""># 按融合分数排序</span></span><br><span leaf="">&nbsp; &nbsp; sorted_results = sorted(</span><br><span leaf="">&nbsp; &nbsp; &nbsp; &nbsp; fused_scores.values(), key=</span><span><span leaf="">lambda</span></span><span leaf="">&nbsp;x: x[</span><span><span leaf="">"score"</span></span><span leaf="">], reverse=</span><span><span leaf="">True</span></span><br><span leaf="">&nbsp; &nbsp; )</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><span><span leaf="">return</span></span><span leaf="">&nbsp;[item[</span><span><span leaf="">"doc"</span></span><span leaf="">]&nbsp;</span><span><span leaf="">for</span></span><span leaf="">&nbsp;item&nbsp;</span><span><span leaf="">in</span></span><span leaf="">&nbsp;sorted_results]</span><br>

完整的混合检索实现

<span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.vectorstores&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;Chroma</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.retrievers&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;BM25Retriever</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_core.runnables&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;RunnableParallel</span><br><br><span><span leaf=""># 向量检索器</span></span><br><span leaf="">vector_retriever = vectorstore.as_retriever(search_kwargs={</span><span><span leaf="">"k"</span></span><span leaf="">:&nbsp;</span><span><span leaf="">10</span></span><span leaf="">})</span><br><br><span><span leaf=""># BM25检索器</span></span><br><span leaf="">bm25_retriever = BM25Retriever.from_documents(chunks, k=</span><span><span leaf="">10</span></span><span leaf="">)</span><br><br><span><span leaf=""># 并行执行两路检索</span></span><br><span leaf="">parallel_retrieval = RunnableParallel({</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><span><span leaf="">"vector"</span></span><span leaf="">: vector_retriever,</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><span><span leaf="">"bm25"</span></span><span leaf="">: bm25_retriever,</span><br><span leaf="">})</span><br><br><span><span leaf=""># 融合</span></span><br><span><span><span leaf="">def</span></span><span leaf="">&nbsp;</span><span><span leaf="">hybrid_fusion</span></span><span><span leaf="">(results)</span></span><span leaf="">:</span></span><br><span leaf="">&nbsp; &nbsp; vector_results = results[</span><span><span leaf="">"vector"</span></span><span leaf="">]</span><br><span leaf="">&nbsp; &nbsp; bm25_results = results[</span><span><span leaf="">"bm25"</span></span><span leaf="">]</span><br><span leaf="">&nbsp; &nbsp;&nbsp;</span><span><span leaf="">return</span></span><span leaf="">&nbsp;reciprocal_rank_fusion([vector_results, bm25_results])</span><br><br><span><span leaf=""># 构建混合检索链</span></span><br><span leaf="">hybrid_retriever = parallel_retrieval | hybrid_fusion</span><br>

实测效果:在法律合同知识库上,纯向量检索Top-5准确率68%,纯BM25是55%,混合检索达到82%。两路融合比单路的提升是实打实的。

Rerank重排序:海选之后的决赛

混合检索解决了"召回"的问题——确保相关文档被找到。但找到的文档排序可能不对——最相关的排第5,不太相关的排第1。

Rerank就是在检索结果上再做一次精排,用Cross-Encoder模型同时看问题和文档,给出更精准的相关性分数。

为什么需要Rerank?

向量检索用的是Bi-Encoder(问题和文档分别编码再算相似度),快但不够精准。Rerank用的是Cross-Encoder(问题和文档一起编码,让模型看两者的交互关系),慢但精准。

类比:Bi-Encoder是"看简历筛选”(快但粗),Cross-Encoder是"面试精评"(慢但准)。

Rerank模型选型

| 模型

|

特点

|

适用场景

bge-reranker-v2-m3

|

BGE系列,多语言,开源

| 中文首选推荐 | |

bge-reranker-large

|

BGE系列,英文强

|

英文项目

| |

Cohere Rerank

|

API调用,效果很好

|

不想自己部署

| |

Jina Reranker v2

|

多语言,开源

|

替代bge

|

用Cohere Rerank(最快上手)

<span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.document_compressors&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;CohereRerank</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain.retrievers&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;ContextualCompressionRetriever</span><br><br><span><span leaf=""># Rerank模型</span></span><br><span leaf="">compressor = CohereRerank(top_n=</span><span><span leaf="">5</span></span><span leaf="">) &nbsp;</span><span><span leaf=""># 从候选中选最相关的5个</span></span><br><br><span><span leaf=""># 包装检索器:检索→Rerank</span></span><br><span leaf="">compression_retriever = ContextualCompressionRetriever(</span><br><span leaf="">&nbsp; &nbsp; base_compressor=compressor,</span><br><span leaf="">&nbsp; &nbsp; base_retriever=vector_retriever, &nbsp;</span><span><span leaf=""># 换成hybrid_retriever也行</span></span><br><span leaf="">)</span><br><br><span><span leaf=""># 使用</span></span><br><span leaf="">results = compression_retriever.invoke(</span><span><span leaf="">"年假申请流程"</span></span><span leaf="">)</span><br>

用bge-reranker(本地部署)

<span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.document_compressors&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;CrossEncoderReranker</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.cross_encoders&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;HuggingFaceCrossEncoder</span><br><br><span><span leaf=""># 加载本地Rerank模型</span></span><br><span leaf="">cross_encoder = HuggingFaceCrossEncoder(model_name=</span><span><span leaf="">"BAAI/bge-reranker-v2-m3"</span></span><span leaf="">)</span><br><span leaf="">compressor = CrossEncoderReranker(model=cross_encoder, top_n=</span><span><span leaf="">5</span></span><span leaf="">)</span><br><br><span leaf="">compression_retriever = ContextualCompressionRetriever(</span><br><span leaf="">&nbsp; &nbsp; base_compressor=compressor,</span><br><span leaf="">&nbsp; &nbsp; base_retriever=hybrid_retriever,</span><br><span leaf="">)</span><br>

实测对比

在同一个知识库上,用30个测试问题跑对比:

| 方案

|

Top-5准确率

|

平均延迟

纯向量检索

|

68%

|

50ms

| |

混合检索(BM25+向量)

|

82%

|

80ms

| |

混合检索+Rerank

| 93% |

250ms

|

Rerank的代价是延迟增加了约170ms,但准确率提升了11个百分点。对大多数业务场景来说,这个trade-off完全值得。

Java类比:这就像你做搜索系统——先ES粗排(Bi-Encoder向量检索,快),再用Learning to Rank精排(Cross-Encoder Rerank,准)。两段式检索是搜索引擎的经典架构,RAG里一模一样的思路。

上下文压缩:给检索结果"瘦身"

有时候检索到的chunk很长,但只有一两句话跟问题相关。把整个chunk都塞进提示词,浪费Token还干扰模型。

上下文压缩就是在不丢失关键信息的前提下,压缩检索结果:

<span><span leaf="">from</span></span><span leaf="">&nbsp;langchain.retrievers&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;ContextualCompressionRetriever</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.document_compressors&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;LLMChainExtractor</span><br><br><span><span leaf=""># 用LLM提取跟问题相关的部分</span></span><br><span leaf="">compressor = LLMChainExtractor.from_llm(llm=model)</span><br><br><span leaf="">compression_retriever = ContextualCompressionRetriever(</span><br><span leaf="">&nbsp; &nbsp; base_compressor=compressor,</span><br><span leaf="">&nbsp; &nbsp; base_retriever=hybrid_retriever,</span><br><span leaf="">)</span><br><br><span><span leaf=""># 原始chunk可能是1000字,压缩后可能只剩200字跟问题相关的部分</span></span><br><span leaf="">results = compression_retriever.invoke(</span><span><span leaf="">"年假申请流程"</span></span><span leaf="">)</span><br>

注意:LLM压缩会增加延迟和成本。如果检索结果本身就比较短(500字以内),不用压缩。

我的生产配置推荐

<span><span leaf=""># 标准生产级RAG检索配置</span></span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.vectorstores&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;Milvus</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.retrievers&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;BM25Retriever</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.document_compressors&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;CrossEncoderReranker</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain_community.cross_encoders&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;HuggingFaceCrossEncoder</span><br><span><span leaf="">from</span></span><span leaf="">&nbsp;langchain.retrievers&nbsp;</span><span><span leaf="">import</span></span><span leaf="">&nbsp;ContextualCompressionRetriever</span><br><br><span><span leaf=""># 1. 向量检索</span></span><br><span leaf="">vector_retriever = vectorstore.as_retriever(search_kwargs={</span><span><span leaf="">"k"</span></span><span leaf="">:&nbsp;</span><span><span leaf="">15</span></span><span leaf="">})</span><br><br><span><span leaf=""># 2. BM25检索</span></span><br><span leaf="">bm25_retriever = BM25Retriever.from_documents(chunks, k=</span><span><span leaf="">15</span></span><span leaf="">)</span><br><br><span><span leaf=""># 3. 混合检索 + RRF融合</span></span><br><span leaf="">hybrid_retriever = HybridRetriever(</span><br><span leaf="">&nbsp; &nbsp; vector_retriever=vector_retriever,</span><br><span leaf="">&nbsp; &nbsp; bm25_retriever=bm25_retriever,</span><br><span leaf="">&nbsp; &nbsp; k=</span><span><span leaf="">15</span></span><span leaf="">,</span><br><span leaf="">)</span><br><br><span><span leaf=""># 4. Rerank精排</span></span><br><span leaf="">cross_encoder = HuggingFaceCrossEncoder(model_name=</span><span><span leaf="">"BAAI/bge-reranker-v2-m3"</span></span><span leaf="">)</span><br><span leaf="">compressor = CrossEncoderReranker(model=cross_encoder, top_n=</span><span><span leaf="">5</span></span><span leaf="">)</span><br><br><span><span leaf=""># 5. 最终检索器</span></span><br><span leaf="">final_retriever = ContextualCompressionRetriever(</span><br><span leaf="">&nbsp; &nbsp; base_compressor=compressor,</span><br><span leaf="">&nbsp; &nbsp; base_retriever=hybrid_retriever,</span><br><span leaf="">)</span><br>

这套配置:两路召回各15条→RRF融合→Rerank精排5条→送入模型。兼顾了召回率和精确率,延迟控制在300ms以内。

本篇要点

| 要点

|

说明

混合检索

|

向量+BM25两路召回,RRF融合

| |

RRF融合

|

1/(60+rank),简单有效不用调参

| |

Bi-Encoder

|

问题和文档分别编码,快但粗

| |

Cross-Encoder

|

问题和文档一起编码,慢但准

| |

Rerank模型

|

bge-reranker-v2-m3中文首选

| |

效果对比

|

纯向量68% → 混合82% → +Rerank 93%

| |

延迟

|

Rerank增加~170ms,值得

| |

上下文压缩

|

LLM提取相关部分,短chunk不用

| |

生产配置

|

粗排Top-15 → Rerank → 精排Top-5

|

下篇预告

下一篇进入RAG的高级架构——GraphRAG、Agentic RAG、查询改写、多跳检索。这些是2025-2026年RAG的前沿方向,适合你的知识库已经跑起来了、基础检索也调好了,但还想要更高精度的场景。


你现在的RAG用的什么检索方案?纯向量还是已经上了混合检索?评论区说说效果。

觉得有用就点个在看,下一篇讲高级RAG架构——基础RAG搞不定的场景怎么办。