一、问题背景:为什么突然要换向量数据库
我们团队做的是电商以图搜图,底库里有500万张商品主图的CLIP特征,单条向量256维float32。老方案是Faiss自己搭的,召回没问题,但增量更新太痛苦——每次上新款都要全量重建索引,耗时4小时,运维天天骂娘。
于是2个月前,我们决定引入真正的向量数据库。选型范围圈定Milvus和Qdrant,原因很简单:社区活跃、文档全、都支持Docker部署。但网上那些对比文章大多是跑个随机数据的benchmark,真到业务场景全是坑。这篇文章就把我们从部署到压测的完整过程掏出来,包括那些文档里没写的坑。
二、环境与版本:先亮底牌
硬件环境(两台相同的物理机):
- CPU:Intel Xeon Gold 6248R @ 3.0GHz(24核48线程)
- 内存:256GB DDR4-2933
- 存储:NVMe SSD 2TB,顺序读速度3.5GB/s
- 网络:10GbE内网
软件版本:
- Milvus:v2.4.1(standalone模式,etcd + minio + milvus)
- Qdrant:v1.9.2(单节点)
- Python客户端:pymilvus 2.4.3 / qdrant-client 1.9.1
- 数据集:500万条256维float32向量,从我们生产环境随机抽取,归一化处理过
这里有个重要提示:Milvus 2.4开始,standalone模式直接自带etcd和minio,不用再单独配了,对测试环境友好很多。Qdrant就更简单,一个二进制文件搞定。
三、方案设计:测试映射到真实业务
我们的业务核心是三件事:
- 实时增量:平均每秒进来5条新商品向量,要求5秒内可见
- 查询延迟:P99必须小于120ms,否则用户体验断崖
- 资源占用:单机部署,内存预算不超过32GB,CPU峰值不超60%
测试方案分三步:
- 部署成本对比:记录从拉镜像到能跑通查询的时间
- 写入性能测试:批量导入500万条,记录吞吐量和耗时
- 查询性能测试:用10000条真实查询向量打点,记录P50/P99延迟
四、核心实现:Docker部署与压测代码
4.1 Milvus部署(踩坑预警)
Milvus的Docker部署官方给了docker-compose.yml,但直接跑会遇到两个坑:一是etcd和minio的镜像拉取极慢,建议提前配置镜像加速;二是默认配置的内存参数偏保守,需要手动调整。
# docker-compose.yml (Milvus 2.4.1关键配置)
version: '3.5'
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data --console-address ":9001"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
milvus:
container_name: milvus-standalone
image: milvusdb/milvus:v2.4.1
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
- "9091:9091"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
depends_on:
- etcd
- minio
启动后,用Python客户端建collection并创建索引。这里有个性能关键点:Milvus的HNSW参数(M和efConstruction)直接影响查询速度和内存占用。我们最终选择M=16、efConstruction=200,这是召回率和性能的平衡点。
# pymilvus 2.4.3 建索引与写入
from pymilvus import (
connections, Collection, CollectionSchema,
FieldSchema, DataType, utility
)
connections.connect(host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=256)
]
schema = CollectionSchema(fields, description="product image embedding")
col = Collection("product_embedding", schema)
# 创建HNSW索引,注意metric_type必须是IP,因为我们做了归一化
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {"M": 16, "efConstruction": 200}
}
col.create_index("embedding", index_params)
# 批量写入500万条
import random
import time
batch_size = 10000
total = 5000000
start = time.time()
for i in range(0, total, batch_size):
ids = list(range(i, i + batch_size))
vectors = [[random.random() for _ in range(256)] for _ in range(batch_size)]
col.insert([ids, vectors])
if i % 500000 == 0:
print(f"inserted {i} rows, elapsed {time.time()-start:.2f}s")
col.flush()
print(f"Total insert time: {time.time()-start:.2f}s")
4.2 Qdrant部署与写入
Qdrant的部署就清爽多了,直接一个容器,连配置文件都省了:
# Qdrant 1.9.2 Docker部署
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
Python侧写入代码更简洁,Qdrant的client设计得更现代:
# qdrant-client 1.9.1 批量写入
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import random, time
client = QdrantClient(host="localhost", port=6333)
# 创建collection,设置HNSW参数
client.recreate_collection(
collection_name="product_embedding",
vectors_config=VectorParams(size=256, distance=Distance.DOT),
hnsw_config={"m": 16, "ef_construct": 200}
)
# 批量写入,Qdrant的upsert支持分批
batch_size = 10000
total = 5000000
start = time.time()
for i in range(0, total, batch_size):
points = [
PointStruct(
id=j,
vector=[random.random() for _ in range(256)]
) for j in range(i, i + batch_size)
]
client.upsert(
collection_name="product_embedding",
points=points,
wait=False # 非阻塞写入,性能更高
)
if i % 500000 == 0:
print(f"upserted {i} rows, elapsed {time.time()-start:.2f}s")
# 等待所有写入完成
client.update_collection(collection_name="product_embedding")
print(f"Total upsert time: {time.time()-start:.2f}s")
五、踩坑与优化:这些坑你一定也会遇到
坑1:Milvus的metric_type选择
我们一开始用的L2距离,结果查询延迟飙到200ms以上。后来检查数据才发现,CLIP特征做过L2归一化,用欧氏距离和余弦距离结果一样,但IP内积在HNSW里的计算效率远高于L2。换成IP后,延迟直接降了40%。
坑2:Qdrant的wait参数
第一次压测时,Qdrant写入用了wait=True,结果吞吐量只有Milvus的1/3。后来看文档发现,wait=False配合update_collection批量确认,吞吐量能提升3倍。但注意,生产环境中wait=False会牺牲一致性,需要评估业务容忍度。
坑3:Milvus的segment合并
Milvus默认每写入2000行就封一个segment,查询时要跨segment检索。500万条数据会产生2500个segment,查询性能急剧下降。需要手动触发col.compact(),或者调大segment.row_count参数。我们最终把dataCoord.segment.maxSize调到2048MB,减少segment数量。
六、效果数据:实测对比
压测使用10000条真实查询向量,每条查询取Top10相似结果,连续跑5轮取平均值:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 | 差异 |
|---|---|---|---|
| 批量写入耗时 | 8分42秒 | 12分15秒 | Milvus快40% |
| 平均查询延迟P50 | 43ms | 38ms | Qdrant快12% |
| P99查询延迟 | 110ms | 87ms | Qdrant快21% |
| 内存峰值 | 28.5GB | 19.4GB | Qdrant低32% |
| CPU平均使用率 | 47% | 65% | Milvus更均衡 |
| 索引构建时间 | 6分15秒 | 4分40秒 | Qdrant快25% |
关键发现:
- Qdrant在查询性能上完胜,特别是P99这种尾部延迟,优势明显
- Milvus的批量写入优势来自其内部批量插入优化,但代价是更高的内存占用
- 两者在增量更新(每秒5条)场景下都无压力,延迟都<50ms
七、总结:我的选择和建议
最终我们选了Qdrant。原因很简单:我们的业务是读多写少,查询延迟是命根子。Qdrant的P99 87ms比Milvus的110ms好太多,而且内存占用低了9GB,可以省下来给其他服务。
但如果你面临的是海量离线导入,比如每天要灌几千万条数据,Milvus的写入优势会非常明显。另外,Milvus的生态更完善,有Milvus Insight可视化工具,运维调试方便不少。
给同行的建议:
1. 先测自己的数据,别信网上的benchmark
2. 注意向量归一化,这直接影响距离计算的选择
3. 两个数据库的HNSW参数强烈建议调,默认配置都是狗屎
4. 压测至少跑10000条查询,看P99才有意义
最后说一句:向量数据库没有银弹,适合自己的业务模型才是最好的。希望这篇实测能帮你少走弯路。有具体问题的同学,评论区见。