一、问题背景

我们做的是一个电商商品以图搜图 + 语义检索的业务。商品库大约 1200 万条,向量维度 128,未来半年可能涨到 3000 万。需求很明确:

  • 单次查询返回 Top 50,P99 控制在 50ms 以内
  • 支持按类目、价格区间做过滤(payload filter)
  • 部署要简单,团队没有专职运维
  • 单机资源预算:8 核 16G,SSD 500G

候选方案锁定了 Milvus 和 Qdrant。网上文章要么是官方 benchmark,要么是玩具级 demo,跟自己业务差距太大。所以我干脆自己搭环境跑了一遍,把数据记录下来。

二、环境与版本

项目 配置
机器 阿里云 ecs.g7.2xlarge,8 vCPU / 16GB / ESSD PL1
系统 Ubuntu 22.04 LTS,内核 5.15
Docker 24.0.7
Milvus 2.4.0(standalone 模式)
Qdrant 1.8.2
客户端 pymilvus 2.4.0 / qdrant-client 1.8.2
数据集 1000 万条,128 维 float32,随机生成 + 类目字段

说明一下:Milvus standalone 内部依赖 etcd 和 MinIO,Qdrant 是单二进制。这点在部署阶段差异就很大。

三、方案设计与部署

3.1 Milvus 部署

Milvus 官方推荐用 docker-compose 起 standalone。下载 2.4.0 的 compose 文件:

wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d

起来之后会有三个容器:milvus-standalone、milvus-etcd、milvus-minio。默认端口 19530(gRPC)和 9091(metrics)。

几个关键配置我改了 milvus.yaml

queryNode:
  cache:
    cacheSize: 4        # GB,默认按内存比例,我手动压到 4G
  segcore:
    chunkRows: 32768
dataCoord:
  segment:
    maxSize: 512        # MB

首次启动大约 40 秒,etcd 和 MinIO 各占 100MB 左右内存。

3.2 Qdrant 部署

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.8.2

一个容器搞定,启动 3 秒。默认使用 mmap 存储,磁盘即内存的延伸。

3.3 建集合与灌数据

Qdrant 侧:

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

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

client.recreate_collection(
    collection_name="products",
    vectors_config=VectorParams(size=128, distance=Distance.COSINE),
    hnsw_config={"m": 16, "ef_construct": 200},
    optimizers_config={"memmap_threshold": 20000},
)

BATCH = 2000
for i in range(0, 10_000_000, BATCH):
    vecs = np.random.rand(BATCH, 128).astype(np.float32)
    points = [
        PointStruct(
            id=i + j,
            vector=vecs[j].tolist(),
            payload={"cate": int(np.random.randint(0, 50)),
                     "price": float(np.random.rand() * 1000)},
        )
        for j in range(BATCH)
    ]
    client.upsert(collection_name="products", points=points)

Milvus 侧:

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

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

fields = [
    FieldSchema("id", DataType.INT64, is_primary=True),
    FieldSchema("vec", DataType.FLOAT_VECTOR, dim=128),
    FieldSchema("cate", DataType.INT32),
    FieldSchema("price", DataType.FLOAT),
]
schema = CollectionSchema(fields)
col = Collection("products", schema)

col.create_index("vec", {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200},
})
col.create_index("cate", {"index_type": "INVERTED"})

BATCH = 2000
for i in range(0, 10_000_000, BATCH):
    vecs = np.random.rand(BATCH, 128).astype(np.float32)
    ids = list(range(i, i + BATCH))
    cates = np.random.randint(0, 50, BATCH).tolist()
    prices = (np.random.rand(BATCH) * 1000).tolist()
    col.insert([ids, vecs.tolist(), cates, prices])
    if i % 200000 == 0:
        col.flush()
col.flush()

灌 1000 万条数据,Qdrant 用时约 22 分钟,Milvus 约 35 分钟。Milvus 慢主要是 flush 和索引构建阶段开销大。

