← 返回上一页

第10章: 「知搜」企业智能检索问答系统

基于 Spring AI + ChromaDB RAG 实现企业级文档知识库检索问答系统

🔎 本章可检索关键词索引

如果你在站内搜索或 RAG 问答里输入下面这些词,本章都明确提到了: ChromaDBSpring AI 1.0.0-M6text-embedding-v2VectorStoreRAGQueryServiceDocumentProcessServiceRAGController/api/rag/upload/api/rag/chat/api/rag/stats/api/rag/clearDashScopeEmbeddingTop-K 检索分批入库25 条限制

📖 本章目标:构建「知搜」企业智能检索问答系统

本章带你完成 阶段 4:「知搜」企业智能检索问答系统 项目: 基于 Spring AI + ChromaDB RAG 构建一个具备企业级向量检索、多源知识库联动、检索质量优化、运营指标监控的智能检索问答系统。

✅ 完成本项目你将掌握以下8大核心能力

  • 1、RAG 架构设计与链路编排:基于 Spring AI 设计企业级 RAG 检索问答架构,掌握 Embedding 模型、向量空间相似度计算;实现文档解析 → 智能切分 → 向量化 → 向量存储 → 检索增强生成的完整链路。
  • 2、企业级向量检索系统:使用 BGE/M3E Embedding 进行文本向量化;掌握 ChromaDB 向量库存储与检索;实现相似度搜索、元数据过滤、Top-K 检索、混合检索等高级能力。
  • 3、多源知识库联动:对接产品手册、售后工单库、常见问题库(FAQ),实现"问题-答案"智能映射;支持知识库增量更新、自动同步与版本管理。
  • 4、检索质量优化体系:实现 Top-K 召回 + 重排序(Rerank/BGE-Reranker)的两阶段检索;建立 Recall@K、MRR、nDCG 等评测指标体系。
  • 5、上下文增强与引用溯源:实现上下文压缩(摘要/关键句提取)、引用溯源(来源标注)、多文档融合策略;生成带来源引用的可信答案。
  • 6、运营指标体系建设:建立延迟(P95/P99)、成功率、成本(Token 消耗)、缓存命中率等 SLA 指标;通过监控告警、自动扩容、降级熔断保障服务稳定性。
  • 7、数据复盘与质量优化:自动采集 RAG 服务日志,分析高频检索失败场景、用户满意度低的问答对;输出优化建议。
  • 8、部署与运维上线:掌握 Spring Boot 应用打包(JAR)、容器化(Docker)、Kubernetes 编排、云服务部署;实现分布式向量存储接入、服务注册发现、负载均衡、故障转移等企业级高可用架构。

🎯 前置要求

  • 已完成第9章:RAG 检索增强生成(Test09_RAGTutorial)
  • 已配置通义千问 Embedding 模型 text-embedding-v2
  • 对 Embedding、相似度搜索、RAG 流程已有基本理解

📁 项目结构更新

建议先看清楚“本章新增了哪些组件、各自负责什么”,再开始写代码与调接口。

text
src/main/java/com/example/chatbot/
├── AiChatbotApplication.java
├── controller/
│   ├── ChatController.java                  # 基础聊天(历史章节)
│   ├── MemoryChatController.java            # 记忆 + 流式 + Function Calling(历史章节)
│   └── RAGController.java                   # ⭐ 本章新增:上传/问答/统计/清空 API
├── dto/
│   ├── FileUploadResponse.java              # ⭐ 本章新增:上传响应(文档数/分块信息等)
│   ├── RAGChatRequest.java                  # ⭐ 本章新增:问答请求(knowledgeBase/question/topK)
│   ├── RAGChatResponse.java                 # ⭐ 本章新增:问答响应(answer + relevantDocuments)
│   └── ...                                  # 其他 DTO(历史章节)
├── service/
│   ├── DocumentProcessService.java          # ⭐ 本章新增:文本提取/分割/清洗(chunk + overlap)
│   ├── RAGQueryService.java                 # ⭐ 本章新增:基于 ChromaDB 的检索与问答编排
│   └── ConversationService.java             # 对话记忆(历史章节)
└── util/
    └── VectorDistanceUtils.java             # 向量相似度/距离(第9章;本章复用)

✅ 本章链路(从上传到问答)

上传:POST /api/rag/uploadDocumentProcessService 分割文本 → EmbeddingModel 向量化 → VectorStore(ChromaDB) 入库。
问答:POST /api/rag/chat → query 向量化 → ChromaDB TopK 检索 → 拼接上下文 Prompt → ChatModel 生成回答。
运维:GET /api/rag/stats / DELETE /api/rag/clear → 统计/清空 ChromaDB 文档。

🧩 关键设计点(为什么这么拆)

  • Controller 薄:只负责接口编排与参数校验,真正逻辑下沉到 Service,方便后续替换存储(ChromaDB/Redis/其它向量库)。
  • Store 与 Process 解耦:向量库只关心“存/查/清”,文本分割只关心“怎么切”,两者分离更易扩展与测试。
  • 可观测性:建议在入库/检索阶段记录 topK、相似度、分块数量,便于解释“为什么检索到这些文档”。

📦 Step 0:添加 Maven 依赖(支持 PDF 解析)

本章的 DocumentProcessService 需要解析 PDF 文件,PDF 是二进制格式,不能直接当文本读。
我们使用 Apache PDFBox(Apache 开源 PDF 处理库)来提取 PDF 中的真实文字内容。

0.1 在 pom.xml 中添加 PDFBox 依赖

<dependencies> 节点内追加:

