一、问题背景
我们做的是一个面向企业内部的知识库图文检索系统。业务侧的基本盘是这样的:
- 向量规模:约 1200 万条,维度 768(BGE-base-zh 模型输出),后续半年预计增长到 3000 万;
- 查询模式:TopK=20,必须带
tenant_id过滤(多租户,租户数量约 200,单租户最大数据量约 80 万条); - 性能要求:并发 50 QPS 下 P99 < 50ms,批量导入吞吐不低于 2000 条/秒;
- 部署约束:初期只有 3 台 16C64G 的机器可用,希望单机先跑起来,后期能平滑扩展。
候选方案最终收敛到两个:Milvus 和 Qdrant。原因很简单——前者是国内社区最活跃、生态最全的向量数据库,后者是近两年在 filtered search 上口碑很好的后起之秀,单机部署极轻。网上大量文章都在说 Milvus “功能全”、Qdrant “过滤快”,但很少有文章把两者放在同一个业务约束下,用同一批数据、同一套硬件跑出可对比的数字。这篇文章就是补上这一块。
二、环境与版本
测试环境如下,两台配置完全一致的机器,避免硬件差异干扰:
| 项目 | 配置 |
|---|---|
| CPU | Intel Xeon Gold 6248R,16C/32T |
| 内存 | 64GB DDR4 |
| 磁盘 | NVMe SSD 1TB |
| OS | Ubuntu 22.04.3 LTS |
| Docker | 24.0.7 |
| Milvus | 2.4.4(standalone,docker-compose 部署) |
| Qdrant | 1.9.2(单容器) |
| 客户端 | pymilvus 2.4.4 / qdrant-client 1.9.1 |
| Python | 3.11.6 |
数据集:1200 万条 768 维 float32 向量,随机生成但做了归一化(模拟余弦相似度场景),每条附带 tenant_id(int64,取值 1~200,按 zipf 分布倾斜)和 doc_id。向量原始大小约 1200万 × 768 × 4B ≈ 34.3GB,这个体量足以把单机的内存策略差异暴露出来。
三、方案设计
两边的集合结构保持一致:主键 id(int64)、向量字段 embedding(768 维,余弦距离)、标量字段 tenant_id(int64)。
Milvus 侧设计:使用 COSINE 度量,索引选 HNSW(M=16,efConstruction=200),tenant_id 建标量索引。Milvus 2.4 支持 partition key,我们把 tenant_id 设为 partition key(num_partitions=64),这是官方推荐的多租户隔离方案,目的是让过滤条件下推得更彻底。
Qdrant 侧设计:同样 COSINE 距离,HNSW 参数 m=16、ef_construct=200,tenant_id 建 payload 索引(integer 类型)。Qdrant 的 filtered search 会在 HNSW 图上做过滤感知的遍历,这块是它的强项,正好用来和 Milvus 的 partition key 方案对比。
部署步骤(这部分我尽量给到可以直接抄的命令):
Milvus standalone 用官方 compose:
wget https://github.com/milvus-io/milvus/releases/download/v2.4.4/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
# 确认三个容器 etcd / minio / milvus-standalone 都 healthy
docker compose ps
Qdrant 更简单,一条命令:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
-e QDRANT__STORAGE__ON_DISK_PAYLOAD=true \
qdrant/qdrant:v1.9.2
注意 Qdrant 默认把向量放内存、payload 也放内存,1200 万条 768 维的原始向量就是 34GB,加上 HNSW 图结构,64G 机器会很紧张。所以我在部署时通过 QDRANT__STORAGE__ON_DISK_PAYLOAD=true 把 payload 落盘,向量本身还是走内存映射(mmap),这是 Qdrant 1.9 的默认行为,下面实测会看到内存占用的差异。
四、核心实现(含代码)
先看 Milvus 的建表与写入。这里有个坑我后面会讲:partition key 字段不能随便改,建表时就要定好。
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=768)
schema.add_field("tenant_id", DataType.INT64, is_partition_key=True) # 多租户关键
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
index_params.add_index(field_name="tenant_id", index_type="INVERTED")
client.create_collection(
collection_name="kb_vectors",
schema=schema,
index_params=index_params,
)
# 批量写入,每批 5000 条
BATCH = 5000
for i in range(0, len(vectors), BATCH):
rows = [
{"id": ids[j], "embedding": vectors[j].tolist(), "tenant_id": tenants[j]}
for j in range(i, min(i + BATCH, len(vectors)))
]
client.insert(collection_name="kb_vectors", data=rows)
client.load_collection("kb_vectors")
Qdrant 侧等价实现:
from qdrant_client import QdrantClient
from qdrant_client.models import (
VectorParams, Distance, PointStruct,
HnswConfigDiff, PayloadSchemaType, OptimizersConfigDiff,
)
qc = QdrantClient(host="localhost", port=6333)
qc.recreate_collection(
collection_name="kb_vectors",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
optimizers_config=OptimizersConfigDiff(
indexing_threshold=20000, # 攒够 2 万条再建索引,避免频繁重建
memmap_threshold=200000, # 超过 20 万条走 mmap
),
)
qc.create_payload_index(
collection_name="kb_vectors",
field_name="tenant_id",
field_schema=PayloadSchemaType.INTEGER,
)
BATCH = 1000 # qdrant-client 单次 upsert 建议不超过 1000 条
for i in range(0, len(vectors), BATCH):
points = [
PointStruct(id=ids[j], vector=vectors[j].tolist(),
payload={"tenant_id": tenants[j]})
for j in range(i, min(i + BATCH, len(vectors)))
]
qc.upsert(collection_name="kb_vectors", points=points, wait=False)
查询侧两边都是“向量 + tenant_id 过滤 + topK=20”:
# Milvus
res = client.search(
collection_name="kb_vectors",
data=[query_vec.tolist()],
filter=f"tenant_id == {tid}",
limit=20,
search_params={"metric_type": "COSINE", "params": {"ef": 64}},
)
# Qdrant
hits = qc.search(
collection_name="kb_vectors",
query_vector=query_vec.tolist(),
query_filter={"must": [{"key": "tenant_id", "match": {"value": tid}}]},
limit=20,
search_params={"hnsw_ef": 64, "exact": False},
)
五、踩坑与优化
坑 1:Milvus partition key 的物理分区数不可改。 我一开始拍脑袋设了 num_partitions=16,结果 200 个租户挤在 16 个物理分区里,过滤时每个分区都要扫,P99 直接飙到 90ms+。后来重建集合改成 64,才降到 40ms 左右。经验是:partition key 的物理分区数建议按“租户数量 / 4~8”估算,且要一次定好,改不了。
坑 2:Qdrant 的 indexing_threshold 影响写入阶段的查询质量。 默认 20000 条才建索引,批量导入过程中如果穿插查询,会走暴力扫描,延迟很高。我们是导入完成后调用一次 qc.update_collection(...) 强制触发优化,再开始压测,否则数据不准。
坑 3:Qdrant payload 落盘后过滤性能有波动。 开启 ON_DISK_PAYLOAD 后,冷启动第一次带过滤查询会明显慢(磁盘 IO),需要预热。我们用 200 次随机查询预热后再测。
优化手段:Milvus 侧把 ef 从 64 调到 96 后召回率提升但延迟上升,最终在 P99 约束下定在 64;Qdrant 侧 hnsw_ef=64 表现已经很稳,且 filtered search 有专门的过滤感知遍历,没额外调参。
六、效果数据
写入吞吐(1200 万条,单线程,batch 如代码所示):
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| 总写入耗时 | 51 分钟 | 38 分钟 |
| 平均吞吐 | ~3900 条/秒 | ~5200 条/秒 |
查询性能(50 并发,混合租户,TopK=20,ef=64,预热后跑 5 分钟取 P99):
| 指标 | Milvus | Qdrant |
|---|---|---|
| P50 | 12ms | 9ms |
| P99(无过滤) | 31ms | 26ms |
| P99(tenant_id 过滤) | 44ms | 33ms |
| QPS 上限(P99<50ms) | ~62 | ~85 |
资源占用(数据加载完成、稳定运行后):
| 指标 | Milvus | Qdrant |
|---|---|---|
| 常驻内存 RSS | 41GB | 27GB |
| 磁盘占用 | 48GB(含 MinIO 原始数据 + 索引) | 39GB |
| 启动到可查询 | 约 3 分钟 | 约 50 秒 |
结论很清楚:在“千万级 + 强过滤”这个约束下,Qdrant 的内存占用低约 35%,P99 延迟在过滤场景优势明显(33ms vs 44ms),单机部署和启动速度也更快。Milvus 的强项不在单机性能,而在分布式扩展、partition key 的多租户治理能力、以及生态(Attu、各种 SDK)。如果我们的数据量真的冲到 3000 万以上、单机内存扛不住,Milvus 的集群模式才是它的主场;但在当前阶段,Qdrant 是更划算的选择。
七、总结
选型从来不是“谁更强”,而是“谁更匹配当前约束”。这次实测给我们的结论是:千万级、单机可承载、过滤条件强的场景,Qdrant 1.9 在资源效率和延迟稳定性上更优;数据量继续增长、需要水平扩展和成熟多租户治理时,Milvus 2.4 的 partition key 与分布式架构更值得投入。 另外提醒两点:一是 Milvus 的 partition key 分区数一定要提前算好,二是 Qdrant 的 payload 落盘策略和 indexing_threshold 会直接影响压测数字,务必预热后再测。数据都在这了,希望能帮到正在做同样选型的同学。