一、问题背景
我们做的是一条 RAG 检索链路,知识库规模大概 1200 万条 chunk,向量维度 768(bge-base-zh 输出)。原来的方案是 FAISS + 自建服务,问题很明显:增量更新要重建索引,多副本一致性靠自己写,运维成本高。于是决定换成专业的向量数据库。
候选就两个:Milvus 和 Qdrant。网上对比文章不少,但大多停留在"Milvus 功能全、Qdrant 轻量"这种定性描述,缺少可复现的数据。我干脆自己搭环境测一遍,把部署、写入、查询、资源占用都跑一遍,用数据说话。
先说结论,免得有人看不下去:单机中小规模(千万级以下)优先 Qdrant,运维简单、内存省;需要分布式横向扩展、多租户、强一致场景选 Milvus。下面是实测过程和依据。
二、环境与版本
硬件与软件环境统一如下,保证可比性:
| 项目 | 配置 |
|---|---|
| CPU | 16 vCPU (Intel Xeon Platinum 8369B) |
| 内存 | 64 GB |
| 磁盘 | 500 GB NVMe SSD |
| OS | Ubuntu 22.04.3 LTS |
| Docker | 24.0.7 |
| Milvus | 2.4.4 (docker-compose 单机集群) |
| Qdrant | 1.9.2 (单容器) |
| 客户端 | pymilvus 2.4.4 / qdrant-client 1.9.1 |
| 数据集 | 1000 万条 768 维 float32 随机向量 + 标量字段 |
向量用 numpy 随机生成,虽然不如真实 embedding 有聚类特征,但用于横向对比两者的索引构建和检索开销是够的,后面我会说明这个假设的局限。
三、方案设计
两个库都采用 HNSW 索引,参数尽量对齐:
- Milvus:
M=16, efConstruction=200,查询ef=64,metric 用 COSINE - Qdrant:
m=16, ef_construct=200,查询hnsw_ef=64,distance 用 Cosine
测试分三块:
- 部署:记录从零到服务可用的耗时和步骤复杂度
- 写入:批量灌 1000 万条,记录总耗时和内存峰值
- 查询:单线程延迟、32 并发 QPS、P99 延迟,分别测 top-10 检索
资源占用用 docker stats 采样,每 5 秒一次,取稳定期均值。
四、核心实现
4.1 Milvus 部署
Milvus 单机版推荐用官方 compose,但默认拉的是 standalone 全家桶(etcd + minio + milvus)。我直接用官方脚本:
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
# 查看状态
docker compose ps
启动后 etcd、minio、milvus-standalone 三个容器,从 up -d 到 healthcheck 通过大约 48 秒。注意 2.4 版本默认给 milvus 容器分配的内存上限不高,千万级数据要改 docker-compose.yml 里的 deploy.resources,我调到了 32G。
建集合和灌数据:
from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType
import numpy as np, time
connections.connect("default", host="127.0.0.1", port="19530")
fields = [
FieldSchema("id", DataType.INT64, is_primary=True),
FieldSchema("vec", DataType.FLOAT_VECTOR, dim=768),
FieldSchema("biz_id", DataType.INT64),
]
schema = CollectionSchema(fields, description="bench")
col = Collection("bench_milvus", schema)
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
col.create_index("vec", index_params)
BATCH = 5000
t0 = time.time()
for start in range(0, 10_000_000, BATCH):
ids = list(range(start, start + BATCH))
vecs = np.random.rand(BATCH, 768).astype(np.float32)
biz = [i % 100 for i in ids]
col.insert([ids, vecs.tolist(), biz])
col.flush()
print("insert cost:", time.time() - t0)
4.2 Qdrant 部署
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
从 docker run 到 6333 端口可访问大约 4 秒。这个差距不是一点半点,Qdrant 的部署体验确实舒服。
灌数据:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import numpy as np, time
client = QdrantClient(host="127.0.0.1", port=6333)
client.recreate_collection(
collection_name="bench_qdrant",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config={"m": 16, "ef_construct": 200},
)
BATCH = 5000
t0 = time.time()
for start in range(0, 10_000_000, BATCH):
vecs = np.random.rand(BATCH, 768).astype(np.float32)
points = [
PointStruct(id=start + i, vector=vecs[i].tolist(), payload={"biz_id": (start + i) % 100})
for i in range(BATCH)
]
client.upsert(collection_name="bench_qdrant", points=points)
print("insert cost:", time.time() - t0)
4.3 查询压测脚本
两边用同一套逻辑,单线程测延迟,多线程测 QPS:
import time, numpy as np, threading
from concurrent.futures import ThreadPoolExecutor
def bench_milvus(col, queries, topk=10, ef=64):
col.load()
col.set_properties({"collection.ttl.seconds": 0})
search_params = {"metric_type": "COSINE", "params": {"ef": ef}}
lat = []
for q in queries:
t = time.perf_counter()
col.search([q.tolist()], "vec", search_params, limit=topk)
lat.append((time.perf_counter() - t) * 1000)
return lat
def bench_qdrant(client, queries, topk=10, ef=64):
lat = []
for q in queries:
t = time.perf_counter()
client.search("bench_qdrant", query_vector=q.tolist(), limit=topk,
search_params={"hnsw_ef": ef})
lat.append((time.perf_counter() - t) * 1000)
return lat
qs = np.random.rand(500, 768).astype(np.float32)
并发部分用 ThreadPoolExecutor(max_workers=32) 跑 5000 次查询统计 QPS,这里不展开贴全量代码。
五、踩坑与优化
测的过程中遇到几个坑,值得记录:
坑 1:Milvus 写入不 flush 查不到数据。 灌完数据直接 search 返回空,必须 col.flush() 或者等 segment 落盘。生产环境建议用 growing segment 的 growing 查询参数,别频繁手动 flush,会拖慢写入。
坑 2:Qdrant 批量 upsert 太大反而慢。 我一开始 BATCH 设 50000,单次请求 payload 巨大,gRPC 层超时。调到 5000 后吞吐反而提升约 18%。经验值:单批 1000~10000 之间根据向量维度调。
坑 3:Qdrant 默认把 payload 放内存。 1000 万条带 payload 的数据,内存飙升。加 ON_DISK_PAYLOAD=true 后内存降下来,但标量过滤查询延迟会略增。如果过滤字段查询频繁,还是建议留内存。
优化:ef 参数调优。 两边都试了 ef=32/64/128。ef=64 时 Qdrant P99 约 12ms、Milvus 约 15ms;ef=128 时召回提升但延迟翻倍。生产上我们用 ef=64 配合业务侧 rerank,够用。
六、效果数据
实测数据汇总(1000 万条 768 维,HNSW M=16/efConstruction=200,查询 ef=64,top-10):
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| 部署到可用耗时 | 48 s | 4 s |
| 容器数 | 3 | 1 |
| 千万条写入总耗时 | 约 41 min | 约 36 min |
| 写入期内存峰值 | 28.6 GB | 19.4 GB |
| 稳定期内存占用 | 22.1 GB | 14.3 GB |
| 单线程 P50 延迟 | 6.8 ms | 5.2 ms |
| 单线程 P99 延迟 | 15.3 ms | 12.1 ms |
| 32 并发 QPS | 4820 | 5310 |
| 32 并发 P99 | 21.4 ms | 18.7 ms |
几点解读:
- 内存差距明显,Qdrant 稳定期少用约 7.8 GB(约 35%),对我们这种单机部署、内存吃紧的场景是加分项。
- 查询性能 Qdrant 略优,单机同等参数下 P99 低 20% 左右。但要注意,这是单机对比,Milvus 的分布式扩展能力在这轮没体现。
- 写入 Milvus 略慢,主要是 flush 和 segment 合并的开销,但如果开
growing查询、减少 flush,差距会缩小。 - 部署复杂度差距最大,Qdrant 一个容器搞定,Milvus 要维护 etcd 和 minio,运维心智负担不同。
需要说明的局限:随机向量没有真实 embedding 的聚类结构,HNSW 的实际召回和延迟在真实数据上会有差异,尤其是过滤查询场景。建议大家在选型时用自己的样本数据跑一遍。另外 Milvus 的分布式模式(cluster)没测,横向扩展能力是它相对 Qdrant 的核心优势,如果规模上亿,结论可能反过来。
七、总结
回到选型问题,我的判断是:
- 千万级以下、单机部署、团队运维人力有限:选 Qdrant。部署快、内存省、查询延迟低,API 也简洁。
- 上亿规模、需要多副本高可用、多租户隔离、或者已经在用 Milvus 生态:选 Milvus。分布式架构成熟,功能全(分区、alias、多索引类型),代价是运维复杂度和资源占用。
- 中间地带:可以先 Qdrant 单机扛,到瓶颈再评估迁移成本。我们最终选了 Qdrant,因为当前数据量和团队规模下它的性价比最高,迁移路径也留好了(Qdrant 支持分布式集群模式,后续可以平滑升级)。
最后一句忠告:别只看别人的 benchmark,向量检索性能跟数据分布、维度、过滤条件强相关,一定要拿自己的数据跑。上面的脚本可以直接复用,改改数据源就能测。