一、问题背景

我们做的是一个图文内容推荐系统,商品库大概 1200 万条,每条内容用 CLIP 类模型抽取 768 维向量。业务侧对检索的要求是:

  • 单次 TopK=50 查询,P99 延迟控制在 100ms 以内;
  • 支持按类目、上下架状态做标量过滤;
  • 每天增量写入约 20 万条,偶尔有全量重建;
  • 机器预算有限,希望单机 16C64G 能扛住,不要一上来就上集群。

候选方案里,Milvus 和 Qdrant 是最常被提到的两个。Milvus 生态大、文档全、社区活跃;Qdrant 用 Rust 写,主打轻量和过滤性能。到底选哪个,光看 benchmark 文章没意义,必须在自己场景里跑一遍。下面是我这轮选型的完整记录。

二、环境与版本

测试机器配置:

  • CPU:Intel Xeon Gold 6248R,16 核
  • 内存:64GB
  • 磁盘:NVMe SSD 1TB
  • OS:Ubuntu 22.04,内核 5.15
  • Docker:24.0.7,Docker Compose v2.24

软件版本:

  • Milvus 2.4.5(standalone 模式,依赖 etcd 3.5.5、MinIO RELEASE.2023-03-20)
  • Qdrant 1.9.1
  • Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
  • 数据集:1200 万条 768 维 float32 向量,随机生成,附带 category(0-99)和 status(0/1)两个标量字段

三、方案设计

两组实验都采用 HNSW 索引,因为这是当前中小规模下延迟和召回最均衡的选择。Milvus 使用 COSINE 距离,Qdrant 同样用 Cosine。索引参数尽量对齐:

  • Milvus:M=16, efConstruction=200,查询时 ef=128
  • Qdrant:m=16, ef_construct=200,查询时 hnsw_ef=128

写入方式统一用批量插入,每批 5000 条,分 2400 批完成。查询测试用 1000 条随机向量做 TopK=50,记录 P50/P95/P99 和 QPS。

四、核心实现

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
    volumes:
      - ./volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd

  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    volumes:
      - ./volumes/minio:/minio_data
    command: minio server /minio_data

  milvus:
    image: milvusdb/milvus:v2.4.5
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ./volumes/milvus:/var/lib/milvus
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - etcd
      - minio

启动:docker compose -f docker-compose-milvus.yml up -d

Qdrant 就简单多了,单容器:

# docker-compose-qdrant.yml
version: '3.8'
services:
  qdrant:
    image: qdrant/qdrant:v1.9.1
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - ./volumes/qdrant:/qdrant/storage
    environment:
      QDRANT__SERVICE__GRPC_PORT: 6334

启动:docker compose -f docker-compose-qdrant.yml up -d

4.2 写入与查询代码

Milvus 侧:

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np, time

connections.connect("default", host="localhost", port="19530")

fields = [
    FieldSchema("id", DataType.INT64, is_primary=True),
    FieldSchema("category", DataType.INT64),
    FieldSchema("status", DataType.INT64),
    FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="product_vectors")
col = Collection("products_milvus", schema)

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.load()

# 批量写入
BATCH = 5000
t0 = time.time()
for i in range(0, 12_000_000, BATCH):
    ids = list(range(i, i + BATCH))
    cats = np.random.randint(0, 100, BATCH).tolist()
    stats = np.random.randint(0, 2, BATCH).tolist()
    vecs = np.random.rand(BATCH, 768).astype(np.float32).tolist()
    col.insert([ids, cats, stats, vecs])
print(f"Milvus insert cost: {time.time()-t0:.1f}s")

# 查询
search_params = {"metric_type": "COSINE", "params": {"ef": 128}}
query_vec = np.random.rand(768).astype(np.float32).tolist()
res = col.search(
    data=[query_vec],
    anns_field="embedding",
    param=search_params,
    limit=50,
    expr="category in [1,2,3] and status == 1",
    output_fields=["category", "status"],
)

Qdrant 侧:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue, HnswConfigDiff
import numpy as np, time

