一、问题背景

我们做的是一个电商“以图搜图 + 文本语义召回”的混合检索服务。商品库约 100 万 SKU,向量由 CLIP ViT-L/14 生成,维度 768,后续还要接 BGE-M3 做文本召回。业务要求:

  • 单次查询 TopK=10,过滤条件包含类目、上下架状态;
  • 高峰期 200 QPS,P99 控制在 50ms 以内;
  • 团队只有 2 个后端,没有专职运维,部署不能太重;
  • 后续可能扩到 1000 万向量,需要能平滑扩容。

候选就是 Milvus 和 Qdrant。网上文章大多只讲“Hello World”,缺少同场景、同数据量、同硬件的横向数据。于是我自己搭了两套环境,用真实商品向量跑了一遍。

二、环境与版本

硬件统一为一台阿里云 ecs.g7.2xlarge:

  • 8 vCPU / 32GB RAM / 500GB ESSD PL1
  • Ubuntu 22.04,Docker 26.1.1,Docker Compose v2.27.0

软件版本:

  • Milvus 2.4.1(standalone + etcd 3.5.5 + MinIO RELEASE.2023-03-20)
  • Qdrant 1.9.4(单容器)
  • Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
  • 压测工具:locust 2.29.0

三、方案设计

两条链路尽量对齐:

  1. 数据:100 万条 768 维 float32 向量,随机生成但固定 seed,附带 category_id(int)和 online(bool)两个 payload 字段。
  2. 索引:Milvus 用 HNSW(M=16,efConstruction=200),度量 COSINE;Qdrant 用 HNSW(m=16,ef_construct=200),距离 Cosine。
  3. 查询:TopK=10,带 category_id in [...] and online == true 过滤,ef/search_ef 都设为 64。
  4. 压测:先预热 200 次,再以 50 并发持续 5 分钟,统计 QPS、P50、P99。

四、核心实现

4.1 部署

Milvus standalone 用官方 compose:

wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d
# 检查
docker compose ps
# 默认端口 19530

Qdrant 更简单:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.9.4

4.2 建库与写入(Python)

Milvus:

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

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

fields = [
    FieldSchema("id", DataType.INT64, is_primary=True),
    FieldSchema("category_id", DataType.INT64),
    FieldSchema("online", DataType.BOOL),
    FieldSchema("embedding", DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="goods")
col = Collection("goods", schema)

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

N = 1_000_000
BATCH = 5000
rng = np.random.default_rng(42)
for start in range(0, N, BATCH):
    end = min(start + BATCH, N)
    vectors = rng.random((end - start, 768), dtype=np.float32).tolist()
    ids = list(range(start, end))
    cats = [i % 50 for i in ids]
    online = [True] * (end - start)
    col.insert([ids, cats, online, vectors])
    if start % 100000 == 0:
        print("inserted", end)
col.flush()

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",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config=HnswConfigDiff(m=16, ef_construct=200),
)

N, BATCH = 1_000_000, 5000
rng = np.random.default_rng(42)
for start in range(0, N, BATCH):
    end = min(start + BATCH, N)
    vecs = rng.random((end - start, 768), dtype=np.float32)
    points = [
        PointStruct(
            id=i,
            vector=vecs[i - start].tolist(),
            payload={"category_id": i % 50, "online": True},
        )
        for i in range(start, end)
    ]
    client.upsert(collection_name="goods", points=points, wait=False)
    if start % 100000 == 0:
        print("upserted", end)

4.3 查询

Milvus:

res = col.search(
    data=[query_vec],
    anns_field="embedding",
    param={"metric_type": "COSINE", "params": {"ef": 64}},
    limit=10,
    expr='category_id in [1,2,3] and online == true',
    output_fields=["category_id"],
)

Qdrant:

from qdrant_client.models import Filter, FieldCondition, MatchAny, MatchValue
res = client.search(
    collection_name="goods",
    query_vector=query_vec,
    limit=10,
    search_params={"hnsw_ef": 64},
    query_filter=Filter(must=[
        FieldCondition(key="category_id", match=MatchAny(any=[1,2,3])),
        FieldCondition(key="online", match=MatchValue(value=True)),
    ]),
)

五、踩坑与优化

  1. Milvus 写入必须 flush() 后才可见,否则 count 一直偏小;Qdrant 用 wait=False 时也要等后台索引,压测前我加了 60s 静默。
  2. Milvus 的 ef 参数在 search 时传入,容易和索引阶段的 efConstruction 混淆,前者影响召回与延迟,后者只影响构建。
  3. Qdrant 的 payload 过滤默认走 filterable HNSW,如果过滤字段没建索引,会退化成全量扫描。我给 category_id 建了 keyword/int 索引后,P99 从 40ms 降到 12ms。
  4. 内存:Milvus 常驻约 11GB(含 etcd/MinIO),Qdrant 约 6.5GB。Qdrant 可以用 --mem-limit 控制,Milvus 的 querynode 内存需要改 milvus.yaml
  5. 批量写入:Milvus 5000 一批明显更快,Qdrant 在 2000 左右更稳,过大反而触发超时。

六、效果数据

100 万向量,TopK=10,50 并发,5 分钟:

指标 Milvus 2.4.1 Qdrant 1.9.4
索引构建 约 9 分 20 秒 约 7 分 05 秒
写入总耗时 约 14 分钟 约 18 分钟
QPS 312 358
P50 9ms 6ms
P99 18ms 12ms
常驻内存 11.2GB 6.5GB
磁盘占用 4.8GB 3.9GB
冷启动到可查 约 55s 约 18s

召回率(与暴力检索对比 Recall@10):两者都在 0.97 以上,差距在 0.3% 以内,可忽略。

七、总结

如果团队小、要单机快速上线、内存敏感,Qdrant 1.9.4 的综合体验更好:部署一个容器、冷启动快、P99 更低、内存省 40%。如果数据量要冲千万级、需要分布式和多种索引(IVF、DiskANN)、生态上还要接 Spark/Flink,Milvus 2.4.1 的扩展性和运维工具更成熟,代价是资源占用和部署复杂度更高。我们的选择是:当前 100 万级用 Qdrant,等过了 500 万再评估迁移到 Milvus 集群。选型没有绝对优劣,把数据量、延迟目标和人力放一起算,答案就出来了。