向量数据库选型与踩坑实录:Milvus vs Qdrant vs pgvector 生产横评

向量数据库选型与踩坑实录:Milvus vs Qdrant vs pgvector 生产横评

在 LLM + RAG(检索增强生成)技术全面落地的今天,向量数据库(Vector DB)已成为企业知识库、智能客服等场景的核心基础设施。然而,当数据量级从十万级飙升至千万级(1536 维度,如 OpenAI 的 text-embedding-3-large),很多团队在选型时因为盲目看重 Benchmark 跑分,在生产环境中交了高昂的学费。

写入吞吐雪崩、HNSW 索引构建导致 OOM、混合查询(向量 + 标量过滤)延迟飙升、多组件运维成本高企……这些都是一线的真实痛点。本文将基于我主导的千万级企业知识库项目,对市面上最主流的三款向量数据库 Milvus、Qdrant 和 pgvector (PostgreSQL) 进行全方位的生产级横评。


一、 问题背景与业务痛点

在我们的千万级企业知识库项目中,文档切片(Chunks)总量达到了 1500 万。每个切片通过 Embedding 模型转化为 1536 维的浮点数向量。业务上要求支持多租户隔离、实时增删改以及混合检索(例如:在指定 tenant_id = 9527 且 security_level >= 3 的条件下,检索相似度前 5 的知识点)。

在早期技术选型和压测阶段,我们遭遇了以下三个致命痛点:

  1. 混合查询的“伪索引”塌陷:
    传统的“先过滤、后检索(Post-filtering)”会导致召回不足(Limit 5 过滤后可能只剩 1 个);而“先检索、后过滤(Pre-filtering)”如果不支持高效的标量联合索引,在千万级数据下会导致全表扫描。
  2. HNSW 索引构建的内存黑洞:
    HNSW(Hierarchical Navigable Small World)是目前召回率和耗时平衡最好的索引算法。但它极其消耗内存。在千万级 1536 维向量下,构建索引时内存直接飙升了数倍,导致容器频繁被 OOM Killer 杀掉。
  3. 运维复杂度与高可用代价:
    Milvus 采用微服务架构,包含 QueryNode、IndexNode、DataNode、Proxy,还要依赖 MinIO、Pulsar/Kafka、Etcd 等一众组件。对于中小型研发团队,维护这套架构的成本甚至超过了业务开发本身。

二、 核心设计与解决思路

为了解决上述痛点,我们需要设计一套高并发、低延迟且具备多租户隔离能力的 RAG 向量检索架构。

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

在系统设计上,我们采用统一的向量网关屏蔽底层差异,通过路由策略支持多租户的数据分发。

▲ 架构图 1:系统核心组件交互拓扑与数据流向
▲ 架构图 1:系统核心组件交互拓扑与数据流向

2. 端到端请求执行时序图

以下是用户发起提问到向量检索、标量过滤,再到最终召回的完整端到端链路。

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

3. 核心方案选型对比

基于我们在生产环境(千万级 1536 维向量,HNSW 索引,M=16, EF=64)的压测数据,三者的横向对比如下:

对比维度 Milvus (v2.4.x) Qdrant (v1.9.x) pgvector (v0.7.x)
开发语言 Go / C++ Rust C (PostgreSQL 插件)
写入吞吐 (TPS) 约 8,500 (批量导入极强) 约 12,000 (高并发写入优秀) 约 3,200 (受限 PG 事务与 WAL)
HNSW 索引构建速度 较快 (支持多 IndexNode 并行) 极快 (Rust 线程调度优秀) 较慢 (构建时单 CPU 瓶颈明显)
内存占用 (1000万数据) 约 32GB (组件多,静态开销大) 约 18GB (内存管理极度精细) 约 28GB (共享缓冲区开销大)
标量过滤性能 优秀 (支持 Field Index) 极佳 (Payload Index 深度优化) 优秀 (利用 PG 原生 B-Tree 索引)
运维复杂度 极高 (需要维护 K8s/Helm/多个组件) 极低 (单二进制文件/开箱即用) 低 (已有 PG 运维经验即可)
适用场景 亿级以上、有专业运维团队的场景 千万级、追求高性能与低运维成本 十万到百万级、已有 PG 且不想引入新组件