client = QdrantClient(host="localhost", port=6333)

client.recreate_collection(
    collection_name="products_qdrant",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)

BATCH = 5000
t0 = time.time()
for i in range(0, 12_000_000, BATCH):
    points = [
        PointStruct(
            id=i + j,
            vector=np.random.rand(768).astype(np.float32).tolist(),
            payload={
                "category": int(np.random.randint(0, 100)),
                "status": int(np.random.randint(0, 2)),
            },
        )
        for j in range(BATCH)
    ]
    client.upsert(collection_name="products_qdrant", points=points, wait=False)
print(f"Qdrant upsert cost: {time.time()-t0:.1f}s")

# 查询
res = client.search(
    collection_name="products_qdrant",
    query_vector=np.random.rand(768).astype(np.float32).tolist(),
    limit=50,
    query_filter=Filter(
        must=[
            FieldCondition(key="category", match=MatchValue(value=1)),
            FieldCondition(key="status", match=MatchValue(value=1)),
        ]
    ),
    search_params={"hnsw_ef": 128},
)

注意 Qdrant 写入时 wait=False 会异步落盘,速度更接近真实吞吐;Milvus 的 insert 默认进内存段,flush 后才持久化,两边口径基本可比。

五、踩坑与优化

第一个坑是 Milvus 的内存。默认配置下,standalone 加载 1200 万条 768 维向量后,querynode 常驻内存接近 28GB,加上 etcd 和 MinIO 总共吃到 34GB。如果同时跑别的服务,很容易 OOM。后来我调了 queryNode.segcore.chunkRowscache.cacheSize,把 cacheSize 限制到 16GB,内存压到 26GB 左右,但 P99 略有上升。

第二个坑是 Qdrant 的 wait 参数。压测写入时如果每条 upsert 都 wait=True,吞吐会掉一半以上。改成 wait=False 后,写入速度从 1.2 万条/秒提升到 3.8 万条/秒,但要注意在写入结束后调用一次 client.http.collections_api.get_collection 确认落盘。

第三个坑是标量过滤。Milvus 在 category in [1,2,3] 这种多值过滤上,如果过滤后候选集太小,会退化成暴力扫描,延迟飙升。Qdrant 的 payload 索引对这类过滤更友好,建议对高频过滤字段建 create_payload_index

优化后,Milvus 侧我开启了 mmap 模式,把部分段放到磁盘,内存降到 18GB,代价是 P99 从 68ms 涨到 92ms。Qdrant 侧开启了 on_disk_payload=true,内存从 21GB 降到 14GB,P99 几乎没变,这点让我挺意外。

六、效果数据

写入 1200 万条后的实测结果(单次 TopK=50,1000 次查询取分位):

指标 Milvus 2.4.5 Qdrant 1.9.1
写入总耗时 41 分钟 33 分钟
索引构建耗时 22 分钟 18 分钟
内存占用(默认) 34GB 21GB
内存占用(优化后) 18GB 14GB
P50 延迟 21ms 14ms
P95 延迟 47ms 31ms
P99 延迟 68ms 43ms
QPS(单并发) 42 67
过滤查询 P99 112ms 58ms

从数据看,Qdrant 在这个规模下延迟和内存都占优,尤其是带过滤的查询,优势明显。Milvus 的优势在于批量写入的稳定性,以及在更大规模(上亿)时的分片扩展能力,还有它那套完整的生态:Attu 可视化、多租户、一致性级别可调。

七、总结

如果业务是千万级向量、单机部署、过滤条件多、对延迟敏感,我会优先选 Qdrant,部署简单、资源占用低、过滤性能好。如果预期数据量会快速涨到亿级,或者需要多租户、强一致性、成熟的运维工具链,Milvus 更合适,但要做好内存规划和集群化准备。

我们最终选了 Qdrant,因为当前数据量在 1200 万,且过滤场景占比超过 60%。等数据量到 5000 万以上时,会再评估一次是否迁移到 Milvus 集群。选型没有绝对的对错,只有和业务阶段是否匹配。