一、问题背景

去年底我们做了一个图文内容平台,用户上传的图片和文案需要做相似度去重和推荐召回。最初用的是 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)。

三、方案设计

对比维度定四个:部署复杂度、写入吞吐、查询延迟(含标量过滤)、资源占用。查询场景设计了三组:

  1. 纯向量 TopK:topk=10,nprobe/ef 用默认调优值;
  2. 带标量过滤的向量检索:category == 3 and status == 1,再取 TopK=10;
  3. 高并发混合查询: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 也不迟——向量数据的迁移成本,比想象中低。