基于 Spring AI + ChromaDB RAG 实现企业级文档知识库检索问答系统
如果你在站内搜索或 RAG 问答里输入下面这些词,本章都明确提到了: ChromaDB、Spring AI 1.0.0-M6、text-embedding-v2、 VectorStore、RAGQueryService、DocumentProcessService、 RAGController、/api/rag/upload、/api/rag/chat、 /api/rag/stats、/api/rag/clear、DashScope、 Embedding、Top-K 检索、分批入库、25 条限制。
本章带你完成 阶段 4:「知搜」企业智能检索问答系统 项目: 基于 Spring AI + ChromaDB RAG 构建一个具备企业级向量检索、多源知识库联动、检索质量优化、运营指标监控的智能检索问答系统。
text-embedding-v2建议先看清楚“本章新增了哪些组件、各自负责什么”,再开始写代码与调接口。
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/upload → DocumentProcessService 分割文本 → EmbeddingModel 向量化 → VectorStore(ChromaDB) 入库。POST /api/rag/chat → query 向量化 → ChromaDB TopK 检索 → 拼接上下文 Prompt → ChatModel 生成回答。GET /api/rag/stats / DELETE /api/rag/clear → 统计/清空 ChromaDB 文档。
本章的 DocumentProcessService 需要解析 PDF 文件,PDF 是二进制格式,不能直接当文本读。
我们使用 Apache PDFBox(Apache 开源 PDF 处理库)来提取 PDF 中的真实文字内容。
pom.xml 中添加 PDFBox 依赖在 <dependencies> 节点内追加:
<!-- Apache PDFBox:用于解析 PDF 文件,提取文字内容 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.4</version>
</dependency>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这是因为
PDDocument、PDFTextStripper、Loader 都来自 PDFBox 库,JDK 自身不包含 PDF 处理能力。
在 IDEA 中右键 pom.xml → Maven → Reload Project,或者在终端执行:
# 重新下载依赖
mvn dependency:resolve
# 验证 PDFBox 是否引入成功
mvn dependency:tree | grep pdfbox预期输出:
[INFO] +- org.apache.pdfbox:pdfbox:jar:3.0.4:compileLoader.loadPDF(byte[])(3.x)vs PDDocument.load(byte[])org.apache.pdfbox.Loader 是 3.x 新增的类Loader.loadPDF() 改回 PDDocument.load()如果你已经完成第9章,只需要确保下面的配置存在:src/main/resources/application.yml
spring:
ai:
vectorstore:
chroma:
client:
host: http://localhost
port: 8000
collection-name: SpringAiCollection
initialize-schema: trueRAGQueryService创建文件:src/main/java/com/example/chatbot/service/RAGQueryService.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);
}
}VectorStore。
上传后的文档会写入 ChromaDB;为了兼容 DashScope Embedding 的 25 条文本限制,入库时会自动分批。
DocumentProcessService创建文件:src/main/java/com/example/chatbot/service/DocumentProcessService.java
extractText() 对所有文件统一执行 new String(bytes, UTF_8),PDF 是二进制格式,这样读出来全是乱码。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");
}
}创建文件:src/main/java/com/example/chatbot/dto/FileUploadResponse.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(错误详细信息)
);
}
}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;
}
}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
);
}
}创建文件:src/main/java/com/example/chatbot/controller/RAGController.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 + " 个文档";
}
}mvn spring-boot:runcompany_handbook.txt点击下载公司员工手册作为测试文档:
💡 下载完成后,将文件保存在项目根目录,然后使用下面的命令上传到知识库。
curl -X POST http://localhost:8080/api/rag/upload \
-F "file=@company_handbook.txt" \
-F "chunkSize=300" \
-F "overlapSize=30"示例:询问公司年假制度
curl -X POST http://localhost:8080/api/rag/chat \
-H "Content-Type: application/json" \
-d '{
"knowledgeBase": "spring-ai",
"question": "公司的年假制度是怎样的?",
"topK": 3
}'返回结果中将包含:
answerrelevantDocuments# 查看统计信息
curl http://localhost:8080/api/rag/stats
# 清空 ChromaDB 文档
curl -X DELETE http://localhost:8080/api/rag/clear你已掌握「知搜」企业智能检索问答系统的开发能力。 至此,你已完成了 Spring AI 企业级落地课程的5个项目, 具备构建生产级 AI 应用的能力!