四、核心实现:查询对比

查询侧我都测了三类:纯向量 Top50、带类目过滤、带价格范围过滤。

import time
import numpy as np

# ---- Qdrant ----
q = np.random.rand(128).astype(np.float32).tolist()
lat_q = []
for _ in range(200):
    t = time.perf_counter()
    client.search(
        collection_name="products",
        query_vector=q,
        limit=50,
        query_filter={
            "must": [{"key": "cate", "match": {"value": 3}},
                     {"key": "price", "range": {"gte": 100, "lte": 500}}]
        },
        search_params={"hnsw_ef": 128},
    )
    lat_q.append((time.perf_counter() - t) * 1000)

# ---- Milvus ----
from pymilvus import Collection
col = Collection("products")
col.load()
lat_m = []
for _ in range(200):
    t = time.perf_counter()
    col.search(
        data=[q], anns_field="vec",
        param={"metric_type": "COSINE", "params": {"ef": 128}},
        limit=50,
        expr="cate == 3 and price >= 100 and price <= 500",
        output_fields=["cate", "price"],
    )
    lat_m.append((time.perf_counter() - t) * 1000)

注意 Milvus 的过滤表达式是字符串 DSL,Qdrant 是 JSON 结构。前者更灵活但拼字符串容易出错,后者类型安全。

五、踩坑与优化

坑 1:Milvus 首次 load 极慢。 1000 万条数据 col.load() 花了 6 分钟,把 segment 全加载进内存。如果内存不够会触发 swapping。后来我把 cacheSize 设成 4GB,靠磁盘兜底,但查询 P99 涨到 60ms+。Qdrant 用 mmap,load 几乎瞬时。

坑 2:Qdrant 的 memmap_threshold。 默认值比较大,小集合会用内存模式。数据量过千万必须显式调,否则 OOM。我设成 20000(点数量),超过就落盘。

坑 3:Milvus 过滤字段要建倒排索引。 不建 INVERTED 索引,带过滤的查询会退化成暴力扫描,P99 直接从 27ms 飙到 400ms+。这个坑卡了我半天。

坑 4:Qdrant 的 ef 参数。 默认 hnsw_ef=128 是查询时参数,跟构建的 ef_construct 是两个东西。调大召回率高但延迟上升。我在召回率 0.96 时把 ef 定在 128,ef 提到 256 召回 0.98 但延迟 +40%。

六、效果数据

1000 万条、128 维、200 次查询取 P50/P99:

指标 Qdrant 1.8.2 Milvus 2.4.0
纯向量 P50 6.8ms 9.2ms
纯向量 P99 18.1ms 27.4ms
带过滤 P50 9.4ms 14.7ms
带过滤 P99 24.6ms 41.3ms
召回率@50 0.96 0.97
常驻内存 6.2GB 9.8GB
磁盘占用 5.4GB 7.1GB
冷启动到可用 3s 45s
灌数 1000 万耗时 22min 35min

另外做了个压力测试,16 并发持续查询,Qdrant P99 涨到 58ms,Milvus 涨到 112ms 并出现少量超时。Milvus 在 standalone 下并发能力明显是瓶颈,要上集群才行。

七、总结

如果按我们业务的需求(单机、过滤多、并发中等、运维简单),Qdrant 是更合适的选择:

  • 部署成本几乎为零,一个容器搞定
  • 内存占用低 36%,mmap 让磁盘当内存用
  • 延迟低 30% 左右,过滤场景优势更明显
  • JSON 过滤接口比 Milvus DSL 更友好

Milvus 的优势在于生态和分布式能力。如果数据量过亿、要上集群、要接 Flink/Spark 做流式写入,Milvus 的架构更成熟,多租户和一致性也更强。单机场景下它有点重。

我的建议是:单机 5000 万以内选 Qdrant,超过或需要分布式选 Milvus。 别迷信官方 benchmark,自己按业务数据跑一遍才是真的。