一、问题背景
我们团队做的是一个图文内容社区,日增图片约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_params里hnsw_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,这个差距在选型时不能忽略。