一、问题背景
我们做的是一个图文内容平台,日增图文素材约 40 万条。上线两年后,去重和"相似内容推荐"两个功能开始拖后腿:原方案是 MySQL 存向量 + 内存里暴力算余弦相似度,数据量到 800 万时,一次批量去重请求要跑 9 秒,服务端内存被打到 90% 以上。
需求很明确:
- 存量 1200 万条向量,维度 768(CLIP ViT-L/14 输出),后续可能换 1024 维多模态模型;
- 单条查询 P95 控制在 50ms 以内,批量查询(一次 200 条)走异步;
- 必须支持按
tenant_id、category做标量过滤后再做向量检索,不能"先召回再过滤"; - 团队只有 3 个后端,运维能力有限,最好能单机起步、后续平滑扩集群。
候选就两个:Milvus 和 Qdrant。网上文章要么是跑个 10 万条 demo 就下结论,要么通篇概念介绍。我干脆自己搭环境、灌真实数据、压测,把数字摆出来。
二、环境与版本
- 机器:阿里云 ecs.g7.4xlarge,16 vCPU / 64GB / ESSD PL1 500GB,Ubuntu 22.04
- Docker:24.0.7,Docker Compose v2.23.0
- Milvus:2.4.4(standalone 模式,etcd + MinIO 依赖)
- Qdrant:1.9.2(单节点)
- 客户端:pymilvus 2.4.4、qdrant-client 1.9.1、Python 3.11
- 数据集:1200 万条 768 维 float32 向量,随机生成但按 200 个 tenant 分布(模拟真实倾斜),另带
category(20 类)标量字段
三、方案设计
对比原则:两边都用各自"官方推荐的通用配置",不刻意调优某一方,先看开箱表现,再针对差距做一轮优化。
索引参数统一思路:
- 都用 HNSW。Milvus 的
M=16, efConstruction=200,查询ef=64;Qdrant 的m=16, ef_construct=200,查询hnsw_ef=64。 - 距离度量都用 COSINE。
- 写入都走批量(batch=2000),不建额外标量索引(第一轮),第二轮再给过滤字段加索引看差异。
测试项:
- 灌库耗时与磁盘/内存占用(灌完后 sleep 5 分钟再读,避免缓存干扰)
- 单条 topK=10 查询,QPS 与 P50/P95/P99(200 并发,持续 5 分钟)
- 带
tenant_id过滤的查询延迟 - 重启后首次查询冷启动延迟
四、核心实现
4.1 部署
Milvus standalone 用官方 compose(简化版,只列关键服务):
# docker-compose-milvus.yml
version: '3.5'
services:
etcd:
image: quay.io/coreos/etcd:v3.5.5
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
volumes:
- ./volumes/etcd:/etcd
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
command: minio server /minio_data --console-address ":9001"
volumes:
- ./volumes/minio:/minio_data
standalone:
image: milvusdb/milvus:v2.4.4
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
docker compose -f docker-compose-milvus.yml up -d,约 40 秒后 curl localhost:9091/healthz 返回 OK。
Qdrant 简单得多:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
qdrant/qdrant:v1.9.2
这里有个小坑:Qdrant 默认 storage 里 on_disk_payload 是 false,payload 全进内存。我们 1200 万条带 tenant_id/category,灌到 900 万时 RSS 就爬到 30GB 了。第二轮改成 collection 级别 on_disk_payload=true 才压下来。
4.2 建表与灌数据
Milvus:
from pymilvus import MilvusClient, DataType
import numpy as np, time
client = MilvusClient(uri="http://localhost:19530")
COLL = "img_vec_v1"
if client.has_collection(COLL):
client.drop_collection(COLL)
schema = client.create_schema(auto_id=True, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("tenant_id", DataType.INT64)
schema.add_field("category", DataType.INT32)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=768)
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding",
index_type="HNSW",
metric_type="COSINE",
params={"M": 16, "efConstruction": 200},
)
client.create_collection(COLL, schema=schema, index_params=index_params)
BATCH = 2000
t0 = time.time()
for start in range(0, 12_000_000, BATCH):
n = min(BATCH, 12_000_000 - start)
rows = [{
"tenant_id": (start + i) % 200,
"category": (start + i) % 20,
"embedding": np.random.rand(768).astype(np.float32).tolist(),
} for i in range(n)]
client.insert(COLL, rows)
print("insert cost:", round(time.time() - t0, 1), "s")
client.flush(COLL)
Qdrant:
from qdrant_client import QdrantClient
from qdrant_client.models import (VectorParams, Distance, PointStruct,
HnswConfigDiff, OptimizersConfigDiff)
import numpy as np, time
qc = QdrantClient(host="localhost", port=6333)
COLL = "img_vec_v1"
qc.recreate_collection(
collection_name=COLL,
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
optimizers_config=OptimizersConfigDiff(
indexing_threshold=20000,
memmap_threshold=200000,
),
on_disk_payload=True, # 关键,见踩坑
)
BATCH = 2000
t0 = time.time()
for start in range(0, 12_000_000, BATCH):
n = min(BATCH, 12_000_000 - start)
points = [
PointStruct(
id=start + i,
vector=np.random.rand(768).astype(np.float32).tolist(),
payload={"tenant_id": (start + i) % 200, "category": (start + i) % 20},
) for i in range(n)
]
qc.upsert(collection_name=COLL, points=points, wait=False)
# 等待索引构建完成
while qc.get_collection(COLL).status != "green":
time.sleep(5)
print("insert cost:", round(time.time() - t0, 1), "s")
注意 Qdrant 的 wait=False 是必须的,否则每批都同步等索引,灌库时间会被放大三倍以上。但代价是最终一致性——压测前一定要轮询 collection 状态到 green。
4.3 查询
Milvus 带过滤查询:
res = client.search(
collection_name=COLL,
data=[query_vec],
anns_field="embedding",
search_params={"metric_type": "COSINE", "params": {"ef": 64}},
filter='tenant_id == 7 and category in [1,3,5]',
limit=10,
output_fields=["tenant_id", "category"],
)
Qdrant 等价写法:
from qdrant_client.models import Filter, FieldCondition, MatchValue, MatchAny
res = qc.search(
collection_name=COLL,
query_vector=query_vec,
query_filter=Filter(must=[
FieldCondition(key="tenant_id", match=MatchValue(value=7)),
FieldCondition(key="category", match=MatchAny(any=[1, 3, 5])),
]),
limit=10,
search_params={"hnsw_ef": 64, "exact": False},
)
五、踩坑与优化
坑 1:Qdrant payload 内存膨胀。 上面提过,默认 payload 全内存。1200 万条 × 两个 int 字段看着不大,但 Qdrant 的 payload 存储有额外开销,实测占了约 9GB。开 on_disk_payload=true 后降到 2GB 左右,代价是过滤查询 P95 从 15ms 涨到 18ms,可以接受。
坑 2:Milvus 的 flush 不要滥用。 我一开始每 10 万条 flush 一次,结果 segment 碎成几百个,查询时 merge 开销巨大,P99 直接飙到 300ms+。改成灌完统一 flush 一次,再等 compact 完成,P99 回到 80ms 以内。生产上应该靠 dataCoord.segment.maxSize 控制,别手动频繁 flush。
坑 3:过滤字段必须建标量索引。 第一轮两边都没给 tenant_id 建索引,Milvus 在过滤命中率低时退化明显(某次 tenant 只有 6 万条数据,查询 P95 到了 120ms);Qdrant 相对好一些但也在 60ms 上下。第二轮 Milvus 加 create_index("tenant_id", index_type="INVERTED"),Qdrant 加 create_payload_index(..., field_schema=PayloadSchemaType.INTEGER) 后,两边都降到 30ms 以内。这一步是必做项,不是优化项。
坑 4:Qdrant 的 memmap_threshold。 默认值是 200000,意味着向量数超过 20 万就走 mmap。单机 64GB 内存其实可以调大,让热点段留在内存里。我们调到 400 万后,QPS 提升约 22%,但内存涨了 6GB。这是个明确的 trade-off,看机器规格定。
六、效果数据
灌库与资源占用(灌完静置 5 分钟后采样):
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| 1200 万条灌库耗时 | 52 min | 38 min |
| 磁盘占用 | 41 GB | 33 GB |
| 常驻内存 RSS | 29 GB | 21 GB(开 on_disk_payload) |
| 进程数 | 5(含 etcd/minio) | 1 |
查询性能(topK=10,200 并发,持续 5 分钟,无过滤):
| 指标 | Milvus | Qdrant |
|---|---|---|
| QPS | 1180 | 1640 |
| P50 | 21 ms | 11 ms |
| P95 | 34 ms | 18 ms |
| P99 | 79 ms | 41 ms |
带 tenant_id + category 过滤(命中约 3 万条,已建标量索引):
| 指标 | Milvus | Qdrant |
|---|---|---|
| P95 | 27 ms | 26 ms |
| P99 | 61 ms | 55 ms |
冷启动首次查询(重启后第一条):
| 指标 | Milvus | Qdrant |
|---|---|---|
| 首次查询延迟 | 2.1 s | 0.9 s |
结论基本清晰:单机场景下 Qdrant 全面占优,资源省、部署简单、延迟低。Milvus 的优势在别处:
- 我们做过一轮 3 节点 Milvus 集群的预演(querynode × 2 + 独立 proxy),QPS 线性涨到 3100,扩容路径很清晰;Qdrant 的分布式模式(1.9 还是 beta 阶段)当时不敢上生产。
- Milvus 支持 partition key 做多租户物理隔离,200 个 tenant 的场景下,按 tenant 分区后过滤查询还能再快一截;Qdrant 只有 payload 索引,逻辑隔离。
- 团队之前有 Kafka + MinIO 的运维经验,Milvus 的依赖栈不算陌生。
最终选型:当前阶段用 Qdrant 单机,1200 万规模完全够用,运维成本最低。同时把数据写入层做了解耦(向量库只做检索,元数据仍在 PG),预留了后续切 Milvus 集群的接口——如果数据量涨到 5000 万以上或需要强多租户隔离,再迁移。
七、总结
几点可直接拿走的经验:
- 千万级、单机、团队小,Qdrant 是更务实的选择,部署一条 docker run,内存和磁盘都省 30% 左右。
- 过滤字段的标量索引是必做项,不做的话两边延迟都能差 3-4 倍,别拿"没建索引"的压测数据下结论。
- Qdrant 的
on_disk_payload和memmap_threshold是两个必须显式配置的参数,默认值在千万级数据下会坑人。 - Milvus 别手动频繁 flush,segment 碎片对 P99 的影响比想象中大。
- 压测一定要等索引构建完成(Milvus 看
indexBuildProgress,Qdrant 轮询 collection status 到 green),否则数字全是假的。
选型没有绝对优劣,只有场景匹配。如果你也在做类似决策,建议先用真实数据量灌一遍——10 万条 demo 的结论,到 1000 万条基本都会翻车。