一、问题背景

上个月接手一个语义检索项目:商品知识库约 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% 以上,别直接抄别人的数字做决策。