xml
<!-- Apache PDFBox:用于解析 PDF 文件,提取文字内容 -->
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>3.0.4</version>
</dependency>
🚨 不加这个依赖会怎样?
如果缺少 PDFBox 依赖,编译时会报错:
java: package org.apache.pdfbox does not exist
java: cannot find symbol: class PDDocument
java: cannot find symbol: class PDFTextStripper
java: cannot find symbol: class Loader
这是因为 PDDocumentPDFTextStripperLoader 都来自 PDFBox 库,JDK 自身不包含 PDF 处理能力。

0.2 添加完依赖后刷新 Maven

在 IDEA 中右键 pom.xmlMaven → Reload Project,或者在终端执行:

bash
# 重新下载依赖
mvn dependency:resolve

# 验证 PDFBox 是否引入成功
mvn dependency:tree | grep pdfbox

预期输出:

text
[INFO] +- org.apache.pdfbox:pdfbox:jar:3.0.4:compile
💡 PDFBox 版本说明
PDFBox 3.x 与 2.x 的 API 差异很大,本章使用 3.0.4(最新稳定版):
  • 加载 PDF:Loader.loadPDF(byte[])(3.x)vs PDDocument.load(byte[])(2.x 已废弃)
  • import 路径:org.apache.pdfbox.Loader 是 3.x 新增的类
  • 如果你用的是 2.x 版本,需要把 Loader.loadPDF() 改回 PDDocument.load()

📝 Step 1:接入 ChromaDB 向量数据库

1.1 为什么这里改成 ChromaDB?

1.2 复用第9章的 ChromaDB 配置

如果你已经完成第9章,只需要确保下面的配置存在:src/main/resources/application.yml

yaml
spring:
  ai:
    vectorstore:
      chroma:
        client:
          host: http://localhost
          port: 8000
        collection-name: SpringAiCollection
        initialize-schema: true

1.3 创建 RAGQueryService

创建文件:src/main/java/com/example/chatbot/service/RAGQueryService.java

java
package com.example.chatbot.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;

/**
 * RAG 查询服务
 * 
 * 功能职责:
 * 1. 管理向量文档的增删改查(基于 ChromaDB)
 * 2. 处理 Embedding 批量限制(DashScope 25 条/批)
 * 3. 提供向量相似度检索功能
 * 4. 维护文档 ID 列表(用于清空操作)
 * 
 * 核心设计:
 * - 不再维护内存向量表,直接使用 Spring AI 的 VectorStore
 * - 自动分批处理,兼容 DashScope Embedding 的 25 条限制
 * - 使用 CopyOnWriteArrayList 保证线程安全
 */
@Service
public class RAGQueryService {

    private static final Logger log = LoggerFactory.getLogger(RAGQueryService.class);

    /** 
     * DashScope Embedding API 的最大批次大小
     * 
     * 说明:阿里云 DashScope 的 text-embedding-v2 模型限制每次最多 25 条文本
     * 超过限制会报错:{"error": {"code": "InvalidParameter", "message": "Input text count exceeds limit"}}
     */
    private static final int MAX_EMBEDDING_BATCH_SIZE = 25;

    /** Spring AI 自动装配的向量存储(基于 ChromaDB) */
    private final VectorStore vectorStore;
    
    /** 
     * 文档 ID 列表(用于清空操作)
     * 
     * 使用 CopyOnWriteArrayList 保证线程安全:
     * - 读操作无锁(性能好)
     * - 写操作创建新副本(线程安全)
     * - 适合读多写少的场景
     */
    private final List<String> documentIds = new CopyOnWriteArrayList<>();

    /**
     * 检索结果封装类
     * 
     * 将 Spring AI 的 Document 和相似度分数封装在一起,
     * 方便 Controller 层统一返回给前端。
     */
    public static class SearchResult {
        /** 匹配到的文档对象(包含内容和元数据) */
        private final Document document;
        
        /** 相似度分数(0.0-1.0,越接近 1.0 越相似) */
        private final double similarity;

        /**
         * 构造检索结果
         * 
         * @param document 匹配的文档
         * @param similarity 相似度分数
         */
        public SearchResult(Document document, double similarity) {
            this.document = document;
            this.similarity = similarity;
        }

        /** 获取文档对象 */
        public Document getDocument() {
            return document;
        }

        /** 获取相似度分数 */
        public double getSimilarity() {
            return similarity;
        }
    }

    /**
     * 构造函数
     * 
     * @param vectorStore Spring AI 自动装配的向量存储
     */
    public RAGQueryService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    /**
     * 批量添加文档到向量库
     * 
     * 处理流程:
     * 1. 将文本内容转换为 Spring AI Document 对象
     * 2. 为每个文档生成唯一 ID 和元数据
     * 3. 按 25 条/批的限制分批处理
     * 4. 调用 VectorStore.add() 写入 ChromaDB
     * 5. 记录所有文档 ID(用于后续清空)
     * 
     * @param contents 文本内容列表
     * @param metadata 全局元数据(文件名、上传时间等)
     * @return 生成的文档 ID 列表
     */
    public List<String> addDocuments(List<String> contents, Map<String, Object> metadata) {
        List<String> ids = new ArrayList<>();

        // 1) 分批处理:每批最多 25 条(DashScope 限制)
        for (int start = 0; start < contents.size(); start += MAX_EMBEDDING_BATCH_SIZE) {
            // 计算当前批次的结束位置
            int end = Math.min(start + MAX_EMBEDDING_BATCH_SIZE, contents.size());
            List<Document> batchDocuments = new ArrayList<>(end - start);

            // 2) 为当前批次的每个文本创建 Document 对象
            for (String content : contents.subList(start, end)) {
                // 2.1) 生成唯一文档 ID
                String id = UUID.randomUUID().toString();
                
                // 2.2) 复制全局元数据,添加文档特定元数据
                Map<String, Object> docMetadata = new HashMap<>(metadata);
                docMetadata.put("chunkId", id);  // 标识这是哪个文档块
                
                // 2.3) 创建 Document 对象并加入批次
                batchDocuments.add(new Document(id, content, docMetadata));
                ids.add(id);
            }

            // 3) 批量写入向量库(自动调用 Embedding API)
            vectorStore.add(batchDocuments);
            
            // 4) 记录文档 ID(用于清空操作)
            documentIds.addAll(batchDocuments.stream().map(Document::getId).toList());
            
            log.info("📦 批次写入完成: {} 个文档块 (批次大小: {})", 
                    batchDocuments.size(), batchDocuments.size());
        }

        log.info("✅ 文档入库完成: 总计 {} 个文档块", ids.size());
        return ids;
    }

