Spring AI + Milvus 简单 RAG 知识库案例笔记
案例模块:
springAi-Demo/demo1-databases-rag(Spring AI 1.1.3 / Spring Boot 3.x / JDK 17)核心内容:文档上传 → 解析 → 切分 → 向量化 → 写入 Milvus → 检索增强生成(RAG)
一、整体流程
上传文件(MultipartFile)
↓ TikaDocumentReader 解析 PDF/Word/TXT/MD 等
List<Document>
↓ TokenTextSplitter 按 token 切块
List<Document>(切块后)
↓ EmbeddingModel 文本 → 向量(本例 384 维,本地模型)
↓ MilvusVectorStore.add 写入 Milvus 集合
用户提问
↓ 向量化 → similaritySearch(topK + 阈值 + 过滤)
召回片段
↓ QuestionAnswerAdvisor 拼进 Prompt(检索增强)
ChatClient → ChatModel 输出
二、依赖
pom.xml(版本由父工程的 spring-ai-bom 统一管理,无需手写 version):
xml
<!-- 本地 Embedding 模型(ONNX,不需要联网、不需要 API Key)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-transformers</artifactId>
</dependency>
<!-- Milvus 向量库(starter 只是把自动配置+客户端 SDK 带进来,仍可手动装配)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
</dependency>
<!-- 文档解析(Tika)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
<!-- QuestionAnswerAdvisor 所在包 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
<!-- RAG 模块(RetrievalAugmentationAdvisor 等)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-rag</artifactId>
</dependency>
<!-- ChatMemory(MessageChatMemoryAdvisor 需要 ChatMemory bean)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-autoconfigure-model-chat-memory</artifactId>
</dependency>
Spring AI BOM(放父 pom 的 dependencyManagement):
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
三、手动配置 MilvusVectorStore(不使用自动配置)
自动配置 (application.yaml 里写 spring.ai.vectorstore.milvus.*)够用,但当你需要
自定义客户端(鉴权、多数据源、URI 动态拼装、Testcontainers)时,就手动声明两个 Bean:
MilvusServiceClient + VectorStore。
java
package com.ai.config;
import io.milvus.client.MilvusServiceClient;
import io.milvus.param.ConnectParam;
import io.milvus.param.IndexType;
import io.milvus.param.MetricType;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.BatchingStrategy;
import org.springframework.ai.model.embedding.TokenCountBatchingStrategy;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.transformers.TransformersEmbeddingModel;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.milvus.MilvusVectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class RagConfig {
/** 切分器:默认按 800 token 一块 */
@Bean
public TokenTextSplitter tokenTextSplitter() {
return new TokenTextSplitter();
}
/**
* Embedding 模型:本地 ONNX(默认 sentence-transformers/all-MiniLM-L6-v2,输出 384 维)。
* 加 @Primary 覆盖 OpenAI 的自动配置,避免两个 EmbeddingModel 冲突。
*/
@Bean("localTransformersEmbeddingModel")
@Primary
public EmbeddingModel embeddingModel() {
return new TransformersEmbeddingModel();
}
/** Milvus 客户端:手写连接参数,不走自动配置 */
@Bean
public MilvusServiceClient milvusClient(
@Value("${milvus.host:101.42.40.19}") String host,
@Value("${milvus.port:19530}") int port,
@Value("${milvus.username:root}") String username,
@Value("${milvus.password:Milvus}") String password) {
return new MilvusServiceClient(ConnectParam.newBuilder()
.withHost(host)
.withPort(port)
.withAuthorization(username, password)
// 也可用 withUri("http://host:19530");测试用容器时:withUri(milvusContainer.getEndpoint())
.build());
}
/** 向量库:手动指定集合/索引/度量,覆盖自动配置 */
@Bean
public VectorStore vectorStore(MilvusServiceClient milvusClient, EmbeddingModel embeddingModel) {
return MilvusVectorStore.builder(milvusClient, embeddingModel)
.collectionName("vector_store_test") // 集合名
.databaseName("default")
.embeddingDimension(384) // ★ 必须和 EmbeddingModel 输出维度一致(MiniLM=384)
.indexType(IndexType.IVF_FLAT)
.metricType(MetricType.COSINE) // 余弦相似度,score 越大越相似(0~1)
.batchingStrategy(new TokenCountBatchingStrategy())
.initializeSchema(true) // 集合不存在时自动建(含索引与元数据字段)
.build();
}
}
自动配置写法(二选一,不要同时用)
yaml
spring:
ai:
vectorstore:
milvus:
client:
host: 101.42.40.19
port: 19530
username: root
password: Milvus
databaseName: default
collectionName: vector_store_test
embeddingDimension: 384
indexType: IVF_FLAT
metricType: COSINE
initialize-schema: true
一旦自己声明了
VectorStorebean,上面这段 yaml 就不会生效了(连接信息改由ConnectParam提供)。
四、灌库:解析 → 切分 → 入库
java
@PostMapping(value = "/upload", headers = "content-type=multipart/form-data")
public Object uploadFile(@RequestParam(name = "file") List<MultipartFile> files) {
if (files.isEmpty()) {
return Map.of("code", 500, "msg", "file is empty");
}
files.forEach(file -> {
// 1. 解析(PDF/Word/Excel/TXT... 由 Tika 自动识别)
List<Document> documents = new TikaDocumentReader(file.getResource()).read();
// 2. 切分(避免超长文本被截断 / 语义过于分散)
List<Document> splitDocuments = tokenTextSplitter.apply(documents);
// 3. 可写入业务元数据,后续按知识库隔离检索
// splitDocuments.forEach(d -> d.getMetadata().put("dabaseID", dabaseID));
// 4. 向量化 + 入库(内部按 BatchingStrategy 分批)
vectorStore.add(splitDocuments);
});
return Map.of("code", 200, "msg", "success");
}
要点:
TikaDocumentReader读的是纯文本,扫描版 PDF(图片)读出来是空的 → 入库 0 条,检索必然为空。- 元数据字段(如
dabaseID)必须在入库前 放进document.getMetadata(),否则后续过滤表达式匹配不到。
五、分片(Chunking)策略
分片是 RAG 里最影响效果的一步:切得太碎 → 语义残缺、召回不准;切得太大 → 噪声多、挤占上下文。
1)八种常见策略速览
| 策略 | 一句话本质 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定长度分块 | 最原始的机械切分,像尺子一样精准但无情 | 实现最简单、块大小恒定、成本极低 | 会从句子/语义中间"一刀切断" | 日志、低结构化长文本、快速原型 |
| 基于句子分块 | 先切碎再拼凑,保证每一句话的完整呼吸 | 不破坏句子完整性,可读性好 | 句子长短不一 → 块大小抖动 | FAQ、问答语料、新闻 |
| 递归字符分块 | 由粗到细的漏斗筛选(段落 → 句子 → 字符),寻找最佳切分点 | 兼顾块大小与语义边界,通用性最好 | 分隔符层级需按语料调优 | 通用文档(无脑默认首选) |
| 结构化分块 | 尊重文档骨架,按标题层级(H1/H2...)打包内容 | 块自带层级语境、内聚性高 | 强依赖文档结构,纯文本无效 | Markdown、技术手册、规章制度 |
| 对话式分块 | 滑动窗口机制,保留"问"与"答"的上下文 | 保留对话语义,不会把问答拆散 | 需识别说话人/轮次与窗口大小 | 客服会话、聊天记录 |
| 语义分块 | 倾听数据的"心跳",在话题突变处切分 | 语义最完整,检索准确率通常最高 | 需额外 embedding 计算,慢且贵 | 高质量知识库、长篇综述 |
| 父文档检索 | 索引的是碎片,召回的是全貌(小块做索引、大块喂模型) | 小块好匹配、大块好作答 | 需两级存储、实现复杂度高 | 合同条款、说明书等"精准定位但需完整上下文" |
| LLM 智能分块 | 利用大模型的理解力,像人类编辑一样精准断句 | 效果最好、规则最灵活 | 最慢最贵、结果有不确定性 | 高价值小体量语料、离线预处理 |
2)Spring AI 1.1.3 的现状(实测)
实际只提供:org.springframework.ai.transformer.splitter.TokenTextSplitter
(基类 TextSplitter implements DocumentTransformer)
- 没有 SentenceSplitter / 递归 / 语义 / 父文档 / LLM 分片器;
spring-ai-rag里的VectorStoreDocumentRetriever是检索器,不是分片器,别搞混。 - 其余策略要么自己实现
DocumentTransformer(List<Document> apply(List<Document>)),要么引入 LangChain4j 的DocumentSplitters等外部实现。 TokenTextSplitter本质是"按 token 定长 + 标点回退":先按 token 切,再尝试回退到最近的标点处断句,所以它是"固定长度 + 句子/递归"的折中体,不是纯机械切分。
3)TokenTextSplitter 参数与默认值(javap 实测)
| 参数 | 默认值 | 说明 |
|---|---|---|
chunkSize |
800 | 目标块大小(token 数) |
minChunkSizeChars |
350 | 小于该字符数的块,尝试与相邻块合并 |
minChunkLengthToEmbed |
5 | 低于该长度直接丢弃 |
maxNumChunks |
10000 | 单文档最大块数,防止爆量 |
keepSeparator |
true | 是否保留分隔符 |
punctuationMarks |
. ? ! \n |
断句标点集合 |
java
@Bean
public TokenTextSplitter tokenTextSplitter() {
return TokenTextSplitter.builder()
.withChunkSize(500) // 块更小 → 定位更准、上下文更少
.withMinChunkSizeChars(200)
.withMinChunkLengthToEmbed(5)
.withMaxNumChunks(10000)
.withKeepSeparator(true)
.build();
}
注意:
TokenTextSplitter没有 overlap(重叠窗口)参数。需要重叠得自己实现(相邻块之间多带 N 个 token 的上文),否则跨块的语义会被切断。
4)结论:最佳分片策略 = 由你自己按场景选择
不存在放之四海皆准的最佳分片,分片效果取决于你的语料结构 × 查询方式 × 成本预算:
- 不知道选什么 → 先用 TokenTextSplitter / 递归字符分块 跑通基线;
- 文档有明显标题层级 → 升级 结构化分块;
- 长文档且要求答案完整 → 用 父文档检索;
- 基线仍不达标又有预算 → 上 语义分块 / LLM 智能分块;
- 会话数据 → 对话式分块。
最终采用哪一种、chunkSize 取多少,都应由使用者自己按真实语料选择并验证 ,不要迷信任何"最优解"。唯一的验收标准:拿真实问题跑 similaritySearch,看 topK 召回的内容是否足以支撑答案,不够就调分片再测。
六、检索与对话
1)纯检索(调试用)
java
@GetMapping("/testVectorSearch")
public List<Document> testVectorSearch(@RequestParam(name = "message") String message) {
SearchRequest sr = SearchRequest.builder()
.query(message)
.topK(5)
.similarityThreshold(0.2) // COSINE 下 score 越大越相似,阈值别设太高
.build();
List<Document> docs = vectorStore.similaritySearch(sr);
docs.forEach(doc -> System.out.println("====doc==== " + doc.getText()));
return docs;
}
2)带知识库过滤 + 会话记忆 + 检索增强的对话
java
@GetMapping(value = "/chat", produces = "text/event-stream;charset=UTF-8")
public Flux<String> generate(@RequestParam(value = "message", defaultValue = "") String message,
@RequestParam(name = "dabaseID", required = false) String dabaseID) {
String userID = "1"; // 实际取登录用户 id,用作会话隔离
SearchRequest.Builder srBuilder = SearchRequest.builder()
.similarityThreshold(0.2)
.topK(5);
// 只有指定知识库时才加过滤条件(Milvus 的过滤字段必须先存在于元数据中)
if (dabaseID != null && !dabaseID.isBlank()) {
Filter.Expression filter = new FilterExpressionBuilder()
.eq("dabaseID", dabaseID)
.build();
srBuilder.filterExpression(filter);
}
return chatClient.prompt()
.user(message)
.advisors(a -> a.param("curren_data", LocalDateTime.now().toString()))
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userID))
.advisors(QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(srBuilder.build())
.build())
.stream()
.content();
}
ChatClient 装配(系统提示词 + 日志 + 会话记忆):
java
public RagController(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory) {
this.chatClient = chatClientBuilder
.defaultAdvisors(new SimpleLoggerAdvisor(),
MessageChatMemoryAdvisor.builder(chatMemory).build())
.defaultSystem("你是个人知识库ai助手,今天的日期是: {curren_data}")
.build();
}
对话模型(OpenAI 协议兼容,示例用 DeepSeek):
yaml
spring:
ai:
openai:
base-url: ${AI_BASE_URL:https://api.deepseek.com}
api-key: ${AI_API_KEY:sk-xxxx}
chat:
options:
model: ${AI_MODEL:deepseek-chat}
temperature: 0.3
七、自测顺序
bash
# 1. 上传(必须带 multipart/form-data)
curl -X POST http://127.0.0.1:8004/rag/upload -F "file=@D:/test.pdf"
# 2. 检索是否召回(先确认不为 [])
curl "http://127.0.0.1:8004/rag/testVectorSearch?message=你的问题"
# 3. 对话(检索增强 + 记忆)
curl "http://127.0.0.1:8004/rag/chat?message=你的问题&dabaseID=0c93fc70-..."