一、问题背景

我们团队做的是一个图文内容社区,日增图片约80万张。为了防止用户重复上传相同或高度相似的图片,需要构建一套向量检索服务。具体需求是:对每张新上传的图片提取CLIP特征(512维),在已有向量库中检索Top-10最相似的图片,如果余弦相似度超过0.92则判定为重复。

这里有几个硬性约束:
- 向量规模:当前存量约1200万,半年内预计到3000万
- QPS:写入峰值约50/s,查询峰值约200/s
- 延迟要求:P99 < 50ms
- 硬件预算:单节点,32核64GB内存,NVMe SSD

候选方案就是Milvus和Qdrant。网上文章要么是官方文档翻译,要么是跑个几万向量的玩具测试,没有参考价值。我决定自己动手,在同一台机器上把两个都部署一遍,用真实数据压测。

二、环境与版本

  • 操作系统:Ubuntu 22.04 LTS
  • CPU:AMD EPYC 7543 32核
  • 内存:64GB DDR4
  • 磁盘:1TB NVMe SSD
  • Docker:24.0.7
  • Milvus:2.4.1(standalone模式)
  • Qdrant:1.9.0
  • Python客户端:pymilvus==2.4.1,qdrant-client==1.9.0
  • 数据集:1200万条512维float32向量,随机生成但做了聚类,模拟真实分布

需要说明的是,Milvus standalone模式虽然叫“单机”,但内部依然依赖etcd和MinIO,实际是三个容器。Qdrant则是纯单进程,依赖极少。

三、方案设计

两个库都采用HNSW索引,因为这是当前在召回率和延迟之间平衡最好的选择。参数尽量对齐:

  • HNSW M:16
  • efConstruction:200
  • efSearch:64
  • 距离度量:Cosine
  • 向量维度:512

Milvus的collection需要指定分片数,我设为2(单机模式下分片过多反而增加开销)。Qdrant的collection不需要分片概念,但可以设置segment数量,默认即可。

写入方式:两个库都采用批量写入,每批1000条,共12000批。查询测试:从数据集中随机抽10000条向量作为查询集,测Top-10召回。

四、核心实现

4.1 部署步骤

Milvus 2.4.1 standalone部署

# 下载官方docker-compose
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose-milvus.yml

# 启动
docker compose -f docker-compose-milvus.yml up -d

# 检查状态
docker ps | grep milvus

启动后有三个容器:milvus-standalone、milvus-etcd、milvus-minio。默认端口19530。

Qdrant 1.9.0部署

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.9.0

就这一个命令,没有额外依赖。端口6333是HTTP,6334是gRPC。

4.2 建表与写入代码

Milvus端

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

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

# 定义schema
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=512)
]
schema = CollectionSchema(fields=fields, description="image_dedup")
collection = Collection(name="image_vectors", schema=schema)

# 创建HNSW索引
index_params = {
    "metric_type": "COSINE",
    "index_type": "HNSW",
    "params": {"M": 16, "efConstruction": 200}
}
collection.create_index(field_name="embedding", index_params=index_params)

# 批量写入
batch_size = 1000
for i in range(0, 12000000, batch_size):
    ids = list(range(i, i+batch_size))
    vectors = np.random.randn(batch_size, 512).astype(np.float32)
    # 归一化,因为用COSINE
    vectors = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
    collection.insert([ids, vectors.tolist()])
    if i % 1000000 == 0:
        print(f"Inserted {i} vectors")

collection.flush()

Qdrant端

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

client = QdrantClient(host='localhost', port=6333)

# 创建collection
client.recreate_collection(
    collection_name="image_vectors",
    vectors_config=VectorParams(size=512, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200)
)

# 批量写入
batch_size = 1000
for i in range(0, 12000000, batch_size):
    vectors = np.random.randn(batch_size, 512).astype(np.float32)
    vectors = vectors / np.linalg.norm(vectors, axis=1, keepdims=True)
    points = [
        PointStruct(id=i+j, vector=vectors[j].tolist())
        for j in range(batch_size)
    ]
    client.upsert(collection_name="image_vectors", points=points)
    if i % 1000000 == 0:
        print(f"Inserted {i} vectors")

4.3 查询代码

Milvus查询

collection.load()
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}

query_vectors = np.random.randn(10000, 512).astype(np.float32)
query_vectors = query_vectors / np.linalg.norm(query_vectors, axis=1, keepdims=True)