    /**
     * 向量相似度检索
     * 
     * 处理流程:
     * 1. 将查询文本转换为向量(通过 Embedding API)
     * 2. 在 ChromaDB 中搜索最相似的 topK 个文档
     * 3. 将结果封装为 SearchResult 对象
     * 
     * @param query 查询文本
     * @param topK 返回结果数量
     * @return 检索结果列表(按相似度降序排列)
     */
    public List<SearchResult> search(String query, int topK) {
        log.info("🔍 开始向量检索: query='{}', topK={}", query, topK);
        
        // 1) 调用 Spring AI 的相似度搜索
        List<Document> results = vectorStore.similaritySearch(
                SearchRequest.builder()
                    .query(query)      // 查询文本
                    .topK(topK)        // 返回数量
                    .build()
        );
        
        // 2) 将 Document 转换为 SearchResult(封装相似度分数)
        List<SearchResult> searchResults = results.stream()
                .map(document -> {
                    // 获取相似度分数,如果为 null 则默认 0.0
                    double similarity = document.getScore() != null ? document.getScore() : 0.0d;
                    return new SearchResult(document, similarity);
                })
                .collect(Collectors.toList());
        
        log.info("🎯 检索完成: 找到 {} 个相关文档", searchResults.size());
        return searchResults;
    }

    /**
     * 获取当前向量库中的文档数量
     * 
     * @return 文档总数
     */
    public int getDocumentCount() {
        return documentIds.size();
    }

    /**
     * 清空向量库中的所有文档
     * 
     * 处理流程:
     * 1. 使用记录的所有文档 ID 调用 VectorStore.delete()
     * 2. 清空本地的 documentIds 列表
     * 
     * 注意:这个操作不可逆,请谨慎使用
     */
    public void clear() {
        int count = documentIds.size();
        
        if (count == 0) {
            log.info("🗑️ 向量库已经是空的,无需清空");
            return;
        }
        
        // 1) 从 ChromaDB 中删除所有文档
        vectorStore.delete(new ArrayList<>(documentIds));
        
        // 2) 清空本地 ID 列表
        documentIds.clear();
        
        log.info("🗑️ 向量库清空完成: 删除了 {} 个文档", count);
    }
}
✅ 说明
这里不再维护内存向量表,而是直接使用 Spring AI 自动装配的 VectorStore。 上传后的文档会写入 ChromaDB;为了兼容 DashScope Embedding 的 25 条文本限制,入库时会自动分批。

📝 Step 2:创建文档处理服务

2.1 文档分割的必要性

2.2 创建 DocumentProcessService

创建文件:src/main/java/com/example/chatbot/service/DocumentProcessService.java

💡 核心改动说明
原版 extractText() 对所有文件统一执行 new String(bytes, UTF_8)PDF 是二进制格式,这样读出来全是乱码
改造后:根据文件扩展名分发 → TXT/MD 走文本解析PDF 走 Apache PDFBox 提取真正的文字内容
java
package com.example.chatbot.service;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * 文档处理服务
 * 
 * 功能职责:
 * 1. 支持多种文档格式的文本提取(TXT/MD/ PDF)
 * 2. 对 PDF 提取的原始文本进行清洗,去除噪音
 * 3. 将长文档智能分割成适合 Embedding 的文本块
 * 4. 提供文件格式校验功能
 * 
 * 核心改造:
 * - 原版:所有文件统一执行 new String(bytes, UTF_8) → PDF 读出来全是乱码
 * - 改造:根据文件扩展名分发 → TXT/MD 走文本解析,PDF 走 PDFBox 提取真实文字
 */
@Service
public class DocumentProcessService {

    private static final Logger log = LoggerFactory.getLogger(DocumentProcessService.class);

    /** 默认文本块大小:500 字符(适合大多数 Embedding 模型) */
    private static final int DEFAULT_CHUNK_SIZE = 500;
    
    /** 默认重叠大小:50 字符(避免句子被截断导致信息丢失) */
    private static final int DEFAULT_OVERLAP_SIZE = 50;

    // ==================== 1. 文本提取(核心改造点) ====================

    /**
     * 根据文件后缀自动选择解析方式
     * 
     * @param fileContent 文件字节数组
     * @param fileName 文件名(用于判断格式)
     * @return 提取出的纯文本内容
     * 
     * 处理策略:
     * - .txt / .md  → 直接按文本读取(UTF-8 优先,自动降级 GBK)
     * - .pdf         → 使用 PDFBox 提取文字(二进制 → 文字)
     * - 其他格式     → 按文本处理(兜底策略)
     */
    public String extractText(byte[] fileContent, String fileName) {
        // 1) 获取文件扩展名并转为小写(统一处理)
        String ext = getFileExtension(fileName).toLowerCase();
        log.info("📄 开始处理文件: {} (格式: {})", fileName, ext);

        try {
            // 2) 根据扩展名分发到对应的解析方法
            return switch (ext) {
                case "pdf" -> extractFromPDF(fileContent, fileName);
                default    -> extractFromText(fileContent, fileName);
            };
        } catch (Exception e) {
            // 3) 异常处理:记录错误日志,返回空字符串(避免中断流程)
            log.error("❌ 文件处理失败: {}", fileName, e);
            return "";
        }
    }

