向量数据库选型与踩坑实录:Milvus vs Qdrant vs pgvector 生产横评 (生产高阶进阶实战)

向量数据库选型与踩坑实录:Milvus vs Qdrant vs pgvector 生产横评 (生产高阶进阶实战)

在生成式 AI 与 RAG(检索增强生成)系统全面落地的今天,向量数据库已成为大模型生态中的核心基础设施。许多团队在项目初期为了快速上线,盲目选择技术栈,当向量数据量从十万级暴增至千万级甚至亿级时,往往会遭遇 检索延迟暴涨、索引构建触发 OOM(内存溢出)、混合过滤性能断崖式下跌 等严重生产故障。

本文将摒弃纸上谈兵的理论罗列,结合我们在生产环境(千万级 1536 维向量数据集)下的极端压测、高可用容灾演练与内核架构演进经验,对当前最主流的三款向量数据库 —— Milvus、Qdrant 与 pgvector 进行深度横评,并给出基于 Spring Boot 3.x 的生产级接入与降级方案。


一、问题背景与业务痛点

在我们的智能知识库与业务大模型系统中,需要对海量文档切片(Chunk)及用户行为特征进行向量化存储与相似度检索。随着业务迭代,系统面临以下三大核心痛点:

  1. pgvector 早期红利消退与性能断崖:
    项目初期使用 PostgreSQL 扩展 pgvector,无需额外维护独立的中间件,开发效率极高。但当单表向量数据量突破 800 万、并发 QPS 达到 300+ 时,数据库 CPU 直接打满,P99 延迟从 20ms 暴增至 2.5s 以上。此外,构建 HNSW 索引时多次导致 PG 主节点 OOM 宕机。
  2. 混合检索(Scalar + Vector Filter)效率低下:
    实际业务并非纯粹的向量 Similarity 检索,通常带有复杂的标量过滤(例如:tenant_id = 1001 AND status = 'PUBLISHED' AND created_at > 1704067200)。传统向量数据库在执行“先过滤后向量”或“先向量后过滤”策略时,容易导致 HNSW 图图链路断裂或检索召回率(Recall Rate)严重下降。
  3. 云原生架构下的运维与容灾成本高昂:
    分布式向量数据库(如 Milvus)组件极多(Pulsar/Kafka、Etcd、MinIO、DataNode、QueryNode 等),架构极其复杂,单节点故障时容易引发级联反应;而轻量级向量数据库(如 Qdrant)在跨机房多活与云原生弹性扩缩容方面则需要更精细的控制。

二、

Spring 核心请求处理生命周期与拦截器设计模型
▲ 权威参考图:Spring 核心请求处理生命周期与拦截器设计模型 (已转存博客园图床)

核心设计与解决思路

为了兼顾系统高可用、低延迟以及团队运维成本,我们对三款主流向量数据库的内核架构与技术特性进行了多维度对比:

1. 核心技术选型对比

维度 pgvector Qdrant Milvus 2.x
内核语言 C (PostgreSQL C Extension) Rust Go + C++ (Knowhere 核心引擎)
部署架构 单体/主从(依赖 PG 生态) 极简单体 / 原生 Distributed Cluster 深度云原生微服务 / 存算分离架构
支持索引 IVFFlat, HNSW HNSW (改进版内存/磁盘图) HNSW, IVF_FLAT, IVF_SQ8, DiskANN
标量过滤 借力 PG 强大 SQL 执行器 (BTREE) 内置 Payload 索引与严格图遍历过滤 Scalar Index + 独立 Query Engine
1000万向量内存占用 很高(需全量加载或依赖 OS Buffer) 中等(支持 Memmory-mapped/Mmap) 低~中等(支持 SQ8/PQ 量化与 DiskANN)
千万级 P99 延迟 > 1500 ms (高并发下) < 25 ms < 15 ms
适用场景 < 100万向量,已有 PG 栈快速 POC 100万~5000万向量,高性能轻量级服务 > 5000万向量,亿级高并发云原生大厂

2. 核心架构设计与组件拓扑

在生产架构中,为了避免单一向量数据库故障导致全局瘫痪,我们设计了一套多级混合向量检索架构,由 Spring Boot 微服务作为统一网关进行动态路由与熔断降级。

flowchart TD
    subgraph Client Layer
        A[App / Web / API Client]
    |
    subgraph API Gateway & Service Layer
        B[Spring Boot 3.x Vector Gateway]
        C[RAG Retrieval Core]
        D[Resilience4j Circuit Breaker]
    end

    subgraph Vector Database Tier
        E[Milvus Cluster Primary]
        F[Qdrant Cluster Secondary]
        G[PostgreSQL + pgvector Fallback]
    end

    subgraph Storage & Metadata Layer
        H[MinIO / S3 Segment Storage]
        I[Etcd Cluster Metadata]
        J[PostgreSQL Metadata DB]
    end

    A --> B
    B --> C
    C --> D
    D -->|Primary Strategy| E
    D -->|Warm Standby / Shadow| F
    D -->|Degradation Fallback| G

    E --> H
    E --> I
    G --> J

