一、问题背景:为什么要在Milvus和Qdrant之间做选择

我们做的是一个内容平台的图文去重服务。每天新增约80万条内容,每条内容经过CLIP模型编码成768维向量,需要在历史约1000万条向量中找出Top-10相似项,相似度超过阈值就判定为重复或高度相似。

早期我们用FAISS做离线批处理,但随着业务变成准实时(新内容入库后1秒内要能查到),FAISS的“无服务、无持久化、无并发管理”问题就暴露了:

  • 索引需要自己序列化加载,进程重启后恢复慢;
  • 多进程并发查询要自己做分片和合并;
  • 增删改需要重建索引,无法增量更新。

于是我们转向专业向量数据库。候选里最主流的是Milvus和Qdrant:

  • Milvus:CNCF毕业项目,生态最大,支持FLAT、IVF、HNSW、DiskANN等多种索引,天然支持分布式;
  • Qdrant:Rust编写,单机性能强,过滤(payload filter)能力突出,部署极简。

网上很多文章只讲“Hello World”式插入查询,缺少同机同数据的横向对比。所以我决定自己做一次实测,把部署、代码、性能、资源全部记录下来。

二、环境与版本

测试机配置:

  • CPU:AMD EPYC 7B13,16核
  • 内存:64GB
  • 磁盘:NVMe SSD 1TB
  • OS:Ubuntu 22.04 LTS

软件版本:

  • Docker:24.0.7
  • Milvus:2.4.0 Standalone(milvusdb/milvus:v2.4.0)
  • Qdrant:1.9.0(qdrant/qdrant:v1.9.0)
  • Python SDK:pymilvus==2.4.0,qdrant-client==1.9.0
  • 向量数据:1000万条,768维,float32,随机生成并做归一化

统一使用HNSW索引,距离度量用COSINE,因为我们的向量已归一化,余弦和内积等价。

三、方案设计与部署步骤

3.1 Milvus Standalone 部署

Milvus Standalone依赖etcd和MinIO,官方提供docker-compose。我使用了官方脚本:

wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d

启动后容器情况:

milvus-standalone   milvusdb/milvus:v2.4.0
milvus-minio        minio/minio:RELEASE.2023-03-20T20-16-18Z
milvus-etcd         quay.io/coreos/etcd:v3.5.5

Milvus默认端口19530(gRPC)和9091(metrics)。注意:Milvus Standalone本身内存占用就不低,启动后空载约1.2GB。

3.2 Qdrant 部署

Qdrant单容器即可,无需外部依赖:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v /data/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.9.0

6333是HTTP/REST,6334是gRPC。空载内存约80MB。这一点差异非常明显。

3.3 索引参数设计

两者都用HNSW,参数尽量对齐:

  • M = 16
  • ef_construct = 200
  • ef_search = 128(查询时)

Milvus的HNSW参数写在index_params里,ef_search通过search_params传入。Qdrant在collection创建时配置hnsw_config,查询时用hnsw_ef。

四、核心实现代码

4.1 数据写入:Milvus

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np

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

dim = 768
collection_name = "dedup_milvus"

if utility.has_collection(collection_name):
    utility.drop_collection(collection_name)

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=dim),
]
schema = CollectionSchema(fields, description="dedup vectors")
col = Collection(collection_name, schema, consistency_level="Bounded")

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200},
}
col.create_index(field_name="vec", index_params=index_params)
col.load()

# 分批写入,每批10万
BATCH = 100000
TOTAL = 10_000_000
rng = np.random.default_rng(42)

for start in range(0, TOTAL, BATCH):
    n = min(BATCH, TOTAL - start)
    vecs = rng.random((n, dim), dtype=np.float32)
    # 归一化
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    ids = list(range(start, start + n))
    col.insert([ids, vecs.tolist()])
    if start % 1_000_000 == 0:
        print(f"inserted {start + n}")

col.flush()
print("milvus num entities:", col.num_entities)

4.2 数据写入:Qdrant

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, HnswConfigDiff, PointStruct
import numpy as np