    /**
     * PDF 文件解析
     * 
     * PDF 是二进制格式,不能直接当作文本读取。必须使用专门的 PDF 解析库。
     * 
     * @param fileContent PDF 文件的字节数组
     * @param fileName 文件名(用于日志)
     * @return 提取并清洗后的纯文本
     * @throws IOException PDF 解析异常
     * 
     * 技术细节:
     * 1. 使用 PDFBox 3.x 的 Loader.loadPDF(byte[]) 加载 PDF
     * 2. PDFTextStripper 提取文字,setSortByPosition(true) 保持阅读顺序
     * 3. cleanPdfText() 清洗噪音(页眉页脚、多余换行等)
     * 4. try-with-resources 自动关闭 PDDocument,避免内存泄漏
     */
    private String extractFromPDF(byte[] fileContent, String fileName) throws IOException {
        // PDFBox 3.x 推荐用 Loader.loadPDF(byte[]),替代已废弃的 PDDocument.load()
        try (PDDocument doc = Loader.loadPDF(fileContent)) {
            // 创建文本提取器
            PDFTextStripper stripper = new PDFTextStripper();
            
            // 按位置排序,保持阅读顺序(重要:否则可能文字顺序错乱)
            stripper.setSortByPosition(true);

            // 提取原始文本(可能包含噪音)
            String raw = stripper.getText(doc);

            // --- 后处理:把 PDF 提取的"脏文本"清理干净 ---
            String clean = cleanPdfText(raw);

            // 记录处理结果(页数、原始长度、清洗后长度)
            log.info("📄 PDF 提取完成: {} ({} 页, 原始 {} 字符 → 清洗后 {} 字符)",
                    fileName, doc.getNumberOfPages(), raw.length(), clean.length());
            return clean;
        }
    }

    /**
     * 纯文本文件解析
     * 
     * @param fileContent 文本文件的字节数组
     * @param fileName 文件名(用于日志)
     * @return 解码后的文本内容
     * 
     * 编码处理策略:
     * 1. 优先使用 UTF-8(现代文件最常用)
     * 2. 检测 UTF-8 解码是否产生大量替换字符 ''(Unicode 替换字符)
     * 3. 如果乱码严重,自动尝试 GBK 编码(中文 Windows 常见)
     * 4. 最终都失败则返回 UTF-8 结果并记录警告
     */
    private String extractFromText(byte[] fileContent, String fileName) {
        // 1) 默认使用 UTF-8 解码
        String text = new String(fileContent, StandardCharsets.UTF_8);
        
        // 2) 检测乱码:统计替换字符数量
        //    Unicode 替换字符 U+FFFD 表示无法解码的字节
        long garbageCount = text.chars().filter(c -> c == 0xFFFD).count();
        
        // 3) 如果乱码超过阈值(5个字符),尝试 GBK 编码
        if (garbageCount > 5) {
            try {
                text = new String(fileContent, "GBK");
                log.info("📄 使用 GBK 编码解析: {} (检测到 {} 个乱码字符)", fileName, garbageCount);
            } catch (Exception ignored) {
                // GBK 编码不支持,继续使用 UTF-8 结果
                log.warn("⚠️ GBK 编码不支持,使用 UTF-8 结果: {}", fileName);
            }
        }
        
        log.info("📄 文本提取完成: {} ({} 字符)", fileName, text.length());
        return text;
    }

    // ==================== 2. PDF 文本清洗 ====================

    /**
     * 清洗 PDF 提取出的原始文本
     * 
     * PDF 直接提取会带很多噪音,影响 RAG 检索质量:
     * - 页眉页脚("第X页"、"Page X")
     * - 多余的换行符(PDF 中每行都是独立的)
     * - 重复的空格和空行
     * - 特殊连字符(fi、fl 等)
     * 
     * @param raw PDF 提取的原始文本
     * @return 清洗后的干净文本
     */
    private String cleanPdfText(String raw) {
        // 1) 统一换行符:Windows (\r\n) 和 Mac (\r) 都转为 Unix (\n)
        String text = raw.replace("\r\n", "\n").replace("\r", "\n");

        // 2) 去掉页眉页脚常见模式
        //    (?m) 表示多行模式,^ 表示行首,$ 表示行尾
        //    \s* 匹配任意空白,\d+ 匹配数字,.* 匹配任意字符
        text = text.replaceAll("(?m)^\\s*第\\s*\\d+\\s*页.*$", "");      // "第 1 页"
        text = text.replaceAll("(?m)^\\s*Page\\s+\\d+.*$", "");        // "Page 1"
        text = text.replaceAll("(?m)^\\s*-\\s*\\d+\\s*-\\s*$", "");     // "- 1 -"
        text = text.replaceAll("(?m)^\\s*\\d+\\s*$", "");              // 纯数字行

        // 3) 把"非段落结尾的换行"合并为空格
        //    段落结尾 = 以句号/问号/感叹号/冒号结尾的行
        //    例如:"这是第一行。\n这是第二行" → "这是第一行。 这是第二行"
        text = text.replaceAll("([^。!?:\\n])\\n([^\\n])", "$1 $2");

        // 4) 多个空格合并为一个(包括制表符)
        text = text.replaceAll("[ \\t]{2,}", " ");

        // 5) 多个连续空行合并为一个(保持段落结构)
        text = text.replaceAll("\\n{3,}", "\n\n");

        // 6) 修复常见 PDF 连字符问题(Unicode 连字 → 普通字符)
        text = text.replace("fi", "fi").replace("fl", "fl");

        return text.trim();
    }

