一、问题背景

我们团队在做一套面向内部文档的语义检索系统,数据规模预估在 1000 万条 chunk 左右,向量维度 768(bge-base-zh 输出)。检索模式是典型的"单条 query + top-k 相似度 + 少量标量过滤(按部门、文档类型)",QPS 峰值大约 50,要求 P99 延迟控制在 100ms 以内。

候选方案最后收敛到 Milvus 和 Qdrant 两个。原因很直接:两者都是开源、社区活跃、支持标量过滤与 HNSW/IVF 系列索引的成熟产品,且都有 Python SDK 和 Docker 部署方式。网上很多对比文章要么只讲概念,要么数据是几年前的版本,所以我们决定自己压一轮,用真实数据说话。

这篇文章记录的是我们这套场景下的实测结果,不代表所有场景,但希望能给正在选型的同学一些参考。

二、环境与版本

  • 机器:单机 16 vCPU / 64GB RAM / NVMe SSD 1TB,Ubuntu 22.04
  • Docker 24.0.7,Docker Compose v2.24
  • Milvus:v2.4.4(standalone 模式,etcd + minio 由官方 compose 一并拉起)
  • Qdrant:v1.9.2(单节点,未开分布式)
  • Python 3.10,pymilvus 2.4.4,qdrant-client 1.9.1
  • 数据集:1000 万条 768 维 float32 向量,随机生成 + 归一化;另建 3 个标量字段(dept_id int、doc_type int、create_ts int64)

需要注意一点:Milvus standalone 严格来说是"单机版",但内部仍依赖 etcd 和对象存储,这不是纯单进程;Qdrant 单节点则是一个进程搞定。这个差异在部署和资源占用上会直接体现出来。

三、方案设计

两边都采用相同的索引参数,尽量做到公平:

  • 索引类型:HNSW
  • Milvus:M=16, efConstruction=200,查询 ef=64
  • Qdrant:m=16, ef_construct=200,查询 hnsw_ef=64
  • 距离度量:COSINE(向量已归一化,等价于内积)
  • top-k = 10

测试分三块:

  1. 部署耗时与资源:从零拉起服务到可写入的耗时,以及空载内存占用。
  2. 写入性能:1000 万条数据分批写入的总耗时与吞吐。
  3. 查询性能:用 2000 条 query 打点,统计 P50 / P95 / P99 延迟,分"纯向量检索"和"带标量过滤"两组。

四、核心实现

4.1 部署步骤

Milvus 用官方 standalone compose:

# milvus 2.4.4 standalone
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
# 三个容器:milvus-standalone / milvus-etcd / milvus-minio

Qdrant 简单得多:

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

实测部署耗时:Milvus 首次拉镜像 + 启动约 3 分 40 秒(镜像总量约 1.2GB,含 etcd/minio),Qdrant 约 25 秒(镜像约 180MB)。空载常驻内存:Milvus 三容器合计约 1.1GB,Qdrant 约 90MB。这一项 Qdrant 优势明显。

4.2 建表与写入代码

Milvus 侧:

from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection, utility

connections.connect("default", host="127.0.0.1", port="19530")

fields = [
    FieldSchema("id", DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=768),
    FieldSchema("dept_id", DataType.INT32),
    FieldSchema("doc_type", DataType.INT32),
    FieldSchema("create_ts", DataType.INT64),
]
schema = CollectionSchema(fields, description="kb_chunks")
if utility.has_collection("kb_chunks"):
    utility.drop_collection("kb_chunks")
col = Collection("kb_chunks", schema)

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.create_index("dept_id", {"index_type": "INVERTED"})
col.load()

Qdrant 侧:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff

client = QdrantClient(host="127.0.0.1", port=6333)
if client.collection_exists("kb_chunks"):
    client.delete_collection("kb_chunks")

client.create_collection(
    collection_name="kb_chunks",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)

