Spring AI RAG 完整实战:从零搭建企业知识库问答系统

Spring AI RAG 完整实战:从零搭建企业知识库问答系统

本文是《Spring AI 实战》系列第 7 章。前面六章分别讲了文档读取(第5章)、分割(第5章)、Embedding(第6章)、VectorStore(第6章)、ChatClient 和 Advisor 机制(第4章)。本章把所有组件串起来,搭建一个完整的企业知识库问答系统------从文档上传到 AI 回答,全流程可运行。


一、开篇:把前面学的串起来

如果你跟着前面的章节一步步做了实验,你手里已经有了这些"零件":

  • DocumentReader:能把 PDF 读成 Document 对象
  • TokenTextSplitter:能把 Document 切成小块
  • EmbeddingModel:能把文本变成向量
  • VectorStore:能存向量、做相似度搜索
  • ChatClient + Advisor:能调用大模型,能通过 Advisor 拦截增强

现在要做的是:把这些零件组装成一条完整的流水线。

这条流水线有两条链路:

  1. 文档入库链路(离线):PDF → 读取 → 分割 → 向量化 → 存入 VectorStore
  2. 问答检索链路(在线):用户提问 → 向量化 → 检索相关文档 → 拼入 Prompt → 大模型生成回答

本章的目标是:你复制完整代码,改一下配置,就能跑起来一个可用的知识库问答系统。


二、系统架构设计

2.1 两条链路详解

链路一:文档入库(Document Ingest Pipeline)

复制代码
用户上传 PDF
    ↓
DocumentController 接收 MultipartFile
    ↓
DocumentIngestService.ingest()
    ├→ (1) PagePdfDocumentReader 读取 PDF → List<Document>
    ├→ (2) 添加元数据(file_name, department, doc_type, upload_time)
    ├→ (3) TokenTextSplitter 分割 → List<Document> chunks
    └→ (4) VectorStore.add(chunks)  → 自动 Embedding + 写入 Redis

链路二:RAG 问答(Query Pipeline)

复制代码
用户提问 "公司的年假政策是什么?"
    ↓
KnowledgeController 接收问题
    ↓
RagQueryService.ask()
    ↓
ChatClient 调用(带 QuestionAnswerAdvisor)
    ├→ QuestionAnswerAdvisor 拦截请求
    │   ├→ 将问题向量化
    │   ├→ VectorStore.similaritySearch() 检索相关文档
    │   └→ 将检索到的文档拼入 System Prompt
    ├→ 增强后的 Prompt 发送给大模型
    └→ 大模型基于真实文档内容生成回答
    ↓
返回回答给用户(可附带引用来源)

2.2 模块划分

模块 类名 职责
Controller KnowledgeController 接收 HTTP 请求(上传文件 + 提问)
文档入库 DocumentIngestService 读取 + 分割 + 元数据 + 存入 VectorStore
RAG 问答 RagQueryService 调用 ChatClient + QuestionAnswerAdvisor 回答问题
配置 RagConfig 配置 ChatClient(带 RAG Advisor)和 VectorStore

三、Step 1:文档入库

3.1 完整的 DocumentIngestService 代码

java 复制代码
package com.example.springaidemo.service;

import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;

/**
 * 文档入库服务
 * 
 * 职责:将用户上传的 PDF 文件处理并存入向量数据库
 * 
 * 处理流程:
 * 1. 将 MultipartFile 转为 Spring Resource
 * 2. 用 PagePdfDocumentReader 读取 PDF
 * 3. 为每个 Document 添加自定义元数据
 * 4. 用 TokenTextSplitter 分割文档
 * 5. 调用 VectorStore.add() 存入(自动 Embedding)
 */
@Service
public class DocumentIngestService {

    private final VectorStore vectorStore;

