基于SpringAI + 向量数据库实现RAG文档知识库功能
本文主要基于下面这几个核心类:
RagController:提供对外的 RAG 接口(文档上传 & 向量检索测试)
RagService:RAG 服务接口,定义能力边界
RagServiceImpl:RAG 服务实现,负责“从文件到向量”的整个链路
VectorStore:Spring AI 提供的向量存储抽象(底层可接 Milvus、PGVector 等)
FileService:负责将原始文件上传至 OSS(对象存储)
OSS 存原始文档 + 向量数据库存语义切片,
后续 ChatClient 通过 QuestionAnswerAdvisor + VectorStore 做语义召回,实现真正的 RAG。
一、总体设计:RAG 在项目中的定位
在你的项目中,RAG 的职责可以简化为一句话:
把用户上传的知识文档,变成可检索的“语义向量片段”,写入向量数据库,为后续问答召回做准备。
整体流程大致如下:
前端调用 /rag/upload 上传 PDF/Word/TXT 文件
后端将文件同时:
- 上传到 OSS(便于后续下载、预览、审计)
- 使用合适的
DocumentReader 解析成文本
使用 TokenTextSplitter 对文本进行切片(chunk)
调用 VectorStore.add(documents) 写入向量数据库(例如 Milvus)
通过 /rag/search 接口验证向量检索是否正常工作
二、Controller 层:对外暴露 RAG 接口
1. 文档上传接口 /rag/upload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| @Tag(name="RAG接口") @Slf4j @RequiredArgsConstructor @RestController @RequestMapping("/rag") public class RagController {
private final VectorStore vectorStore; private final RagService ragService;
@Operation(summary = "上传文档并向量化", description = "用于 RAG 文档知识库构建") @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public Result<String> uploadAndLoadData( @Parameter( description = "上传文档(PDF/Word/TXT)", required = true, content = @Content( mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, schema = @Schema(type = "string", format = "binary") ) ) @RequestParam("file") MultipartFile file ) throws IOException, ClientException {
boolean success = ragService.uploadAndLoadData(file); return success ? Result.success() : Result.error("处理失败"); } }
|
- Controller 非常薄,只负责参数接收 + 调用
RagService + 包装统一的 Result 返回,业务逻辑完全放在 Service。
这一层的职责就是:给前端一个统一的、简单的“上传并构建知识库”的入口。
2. 向量检索测试接口 /rag/search
1 2 3 4 5 6 7 8 9 10
| @Operation(summary = "向量相似度查询") @GetMapping("/search") public void search(@RequestParam String query) { List<Document> documents = vectorStore.similaritySearch(query); assert documents != null; System.out.println("搜索到文档数量:" + documents.size()); for(Document document : documents) { System.out.println(document.getText()); } }
|
这个接口是一个临时调试工具:
- 通过
vectorStore.similaritySearch(query) 调用 Spring AI 向量存储,进行语义相似检索。
- 返回的是
List<Document>,你打印了文本内容来验证向量库是否成功写入。
这一层可以理解为:RAG 向量库的测试接口。
三、RagService 接口:把 RAG 能力抽象为一组服务
1 2 3 4 5 6 7 8 9 10 11 12
| public interface RagService {
boolean uploadAndLoadData(MultipartFile file) throws IOException, ClientException;
List<Document> readAndSplitDocument(InputStream inputStream, String fileType) throws IOException;
List<Document> getDocuments(String fileType, byte[] fileBytes);
String getFileExtension(String filename);
boolean isValidFileType(String fileType); }
|
把 RAG 流程拆成几个步骤:
uploadAndLoadData:对外的总入口方法(上传 + 解析 + 切分 + 写入向量库)
readAndSplitDocument:负责 文档读取 & 文本切分
getDocuments:负责根据文件类型选用不同的 DocumentReader
getFileExtension、isValidFileType:负责文件类型的基础校验
四、RagServiceImpl:从文件到向量的完整链路
RagServiceImpl 是整套 RAG 流程的核心
1. uploadAndLoadData:从上传文件到写入向量库
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| @Slf4j @Service @RequiredArgsConstructor public class RagServiceImpl implements RagService {
private final VectorStore vectorStore; private final FileService fileService;
@Override public boolean uploadAndLoadData(MultipartFile file) throws IOException, ClientException { if (file == null || file.isEmpty()) { throw new BusinessException("文件不能为空"); }
String fileType = getFileExtension(file.getOriginalFilename()); if (!isValidFileType(fileType)) { throw new BusinessException("不支持的文件类型,仅支持PDF/WORD/TXT格式"); }
try (InputStream ossInputStream = file.getInputStream()) { boolean success = fileService.upload("rag", ossInputStream); if (!success) { throw new BusinessException("文件上传失败"); } }
try (InputStream docInputStream = file.getInputStream()) { List<Document> splitDocuments = readAndSplitDocument(docInputStream, fileType); if (splitDocuments.isEmpty()) { throw new BusinessException("文件内容解析失败"); }
vectorStore.add(splitDocuments);
log.info("文件已上传并保存到向量存储中,文件名:{},切分文档数:{}", file.getOriginalFilename(), splitDocuments.size()); return true; } } }
|
这里有几个关键设计点:
1)文件类型校验
1 2 3 4
| String fileType = getFileExtension(file.getOriginalFilename()); if (!isValidFileType(fileType)) { throw new BusinessException("不支持的文件类型,仅支持PDF/WORD/TXT格式"); }
|
- 防止用户乱上传
.exe、.zip 等无意义文件
- 统一控制知识库文档类型,便于维护和安全管控
2)同一文件流读两次的处理
MultipartFile 的 getInputStream() 每次调用都会返回一个新的流,所以:
- 第一次流:用于上传到 OSS
- 第二次流:用于文档解析 & 切分
如果底层实现不支持重复读,在后面把流统一转换成 byte[] 再处理(在 readAndSplitDocument 中),避免了“流只能读一次”的坑。
3)文档解析完之后,调用 vectorStore.add
1
| vectorStore.add(splitDocuments);
|
这一行是 RAG 的关键:
最终所有文档片段会被转成向量,写入你配置好的向量数据库(例如 Milvus)。
在 Spring AI 的配置里,你应该已经注入了一个基于 Milvus 的 VectorStore 实现,比如:
1 2 3 4
| @Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { return new MilvusVectorStore(embeddingModel, ...); }
|
之后 QuestionAnswerAdvisor 的检索就是基于这套 VectorStore 来做的。
2. readAndSplitDocument:读取文档并进行文本切片
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Override public List<Document> readAndSplitDocument(InputStream inputStream, String fileType) throws IOException { byte[] fileBytes = inputStream.readAllBytes(); List<Document> docs = getDocuments(fileType, fileBytes); if (docs.stream().allMatch(doc -> doc.getText().isBlank())) { throw new BusinessException("文档内容解析失败或为空"); }
return new TokenTextSplitter( 1024, 200, 10, 2000, true ).apply(docs).stream() .peek(doc -> doc.getMetadata().put("file_type", fileType)) .toList(); }
|
核心步骤:
inputStream.readAllBytes():先把文件读成 byte[],便于后续多次处理。
getDocuments(fileType, fileBytes):根据文件类型创建对应的 DocumentReader,将文件 → 文本。
- 使用
TokenTextSplitter 做文本切片(Chunking)。
- 在每个
Document 的 metadata 中打上 "file_type",方便后续过滤、调试。
TokenTextSplitter 参数解释
1 2 3 4 5 6 7
| new TokenTextSplitter( 1024, 200, 10, 2000, true )
|
- chunkSize = 1024:单个文本块长度(以 token/字符为单位,具体实现可能略有差异)
→ 太小信息不完整,太大影响召回精度和 embedding 性能。
- chunkOverlap = 200:块之间的重叠字符数
→ 防止一句话被切断导致上下文缺失。
- minChunkSize / maxChunkSize:防止切得太碎 / 太大。
- recursive = true:启用递归切分(先按段落,再按句子等),切分质量更好。
3. getDocuments:根据文件类型选用不同的 DocumentReader
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Override public List<Document> getDocuments(String fileType, byte[] fileBytes) { Resource resource = new InputStreamResource(new ByteArrayInputStream(fileBytes));
DocumentReader reader = switch (fileType.toLowerCase()) { case "pdf" -> new PagePdfDocumentReader(resource); case "doc", "docx" -> new TikaDocumentReader(resource); case "txt" -> new TextReader(resource); default -> throw new BusinessException("不支持的文档类型: " + fileType); };
return reader.get(); }
|
这里是 Spring AI 提供的文档读取器体系:
调用 reader.get() 就能拿到一个 List<Document>,每个 Document 包含:
text:该片段的文本内容
metadata:例如页码、文件名等(取决于具体 Reader 实现)
4. 文件类型工具方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Override public String getFileExtension(String filename) { if (filename == null || filename.lastIndexOf(".") == -1) { return ""; } return filename.substring(filename.lastIndexOf(".") + 1); }
@Override public boolean isValidFileType(String fileType) { if (fileType == null || fileType.isEmpty()) { return false; } String type = fileType.toLowerCase(); return type.equals("pdf") || type.equals("doc") || type.equals("docx") || type.equals("txt"); }
|
- 一个负责从文件名取后缀
- 一个负责校验是否在支持范围内
五、和 Spring AI 对话服务的联动:RAG 真正用起来的时候
目前你这部分代码负责的是“存入向量库”,真正 RAG 生效是在对话接口里。
你在别处已经配置过:
1 2 3 4 5 6 7 8 9 10 11
| @Bean public ChatClient chatClient() { MessageChatMemoryAdvisor messageChatMemoryAdvisor = new MessageChatMemoryAdvisor(chatMemory); return ChatClient.builder(chatModel) .defaultAdvisors( messageChatMemoryAdvisor, new SimpleLoggerAdvisor(), new QuestionAnswerAdvisor(vectorStore) ) .build(); }
|
QuestionAnswerAdvisor(vectorStore) 会在每次对话时:
- 拿用户问题提 embedding
- 调用你上面写入过的
vectorStore.similaritySearch(question)
- 把召回到的
Document 作为 上下文(Context) 注入到 Prompt 中
- 最终实现“基于你上传的文档进行回答”的效果 —— 也就是 RAG
所以这篇文章讲的这套 上传 → 文档解析 → 切分 → 存向量库,就是 RAG 中的 “索引构建”阶段。
六、小结
RagController 暴露 /rag/upload & /rag/search 两个接口
RagService 将 RAG 能力抽象为“上传、解析、切分、校验”等方法
RagServiceImpl 实现了:
- 文件上传到 OSS
- 使用
DocumentReader 解析 PDF/Word/TXT
- 使用
TokenTextSplitter 进行语义切片
vectorStore.add 写入向量数据库
QuestionAnswerAdvisor + VectorStore 在对话时完成语义召回,实现真正的检索增强生成(RAG)
“支持上传业务文档 → 自动进入知识库 → 后续通过对话进行检索问答” 。