8. 生产级向量数据库

6 min

模块化 RAG 大幅提升了检索质量,但底层的 SimpleVectorStore 还是内存存储,应用一重启,所有向量数据就没了。是时候把存储层升级到一个真正的向量数据库,让 RAG 系统具备生产级的持久化能力。

1. 为什么升级

先看看 SimpleVectorStore 的局限:

维度 SimpleVectorStore 生产级向量数据库
数据持久化 内存中,重启丢失 磁盘持久化
保存/恢复 需手动调用 save/load 自动持久化
并发访问 不支持 多线程/多进程安全
数据规模 受 JVM 内存限制 可存储百万级向量
过滤查询 内存遍历 原生索引加速
索引优化 无(暴力搜索) HNSW / IVF 等近似最近邻索引
生产部署 不推荐 生产就绪

SimpleVectorStore 的定位很明确:快速原型和教学演示。一旦进入实际开发,就需要一个真正的向量数据库。

2. 选择 Qdrant 还是 PGVector

Spring AI 支持十余种向量数据库,自托管场景下 PGVectorQdrant 是最常被对比的选择。

2.1 PGVector:零成本扩展,但有硬伤

作为 PostgreSQL 的向量扩展,它的核心优势是复用现有基础设施 ,已有 PG 的项目只需启用扩展即可获得向量能力,无需引入新组件。

2.2 Qdrant:专业向量数据库

专为向量检索设计(Rust 编写),优势明确:

特性 PGVector Qdrant
部署 依赖 PostgreSQL 单 Docker 容器
HNSW 维度上限 2000 65536
4096 维支持 ❌ 不支持 ✅ 原生支持
元数据过滤 JSONB 查询 原生 payload 索引
可视化 需第三方工具 自带 Web Dashboard
Spring AI 支持 需额外配置 spring-ai-starter-vector-store-qdrant 开箱即用

本专栏选择 Qdrant,原因:

  1. 完美支持 4096 维 HNSW 索引;
  2. Web Dashboard 大幅降低学习和调试成本;

3. Docker Compose 启动 Qdrant

在项目根目录创建 docker-compose.yml

yaml
services:
  qdrant:
    image: qdrant/qdrant:v1.14.0
    restart: unless-stopped
    ports:
      - "6333:6333"  # REST API + Web Dashboard
      - "6334:6334"  # gRPC(Spring AI 使用)
    volumes:
      - qdrant_data:/qdrant/storage

volumes:
  qdrant_data:

配置说明:

  • qdrant/qdrant:v1.14.0:官方镜像,固定 v1.14.0 以兼容 Spring AI 2.0.0-M3 内置的 Qdrant 客户端(1.13.x),minor 版本差控制在 1 以内
  • 6333 端口:REST API 与 Web Dashboard,访问 http://localhost:6333/dashboard 查看数据
  • 6334 端口:gRPC 接口,Spring AI 连接用
  • qdrant_data 卷:持久化存储,容器重建数据不丢失

启动服务:

bash
docker compose up -d

验证服务运行:

bash
curl http://localhost:6333/

返回 JSON 包含 Qdrant 版本信息说明启动成功。

4. 添加 Qdrant 依赖

pom.xml 中添加 Qdrant starter:

xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-qdrant</artifactId>
</dependency>

这个 starter 会自动配置:

  • QdrantClient:基于 gRPC 的客户端,根据配置的 host/port 连接 Qdrant
  • QdrantVectorStore Bean:实现 VectorStore 接口,替代 SimpleVectorStore
  • Schema 初始化:根据 EmbeddingModel 的输出维度自动创建 collection

5. 配置 Qdrant

更新 src/main/resources/application.yaml,添加 Qdrant 配置:

yaml
spring:
  application:
    name: rag-demo

  ai:
    ollama:
      base-url: http://localhost:11434
      init:
        pull-model-strategy: when_missing
      chat:
        model: qwen3.5:9b
      embedding:
        model: qwen3-embedding:8b
    vectorstore:
      qdrant:
        host: localhost
        port: 6334
        collection-name: rag_demo
        initialize-schema: true

server:
  port: 8080

新增配置说明:

  • host / port:Qdrant 的 gRPC 地址,端口默认是 6334(注意不是 REST 的 6333)
  • collection-name:collection 名称,相当于关系数据库的"表",每个 collection 存储一组维度相同的向量
  • initialize-schema: true:启动时自动创建 collection。维度从 EmbeddingModel 推断(这里是 4096),距离度量默认 Cosine

6. 自动建集合

initialize-schema: true 时,QdrantVectorStore 在 Bean 初始化阶段会通过 gRPC 连接 Qdrant,检查指定的 collection 是否存在,不存在则自动创建。创建时会从容器中的 EmbeddingModel Bean 读取向量维度,并使用 Cosine 距离作为相似度度量。