    public DocumentIngestService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    /**
     * 处理上传的 PDF 文件并存入向量库
     * 
     * @param file       用户上传的 PDF 文件
     * @param department 所属部门(用于元数据过滤)
     * @param docType    文档类型(policy/manual/guide 等)
     * @return 处理结果摘要
     */
    public IngestResult ingest(MultipartFile file,
                               String department,
                               String docType) {
        long startTime = System.currentTimeMillis();
        String fileName = file.getOriginalFilename();

        try {
            // ====== 步骤 1:读取 PDF ======
            // 将 MultipartFile 转为 Spring 的 Resource 对象
            var resource = new ByteArrayResource(file.getBytes());

            var config = PdfDocumentReaderConfig.builder()
                    .pageExtractedTextMaxLength(10000)
                    .build();
            var reader = new PagePdfDocumentReader(resource, config);
            List<Document> documents = reader.get();

            if (documents.isEmpty()) {
                return new IngestResult(fileName, 0, 0, 0,
                        "PDF 内容为空(可能是扫描版图片 PDF)");
            }

            // ====== 步骤 2:添加元数据 ======
            String uploadTime = LocalDateTime.now()
                    .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);

            documents.forEach(doc -> {
                Map<String, Object> meta = doc.getMetadata();
                meta.put("file_name", fileName);
                meta.put("department", department);
                meta.put("doc_type", docType);
                meta.put("upload_time", uploadTime);
            });

            // ====== 步骤 3:分割文档 ======
            var splitter = TokenTextSplitter.builder()
                    .defaultChunkSize(800)       // 每个 chunk 约 800 token
                    .defaultOverlap(100)         // 相邻 chunk 重叠 100 token
                    .minChunkSizeChars(100)      // 最小字符数
                    .build();
            List<Document> chunks = splitter.apply(documents);

            // ====== 步骤 4:存入向量库 ======
            // VectorStore.add() 内部会自动调用 EmbeddingModel 向量化
            vectorStore.add(chunks);

            long cost = System.currentTimeMillis() - startTime;

            return new IngestResult(
                    fileName,
                    documents.size(),    // 原始页数
                    chunks.size(),       // 分割后的 chunk 数
                    cost,                // 耗时(毫秒)
                    "入库成功"
            );

        } catch (IOException e) {
            throw new RuntimeException("读取上传文件失败: " + fileName, e);
        }
    }

    /**
     * 入库结果记录
     */
    public record IngestResult(
            String fileName,
            int pageCount,
            int chunkCount,
            long costMs,
            String message
    ) {}
}

3.2 关键设计说明

为什么在 Service 层处理 MultipartFile 而不是先存文件? 两种方式都可以。对于小文件(< 50MB),直接从内存读取更快。对于大文件或需要异步处理的场景,建议先存到本地/对象存储,再异步处理。这里的代码展示的是最直接的同步处理方式。

VectorStore.add() 内部做了什么? 这是 Spring AI 的核心抽象之一。add() 方法内部会:

  1. 遍历每个 Document
  2. 调用 EmbeddingModel.embed() 将文本转为向量
  3. 将向量 + 文本 + 元数据一起写入底层存储(Redis / PG / Milvus 等)

你不需要手动调用 Embedding API------VectorStore 已经帮你封装好了。


四、Step 2:RAG 问答

4.1 QuestionAnswerAdvisor 详解

QuestionAnswerAdvisor 是 Spring AI 内置的 RAG Advisor,它的作用是自动完成检索增强

复制代码
用户问题 "年假有几天?"
    ↓
QuestionAnswerAdvisor 拦截
    ↓
1. 将问题传给 VectorStore.similaritySearch() 做语义搜索
2. 检索出 top-k 条相关文档片段
3. 将文档片段格式化后拼入 Prompt(作为上下文)
    ↓
增强后的 Prompt 发送给大模型
    ↓
大模型基于真实文档内容生成回答

你不需要手动拼接 PromptQuestionAnswerAdvisor 会自动生成类似这样的增强 Prompt:

复制代码
请基于以下参考信息回答用户的问题。如果参考信息中没有相关内容,请如实告知。

参考信息:
---
[文档1内容]
---
[文档2内容]
---

用户问题:年假有几天?

4.2 完整的 RagQueryService 代码

java 复制代码
package com.example.springaidemo.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;

import java.util.Map;

/**
 * RAG 问答服务
 * 
 * 职责:基于向量数据库中的文档回答用户问题
 * 
 * 核心机制:QuestionAnswerAdvisor
 * - 自动将用户问题向量化
 * - 自动在 VectorStore 中检索相关文档
 * - 自动将检索结果拼入 Prompt
 * - 大模型基于真实文档生成回答
 */
@Service
public class RagQueryService {

    private final ChatClient ragClient;

    public RagQueryService(ChatClient ragClient) {
        this.ragClient = ragClient;
    }

    /**
     * 基于 RAG 回答问题
     * 
     * @param question 用户问题
     * @return AI 回答(基于知识库文档)
     */
    public String ask(String question) {
        return ragClient.prompt()
                .user(question)
                .call()
                .content();
    }

