一、问题背景:为什么要在这两个库之间纠结

先说业务。我们做的是一个商品图文检索系统,用户上传一张图,系统返回视觉相似的商品。CLIP ViT-L/14 出 768 维向量,当前库存约 300 万条,预计半年内涨到 1000 万。查询 QPS 峰值不高,大概 30,但要求 P99 控制在 50ms 以内,而且几乎每个查询都带业务过滤条件——比如"只看某个类目""价格区间 100-500""只查在售商品"。

这个场景有两个硬约束:

  1. 过滤 + 向量检索必须一起做。如果先按条件过滤再暴力算向量,类目下的数据量可能只有几万,但如果是全类目搜索就没法用这招。我们希望数据库自己能搞定 pre-filter 或 post-filter。
  2. 资源不能太奢侈。团队给的机器就是 8C32G 的云主机,后面可能扩到 3 节点,但单节点必须能扛住当前数据量。

选型圈定在 Milvus 和 Qdrant,原因很直接:两者都是开源、活跃、支持标量过滤和 HNSW 索引,社区案例多。Milvus 是"全家桶"路线,组件多、功能全;Qdrant 是 Rust 写的单二进制,轻量。纸面参数看不出谁更适合我们,只能实测。

二、环境与版本

所有测试在同一台机器上完成,避免硬件差异干扰:

  • 机器:阿里云 ecs.g7.2xlarge,8 vCPU / 32GB / ESSD 500GB
  • OS:Ubuntu 22.04.3 LTS,内核 5.15
  • Docker:24.0.7,Docker Compose v2.23.0
  • Milvus:2.4.4(standalone 模式,docker-compose 部署)
  • Qdrant:1.9.2(单容器,qdrant/qdrant:v1.9.2)
  • 客户端:pymilvus 2.4.4,qdrant-client 1.9.1
  • 数据集:300 万条 768 维 float32 向量,随机生成但做了聚类(模拟真实 embedding 分布),附带 5 个标量字段(category、price、status、shop_id、create_ts)

注意 Milvus 2.4.x 的 standalone 模式仍然依赖 etcd 和 MinIO,compose 文件里会一起拉起来,这部分资源开销要算进去,不能只算 milvus 容器本身。

三、方案设计

两边都用 HNSW 索引,参数尽量对齐,保证可比性:

  • 距离度量:COSINE(我们的向量做过归一化,内积和 cosine 等价,但接口上统一用 COSINE)
  • HNSW:M=16,efConstruction=200
  • 查询:top-10,ef=64(Qdrant 叫 hnsw_ef)
  • 过滤字段:category(低基数,20 个值)、price(数值范围)、status(布尔)

写入策略:Milvus 用 insert 分批灌 5 万条一批,最后 flush;Qdrant 用 upsert 分批,wait=true。两边都等索引 build 完成后再压测。

压测分三组:

  1. 纯向量 top-10:不带任何过滤,测基础检索能力
  2. 过滤 + 向量:category == "electronics" and price between 100 and 500,命中量约 8 万条
  3. 高选择性过滤:status == true and category == "books",命中量约 5000 条

每组跑 2000 次查询,丢弃前 200 次预热,统计 P50 / P95 / P99。

四、核心实现

4.1 部署

Milvus 用官方 compose,重点是给 etcd 和 MinIO 限制资源,不然它们会抢内存:

# 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
    deploy:
      resources:
        limits: { memory: 2G }
  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"
    deploy:
      resources:
        limits: { memory: 2G }
  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"]
    deploy:
      resources:
        limits: { memory: 20G }

Qdrant 就一条命令,单容器,数据挂本地盘:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v /data/qdrant_storage:/qdrant/storage \
  -e QDRANT__SERVICE__GRPC_PORT=6334 \
  --memory=20g \
  qdrant/qdrant:v1.9.2

4.2 建集合与写入

Milvus 侧关键代码(省略了 client 初始化):

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

connections.connect(host="127.0.0.1", port="19530")

fields = [
    FieldSchema("id", DataType.INT64, is_primary=True),
    FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=768),
    FieldSchema("category", DataType.VARCHAR, max_length=64),
    FieldSchema("price", DataType.FLOAT),
    FieldSchema("status", DataType.BOOL),
    FieldSchema("shop_id", DataType.INT64),
]
schema = CollectionSchema(fields, description="goods_image_vectors")
col = Collection("goods_v1", schema)

# 灌数据:5 万一批
BATCH = 50000
for start in range(0, 3_000_000, BATCH):
    ids = list(range(start, start + BATCH))
    vecs = np.random.randn(BATCH, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    cats = np.random.choice(["electronics", "books", "fashion", "home"], BATCH).tolist()
    prices = np.random.uniform(10, 2000, BATCH).tolist()
    status = np.random.choice([True, False], BATCH, p=[0.8, 0.2]).tolist()
    shops = np.random.randint(0, 5000, BATCH).tolist()
    col.insert([ids, vecs.tolist(), cats, prices, status, shops])

col.flush()
# 建索引
col.create_index("embedding", {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200}
})
col.load()

