MinIO第07篇:基于MinIO + Spring AI + pgvector构建RAG知识库(Java全栈实战)

系列导读:前六篇我们完成了从架构认知、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管道,实现表格+文档的统一检索

参考资料

相关推荐
四六的六3 小时前
GPT-6 会自己画界面了:从写页面到定规则,前端在 Intelligent UI 时代的新活法
人工智能·个人开发·ai编程·ai大模型·组件库·ai产品·ui界面
做个文艺程序员3 小时前
MinIO第06篇:分布式集群部署、纠删码调优与生产级性能优化
性能调优·minio·高可用·安全漏洞·分布式部署·纠删码
鱼宵16 小时前
Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
java·人工智能·spring·few-shot·提示词工程·springai
天堂 沙县小吃21 小时前
RAG 进入 2.0 时代:从传统 RAG 到 Agentic RAG——检索增强生成的新范式
rag
鱼宵1 天前
Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
人工智能·spring·microsoft·大模型·springai·chatclient
鱼宵1 天前
Spring AI 工具调用:@Tool 让大模型自己查订单查库存
人工智能·spring·工具调用·functioncalling·springai
小蒋观天下2 天前
端侧大模型在安防摄像头部署实操(上)|行业痛点、架构选型、落地思路解析
大数据·人工智能·安全·计算机视觉·ai大模型
互联网叫兽2 天前
RAG 知识库-基于元数据的细粒度权限控制
ai·llm·rag
n112122 天前
大模型 API 报 429 和超时:限流、重试与降级该怎么写
ai大模型·大模型api