client = QdrantClient(host="localhost", port=6333)
collection_name = "dedup_qdrant"

client.recreate_collection(
    collection_name=collection_name,
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)

BATCH = 10000
TOTAL = 10_000_000
rng = np.random.default_rng(42)

for start in range(0, TOTAL, BATCH):
    n = min(BATCH, TOTAL - start)
    vecs = rng.random((n, 768), dtype=np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    points = [
        PointStruct(id=start + i, vector=vecs[i].tolist())
        for i in range(n)
    ]
    client.upsert(collection_name=collection_name, points=points, wait=False)
    if start % 1_000_000 == 0:
        print(f"upserted {start + n}")

print("qdrant count:", client.count(collection_name).count)

注意Qdrant的upsert是异步的,wait=False可以大幅提升写入吞吐,但最后要等索引构建完成再测查询。

4.3 查询对比

import time, numpy as np

def bench_milvus(col, queries, topk=10, ef=128):
    params = {"metric_type": "COSINE", "params": {"ef": ef}}
    lat = []
    for q in queries:
        t0 = time.perf_counter()
        col.search([q.tolist()], "vec", param=params, limit=topk)
        lat.append((time.perf_counter() - t0) * 1000)
    return np.percentile(lat, [50, 95, 99])

def bench_qdrant(client, name, queries, topk=10, ef=128):
    lat = []
    for q in queries:
        t0 = time.perf_counter()
        client.search(name, query_vector=q.tolist(), limit=topk, search_params={"hnsw_ef": ef})
        lat.append((time.perf_counter() - t0) * 1000)
    return np.percentile(lat, [50, 95, 99])

五、踩坑与优化

坑1:Milvus写入后必须flush+load才能稳定查询。 不flush时数据在Growing Segment,查询走暴力扫描,延迟波动极大。我们一开始测出P99超过300ms,后来发现是没等索引构建完。正确做法是insert后flush,再等index building完成。

坑2:Qdrant的wait=False写入太快会堆积。 批量upsert如果不等,内存会涨,索引后台构建跟不上。建议每100万条做一次client.count确认,或最后调用一次wait=True

坑3:ef_search对两者影响都很大。 ef从64提到256,召回率从0.91升到0.98,但P99延迟几乎翻倍。我们业务最终取ef=128,召回0.96,可接受。

坑4:Milvus内存占用高。 1000万×768维float32原始向量约30GB,Milvus加载索引后RSS达到约38GB,接近我们64GB机器的一半。Qdrant同样数据RSS约22GB,明显更省。

六、效果数据

1000万条768维向量,HNSW(M=16, ef_construct=200),ef_search=128,单线程顺序查询1000次:

指标 Milvus 2.4.0 Qdrant 1.9.0
写入1000万耗时 约42分钟 约28分钟
索引构建耗时 约18分钟 约11分钟
P50延迟 8.2ms 5.1ms
P95延迟 21ms 12ms
P99延迟 34ms 19ms
单线程QPS 约118 约190
空载内存 1.2GB 80MB
加载后RSS 约38GB 约22GB
磁盘占用 约34GB 约31GB
召回率@10 0.962 0.958

并发测试(32线程压测)下,Milvus QPS约2100,Qdrant约3400。两者召回率接近,Qdrant在延迟和资源上全面占优;Milvus在分布式、多索引类型、生态集成上更强。

七、总结

如果业务是单机、千万级向量、对延迟和内存敏感,Qdrant是更务实的选择:部署一个容器、无需etcd/MinIO、资源占用低、P99延迟19ms。如果业务需要横向扩展到亿级、需要多种索引(DiskANN、GPU)、需要和Flink/Spark生态打通,Milvus更合适,代价是运维复杂度和资源开销更高。

我们最终选型:当前阶段用Qdrant单机,预留迁移到Milvus分布式的能力。选型没有绝对优劣,只有和业务阶段是否匹配。建议你在自己的数据分布上复现这套压测,因为HNSW的实际表现和向量维度、数据分布、过滤条件强相关。