import time
latencies = []
for vec in query_vectors:
    start = time.perf_counter()
    results = collection.search(
        data=[vec.tolist()],
        anns_field="embedding",
        param=search_params,
        limit=10
    )
    latencies.append((time.perf_counter() - start) * 1000)

print(f"P50: {np.percentile(latencies, 50):.2f}ms")
print(f"P99: {np.percentile(latencies, 99):.2f}ms")

Qdrant查询

latencies = []
for vec in query_vectors:
    start = time.perf_counter()
    results = client.search(
        collection_name="image_vectors",
        query_vector=vec.tolist(),
        limit=10,
        search_params={"hnsw_ef": 64}
    )
    latencies.append((time.perf_counter() - start) * 1000)

print(f"P50: {np.percentile(latencies, 50):.2f}ms")
print(f"P99: {np.percentile(latencies, 99):.2f}ms")

五、踩坑与优化

Milvus的坑:

第一个坑是插入速度。Milvus在插入过程中如果频繁flush,会触发segment合并,导致写入性能骤降。我一开始每100万条flush一次,结果写入到800万时几乎卡死。后来改成全部插完再flush,写入时间从预计的40分钟降到18分钟。

第二个坑是内存。Milvus standalone默认配置下,加载1200万512维向量后,内存占用直接冲到11.5GB,其中MinIO和etcd还额外占了约1.5GB。如果内存不够,查询会触发磁盘swap,延迟飙升到200ms以上。需要在milvus.yaml里调整queryNode.cacheSize,但调小又影响召回速度。

第三个坑是collection.load()。如果没调用这个,查询会直接报错。而且load之后修改索引参数需要先release再load,生产环境慎用。

Qdrant的坑:

Qdrant的写入速度明显更快,1200万条只用了12分钟。但有个问题是默认的segment数量会随着数据增长自动分裂,查询时会并行搜索多个segment,导致延迟波动。可以在创建collection时设置optimizers_config来限制segment数量:

from qdrant_client.models import OptimizersConfigDiff

client.recreate_collection(
    collection_name="image_vectors",
    vectors_config=VectorParams(size=512, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
    optimizers_config=OptimizersConfigDiff(
        default_segment_number=4,
        max_segment_size=2000000
    )
)

这样设置后,P99延迟从35ms降到23ms,效果明显。

另外Qdrant的search_paramshnsw_ef默认是100,我设成64后延迟降低但召回率略降。实测召回率从0.981降到0.976,可以接受。

六、效果数据

以下是1200万向量、efSearch=64条件下的实测数据:

指标 Milvus 2.4.1 Qdrant 1.9.0
部署容器数 3 1
写入耗时 18min 12min
内存占用 11.5GB 6.2GB
磁盘占用 28GB 24GB
P50延迟 12ms 8ms
P99延迟 31ms 23ms
召回率@10 0.983 0.976
QPS(单线程) 82 125

补充说明:Milvus的召回率略高是因为它的HNSW实现做了更精细的图剪枝。Qdrant在设置default_segment_number=4后,P99从35ms降到23ms,这个优化很关键。

在200并发压测下,Milvus的P99升到89ms,Qdrant升到67ms。两者都出现了不同程度的延迟抖动,但Qdrant恢复更快。

七、总结

如果让我给一个明确的选型建议:

选Qdrant的场景:单节点部署、内存有限、追求低延迟、团队没有专职运维。Qdrant的部署简单到令人发指,一个docker run就完事,资源占用也明显更优。我们最终在生产环境选了Qdrant,跑了三个月没出过问题。

选Milvus的场景:数据量超过5000万、需要分布式水平扩展、需要多租户隔离、团队有K8s运维能力。Milvus的集群模式确实强大,但单机模式下它的优势并不明显,反而因为依赖组件多而显得笨重。

一个补充观察:Qdrant的Rust实现在内存效率上确实有优势,但它的生态工具链不如Milvus丰富。比如Milvus有Attu这个图形化管理界面,Qdrant的Web UI相对简陋。不过对于API驱动的场景,这个差异不重要。

最后提醒一句:无论选哪个,一定要用真实数据做压测。随机向量和真实业务向量的分布差异很大,HNSW在不同分布下的表现可能完全不同。我们用自己的CLIP特征测出来的P99比随机向量高了约8ms,这个差距在选型时不能忽略。