    // ==================== 3. 文档分割 ====================

    /**
     * 将长文档分割成适合 Embedding 的文本块
     * 
     * @param content 待分割的文本内容
     * @param chunkSize 每个文本块的最大字符数
     * @param overlapSize 相邻文本块的重叠字符数
     * @return 分割后的文本块列表
     * 
     * 分割策略:
     * 1. 在句子边界切分(避免截断语义)
     * 2. 强分隔符优先:句号、问号、感叹号、换行
     * 3. 弱分隔符兜底:逗号、分号、冒号
     * 4. 过滤过短片段(<=10 字符,通常是无意义的)
     * 5. 重叠策略避免信息丢失
     */
    public List<String> splitDocument(String content, int chunkSize, int overlapSize) {
        List<String> chunks = new ArrayList<>();
        
        // 1) 基础校验
        if (content == null || content.isEmpty()) {
            return chunks;
        }
        
        // 2) 预处理:去除首尾空白
        content = content.trim();
        
        // 3) 滑动窗口分割
        int start = 0;
        while (start < content.length()) {
            // 计算本次分割的结束位置
            int end = Math.min(start + chunkSize, content.length());
            
            // 4) 智能边界调整(避免截断句子)
            if (end < content.length()) {
                // 在句子边界切分,避免截断语义
                int boundary = findBestBoundary(content, start, end);
                if (boundary > start) {
                    end = boundary + 1;  // +1 包含分隔符本身
                }
            }
            
            // 5) 提取文本块
            String chunk = content.substring(start, end).trim();
            
            // 6) 质量过滤:太短的片段通常无意义
            if (chunk.length() > 10) {
                chunks.add(chunk);
            }
            
            // 7) 计算下次起始位置(实现重叠)
            int nextStart = end - overlapSize;
            if (nextStart <= start) {
                // 防止死循环:如果重叠导致不前进,强制前进 1 字符
                nextStart = start + 1;
            }
            start = nextStart;
        }
        
        log.info("📄 文档分割完成: {} 字符 -> {} 个块", content.length(), chunks.size());
        return chunks;
    }

    /**
     * 使用默认参数分割文档
     * 
     * @param content 待分割的文本内容
     * @return 分割后的文本块列表
     */
    public List<String> splitDocument(String content) {
        return splitDocument(content, DEFAULT_CHUNK_SIZE, DEFAULT_OVERLAP_SIZE);
    }

    /**
     * 在 [start, end] 范围内寻找最合适的分割边界
     * 
     * @param content 原始文本
     * @param start 搜索起始位置(包含)
     * @param end 搜索结束位置(不包含)
     * @return 最佳分割边界的位置(-1 表示未找到)
     * 
     * 算法策略:
     * 1. 优先找强分隔符(句子结束符)
     * 2. 其次找弱分隔符(句子内部停顿符)
     * 3. 如果都没找到,返回 -1(调用方会在 chunkSize 处强制分割)
     */
    private int findBestBoundary(String content, int start, int end) {
        // 1) 强分隔符:句号、问号、感叹号、换行
        //    这些通常表示句子或段落的结束
        char[] strongDelimiters = {'。', '!', '?', '\n'};
        int best = -1;
        for (char d : strongDelimiters) {
            int pos = content.lastIndexOf(d, end);
            if (pos > start && pos > best) best = pos;
        }
        if (best > start) return best;

        // 2) 弱分隔符:逗号、分号、冒号
        //    这些表示句子内部的停顿,但不是句子结束
        char[] weakDelimiters = {',', ';', ':', ',', ';', ':'};
        for (char d : weakDelimiters) {
            int pos = content.lastIndexOf(d, end);
            if (pos > start && pos > best) best = pos;
        }
        
        // 3) 返回找到的最佳边界(可能是 -1)
        return best;
    }

    // ==================== 4. 工具方法 ====================

    /**
     * 获取文件扩展名
     * 
     * @param fileName 完整文件名
     * @return 文件扩展名(不包含点),如 "pdf"、"txt"
     */
    private String getFileExtension(String fileName) {
        if (fileName == null) return "";
        int dot = fileName.lastIndexOf('.');
        return dot > 0 ? fileName.substring(dot + 1) : "";
    }

    /**
     * 判断文件格式是否支持
     * 
     * @param fileName 文件名
     * @return true 表示支持,false 表示不支持
     * 
     * 支持格式:txt、md、pdf
     * 不支持格式:doc、docx、xls、xlsx、ppt、pptx、jpg、png 等
     */
    public boolean isSupportedFormat(String fileName) {
        String ext = getFileExtension(fileName).toLowerCase();
        return ext.equals("txt") || ext.equals("md") || ext.equals("pdf");
    }
}

📝 Step 3:创建文件上传 DTO

3.1 FileUploadResponse

创建文件:src/main/java/com/example/chatbot/dto/FileUploadResponse.java

java
package com.example.chatbot.dto;

import java.time.LocalDateTime;
import java.util.List;

/**
 * 文件上传响应 DTO
 * 
 * 用于统一返回文件上传的处理结果,包含成功和失败两种情况。
 * 
 * 字段说明:
 * - success: 操作是否成功
 * - message: 操作结果描述(成功时为"文件上传成功",失败时为错误信息)
 * - fileName: 上传的文件名(失败时可能为 null)
 * - chunkCount: 分割后的文档块数量(失败时为 0)
 * - documentIds: 每个文档块的唯一 ID 列表(失败时为 null)
 * - timestamp: 操作时间戳
 * - error: 错误详细信息(成功时为 null,失败时包含具体错误)
 */
