一、问题背景
我们团队在做一套面向内部文档的语义检索系统,数据规模预估在 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
测试分三块:
- 部署耗时与资源:从零拉起服务到可写入的耗时,以及空载内存占用。
- 写入性能:1000 万条数据分批写入的总耗时与吞吐。
- 查询性能:用 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、过滤比例、内存预算这几个数字列清楚,再用同参数跑一遍,结论通常不会和本文差太远。代码和参数都在上面,可以直接复现。