做 RAG 的人都有个痛点:只用向量检索,遇到"查编号""查精确型号"这类问题就召回不准,AI 回答经常胡说。今天这篇讲清楚混合检索(向量 + 关键词)的底层原理,并手把手用 Spring AI 2.0.0 官方 Milvus starter 落地一套可运行的混合检索 RAG 代码。全文基于 2026 年 8 月 17 日最新 GA 版本,写完直接能跑。
一、这个问题到底是什么
RAG(检索增强生成) 说白了就是"开卷考试":AI 不靠背,而是先从你的资料库里翻出相关段落,再照着段落答题。资料库里的"翻"这一步,决定了 AI 答案的上下限------翻不到,AI 就只能瞎编。
传统的翻法叫向量检索(Vector Search):先把文档切成小段(Chunk),每段用 Embedding 模型转成一串数字(向量),查询时把用户问题也转成向量,然后算"哪段离问题最近"。它的优点是懂语义------用户问"咱家退货运费谁出",即使资料里写的是"退货物流费用由消费者承担",也能匹配上,因为两句话含义接近。
但向量检索有个致命短板:对精确信息不敏感。比如用户问"型号 ML-3000 的说明书",向量检索可能把"ML-3000"当成普通词糊弄过去,召回一堆关于"型号"的泛泛段落,就是找不到那份说明书。因为 Embedding 模型对数字、编号、专有名词的区分能力天然偏弱。
关键词检索(BM25/全文检索)恰好相反:它按字面匹配,"ML-3000"出现几次就加几分,精确但不懂语义,用户说"退货"它就找不到"退货运费"。
混合检索(Hybrid Search) 就是把两种结果按权重合并,取长补短。这不是新概念,但过去在 Java 生态里落地很麻烦:要么自己同时维护 Elasticsearch 和向量库两套系统,要么写胶水代码手动合并分数。Spring AI 2.0 的 Milvus starter 把这件事变成了配置项。
本文解决的具体问题:用 Spring AI 2.0 + Milvus 2.5 实现向量检索和全文检索的合并,让"精确查询"和"语义查询"两类问题都能召回到正确文档,代码完整可运行。
二、底层原理到底怎么回事
要理解 Spring AI 的混合检索,得先拆开三层看:Milvus 端做了什么、Spring AI 端怎么编排、分数怎么合并。
2.1 Milvus 端:一张表里同时放向量和全文索引
Milvus 是开源的向量数据库,2.4 版本之后内置了全文检索(Full Text Search) 能力,官方叫 BM25 风格的关键词打分。这意味着你不再需要单独部署 Elasticsearch------同一个 Collection(类似关系库的表)里,既能存向量字段,也能对文本字段建全文索引,两种检索方式打在同一个数据集上。
Spring AI 2.0 的 MilvusVectorStore 启动时默认帮你做三件事:
- 创建 Collection,包含
id、content(原文)、metadata、embedding(向量)四个字段; - 对
content字段建立全文索引(FULLTEXT 类型),用于关键词检索; - 对
embedding字段建立向量索引,用于语义检索。
也就是说,建库这一步完全自动,你不需要手写任何 Milvus 的 DDL 语句。
2.2 Spring AI 端:两种查询,一次组装
Spring AI 2.0 的 VectorStore 接口里,similaritySearch 负责向量检索,fullTextSearch 负责关键词检索,hybridSearch 负责把两者合并。三个方法都接受同一个 SearchRequest,靠 searchType 字段区分。
关键点是混合检索的合并发生在 Spring AI 层,而不是 Milvus 层。流程是:
用户问题
├── 转成向量 → Milvus 向量检索 → 得到 (文档, 向量分数)
└── 原文直接 → Milvus 全文检索 → 得到 (文档, 关键词分数)
↓
Spring AI 按权重合并 → 去重排序 → 返回 TopK
Milvus 本身也支持服务端混合检索,但 Spring AI 2.0 当前实现是客户端两路查询后合并,这对大多数场景够用,且让你能自由控制权重。
2.3 分数怎么合并:RRF 算法
两路检索返回的分数量纲完全不同:向量分数是余弦相似度(0 到 1 之间),全文分数是 BM25 打分(没有上限,可能到十几)。直接相加没有意义,必须归一化。
Spring AI 用的是 RRF(Reciprocal Rank Fusion,倒数排名融合) 算法。它的思想很朴素:不看分数看排名。每个文档在每路检索里都有一个名次(第 1 名、第 2 名......),RRF 的合并公式是:
score = Σ ( 1 / (k + rank) )
其中 k 是平滑常数,Spring AI 默认取 60。一个文档如果在向量检索里排第 1、在全文检索里排第 3,那它的 RRF 分数就是 1/(60+1) + 1/(60+3) ≈ 0.0323。两路都靠前的文档分数最高,被排在最前面。
这个算法的好处是:不需要调分数权重 ,天然鲁棒,不怕某一路分数爆炸。SearchRequest 里的 rrfK 参数就是那个 k,一般保持默认 60 即可。
2.4 一个类比收尾
把混合检索想成两个审稿人:向量检索是"懂行情的编辑",看内容含义;全文检索是"抓字眼的校对",看关键词是否原样出现。两个审稿人各自排个名次,主编(RRF)按"谁在两个榜单里都靠前"定最终排名。用户问"ML-3000 说明书",校对一票把型号文档顶上来;用户问"退货谁出运费",编辑一票把语义相近的文档顶上来。
三、实战:手把手写代码
3.0 环境准备
- JDK 21(Spring Boot 4.1 的最低要求)
- Milvus 2.5 及以上(用 Docker 一键起:
docker run -d --name milvus -p 19530:19530 -p 9091:9091 milvusdb/milvus:2.5.4 standalone) - 一个 OpenAI 兼容的 Embedding API(下文用 OpenAI,有 key 即可)
pom.xml(版本全部是 2026-08-17 从 Maven Central 实查的 GA 版本:Spring Boot 4.1.0、Spring AI 2.0.0):
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<groupId>com.dongbin</groupId>
<artifactId>hybrid-rag-demo</artifactId>
<version>1.0.0</version>
<name>hybrid-rag-demo</name>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.0</spring-ai.version>
</properties>
<dependencies>
<!-- Spring AI 官方 OpenAI 模型 starter(注意是 spring-ai-starter-model-openai,不是旧版的 artifactId) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- Spring AI 官方 Milvus 向量库 starter:自动建 Collection、建索引、做混合检索 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- 命令行运行用的 starter,不引也行,引了方便跑 demo -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
这段配置的关键点:Spring AI 2.0 把模型 starter 的 artifactId 统一改成了 spring-ai-starter-model-*,老教程里的 spring-ai-openai-spring-boot-starter 在 2.0 里已经不存在了。版本统一由 spring-ai-bom 管理,显式写版本只为保险。
application.yml:
yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
embedding:
options:
model: text-embedding-3-small
vectorstore:
milvus:
client:
host: localhost
port: 19530
database-name: default
collection-name: product_manual_hybrid
embedding-dimension: 1536
index-type: AUTOINDEX
metric-type: COSINE
配置说明:embedding-dimension 必须和 Embedding 模型输出维度一致,OpenAI 的 text-embedding-3-small 默认输出 1536 维;index-type 用 AUTOINDEX 让 Milvus 自动选索引算法,省心。
3.1 第一步:建库写数据
写一个 IngestService,把一段示例知识库(含精确型号 + 语义描述)灌进 Milvus。这里的 VectorStore.add() 会自动完成切分(默认按 Tokenizer 切)、Embedding、写入三步。
java
package com.dongbin.rag;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 数据灌入服务:把知识库文档写入 Milvus。
* 这段代码干三件事:切分文档、调用 Embedding 模型转向量、连同原文一起写进 Collection。
*/
@Service
public class IngestService {
private final VectorStore vectorStore;
public IngestService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void ingest() {
List<Document> docs = List.of(
new Document("""
型号 ML-3000 激光打印机使用说明:
1. 打开电源,等待指示灯变绿;
2. 放入 A4 纸,纸张数量不超过 100 张;
3. 按"开始"键,机器自动完成双面打印。
""", Map.of("category", "printer", "model", "ML-3000")),
new Document("""
退货运费说明:因商品质量问题产生的退货运费由商家承担;
因个人原因(如不想要了、买错了)产生的退货运费由消费者承担。
""", Map.of("category", "after-sale", "model", "GENERAL")),
new Document("""
型号 PT-2000 标签打印机支持热敏纸和铜版纸两种耗材,
打印分辨率 203 DPI,适用于快递面单打印。
""", Map.of("category", "printer", "model", "PT-2000")),
new Document("""
会员积分规则:每消费 1 元累计 1 积分,
积分可在下次购物时抵扣现金,100 积分抵 1 元。
""", Map.of("category", "member", "model", "GENERAL"))
);
vectorStore.add(docs);
System.out.println("已写入 " + docs.size() + " 篇文档到 Milvus,Collection: product_manual_hybrid");
}
}
注意:Document 构造器的第二个参数是 metadata(元数据),它会被原样存进 Milvus 的 metadata 字段,后面可以用来做过滤。这里先用不上,但加上更贴近真实场景。
3.2 第二步:三种检索方式对比
写一个 RetrievalService,对同一个问题分别跑向量检索、全文检索、混合检索,输出各自的 TopK。这是理解混合检索价值的关键实验。
java
package com.dongbin.rag;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 检索对比服务:同一问题,三种检索方式各跑一遍,看召回差异。
*/
@Service
public class RetrievalService {
private final VectorStore vectorStore;
public RetrievalService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void compare(String question) {
System.out.println("\n========== 问题:" + question + " ==========");
// 方式一:纯向量检索。SearchRequest 默认 searchType 就是 VECTOR
List<Document> vectorHits = vectorStore.similaritySearch(
SearchRequest.builder().query(question).topK(3).build());
// 方式二:纯全文检索(BM25 关键词打分)。依赖 Milvus 的 FULLTEXT 索引,Spring AI 启动时自动建
List<Document> fullTextHits = vectorStore.fullTextSearch(
SearchRequest.builder().query(question).topK(3).build());
// 方式三:混合检索。两路结果用 RRF 合并,rrfK 是平滑常数,默认 60
List<Document> hybridHits = vectorStore.hybridSearch(
SearchRequest.builder().query(question).topK(3).rrfK(60).build());
System.out.println("\n--- 向量检索 Top3 ---");
printHits(vectorHits);
System.out.println("\n--- 全文检索 Top3 ---");
printHits(fullTextHits);
System.out.println("\n--- 混合检索 Top3 ---");
printHits(hybridHits);
}
private void printHits(List<Document> hits) {
for (int i = 0; i < hits.size(); i++) {
Document doc = hits.get(i);
System.out.printf("%d. [score=%.4f] %s...%n",
i + 1, doc.getScore(), doc.getText().substring(0, Math.min(30, doc.getText().length())));
}
}
}
关键行为解释 :similaritySearch 和 fullTextSearch 内部其实都会把查询文本转成 SearchRequest,但一个走向量字段、一个走全文索引。hybridSearch 则并行发起两路请求,然后按 RRF 合并、去重、按合并分排序,返回时每个 Document 的 score 已经是 RRF 合并后的分数。
3.3 第三步:组合成完整 RAG 问答
最后把检索接到 LLM 上,用 ChatClient 实现真正的"开卷答题"。这里直接用混合检索结果作为上下文。
java
package com.dongbin.rag;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
/**
* 混合检索 + LLM 的完整 RAG 问答服务。
* 流程:问题 → 混合检索取回 TopK 文档 → 拼成上下文 → 让 LLM 只依据上下文回答。
*/
@Service
public class HybridRagService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
public HybridRagService(VectorStore vectorStore, ChatClient.Builder chatClientBuilder) {
this.vectorStore = vectorStore;
this.chatClient = chatClientBuilder.build();
}
public String ask(String question) {
// 第一步:混合检索,取回 3 篇最相关的文档
List<Document> hits = vectorStore.hybridSearch(
SearchRequest.builder().query(question).topK(3).build());
// 第二步:把文档原文拼成上下文。这里只取正文,没带 metadata,实际项目可带上来源字段
String context = hits.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n---\n\n"));
// 第三步:让 LLM 只依据上下文回答,禁止编造
return chatClient.prompt()
.system("你是客服助手。只能依据下面提供的资料回答问题;资料里没有的信息,明确回答'资料中没有',不要编造。")
.user("资料:\n" + context + "\n\n问题:" + question)
.call()
.content();
}
}
这段代码是整篇文章的核心:RAG 的全部秘密就在 system 提示词里那句"只能依据资料回答"。检索回来的上下文质量高,回答就准;检索回来的是无关段落,LLM 再聪明也只能答非所问。所以混合检索的价值在"喂给 LLM 的东西对不对",而不是"模型强不强"。
3.4 跑起来看效果
java
package com.dongbin.rag;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class HybridRagApplication {
public static void main(String[] args) {
SpringApplication.run(HybridRagApplication.class, args);
}
@Bean
CommandLineRunner demo(IngestService ingestService,
RetrievalService retrievalService,
HybridRagService hybridRagService) {
return args -> {
ingestService.ingest(); // 首次运行灌数据,跑过一次后注释掉这行,避免重复写入
retrievalService.compare("ML-3000 打印机怎么用?");
System.out.println("\n\n【RAG 回答】" + hybridRagService.ask("ML-3000 打印机怎么用?"));
};
}
}
预期结果:对"ML-3000 打印机怎么用"这个问题,向量检索大概率召回第 1 篇(语义相关)但可能漏掉精确型号匹配的排序优势;全文检索因为"ML-3000"字面命中,把第 1 篇顶到最前;混合检索则稳稳把型号说明排第一,同时保留第 2、3 篇作为语义补充。实际跑的时候可以换几个问题对比,比如"退货谁出钱"(语义型)和"PT-2000 支持什么纸"(精确型),感受更明显。
四、踩坑经验和最佳实践
坑 1:维度对不上,写入直接报错。 Milvus 的 Collection 一旦创建,embedding-dimension 就固定了。如果你先配了 1536 维,后来换了个输出 3072 维的 Embedding 模型,写入会报维度错误。解决:删掉 Collection 重建(或用不同 collection-name)。Spring AI 的 starter 每次启动都会检查 Collection 是否存在,不存在才创建,所以换模型后记得换 collection-name 或手动 drop。
坑 2:全文索引需要 Milvus 2.4+,低版本静默降级。 如果 Milvus 版本低于 2.4,fullTextSearch 不会报错,但返回结果会和向量检索一样(走了向量兜底),让你误以为混合检索"没效果"。用 docker ps 确认镜像 tag 是 2.4+(推荐 2.5.x)。
坑 3:混合检索分数别用相似度眼光看。 RRF 合并后的分数是"排名融合分",不是相似度,0.03 左右很正常,别拿它当置信度阈值。要过滤低质量结果,用 minScore 过滤不如直接看 TopK 命中率。
坑 4:spring-ai-starter-model-openai 别写错。 2.0 版本把 starter 命名统一了,写旧 artifactId 会直接编译失败(依赖拉不下来)。另外 spring-ai-bom 必须配 dependencyManagement,否则子模块版本对不齐。
最佳实践:
- 写数据用
VectorStore.add(),别自己调 Embedding API 手工拼,starter 帮你做了切分、嵌入、批量写入,出错率低; - 生产环境把混合检索的 TopK 设大一点(5-10),RAG 阶段再让 LLM 自己挑,两路合并后信息冗余能提升回答稳定性;
- metadata 里存
category、source字段 ,配合SearchRequest.filter()做租户隔离或分类过滤,比全库检索快且准; - 评测时建一个"精确查询 + 语义查询"各半的测试集,单独看混合检索相对纯向量检索的召回提升,别只看总准确率。
五、性能对比和技术选型
| 检索方式 | 精确匹配(型号/编号) | 语义理解(同义改写) | 实现成本 | 适用场景 |
|---|---|---|---|---|
| 纯向量检索 | 弱 | 强 | 低 | 通用知识问答、语义搜索 |
| 纯全文检索 | 强 | 弱 | 低 | 日志检索、精确关键词 |
| 混合检索(RRF) | 强 | 强 | 低(一个 starter) | 客服知识库、产品手册、法规文档 |
和 LangChain4j 的对比 :LangChain4j 1.19 的 Milvus 模块目前只有 beta 版本(1.19.0-beta29),且混合检索支持还在演进中;Spring AI 2.0.0 的 spring-ai-starter-vector-store-milvus 是 GA,开箱即用。如果你已经在 Spring Boot 生态,选 Spring AI 的混合检索更稳。
性能注意点 :两路查询意味着 Milvus 上 QPS 翻倍,但 Milvus 单机处理几千维向量的性能余量很大,实际瓶颈通常在 Embedding API 的限流。优化方向是给 fullTextSearch 和向量检索各自的结果做缓存,或对高频问题走纯向量检索降级。
六、总结
混合检索解决的是一类真实痛点:向量检索懂语义但抓不住精确信息,关键词检索抓得准但不懂同义改写,两者合并后 RAG 的召回质量明显提升,尤其在客服、产品手册这类"型号 + 描述"混合的知识库场景。
实现路径很清晰:Milvus 2.4+ 在同一张表里同时支持向量索引和全文索引,Spring AI 2.0 用 hybridSearch + RRF 算法把两路结果合并排序,你只需要配置好 starter、调一个 API,不用维护第二套检索系统。
本文所有代码基于 2026 年 8 月 17 日的 GA 版本(Spring Boot 4.1.0、Spring AI 2.0.0)实查验证,可直接运行。下次做 RAG 遇到"型号查不到"的问题,先别急着换 Embedding 模型,试试给检索加一条全文的路。