    /**
     * 基于 RAG 回答问题,支持自定义搜索参数
     * 
     * @param question  用户问题
     * @param topK      返回的文档数量
     * @param threshold 相似度阈值
     * @return AI 回答
     */
    public String askWithParams(String question, int topK, double threshold) {
        // 通过 advisors 方法传入本次请求的搜索参数
        return ragClient.prompt()
                .user(question)
                .advisors(a -> a
                        .param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "")  // 不过滤
                        .param(SearchRequest.TOP_K, String.valueOf(topK))
                        .param(SearchRequest.SIMILARITY_THRESHOLD, 
                                String.valueOf(threshold))
                )
                .call()
                .content();
    }

    /**
     * 带部门过滤的 RAG 问答
     * 
     * 场景:不同部门的用户只能搜索本部门的文档
     */
    public String askWithDepartment(String question, String department) {
        return ragClient.prompt()
                .user(question)
                .advisors(a -> a
                        .param(QuestionAnswerAdvisor.FILTER_EXPRESSION,
                                "department == '" + department + "'")
                )
                .call()
                .content();
    }
}

4.3 RAG 配置类

java 复制代码
package com.example.springaidemo.config;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * RAG 配置类
 * 
 * 核心配置:将 QuestionAnswerAdvisor 注册到 ChatClient
 * 这样每次调用 ChatClient 时,Advisor 会自动完成检索增强
 */
@Configuration
public class RagConfig {

    /**
     * 配置带 RAG 能力的 ChatClient
     * 
     * QuestionAnswerAdvisor 的工作流程:
     * 1. 拦截用户问题
     * 2. 调用 VectorStore 做相似度搜索
     * 3. 将搜索结果拼入 Prompt
     * 4. 将增强后的 Prompt 发送给大模型
     */
    @Bean
    public ChatClient ragClient(ChatClient.Builder builder,
                                 VectorStore vectorStore) {
        return builder
                .defaultSystem("""
                        你是一个企业知识库问答助手。
                        请严格基于提供的参考信息回答问题。
                        规则:
                        1. 如果参考信息中有相关内容,基于参考信息回答,并在回答末尾标注引用来源
                        2. 如果参考信息中没有相关内容,如实说"知识库中未找到相关信息"
                        3. 不要编造参考信息中没有的内容
                        4. 回答要简洁、准确、有条理
                        """)
                .defaultAdvisors(
                        // RAG 核心 Advisor:自动检索相关文档并拼入 Prompt
                        new QuestionAnswerAdvisor(vectorStore)
                )
                .build();
    }
}

五、Step 3:Controller 层

java 复制代码
package com.example.springaidemo.controller;

import com.example.springaidemo.service.DocumentIngestService;
import com.example.springaidemo.service.RagQueryService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;

/**
 * 知识库 Controller
 * 
 * 提供两个接口:
 * 1. POST /api/knowledge/upload ------ 上传文档到知识库
 * 2. POST /api/knowledge/ask   ------ 基于知识库提问
 */
@RestController
@RequestMapping("/api/knowledge")
public class KnowledgeController {

    private final DocumentIngestService ingestService;
    private final RagQueryService queryService;

    public KnowledgeController(DocumentIngestService ingestService,
                                RagQueryService queryService) {
        this.ingestService = ingestService;
        this.queryService = queryService;
    }

    /**
     * 上传文档到知识库
     * 
     * @param file       PDF 文件
     * @param department 部门(可选,默认 general)
     * @param docType    文档类型(可选,默认 general)
     * @return 入库结果
     */
    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<Map<String, Object>> upload(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "department", defaultValue = "general") String department,
            @RequestParam(value = "docType", defaultValue = "general") String docType) {

        // 校验文件类型
        String fileName = file.getOriginalFilename();
        if (fileName == null || !fileName.toLowerCase().endsWith(".pdf")) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "仅支持 PDF 文件"));
        }

        // 校验文件大小(限制 50MB)
        if (file.getSize() > 50 * 1024 * 1024) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "文件大小不能超过 50MB"));
        }

        // 执行入库
        var result = ingestService.ingest(file, department, docType);

        return ResponseEntity.ok(Map.of(
                "code", 200,
                "message", "入库成功",
                "data", Map.of(
                        "fileName", result.fileName(),
                        "pageCount", result.pageCount(),
                        "chunkCount", result.chunkCount(),
                        "costMs", result.costMs()
                )
        ));
    }

    /**
     * 基于知识库提问
     * 
     * @param request 包含 question 和可选的 department
     * @return AI 回答
     */
    @PostMapping("/ask")
    public ResponseEntity<Map<String, Object>> ask(
            @RequestBody Map<String, String> request) {

        String question = request.get("question");
        if (question == null || question.isBlank()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "问题不能为空"));
        }

        String answer;
        String department = request.get("department");

        // 如果传了 department,则按部门过滤搜索
        if (department != null && !department.isBlank()) {
            answer = queryService.askWithDepartment(question, department);
        } else {
            answer = queryService.ask(question);
        }

        return ResponseEntity.ok(Map.of(
                "code", 200,
                "data", Map.of(
                        "question", question,
                        "answer", answer
                )
        ));
    }
}

