一、问题背景
团队在做一个企业内部知识库的语义检索服务,数据规模预估在千万级向量,维度768(来自bge-large-zh-v1.5)。业务对延迟的要求是P99控制在100ms以内,同时希望运维成本别太高——我们只有一台16核64GB的物理机可用,短期内不打算上K8s集群。
候选方案锁定在两个:Milvus和Qdrant。两者都是当前社区活跃度很高的向量数据库,但设计哲学差别挺大。Milvus走的是云原生分布式路线,组件多、能力强;Qdrant是Rust写的单二进制服务,部署轻量。网上大部分对比文章要么只跑个demo,要么数据规模太小看不出差异,所以我决定自己按真实业务规模压一遍。
这篇博客不吹不黑,只讲我实测到的数字和踩过的坑。
二、环境与版本
- 硬件:单机,AMD EPYC 7302 16核32线程,64GB DDR4,NVMe SSD 1TB
- OS:Ubuntu 22.04 LTS
- Docker:24.0.7
- Milvus:2.4.4(standalone 模式,docker-compose 部署)
- Qdrant:1.9.2(单容器部署)
- 客户端:Python 3.11,pymilvus 2.4.4,qdrant-client 1.9.1
- 数据集:1000万条 768 维 float32 向量,随机生成(正态分布),另附1000条查询向量
需要说明的是,Milvus 2.4 的 standalone 模式其实内部还是跑着 etcd、minio、pulsar 这一套依赖,只是打包在一个 compose 里。这点后面部署时会体现出来。
三、方案设计
两个库都使用 HNSW 索引,参数尽量对齐以便公平比较:
- M(每层最大连接数):16
- efConstruction:200
- 查询时 ef:64
- 距离度量:COSINE
数据写入采用批量插入,batch size 设为 2000。压测使用 1000 条查询向量,串行执行,统计 P50 / P95 / P99 延迟和 QPS。每轮压测前先 warmup 100 次查询。
四、核心实现
4.1 Qdrant 部署与写入
Qdrant 的部署非常直接,一条 docker 命令搞定:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.2
写入和建索引的代码如下:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
import numpy as np
client = QdrantClient(host="localhost", port=6333)
COLLECTION = "kb_vectors"
DIM = 768
# 建集合,指定 HNSW 参数
client.recreate_collection(
collection_name=COLLECTION,
vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
hnsw_config={
"m": 16,
"ef_construct": 200,
"full_scan_threshold": 10000,
},
)
def batch_insert(vectors: np.ndarray, start_id: int, batch_size: int = 2000):
for i in range(0, len(vectors), batch_size):
chunk = vectors[i:i + batch_size]
points = [
PointStruct(
id=start_id + i + j,
vector=chunk[j].tolist(),
payload={"source": "test"},
)
for j in range(len(chunk))
]
client.upsert(collection_name=COLLECTION, points=points, wait=False)
# 假设 all_vectors 是 (10_000_000, 768) 的 np.ndarray
# batch_insert(all_vectors, 0)
注意 wait=False,写入是异步落盘的。全量写完后我调了一次 client.count() 确认数量,再等索引构建完成(Qdrant 会在后台自动建 HNSW)。
4.2 Milvus 部署与写入
Milvus standalone 需要下载官方 compose 文件:
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 ps 能看到三个容器:milvus-standalone、milvus-etcd、milvus-minio。内存占用从一开始就比 Qdrant 高不少。
写入代码:
from pymilvus import (
connections, Collection, CollectionSchema,
FieldSchema, DataType, utility
)
import numpy as np
connections.connect(host="localhost", port="19530")
COLLECTION = "kb_vectors"
DIM = 768
if utility.has_collection(COLLECTION):
utility.drop_collection(COLLECTION)
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=DIM),
FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=64),
]
schema = CollectionSchema(fields=fields)
collection = Collection(name=COLLECTION, schema=schema)
# 建 HNSW 索引
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
collection.create_index(field_name="vector", index_params=index_params)
def batch_insert(vectors: np.ndarray, start_id: int, batch_size: int = 2000):
for i in range(0, len(vectors), batch_size):
chunk = vectors[i:i + batch_size]
ids = [start_id + i + j for j in range(len(chunk))]
entities = [
ids,
chunk.tolist(),
["test"] * len(chunk),
]
collection.insert(entities)
collection.flush()
Milvus 的 flush() 会触发数据持久化和索引构建,1000万条数据这一步等了大概几分钟。
4.3 压测脚本
两个库共用一个压测函数,只替换 search 调用:
import time
import numpy as np
def benchmark(search_fn, queries: np.ndarray, top_k: int = 10):
# warmup
for q in queries[:100]:
search_fn(q, top_k)
latencies = []
for q in queries:
t0 = time.perf_counter()
search_fn(q, top_k)
latencies.append((time.perf_counter() - t0) * 1000)
latencies = np.array(latencies)
return {
"p50": float(np.percentile(latencies, 50)),
"p95": float(np.percentile(latencies, 95)),
"p99": float(np.percentile(latencies, 99)),
"qps": float(1000.0 / latencies.mean()),
}
五、踩坑与优化
坑1:Milvus 的 insert 不落盘。 一开始我没调 flush(),直接搜发现召回为0。Milvus 是分段存储的,插入的数据在内存 buffer 里,必须 flush 或等 segment 达到阈值才会被索引。大批量导入后务必手动 flush 一次。
坑2:Qdrant 的 ef 参数位置。 Qdrant 查询时的 ef 不是建集合时设的,而是在 search_params 里传,容易搞混:
from qdrant_client.models import SearchParams
client.search(
collection_name=COLLECTION,
query_vector=query.tolist(),
limit=10,
search_params=SearchParams(hnsw_ef=64, exact=False),
)
坑3:内存。 1000万×768×4字节 ≈ 28.6GB 原始向量数据。Milvus 加上 etcd、minio 和 segment 索引开销,实测常驻内存 31GB 左右;Qdrant 用 mmap 存储,实测常驻 22GB,明显更省。我们64GB的机器跑 Milvus 时系统负载偏高,跑 Qdrant 则有余量。
优化: Qdrant 开启 on_disk_payload 并把向量也配置为 mmap 存储后,内存还能再降,但 P99 会上升约 15%。权衡后我们保留了内存模式。
六、效果数据
1000万条数据,1000次查询,top_k=10,统计如下:
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| P50 延迟 | 31 ms | 21 ms |
| P95 延迟 | 45 ms | 33 ms |
| P99 延迟 | 52 ms | 38 ms |
| QPS(串行) | 27 | 43 |
| 常驻内存 | 31 GB | 22 GB |
| 磁盘占用 | 34 GB | 30 GB |
| 全量导入耗时 | 约 18 分钟 | 约 12 分钟 |
| 冷启动到可服务 | 约 90 秒 | 约 8 秒 |
单机场景下 Qdrant 在延迟和内存上都占优,这和我预期一致——Rust + 单进程 + mmap 的架构在单机上是天然优势。Milvus 的价值在分布式扩展、多租户、标量过滤的成熟度,以及更完整的企业级能力,但这些在单机千万级场景里用不上。
七、总结
如果你的场景是单机或小规模集群、数据在千万级以内、追求低运维成本和低延迟,我这次的实测结论是选 Qdrant,部署简单、内存友好、P99 更稳。
如果你已经确定要上分布式、数据量会往亿级走、需要成熟的资源隔离和标量过滤能力,Milvus 更合适,代价是组件多、内存开销大、运维复杂度高。
我们最终选了 Qdrant,因为它匹配当前业务规模。但选型不是一锤定音的事,等数据量翻十倍,我会再回来测一轮 Milvus 集群模式。