深度解析
一、为什么纯向量检索不够用?
向量检索的原理是把文本通过 Embedding 模型编码成高维向量,然后用余弦相似度或内积来衡量语义相关性。这对 “理解意思” 很在行,但它有一个致命弱点:对精确关键词的匹配能力很弱。
举个实际的例子。假设你的知识库里有这样一段文档:
“
Spring AI整合Milvus向量数据库时,需要在pom.xml中引入spring-ai-milvus-store依赖,版本号1.0.0-M4。
用户提问:”spring-ai-milvus-store 的版本号是多少?”
向量检索可能会返回一堆跟 Spring AI、Milvus、依赖配置语义相关的文档,但精确包含 spring-ai-milvus-store 这个 artifactId 的那段文档,排名可能反而靠后——因为 Embedding 模型把这段文本编码后,”精确匹配 artifactId” 这个信号被稀释到整个语义空间里了。
而 BM25 关键词检索呢?它看到 spring-ai-milvus-store 这个精确 token,直接就能命中目标文档,简单粗暴但有效。
反过来,用户问:”Spring AI 怎么连接向量数据库?”BM25 可能搜不到好结果,因为文档里写的是 “整合 Milvus“,没有 “连接” 这个词。但向量检索能理解 “连接” 和 “整合” 在这个语境下是一个意思。
这就是为什么需要混合检索——两种检索方式是互补的,不是替代关系。
二、混合检索的整体架构

