一、问题背景:为什么要在 Milvus 和 Qdrant 之间纠结
我们做的是一个企业内部知识库问答系统,文档切片后大概 100~500 万条 chunk,embedding 用 bge-large-zh,768 维。检索要求是:单次查询 P95 控制在 50ms 以内,QPS 峰值 200 左右,同时要支持按部门、文档类型做 metadata 过滤。
候选方案其实就那几个:Faiss 太底层,pgvector 过滤性能一般,剩下的就是 Milvus 和 Qdrant。这两个社区都活跃,功能也接近,但设计哲学差别很大:
- Milvus 是"分布式优先",架构里天然有 etcd、MinIO、Pulsar 这些组件,standalone 模式只是把 coordinator 合并了,资源占用起点高。
- Qdrant 是"Rust 单进程优先",一个二进制文件就能跑,靠 segment + WAL 做持久化,水平扩展是后加的。
网上很多对比文章只有"跑个 demo"的结论,我就自己搭环境测了一遍。下面所有数据都是同一台机器上实测的,配置也会写清楚。
二、环境与版本
测试机:阿里云 ecs.g7.2xlarge,8 vCPU / 16GB / 500GB ESSD PL1。
软件版本固定如下,避免版本差异影响结论:
- Milvus:v2.4.1,standalone 模式,docker-compose 部署
- Qdrant:v1.9.2,单节点 docker 部署
- 客户端:pymilvus 2.4.3,qdrant-client 1.9.1
- Python 3.10,numpy 1.26
数据:随机生成的 100 万条 768 维 float32 向量,L2 归一化后使用 COSINE 距离,附带 3 个 metadata 字段(dept_id、doc_type、create_ts)。
三、方案设计与部署步骤
3.1 Qdrant 部署
Qdrant 部署非常简单,一条 docker 命令搞定:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant_storage:/qdrant/storage \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
qdrant/qdrant:v1.9.2
存储配置在 collection 创建时指定,我用了 on_disk_payload=true 把 payload 放到磁盘,向量仍常驻内存,这是知识库场景比较常见的折中。
3.2 Milvus 部署
Milvus standalone 用官方 docker-compose:
wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
启动后会拉起三个容器:milvus-standalone、milvus-etcd、milvus-minio。默认配置下 etcd 和 MinIO 都会占内存,16G 机器上要留意。
3.3 建表与写入代码
Qdrant 建 collection:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PayloadSchemaType
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="kb_chunks",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config={"m": 16, "ef_construct": 200},
optimizers_config={"memmap_threshold": 20000},
on_disk_payload=True,
)
# 为过滤字段建索引,否则过滤会退化成全量扫描
client.create_payload_index("kb_chunks", "dept_id", PayloadSchemaType.INTEGER)
client.create_payload_index("kb_chunks", "doc_type", PayloadSchemaType.KEYWORD)
Milvus 建 collection(用 2.4 的 schema 方式):
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
schema = client.create_schema(auto_id=True, enable_dynamic_field=True)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=768)
schema.add_field("dept_id", DataType.INT64)
schema.add_field("doc_type", DataType.VARCHAR, max_length=64)
schema.add_field("create_ts", DataType.INT64)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="vector",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
index_params.add_index(field_name="dept_id", index_type="INVERTED")
client.create_collection("kb_chunks", schema=schema, index_params=index_params)
两边 HNSW 参数刻意保持一致(M=16,efConstruction=200),这样对比才有意义。查询时 ef 都取 128。
四、核心实测:查询性能与资源占用
4.1 写入性能
100 万条数据,batch=1000 写入:
| 指标 | Milvus 2.4.1 | Qdrant 1.9.2 |
|---|---|---|
| 总写入耗时 | 214s | 168s |
| 建索引耗时 | 96s | 62s |
| 峰值内存 | 5.8GB | 3.4GB |
Qdrant 写入更快主要因为它是单进程内存索引,Milvus 要走 WAL + object storage 两层。这个差距在批量导入时挺明显。
4.2 查询性能
用 1000 条真实 query 打,并发 32,统计 P50/P95/P99。分两组:纯向量检索、带过滤检索。
纯向量 topk=10:
| 指标 | Milvus | Qdrant |
|---|---|---|
| QPS | 412 | 587 |
| P50 | 62ms | 41ms |
| P95 | 96ms | 68ms |
| P99 | 143ms | 102ms |
带 dept_id in [...] 过滤:
| 指标 | Milvus | Qdrant |
|---|---|---|
| QPS | 318 | 405 |
| P95 | 128ms | 94ms |
单机 100 万规模下 Qdrant 全面领先,主要是它没有 gRPC → proxy → querynode 这条链路开销。Milvus 的 standalone 模式其实还是走完整的分布式查询路径,网络跳数多。
4.3 资源占用
稳定运行 30 分钟后采集:
| 指标 | Milvus(含 etcd+MinIO) | Qdrant |
|---|---|---|
| 常驻内存 | 6.2GB | 3.7GB |
| 磁盘占用 | 4.1GB | 2.8GB |
| 空闲 CPU | 3~6% | <1% |
Milvus 那一坨组件加起来是 Qdrant 的 1.7 倍内存。如果机器是 8G 的,Milvus 会相当紧张。
4.4 数据量上到 500 万后的变化
我把数据扩到 500 万再测,结论开始反转:
| 指标 | Milvus | Qdrant |
|---|---|---|
| P95(纯向量) | 118ms | 210ms |
| 内存 | 11.3GB | 12.6GB |
| 召回率@10 | 0.982 | 0.961 |
Qdrant 单机在 500 万后开始出现明显的延迟抖动,因为 HNSW 图太大、内存吃紧触发 swap。Milvus 因为走的是分段索引 + 磁盘,反而更稳。这也印证了两者的定位差异。
五、踩坑与优化
坑 1:Qdrant 忘记建 payload index。 一开始过滤查询 P95 直接 300ms+,加上 create_payload_index 后掉到 94ms。这个坑太常见了,官方文档虽然写了但容易忽略。
坑 2:Milvus 的 enable_dynamic_field。 开了之后写入方便,但查询时动态字段没有索引,过滤会走 brute force。我们的 doc_type 一开始放在动态字段里,性能惨不忍睹,后来显式建 schema 才正常。
坑 3:Qdrant 的 memmap_threshold。 默认值下,小于阈值的 segment 全在内存,数据量一上来内存就爆。我把它设成 20000 让超过 2 万点的段 mmap 到磁盘,内存降了 30%,P95 只涨了 8ms,很划算。
坑 4:Milvus standalone 的 etcd 默认 quota。 etcd 默认 2GB 空间,写多了会报 etcdserver: mvcc: database space exceeded,要提前调 --quota-backend-bytes。
优化建议:
- Qdrant:hnsw_ef 查询时按需调,从 128 降到 64,P95 能再降 20%,召回损失 <1%。
- Milvus:开启 scalarvindex + 用 growing segment 的 growing_segment_size 调大,能减少小段合并。
六、总结:到底选哪个
直接说结论:
选 Qdrant 的场景: 数据量 < 500 万,机器规格不大(8C16G 以下),团队不想维护一堆中间件,追求部署简单和低延迟。我们最终生产环境选的就是 Qdrant,因为业务量级在 200 万左右,Qdrant 一个容器搞定,运维成本几乎为零。
选 Milvus 的场景: 数据量 500 万以上且会持续增长,需要水平扩展,需要多租户/多 collection 隔离,团队有能力维护 K8s 和对象存储。Milvus 在分布式上的成熟度是 Qdrant 目前比不了的。
一句话:Qdrant 是"精致的小钢炮",Milvus 是"重型装甲车"。别被网上的 benchmark 带节奏,先搞清楚自己的数据量级和运维能力,这两个约束基本就能帮你拍板了。