系列导读:前六篇我们完成了从架构认知、Spring Boot集成、大文件分片上传、多租户隔离、事件驱动架构到分布式部署的完整链路。这一篇是系列的收官之作,我们将把MinIO从"文件存储工具"升级为"AI数据底座"------用Spring Boot 3 + Spring AI + MinIO + PostgreSQL/pgvector构建一个完整的RAG(检索增强生成)知识库系统。
一、为什么RAG需要对象存储?
RAG的核心流程是:文档 → 向量化 → 检索 → 大模型生成。大多数教程只关注向量数据库,但一个被忽略的关键问题是:原始文档存哪里?
你不可能把PDF、Word、PPT的二进制内容塞进向量数据库。向量数据库存的是嵌入向量和文本片段,而不是原始文件。当用户上传一份50页的合同PDF时,你需要同时保存两样东西:原始PDF文件(用户可能随时要下载原文件),以及从PDF中提取的文本分块及其向量表示(用于检索)。
MinIO正是这个架构中"原始文档层"的最佳载体。在RAG管道中,MinIO扮演的是持久化记录层(durable system of record) ------它锚定了"文档→嵌入→索引"这个闭环的起点和终点,确保原始文件不会因为向量数据库的迁移或重建而丢失。
#mermaid-svg-P704oUFNpL8d7eSq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-P704oUFNpL8d7eSq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-P704oUFNpL8d7eSq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-P704oUFNpL8d7eSq .error-icon{fill:#552222;}#mermaid-svg-P704oUFNpL8d7eSq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-P704oUFNpL8d7eSq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-P704oUFNpL8d7eSq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-P704oUFNpL8d7eSq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-P704oUFNpL8d7eSq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-P704oUFNpL8d7eSq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-P704oUFNpL8d7eSq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-P704oUFNpL8d7eSq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-P704oUFNpL8d7eSq .marker.cross{stroke:#333333;}#mermaid-svg-P704oUFNpL8d7eSq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-P704oUFNpL8d7eSq p{margin:0;}#mermaid-svg-P704oUFNpL8d7eSq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-P704oUFNpL8d7eSq .cluster-label text{fill:#333;}#mermaid-svg-P704oUFNpL8d7eSq .cluster-label span{color:#333;}#mermaid-svg-P704oUFNpL8d7eSq .cluster-label span p{background-color:transparent;}#mermaid-svg-P704oUFNpL8d7eSq .label text,#mermaid-svg-P704oUFNpL8d7eSq span{fill:#333;color:#333;}#mermaid-svg-P704oUFNpL8d7eSq .node rect,#mermaid-svg-P704oUFNpL8d7eSq .node circle,#mermaid-svg-P704oUFNpL8d7eSq .node ellipse,#mermaid-svg-P704oUFNpL8d7eSq .node polygon,#mermaid-svg-P704oUFNpL8d7eSq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-P704oUFNpL8d7eSq .rough-node .label text,#mermaid-svg-P704oUFNpL8d7eSq .node .label text,#mermaid-svg-P704oUFNpL8d7eSq .image-shape .label,#mermaid-svg-P704oUFNpL8d7eSq .icon-shape .label{text-anchor:middle;}#mermaid-svg-P704oUFNpL8d7eSq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-P704oUFNpL8d7eSq .rough-node .label,#mermaid-svg-P704oUFNpL8d7eSq .node .label,#mermaid-svg-P704oUFNpL8d7eSq .image-shape .label,#mermaid-svg-P704oUFNpL8d7eSq .icon-shape .label{text-align:center;}#mermaid-svg-P704oUFNpL8d7eSq .node.clickable{cursor:pointer;}#mermaid-svg-P704oUFNpL8d7eSq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-P704oUFNpL8d7eSq .arrowheadPath{fill:#333333;}#mermaid-svg-P704oUFNpL8d7eSq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-P704oUFNpL8d7eSq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-P704oUFNpL8d7eSq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-P704oUFNpL8d7eSq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-P704oUFNpL8d7eSq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-P704oUFNpL8d7eSq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-P704oUFNpL8d7eSq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-P704oUFNpL8d7eSq .cluster text{fill:#333;}#mermaid-svg-P704oUFNpL8d7eSq .cluster span{color:#333;}#mermaid-svg-P704oUFNpL8d7eSq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-P704oUFNpL8d7eSq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-P704oUFNpL8d7eSq rect.text{fill:none;stroke-width:0;}#mermaid-svg-P704oUFNpL8d7eSq .icon-shape,#mermaid-svg-P704oUFNpL8d7eSq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-P704oUFNpL8d7eSq .icon-shape p,#mermaid-svg-P704oUFNpL8d7eSq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-P704oUFNpL8d7eSq .icon-shape .label rect,#mermaid-svg-P704oUFNpL8d7eSq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-P704oUFNpL8d7eSq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-P704oUFNpL8d7eSq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-P704oUFNpL8d7eSq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询服务层
AI处理管道
数据接入层
用户上传文档
PDF/Word/Markdown
MinIO 对象存储
原始文档持久化
Apache Tika
文档解析
TokenTextSplitter
文本分块
EmbeddingModel
向量化
PostgreSQL + pgvector
向量存储
用户提问
问题向量化
相似度检索
Top-K
Prompt组装
ChatModel
大模型生成
回答 + 引用来源
二、技术选型与架构设计
2.1 为什么选择Spring AI + pgvector?
在Java生态中构建RAG系统,可选的技术栈有LangChain4j、Spring AI Alibaba、以及原生的Spring AI。本文选择原生Spring AI,原因有三:
统一的API抽象。 Spring AI提供了 EmbeddingModel 和 ChatModel 接口,支持OpenAI、Azure OpenAI、Ollama等多种后端,切换模型只需修改配置,无需改动业务代码。
pgvector兼顾性能与事务支持。 与纯向量数据库(如Milvus、Chroma)相比,pgvector作为PostgreSQL扩展,可以与关系数据共存,对Java开发者非常友好。你的SaaS平台可能已经在使用PostgreSQL存储租户数据和业务数据,引入pgvector不需要额外维护一套数据库集群。
ETL Pipeline原生支持。 Spring AI的ETL管道(DocumentReader → DocumentTransformer → VectorStore)提供了从文档读取到向量存储的完整抽象,与我们在第五篇中构建的事件驱动架构天然契合。
2.2 依赖配置
xml
<properties>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<!-- Spring AI OpenAI 模型(也可替换为 Ollama 用于本地开发) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Spring AI pgvector 向量存储 Starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
<!-- Apache Tika 文档解析(支持 PDF/Word/PPT/HTML 等格式) -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>3.1.0</version>
</dependency>
<!-- 复用第二篇的 MinIO SDK 依赖 -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
</dependencies>
踩坑记录 :Spring AI 1.0 GA之后,starter模块的artifact名称发生了重大变化。如果你的项目还在使用
spring-ai-pgvector-store-spring-boot-starter这样的旧坐标,需要参考升级指南进行迁移。使用spring-ai-starter-vector-store-pgvector是当前推荐的坐标。
2.3 数据库准备
pgvector需要PostgreSQL启用 vector、hstore 和 uuid-ossp 三个扩展。以下Docker Compose配置可以一键启动带pgvector的PostgreSQL:
yaml
# docker-compose.yml --- 追加 PostgreSQL + pgvector 服务
services:
postgres:
image: pgvector/pgvector:pg17 # 官方镜像,已预装 pgvector
container_name: rag-postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: rag_knowledge
POSTGRES_USER: rag_user
POSTGRES_PASSWORD: Rag@2026!
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rag_user -d rag_knowledge"]
interval: 10s
retries: 5
启动后,手动执行扩展启用:
sql
-- 连接到 rag_knowledge 数据库后执行
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
Spring AI的 PgVectorStore 可以自动创建 vector_store 表,但需要显式开启schema初始化。注意:这是一个破坏性变更------在Spring AI早期版本中,schema初始化默认发生,但从1.0版本起需要手动开启。
yaml
# application.yml --- Spring AI 与 pgvector 配置
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-3-small # 1536 维,与 pgvector 表定义匹配
dimensions: 1536
vectorstore:
pgvector:
initialize-schema: true # 显式开启 schema 自动初始化
dimensions: 1536
distance-type: COSINE_DISTANCE
index-type: HNSW
max-document-batch-size: 1000
踩坑记录 :
initialize-schema: true只在第一次启动时创建表。如果你修改了dimensions配置(比如从1536改为1024),Spring AI不会 自动重建表------已有的表结构仍然使用旧的维度。此时必须手动DROP TABLE vector_store后重启应用。排查症状:启动时报"expected 1536 dimensions but got 1024"。
三、文档摄入管道:从MinIO到向量库
3.1 文档上传与MinIO存储
在第二篇的 MinioStorageService 基础上,我们构建一个文档摄入服务。当用户上传文档时,原始文件先存入MinIO,然后触发异步的解析和向量化流程。
java
@Service
public class DocumentIngestionService {
private static final Logger log = LoggerFactory.getLogger(
DocumentIngestionService.class);
private final MinioStorageService storageService;
private final DocumentParser documentParser;
private final VectorStore vectorStore;
private final DocumentMetadataRepository metadataRepository;
public DocumentIngestionService(MinioStorageService storageService,
DocumentParser documentParser,
VectorStore vectorStore,
DocumentMetadataRepository metadataRepository) {
this.storageService = storageService;
this.documentParser = documentParser;
this.vectorStore = vectorStore;
this.metadataRepository = metadataRepository;
}
/**
* 文档摄入流程:MinIO存储 → Tika解析 → 分块 → 向量化 → pgvector
*
* 关键设计决策:
* 1. 原始文件优先存入 MinIO,再触发向量化
* 原因:即使向量化失败,原始文件也不会丢失,
* 可以重新触发处理。如果先向量化后存储,
* 向量化成功但存储失败会导致数据不一致。
* 2. 分块前先做文档解析,将二进制转为纯文本
* 原因:EmbeddingModel 只接受文本输入
* 3. metadata 中携带 tenantId 和 bucket
* 原因:后续检索时需要按租户过滤
*
* @param tenantId 租户ID(来自 TenantContext)
* @param file 上传的文件
* @param bucketName 租户Bucket名称
* @return 文档ID
*/
public String ingest(String tenantId, MultipartFile file,
String bucketName) throws Exception {
String objectName = String.format("knowledge/%s/%s",
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")),
UUID.randomUUID() + "_" + file.getOriginalFilename());
// 步骤1:原始文件存入 MinIO
try (InputStream is = file.getInputStream()) {
storageService.upload(bucketName, objectName, is,
file.getSize(), file.getContentType());
}
log.info("文档已存入 MinIO: {}/{}", bucketName, objectName);
// 步骤2:异步触发解析和向量化
// 实际生产环境中,这一步通过第五篇的事件通知机制触发
// 本文为了简化演示,采用直接调用的方式
try (InputStream is = storageService.download(bucketName, objectName)) {
ingestToVectorStore(tenantId, bucketName, objectName, is);
}
// 步骤3:记录文档元数据
DocumentMetadata metadata = new DocumentMetadata();
metadata.setTenantId(tenantId);
metadata.setBucketName(bucketName);
metadata.setObjectName(objectName);
metadata.setOriginalFileName(file.getOriginalFilename());
metadata.setFileSize(file.getSize());
metadata.setStatus("COMPLETED");
metadata.setCreatedAt(Instant.now());
metadataRepository.save(metadata);
return metadata.getId();
}
/**
* 解析文档并存入向量库
*
* 坑点1:Tika 解析大文档可能 OOM,
* 必须设置 maxStringLength 限制
* 坑点2:分块时需要指定中文标点作为切分边界,
* 否则中文文档会被按字符硬切,语义完全断裂
* 坑点3:metadata 中的 tenantId 是租户隔离的关键,
* 检索时必须按此字段过滤
*/
private void ingestToVectorStore(String tenantId, String bucketName,
String objectName, InputStream is)
throws Exception {
// 1. 解析文档为文本
String text = documentParser.parse(is);
log.info("文档解析完成: {} 字符", text.length());
// 2. 构建 Spring AI Document 并附加元数据
Document doc = new Document(text, Map.of(
"tenantId", tenantId,
"bucketName", bucketName,
"objectName", objectName
));
// 3. 分块
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(800) // 每块最大 800 tokens
.withMinChunkSizeChars(350) // 最小字符数,避免过短碎片
.withPunctuationMarks(List.of(
'。', '?', '!', '\n', '.')) // 中文标点边界
.build();
List<Document> chunks = splitter.apply(List.of(doc));
log.info("文档分块完成: {} 个块", chunks.size());
// 4. 向量化并存入 pgvector
vectorStore.add(chunks);
log.info("向量化完成,已存入 pgvector: {}/{}",
bucketName, objectName);
}
}
3.2 文档解析器实现
Apache Tika提供了统一的文档解析接口,支持PDF、Word、PPT、HTML等多种格式。但对于扫描版PDF(图片型PDF),Tika的文本提取能力有限------它只能提取PDF中已有的文本层,无法对扫描图片做OCR。
java
@Component
public class DocumentParser {
private static final Logger log = LoggerFactory.getLogger(
DocumentParser.class);
/**
* Tika 自动检测文档类型解析器
* 支持:PDF, DOCX, PPTX, XLSX, TXT, Markdown, HTML 等
*/
private final AutoDetectParser parser = new AutoDetectParser();
private final Metadata metadata = new Metadata();
/**
* 解析文档为纯文本
*
* 坑点1:maxStringLength 必须设置,否则大文档会 OOM。
* 10MB 设置对于大多数业务文档是合理的上限。
* 坑点2:扫描版 PDF 无法通过 Tika 提取文本,
* 需要在业务层判断文本长度,如果过短(如 < 100 字符),
* 提示用户该文档可能需要 OCR 处理。
* 坑点3:Tika 解析过程可能抛出 SAXException 等异常,
* 需要捕获并转换为业务异常,不能直接向上抛
*/
public String parse(InputStream is) throws Exception {
try {
WriteOutContentHandler handler = new WriteOutContentHandler(
10 * 1024 * 1024); // 最大 10MB 文本
parser.parse(is, handler, metadata, new ParseContext());
String text = handler.toString();
if (text.strip().length() < 100) {
log.warn("文档解析后文本过短({} 字符)," +
"可能是扫描版PDF或空文档", text.strip().length());
}
return text;
} catch (Exception e) {
log.error("文档解析失败", e);
throw new StorageException("文档解析失败: " + e.getMessage(), e);
}
}
}
踩坑记录 :Tika解析PDF时最常见的坑是内存溢出 。默认情况下,Tika会将整个文档内容加载到内存中。一个200MB的PDF可能消耗1GB以上的JVM堆内存。解决方案是使用
WriteOutContentHandler设置最大文本长度限制,同时在Spring Boot中配置spring.servlet.multipart.max-file-size限制上传文件大小。对于超过50MB的文档,建议单独部署一个Python服务用Unstructured库处理。
3.3 分块策略的选型思考
分块是RAG系统中对检索质量影响最大的环节,没有之一。Spring AI 2.0.1默认按照token数量切分,并支持自定义中文标点作为边界。
在选择分块策略时,核心要理解两个方向的权衡:块太大 会包含多个主题,检索时带进大量噪声;块太小则缺乏前后文,模型可能无法正确理解。
以下是对不同文档类型的分块策略建议:
| 文档类型 | 推荐策略 | Chunk大小 | 说明 |
|---|---|---|---|
| 技术文档/API文档 | TokenTextSplitter | 600-800 | 按token切分,保留标点边界 |
| 合同/法律文书 | RecursiveCharacterTextSplitter | 1000-1500 | 按段落和条款切分 |
| Markdown笔记 | MarkdownSplitter | 800-1200 | 按标题层级切分,保留结构 |
| 学术论文 | SemanticChunker | 500-800 | 按语义相似度切分 |
| 表格密集型文档 | 按表格行切分 | 200-400 | 每行或每几行一块 |
对于大多数SaaS知识库场景,TokenTextSplitter 配合中文标点边界是合理的起点。Spring AI的 TokenTextSplitter 使用CL100K_BASE编码进行token计数,默认目标大小为800个token,最小字符数为350。
四、检索与生成:RAG查询管道
4.1 检索配置------PgVectorStore的核心参数
Spring AI的 PgVectorStore 提供了丰富的配置选项。以下是核心参数的选型依据:
| 参数 | 推荐值 | 选型依据 |
|---|---|---|
dimensions |
1536 | 与EmbeddingModel输出维度匹配(text-embedding-3-small为1536) |
distanceType |
COSINE_DISTANCE | 文本嵌入最常用的相似度度量 |
indexType |
HNSW | 查询性能优于IVFFlat,适合在线检索场景 |
maxDocumentBatchSize |
1000 | 批量写入时的最大文档数 |
关于索引类型的选择,有一个需要理解的权衡:HNSW(Hierarchical Navigable Small World)的查询性能更好,但构建索引更慢且占用更多内存;IVFFlat构建更快、内存更少,但查询性能较低。对于SaaS知识库这种"写入不频繁、查询频繁"的场景,HNSW是更优的选择。
4.2 检索服务实现
java
@Service
public class RagQueryService {
private static final Logger log = LoggerFactory.getLogger(
RagQueryService.class);
private final VectorStore vectorStore;
private final ChatClient chatClient;
public RagQueryService(VectorStore vectorStore,
ChatModel chatModel) {
this.vectorStore = vectorStore;
this.chatClient = ChatClient.builder(chatModel).build();
}
/**
* RAG 查询:检索 + 生成
*
* 关键设计:
* 1. Top-K 选择 K=5
* 原因:K 太小可能漏掉关键信息,
* K 太大则 Prompt 过长,导致大模型"迷失在中间"
* 2. similarityThreshold 设为 0.7
* 原因:过滤掉低相关度的分块,减少噪声
* 3. 按 tenantId 过滤
* 原因:SaaS 多租户隔离------租户A不能检索到租户B的文档
*
* @param tenantId 租户ID
* @param question 用户问题
* @return RAG 回答(含引用来源)
*/
public RagAnswer query(String tenantId, String question) {
// 步骤1:向量检索 Top-5
SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.topK(5)
.similarityThreshold(0.7)
.filterExpression("tenantId == '" + tenantId + "'")
.build();
List<Document> relevantDocs = vectorStore.similaritySearch(
searchRequest);
if (relevantDocs.isEmpty()) {
return new RagAnswer(
"抱歉,没有找到与您的问题相关的文档内容。",
List.of());
}
log.info("检索到 {} 个相关文档块", relevantDocs.size());
// 步骤2:组装 Prompt
String context = relevantDocs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n---\n"));
String prompt = """
你是一个知识库助手。请基于以下参考资料回答用户的问题。
如果参考资料中没有相关信息,请如实告知,不要编造答案。
【参考资料】
%s
【用户问题】
%s
""".formatted(context, question);
// 步骤3:调用大模型生成回答
String answer = chatClient.prompt()
.user(prompt)
.call()
.content();
// 步骤4:提取引用来源
List<String> sources = relevantDocs.stream()
.map(doc -> (String) doc.getMetadata().get("objectName"))
.distinct()
.collect(Collectors.toList());
return new RagAnswer(answer, sources);
}
}
4.3 混合检索------纯向量检索的局限与改进
纯向量检索有一个明显的局限:对"精确关键词"场景效果差。例如,用户搜索"CVE-2026-41145",向量检索可能返回一堆关于安全漏洞的文档,但未必精确命中包含这个CVE编号的文档------因为向量模型更擅长捕捉语义相似性,而不是精确的字符串匹配。
混合检索(Hybrid Search)通过结合向量检索 和关键词检索(BM25) 来弥补这个缺陷。Spring AI 2.0.1提供了 VectorStoreDocumentRetriever 和 RewriteQueryTransformer 等接口来支持更复杂的检索策略。
java
/**
* 混合检索:向量相似度 + 关键词匹配
*
* 使用 PostgreSQL 的全文检索(tsvector)配合 pgvector,
* 在同一张表上同时执行两种检索,然后做 RRF 融合。
*
* 坑点:需要在 vector_store 表的 metadata 列上
* 额外创建 GIN 索引来加速全文检索
*/
public List<Document> hybridSearch(String tenantId,
String question, int topK) {
// 向量检索
List<Document> vectorResults = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(topK * 2) // 多取一些,用于后续融合
.similarityThreshold(0.6)
.filterExpression("tenantId == '" + tenantId + "'")
.build());
// 关键词检索(通过 JdbcTemplate 直接查询)
// 实际项目中建议封装为独立的 KeywordSearchRepository
List<Document> keywordResults = keywordSearch(tenantId, question, topK);
// RRF(Reciprocal Rank Fusion)融合
return rrfFusion(vectorResults, keywordResults, topK);
}
/**
* RRF 融合算法
*
* 核心思想:对每个文档在两个检索列表中的排名取倒数求和,
* 排名越靠前得分越高。k 常数通常取 60。
*/
private List<Document> rrfFusion(List<Document> vectorResults,
List<Document> keywordResults,
int topK) {
Map<String, Double> scores = new HashMap<>();
int k = 60;
for (int i = 0; i < vectorResults.size(); i++) {
String id = vectorResults.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
}
for (int i = 0; i < keywordResults.size(); i++) {
String id = keywordResults.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
}
// 按融合得分降序排列,返回 Top-K
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(entry -> findDocumentById(entry.getKey()))
.collect(Collectors.toList());
}
4.4 RAG查询管道全景
大模型 PostgreSQL PgVectorStore Spring Boot API 用户 大模型 PostgreSQL PgVectorStore Spring Boot API 用户 #mermaid-svg-APGSaWtY8SCyc5DN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-APGSaWtY8SCyc5DN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-APGSaWtY8SCyc5DN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-APGSaWtY8SCyc5DN .error-icon{fill:#552222;}#mermaid-svg-APGSaWtY8SCyc5DN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-APGSaWtY8SCyc5DN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-APGSaWtY8SCyc5DN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-APGSaWtY8SCyc5DN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-APGSaWtY8SCyc5DN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-APGSaWtY8SCyc5DN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-APGSaWtY8SCyc5DN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-APGSaWtY8SCyc5DN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-APGSaWtY8SCyc5DN .marker.cross{stroke:#333333;}#mermaid-svg-APGSaWtY8SCyc5DN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-APGSaWtY8SCyc5DN p{margin:0;}#mermaid-svg-APGSaWtY8SCyc5DN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-APGSaWtY8SCyc5DN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-APGSaWtY8SCyc5DN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-APGSaWtY8SCyc5DN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-APGSaWtY8SCyc5DN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-APGSaWtY8SCyc5DN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-APGSaWtY8SCyc5DN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-APGSaWtY8SCyc5DN .sequenceNumber{fill:white;}#mermaid-svg-APGSaWtY8SCyc5DN #sequencenumber{fill:#333;}#mermaid-svg-APGSaWtY8SCyc5DN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-APGSaWtY8SCyc5DN .messageText{fill:#333;stroke:none;}#mermaid-svg-APGSaWtY8SCyc5DN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-APGSaWtY8SCyc5DN .labelText,#mermaid-svg-APGSaWtY8SCyc5DN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-APGSaWtY8SCyc5DN .loopText,#mermaid-svg-APGSaWtY8SCyc5DN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-APGSaWtY8SCyc5DN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-APGSaWtY8SCyc5DN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-APGSaWtY8SCyc5DN .noteText,#mermaid-svg-APGSaWtY8SCyc5DN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-APGSaWtY8SCyc5DN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-APGSaWtY8SCyc5DN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-APGSaWtY8SCyc5DN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-APGSaWtY8SCyc5DN .actorPopupMenu{position:absolute;}#mermaid-svg-APGSaWtY8SCyc5DN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-APGSaWtY8SCyc5DN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-APGSaWtY8SCyc5DN .actor-man circle,#mermaid-svg-APGSaWtY8SCyc5DN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-APGSaWtY8SCyc5DN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 提问:"退款流程是什么?"EmbeddingModel 将问题向量化similaritySearch(query, topK=5, filter=tenantId)SELECT ... ORDER BY embedding <=> query_vector返回 Top-5 相似文档块List<Document>组装 Prompt(参考资料 + 问题)ChatModel.call(prompt)生成的回答回答 + 引用来源
五、性能优化与生产实践
5.1 批量写入与索引优化
在文档摄入阶段,大量分块的向量化写入是性能瓶颈。Spring AI的 PgVectorStore 支持批量处理,通过 maxDocumentBatchSize 参数控制每次批量写入的文档数。
对于HNSW索引,建议在批量导入完成后再创建索引,而不是边导入边索引。HNSW索引的构建是CPU密集型操作,如果在大量写入的同时维护索引,会导致写入吞吐量显著下降。
sql
-- 批量导入完成后,再创建 HNSW 索引
CREATE INDEX CONCURRENTLY ON vector_store
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
m 参数控制每个节点的连接数,默认16,增大可提高查询召回率但增加内存和构建时间。ef_construction 控制构建时的动态列表大小,默认64,增大可提高索引质量。
5.2 常见问题排查
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 检索结果不相关 | 分块过大或过小 | 调整chunkSize,尝试600-1200区间 |
| 中文检索效果差 | 分块未按中文标点切分 | 在splitter中配置中文标点边界 |
| 检索返回空 | similarityThreshold过高 | 降低阈值(如从0.7降到0.5) |
| 向量维度不匹配 | 修改了Embedding模型 | 删除vector_store表后重启应用 |
| 写入速度慢 | 边写边建HNSW索引 | 批量导入完成后单独建索引 |
| 内存溢出 | 大文档全量加载到内存 | 设置Tika maxStringLength限制 |
六、进阶方向:AIStor Tables与Iceberg
如果你正在构建企业级AI应用,MinIO AIStor Tables提供了一个值得关注的演进方向。
2026年2月正式发布的AIStor Tables是业内首个将完整Apache Iceberg V3 Catalog REST API直接内建到数据存储中的产品。它的核心价值在于统一了结构化表格数据和非结构化对象数据------RAG系统可以在同一个数据平面上同时查询结构化数据(如产品目录、合同条款表)和非结构化文档。
对于RAG场景的具体意义是:Iceberg表可以维护指向向量嵌入和分块文档的指针,让AI Agent在检索时同时命中表格数据和文档内容。AMD在其内部数据智能平台中就采用了这种架构------基于MinIO AIStor构建GraphRAG引擎,将工单、代码、日志和遥测数据连接为图感知的数据层,实现了90%以上的查询延迟降低。
另一个值得关注的方向是MemKV------MinIO AIStor与NVIDIA BlueField-4 DPU集成,提供原生KV Cache上下文内存存储层,减少推理重计算,在不增加GPU HBM成本的前提下维持Token吞吐量。对于长上下文推理场景,这可以显著降低推理成本。
七、生产环境Checklist
| 检查项 | 说明 | 严重程度 |
|---|---|---|
| tenantId过滤 | 检索时必须按租户过滤,防止跨租户数据泄漏 | 🔴 必须 |
| 向量维度对齐 | EmbeddingModel输出维度与vector_store表维度一致 | 🔴 必须 |
| API Key外置化 | OpenAI API Key通过环境变量注入 | 🔴 必须 |
| Tika内存限制 | 设置maxStringLength,防止大文档OOM | 🟡 推荐 |
| HNSW索引分离 | 批量导入后单独建索引 | 🟡 推荐 |
| 相似度阈值调优 | 根据实际效果调整threshold(0.5-0.8区间) | 🟡 推荐 |
| 分块策略验证 | 用真实查询验证分块效果,必要时调整 | 🟡 推荐 |
| 引用来源保留 | 回答中附带文档引用,提升可信度 | 🟢 推荐 |
八、系列总结:从对象存储到AI数据底座
七篇文章的旅程至此画上句号。让我们回顾一下整个系列的知识脉络:
#mermaid-svg-wS262irJK7KSkVox{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wS262irJK7KSkVox .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wS262irJK7KSkVox .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wS262irJK7KSkVox .error-icon{fill:#552222;}#mermaid-svg-wS262irJK7KSkVox .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wS262irJK7KSkVox .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wS262irJK7KSkVox .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wS262irJK7KSkVox .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wS262irJK7KSkVox .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wS262irJK7KSkVox .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wS262irJK7KSkVox .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wS262irJK7KSkVox .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wS262irJK7KSkVox .marker.cross{stroke:#333333;}#mermaid-svg-wS262irJK7KSkVox svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wS262irJK7KSkVox p{margin:0;}#mermaid-svg-wS262irJK7KSkVox .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wS262irJK7KSkVox .cluster-label text{fill:#333;}#mermaid-svg-wS262irJK7KSkVox .cluster-label span{color:#333;}#mermaid-svg-wS262irJK7KSkVox .cluster-label span p{background-color:transparent;}#mermaid-svg-wS262irJK7KSkVox .label text,#mermaid-svg-wS262irJK7KSkVox span{fill:#333;color:#333;}#mermaid-svg-wS262irJK7KSkVox .node rect,#mermaid-svg-wS262irJK7KSkVox .node circle,#mermaid-svg-wS262irJK7KSkVox .node ellipse,#mermaid-svg-wS262irJK7KSkVox .node polygon,#mermaid-svg-wS262irJK7KSkVox .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wS262irJK7KSkVox .rough-node .label text,#mermaid-svg-wS262irJK7KSkVox .node .label text,#mermaid-svg-wS262irJK7KSkVox .image-shape .label,#mermaid-svg-wS262irJK7KSkVox .icon-shape .label{text-anchor:middle;}#mermaid-svg-wS262irJK7KSkVox .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wS262irJK7KSkVox .rough-node .label,#mermaid-svg-wS262irJK7KSkVox .node .label,#mermaid-svg-wS262irJK7KSkVox .image-shape .label,#mermaid-svg-wS262irJK7KSkVox .icon-shape .label{text-align:center;}#mermaid-svg-wS262irJK7KSkVox .node.clickable{cursor:pointer;}#mermaid-svg-wS262irJK7KSkVox .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wS262irJK7KSkVox .arrowheadPath{fill:#333333;}#mermaid-svg-wS262irJK7KSkVox .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wS262irJK7KSkVox .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wS262irJK7KSkVox .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wS262irJK7KSkVox .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wS262irJK7KSkVox .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wS262irJK7KSkVox .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wS262irJK7KSkVox .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wS262irJK7KSkVox .cluster text{fill:#333;}#mermaid-svg-wS262irJK7KSkVox .cluster span{color:#333;}#mermaid-svg-wS262irJK7KSkVox div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wS262irJK7KSkVox .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wS262irJK7KSkVox rect.text{fill:none;stroke-width:0;}#mermaid-svg-wS262irJK7KSkVox .icon-shape,#mermaid-svg-wS262irJK7KSkVox .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wS262irJK7KSkVox .icon-shape p,#mermaid-svg-wS262irJK7KSkVox .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wS262irJK7KSkVox .icon-shape .label rect,#mermaid-svg-wS262irJK7KSkVox .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wS262irJK7KSkVox .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wS262irJK7KSkVox .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wS262irJK7KSkVox :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} P1: 对象存储原理与MinIO架构
P2: Spring Boot 3 集成MinIO
P3: 大文件分片上传与断点续传
P4: 多租户SaaS隔离策略
P5: 事件驱动架构与Kafka集成
P6: 分布式部署与性能调优
P7: MinIO + RAG知识库实战
第一篇 建立了对象存储的认知框架------理解MinIO的去中心化架构和纠删码机制。第二篇 完成了Spring Boot的基础集成,构建了生产级的文件存储服务。第三篇 攻克了大文件分片上传的工程难题。第四篇 解决了SaaS多租户的隔离挑战。第五篇 引入了事件驱动架构,让文件处理走向异步化。第六篇 深入分布式部署和性能调优。第七篇将MinIO从文件存储工具升级为AI数据底座。
这套技术栈的核心价值在于:MinIO不是孤立的存储组件,而是SaaS平台和AI应用的数据中枢。它承载了用户上传的原始文件,驱动了异步处理管道,支撑了多租户隔离,最终为RAG知识库提供了持久化的文档层。
进阶路线建议:
- Kubernetes Operator部署:将第六篇的分布式部署方案容器化,使用MinIO Operator实现自动化运维
- 多集群联邦:跨区域部署MinIO集群,通过站点复制实现数据就近访问
- GraphRAG:在向量检索的基础上引入知识图谱,实现多跳推理(参考AMD的GraphRAG实践)
- AIStor Tables集成:将结构化数据纳入RAG管道,实现表格+文档的统一检索
参考资料
- Spring AI PgVectorStore 官方API文档:https://docs.spring.io/spring-ai/docs/2.0.0-M2/api/org/springframework/ai/vectorstore/pgvector/PgVectorStore.html
- Spring AI Alibaba PGvector 集成指南:https://java2ai.com/integration/rag/vectordbs/pgvector/
- pgvector-java 官方仓库:https://github.com/pgvector/pgvector-java
- Spring AI RAG Pipeline Demo(GitHub):https://github.com/ajayp7tech/spring-ai-rag-demo
- 个人知识库RAG项目(MinIO + pgvector):https://github.com/wl-kkkkk/personal-rag
- MinIO AIStor Tables GA 发布公告:https://www.min.io/press/minio-introduces-ga-of-aistor-tables-unifying-enterprise-data-for-agentic-ai
- MinIO AIStor NVIDIA AI Factory 白皮书:https://www.min.io/resources/minio-aistor-the-unified-data-foundation-for-the-nvidia-ai-factory
- AMD AIStor Lakehouse 客户案例:https://www.min.io/customer-stories/accelerating-issue-resolution-with-a-modern-data-lakehouse-built-on-minio-aistor
- Spring AI 2.0 RAG工程优化:https://www.cnblogs.com/joyt/articles/23027882