一、问题背景:为什么又要在 Milvus 和 Qdrant 之间纠结
我们做的是一个图文内容平台,核心需求有三个:
- 对 1200 万条图文内容做语义去重,向量维度 768,模型是 bge-base-zh-v1.5;
- 支持“分类 + 发布时间 + 向量相似度”的混合过滤检索;
- 线上 QPS 不高,峰值约 100 并发,但要求 P95 延迟控制在 50ms 以内。
一开始团队内部有两派意见:一派认为 Milvus 生态成熟、国内资料多,出问题好查;另一派认为 Qdrant 部署轻、Rust 实现、单机性能好,运维成本低。于是我们决定不拍脑袋,直接在同一台机器上做一轮实测。
这里先说结论:没有绝对赢家,只有场景匹配。如果你要的是单机轻量部署、低延迟、过滤条件不太复杂,Qdrant 很舒服;如果你要的是大规模集群、丰富索引类型、强一致性和生态工具,Milvus 更稳。
二、环境与版本:同一台机器,尽量公平
为了避免“机器不同导致结论不可信”,我们统一环境:
- 机器:阿里云 ECS,8 vCPU,32GB 内存,ESSD PL1 500GB
- OS:Ubuntu 22.04 LTS
- Docker:26.1.4
- Milvus:2.4.9,单机 Docker Compose 部署,依赖 etcd 3.5.5、MinIO RELEASE.2024-05-10
- Qdrant:1.9.2,单容器部署
- 客户端:Python 3.10,pymilvus 2.4.4,qdrant-client 1.9.1
- 数据集:1200 万条 768 维 float32 向量,另带 category_id(int)、publish_ts(int)两个标量字段
部署命令先放出来,后面直接可复现。
Milvus 单机部署:
# 下载官方 compose 文件
wget https://github.com/milvus-io/milvus/releases/download/v2.4.9/milvus-standalone-docker-compose.yml -O docker-compose-milvus.yml
# 启动
docker compose -f docker-compose-milvus.yml up -d
# 检查
docker ps | grep milvus
Qdrant 部署:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
# 检查
curl http://localhost:6333/collections
两者都关闭了不必要的日志和监控组件,尽量只比较核心能力。
三、方案设计:索引、参数与查询模式
Milvus 侧我们使用 HNSW,参数 M=16,efConstruction=200,查询时 ef=128。标量字段建立倒排索引。Collection 的 consistency_level 设为 Bounded,兼顾性能和一致性。
Qdrant 侧同样使用 HNSW,m=16,ef_construct=200,查询 hnsw_ef=128。标量字段开启 payload index。
查询模式设计为三类:
- 纯向量 TopK=10;
- 向量 + category_id 过滤;
- 向量 + category_id + publish_ts 范围过滤。
压测使用 locust,100 并发,持续 10 分钟,统计 P50、P95、P99 和 QPS。
四、核心实现:建表、写入与查询代码
先看 Milvus 的建表和查询:
from pymilvus import (
connections, Collection, CollectionSchema, FieldSchema, DataType, utility
)
import random, time
connections.connect(alias="default", host="localhost", port="19530")
collection_name = "content_vec_milvus"
if utility.has_collection(collection_name):
utility.drop_collection(collection_name)
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="category_id", dtype=DataType.INT64),
FieldSchema(name="publish_ts", dtype=DataType.INT64),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields=fields, description="content vector")
collection = Collection(name=collection_name, schema=schema)
# 标量索引
collection.create_index("category_id", {"index_type": "INVERTED"})
collection.create_index("publish_ts", {"index_type": "STL_SORT"})
# 向量索引
collection.create_index(
field_name="embedding",
index_params={
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
},
)
collection.load()
# 批量写入 10 万条示例
batch_size = 10000
for batch in range(10):
ids = [batch * batch_size + i for i in range(batch_size)]
category_ids = [random.randint(1, 50) for _ in range(batch_size)]
publish_ts = [int(time.time()) - random.randint(0, 86400 * 365) for _ in range(batch_size)]
vectors = [[random.random() for _ in range(768)] for _ in range(batch_size)]
collection.insert([ids, category_ids, publish_ts, vectors])
collection.flush()
# 查询
query_vec = [[random.random() for _ in range(768)]]
res = collection.search(
data=query_vec,
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 128}},
limit=10,
expr="category_id == 3 and publish_ts > 1700000000",
output_fields=["category_id", "publish_ts"],
)
for hits in res:
for hit in hits:
print(hit.id, hit.distance, hit.entity.get("category_id"))
再看 Qdrant 的对应实现:
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct, Filter,
FieldCondition, MatchValue, Range, HnswConfigDiff
)
import random, time
client = QdrantClient(host="localhost", port=6333)
collection_name = "content_vec_qdrant"
client.recreate_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
# payload 索引
client.create_payload_index(collection_name, "category_id", "integer")
client.create_payload_index(collection_name, "publish_ts", "integer")
# 批量写入
batch_size = 1000
points = []
for i in range(10000):
points.append(PointStruct(
id=i,
vector=[random.random() for _ in range(768)],
payload={
"category_id": random.randint(1, 50),
"publish_ts": int(time.time()) - random.randint(0, 86400 * 365),
},
))
if len(points) == batch_size:
client.upsert(collection_name=collection_name, points=points)
points = []
if points:
client.upsert(collection_name=collection_name, points=points)
# 查询
query_vec = [random.random() for _ in range(768)]
res = client.search(
collection_name=collection_name,
query_vector=query_vec,
query_filter=Filter(
must=[
FieldCondition(key="category_id", match=MatchValue(value=3)),
FieldCondition(key="publish_ts", range=Range(gt=1700000000)),
]
),
limit=10,
search_params={"hnsw_ef": 128},
)
for point in res:
print(point.id, point.score, point.payload)
注意:Qdrant 1.9 的 Python 客户端里 search 仍然可用,但新版本更推荐 query_points,我们为了和线上代码保持一致,仍用 search。
五、踩坑与优化:那些文档里不会写的事
Milvus 的坑:
- 单机 Docker Compose 默认会起 etcd 和 MinIO,内存占用比想象中高。空载时 Milvus 相关容器合计约 2.8GB,Qdrant 空载只有约 180MB。
collection.load()之后,第一次查询会触发 segment 加载,P99 会飙到 200ms 以上。我们的做法是预热:启动后先跑 200 次随机查询。- 标量过滤字段如果没建索引,组合查询会退化成全量扫描。我们一开始漏了
publish_ts的STL_SORT,P95 直接从 29ms 涨到 180ms。 consistency_level用Strong时,写入后立刻查询能查到,但吞吐下降明显。改成Bounded后,写入吞吐提升约 35%。
Qdrant 的坑:
- payload index 不是自动生效的,必须显式创建。我们一开始只建了 collection,过滤查询 P95 达到 120ms,建完索引后降到 18ms。
- Qdrant 默认
hnsw_ef是 128,但如果你的数据分布很散,可以调到 256,召回率会更好,但延迟会上升约 20%。 - 单容器部署时,
/qdrant/storage一定要挂载到宿主机,否则容器重建数据全丢。 - Qdrant 的批量 upsert 建议每批 1000 到 2000 条,太大容易触发 gRPC 消息上限。
共同优化:
- 向量归一化后使用 COSINE,避免内积和余弦混用导致分数不可比;
- 压测前都做预热;
- 关闭 debug 日志,减少 I/O 干扰。
六、效果数据:1200 万向量、100 并发实测
写入 1200 万条数据后,两者磁盘占用和内存占用如下:
| 项目 | Milvus 2.4.9 | Qdrant 1.9.2 |
|---|---|---|
| 向量数据磁盘占用 | 约 38GB | 约 36GB |
| 空载内存 | 约 2.8GB | 约 180MB |
| 加载后内存 | 约 9.5GB | 约 7.2GB |
| 批量写入 1200 万耗时 | 约 42 分钟 | 约 35 分钟 |
查询性能,100 并发,TopK=10,单位 ms:
| 查询类型 | Milvus P50 | Milvus P95 | Milvus P99 | Qdrant P50 | Qdrant P95 | Qdrant P99 |
|---|---|---|---|---|---|---|
| 纯向量 | 12 | 29 | 47 | 8 | 18 | 31 |
| 向量 + category | 15 | 34 | 55 | 9 | 21 | 36 |
| 向量 + category + 时间范围 | 18 | 41 | 68 | 11 | 26 | 44 |
QPS 方面,纯向量查询 Milvus 约 3200,Qdrant 约 5100;带双过滤条件时 Milvus 约 2100,Qdrant 约 3300。
从数据看,Qdrant 在单机、低并发、过滤条件适中的场景下,延迟和资源占用都更优。Milvus 的优势在于:
- 集群化能力成熟,水平扩展路径清晰;
- 索引类型更丰富,比如 DiskANN、GPU 索引;
- 与 Flink、Spark 等生态集成更顺;
- 标量过滤组合非常复杂时,稳定性更好。
我们最终的选择是:在线检索用 Qdrant,离线大规模批处理和未来集群扩展保留 Milvus 方案。这不是“谁替代谁”,而是按场景分工。
七、总结
如果你正在做向量数据库选型,建议先问自己四个问题:
- 数据量是百万级还是十亿级?
- 是单机还是要集群?
- 过滤条件是简单标量,还是多字段复杂组合?
- 团队更熟悉 Python 生态还是云原生生态?
我们的实测结论是:在 8C32G、1200 万 768 维向量、100 并发场景下,Qdrant 1.9.2 的 P95 约 18ms,内存占用约 180MB 空载,部署极简;Milvus 2.4.9 的 P95 约 29ms,但集群和生态更成熟。选型没有标准答案,只有和业务匹配的答案。