3. 端到端请求执行时序

客户端发起带有标量条件的向量检索请求时,服务层的链路流转与异常降级时序如下图所示:

▲ 时序图 2:端到端请求处理与调用时序链路
▲ 时序图 2:端到端请求处理与调用时序链路


三、完整实战代码与配置

以下提供基于 Spring Boot 3.2、Java 17 以及 Milvus / Qdrant Java SDK 实现的高性能向量路由与容灾检索代码。

1. 系统配置文件 bootstrap.yml

spring:
  application:
    name: vector-retrieval-service

vector:
  engine:
    primary: milvus
    secondary: qdrant
  milvus:
    host: 192.168.10.100
    port: 19530
    token: root:Milvus2024!
    database: rag_knowledge
    collection-name: doc_chunks_v1
  qdrant:
    host: 192.168.10.101
    port: 6334
    api-key: QdrantSecureToken2024
    collection-name: doc_chunks_v1

resilience4j:
  circuitbreaker:
    instances:
      milvusSearch:
        slidingWindowSize: 20
        failureRateThreshold: 50
        waitDurationInOpenState: 10000ms
        permittedNumberOfCallsInHalfOpenState: 5

2. 统一向量模型定义 VectorSearchRequest.java

package com.architect.vector.model;

import lombok.Builder;
import lombok.Data;
import java.util.List;

@Data
@Builder
public class VectorSearchRequest {
    private String tenantId;
    private List<Float> queryVector;
    private int topK;
    private String category;
    private long minTimestamp;
}

3. 核心向量检索服务实现 VectorRetrievalEngine.java

package com.architect.vector.service;

import com.architect.vector.model.VectorSearchRequest;
import com.architect.vector.model.VectorSearchResult;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.milvus.client.MilvusServiceClient;
import io.milvus.param.dml.SearchParam;
import io.milvus.response.SearchResultsWrapper;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.grpc.Points;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class VectorRetrievalEngine {

    private final MilvusServiceClient milvusClient;
    private final QdrantClient qdrantClient;
    private final JdbcTemplate jdbcTemplate;

    /**
     * 主向量检索接口(带 CircuitBreaker 熔断降级)
     */
    @CircuitBreaker(name = "milvusSearch", fallbackMethod = "fallbackToQdrant")
    public List<VectorSearchResult> searchVectors(VectorSearchRequest request) {
        log.info("Executing Primary Vector Search via Milvus, Tenant: {}", request.getTenantId());

        // 1. 构建 Milvus 标量过滤表达式与搜索参数
        String expr = String.format("tenant_id == '%s' && category == '%s' && created_at >= %d", 
                request.getTenantId(), request.getCategory(), request.getMinTimestamp());

        SearchParam searchParam = SearchParam.newBuilder()
                .withCollectionName("doc_chunks_v1")
                .withMetricType(io.milvus.param.MetricType.COSINE)
                .withOutFields(List.of("doc_id", "chunk_content"))
                .withTopK(request.getTopK())
                .withVectors(List.of(request.getQueryVector()))
                .withVectorFieldName("embedding")
                .withExpr(expr)
                .withParams("{\"ef\": 64}") // HNSW 检索范围参数
                .build();

        var response = milvusClient.search(searchParam);
        if (response.getStatus() != 0) {
            throw new RuntimeException("Milvus Search Error: " + response.getMessage());
        }

        // 2. 解析结果
        SearchResultsWrapper wrapper = new SearchResultsWrapper(response.getData().getResults());
        List<VectorSearchResult> results = new ArrayList<>();
        List<SearchResultsWrapper.IDScore> scores = wrapper.getIDScore(0);

        for (int i = 0; i < scores.size(); i++) {
            SearchResultsWrapper.IDScore score = scores.get(i);
            results.add(VectorSearchResult.builder()
                    .docId(score.getStrID())
                    .score(score.getScore())
                    .content((String) wrapper.getFieldData("chunk_content", 0).get(i))
                    .sourceEngine("MILVUS")
                    .build());
        }
        return results;
    }

    /**
     * 一级降级:Qdrant 备用集群
     */
    public List<VectorSearchResult> fallbackToQdrant(VectorSearchRequest request, Throwable t) {
        log.warn("Milvus Primary failed! Falling back to Qdrant. Reason: {}", t.getMessage());
        try {
            Points.Filter filter = Points.Filter.newBuilder()
                    .addMust(Points.Condition.newBuilder()
                            .setField(Points.FieldCondition.newBuilder()
                                    .setKey("tenant_id")
                                    .setMatch(Points.Match.newBuilder().setKeyword(request.getTenantId()).build())
                                    .build())
                            .build())
                    .build();

            var searchPoints = Points.SearchPoints.newBuilder()
                    .setCollectionName("doc_chunks_v1")
                    .addAllVector(request.getQueryVector())
                    .setLimit(request.getTopK())
                    .setFilter(filter)
                    .setWithPayload(Points.WithPayloadSelector.newBuilder().setEnable(true).build())
                    .build();

            var qdrantResults = qdrantClient.searchAsync(searchPoints).get();
            List<VectorSearchResult> results = new ArrayList<>();
            for (Points.ScoredPoint point : qdrantResults) {
                results.add(VectorSearchResult.builder()
                        .docId(point.getId().getNum() + "")
                        .score(point.getScore())
                        .content(point.getPayloadMap().get("chunk_content").getStringValue())
                        .sourceEngine("QDRANT_FALLBACK")
                        .build());
            }
            return results;
        } catch (Exception e) {
            log.error("Qdrant Fallback failed, routing to final PGVector fallback", e);
            return fallbackToPgVector(request);
        }
    }

    /**
     * 二级降级:PostgreSQL + pgvector 保底
     */
    private List<VectorSearchResult> fallbackToPgVector(VectorSearchRequest request) {
        log.error("CRITICAL: All dedicated vector databases down! Executing PGVector Fallback Query.");
        String sql = """
            SELECT doc_id, chunk_content, 1 - (embedding <=> ?::vector) AS similarity
            FROM doc_chunks
            WHERE tenant_id = ? AND category = ?
            ORDER BY embedding <=> ?::vector
            LIMIT ?
            """;

        String vectorStr = request.getQueryVector().toString();
        return jdbcTemplate.query(sql, (rs, rowNum) -> VectorSearchResult.builder()
                .docId(rs.getString("doc_id"))
                .content(rs.getString("chunk_content"))
                .score(rs.getFloat("similarity"))
                .sourceEngine("PG_VECTOR_DEGRADED")
                .build(),
                vectorStr, request.getTenantId(), request.getCategory(), vectorStr, request.getTopK());
    }
}

