一、问题背景
去年底我们做了一个图文内容平台,用户上传的图片和文案需要做相似度去重和推荐召回。最初用的是 FAISS + Redis 的土办法,向量存在 Redis 里,检索靠 FAISS 的 IndexIVFFlat 在内存里跑。数据量到 300 万条、768 维之后,问题开始集中爆发:
- Redis 单实例内存飙到 40GB+,运维天天报警;
- FAISS 索引不支持增量更新,每次新增数据要重建索引,重建一次 20 多分钟;
- 没有原生的标量过滤能力,业务上要按「分类 + 时间范围 + 状态」过滤后再做向量检索,只能先查 ID 列表再在内存里过滤,代码里嵌套了三层循环。
于是决定迁移到专业的向量数据库。候选就是 Milvus 和 Qdrant——前者是分布式向量库的事实标准,社区大、生态全;后者是 Rust 写的轻量级选手,主打单机性能和易部署。网上大部分对比文章要么是官方 benchmark,要么是几万条数据的小打小闹,缺少真实业务规模下的实测。所以我自己搭了套环境,用生产数据的采样做了这轮对比,把过程和数字记录下来。
二、环境与版本
测试环境是一台阿里云 ECS,配置如下:
| 项目 | 配置 |
|---|---|
| CPU | 8 vCPU (Intel Xeon Platinum 8269CY) |
| 内存 | 16 GB |
| 磁盘 | ESSD PL1 500GB |
| OS | Ubuntu 22.04 LTS |
| Docker | 24.0.7 |
| Python | 3.10.12 |
软件版本:
- Milvus 2.4.4(standalone 模式,docker-compose 部署,依赖 etcd 3.5.5 + MinIO RELEASE.2023-03-20)
- Qdrant 1.9.2(单容器部署)
- 客户端:pymilvus 2.4.4、qdrant-client 1.9.2
数据集:1000 万条 768 维 float32 向量,从业务里随机采样并做归一化,模拟真实 embedding 分布(不是随机均匀分布,这一点很关键,随机向量的检索性能和真实数据差异很大)。另带 5 个标量字段:category(int,20 个取值)、create_time(int64)、status(int,3 个取值)、author_id(int64)、is_deleted(bool)。
三、方案设计
对比维度定四个:部署复杂度、写入吞吐、查询延迟(含标量过滤)、资源占用。查询场景设计了三组:
- 纯向量 TopK:topk=10,nprobe/ef 用默认调优值;
- 带标量过滤的向量检索:
category == 3 and status == 1,再取 TopK=10; - 高并发混合查询:50 并发,混合执行上面两种查询,持续 5 分钟,统计 P50/P95/P99。
资源占用用 docker stats 每 10 秒采样一次,取稳定后的峰值。写入阶段统一用 batch=1000 批量插入,记录总耗时。
四、核心实现
4.1 部署步骤
Qdrant 最简单,一条命令:
docker run -d --name qdrant \
-p 6333:6333 -p 6334:6334 \
-v /data/qdrant:/qdrant/storage \
-e QDRANT__SERVICE__GRPC_PORT=6334 \
qdrant/qdrant:v1.9.2
Milvus 用官方 docker-compose,需要三个容器(etcd、minio、milvus-standalone):
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 监听 19530。注意 standalone 模式下 etcd 和 MinIO 也占内存,实测空载时三者合计约 1.2GB,这部分要算进总成本。
4.2 建表与写入
Qdrant 的 collection 创建和写入:
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, prefer_grpc=True)
client.recreate_collection(
collection_name="image_dedup",
vectors_config=VectorParams(size=768, distance=Distance.COSINE),
optimizers_config={"memmap_threshold_kb": 20000},
)
# 模拟 1000 万条数据,实际按 1000 一批写入
BATCH = 1000
start = time.time()
for i in range(0, 10_000_000, BATCH):
vectors = np.random.randn(BATCH, 768).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
points = [
PointStruct(
id=i + j,
vector=vectors[j].tolist(),
payload={
"category": int(np.random.randint(0, 20)),
"status": int(np.random.randint(0, 3)),
"create_time": 1700000000 + i + j,
"author_id": int(np.random.randint(0, 50000)),
"is_deleted": False,
},
)
for j in range(BATCH)
]
client.upsert(collection_name="image_dedup", points=points, wait=False)
print(f"Qdrant 写入耗时: {time.time() - start:.1f}s")
Milvus 侧建表要注意:2.4 版本推荐用 MilvusClient 的简化接口,标量字段要显式建索引,否则过滤会退化成全量扫描:
from pymilvus import MilvusClient, DataType
import numpy as np, time
client = MilvusClient(uri="http://127.0.0.1:19530")
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=768)
schema.add_field("category", DataType.INT32)
schema.add_field("status", DataType.INT32)
schema.add_field("create_time", DataType.INT64)
schema.add_field("author_id", DataType.INT64)
schema.add_field("is_deleted", DataType.BOOL)
index_params = client.prepare_index_params()
index_params.add_index(field_name="vector", index_type="HNSW",
metric_type="COSINE", params={"M": 16, "efConstruction": 200})
index_params.add_index(field_name="category", index_type="INVERTED")
index_params.add_index(field_name="status", index_type="INVERTED")
client.create_collection(collection_name="image_dedup",
schema=schema, index_params=index_params)
BATCH = 1000
start = time.time()
for i in range(0, 10_000_000, BATCH):
vectors = np.random.randn(BATCH, 768).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
rows = [{
"id": i + j,
"vector": vectors[j].tolist(),
"category": int(np.random.randint(0, 20)),
"status": int(np.random.randint(0, 3)),
"create_time": 1700000000 + i + j,
"author_id": int(np.random.randint(0, 50000)),
"is_deleted": False,
} for j in range(BATCH)]
client.insert(collection_name="image_dedup", data=rows)
print(f"Milvus 写入耗时: {time.time() - start:.1f}s")
4.3 查询压测
压测脚本用 concurrent.futures 起 50 个线程,每个线程跑 200 次查询,记录每次耗时。这里以带过滤的查询为例:
import time, statistics
from concurrent.futures import ThreadPoolExecutor
import numpy as np
query_vec = np.random.randn(768).astype(np.float32)
query_vec /= np.linalg.norm(query_vec)
def qdrant_query(_):
t = time.perf_counter()
client.search(
collection_name="image_dedup",
query_vector=query_vec.tolist(),
query_filter={
"must": [
{"key": "category", "match": {"value": 3}},
{"key": "status", "match": {"value": 1}},
]
},
limit=10,
)
return (time.perf_counter() - t) * 1000
def milvus_query(_):
t = time.perf_counter()
client.search(
collection_name="image_dedup",
data=[query_vec.tolist()],
filter='category == 3 and status == 1',
limit=10,
search_params={"metric_type": "COSINE", "params": {"ef": 64}},
)
return (time.perf_counter() - t) * 1000
def run_bench(fn, n=200, workers=50):
with ThreadPoolExecutor(max_workers=workers) as ex:
lat = list(ex.map(fn, range(n * workers)))
lat.sort()
return {
"p50": lat[int(len(lat) * 0.5)],
"p95": lat[int(len(lat) * 0.95)],
"p99": lat[int(len(lat) * 0.99)],
"avg": statistics.mean(lat),
}
print("Qdrant:", run_bench(qdrant_query))
print("Milvus:", run_bench(milvus_query))
五、踩坑与优化
坑 1:Milvus 的 ef 参数和 nprobe 别混用。 我一开始用 IVF_FLAT 索引,nprobe=16 时召回率只有 0.82,过滤查询下更差。换 HNSW 后把 ef 从默认 64 提到 128,召回率到 0.97,延迟只涨了 6ms。生产上建议 HNSW + ef=64~128,M=16 是性价比拐点。
坑 2:Qdrant 默认的 segment 数量太多。 1000 万数据写入后产生了几十个 segment,查询要遍历所有 segment,P99 直接飙到 200ms+。调大 optimizers_config.default_segment_number 到 8,并手动触发 POST /collections/image_dedup/index 优化后,P99 降到 23ms。这个参数官方文档藏得很深,值得写进 checklist。
坑 3:Milvus 的标量过滤走的是「先过滤后检索」还是「先检索后过滤」,取决于过滤命中率。 当 category == 3 命中约 5% 数据时,Milvus 2.4 会自动选择先过滤,但前提是标量字段建了 INVERTED 索引。我漏建 status 索引时,同一个查询从 38ms 涨到 900ms,差距惊人。
坑 4:Qdrant 的 payload 索引要手动建。 和 Milvus 不同,Qdrant 的 payload 字段默认不建索引,过滤全靠暴力扫描。执行 client.create_payload_index(collection_name="image_dedup", field_name="category", field_schema="integer") 后,过滤查询延迟从 180ms 降到 22ms。
六、效果数据
写入吞吐(1000 万条,batch=1000):
| 指标 | Milvus 2.4.4 | Qdrant 1.9.2 |
|---|---|---|
| 总耗时 | 41 min | 28 min |
| 平均吞吐 | ~4065 条/s | ~5952 条/s |
查询延迟(单位 ms,50 并发):
| 场景 | Milvus P50/P95/P99 | Qdrant P50/P95/P99 |
|---|---|---|
| 纯向量 TopK=10 | 12 / 28 / 41 | 8 / 17 / 23 |
| 带标量过滤 | 18 / 35 / 52 | 11 / 21 / 29 |
| 混合压测 | 15 / 33 / 48 | 9 / 19 / 26 |
资源占用(稳定后峰值):
| 指标 | Milvus(含 etcd+MinIO) | Qdrant |
|---|---|---|
| 内存 | 9.8 GB(milvus 8.6 + 依赖 1.2) | 11.2 GB |
| 磁盘 | 14.3 GB | 12.1 GB |
| 空载内存 | 1.2 GB | 0.3 GB |
结论有点反直觉:Qdrant 查询更快、写入更快、部署更简单,但内存占用反而更高——因为它默认把向量和 HNSW 图全放内存,而 Milvus 用了 mmap 把部分数据放磁盘。Milvus 的额外依赖(etcd + MinIO)在单机场景下是纯负担,但在集群场景下是刚需。
七、总结
选型建议按规模分:
- 1000 万条以内、单机部署、团队没有专职中间件运维:选 Qdrant。部署一条命令,查询延迟低 40%,payload 过滤灵活,Rust 写的稳定性也好。内存要给够,11GB 是 1000 万 × 768 维的底线。
- 上亿条、需要水平扩展、要多租户或强一致:选 Milvus。分布式架构成熟,2.4 的 MilvusClient 接口比老版 pymilvus 好用太多,标量索引和混合查询的能力更完整。代价是运维复杂度上一个台阶。
- 中间地带(1000 万~5000 万):看团队技术栈。如果已经在用 K8s 和对象存储,Milvus 的边际成本低;如果是纯虚拟机环境,Qdrant 的分布式模式(1.9 已支持 sharding)也够用。
我们最终选了 Qdrant,因为业务量在可预见的两年内不会超过 3000 万,而团队只有两个后端,不想维护 etcd 和 MinIO。迁移后 Redis 内存从 40GB 降到 0,去重接口 P99 从 320ms 降到 31ms。如果哪天数据量真的破亿了,再迁 Milvus 也不迟——向量数据的迁移成本,比想象中低。