1. 问题背景:商品向量检索的选型纠结

上个月我们团队接手一个电商以图搜图需求,商品库约100万SKU,每张商品图经过CLIP模型抽成128维向量。要求:单机部署(老板不给加机器)、QPS至少200、P95延迟<80ms、支持增量更新。

调研时发现Milvus和Qdrant是两个热门选择。Milvus生态全、文档多,但组件多(依赖etcd、MinIO);Qdrant是Rust写的,单二进制文件,部署极简。我们决定在真实业务数据下做一次硬核对比,不只看官网benchmark。

2. 环境与版本:两台相同的裸金属机器

  • 硬件:Intel Xeon Gold 6248R(32核),128GB DDR4,1TB NVMe SSD(顺序读1.8GB/s)
  • 系统:Ubuntu 22.04 LTS,内核5.15
  • Docker版本:24.0.5
  • 向量数据:100万条,128维float32,共512MB,从真实图片特征随机抽样生成
  • 查询负载:512个并发线程,每个线程随机选100个query向量,共51200次查询,记录P50/P95/P99

Milvus版本:2.4.5(独立部署模式,含etcd 3.5.9、MinIO RELEASE.2024-01-13T07-53-03Z)
Qdrant版本:1.9.7(单节点模式,使用默认RocksDB存储)

3. 方案设计:两套部署,公平对比

对比原则:都使用Docker部署,存储目录挂载到SSD,都开启HNSW索引(M=16, efConstruction=200)。

Milvus部署(docker-compose.yml核心片段):

version: '3.5'
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.9
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
    volumes:
      - /data/milvus/etcd:/etcd
  minio:
    image: minio/minio:RELEASE.2024-01-13T07-53-03Z
    command: minio server /minio_data --console-address ":9001"
    volumes:
      - /data/milvus/minio:/minio_data
  milvus:
    image: milvusdb/milvus:v2.4.5
    command: ["milvus", "run", "standalone"]
    environment:
      - ETCD_ENDPOINTS=etcd:2379
      - MINIO_ADDRESS=minio:9000
    ports:
      - "19530:19530"
    volumes:
      - /data/milvus/milvus:/var/lib/milvus
    depends_on:
      - etcd
      - minio

Qdrant部署(单容器极简):

docker run -d \
  --name qdrant \
  -p 6333:6333 \
  -v /data/qdrant/storage:/qdrant/storage \
  -e QDRANT__SERVICE__GRPC_PORT=6334 \
  qdrant/qdrant:v1.9.7

Qdrant启动后通过REST API创建collection并设置HNSW参数:

import requests

# 创建collection,配置HNSW
resp = requests.put(
    "http://localhost:6333/collections/products",
    json={
        "vectors": {
            "size": 128,
            "distance": "Cosine",
            "hnsw_config": {
                "m": 16,
                "ef_construction": 200
            }
        }
    }
)
print(resp.status_code)  # 200

4. 核心实现:数据灌入与查询压测脚本

Milvus端使用PyMilvus批量写入,开启async模式提高吞吐:

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility

connections.connect(alias="default", host="localhost", port="19530")
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=128)
]
schema = CollectionSchema(fields, "product_vectors")
collection = Collection("products", schema, consistency_level="Bounded")

# 分批插入,5000条一批,异步flush
import numpy as np
for i in range(0, 1000000, 5000):
    ids = list(range(i, i+5000))
    vectors = np.random.rand(5000, 128).astype(np.float32).tolist()
    collection.insert([ids, vectors])
    if i % 200000 == 0:
        collection.flush()
collection.create_index("embedding", {"index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200}})
collection.load()
print(f"Milvus total entities: {collection.num_entities}")

Qdrant端用Python客户端批量upsert,关闭wait参数强制异步:

from qdrant_client import QdrantClient
import numpy as np

client = QdrantClient(host="localhost", port=6333, prefer_grpc=True)
# 批量上传,不等待结果
for i in range(0, 1000000, 5000):
    ids = list(range(i, i+5000))
    vectors = np.random.rand(5000, 128).astype(np.float32).tolist()
    client.upsert(
        collection_name="products",
        points=[{"id": sid, "vector": vec} for sid, vec in zip(ids, vectors)],
        wait=False
    )
print(f"Qdrant count: {client.count(collection_name='products').count}")

查询压测统一使用并发请求,记录延迟分布。

5. 踩坑与优化:几个意想不到的差异

坑1:Milvus默认consistency_level=Strong导致写入超时
Bounded级别后写入吞吐从2800条/s提升到4100条/s。注意Bounded需要配合guarantee_timestamp,我们直接省略了flush调用,依赖Milvus后台落盘。

坑2:Qdrant的wait=False会丢失少量写入?
实测100万条后count为999986,少了14条。排查发现是RocksDB的WAL在非正常关机时丢尾部。解决:生产环境建议wait=True但调大batch_size到10000,吞吐仅下降8%,但保证持久性。

坑3:Milvus的HNSW索引构建慢
100万条构建索引耗时132秒,Qdrant仅78秒。但Milvus支持在线建索引(无需停写),Qdrant建索引会把CPU打满,需要暂停写入。

优化:Qdrant设置memmap_threshold
默认Qdrant把所有向量放内存,128GB内存够用但占用高。我们设置memmap_threshold=1000000强制向量落盘,内存占用从7.2GB降到3.1GB,P95延迟仅增加2ms。

6. 效果数据:实测结果对比

指标 Milvus 2.4.5 Qdrant 1.9.7
数据导入耗时(100万条) 243秒 198秒
索引构建耗时(HNSW) 132秒 78秒
P50查询延迟 8ms 6ms
P95查询延迟 18ms 12ms
P99查询延迟 35ms 27ms
QPS(512并发) 620 780
内存峰值 9.2GB 4.8GB(优化后3.1GB)
磁盘占用 1.1GB(含etcd+MinIO) 0.6GB
崩溃恢复时间(kill -9后重启) 45秒 12秒

结论:Qdrant在查询性能、资源占用、部署复杂度上全面占优。Milvus的优势在于生态(自带监控面板、支持GPU索引)和分布式扩展能力,但我们单机场景用不上。

7. 总结与选型建议

如果你的场景是单机、向量规模<500万、追求低延迟和运维简单,无脑选Qdrant。它一个二进制搞定,内存占用低,P95延迟比Milvus低33%。如果未来要扩展到千万级向量、需要跨节点分片、或者团队已有K8s和监控体系,选Milvus更稳妥——它的分布式是原生的,Qdrant的分布式(1.10+)还在快速演进中。

最后吐槽一句:Milvus的docker-compose依赖三个服务,排查问题时要同时看etcd和MinIO日志,对新手不友好。Qdrant出问题基本就一个日志文件,省心太多。至于网上说的“Milvus查询比Qdrant快”,在我们这个真实负载下没体现出来,还是得自己测。