1. 业务场景与选型困惑

我们团队负责一个UGC社区的“低俗内容AI拦截”系统。流程很简单:用户发帖 -> 文本向量化(SGPT-125M模型,768维)-> 存入向量库 -> 检索相似违规样本。起初用FAISS单机搞定,但数据量从百万涨到千万级后,单机内存吃紧,且需要支持实时增量。

于是开始评估Milvus和Qdrant。我个人的初步感知是:Milvus功能全、生态大,但架构重(依赖etcd、MinIO);Qdrant轻量,Rust写的,单二进制文件。但网上的对比文章大多是“玩具级”数据量,缺乏参考价值。于是决定在相同硬件上做一轮实测。

2. 环境与版本锁定

为了避免“版本不同导致结果差异”的争论,我固定了如下环境:

  • 硬件:2台物理机(1台部署,1台压测),每台配置:Intel Xeon Gold 6330(16核分配)、64GB RAM、NVMe SSD 1TB。
  • OS:Ubuntu 22.04 LTS,内核5.15。
  • Docker:24.0.7,docker-compose v2.24。
  • Milvus:2.4.5(standalone模式,内置etcd和MinIO)。
  • Qdrant:1.9.7(单节点,使用qdrant/qdrant镜像)。
  • 数据:500万条随机向量(768维,float32),外加10万条“违规样本”作为查询目标,距离度量使用余弦相似度。

为什么选这两个版本? Milvus 2.4.x是当前稳定版,支持GPU索引(虽然我没用);Qdrant 1.9.x引入了新的二进制量化,但这次没启用,保持公平。

3. 方案设计:两套部署文件与压测口径

我的压测脚本用Python concurrent.futures模拟100并发,持续10分钟,分别测试:

  • 写入测试:批量插入(每批100条),观察QPS和延迟。
  • 查询测试:固定topk=10,搜索10万条预置query向量。

关键设计:两个系统都使用HNSW索引(M=16, efConstruction=200)。但注意,Milvus需要先建collection再建索引,而Qdrant是在创建collection时指定。

Milvus部署(docker-compose.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
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://etcd: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
  standalone:
    image: milvusdb/milvus:v2.4.5
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    depends_on:
      - etcd
      - minio

Qdrant部署(docker-compose.yml)

version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.9.7
    ports:
      - "6333:6333"
    volumes:
      - ./qdrant_storage:/qdrant/storage
    command: ["qdrant", "--disable-telemetry"]

核心实现(Python写入示例)

# 以Milvus为例,Qdrant类似
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility

connections.connect(alias="default", host="localhost", port="19530")

# 删除旧collection(如果存在)
if utility.has_collection("content_audit"):
    utility.drop_collection("content_audit")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768)
]
schema = CollectionSchema(fields, description="content audit vectors")
col = Collection("content_audit", schema)

# 创建HNSW索引,注意这里的参数
index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200}
}
col.create_index("embedding", index_params)

# 批量写入(100条一批)
import numpy as np
for i in range(0, 5000000, 100):
    vectors = np.random.rand(100, 768).astype(np.float32)
    ids = list(range(i, i+100))
    col.insert([ids, vectors])
col.flush()
print("写入完成,实体数:", col.num_entities)

4. 踩坑与优化:两个系统各有一个大坑

Milvus的坑:索引参数不生效。我最初在创建索引时写了efConstruction=200,但查询时发现延迟高达80ms。后来检查Milvus的get_index_params,发现它默认用了ef=64(查询时的动态参数)。必须要在查询前显式设置search_params里的ef,否则会用默认值。而ef直接影响查询精度和延迟,我最终把ef设为128,延迟从80ms降到37ms,召回率从89%升到97%。

Qdrant的坑:内存爆掉。Qdrant默认会把所有向量加载到内存(on_disk=False)。500万条768维float32向量约15GB,加上HNSW图结构,轻松超过32GB。后来在创建collection时必须指定"on_disk": True,但这样查询延迟会从18ms飙升到45ms。最终折中方案:将HNSW的m从16降到8,并启用mmap模式,延迟回到23ms,内存稳定在12GB。

调优后的Qdrant collection配置

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, HnswConfigDiff

client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
    collection_name="content_audit",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(
        m=8,
        ef_construct=200,
        full_scan_threshold=10000,
        on_disk=True  # 关键:使用磁盘存储
    )
)

5. 效果数据:延迟、吞吐、资源占用对比

压测结果(100并发,10分钟稳定值):

指标 Milvus 2.4.5 Qdrant 1.9.7 差异
P99 查询延迟 37ms 23ms Qdrant 快 38%
P50 查询延迟 21ms 14ms Qdrant 快 33%
批量写入吞吐 8.2k QPS 4.8k QPS Milvus 高 42%
单条写入延迟(P99) 180ms 320ms Milvus 低 43%
内存占用(空闲) 8.2GB 5.1GB Qdrant 低 38%
内存占用(压测时) 18.5GB 12.3GB Qdrant 低 34%
磁盘占用 21GB(含etcd/minio) 16GB Qdrant 低 24%
部署组件数 3个容器(etcd/minio/standalone) 1个容器 Qdrant 简单

查询召回率:在ef=128(Milvus)和ef_construct=200(Qdrant)下,两者召回率均超过97%,无明显差异。

资源占用细看:Milvus的空闲内存8.2GB主要被etcd缓存和MinIO占用,实际向量引擎只用了4GB左右。Qdrant的5.1GB则纯粹是向量+HNSW图。如果数据量再翻倍到1000万,Qdrant可能更需要关注mmap的IO瓶颈,而Milvus则要小心etcd的磁盘空间。

6. 总结与最终选型

我的结论可能和部分“评测文”相反:

  • 如果侧重查询低延迟且数据量在千万级以下,选Qdrant。部署简单、内存可控,单容器就能跑,适合微服务架构。
  • 如果侧重写入吞吐、需要丰富的数据类型支持(如动态字段、分区),选Milvus。它虽然重,但原生支持批量写入的流式处理,且自带etcd保证了元数据一致性。
  • 如果团队已有Kafka/Pulsar数据管道,Milvus更友好,因为它有内置的消息队列抽象。

我们最终选择了Qdrant。原因很现实:查询延迟是业务硬指标(必须低于30ms),而写入可以通过增加消费者实例来缓解QPS差距。另外,运维只维护一个容器,省心多了。

最后提醒一句:向量数据库选型没有“银弹”,务必用真实业务数据量(至少百万级)和真实查询模式(比如带过滤条件)去压测。我这次没做带标量过滤的测试,如果你的场景有“分类ID + 向量”的混合检索,结论可能会反过来。