这篇文章只讲一件事:怎么用 `Spring AI Alibaba + Milvus` 跑通一套真正能落地的 RAG 知识库。

看完你会得到两样东西:一是搞清楚 `检索、增强、生成` 这条链路到底怎么接,二是拿到一套可以继续往企业知识库场景演进的实战思路。

RAG 说白了就三步:先检索,再补充上下文,最后让模型生成答案。难点不在概念,而在工程落地。

这篇就用 `Spring AI Alibaba + Milvus` 把这条链路完整跑一遍:

`文件上传 -> 文档解析 -> 文本切分 -> 向量化 -> Milvus 存储 -> 检索增强 -> 大模型回答`

如果你是 `Java / Spring Boot` 开发者,这套方案会很好上手。下面直接进入实战。

 一 、 引入 Milvus 向量存储依赖

RAG 的第一步是检索,而语义检索的基础就是向量库。

这里我们选 `Milvus`,原因也很直接:

  • 方案成熟

  • 社区广泛使用

  • Spring AI 的集成比较顺

先引入依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-milvus-store</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-autoconfigure-vector-store-milvus</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>

    这几个依赖可以理解成三层能力:

    • 向量存储能力

    • Spring Boot 自动配置能力

    • RAG 检索增强能力

    把这三件事补齐,后面整条链路才接得起来。

     二 、在 application.yml 里接上 Milvus

    依赖加完,下一步就是连接配置。

    spring:
      ai:
        vectorstore:
          milvus:
            enabled: true
            initialize-schema: true
            client:
              host: "1.111.115.117"
              port: 19530
              username: "minioadmin"
              password: "minioadmin"
            databaseName: "default"
            collectionName: "vector_store"
            embeddingDimension: 1024
            metricType: COSINE

      这里有几个参数要特别注意。`initialize-schema: true`

      它的作用是应用启动时自动初始化集合,适合我们这种先快速跑通链路的场景。

      `embeddingDimension: 1024`

      这个维度必须和你实际使用的嵌入模型输出一致。这个地方一旦不匹配,后面写向量时大概率会直接报错。

      `metricType: COSINE`

      这是文本语义检索里很常见的一种相似度计算方式,绝大多数场景下都够用。

      到这里为止,你的应用已经具备连接 Milvus 的能力了。

      但光能连上还不够,向量库里还得先有内容。

      三、启动时初始化一批测试向量数据

      很多人做 RAG,容易一上来就卡在“为什么查不到数据”。

      所以最实用的方式,是先在应用启动时塞一批演示文档,先验证整条链是不是通的。

      @Configuration
      public class MilvusInitializer {
      
          private static final Logger logger = LoggerFactory.getLogger(MilvusInitializer.class);
      
          @Bean("milvusDataInitializer")
          public ApplicationRunner milvusInitializer(
                  VectorStore vectorStore,
                  @Value("${spring.ai.vectorstore.milvus.initialize-demo-data:true}") boolean initializeDemoData) {
      
              return args -> {
                  if (initializeDemoData) {
                      logger.info("开始初始化Milvus数据...");
      
                      List<Document> demoDocs = List.of(
                              new Document("苹果是一种常见的水果,富含维生素C和纤维素"),
                              new Document("香蕉是热带水果,含有丰富的钾元素"),
                              new Document("橙子味道酸甜,富含维生素C"),
                              new Document("草莓外观鲜红,口感甜美,富含抗氧化物质"),
                              new Document("葡萄可以制作葡萄酒,含有多种有益成分")
                      );
      
                      try {
                          int batchSize = 10;
                          for (int i = 0; i < demoDocs.size(); i += batchSize) {
                              int endIndex = Math.min(i + batchSize, demoDocs.size());
                              List<Document> batch = demoDocs.subList(i, endIndex);
                              vectorStore.add(batch);
                              logger.info("已添加批次文档到Milvus,批次范围: {}-{}", i, endIndex - 1);
                          }
                          logger.info("成功添加 {} 个文档到Milvus", demoDocs.size());
                      } catch (Exception e) {
                          logger.warn("添加数据到Milvus时出现错误,由于集合尚未完全准备好,错误信息: {}", e.getMessage());
                      }
                  }
              };
          }
      }

      这段代码的价值,不只是“放几条水果数据做演示”。

      它背后其实是在做一件更重要的事:

      先把向量存储、向量写入、集合初始化这几个环节验证通。

      另外,这里保留“分批写入”也很有必要。

      因为你后面如果接的是在线 Embedding 服务,批量大小往往会受到限制。提前按批次写,工程上会更稳。

      四、把 QuestionAnswerAdvisor 接进 ChatClient

      前面只是把数据放进了向量库。

      真正让 RAG 发挥作用的关键,是让模型在回答前先去检索。

      在 `Spring AI` 里,这一步的核心组件之一就是 `QuestionAnswerAdvisor`。

      先看配置:

      @Configuration
      public class ChatConfig {
      
          @Bean
          public ChatClient milvusRagChatClient(
                  @Qualifier("dashscopeChatModel") ChatModel dashscopeChatModel,
                  RedisChatMemoryRepository redisChatMemoryRepository,
                  VectorStore vectorStore) {
      
              return ChatClient.builder(dashscopeChatModel)
                      .defaultSystem("""
                              你是一位专业的知识库问答助手,名字叫小王同学。
                              你只能基于 Milvus 向量数据库中检索到的信息回答用户问题。
                              请遵循以下原则:
                              1. 只能使用知识库中检索到的信息作答,不要凭空编造内容。
                              2. 如果检索结果不足以回答问题,要明确告诉用户当前知识库无法支持作答。
                              3. 回答要简洁、准确、重点清晰,结构尽量明了。
                              4. 当检索结果与问题相关时,结合这些信息给出可靠答案。
                              5. 保持专业、客观的语气,不添加无依据的主观判断。
                              """)
                      .defaultOptions(DashScopeChatOptions.builder()
                              .withModel("qwen-plus")
                              .withTemperature(0.7)
                              .build())
                      .defaultAdvisors(
                              new SimpleLoggerAdvisor(),
                              MessageChatMemoryAdvisor.builder(
                                      MessageWindowChatMemory.builder()
                                              .chatMemoryRepository(redisChatMemoryRepository)
                                              .maxMessages(Integer.MAX_VALUE)
                                              .build()
                              ).build(),
                              QuestionAnswerAdvisor.builder(vectorStore)
                                      .searchRequest(SearchRequest.builder()
                                              .topK(5)
                                              .similarityThreshold(0.7)
                                              .build())
                                      .build()
                      )
                      .build();
          }
      }

      这段配置建议重点看三个点。

      4.1  System Prompt 负责给模型“划边界”

      这里不是为了把 Prompt 写得多华丽,而是为了限制模型别乱答。

      尤其在知识库场景里,比“答不上来”更危险的,是“胡说八道但语气很坚定”。

      所以你会看到这里明确强调:

      • 只能基于检索结果回答

      • 检索不到就直接说不知道

      • 不要凭空补全

      这一步,本质上是在压幻觉。

      4.2  MessageChatMemoryAdvisor 负责让对话不断片

      如果用户上一轮问“这个接口怎么配”,下一轮又问“那端口号是多少”,系统必须知道“那”指的是前文哪个上下文。

      这就是聊天记忆的意义。

      它不直接负责检索,但会极大改善多轮问答体验。

      4.2.1 QuestionAnswerAdvisor 负责把检索结果真正塞进问答流程

      这才是 RAG 最核心的一步。

      用户提问后,它会先去 `VectorStore` 检索相关片段,再把这些片段补充进模型上下文里,然后模型才开始生成答案。

      这里两个参数尤其关键:

      - `topK(5)`:拿回最相关的 5 个片段

      - `similarityThreshold(0.7)`:只保留相似度达到阈值的内容

      RAG 后面很多调优,调的其实就是这两个东西。

      五、先做一个最小版的文本入库和问答接口

      现在,检索增强能力已经挂进 `ChatClient` 了。

      下一步要做的,是把它真正暴露成接口,先跑通最小闭环。

      下面这个 Controller 里,包含了两个非常关键的接口:

      • 向 Milvus 添加文档

      • 基于 Milvus 进行 RAG 问答

      @RestController
      @RequestMapping("/milvus")
      public class MilvusRagController {
      
          @Autowired
          private VectorStore vectorStore;
      
          @Autowired
          @Qualifier("milvusRagChatClient")
          private ChatClient chatClient;
      
          @Autowired
          private RedisChatMemoryRepository redisChatMemoryRepository;
      
          @Autowired
          private RedisTemplate redisTemplate;
      
          private static final Logger logger = LoggerFactory.getLogger(MilvusRagController.class);
      
          @PostMapping(value = "/add-documents", produces = "text/html;charset=utf-8")
          public String addDocuments(String[] text) {
              try {
                  List<Document> documents = Arrays.stream(text)
                          .map(Document::new)
                          .toList();
      
                  int batchSize = 10;
                  for (int i = 0; i < documents.size(); i += batchSize) {
                      int endIndex = Math.min(i + batchSize, documents.size());
                      List<Document> batch = documents.subList(i, endIndex);
                      vectorStore.add(batch);
                      logger.info("已添加批次文档到Milvus,批次范围: {}-{}", i, endIndex - 1);
                  }
      
                  return String.format("成功添加 %d 个文档到Milvus向量数据库", documents.size());
              } catch (Exception e) {
                  logger.error("添加文档到Milvus时发生错误: ", e);
                  return "添加文档失败: " + e.getMessage();
              }
          }
      
          @GetMapping(value = "/rag-query", produces = "text/html;charset=utf-8")
          public Flux<String> ragQuery(
                  @RequestParam String query,
                  @RequestParam(value = "sessionId", defaultValue = "student_session") String sessionId,
                  @RequestParam(value = "userId", defaultValue = "default_userId") String userId) {
      
              String redisKey = userId + ":" + sessionId;
      
              MessageWindowChatMemory messageWindowChatMemory = MessageWindowChatMemory.builder()
                      .chatMemoryRepository(redisChatMemoryRepository)
                      .maxMessages(Integer.MAX_VALUE)
                      .build();
      
              if (messageWindowChatMemory.get(redisKey).size() < 1) {
                  redisTemplate.setKeySerializer(RedisSerializer.string());
                  redisTemplate.setValueSerializer(RedisSerializer.json());
                  BoundListOperations boundListOperations = redisTemplate.boundListOps("history:" + userId);
                  boundListOperations.leftPush(sessionId);
              }
      
              try {
                  return chatClient.prompt()
                          .advisors(a -> a.param(CONVERSATION_ID, redisKey))
                          .user(query)
                          .advisors(
                                  QuestionAnswerAdvisor.builder(vectorStore)
                                          .searchRequest(SearchRequest.builder().query(query).build())
                                          .build()
                          )
                          .stream()
                          .content();
              } catch (Exception e) {
                  logger.error("RAG查询时发生错误: ", e);
                  return Flux.error(e);
              }
          }
      }

        这一步做完后,你已经有了一个最基础的知识库问答雏形。

        也就是说,只要把文本写进 Milvus,用户提问时系统就能先检索,再作答。

        但问题也很明显。

        现实业务里,谁会手工一条一条传字符串进知识库?

        大家真正需要的,是“直接上传文档”。

        所以接下来这一步,才是让这套方案真正从 Demo 走向可用的关键。

        六、接入文件读取能力,让知识库开始像个产品

        企业知识库里的数据,通常不会老老实实躺成字符串数组。

        它更常见的样子是:

        • PDF 产品手册

        • Word 制度文档

        • Markdown 技术文档

        • PPT、Excel、HTML 等各类业务资料

        所以如果 RAG 想变成一个真正能用的东西,就必须把“文件读取”接进来。

        先加依赖:

        <!-- PDF文档读取器 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-pdf-document-reader</artifactId>
        </dependency>
        
        <!-- Markdown文档读取器 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-markdown-document-reader</artifactId>
        </dependency>
        
        <!-- Tika文档读取器 -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-tika-document-reader</artifactId>
        </dependency>
        
        <!-- Spring AI Alibaba文档读取器 -->
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-document-reader-poi</artifactId>
        </dependency>

          这里的思路很清楚:

          • PDF 单独处理

          • 其他常见文档交给 Tika

          • Spring AI Alibaba 再补一层文档读取支持

          这样后面上传各种业务文件时,整个系统才接得住。

          七、上传文件、切分文本、向量化入库

          到了这里,整篇文章真正最有“实战味”的部分来了。

          因为从这一步开始,你处理的就不再是 demo 数据,而是真实文档。

          代码如下:

          @RestController
          @RequestMapping("/api/rag")
          public class RagController {
          
              @Autowired
              private VectorStore vectorStore;
          
              @Autowired
              @Qualifier("milvusRagChatClient")
              private ChatClient chatClient;
          
              @Autowired
              private RedisChatMemoryRepository redisChatMemoryRepository;
          
              @Autowired
              private RedisTemplate redisTemplate;
          
              @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
              public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
                  try {
                      if (file.isEmpty()) {
                          return ResponseEntity.badRequest().body("文件不能为空");
                      }
          
                      String filename = file.getOriginalFilename();
                      String extension = getFileExtension(filename);
                      List<Document> documentList;
          
                      if ("pdf".equalsIgnoreCase(extension)) {
                          byte[] fileBytes = file.getBytes();
                          ByteArrayInputStream pdfInputStream = new ByteArrayInputStream(fileBytes);
                          InputStreamResource pdfResource = new InputStreamResource(pdfInputStream);
                          PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(pdfResource);
                          documentList = pdfReader.get();
                      } else {
                          TikaDocumentReader reader = new TikaDocumentReader(file.getResource());
                          documentList = reader.get();
                      }
          
                      TokenTextSplitter splitter = new TokenTextSplitter();
                      List<Document> documents = splitter.apply(documentList);
          
                      vectorStore.add(documents);
          
                      return ResponseEntity.ok("成功上传文件并添加到向量数据库中,一共处理了 " + documents.size() + " 个文档片段");
                  } catch (Exception e) {
                      return ResponseEntity.status(500).body("处理文件时发生错误: " + e.getMessage());
                  }
              }
          
              @GetMapping(value = "/query", produces = "text/html;charset=utf-8")
              public Flux<String> ragQuery(@RequestParam("question") String question,
                                           @RequestParam("sessionId") String sessionId,
                                           @RequestParam("userId") String userId) {
          
                  String redisKey = userId + ":" + sessionId;
          
                  MessageWindowChatMemory messageWindowChatMemory = MessageWindowChatMemory.builder()
                          .chatMemoryRepository(redisChatMemoryRepository)
                          .maxMessages(Integer.MAX_VALUE)
                          .build();
          
                  if (messageWindowChatMemory.get(redisKey).size() < 1) {
                      redisTemplate.setKeySerializer(RedisSerializer.string());
                      redisTemplate.setValueSerializer(RedisSerializer.json());
                      BoundListOperations boundListOperations = redisTemplate.boundListOps("history:" + userId);
                      boundListOperations.leftPush(sessionId);
                  }
          
                  try {
                      return chatClient.prompt()
                              .advisors(a -> a.param(CONVERSATION_ID, redisKey))
                              .user(question)
                              .advisors(
                                      QuestionAnswerAdvisor.builder(vectorStore)
                                              .searchRequest(SearchRequest.builder()
                                                      .query(question)
                                                      .topK(5)
                                                      .similarityThreshold(0.3)
                                                      .build())
                                              .build()
                              )
                              .stream()
                              .content();
                  } catch (Exception e) {
                      return Flux.error(e);
                  }
              }
          
              private String getFileExtension(String fileName) {
                  if (fileName == null || !fileName.contains(".")) {
                      return "";
                  }
                  return fileName.substring(fileName.lastIndexOf(".") + 1);
              }
          }

            这段代码其实把“企业知识库入库链路”完整串起来了。

            7.1 先识别文件类型

            PDF 走 `PagePdfDocumentReader`。

            其他常见文档走 `TikaDocumentReader`。

            这一步解决的是“不同格式,不同读取器”的问题。

            7.2 再把整篇文档切成适合检索的小块

            这里用的是 `TokenTextSplitter`。

            这是 RAG 里一个特别容易被低估,但实际上特别关键的环节。

            因为你不能把一整份几十页的 PDF 当成一个大文本块直接去做向量化。

            那样会有两个后果:

            • 检索粒度太粗

            • 上下文噪音太大

            只有切成合理的文档片段,向量检索才真正有意义。

            7.3 最后向量化并写入 Milvus

            `vectorStore.add(documents)` 这一行看起来很轻,但背后其实完成了几件事:

            • 文本向量化

            • 向量数据写入 Milvus

            • 为后续语义检索建立基础

            也就是说,从这一刻起,这份文件才真正进入了你的 RAG 知识库。

             八、加一个简单页面,先把上传链路跑通

            后端链路接完之后,建议先别急着追求页面多漂亮。

            先做一个最简单的上传页,把“文件上传 -> 文档解析 -> 向量入库”这段链路验证通,反而更重要。

            <!DOCTYPE html>
            <html>
            <head>
                <title>RAG文件上传测试</title>
                <meta charset="utf-8">
            </head>
            <body>
                <h1>RAG文件上传测试</h1>
                <form id="uploadForm" action="/api/rag/upload" method="post" enctype="multipart/form-data">
                    <div>
                        <label for="file">选择文件:</label>
                        <input type="file" id="file" name="file"
                               accept=".pdf,.txt,.md,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.xml" required>
                    </div>
                    <br>
                    <button type="submit">上传文件</button>
                </form>
            </body>
            </html>

              这个页面虽然简单,但它对排障非常有价值。

              因为做 RAG 时,最怕的不是“回答有点偏”,而是你根本不知道问题到底出在:

              • 文件没传成功

              • 文档没解析出来

              • 文本没切好

              • 向量根本没入库

              先把上传这段验证通:

               

               

              上传了一个cursor的文档:

               

              检索内容:

              提问一个库中没有的:

              记忆功能:

               

              九、到这里,你其实已经有了一套能继续扩展的 RAG 雏形

              我们回头看一下这套链路到底做成了什么。

              你现在已经完成了:

              1. 用 `Spring AI Alibaba` 接上模型与文档处理能力

              2. 用 `Milvus` 存储向量数据

              3. 用 `TokenTextSplitter` 做文档切片

              4. 用 `QuestionAnswerAdvisor` 接入检索增强

              5. 用 `RedisChatMemoryRepository` 保留会话记忆

              6. 用上传接口实现知识文档动态入库

              这意味着你手上的已经不只是一个“会调用大模型 API”的小 Demo,而是一套可以继续往业务知识库方向推进的最小可用版本。

              十、最后补几句实战里特别容易踩坑的点

              到这里,代码链路算是跑通了。

              但如果你准备继续往真实项目推进,这几个点一定要留意。

              10.1  Embedding 维度一定要对齐

              你在 `application.yml` 里配置了 `embeddingDimension: 1024`,那你的嵌入模型输出维度就必须一致。

              这个地方不对齐,后面向量写入基本很难正常。

              10.2  文档切分策略会直接影响召回质量

              切得太大,检索不准。

              切得太碎,语义断裂。

              所以 `TokenTextSplitter` 虽然默认能用,但真上线时通常还要结合你的业务文档继续调。

              10.3 相似度阈值没有标准答案

              文里示例里出现了 `0.7` 和 `0.3` 两种阈值,不是谁错谁对,而是说明:

              这个参数必须根据你的数据实测。

              不同文档类型、不同 Embedding 模型、不同业务问题,对阈值的敏感度都不一样。

              10.4  很多 RAG 问题,本质是数据问题

              别一看到回答效果差,就马上怀疑模型。

              真实项目里更常见的情况是:

              • 知识库内容本身不干净

              • 文档切分不合理

              • 召回片段质量不高

              • 阈值设置太松或太严

              • 上下文噪音太大

              所以做 RAG,千万别只盯着模型参数。

              数据链路和检索链路,往往更决定最终效果。

              本文所有调用逻辑、配置、代码我都整理完整可运行项目,不用自己拼凑,直接导入就能测。

              如果你对 Java + AI 实战、Spring AI 落地、RAG、MCP、Agent、AI 支付这些内容感兴趣,关注我的技术号,想领取 本节源码的话,关注后后台回复:SpringAI-RAG-Milvus 即可。

               

              Logo

              AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

              更多推荐