public record FileUploadResponse(
    /** 操作是否成功 */
    boolean success,
    
    /** 操作结果描述 */
    String message,
    
    /** 上传的文件名(失败时可能为 null) */
    String fileName,
    
    /** 分割后的文档块数量(失败时为 0) */
    int chunkCount,
    
    /** 每个文档块的唯一 ID 列表(失败时为 null) */
    List<String> documentIds,
    
    /** 操作时间戳 */
    LocalDateTime timestamp,
    
    /** 错误详细信息(成功时为 null) */
    String error
) {
    /**
     * 创建成功的上传响应
     * 
     * @param fileName 上传的文件名
     * @param chunkCount 分割后的文档块数量
     * @param documentIds 每个文档块的唯一 ID 列表
     * @return 成功响应对象
     */
    public static FileUploadResponse success(String fileName, int chunkCount, List<String> documentIds) {
        return new FileUploadResponse(
            true,                           // success
            "文件上传成功",                  // message
            fileName,                       // fileName
            chunkCount,                     // chunkCount
            documentIds,                    // documentIds
            LocalDateTime.now(),            // timestamp
            null                            // error(成功时无错误)
        );
    }

    /**
     * 创建失败的上传响应
     * 
     * @param message 错误信息
     * @return 失败响应对象
     */
    public static FileUploadResponse error(String message) {
        return new FileUploadResponse(
            false,                          // success
            message,                        // message(错误信息)
            null,                           // fileName(失败时为 null)
            0,                              // chunkCount(失败时为 0)
            null,                           // documentIds(失败时为 null)
            LocalDateTime.now(),            // timestamp
            message                         // error(错误详细信息)
        );
    }
}

📝 Step 4:创建 RAG 聊天 DTO

4.1 RAGChatRequest

java
package com.example.chatbot.dto;

/**
 * RAG 聊天请求 DTO
 * 
 * 用于接收前端发送的知识问答请求,包含问题、知识库和检索参数。
 * 
 * 字段说明:
 * - knowledgeBase: 知识库名称(当前版本固定为 "spring-ai",预留扩展)
 * - question: 用户提出的问题
 * - topK: 检索相关文档的数量(可选,默认 3)
 */
public record RAGChatRequest(
    /** 知识库名称(当前版本固定为 "spring-ai",预留扩展) */
    String knowledgeBase,
    
    /** 用户提出的问题 */
    String question,
    
    /** 检索相关文档的数量(可选,默认 3) */
    Integer topK
) {
    /**
     * 参数验证方法
     * 
     * 验证必填字段是否为空或空白字符。
     * 
     * @throws IllegalArgumentException 当参数无效时抛出
     */
    public void validate() {
        if (knowledgeBase == null || knowledgeBase.trim().isEmpty()) {
            throw new IllegalArgumentException("知识库名称不能为空");
        }
        if (question == null || question.trim().isEmpty()) {
            throw new IllegalArgumentException("问题不能为空");
        }
    }

    /**
     * 获取检索文档数量
     * 
     * 如果用户未指定 topK,则返回默认值 3。
     * 
     * @return 检索文档数量
     */
    public int getTopK() {
        return topK != null ? topK : 3;
    }
}

4.2 RAGChatResponse

java
package com.example.chatbot.dto;

import java.time.LocalDateTime;
import java.util.List;

/**
 * RAG 聊天响应 DTO
 * 
 * 用于统一返回知识问答的处理结果,包含成功和失败两种情况。
 * 
 * 字段说明:
 * - answer: AI 生成的答案(失败时为 null)
 * - relevantDocuments: 相关文档列表(失败时为 null)
 * - timestamp: 响应时间戳
 * - success: 操作是否成功
 * - error: 错误信息(成功时为 null)
 */
public record RAGChatResponse(
    /** AI 生成的答案(失败时为 null) */
    String answer,
    
    /** 相关文档列表(失败时为 null) */
    List<RelevantDocument> relevantDocuments,
    
    /** 响应时间戳 */
    LocalDateTime timestamp,
    
    /** 操作是否成功 */
    boolean success,
    
    /** 错误信息(成功时为 null) */
    String error
) {
    /**
     * 相关文档记录
     * 
     * 封装了检索到的文档信息,用于前端展示引用来源。
     * 
     * 字段说明:
     * - content: 文档内容
     * - similarity: 相似度分数(0.0-1.0)
     * - title: 文档标题(通常是文件名)
     */
    public record RelevantDocument(
        /** 文档内容 */
        String content,
        
        /** 相似度分数(0.0-1.0,越接近 1.0 越相似) */
        double similarity,
        
        /** 文档标题(通常是文件名) */
        String title
    ) {}

    /**
     * 创建成功的聊天响应
     * 
     * @param answer AI 生成的答案
     * @param docs 相关文档列表
     * @return 成功响应对象
     */
    public static RAGChatResponse success(String answer, List<RelevantDocument> docs) {
        return new RAGChatResponse(
            answer,                         // answer
            docs,                           // relevantDocuments
            LocalDateTime.now(),           // timestamp
            true,                           // success
            null                            // error(成功时无错误)
        );
    }

    /**
     * 创建失败的聊天响应
     * 
     * @param errorMessage 错误信息
     * @return 失败响应对象
     */
    public static RAGChatResponse error(String errorMessage) {
        return new RAGChatResponse(
            null,                           // answer(失败时为 null)
            null,                           // relevantDocuments(失败时为 null)
            LocalDateTime.now(),           // timestamp
            false,                          // success
            errorMessage                    // error
        );
    }
}

📝 Step 5:创建 RAG 控制器

5.1 RAGController

创建文件:src/main/java/com/example/chatbot/controller/RAGController.java

java
package com.example.chatbot.controller;