Qdrant 侧:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff
import numpy as np

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

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

BATCH = 50000
for start in range(0, 3_000_000, BATCH):
    vecs = np.random.randn(BATCH, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    points = [
        PointStruct(
            id=start + i,
            vector=vecs[i].tolist(),
            payload={
                "category": ["electronics", "books", "fashion", "home"][np.random.randint(0, 4)],
                "price": float(np.random.uniform(10, 2000)),
                "status": bool(np.random.rand() = 100 and price <= 500',
    output_fields=["category", "price"],
)

Qdrant 用 Filter 对象:

from qdrant_client.models import Filter, FieldCondition, MatchValue, Range

hits = client.search(
    collection_name="goods_v1",
    query_vector=query_vec.tolist(),
    limit=10,
    search_params={"hnsw_ef": 64, "exact": False},
    query_filter=Filter(
        must=[
            FieldCondition(key="category", match=MatchValue(value="electronics")),
            FieldCondition(key="price", range=Range(gte=100, lte=500)),
        ]
    ),
)

五、踩坑与优化

坑 1:Milvus 首次查询慢得离谱。 灌完数据直接压测,第一条查询花了 1.2 秒,后面才降到 20ms 以内。原因是 load() 之后索引还在懒加载,需要先打一批预热查询。官方文档提过但很容易忽略。建议上线前跑 500 条随机查询预热。Qdrant 没这个问题,upsert 完就能稳定响应。

坑 2:Qdrant 的过滤字段必须建 payload index。 一开始我在 300 万数据上直接带 category 过滤,P99 飙到 300ms,因为它在全量 payload 上扫描。建了索引后降到 11ms:

client.create_payload_index("goods_v1", "category", field_schema="keyword")
client.create_payload_index("goods_v1", "price", field_schema="float")
client.create_payload_index("goods_v1", "status", field_schema="bool")

Milvus 这边标量字段默认走表达式解析,2.4 版本对 VARCHAR 等值过滤有优化,但数值范围过滤在命中量大时仍会退化,可以给 price 建 INVERTED 索引改善。

坑 3:内存账要算全。 Milvus standalone 的 2.1GB 常驻里,milvus 容器自己约 1.4GB,etcd 约 400MB,MinIO 约 300MB。Qdrant 单容器 380MB 是纯业务占用。如果你的部署环境对容器数量敏感(比如某些托管 K8s 按 Pod 计费),这个差距要提前算进去。

优化点: 两边都把 ef 从 64 调到 128 试过,召回率从约 0.93 提到 0.98,但 P99 分别涨了 60%(Milvus)和 45%(Qdrant)。最终我们选 ef=96 折中,召回 0.96,延迟可接受。

六、效果数据

300 万条 768 维向量,HNSW M=16 / efConstruction=200,查询 ef=64,单位 ms:

场景 库 P50 P95 P99 召回率@10
纯向量 top-10 Milvus 2.4.4 6.2 12.8 18.1 0.94
纯向量 top-10 Qdrant 1.9.2 3.8 8.1 11.3 0.95
过滤+向量(8 万命中) Milvus 9.4 17.2 24.6 0.93
过滤+向量(8 万命中) Qdrant 7.1 14.9 22.8 0.94
高选择性过滤(5 千命中) Milvus 4.1 8.7 12.2 0.96
高选择性过滤(5 千命中) Qdrant 3.2 6.9 10.1 0.96

资源占用(稳定运行 30 分钟后 docker stats 采样):

指标 Milvus 全家桶 Qdrant 单容器
常驻内存 2.1 GB 380 MB
峰值内存(压测中) 4.8 GB 1.9 GB
磁盘占用(含索引) 11.2 GB 9.6 GB
冷启动到可服务 约 75 秒 约 8 秒

写入吞吐:Milvus 灌 300 万条约 22 分钟(含 flush 和建索引),Qdrant 约 31 分钟。Milvus 的批处理管道确实更快,但差距没有想象中大。

七、总结

回到选型结论。纯性能上 Qdrant 在单机小规模场景确实更轻快,延迟低 30%-40%,内存只有 Milvus 的 1/5,冷启动快一个数量级。如果你的数据量在千万级以内、团队不想维护 etcd/MinIO 这套依赖、机器资源紧张,Qdrant 是更务实的选择。

Milvus 的优势在于生态和扩展性:分区、多副本、完整的标量索引体系、成熟的分布式方案,以及更丰富的过滤表达式。我们最终选了 Milvus,原因是半年后要扩到 1000 万并做多租户隔离,Milvus 的 partition 和 resource group 能直接支撑,而 Qdrant 的多租户要靠 payload 过滤模拟,规模上去后维护成本更高。

一句话建议:单机、中小规模、追求轻量,选 Qdrant;要分布式、多租户、长期演进,选 Milvus。 别只看 benchmark 数字,把过滤场景和资源账算清楚,答案基本就出来了。