一、问题背景:为什么要在 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 segmentgrowing_segment_size 调大,能减少小段合并。

六、总结:到底选哪个

直接说结论:

选 Qdrant 的场景: 数据量 < 500 万,机器规格不大(8C16G 以下),团队不想维护一堆中间件,追求部署简单和低延迟。我们最终生产环境选的就是 Qdrant,因为业务量级在 200 万左右,Qdrant 一个容器搞定,运维成本几乎为零。

选 Milvus 的场景: 数据量 500 万以上且会持续增长,需要水平扩展,需要多租户/多 collection 隔离,团队有能力维护 K8s 和对象存储。Milvus 在分布式上的成熟度是 Qdrant 目前比不了的。

一句话:Qdrant 是"精致的小钢炮",Milvus 是"重型装甲车"。别被网上的 benchmark 带节奏,先搞清楚自己的数据量级和运维能力,这两个约束基本就能帮你拍板了。