import com.example.chatbot.dto.FileUploadResponse;
import com.example.chatbot.dto.RAGChatRequest;
import com.example.chatbot.dto.RAGChatResponse;
import com.example.chatbot.service.DocumentProcessService;
import com.example.chatbot.service.RAGQueryService;
import com.example.chatbot.service.RAGQueryService.SearchResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * RAG 控制器:企业智能检索问答系统 API
 * 
 * 功能职责:
 * 1. 文件上传:支持 TXT/MD/PDF 格式,自动提取文本、分割、向量化、入库
 * 2. 知识问答:基于上传的文档进行检索增强生成(RAG)
 * 3. 统计信息:查看当前向量库中的文档数量
 * 4. 清空数据:删除所有已上传的文档(谨慎使用)
 * 
 * API 端点:
 * - GET  /api/rag/health   - 健康检查
 * - POST /api/rag/upload   - 文件上传
 * - POST /api/rag/chat     - 知识问答
 * - GET  /api/rag/stats    - 统计信息
 * - DELETE /api/rag/clear   - 清空数据
 * 
 * 核心流程:
 * 上传 → 文本提取 → 文档分割 → 向量化 → ChromaDB 入库
 * 问答 → 向量检索 → 上下文拼接 → LLM 生成 → 返回结果
 */
@RestController
@RequestMapping("/api/rag")
public class RAGController {

    private static final Logger log = LoggerFactory.getLogger(RAGController.class);

    /** Spring AI 聊天模型(用于生成最终答案) */
    private final ChatModel chatModel;
    
    /** 向量查询服务(负责 ChromaDB 的增删改查) */
    private final RAGQueryService vectorStore;
    
    /** 文档处理服务(负责文本提取和分割) */
    private final DocumentProcessService documentProcess;

    /**
     * 构造函数:依赖注入
     * 
     * @param chatModel Spring AI 聊天模型
     * @param vectorStore 向量查询服务
     * @param documentProcess 文档处理服务
     */
    public RAGController(
        ChatModel chatModel,
        RAGQueryService vectorStore,
        DocumentProcessService documentProcess
    ) {
        this.chatModel = chatModel;
        this.vectorStore = vectorStore;
        this.documentProcess = documentProcess;
    }

    /**
     * 健康检查端点
     * 
     * 用于确认 RAG API 服务是否正常运行,并显示当前文档数量。
     * 
     * @return 服务状态信息
     */
    @GetMapping("/health")
    public String health() {
        return "RAG API is running! 📚 文档数量: " + vectorStore.getDocumentCount();
    }

    /**
     * 文件上传端点
     * 
     * 处理流程:
     * 1. 文件格式校验(只支持 txt/md/pdf)
     * 2. 文本提取(PDF 用 PDFBox,TXT 用编码检测)
     * 3. 文档分割(按句子边界,避免截断语义)
     * 4. 向量化(自动分批处理,兼容 DashScope 25 条限制)
     * 5. 入库 ChromaDB
     * 
     * @param file 上传的文件
     * @param chunkSize 文本块大小(默认 500)
     * @param overlapSize 重叠大小(默认 50)
     * @return 上传结果(包含文档块数量和 ID)
     */
    @PostMapping("/upload")
    public FileUploadResponse uploadFile(
        @RequestParam("file") MultipartFile file,
        @RequestParam(value = "chunkSize", defaultValue = "500") int chunkSize,
        @RequestParam(value = "overlapSize", defaultValue = "50") int overlapSize
    ) {
        try {
            log.info("📤 收到文件上传: {}", file.getOriginalFilename());
            
            // 1) 基础校验
            if (file.isEmpty()) {
                return FileUploadResponse.error("文件为空");
            }
            String fileName = file.getOriginalFilename();
            
            // 2) 文件格式校验:只允许 txt / md / pdf
            if (!documentProcess.isSupportedFormat(fileName)) {
                return FileUploadResponse.error("不支持的文件格式,仅支持 txt、md、pdf");
            }
            
            // 3) 文本提取:根据文件格式选择合适的解析方式
            byte[] fileContent = file.getBytes();
            String text = documentProcess.extractText(fileContent, fileName);
            if (text.trim().isEmpty()) {
                return FileUploadResponse.error("文件内容为空或无法提取文本");
            }
            
            // 4) 文档分割:将长文本分割成适合 Embedding 的块
            List<String> chunks = documentProcess.splitDocument(text, chunkSize, overlapSize);
            if (chunks.isEmpty()) {
                return FileUploadResponse.error("文档分割失败");
            }
            
            // 5) 准备元数据:记录文件信息
            Map<String, Object> metadata = new HashMap<>();
            metadata.put("fileName", fileName);
            metadata.put("uploadTime", System.currentTimeMillis());
            metadata.put("fileSize", file.getSize());

            // 6) 向量化入库:自动分批处理,兼容 DashScope 25 条限制
            List<String> documentIds = vectorStore.addDocuments(chunks, metadata);

            log.info("✅ 文件处理完成: {} -> {} 个文档块", fileName, chunks.size());
            return FileUploadResponse.success(fileName, chunks.size(), documentIds);
        } catch (Exception e) {
            log.error("❌ 文件上传失败", e);
            return FileUploadResponse.error("文件上传失败: " + e.getMessage());
        }
    }

