Spring AI 2.0 接 Milvus 做混合检索:RAG 召回率翻倍的实战方案

做 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 启动时默认帮你做三件事:

  1. 创建 Collection,包含 idcontent(原文)、metadataembedding(向量)四个字段;
  2. content 字段建立全文索引(FULLTEXT 类型),用于关键词检索;
  3. 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-typeAUTOINDEX 让 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())));
        }
    }
}

关键行为解释similaritySearchfullTextSearch 内部其实都会把查询文本转成 SearchRequest,但一个走向量字段、一个走全文索引。hybridSearch 则并行发起两路请求,然后按 RRF 合并、去重、按合并分排序,返回时每个 Documentscore 已经是 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 里存 categorysource 字段 ,配合 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 模型,试试给检索加一条全文的路。

相关推荐
段一凡-华北理工大学1 小时前
高炉炉况智能诊断与预警实战~系列文章14:机器学习炉况分类:样本构建、类别不平衡与模型选型
大数据·人工智能·机器学习·分类·高炉智能化·炉况诊断·炉况分类
SEONIB_Explorer1 小时前
VEONIB 电商 UGC 视频自动化生产实战指南
人工智能·自动化·音视频·跨境电商·视频制作·veonib
代码里的AI星1 小时前
B2B企业AI搜索可见度诊断与GEO技术优化实践:从0%到67%的架构重构之路
大数据·人工智能
QCodingDev1 小时前
Spring AI Alibaba Graph实战:从ReAct Agent到Workflow,企业AI复杂流程该如何编排?
java·人工智能·spring·ai·ai编程·企业ai
DS随心转APP1 小时前
AI导出鸭插件 如何解决这些痛点,以及它如何重构“批量导出”这件事,让 纳米AI导出Excel 和其他格式告别手动整理,让AI导出回归优雅。
人工智能·重构·word·excel·deepseek·ai导出鸭
Raas1001 小时前
AI网关是做什么的?MAI Gateway (魔芋企业级AI网关)给出企业级答案
网络·人工智能·gateway·企业级·ai网关·mai gateway·魔芋
JasmineWr1 小时前
Spring BeanDefinition 与 Bean 生命周期、循环依赖解析
java·后端·spring
欧特克_Glodon1 小时前
OpenCV计算机视觉开发入门与实践<二十>:非线性变换灰度变换
c++·人工智能·opencv·计算机视觉
美狐美颜SDK开放平台1 小时前
直播APP开发技术详解:视频美颜SDK在人脸识别、美型算法与渲染优化中的应用
人工智能·音视频·美颜sdk·直播美颜sdk·第三方美颜sdk