三、 完整实战代码与配置

在千万级 RAG 场景中,Qdrant 凭借 Rust 的极致性能、极低的内存占用以及优秀的标量过滤设计,成为了我们最终的生产选型。下面给出基于 Spring Boot 3.x、JDK 17 以及 Qdrant Java SDK 实现的高性能混合检索实战代码。

1. 依赖配置 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.4</version>
    </parent>

    <groupId>com.mrwu.rag</groupId>
    <artifactId>vector-db-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>17</java.version>
        <qdrant.client.version>1.9.0</qdrant.client.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- Qdrant gRPC 客户端 -->
        <dependency>
            <groupId>io.qdrant</groupId>
            <artifactId>client</artifactId>
            <version>${qdrant.client.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

2. 配置文件 application.yml

server:
  port: 8080

spring:
  application:
    name: rag-vector-service

vector:
  qdrant:
    host: "192.168.11.20"
    port: 6334 # gRPC 默认端口
    use-tls: false
    api-key: "your-super-secret-api-key"
    collection-name: "enterprise_knowledge"

3. 向量检索核心服务 QdrantSearchService.java

package com.mrwu.rag.service;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.JsonWithDouble;
import io.qdrant.client.grpc.Points.*;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;

import static io.qdrant.client.ValueFactory.value;

@Slf4j
@Service
public class QdrantSearchService {

    @Value("${vector.qdrant.host}")
    private String host;

    @Value("${vector.qdrant.port}")
    private int port;

    @Value("${vector.qdrant.use-tls}")
    private boolean useTls;

    @Value("${vector.qdrant.api-key}")
    private String apiKey;

    @Value("${vector.qdrant.collection-name}")
    private String collectionName;

    private QdrantClient client;

    @PostConstruct
    public void init() {
        log.info("Initializing Qdrant Client to {}:{}", host, port);
        QdrantGrpcClient.Builder grpcClientBuilder = QdrantGrpcClient.newBuilder(host, port, useTls);
        if (apiKey != null && !apiKey.isBlank()) {
            grpcClientBuilder.withApiKey(apiKey);
        }
        this.client = new QdrantClient(grpcClientBuilder.build());
    }

    /**
     * 执行高性能混合检索(向量相似度 + 标量多租户过滤)
     *
     * @param queryVector 1536维查询向量
     * @param tenantId    租户ID(标量过滤条件)
     * @param minScore    最低相似度阈值
     * @param limit       返回数量
     * @return 检索出的文档片段
     */
    public List<Map<String, Object>> hybridSearch(List<Float> queryVector, String tenantId, double minScore, int limit) {
        long startTime = System.currentTimeMillis();
        List<Map<String, Object>> results = new ArrayList<>();

        try {
            // 1. 构建标量过滤器 (Filter: tenant_id == tenantId)
            Filter filter = Filter.newBuilder()
                    .addMust(Condition.newBuilder()
                            .setField(FieldCondition.newBuilder()
                                    .setKey("tenant_id")
                                    .setMatch(Match.newBuilder().setKeyword(tenantId).build())
                                    .build())
                            .build())
                    .build();

            // 2. 构建 SearchPoints 请求
            SearchPoints searchPoints = SearchPoints.newBuilder()
                    .setCollectionName(collectionName)
                    .addAllVector(queryVector)
                    .setFilter(filter)
                    .setLimit(limit)
                    .setWithPayload(WithPayloadSelector.newBuilder().setEnable(true).build()) // 召回 Payload
                    .setScoreThreshold((float) minScore) // 过滤低相关度结果
                    .build();

            // 3. 执行 gRPC 调用
            List<ScoredPoint> scoredPoints = client.searchAsync(searchPoints).get();

            // 4. 解析结果
            for (ScoredPoint point : scoredPoints) {
                Map<String, JsonWithDouble.Value> payloadMap = point.getPayloadMap();
                // 转换 Payload 数据
                log.debug("Point ID: {}, Score: {}", point.getId().getUuid(), point.getScore());
                results.add(Map.of(
                        "doc_id", point.getId().getUuid(),
                        "score", point.getScore(),
                        "content", payloadMap.get("content").getStringValue(),
                        "tenant_id", payloadMap.get("tenant_id").getStringValue()
                ));
            }

        } catch (InterruptedException | ExecutionException e) {
            log.error("Failed to execute hybrid search in Qdrant", e);
            Thread.currentThread().interrupt();
            throw new RuntimeException("Vector database query error", e);
        } finally {
            log.info("Hybrid search executed in {} ms", System.currentTimeMillis() - startTime);
        }

        return results;
    }

    @PreDestroy
    public void close() {
        if (client != null) {
            log.info("Closing Qdrant Client...");
            client.close();
        }
    }
}

四、 避坑指南与总结验证

在千万级生产落地过程中,我们踩过了不少深坑。以下是提炼出的黄金避坑指南:

1. pgvector:maintenance_work_mem 导致的索引构建崩溃

  • 痛点:在 PostgreSQL 中使用 CREATE INDEX ... USING hnsw 创建千万级 1536 维向量索引时,数据库频繁发生 Connection reset 或 OOM 崩溃。
  • 原因:pgvector 在构建 HNSW 索引时,需要将大量的图节点缓存在内存中。默认的 maintenance_work_mem 通常只有 64MB,这会导致频繁的磁盘交换,甚至因为内存不足直接 Crash。
  • 解决办法:
    在构建索引前,必须调大该会话的内存限制。对于 16GB 内存的数据库实例,建议将其临时调大至 4GB:
    sql SET maintenance_work_mem = '4GB'; CREATE INDEX ON enterprise_knowledge USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

2. Qdrant:Mmap 内存暴涨与磁盘 I/O 恶化

  • 痛点:Qdrant 运行一段时间后,系统物理内存几乎被占满,同时伴随大量的磁盘读 I/O,导致查询延迟从 10ms 飙升至 500ms 以上。
  • 原因:Qdrant 默认开启了 mmap(内存映射文件),允许将索引直接映射到虚拟内存中。当数据量远超物理内存大小时,操作系统会频繁进行 Page Fault(缺页中断),导致磁盘 I/O 成为致命瓶颈。
  • 解决办法:
    在 Qdrant 的配置文件 config.yaml 中,合理配置 memmap_threshold_kb。对于千万级数据,建议将向量数据强制加载到内存中,而将 Payload(标量数据)留在磁盘:
    yaml storage: # 当向量数量较多时,避免将所有向量索引都丢到 mmap performance: max_search_threads: 0 vector_index: enable_memmap: false # 保证 HNSW 索引完全在内存中,换取极致检索速度

3. Milvus:标量过滤的“隐式类型转换”全表扫描

  • 痛点:在 Milvus 中执行 tenant_id == "9527" 过滤时,明明为 tenant_id 建立了 INVERTED(倒排)索引,但查询耗时依然长达数百毫秒。
  • 原因:Milvus 的 Schema 定义中,如果 tenant_id 被定义为 VARCHAR 类型,但在 Java 查询中传入了 Long 型的 9527,Milvus 在解析 DSL 表达式时会发生隐式类型转换,导致索引失效,退化为全表扫描。
  • 解决办法:
    严格保证客户端入参与 Milvus Schema 定义的数据类型完全一致。在 Java 代码中,显式将参数转换为 String:
    java // 错误写法:filter = "tenant_id == 9527" // 正确写法: String filter = "tenant_id == \"9527\"";

总结

  • Qdrant 是千万级 RAG 知识库的 性价比之王。它用极低的内存开销(Rust 强项)和极简的单机/集群运维成本,扛住了我们日均千万次的混合检索请求,P99 延迟稳定在 15ms 以内。
  • Milvus 适合亿级以上、拥有专职 DBA/运维团队的大厂场景。它的分布式架构上限极高,但小规模团队引入它无异于“大炮打蚊子”。
  • pgvector 则是 PostgreSQL 忠实拥趸的福音。在千万级以下、对事务一致性要求极高的业务中,pgvector 能让你少维护一个数据库组件,但在超大规模下,其性能和内存开销确实略逊于纯 Rust/C++ 编写的专业向量库。
posted @ 2026-10-01 22:51  丨吴丨  阅读(4)  评论(0)    收藏  举报