一、问题背景

我们做的是一条 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

测试分三块:

  1. 部署:记录从零到服务可用的耗时和步骤复杂度
  2. 写入:批量灌 1000 万条,记录总耗时和内存峰值
  3. 查询:单线程延迟、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

几点解读:

  1. 内存差距明显,Qdrant 稳定期少用约 7.8 GB(约 35%),对我们这种单机部署、内存吃紧的场景是加分项。
  2. 查询性能 Qdrant 略优,单机同等参数下 P99 低 20% 左右。但要注意,这是单机对比,Milvus 的分布式扩展能力在这轮没体现。
  3. 写入 Milvus 略慢,主要是 flush 和 segment 合并的开销,但如果开 growing 查询、减少 flush,差距会缩小。
  4. 部署复杂度差距最大,Qdrant 一个容器搞定,Milvus 要维护 etcd 和 minio,运维心智负担不同。

需要说明的局限:随机向量没有真实 embedding 的聚类结构,HNSW 的实际召回和延迟在真实数据上会有差异,尤其是过滤查询场景。建议大家在选型时用自己的样本数据跑一遍。另外 Milvus 的分布式模式(cluster)没测,横向扩展能力是它相对 Qdrant 的核心优势,如果规模上亿,结论可能反过来。

七、总结

回到选型问题,我的判断是:

  • 千万级以下、单机部署、团队运维人力有限:选 Qdrant。部署快、内存省、查询延迟低,API 也简洁。
  • 上亿规模、需要多副本高可用、多租户隔离、或者已经在用 Milvus 生态:选 Milvus。分布式架构成熟,功能全(分区、alias、多索引类型),代价是运维复杂度和资源占用。
  • 中间地带:可以先 Qdrant 单机扛,到瓶颈再评估迁移成本。我们最终选了 Qdrant,因为当前数据量和团队规模下它的性价比最高,迁移路径也留好了(Qdrant 支持分布式集群模式,后续可以平滑升级)。

最后一句忠告:别只看别人的 benchmark,向量检索性能跟数据分布、维度、过滤条件强相关,一定要拿自己的数据跑。上面的脚本可以直接复用,改改数据源就能测。