应用启动后,可以通过 Web Dashboard 查看创建的 collection:

bash
open http://localhost:6333/dashboard
Qdrant Web Dashboard

在 Dashboard 中可以看到名为 rag_demo 的 collection,向量维度 4096,距离度量 Cosine。

7. 更新 AiConfig

src/main/java/com/albertstack/rag/config/AiConfig.java

java
package com.albertstack.rag.config;

import com.albertstack.rag.service.IngestionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

@Slf4j
@Configuration
public class AiConfig {

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }

    @Bean
    public RetrievalAugmentationAdvisor ragAdvisor(
            ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
        return RetrievalAugmentationAdvisor.builder()
            .queryTransformers(RewriteQueryTransformer.builder()
                .chatClientBuilder(chatClientBuilder).build())
            .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .similarityThreshold(0.5)
                .topK(5)
                .build())
            .queryAugmenter(ContextualQueryAugmenter.builder()
                .allowEmptyContext(true)
                .build())
            .build();
    }

    @Bean
    public CommandLineRunner ingestDocuments(IngestionService ingestionService) {
        return args -> {
            List<String> files = List.of(
                "docs/spring-ai-intro.txt",
                "docs/rag-concepts.txt",
                "docs/ollama-guide.txt"
            );
            for (String file : files) {
                var resource = new ClassPathResource(file);
                int chunks = ingestionService.ingest(resource);
                log.info("已摄入: {} -> {} 个分块", file, chunks);
            }
        };
    }
}

相比上一章的版本:

  • 删除手动创建的 SimpleVectorStore Bean 及对应 import
  • QdrantVectorStoreAutoConfiguration 自动注册 QdrantVectorStore,依赖(EmbeddingModelQdrantClient)由 Spring 容器自动注入
  • IngestionServiceRagService、Controller 依赖的都是 VectorStore 接口,无需改动

8. 测试

运行测试前确保 Qdrant 容器已启动:

bash
docker compose ps

8.1 写入与搜索

验证文档写入 Qdrant 后能正常检索。

src/test/java/com/albertstack/rag/QdrantWriteAndSearchTest.java

java
package com.albertstack.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class QdrantWriteAndSearchTest {

    @Autowired
    VectorStore vectorStore;

    @BeforeEach
    void setUp() {
        // 清理上次运行残留的数据,保证测试可重复执行
        var filter = new FilterExpressionBuilder();
        vectorStore.delete(filter.in("source",
            "qdrant-intro.txt", "hnsw-guide.txt", "spring-boot-basics.txt").build());

        vectorStore.add(List.of(
            new Document(
                "Qdrant 是用 Rust 编写的高性能向量数据库,支持高维向量的近似最近邻搜索。",
                Map.of("source", "qdrant-intro.txt", "type", "tutorial")),
            new Document(
                "HNSW 索引是一种高效的近似最近邻搜索算法,查询速度接近常数时间。",
                Map.of("source", "hnsw-guide.txt", "type", "reference")),
            new Document(
                "Spring Boot 的自动配置机制可以根据依赖自动注册相应的 Bean。",
                Map.of("source", "spring-boot-basics.txt", "type", "tutorial"))
        ));
    }

    @Test
    void shouldPersistAndSearchDocuments() {
        List<Document> results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("向量数据库搜索")
                .topK(2)
                .build());

        log.info("搜索结果数量: {}", results.size());
        results.forEach(doc -> log.info("- [{}] {}", doc.getMetadata().get("source"), doc.getText()));

        assertThat(results).isNotEmpty();
        assertThat(results).hasSizeLessThanOrEqualTo(2);
    }
}

8.2 过滤表达式

Qdrant 支持基于 metadata 的过滤查询,Spring AI 的 FilterExpressionBuilder 会把表达式翻译为 Qdrant 原生的 Filter 结构,利用 payload 索引高效执行。

src/test/java/com/albertstack/rag/QdrantFilterTest.java

