一、问题背景

我们做的是一个图文内容平台,日增图文素材约 40 万条。上线两年后,去重和"相似内容推荐"两个功能开始拖后腿:原方案是 MySQL 存向量 + 内存里暴力算余弦相似度,数据量到 800 万时,一次批量去重请求要跑 9 秒,服务端内存被打到 90% 以上。

需求很明确:

  1. 存量 1200 万条向量,维度 768(CLIP ViT-L/14 输出),后续可能换 1024 维多模态模型;
  2. 单条查询 P95 控制在 50ms 以内,批量查询(一次 200 条)走异步;
  3. 必须支持按 tenant_id、category 做标量过滤后再做向量检索,不能"先召回再过滤";
  4. 团队只有 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),不建额外标量索引(第一轮),第二轮再给过滤字段加索引看差异。

测试项:

  1. 灌库耗时与磁盘/内存占用(灌完后 sleep 5 分钟再读,避免缓存干扰)
  2. 单条 topK=10 查询,QPS 与 P50/P95/P99(200 并发,持续 5 分钟)
  3. 带 tenant_id 过滤的查询延迟
  4. 重启后首次查询冷启动延迟

四、核心实现

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 的优势在别处:

  1. 我们做过一轮 3 节点 Milvus 集群的预演(querynode × 2 + 独立 proxy),QPS 线性涨到 3100,扩容路径很清晰;Qdrant 的分布式模式(1.9 还是 beta 阶段)当时不敢上生产。
  2. Milvus 支持 partition key 做多租户物理隔离,200 个 tenant 的场景下,按 tenant 分区后过滤查询还能再快一截;Qdrant 只有 payload 索引,逻辑隔离。
  3. 团队之前有 Kafka + MinIO 的运维经验,Milvus 的依赖栈不算陌生。

最终选型:当前阶段用 Qdrant 单机,1200 万规模完全够用,运维成本最低。同时把数据写入层做了解耦(向量库只做检索,元数据仍在 PG),预留了后续切 Milvus 集群的接口——如果数据量涨到 5000 万以上或需要强多租户隔离,再迁移。

七、总结

几点可直接拿走的经验:

  1. 千万级、单机、团队小,Qdrant 是更务实的选择,部署一条 docker run,内存和磁盘都省 30% 左右。
  2. 过滤字段的标量索引是必做项,不做的话两边延迟都能差 3-4 倍,别拿"没建索引"的压测数据下结论。
  3. Qdrant 的 on_disk_payload 和 memmap_threshold 是两个必须显式配置的参数,默认值在千万级数据下会坑人。
  4. Milvus 别手动频繁 flush,segment 碎片对 P99 的影响比想象中大。
  5. 压测一定要等索引构建完成(Milvus 看 indexBuildProgress,Qdrant 轮询 collection status 到 green),否则数字全是假的。

选型没有绝对优劣,只有场景匹配。如果你也在做类似决策,建议先用真实数据量灌一遍——10 万条 demo 的结论,到 1000 万条基本都会翻车。