一、问题背景:为什么我们被迫换掉向量数据库

我们团队在做电商平台的以图搜图+语义搜索功能,向量数据规模从年初的20万涨到现在的100万,原来的方案是直接用Elasticsearch的dense_vector插件。但到了80万条数据时,ES的查询延迟从80ms飙升到300ms+,而且每次全量索引重建要花40分钟。更难受的是,ES的内存吃相太难看,16G的机器跑ES+向量索引直接OOM。

我们决定引入专业向量数据库,候选是Milvus和Qdrant。为什么是这两个?因为Faiss只是库不是服务,需要自己封装;Weaviate和Pinecone要么太重要么是托管服务。我们的核心诉求就三条:部署简单、查询P99延迟<150ms、内存占用可控

二、环境与版本:同样的机器,不同的命运

测试环境统一使用腾讯云CVM标准型S5,配置如下:

  • CPU:8核 Intel Xeon Platinum 8255C(2.5GHz)
  • 内存:16GB DDR4
  • 磁盘:200GB SSD(IOPS上限3000)
  • 系统:Ubuntu 22.04 LTS,内核5.15
  • Docker版本:24.0.5,docker-compose版本:2.20.2

数据库版本选当时(2024年6月)的最新稳定版:

  • Milvus 2.4.1(standalone模式,用etcd+minio)
  • Qdrant 1.9.2(单节点模式)

这里有个关键点:Milvus 2.4开始默认集成了新的Cardinal优化器,官方说在过滤场景下性能提升2倍,我们后面会验证这个说法。

三、方案设计:不搞花活,贴近生产

我们模拟的是真实RAG场景:商品库有100万条数据,每条包含商品ID、标题、类目、价格、图片向量(768维)。查询模式有两种:

  1. 纯向量查询:输入一个查询向量,返回TopK=10的结果
  2. 混合查询:向量相似度 + 过滤条件(类目=“数码”,价格<5000)

索引配置上,两个库都采用HNSW算法(Milvus的HNSW参数:M=16, efConstruction=200;Qdrant同样参数)。距离度量用余弦相似度。为了公平,两个库都用默认的量化策略(Milvus用Float,Qdrant用Float),不启用标量量化。

数据导入方式:Python脚本读CSV,批量插入。Milvus用pymilvusinsert接口,Qdrant用qdrant-clientupsert。都开批量事务(batch_size=1024)。

四、核心实现:部署与压测代码

4.1 Milvus部署(docker-compose)

# docker-compose-milvus.yml
version: '3.5'
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd

  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  standalone:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    ports:
      - "19530:19530"
      - "9091:9091"
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    depends_on:
      - etcd
      - minio

启动命令:docker-compose -f docker-compose-milvus.yml up -d

4.2 Qdrant部署(docker-compose)

# docker-compose-qdrant.yml
version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.9.2
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/qdrant_storage:/qdrant/storage
    environment:
      QDRANT__SERVICE__GRPC_PORT: 6334
      QDRANT__STORAGE__OPTIMIZER__DEFAULT_SEGMENT_NUMBER: 2
    ulimits:
      nofile:
        soft: 65535
        hard: 65535

启动命令:docker-compose -f docker-compose-qdrant.yml up -d

注意Qdrant的DEFAULT_SEGMENT_NUMBER参数,默认是0(自动),但实际测试中手动设为2能让并发查询性能更稳定,原因后面讲。

4.3 压测代码(Python + Locust)

这里用Locust做并发压测,脚本比ab更灵活。核心逻辑如下:

from locust import User, task, between
from pymilvus import connections, Collection
from qdrant_client import QdrantClient
import numpy as np
import random

