一、问题背景

我们做的是一个面向企业内部的知识库图文检索系统。业务侧的基本盘是这样的:

  • 向量规模:约 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 会直接影响压测数字,务必预热后再测。数据都在这了,希望能帮到正在做同样选型的同学。