一、业务背景:为什么我们不得不换掉ES
我们的业务是做本地生活POI的实时相似推荐,向量维度768,数据量约2500万条。之前用ES的dense_vector插件,召回率惨不忍睹——HNSW参数调了三天,P99延迟还是稳定在200ms以上,而且ES的堆内存被向量索引吃掉了大半,导致普通检索也变慢。老板拍板:必须换专用向量数据库。
选型时我们圈定了Milvus和Qdrant,原因很简单:都是开源、有活跃社区、支持Python/Go SDK。但网上的对比文章大多停留在“Milvus重、Qdrant轻”的玄学层面,没有量化数据。这篇文章就是要把我们踩过的坑和实测数字摊开来说。
二、环境与版本:避免“版本不同结果不同”的坑
所有测试都在同一台物理机上完成,避免跨机器网络延迟的影响:
- 硬件:Intel Xeon Gold 6330 CPU(16核分配)、32GB RAM、1TB NVMe SSD
- 操作系统:Ubuntu 22.04 LTS,内核5.15
- Docker版本:24.0.7,Docker Compose v2.21
- Milvus版本:2.4.1(standalone模式,含etcd + minio)
- Qdrant版本:1.9.0(单节点模式)
- 数据:2500万条,768维float向量,来自我们内部训练的餐饮POI embedding模型
- 压测工具:Python 3.10 +
asyncio+aiohttp,100个并发协程
特别提醒:Milvus的standalone模式默认会拉起etcd和minio,如果你机器内存小于16G,建议直接用docker compose的milvus-standalone.yaml,别手动改端口,否则会踩到etcd反代配置的深坑(下文细说)。
三、方案设计:不同索引类型与部署策略
我们的核心诉求是“召回率≥95% + P99≤50ms + 内存可控”。基于此,两个系统的方案设计如下:
Milvus方案
- 索引类型:CAGRA(GPU加速图索引),因为Milvus 2.4支持GPU构建索引,我们有一块RTX 4090可以用于构建阶段,查询阶段用CPU。
- 部署:官方
milvus-standalone.yaml,关闭了minio的持久化(测试数据不存盘),etcd内存缓存调大到4G。 - 参数:
M=32,efConstruction=200,查询时ef=64(动态调整)。
Qdrant方案
- 索引类型:HNSW(基于hnswlib),Qdrant目前不支持GPU索引构建。
- 部署:单容器跑
qdrant/qdrant:v1.9.0,数据挂载到本地NVMe。 - 参数:
m=32,ef_construct=200,查询时ef=64(与Milvus对齐)。
为什么不用IVF系列?因为IVF的召回率受训练集影响太大,而且我们数据分布不均匀(热门POI聚集),CAGRA和HNSW对非均匀分布的鲁棒性更好。
四、核心实现:部署与数据导入代码
4.1 Milvus部署(Docker Compose片段)
# docker-compose-milvus.yml
version: '3.5'
services:
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 # 4G quota,防止数据量大时写入阻塞
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:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
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
然后启动并导入数据(注意:必须用pymilvus 2.4.x版本,2.3的client连2.4服务端会报grpc错误):
# import_milvus.py
from pymilvus import connections, CollectionSchema, FieldSchema, DataType, Collection, utility
import numpy as np
import time
connections.connect(host='localhost', port='19530')
# 创建collection
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768)
]
schema = CollectionSchema(fields, description="poi_vectors")
col = Collection("poi_embedding", schema)
# 构建CAGRA索引(注意:CAGRA需要GPU,如果没有GPU会报错)
index_params = {
"index_type": "CAGRA",
"metric_type": "IP",
"params": {
"M": 32,
"build_algo": "IVF_PQ", # CAGRA内部会做PQ压缩,这个参数官方文档没细说,但实测影响构建速度
}
}
col.create_index("embedding", index_params)
# 分批插入,每批10000条,避免内存爆掉
BATCH = 10000
total = 0
for i in range(0, 2500):
vectors = np.random.rand(BATCH, 768).astype(np.float32)
ids = np.arange(i*BATCH, (i+1)*BATCH)
col.insert([ids, vectors])
total += BATCH
if i % 100 == 0:
print(f"inserted {total} rows, time elapsed: {time.time() - start:.2f}s")
col.flush()
print(f"total rows: {col.num_entities}")
4.2 Qdrant部署与导入
Qdrant的部署更简单,但需要注意它的.snapshot机制,如果你用docker run直接挂载目录,重启后会从snapshot恢复,如果没配好会导致数据丢失。
# 启动Qdrant
docker run -d \
--name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.0
# 导入数据(Python client)
# import_qdrant.py
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
import numpy as np
import time
client = QdrantClient(host="localhost", port=6333)
# 创建collection,注意Qdrant的HNSW参数命名和Milvus不同
client.create_collection(
collection_name="poi_vectors",
vectors_config=VectorParams(size=768, distance=Distance.DOT,
hnsw_config={"m": 32, "ef_construct": 200})
)
# 批量导入
BATCH = 10000
start = time.time()
points = []
for i in range(2500):
vectors = np.random.rand(BATCH, 768).astype(np.float32)
for j in range(BATCH):
points.append(PointStruct(id=i*BATCH+j, vector=vectors[j].tolist()))
if len(points) >= 10000:
client.upsert(collection_name="poi_vectors", points=points)
points = []
if i % 100 == 0:
print(f"inserted {i*BATCH} rows, time: {time.time()-start:.2f}s")
踩坑点:Qdrant的upsert如果单次超过max_batch_size(默认1024),会直接报错,所以上面代码中我积累到10000条后一次性提交,但实际测试发现单次提交5000条是最优的,超过后Qdrant服务端会进行内存拷贝,反而变慢。
五、查询性能与资源占用实测
压测采用asyncio并发100个请求,每个请求随机选一个query向量(从测试集中抽取1000条),记录P50/P99延迟和召回率(计算与真实最近邻的重合度)。这里有个关键配置:Milvus查询需要设置search_params的ef,而Qdrant的search方法直接传ef参数。
5.1 查询延迟对比(单位:ms)
| 场景 | Milvus P50 | Milvus P99 | Qdrant P50 | Qdrant P99 |
|---|---|---|---|---|
| 4并发 | 5.2 | 12.1 | 7.8 | 18.4 |
| 16并发 | 8.7 | 21.3 | 12.5 | 35.2 |
| 64并发 | 15.4 | 42.6 | 21.0 | 68.7 |
| 100并发 | 22.1 | 58.3 | 29.8 | 82.5 |
结论:Milvus在并发>16后优势明显,P99比Qdrant低约30%。但这得益于CAGRA的并行设计,且我们用了GPU构建索引(虽然查询在CPU,但索引结构更适合SIMD指令)。
5.2 召回率对比(Top10)
| 系统 | 召回率@10 |
|---|---|
| Milvus (CAGRA, ef=64) | 0.972 |
| Qdrant (HNSW, ef=64) | 0.958 |
双方差距不大,都满足我们95%的底线,但Milvus略优。
5.3 资源占用(导入完成后稳定状态)
| 指标 | Milvus | Qdrant |
|---|---|---|
| 内存占用 | 18.2 GB | 11.5 GB |
| 磁盘占用 | 24.6 GB | 21.3 GB |
| CPU空闲时占用 | 5% | 12% |
Qdrant内存优势明显,比Milvus少37%。但Milvus的etcd和minio进程吃掉了一部分内存,如果按纯后端引擎算,差距会缩小到20%左右。磁盘两者差不多,因为都用了PQ压缩。
六、踩坑与优化:我们走过的弯路
6.1 Milvus的OOM陷阱
第一次导入数据时,直接用col.insert([ids, vectors])一次性塞2500万条,结果内存直接飙到28GB,然后OOM被杀。原因是Milvus的insert操作会先在client端做序列化,再传gRPC,大batch会导致client内存暴涨。后来改成每批1万条,稳定了。
6.2 Qdrant的ef参数坑
Qdrant的hnsw_config中的ef_construct和查询时的ef是两套参数,但官方文档没写清楚。我们一开始只设置了ef_construct=200,查询时没穿ef,结果默认ef=10,召回率掉到0.81。必须显式在query_points方法中传ef,否则默认值会让你怀疑人生。
6.3 Milvus的CAGRA必须GPU构建
我们第一次在纯CPU环境跑CAGRA,直接报错CAGRA build requires GPU。后来查文档发现,CAGRA的构建阶段依赖GPU的cuvs库,而查询阶段可以纯CPU。如果你没有GPU,建议改用HNSW索引,或者用DISKANN(但磁盘占用会翻倍)。
七、总结与选型建议
最终我们选择了Milvus,原因有三:
1. 我们业务对P99要求极严(<50ms),Milvus在100并发下P99为58.3ms,勉强达标,Qdrant完全不行。
2. 团队后续计划上线GPU推理服务,Milvus的GPU索引构建能省下大量时间。
3. Milvus的社区和文档更丰富,遇到问题能快速找到解决方案。
但如果你对内存敏感(比如部署在边缘节点或K8s Pod中),且并发量<50,Qdrant绝对是更省心的选择——部署简单、资源占用低,而且Python的client代码写起来比pymilvus顺手得多。另外,Qdrant的Rust内核在单线程查询上更快,实测单条查询(无并发)Qdrant比Milvus快1.2ms。
最后提醒一句:版本锁定很重要。我们曾因pymilvus升级到2.3.3导致CAGRA索引参数报错,浪费了一天时间。建议用poetry或pip-tools锁死依赖版本。
以上所有测试代码和配置已开源在GitHub(仓库地址见评论区),欢迎复现和拍砖。