一、问题背景

先说场景。我们做的是一个内容社区,需要对每天新增的约 200 万条图文内容做相似去重和推荐召回。向量模型用的是 BGE-large-zh-v1.5,输出 768 维。历史存量大约 1000 万条,未来半年预计到 5000 万。

需求很明确:
- 单条查询延迟 P99 控制在 50ms 以内
- 支持按 category、publish_time 做标量过滤
- 运维成本尽量低,团队没有专职的向量数据库 DBA

候选就是 Milvus 和 Qdrant。Milvus 生态大、功能全、有 Zilliz 背书;Qdrant 是 Rust 写的,主打单机性能和易用性。网上文章要么是官方 benchmark,要么是「Hello World」级别的 demo,缺少真实业务参数下的对比。所以我花了两天时间,在同样的机器上把两个都跑了一遍。

二、环境与版本

测试机器是一台阿里云 ECS,配置如下:

  • CPU:Intel Xeon Platinum 8269CY,16 vCPU
  • 内存:64 GB
  • 磁盘:ESSD PL1,500 GB
  • OS:Ubuntu 22.04 LTS
  • Docker:24.0.7,Docker Compose v2.23.0

软件版本:

  • Milvus 2.4.4(standalone 模式,etcd 3.5.5 + MinIO RELEASE.2023-03-20)
  • Qdrant 1.9.2(单节点)
  • Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1

两边都用 Docker 部署,避免环境差异。Milvus 的 standalone 依赖 etcd 和 MinIO,Qdrant 是单二进制,这一点在部署复杂度上差距很明显。

三、方案设计

数据集:1000 万条 768 维 float32 向量,随机生成但做了归一化,模拟真实 embedding 分布。另外给每条向量附加两个标量字段:category(0-99 的整数)和 ts(时间戳)。

索引配置:

  • Milvus:HNSW,M=16,efConstruction=200,查询时 ef=64;标量字段建倒排索引
  • Qdrant:HNSW,m=16,ef_construct=200,查询时 hnsw_ef=64;标量字段建 payload index

测试指标:
1. 批量写入 1000 万条的耗时和内存峰值
2. Top-10 查询在无过滤、带 category 过滤两种条件下的 P50/P99 延迟
3. 常驻内存占用

四、核心实现

4.1 部署

Milvus 的 docker-compose 官方给了模板,我精简了一下:

version: '3.5'
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    volumes:
      - ./volumes/etcd:/etcd

  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    command: minio server /minio_data --console-address ":9001"
    volumes:
      - ./volumes/minio:/minio_data

  standalone:
    image: milvusdb/milvus:v2.4.4
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - etcd
      - minio

Qdrant 就简单多了,一条命令:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.9.2

4.2 写入与查询代码

Milvus 侧:

from pymilvus import MilvusClient
import numpy as np, time

client = MilvusClient(uri="http://localhost:19530")
client.create_collection(
    collection_name="demo",
    dimension=768,
    metric_type="COSINE",
    index_type="HNSW",
    index_params={"M": 16, "efConstruction": 200},
)

# 批量写入,每批 5 万
BATCH = 50000
vectors = np.random.rand(BATCH, 768).astype(np.float32)
data = [
    {"id": i, "vector": vectors[i].tolist(),
     "category": i % 100, "ts": 1700000000 + i}
    for i in range(BATCH)
]
t0 = time.time()
client.insert(collection_name="demo", data=data)
print("insert batch cost:", time.time() - t0)

# 查询
res = client.search(
    collection_name="demo",
    data=[vectors[0].tolist()],
    limit=10,
    filter='category == 5',
    search_params={"metric_type": "COSINE", "params": {"ef": 64}},
)

Qdrant 侧:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
import numpy as np, time

client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
    collection_name="demo",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config={"m": 16, "ef_construct": 200},
)
client.create_payload_index("demo", "category", field_type="integer")
client.create_payload_index("demo", "ts", field_type="integer")

BATCH = 50000
vectors = np.random.rand(BATCH, 768).astype(np.float32)
points = [
    PointStruct(id=i, vector=vectors[i].tolist(),
                payload={"category": i % 100, "ts": 1700000000 + i})
    for i in range(BATCH)
]
t0 = time.time()
client.upsert(collection_name="demo", points=points)
print("upsert batch cost:", time.time() - t0)

res = client.search(
    collection_name="demo",
    query_vector=vectors[0].tolist(),
    limit=10,
    query_filter=Filter(must=[FieldCondition(key="category", match=MatchValue(value=5))]),
    search_params={"hnsw_ef": 64},
)

两边 API 都很直觉,Qdrant 的 payload 过滤写法我个人更喜欢,语义清晰。

五、踩坑与优化

坑 1:Milvus 写入必须 flush。 一开始我没调 flush(),查询一直返回空。Milvus 是段式存储,insert 后数据在内存 buffer,查询可见性依赖 flush 或 growing segment 的加载。1000 万条数据我每 100 万调一次 flush,比较稳。Qdrant 的 upsert 是立即可见的,没这个问题。

坑 2:Qdrant 的 payload index 要提前建。 我第一轮测试忘了建 category 的索引,带过滤查询直接从 12ms 飙到 180ms,因为它在做全量 payload 扫描。建完索引后回到 13ms 左右。这个细节很多教程不会强调。

坑 3:Milvus standalone 的内存吃紧。 1000 万条 768 维向量,原始数据约 30GB(float32),Milvus 加上 etcd、MinIO 和索引,常驻内存到 42GB,64GB 机器已经比较紧张。Qdrant 同样数据常驻约 34GB。Milvus 的额外开销主要来自多组件和 segment 元数据。

优化点: Milvus 我把 queryNode.segcore.chunkRows 从默认 1024 调到 4096,减少 segment 数量,查询 P99 从 31ms 降到 23ms。Qdrant 调了 hnsw_ef,从 128 降到 64,召回率掉 0.3% 但延迟降了 40%。

六、效果数据

写入 1000 万条的总耗时:

数据库 耗时 峰值内存
Milvus 2.4.4 18 分 42 秒 42 GB
Qdrant 1.9.2 14 分 05 秒 34 GB

查询延迟(单线程,1000 次取平均):

场景 Milvus P50 Milvus P99 Qdrant P50 Qdrant P99
无过滤 Top-10 9 ms 23 ms 5 ms 12 ms
category 过滤 Top-10 11 ms 27 ms 6 ms 13 ms

召回率(Recall@10,对比暴力检索):Milvus 0.982,Qdrant 0.978,基本持平。

结论很清晰:单机场景下 Qdrant 在延迟和内存上都有优势,部署也简单得多。Milvus 的优势在于分布式、索引类型丰富(DiskANN、GPU 索引)、生态成熟。

七、总结

如果你的数据量在 5000 万以内、团队运维能力有限、追求单机性能,Qdrant 是更省心的选择,1.9.2 版本已经很稳定。如果数据量上亿、需要水平扩展、或者要用 DiskANN 这类磁盘索引降低内存成本,Milvus 2.4.x 的分布式能力更值得投入。

我们最终选了 Qdrant,因为当前规模单机足够,运维成本低。但预留了迁移路径——向量数据和 payload 都有完整导出,将来真要上 Milvus 也不难。选型没有银弹,先跑通自己的真实数据再决定,比看任何 benchmark 都靠谱。