基于SpringAI + 向量数据库实现RAG文档知识库

基于SpringAI + 向量数据库实现RAG文档知识库功能

本文主要基于下面这几个核心类:

  • RagController:提供对外的 RAG 接口(文档上传 & 向量检索测试)
  • RagService:RAG 服务接口,定义能力边界
  • RagServiceImpl:RAG 服务实现,负责“从文件到向量”的整个链路
  • VectorStore:Spring AI 提供的向量存储抽象(底层可接 Milvus、PGVector 等)
  • FileService:负责将原始文件上传至 OSS(对象存储)

OSS 存原始文档 + 向量数据库存语义切片
后续 ChatClient 通过 QuestionAnswerAdvisor + VectorStore 做语义召回,实现真正的 RAG。

一、总体设计:RAG 在项目中的定位

在你的项目中,RAG 的职责可以简化为一句话:

把用户上传的知识文档,变成可检索的“语义向量片段”,写入向量数据库,为后续问答召回做准备。

整体流程大致如下:

  1. 前端调用 /rag/upload 上传 PDF/Word/TXT 文件

  2. 后端将文件同时:

    • 上传到 OSS(便于后续下载、预览、审计)
    • 使用合适的 DocumentReader 解析成文本
  3. 使用 TokenTextSplitter 对文本进行切片(chunk)

  4. 调用 VectorStore.add(documents) 写入向量数据库(例如 Milvus)

  5. 通过 /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。

这一层的职责就是:给前端一个统一的、简单的“上传并构建知识库”的入口。

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 流程拆成几个步骤:

  1. uploadAndLoadData对外的总入口方法(上传 + 解析 + 切分 + 写入向量库)
  2. readAndSplitDocument:负责 文档读取 & 文本切分
  3. getDocuments:负责根据文件类型选用不同的 DocumentReader
  4. getFileExtensionisValidFileType:负责文件类型的基础校验

四、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格式");
}

// 1. 上传文件到 OSS
try (InputStream ossInputStream = file.getInputStream()) {
boolean success = fileService.upload("rag", ossInputStream);
if (!success) {
throw new BusinessException("文件上传失败");
}
}

// 2. 用新的流解析文档并写入向量库
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 {
// 将 InputStream 转换为可重复读取的字节数组
byte[] fileBytes = inputStream.readAllBytes();
List<Document> docs = getDocuments(fileType, fileBytes);
if (docs.stream().allMatch(doc -> doc.getText().isBlank())) {
throw new BusinessException("文档内容解析失败或为空");
}

// 使用 TokenTextSplitter 进行分块
return new TokenTextSplitter(
1024, // 块大小:适合大多数embedding模型
200, // 重叠长度:保持上下文连贯性
10, // 最小块数:保证基本分割
2000, // 最大块数:防止内存溢出
true // 启用递归分割
).apply(docs).stream()
.peek(doc -> doc.getMetadata().put("file_type", fileType))
.toList();
}

核心步骤:

  1. inputStream.readAllBytes():先把文件读成 byte[],便于后续多次处理。
  2. getDocuments(fileType, fileBytes):根据文件类型创建对应的 DocumentReader,将文件 → 文本。
  3. 使用 TokenTextSplitter 做文本切片(Chunking)。
  4. 在每个 Documentmetadata 中打上 "file_type",方便后续过滤、调试。

TokenTextSplitter 参数解释

1
2
3
4
5
6
7
new TokenTextSplitter(
1024, // chunkSize
200, // chunkOverlap
10, // minChunkSizeChars
2000, // maxChunkSizeChars
true // recursive
)
  • 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 提供的文档读取器体系

  • PDFPagePdfDocumentReader

    • 会按页面进行分割,适合分页结构明显的 PDF。
  • Word(doc/docx)TikaDocumentReader

    • 依赖 Apache Tika,支持多种 Office 格式。
  • TXT 文本TextReader

    • 最简单的纯文本读取。

调用 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) 会在每次对话时:

  1. 拿用户问题提 embedding
  2. 调用你上面写入过的 vectorStore.similaritySearch(question)
  3. 把召回到的 Document 作为 上下文(Context) 注入到 Prompt 中
  4. 最终实现“基于你上传的文档进行回答”的效果 —— 也就是 RAG

所以这篇文章讲的这套 上传 → 文档解析 → 切分 → 存向量库,就是 RAG 中的 “索引构建”阶段

六、小结

  1. RagController 暴露 /rag/upload & /rag/search 两个接口
  2. RagService 将 RAG 能力抽象为“上传、解析、切分、校验”等方法
  3. RagServiceImpl 实现了:
    • 文件上传到 OSS
    • 使用 DocumentReader 解析 PDF/Word/TXT
    • 使用 TokenTextSplitter 进行语义切片
    • vectorStore.add 写入向量数据库
  4. QuestionAnswerAdvisor + VectorStore 在对话时完成语义召回,实现真正的检索增强生成(RAG)

“支持上传业务文档 → 自动进入知识库 → 后续通过对话进行检索问答”