# 写入,batch=1000
points = [
    PointStruct(
        id=i,
        vector=vecs[i],
        payload={"dept_id": depts[i], "doc_type": types[i], "create_ts": ts[i]},
    )
    for i in range(start, start + 1000)
]
client.upsert(collection_name="kb_chunks", points=points, wait=False)

注意 Qdrant 里 wait=False 是异步落盘的关键,压测写入吞吐时必须开;Milvus 侧我们用的是 col.insert() + 定期 flush()。

4.3 查询代码(带过滤)

# Milvus
res = col.search(
    data=[q_vec], anns_field="embedding",
    param={"metric_type": "COSINE", "params": {"ef": 64}},
    limit=10,
    expr="dept_id in [3, 7] and doc_type == 1",
    output_fields=["dept_id"],
)

# Qdrant
from qdrant_client.models import Filter, FieldCondition, MatchAny, MatchValue
hits = client.search(
    collection_name="kb_chunks",
    query_vector=q_vec,
    limit=10,
    query_filter=Filter(must=[
        FieldCondition(key="dept_id", match=MatchAny(any=[3, 7])),
        FieldCondition(key="doc_type", match=MatchValue(value=1)),
    ]),
    search_params={"hnsw_ef": 64},
)

五、踩坑与优化

坑 1:Milvus 的标量过滤索引必须显式建。 一开始我们只给向量建了 HNSW,标量字段没建 INVERTED 索引,带 expr 的查询直接掉到 400ms+。给 dept_id 补上 INVERTED 后回到 60ms 左右。Qdrant 的 payload 索引也需要显式创建(create_payload_index),否则过滤是暴力扫描,这点两边逻辑一致,别踩。

坑 2:Qdrant 默认 hnsw_ef 是 128,不是你以为的值。 我们建 collection 时没设 search 参数,结果查询延迟比预期高。显式传 search_params={"hnsw_ef": 64} 后才对齐 Milvus 的 ef=64。做公平对比时这个一定要固定。

坑 3:Milvus standalone 写入时要控制 flush 频率。 每批都 flush 会让 segment 碎片化,查询反而变慢。改成每 50 万条 flush 一次、最后统一 compact(),索引构建时间从 42 分钟降到 28 分钟。

优化:Qdrant 开 on_disk_payload=true。 1000 万条 payload 全放内存时 Qdrant 常驻内存涨到约 9.8GB;开启后降到 6.1GB,查询 P99 只增加约 4ms。对内存敏感的场景很值得。

六、效果数据

1000 万条 768 维,top-10,2000 次 query 打点:

指标 Milvus 2.4.4 Qdrant 1.9.2
部署耗时 3m40s 25s
空载内存 1.1GB 90MB
写入总耗时(1000万) 21m 48m
写入吞吐 ~7900 条/s ~3500 条/s
索引构建耗时 28m 19m
纯向量 P50 / P99 18ms / 47ms 15ms / 39ms
带过滤 P50 / P99 31ms / 82ms 27ms / 74ms
加载后常驻内存 约 14.2GB 约 6.1GB(on_disk_payload)

结论比较清晰:Qdrant 在单机、中等规模、内存敏感的场景下更省资源、延迟略低;Milvus 在写入吞吐和后续横向扩展(分布式、多副本、多索引类型)上更有优势。 我们的场景 QPS 只有 50、数据增长可控,最终选了 Qdrant;如果未来数据涨到亿级或者要上集群,Milvus 的分布式能力会是更稳的退路。

七、总结

这次对比最大的体会是:向量数据库的选型不能只看 benchmark 榜单,部署形态、资源模型、过滤索引这些"工程细节"往往才是决定因素。Milvus 功能全但组件多,Qdrant 轻量但分布式能力相对弱。建议大家在选型时,先把自己的数据规模、QPS、过滤比例、内存预算这几个数字列清楚,再用同参数跑一遍,结论通常不会和本文差太远。代码和参数都在上面,可以直接复现。