十三、SpringAl 知识库AI问答系统简单实现

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

一旦自己声明了 VectorStore bean,上面这段 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-..."

相关推荐
该逃避避1 小时前
Chrome 插件开发实战指南
人工智能
larance1 小时前
[菜鸟教程] 机器学习教程十课-Python 机器学习应用
人工智能·python·机器学习
Aloudata1 小时前
Metric Layer 建设指南:如何先从核心指标层启动企业语义工程
大数据·人工智能·数据分析·data agent·语义层
聪明蛋子哟1 小时前
不仅仅是向量检索:结合知识图谱与Tool Calling的混合增强生成(Hybrid RAG)方案
人工智能·算法·知识图谱
雪兽软件1 小时前
AI如何玩转太空探索?
人工智能·太空探索
jerryinwuhan1 小时前
HV-DGTF数据治理框架
大数据·人工智能
qq_369173631 小时前
如何将 AI 生成的 HTML 网页发布成在线链接?不用自己搭建服务器
前端·人工智能·html·效率工具·html 发布
每天一道题1 小时前
Agent 评测的关键,不是给它出难题,而是让它做选择
人工智能·ai