    /**
     * 知识问答端点
     * 
     * RAG(检索增强生成)流程:
     * 1. 参数验证
     * 2. 向量检索:在 ChromaDB 中搜索最相关的文档
     * 3. 上下文拼接:将检索结果格式化为参考资料
     * 4. Prompt 增强:构建包含上下文的提示词
     * 5. LLM 生成:调用大模型生成答案
     * 6. 结果封装:返回答案和相关文档
     * 
     * @param request 问答请求(包含问题、知识库名、topK)
     * @return 问答结果(包含答案和相关文档)
     */
    @PostMapping("/chat")
    public RAGChatResponse chat(@RequestBody RAGChatRequest request) {
        try {
            // 1) 参数验证
            request.validate();
            log.info("💬 RAG 聊天请求: {}", request.question());

            // 2) 向量检索:在 ChromaDB 中搜索最相关的文档
            List<SearchResult> searchResults = vectorStore.search(
                request.question(),
                request.getTopK()
            );
            if (searchResults.isEmpty()) {
                return RAGChatResponse.error("知识库中没有相关文档,请先上传文件");
            }

            // 3) 上下文拼接:将检索结果格式化为参考资料
            String context = searchResults.stream()
                .map(result -> {
                    String content = result.getDocument().getText();
                    double similarity = result.getSimilarity();
                    // 格式:[相似度: 0.9543]\n文档内容...
                    return String.format("[相似度: %.4f]\n%s", similarity, content);
                })
                .collect(Collectors.joining("\n\n---\n\n"));

            // 4) Prompt 增强:构建包含上下文的提示词
            String enhancedPrompt = String.format(
                "请基于以下参考资料回答问题。如果参考资料中没有相关信息,请明确说明。\n\n" +
                "参考资料:\n%s\n\n" +
                "问题:%s\n\n" +
                "请用中文回答,并在回答中引用相关的参考资料。",
                context,
                request.question()
            );

            // 5) LLM 生成:调用大模型生成答案
            String answer = chatModel.call(enhancedPrompt);

            // 6) 结果封装:构建相关文档列表(用于前端展示)
            List<RAGChatResponse.RelevantDocument> relevantDocs = searchResults.stream()
                .map(result -> {
                    // 从元数据中获取文件名,如果没有则使用默认值
                    String title = (String) result.getDocument().getMetadata().getOrDefault("fileName", "未知文档");
                    return new RAGChatResponse.RelevantDocument(
                        result.getDocument().getText(),    // 文档内容
                        result.getSimilarity(),            // 相似度
                        title                             // 文档标题(文件名)
                    );
                })
                .collect(Collectors.toList());

            return RAGChatResponse.success(answer, relevantDocs);
        } catch (IllegalArgumentException e) {
            // 参数验证失败
            log.warn("参数验证失败: {}", e.getMessage());
            return RAGChatResponse.error("参数错误: " + e.getMessage());
        } catch (Exception e) {
            // 系统异常
            log.error("RAG 聊天失败", e);
            return RAGChatResponse.error("服务器错误: " + e.getMessage());
        }
    }

    /**
     * 统计信息端点
     * 
     * 返回当前向量库中的文档数量和时间戳,用于监控和调试。
     * 
     * @return 统计信息(文档数量、时间戳)
     */
    @GetMapping("/stats")
    public Map<String, Object> getStats() {
        return Map.of(
            "documentCount", vectorStore.getDocumentCount(),           // 文档数量
            "timestamp", java.time.LocalDateTime.now()               // 当前时间
        );
    }

    /**
     * 清空数据端点
     * 
     * 删除 ChromaDB 中的所有文档。这个操作不可逆,请谨慎使用。
     * 
     * @return 清空结果(包含删除的文档数量)
     */
    @DeleteMapping("/clear")
    public String clear() {
        int count = vectorStore.getDocumentCount();
        vectorStore.clear();
        log.info("🗑️ ChromaDB 向量库已清空");
        return "已清空 ChromaDB 中的 " + count + " 个文档";
    }
}

🧪 Step 6:使用 curl 测试完整流程

6.1 启动项目

bash
mvn spring-boot:run

6.2 下载测试文件 company_handbook.txt

点击下载公司员工手册作为测试文档:

📄 下载 company_handbook.txt

💡 下载完成后,将文件保存在项目根目录,然后使用下面的命令上传到知识库。

6.3 上传文件并写入 ChromaDB 知识库

bash
curl -X POST http://localhost:8080/api/rag/upload \
  -F "file=@company_handbook.txt" \
  -F "chunkSize=300" \
  -F "overlapSize=30"

6.4 基于知识库提问

示例:询问公司年假制度

bash
curl -X POST http://localhost:8080/api/rag/chat \
  -H "Content-Type: application/json" \
  -d '{
    "knowledgeBase": "spring-ai",
    "question": "公司的年假制度是怎样的?",
    "topK": 3
  }'

返回结果中将包含:

6.5 查看 ChromaDB 状态与清空

bash
# 查看统计信息
curl http://localhost:8080/api/rag/stats

# 清空 ChromaDB 文档
curl -X DELETE http://localhost:8080/api/rag/clear

📝 本章小结

🎓 你完成了「知搜」企业智能检索问答系统的8大核心能力

  • 1、RAG 架构设计:基于 Spring AI 设计企业级 RAG 检索问答架构,掌握完整链路。
  • 2、企业级向量检索:使用 BGE/M3E Embedding,掌握 ChromaDB 向量库存储与检索。
  • 3、多源知识库联动:对接产品手册、FAQ 等,实现"问题-答案"智能映射。
  • 4、检索质量优化:实现 Top-K 召回 + Rerank 两阶段检索,建立评测指标体系。
  • 5、上下文增强与引用溯源:实现上下文压缩、引用溯源,生成带来源引用的可信答案。
  • 6、运营指标体系:建立延迟、成功率、成本、缓存命中率等 SLA 指标。
  • 7、数据复盘:自动采集 RAG 服务日志,分析高频检索失败场景。
  • 8、部署与运维:掌握 Spring Boot 打包、容器化、K8s 编排、云服务部署。

🚀 课程完成

你已掌握「知搜」企业智能检索问答系统的开发能力。 至此,你已完成了 Spring AI 企业级落地课程的5个项目, 具备构建生产级 AI 应用的能力!

← 上一章 返回上一页