一、问题背景
上个月接手一个语义检索项目:商品知识库约 120 万条文本,全部经 bge-large-zh 编码成 768 维向量,需要支持实时 Top-K 相似检索,QPS 峰值约 50,要求 P99 控制在 50ms 以内。团队此前一直用 FAISS 做离线索引,但业务方要求支持增量更新和按标签过滤,FAISS 的裸索引方案维护成本太高,于是决定引入专业向量数据库。
候选里最终剩下两个:Milvus 和 Qdrant。两者都是当前社区活跃度最高的开源向量库,但设计哲学差异很大——Milvus 是云原生架构,组件多、依赖 etcd 和对象存储,天生为分布式准备;Qdrant 是 Rust 写的单二进制服务,部署轻量,过滤能力强。网上的对比文章大多只贴 benchmark 榜单,缺少真实业务数据下的部署细节和资源占用数据,所以我自己搭环境跑了一遍。这篇文章就是完整记录。
二、环境与版本
测试机器:阿里云 ECS,8 核 16G,ESSD 云盘 200G,Ubuntu 22.04。
| 组件 | 版本 |
|---|---|
| Milvus | 2.4.4 (standalone, docker-compose) |
| Qdrant | 1.9.2 (docker) |
| Python SDK | pymilvus 2.4.4 / qdrant-client 1.9.1 |
| 数据集 | 100 万条 × 768 维 float32,随机生成 + 真实业务抽样混合 |
| 查询集 | 1000 条 query 向量,取 Top-10 |
Milvus 采用官方 standalone 的 docker-compose(包含 etcd、minio、milvus 三个容器),Qdrant 直接用官方镜像单容器启动。
三、方案设计
对比维度定了五个:部署复杂度、批量写入吞吐、索引构建时间、查询延迟分布、资源占用(RSS + 磁盘)。索引统一使用 HNSW,参数尽量对齐:
- Milvus:
M=16, efConstruction=200,查询ef=64,metric 用 COSINE - Qdrant:
m=16, ef_construct=200,查询hnsw_ef=64,distance 用 Cosine
写入统一按 batch=1000 分批,索引在数据全部写入后统一构建,保证公平。查询测试前各跑 200 次预热,再统计 1000 次结果。
四、核心实现
4.1 部署步骤
Milvus standalone 的 compose 文件(官方 milvus-standalone-docker-compose.yml)启动:
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
docker compose ps
Qdrant 一条命令:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
说实话,Qdrant 的部署体验明显更清爽,一个容器搞定;Milvus 虽然是 standalone,但 etcd 和 minio 的日志噪音不小,第一次启动等了将近 40 秒才 ready。
4.2 写入与建索引代码
Milvus 侧:
from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection, utility
import numpy as np, time
connections.connect(host="127.0.0.1", port="19530")
DIM, N = 768, 1_000_000
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=DIM),
]
schema = CollectionSchema(fields, description="bench")
col = Collection("bench_milvus", schema)
vectors = np.random.rand(N, DIM).astype(np.float32)
ids = list(range(N))
t0 = time.time()
batch = 1000
for i in range(0, N, batch):
col.insert([ids[i:i+batch], vectors[i:i+batch].tolist()])
col.flush()
print(f"insert cost: {time.time()-t0:.1f}s")
index_params = {"index_type": "HNSW", "metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200}}
t0 = time.time()
col.create_index("vec", index_params)
col.load()
print(f"index cost: {time.time()-t0:.1f}s")
Qdrant 侧:
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct, HnswConfigDiff
import numpy as np, time
client = QdrantClient(host="127.0.0.1", port=6333)
DIM, N = 768, 1_000_000
client.recreate_collection(
collection_name="bench_qdrant",
vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
vectors = np.random.rand(N, DIM).astype(np.float32)
t0 = time.time()
batch = 1000
for i in range(0, N, batch):
points = [PointStruct(id=ids_i, vector=vectors[i+j].tolist())
for j, ids_i in enumerate(range(i, i+batch))]
client.upsert(collection_name="bench_qdrant", points=points, wait=False)
print(f"upsert cost: {time.time()-t0:.1f}s")
4.3 查询测试
# Milvus
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}
lat = []
for q in queries[:1000]:
t = time.perf_counter()
col.search([q.tolist()], "vec", search_params, limit=10)
lat.append((time.perf_counter()-t)*1000)
# Qdrant
lat = []
for q in queries[:1000]:
t = time.perf_counter()
client.search(collection_name="bench_qdrant", query_vector=q.tolist(), limit=10)
lat.append((time.perf_counter()-t)*1000)
五、踩坑与优化
第一坑:Milvus 写入时如果不开 flush 就建索引,会报 segment 未 sealed 的错误,必须先 flush() 再 create_index(),否则数据量大的时候索引会漏掉尾部数据。另外 insert 的向量用 .tolist() 转 list 比直接传 numpy 慢不少,实测 100 万条多花了近 20 秒,建议用 entities 批量接口或者直接传 numpy(2.4 已支持)。
第二坑:Qdrant 的 upsert 默认 wait=True,逐批同步确认会让写入速度掉一半,改成 wait=False 后吞吐翻倍,但要注意最后用 client.count() 确认数据全部落盘再开始查询。
第三坑:Milvus standalone 默认 queryNode 的 cache 配置较小,加载 100 万条 768 维向量后 RSS 会飙到 9G 左右,如果机器内存紧张需要调 milvus.yaml 里的 queryNode.cache.memoryLimit。Qdrant 则可以通过 --mem-limit 或者配置文件里的 storage.optimizers 控制,默认策略更省内存。
六、效果数据
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| 部署启动时间 | 约 40s(3 容器) | 约 3s(单容器) |
| 100 万写入耗时 | 186s | 152s |
| HNSW 建索引耗时 | 78s | 61s |
| 查询 P50 | 9ms | 6ms |
| 查询 P95 | 21ms | 10ms |
| 查询 P99 | 34ms | 12ms |
| 加载后 RSS | 9.1GB | 5.3GB |
| 磁盘占用 | 4.2GB(含 minio) | 3.1GB |
结论很清晰:在本场景(单机、百万级、过滤需求不复杂)下,Qdrant 全面占优——内存省 40%,P99 延迟低一半以上,部署和运维成本也低。Milvus 的优势在于写入吞吐和生态:如果数据量上到千万级、需要分布式水平扩展、或者团队已有 K8s 基础设施,Milvus 的云原生架构才是正确选择。我们最终选了 Qdrant,因为业务峰值数据量预计两年内不超过 500 万,单机加副本足够,没必要为用不上的扩展性付出运维复杂度。
七、总结
选型不是选"最强",而是选"最合适"。百万级、单机、低运维成本的场景,Qdrant 1.9 是我目前更推荐的选择;千万级以上、需要多副本和弹性扩缩容,Milvus 2.4 的架构优势才会真正体现。另外提醒一句:所有 benchmark 都要在自己的数据分布上重跑一遍,随机向量和真实业务向量的检索延迟能差 20% 以上,别直接抄别人的数字做决策。