四、避坑指南与总结验证

在生产落地与极限高并发压测过程中,我们踩过了无数深坑,总结出以下三条最致命的“生死教训”:

1. 坑点一:HNSW 索引构建导致 JVM/宿主机崩溃 (OOM Kill)

  • 故障现象:在全量导入 2000 万向量并触发创建 HNSW 索引时,Milvus 的 QueryNode 或 Qdrant 进程频频被 Linux OOM Killer 强行杀掉。
  • 根因分析:HNSW 是一种全内存图索引。构建索引时所需内存远超向量原始大小。公式如下:
    $$\text{Memory} \approx \text{Vector_Count} \times (\text{Dimension} \times 4 + M \times 8) \times 1.2$$
    对于 1536 维数据,$M=16$ 时,1000 万向量仅图结构和向量数据就需要接近 65GB 的物理内存。
  • 调优方案:
  • 绝不能在建表/建 Collection 时立即开启 HNSW。先批量导入数据,再异步触发索引构建。
  • 生产环境严格限制 M (16~32) 和 efConstruction (128~256) 参数,对于超大规模数据集,强制开启 SQ8 (Scalar Quantization) 量化,将 32 位浮点数压缩为 8 位整型,直接省去 70% 内存空间。

2. 坑点二:pgvector 的 hnsw 索引选择度陷阱

  • 故障现象:在 PostgreSQL 中为 embedding 字段建立了 HNSW 索引,但只要 SQL 语句加上 WHERE tenant_id = '1001',查询性能急剧恶化,甚至比全表扫描还慢。
  • 根因分析:pgvector 目前在处理高选择性标量条件时,优化器难以准确估算 HNSW 图遍历的 Cost。当 tenant_id = '1001' 的数据量占比较小时,pgvector 可能会遍历整张 HNSW 图,但绝大部分节点被 SQL 过滤掉,造成海量无效 I/O。
  • 调优方案:在 pgvector 中,对于多租户隔离场景,必须使用分区表 (Partitioning By List/Range),按 tenant_id 将物理数据隔离在不同的子表上,再针对子表单独建 HNSW 索引,彻底避免跨租户图遍历。

3. 生产压测验证性能收益

在 1000 万条 1536 维向量(模拟 OpenAI text-embedding-3-small 模型输出)数据集下,使用 JMeter 进行 100 并发线程压测(标量过滤选择率 5%),性能表现如下:

========================================================================================
Engine          Index Type     QPS (Peak)    P95 Latency(ms)  P99 Latency(ms)  Recall@10
----------------------------------------------------------------------------------------
pgvector 0.7.0  HNSW (m=16)    320           420              1850             94.2%
Qdrant 1.8.0    HNSW + Payload 2,850         18               35               98.1%
Milvus 2.4.0    HNSW + SQ8     4,200         8                14               97.8%
========================================================================================

总结建议

  1. < 100 万向量:首选 pgvector,利用现有的 PostgreSQL 设施,架构最简单,运维成本接近于零。
  2. 100 万 ~ 5000 万向量:首选 Qdrant,Rust 内核性能极佳,资源占用低,单机或轻量集群即可轻松支撑高并发。
  3. > 5000 万向量 / 极高并发:果断选择 Milvus,依靠其强大的存算分离与云原生架构,可灵活水平扩容 QueryNode 与 DataNode,结合 DiskANN 降低海量数据存储成本。
posted @ 2026-10-23 21:00  丨吴丨  阅读(13)  评论(0)    收藏  举报