5.1 完整测试流程

bash 复制代码
# 1. 上传文档到知识库
curl -X POST "http://localhost:8080/api/knowledge/upload" \
  -F "file=@/path/to/employee-handbook.pdf" \
  -F "department=HR" \
  -F "docType=policy"

# 返回:
# {"code":200,"message":"入库成功","data":{"fileName":"employee-handbook.pdf","pageCount":25,"chunkCount":87,"costMs":15230}}

# 2. 基于知识库提问
curl -X POST "http://localhost:8080/api/knowledge/ask" \
  -H "Content-Type: application/json" \
  -d '{"question": "公司的年假有几天?怎么申请?"}'

# 返回:
# {"code":200,"data":{"question":"公司的年假有几天?怎么申请?","answer":"根据《员工手册》第三章规定..."}}

# 3. 带部门过滤的提问
curl -X POST "http://localhost:8080/api/knowledge/ask" \
  -H "Content-Type: application/json" \
  -d '{"question": "报销流程是什么?", "department": "HR"}'

5.2 完整 Maven 依赖

xml 复制代码
<dependencies>
    <!-- Spring AI OpenAI(ChatModel + EmbeddingModel) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>

    <!-- PDF 文档读取 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-pdf-document-reader</artifactId>
    </dependency>

    <!-- Redis VectorStore -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-redis-store</artifactId>
    </dependency>

    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

5.3 完整 application.yml

yaml 复制代码
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini        # 问答用 gpt-4o-mini(便宜快)
      embedding:
        options:
          model: text-embedding-3-small  # Embedding 用 small(够用且便宜)
  data:
    redis:
      host: localhost
      port: 6379
      database: 0

六、优化策略

当你的 RAG 系统跑起来后,大概率会碰到"回答不准确"或"找不到相关文档"的问题。以下是 5 种经过验证的优化方案:

优化 1:调整 Chunk 大小

Chunk 大小 优点 缺点 适用场景
300-500 token 检索精度高,噪音少 容易丢失上下文 问答类、FAQ 类文档
500-1000 token 平衡精度和上下文 通用 大多数场景的推荐配置
1000-2000 token 上下文丰富 噪音多,Token 成本高 技术文档、长篇分析

实操建议:先用 800/100 的默认配置跑起来,如果发现回答不够精准,尝试减小到 500/100。如果发现上下文丢失,增大到 1200/200。

优化 2:增加 top-K 数量

QuestionAnswerAdvisor 默认返回 4 条文档。如果你的文档库较大或问题比较宽泛,增加到 8-10 条能显著提高召回率。代价是消耗更多 Token。

优化 3:换更好的 Embedding 模型

第 6 章的对比表已经说明了:中文场景用通义 text-embedding-v3,效果明显优于 OpenAI 的模型。这一步的投入产出比很高------只改一行配置,不需要改代码。

优化 4:优化 System Prompt

System Prompt 对 RAG 的效果影响很大。关键指令:

  • 要求大模型严格基于参考信息回答,不要用自己的知识
  • 要求标注引用来源,方便用户验证
  • 明确说"不知道",而不是编造

优化 5:混合检索

纯向量检索有盲区。比如用户搜"v3.2.1 版本的已知问题",向量搜索可能搜不到包含精确版本号的文档。混合检索(向量 + 关键词)能互补这个缺陷。第 8 章会详细讲解。


Spring AI 实战 --- 第7章:完整内容与源码

相关推荐
Ai拆代码的曹操12 小时前
K8s Pod Pending 逐层排查:从 FailedScheduling 到 PVC StorageClass 不存在的实战记录
java·linux·kubernetes
To_OC12 小时前
再也不用手动整理会议纪要!我给 Claude 封装了一套永久自动化 Skill
人工智能·agent·claude
wish36612 小时前
使用 Chainlit 调用 TextGen API + 本地 LLM 实现 RAG Chatbot 应用
人工智能·语言模型·自然语言处理·local llm
什巳12 小时前
JAVA练习306- 翻转二叉树
java·数据结构·算法·leetcode
领麦微红外13 小时前
FW-D1 vs 迈来芯MLX90614:参数全面对比
人工智能·产品经理
IT_陈寒13 小时前
React useEffect依赖数组中埋的坑,这次终于让我逮到了
前端·人工智能·后端
longxibo13 小时前
第 14 章 金融行业私有化AI落地案例
人工智能·金融·aigc
z1234567898613 小时前
2026年企业AI工作Agent同类产品深度实测评测
人工智能
wangbing112513 小时前
JPA下自定义主键
java·linux·服务器