class VectorSearchUser(User):
    wait_time = between(0.1, 0.5)

    def on_start(self):
        # 初始化两个客户端
        self.milvus_conn = connections.connect(alias="default", host="localhost", port="19530")
        self.milvus_col = Collection("product_embedding")
        self.milvus_col.load()

        self.qdrant_client = QdrantClient(host="localhost", port=6333)
        # 预生成1000个随机查询向量
        self.query_vectors = [np.random.rand(768).astype(np.float32) for _ in range(1000)]
        self.query_idx = 0

    @task(30)  # 70%概率走Milvus纯向量查询
    def search_milvus(self):
        vec = self.query_vectors[self.query_idx % 1000]
        self.query_idx += 1
        # 纯向量查询,TopK=10
        results = self.milvus_col.search(
            data=[vec],
            anns_field="embedding",
            param={"metric_type": "COSINE", "params": {"ef": 64}},
            limit=10,
            output_fields=["product_id", "title"]
        )

    @task(20)
    def search_qdrant(self):
        vec = self.query_vectors[self.query_idx % 1000]
        self.query_idx += 1
        results = self.qdrant_client.search(
            collection_name="product_embedding",
            query_vector=vec,
            limit=10,
            with_payload=True
        )

    @task(10)  # 混合查询:过滤类目和价格
    def search_milvus_filter(self):
        vec = self.query_vectors[self.query_idx % 1000]
        self.query_idx += 1
        expr = 'category == "数码" and price < 5000'
        results = self.milvus_col.search(
            data=[vec],
            anns_field="embedding",
            param={"metric_type": "COSINE", "params": {"ef": 64}},
            limit=10,
            expr=expr,
            output_fields=["product_id", "price"]
        )

    @task(10)
    def search_qdrant_filter(self):
        vec = self.query_vectors[self.query_idx % 1000]
        self.query_idx += 1
        query_filter = models.Filter(
            must=[
                models.FieldCondition(key="category", match=models.MatchValue(value="数码")),
                models.FieldCondition(key="price", range=models.Range(lt=5000))
            ]
        )
        results = self.qdrant_client.search(
            collection_name="product_embedding",
            query_vector=vec,
            query_filter=query_filter,
            limit=10
        )

运行方式:locust -f load_test.py --host http://localhost:8089 --users 200 --spawn-rate 20 --run-time 10m

五、踩坑与优化:内存优化器是个坑

5.1 Milvus的“内存不足”假死

第一次压测时,Milvus在并发200时直接挂了,日志显示Out of memory。排查发现是HNSW的efConstruction设太高(默认300),导致索引构建时内存峰值爆炸。后来把efConstruction降到200,并在collection配置里加了"mmap": True(2.4版本支持),内存占用降了35%。

5.2 Qdrant的段合并问题

Qdrant默认每10万条数据自动分一个segment,但段太多会导致查询时IO竞争。我们通过DEFAULT_SEGMENT_NUMBER=2强制合并为2个大段,P99延迟从110ms降到85ms。代价是导入时CPU占用高,但查询性能提升明显。

5.3 关键调参差异

  • Milvus的ef(搜索宽度):64时召回率98.2%,128时99.1%,但QPS降30%。我们最终用ef=96做折中
  • Qdrant的hnsw_ef:默认100,调高到200时召回率从97.8%到99.3%,但内存增加200MB
  • 两个库都默认用cosine距离,但注意Qdrant的cosine要求向量已归一化,否则会额外做一次归一化计算影响性能

六、效果数据:用数据说话

压测10分钟,并发200,结果如下:

指标 Milvus 2.4.1 Qdrant 1.9.2
纯向量QPS 1850 1020
纯向量P99延迟 88ms 120ms
混合查询QPS 720 450
混合查询P99延迟 145ms 210ms
内存占用(空闲) 3.2GB 1.8GB
内存占用(满载) 6.8GB 3.9GB
索引构建时间(1M条) 28分钟 22分钟
索引体积 2.1GB 1.6GB
召回率(Top10@100) 98.6% 98.1%

结论:

  1. 性能:Milvus在纯向量查询上QPS领先Qdrant 81%,这得益于2.4版本的Cardinal优化器。但在混合查询上优势缩小到60%,因为Milvus的过滤需要走表达式解析,Qdrant的Filter结构更高效。

  2. 资源:Qdrant完胜。内存占用只有Milvus的57%,索引体积小23%。如果你的机器内存紧张(8G以下),Qdrant是更安全的选择。

  3. 易用性:Milvus的API更符合传统数据库习惯(Collection/Field概念),但依赖etcd+minio两个中间件,部署复杂。Qdrant就一个容器,docker run就完事。

  4. 生态:Milvus有官方WebUI(Attu),数据管理方便。Qdrant的WebUI较弱,但支持gRPC接口,延迟更低。

最终选择:我们生产环境用了Milvus,因为我们的查询压力主要在纯向量检索(占比70%),且需要和现有的数据血缘系统集成(Milvus的元数据管理更强)。但如果你做轻量级应用、内存有限,我强烈建议用Qdrant,省心得多。

以上,都是真实数据,希望能帮你在2024年做出正确的选择。有问题评论区聊。