RAG 混合检索 Hybrid Search 架构图
上图是混合检索的完整流程。核心就三步:
-
两路并行检索:用户查询同时进入稠密检索通道和稀疏检索通道,各自返回 Top-K 个结果(通常 K=10~20) -
结果融合:用 RRF(Reciprocal Rank Fusion)或加权分数融合,把两路结果合并成统一的排序 -
重排序(可选但推荐):用 Cross-Encoder 模型对融合后的结果做精排,进一步提升相关性
三、RRF 融合算法——混合检索的核心魔法
混合检索最关键的步骤是怎么把两路结果合并。你不能简单地把向量检索的分数和 BM25 的分数相加——因为它们的分数量纲完全不同(向量相似度是 0~1 的余弦值,BM25 是基于词频的评分,可能到几十甚至上百)。
RRF(Reciprocal Rank Fusion)巧妙地避开了分数归一化的问题,它只看排名位置,不看原始分数:
RRF_score(d) = Σ 1 / (k + rank_i(d))
-
rank_i(d):文档 d 在第 i 路检索结果中的排名 -
k:平滑常数,通常取 60
举个直观的例子:假设文档 A 在向量检索中排第 1 名,在 BM25 中排第 5 名:
RRF_score(A) = 1/(60+1) + 1/(60+5) = 1/61 + 1/65 ≈ 0.031
文档 B 在向量检索中排第 3 名,在 BM25 中排第 1 名:
RRF_score(B) = 1/(60+3) + 1/(60+1) = 1/63 + 1/61 ≈ 0.031
RRF 的设计哲学是:两种检索方式都给高排名的文档,最终得分就高。即使某一路的分数偏低,只要另一路排名靠前,文档仍然能获得不错的最终分数。这个算法最早由 Cormack, Clarke & Büttcher 在 2009 年提出,到今天已经成为 Elasticsearch、Azure AI Search、OpenSearch 等主流搜索引擎的默认融合策略。
四、代码示例:用 LangChain4j + Elasticsearch 实现混合检索
目前 LangChain4j 框架中,通过 Elasticsearch 集成实现混合检索是最成熟的方案。以下是基于 Elastic 官方博客(2026 年 3 月发布)的完整示例:
Maven 依赖:
<dependencies>
<!-- LangChain4j 核心 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>1.0.0-beta2</version>
</dependency>
<!-- LangChain4j Elasticsearch 集成(支持混合检索) -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-elasticsearch</artifactId>
<version>1.0.0-beta2</version>
</dependency>
<!-- LangChain4j Ollama(本地 Embedding 模型) -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
<version>1.0.0-beta2</version>
</dependency>
</dependencies>
Java 代码:
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.ollama.OllamaEmbeddingModel;
import dev.langchain4j.rag.content.Content;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.store.embedding.elasticsearch.ElasticsearchEmbeddingStore;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import java.util.List;
publicclass HybridSearchDemo {
public static void main(String[] args) {
// 1. 配置 Elasticsearch 客户端
RestClientBuilder restClientBuilder = RestClient.builder(
new HttpHost("localhost", 9200, "http")
);
// 2. 配置 Embedding 模型(这里用 Ollama 本地模型)
EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
.baseUrl("http://localhost:11434")
.modelName("nomic-embed-text")
.build();
// 3. 创建 Elasticsearch 向量存储
ElasticsearchEmbeddingStore embeddingStore = ElasticsearchEmbeddingStore.builder()
.restClientBuilder(restClientBuilder)
.embeddingModel(embeddingModel)
.indexName("knowledge_base")
.build();
// 4. 纯向量检索(作为对比)
ContentRetriever vectorRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(5)
// 默认使用 kNN 向量检索
.build();
// 5. 混合检索(BM25 + kNN + RRF 融合)
ContentRetriever hybridRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(5)
// 开启混合检索:自动使用 BM25 + kNN,通过 RRF 融合结果
.searchType(ElasticsearchEmbeddingStore.SearchType.hybrid())
.build();
// 6. 执行检索对比
String query = "怎么在 Spring AI 中整合 Milvus 向量数据库?";
System.out.println("=== 纯向量检索结果 ===");
List<Content> vectorResults = vectorRetriever.retrieve(query);
vectorResults.forEach(c -> System.out.println(c.textSegment().text()));
System.out.println("\n=== 混合检索结果 ===");
List<Content> hybridResults = hybridRetriever.retrieve(query);
hybridResults.forEach(c -> System.out.println(c.textSegment().text()));
}
}
这段代码的核心区别就在第 5 步——设置 .searchType(ElasticsearchEmbeddingStore.SearchType.hybrid()) 就能开启混合检索模式。Elasticsearch 底层会自动执行 BM25 + kNN 双路检索,然后用 RRF 融合排序。
五、主流向量数据库的混合检索支持情况
|
|
|
|
|
|---|---|---|---|
| Elasticsearch |
|
|
|
| Milvus 2.5+ |
|
|
|
| Weaviate |
|
|
|
| Qdrant |
|
|
|
| OpenSearch |
|
|
|
| Chroma |
|
|
|
如果你用的是 Milvus 2.5,它原生支持 Sparse-BM25 全文检索,可以直接在一个 Collection 中同时存储稠密向量和稀疏向量,然后用 HybridSearchRequest 执行混合查询。Spring AI 框架中通过 MilvusVectorStore 可以对接 Milvus,不过目前 Spring AI 对混合检索的原生抽象还在社区讨论阶段。
六、生产环境的最佳实践
说几个我实际踩过的坑和经验:
1. 混合检索 + Rerank 是黄金组合
Azure AI Search 做过一组对比实验,结论很明确:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
| 混合检索 + Rerank | +25% |
加了 Rerank 之后提升非常明显。推荐用 bge-reranker-v2-m3 或 Cohere Rerank 这类 Cross-Encoder 模型,在融合结果的基础上做精排。
2. 权重需要根据场景调整
两路检索的权重不是固定的。如果你的知识库中专业术语、编号、代码片段比较多,BM25 的权重应该适当提高;如果是概念性、描述性的内容为主,向量检索的权重可以大一些。一般建议从 0.5:0.5 开始,然后根据实际效果调优。
3. Embedding 模型的选择很关键
中文场景别用英文 Embedding 模型,效果会差很多。推荐 bge-large-zh-v1.5、text-embedding-3-large(支持中文)或者国产的 Acme_Embedding。模型选对了,向量检索这路的基线就稳了。
七、常见误区
-
误区一:”向量检索已经理解语义了,不需要关键词检索。”
现实是向量检索对精确匹配天然不敏感。用户搜
ERR-4023这种错误码,向量检索很可能给你一堆跟 “错误” 语义相关但完全不包含这个编号的文档。 -
误区二:”混合检索就是把两路结果拼在一起。”
不是简单拼接,需要通过 RRF 或加权融合来统一排序。直接拼接会导致两路结果各排各的,相关性混乱。
-
误区三:”混合检索一定比单路检索好。”
不一定。如果查询是纯语义类的(比如 “如何提高团队效率”),向量检索可能就够了。混合检索的价值在于应对复杂多样的查询场景,确保 “不管用户怎么问,都能找到相关文档”。

