1. 问题背景:为什么我们非换向量数据库不可
我们做的是电商服装类目以图搜图,商品图经过CLIP ViT-B/32模型编码成768维向量。存量数据500万,每天新增约3万。之前用Elasticsearch的dense_vector插件,到了200万时查询延迟飙到200ms+,而且内存被吃满,GC频繁。
选型时锁定了Milvus和Qdrant,原因很简单:两者都是Rust/C++实现(Milvus的Knowhere是C++,Qdrant纯Rust),都支持HNSW索引,都有成熟Python SDK。但网上对比文章太少,参数配置差异大,我们决定自己实测。
2. 环境与版本:裸金属,不用K8s
- 两台物理机:CPU Intel Xeon Gold 6330 @ 2.0GHz(32核),内存64GB,SSD NVMe 1.5TB
- 操作系统:Ubuntu 22.04 LTS,内核5.15
- Milvus 2.4.1(standalone模式,Docker部署)
- Qdrant 1.9.0(单节点,Docker部署)
- 数据:500万条768维float32向量,每条附带一个int64商品ID和uint32类目ID
- 压测工具:Python 3.10 +
pymilvus2.4.1 +qdrant-client1.9.0
部署方式刻意选裸金属Docker,排除K8s网络损耗干扰。Milvus standalone其实包含3个组件(proxy、coordinator、datanode),但官方docker-compose一把梭。
3. 方案设计:部署步骤与索引参数
3.1 Milvus部署
# 下载standalone部署脚本
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml
docker-compose -f milvus-standalone-docker-compose.yml up -d
# 确认三个容器都在跑
docker ps | grep milvus
注意:Milvus默认配置文件milvus.yaml里,etcd和minio都是单点。生产环境至少得给etcd挂个持久卷,否则重启数据全没。我们测试环境无所谓,直接跑。
创建collection并建索引的代码:
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType
import time
connections.connect(host='localhost', port='19530')
fields = [
FieldSchema(name='id', dtype=DataType.INT64, is_primary=True),
FieldSchema(name='embedding', dtype=DataType.FLOAT_VECTOR, dim=768),
FieldSchema(name='category', dtype=DataType.INT32)
]
schema = CollectionSchema(fields, description='product_image_search')
col = Collection(name='product_vectors', schema=schema, consistency_level='Bounded')
# HNSW参数:M=16,efConstruction=200
index_params = {
'index_type': 'HNSW',
'metric_type': 'COSINE',
'params': {'M': 16, 'efConstruction': 200}
}
col.create_index('embedding', index_params)
col.load()
3.2 Qdrant部署
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.0
Qdrant配置更简洁,不用etcd不要对象存储,一个二进制搞定。创建collection和索引:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, HnswConfigDiff
client = QdrantClient(host='localhost', port='6333')
client.recreate_collection(
collection_name='product_vectors',
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(
m=16,
ef_construct=200,
full_scan_threshold=10000,
max_indexing_threads=0 # 用满CPU
)
)
4. 核心实现:写入性能压测
4.1 批量写入对比
我们用相同数据集,分100批每批5万条写入。注意Milvus需要先insert再flush,Qdrant用upload_collection或upsert。
# Qdrant写入代码
from qdrant_client.models import PointStruct
import numpy as np
# 预生成随机向量(测试用)
vectors = np.random.rand(50000, 768).astype('float32')
points = [
PointStruct(id=i, vector=vectors[i].tolist(), payload={'category': i % 100})
for i in range(50000)
]
start = time.time()
client.upsert(collection_name='product_vectors', points=points, wait=False)
print(f'Qdrant 5万条写入耗时: {time.time()-start:.2f}s')
Milvus写入类似,但注意它默认sync写入,需要设timeout参数。实际结果:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.0 |
|---|---|---|
| 写入吞吐(条/秒) | 42,000 | 30,000 |
| 索引构建时间(500万条) | 41分钟 | 55分钟 |
| 导入时CPU峰值 | 28核 | 24核 |
Milvus写入快的秘密在于它直接往MinIO写预写日志,批量刷盘;而Qdrant的WAL是同步刷。但Qdrant索引构建更慢,因为它默认用所有CPU核但内存受限。
5. 踩坑与优化:三处关键差异
坑1:Qdrant的mmap内存模式坑
Qdrant默认把向量文件mmap进内存,看似省内存实则IO频繁。我们测试时发现Qdrant部署机器内存占用仅22GB(500万×768×4字节≈15GB数据),但磁盘读IOPS高达3000+。后来在qdrant.yaml里加了:
storage:
optimizers:
mmap_vectors: true
memmap_interval: 300
把向量文件改成纯mmap,内存降到14GB,但查询P99从8ms升到15ms。所以如果内存够(≥64GB),建议关掉mmap。
坑2:Milvus的segment合并开销
Milvus默认segment大小是512MB,500万条数据会产生大量小segment。查询时如果跨segment,HNSW图会被拆散,P99飙升。我们手动设了:
col.set_properties({'segment.maxSize': '1024'}) # 单位MB,合并成大segment
合并后查询P99从12ms降到9ms。代价是写入时偶尔会卡顿。
坑3:Qdrant的payload索引必须建
我们按category过滤,Qdrant如果不建payload索引,过滤会全表扫描。补上:
client.create_payload_index(
collection_name='product_vectors',
field_name='category',
field_schema='integer'
)
过滤查询从120ms降到10ms。Milvus的scalar索引是自动的,没这个坑。
6. 效果数据:查询性能与资源对比
压测工具用locust模拟100并发,查询包含随机向量+按category过滤。每轮跑5分钟,取P50/P99。
| 指标 | Milvus 2.4.1 | Qdrant 1.9.0 |
|---|---|---|
| P50延迟 | 4ms | 3ms |
| P99延迟 | 9ms(segment合并后) | 8ms |
| 最大QPS | 12,500 | 15,300 |
| 内存占用(空闲) | 38GB(含MinIO缓存) | 22GB(mmap模式) |
| 内存占用(高负载) | 41GB | 26GB |
| CPU占用(P99时) | 31核 | 28核 |
结论:Qdrant在查询性能上略胜一筹,且内存占用低35%。Milvus赢在写入吞吐和生态(自带监控面板、支持告警)。
7. 总结:我们最后选了谁
最终我们选了Qdrant,理由不全是性能,而是运维简单。Milvus要管etcd、MinIO、pulsar(或kafka)三个依赖,出问题排查链路长。Qdrant单容器,挂载目录就能跑,升级也方便。
但Milvus也有杀手锏:如果业务需要复杂的标量过滤(比如价格区间+类目+时间排序),它的SQL-like语法更成熟。Qdrant的filter API写起来啰嗦,调试麻烦。
建议:如果纯向量检索+简单过滤,选Qdrant;如果数据量大(亿级)且需要复杂过滤/聚合,选Milvus。别迷信性能评测,你的数据分布和查询模式才是决定因素。我们这500万的量级,两者都能轻松扛住,最终比拼的就是运维体验和团队熟悉度。