java
package com.albertstack.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class QdrantFilterTest {

    @Autowired
    VectorStore vectorStore;

    @BeforeEach
    void setUp() {
        // 清理上次运行残留的数据,保证测试可重复执行
        var filter = new FilterExpressionBuilder();
        vectorStore.delete(filter.in("source",
            "spring-ai-intro.txt", "rag-concepts.txt", "qdrant-guide.txt").build());

        vectorStore.add(List.of(
            new Document(
                "Spring AI 提供了统一的 API 来对接各种大模型。",
                Map.of("source", "spring-ai-intro.txt", "type", "tutorial", "chapter", 1)),
            new Document(
                "RAG 通过检索外部知识来增强大模型的回答质量。",
                Map.of("source", "rag-concepts.txt", "type", "tutorial", "chapter", 3)),
            new Document(
                "Qdrant 的 HNSW 索引适用于高维向量的快速检索。",
                Map.of("source", "qdrant-guide.txt", "type", "reference", "chapter", 8))
        ));
    }

    @Test
    void shouldFilterByEquality() {
        // 按 type 精确匹配
        var filter = new FilterExpressionBuilder();
        List<Document> results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("Spring AI")
                .topK(5)
                .filterExpression(filter.eq("type", "tutorial").build())
                .build());

        log.info("type=tutorial 结果数: {}", results.size());
        results.forEach(doc -> log.info("- [{}] {}", doc.getMetadata().get("source"), doc.getText()));

        // 过滤条件生效,只返回 tutorial 类型
        results.forEach(doc ->
            assertThat(doc.getMetadata().get("type")).isEqualTo("tutorial"));
    }

    @Test
    void shouldFilterByAndCondition() {
        // AND 组合:同时满足 type 和 source
        var filter = new FilterExpressionBuilder();
        List<Document> results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("Spring AI")
                .topK(5)
                .filterExpression(filter.and(
                    filter.eq("type", "tutorial"),
                    filter.eq("source", "spring-ai-intro.txt")
                ).build())
                .build());

        log.info("AND 过滤结果数: {}", results.size());
        results.forEach(doc -> {
            assertThat(doc.getMetadata().get("type")).isEqualTo("tutorial");
            assertThat(doc.getMetadata().get("source")).isEqualTo("spring-ai-intro.txt");
        });
    }

    @Test
    void shouldFilterByInExpression() {
        // IN 查询:source 在指定列表中
        var filter = new FilterExpressionBuilder();
        List<Document> results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("Spring AI")
                .topK(5)
                .filterExpression(filter.in("source",
                    "spring-ai-intro.txt", "rag-concepts.txt").build())
                .build());

        log.info("IN 过滤结果数: {}", results.size());
        results.forEach(doc -> {
            String source = (String) doc.getMetadata().get("source");
            log.info("- [{}] {}", source, doc.getText());
            assertThat(source).isIn("spring-ai-intro.txt", "rag-concepts.txt");
        });
    }

    @Test
    void shouldFilterByComparison() {
        // 比较运算:chapter >= 3
        var filter = new FilterExpressionBuilder();
        List<Document> results = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query("向量搜索")
                .topK(5)
                .filterExpression(filter.gte("chapter", 3).build())
                .build());

        log.info("chapter >= 3 结果数: {}", results.size());
        results.forEach(doc -> {
            // Qdrant 内部把整数 payload 存为 Long,用 Number 接收避免类型不匹配
            int chapter = ((Number) doc.getMetadata().get("chapter")).intValue();
            log.info("- chapter={}, {}", chapter, doc.getText());
            assertThat(chapter).isGreaterThanOrEqualTo(3);
        });
    }
}

FilterExpressionBuilder 是 Spring AI 提供的统一过滤抽象,同样的代码在 Qdrant、PGVector、Milvus 等不同存储上都能工作。Spring AI 内部会把表达式翻译为各数据库的原生查询结构。

9. Web Dashboard

Qdrant 自带的 Web Dashboard 是开发调试的利器。在浏览器打开:

http://localhost:6333/dashboard

在 Dashboard 中可以:

  • 查看所有 collection 及其配置(向量维度、距离度量、点数量)
  • 浏览每个 collection 中存储的向量点(point),包括 metadata payload
  • 用控制台直接执行 Qdrant 的 REST API
  • 监控 Qdrant 实例的运行状态

测试运行后,进入 rag_demo collection,可以看到刚才写入的文档点,每个点的 payload 包含 sourcetypechapter 等元数据。

10. 本章小结

完成了从 SimpleVectorStore 到 Qdrant 的升级。整个过程只需要换依赖、改配置、删除手动创建的 SimpleVectorStore Bean,业务代码(Service、Controller)完全不用动,这就是面向 VectorStore 接口编程的好处。

知识点 说明
Qdrant 选型 专为向量检索设计,HNSW 默认支持高维向量,避开 PGVector 2000 维上限
Docker Compose 单容器启动,pin v1.14.0 与 Spring AI 客户端版本兼容
依赖切换 添加 spring-ai-starter-vector-store-qdrant,替换内存存储
配置更新 host + port + collection-name + initialize-schema
AiConfig 更新 删除 SimpleVectorStore Bean,由 auto-configuration 自动注册 QdrantVectorStore
过滤表达式 FilterExpressionBuilder 支持 eq、and、in、gte 等,跨数据库通用
Web Dashboard http://localhost:6333/dashboard,可视化 collection 和数据

接下来把所有组件整合起来,构建一个结构清晰的 RAG API 服务。