一、问题背景
我们做的是一个图文内容推荐系统,需要根据用户最近点击的几十条内容,从约1000万条内容库里召回相似的候选集,再交给排序模型。向量是CLIP ViT-L/14输出的768维,每天新增约20万条。
最早我们用的是FAISS,但FAISS是库不是服务,多进程共享索引、增量更新、按条件过滤都很麻烦。于是决定换成向量数据库。候选主要是Milvus和Qdrant:
- Milvus:CNCF毕业项目,生态成熟,支持存算分离、多种索引、标量过滤,社区大。
- Qdrant:Rust写的,单机性能强,过滤和payload支持好,部署轻。
网上很多对比文章要么只讲概念,要么数据量太小(几万条),参考价值有限。所以我用生产同量级的数据做了一次实测,记录如下。
二、环境与版本
硬件:一台阿里云ECS,16 vCPU、64GB内存、ESSD PL2云盘,Ubuntu 22.04。
软件版本:
- Milvus 2.4.0(standalone,Docker Compose)
- Qdrant 1.9.0(Docker)
- Python 3.10,pymilvus 2.4.0,qdrant-client 1.9.0
- 数据:1000万条768维float32向量,随机生成但做归一化,模拟CLIP输出分布
索引配置:
- Milvus:HNSW,M=16,efConstruction=200,metric=IP
- Qdrant:HNSW,m=16,ef_construct=200,metric=Dot
两边都开标量过滤字段 category(约50个取值)。
三、方案设计
对比目标有三个:
- 单机部署难度和资源占用。
- 查询性能:不同 topK、是否带过滤、不同 ef/search_params 下的QPS和P99。
- 批量写入吞吐。
测试方法:用 locust 风格的 Python 脚本压测,固定并发32,每轮跑5分钟,取稳定段数据。查询向量从测试集中随机取,避免缓存命中偏差。
四、核心实现
4.1 部署
Milvus standalone 用官方 compose:
wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
# 默认端口 19530 (gRPC), 9091 (metrics)
Qdrant 单容器:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.9.0
4.2 建集合与写入
Milvus:
from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection, utility
connections.connect("default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="category", dtype=DataType.INT64),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="content vectors")
col = Collection("content_milvus", schema)
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.load()
# 分批写入,每批1万
import numpy as np
BATCH = 10000
for i in range(0, 10_000_000, BATCH):
ids = list(range(i, i + BATCH))
cats = np.random.randint(0, 50, BATCH).tolist()
vecs = np.random.rand(BATCH, 768).astype(np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
col.insert([ids, cats, vecs.tolist()])
Qdrant:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="content_qdrant",
vectors_config=VectorParams(size=768, distance=Distance.DOT),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)
BATCH = 10000
for i in range(0, 10_000_000, BATCH):
vecs = np.random.rand(BATCH, 768).astype(np.float32)
vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
points = [
PointStruct(
id=i + j,
vector=vecs[j].tolist(),
payload={"category": int(np.random.randint(0, 50))},
)
for j in range(BATCH)
]
client.upsert(collection_name="content_qdrant", points=points)
4.3 查询
Milvus 带过滤:
search_params = {"metric_type": "IP", "params": {"ef": 128}}
res = col.search(
data=[query_vec],
anns_field="embedding",
param=search_params,
limit=10,
expr="category == 3",
output_fields=["category"],
)
Qdrant 带过滤:
from qdrant_client.models import Filter, FieldCondition, MatchValue
hits = client.search(
collection_name="content_qdrant",
query_vector=query_vec.tolist(),
limit=10,
query_filter=Filter(
must=[FieldCondition(key="category", match=MatchValue(value=3))]
),
search_params={"hnsw_ef": 128},
)
五、踩坑与优化
坑1:Milvus standalone 内存暴涨。 写入阶段发现内存一路涨到20GB+,后来发现是 queryNode 的 cache 和 dataNode 的 flush 策略问题。把 queryNode.cache.enabled 调小、增大 dataCoord.segment.maxSize 到 1024MB,内存稳定在8GB左右。Qdrant 默认内存占用就温和很多。
坑2:Qdrant 批量 upsert 太大反而慢。 一开始 batch 设成5万,单批耗时抖动明显。改成1万后吞吐稳定。官方建议也是单请求不超过几百MB。
坑3:过滤性能差异。 Milvus 在带高选择性过滤(category命中约2%数据)时,会走标量索引+向量索引的组合,P99比无过滤高约60%。Qdrant 的 filterable HNSW 在payload过滤上表现更平滑,P99只高约35%。这一点在我们的场景很关键,因为推荐召回几乎总是带类目或时间过滤。
坑4:索引构建时间。 1000万条,Milvus HNSW 构建约42分钟,Qdrant约35分钟。两者都可以边写边建,但 Milvus 需要先 flush 再 index,Qdrant 是后台自动构建。
六、效果数据
稳定段实测(并发32,topK=10,ef=128,单位ms/QPS):
| 指标 | Milvus 2.4.0 | Qdrant 1.9.0 |
|---|---|---|
| 无过滤 QPS | 168 | 212 |
| 无过滤 P99 | 72ms | 48ms |
| 带类目过滤 QPS | 96 | 158 |
| 带类目过滤 P99 | 118ms | 65ms |
| 内存占用(稳定) | 7.8GB | 3.2GB |
| 批量写入吞吐 | 约 4200 条/s | 约 3000 条/s |
| 索引构建时间 | 42min | 35min |
| 冷启动加载 | 约90s | 约40s |
写入方面 Milvus 更强,得益于它的日志结构和批量flush;查询尤其是带过滤查询,Qdrant 明显更优。资源占用 Qdrant 只有 Milvus 的约40%。
七、总结
选型不是非黑即白,看场景:
- 如果你的数据量在千万级、单机或小集群、查询带较多标量过滤、对内存敏感,Qdrant 更合适。我们最终生产选了 Qdrant,P99从原来的110ms降到65ms,内存省了一半。
- 如果你需要存算分离、十亿级以上、写入吞吐优先、团队已有K8s和Milvus运维经验,Milvus 的生态和扩展性更稳。
另外提醒两点:一是别只看官方benchmark,自己用生产分布的数据压一遍;二是索引参数(M、ef、ef_construct)对结果影响很大,本文数据仅代表上述配置。下一步我准备测试两者在十亿级分片下的表现,有兴趣可以关注。