一、问题背景

我们做的是电商「以图搜同款」和「语义搜商品」,向量规模大概 1200 万,维度 768(CLIP ViT-L/14 的 image embedding)。原来图省事,直接拿 Elasticsearch 8.x 的 dense_vector 字段顶了一阵子。问题在于:

  • ES 的 HNSW 索引构建慢,1200 万条重建一次要 6 个多小时;
  • 内存吃不住,光向量索引就占了快 40G,还得跟全文检索抢资源;
  • 过滤 + 向量混合查询(比如「类目=女装 AND 价格<300」再排序)性能掉得厉害,P99 直接飙到 800ms。

于是决定迁到专用向量库。候选就两个:Milvus 和 Qdrant。选它们的原因很实际——社区活跃、都支持 HNSW、都有标量过滤、都有 Go/Rust 写的高性能内核,而且都能私有化部署。

业务侧的硬指标是:

  • 单条查询 P99 < 100ms(TopK=50,带标量过滤)
  • 写入吞吐 ≥ 2000 条/s
  • 召回率 Recall@50 ≥ 0.95
  • 单机内存不超过 32G

二、环境与版本

测试机器统一用同一批,避免硬件差异干扰:

项目 配置
单机 8 vCPU / 32G RAM / 500G NVMe SSD
集群 3 × (8 vCPU / 32G RAM)
OS Ubuntu 22.04 LTS
Docker 26.1.1
Milvus 2.4.1(standalone + cluster)
Qdrant 1.9.2
客户端 pymilvus 2.4.3 / qdrant-client 1.9.1
数据集 1200 万条 768 维 float32 向量 + 类目/价格标量

三、方案设计

Milvus 方案:standalone 模式用 docker-compose 起全套(etcd + minio + milvus),集群模式用官方 Helm chart 起 3 个 querynode。索引统一用 HNSW,参数 M=16, efConstruction=200,查询 ef=128。距离度量用 IP(内积,向量已归一化,等价余弦)。

Qdrant 方案:单机直接用官方镜像,集群用 3 节点 Raft。索引用 HNSW,m=16, ef_construct=200,查询 hnsw_ef=128。同样 Cosine 距离。

两边都开标量过滤:category_id 建整型索引,price 建范围索引。

四、核心实现

4.1 Milvus 部署(docker-compose)

# milvus-standalone-docker-compose.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 --console-address ":9001"

  standalone:
    image: milvusdb/milvus:v2.4.1
    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

4.2 建表 + 插入 + 查询(pymilvus)

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

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

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="category_id", dtype=DataType.INT32),
    FieldSchema(name="price", dtype=DataType.FLOAT),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="product_vectors")
col = Collection("products", schema)

# HNSW 索引
index_params = {
    "index_type": "HNSW",
    "metric_type": "IP",
    "params": {"M": 16, "efConstruction": 200},
}
col.create_index("embedding", index_params)
col.create_index("category_id", {"index_type": "INVERTED"})
col.create_index("price", {"index_type": "STL_SORT"})
col.load()

# 批量写入 1200 万
BATCH = 10000
total = 12_000_000
for start in range(0, total, BATCH):
    n = min(BATCH, total - start)
    ids = list(range(start, start + n))
    cats = np.random.randint(0, 200, n).tolist()
    prices = (np.random.rand(n) * 1000).round(2).tolist()
    vecs = np.random.rand(n, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    col.insert([ids, cats, prices, vecs.tolist()])

# 查询:带过滤的 TopK
query_vec = np.random.rand(1, 768).astype(np.float32)
query_vec /= np.linalg.norm(query_vec)

search_params = {"metric_type": "IP", "params": {"ef": 128}}
t0 = time.time()
res = col.search(
    data=query_vec.tolist(),
    anns_field="embedding",
    param=search_params,
    limit=50,
    expr="category_id == 12 && price < 300",
    output_fields=["category_id", "price"],
)
print(f"Milvus latency: {(time.time()-t0)*1000:.2f} ms")

4.3 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
from qdrant_client import QdrantClient
from qdrant_client.models import (
    VectorParams, Distance, PointStruct,
    HnswConfigDiff, Filter, FieldCondition,
    MatchValue, Range
)
import numpy as np, time

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

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

BATCH = 1000
total = 12_000_000
for start in range(0, total, BATCH):
    n = min(BATCH, total - start)
    vecs = np.random.rand(n, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    points = [
        PointStruct(
            id=start + i,
            vector=vecs[i].tolist(),
            payload={
                "category_id": int(np.random.randint(0, 200)),
                "price": float(np.random.rand() * 1000),
            },
        )
        for i in range(n)
    ]
    client.upsert(collection_name="products", points=points)

# 建 payload 索引
client.create_payload_index("products", "category_id", "integer")
client.create_payload_index("products", "price", "float")

qvec = np.random.rand(768).astype(np.float32)
qvec /= np.linalg.norm(qvec)

flt = Filter(must=[
    FieldCondition(key="category_id", match=MatchValue(value=12)),
    FieldCondition(key="price", range=Range(lt=300)),
])

t0 = time.time()
hits = client.search(
    collection_name="products",
    query_vector=qvec.tolist(),
    query_filter=flt,
    limit=50,
    search_params={"hnsw_ef": 128},
)
print(f"Qdrant latency: {(time.time()-t0)*1000:.2f} ms")

五、踩坑与优化

坑 1:Milvus 默认 ef 太小导致召回掉。 一开始用默认 ef=64,Recall@50 只有 0.89,调到 128 后到 0.965,延迟从 38ms 升到 55ms,可接受。

坑 2:Qdrant 单段写入过大触发强制 flush。 每批 10000 条时,单 segment 涨到 2G 以上,后台优化线程疯狂合并,写入吞吐从 3500 掉到 1200。把 optimizers_config.memmap_threshold 设成 20000、批大小降到 1000 后稳定。

坑 3:Milvus 集群 querynode 内存不均。 3 节点默认按 segment 均分,热点类目查询全打到同一节点。开启 queryNode.loadMemoryUsageFactor 并配合多副本(replica_number=2)后 P99 从 210ms 降到 88ms。

坑 4:过滤字段没建索引。 两边都一样——category_id 不建索引时,Milvus 走全量扫描,延迟直接 1.2s;建了 INVERTED 后降到 60ms。Qdrant 同理,payload 索引必建。

六、效果数据

统一压测:wrk 风格并发 32,持续 5 分钟,TopK=50,带过滤。

指标 Milvus 单机 Milvus 3 节点 Qdrant 单机 Qdrant 3 节点
写入吞吐 (条/s) 2800 7200 3500 6800
查询 QPS 1420 3900 2020 3600
P50 延迟 (ms) 21 8 14 9
P99 延迟 (ms) 68 41 47 44
Recall@50 0.965 0.965 0.971 0.971
索引内存 (GB) 18.4 6.3/节点 13.1 4.8/节点
建索引耗时 42 min — 31 min —

结论很清晰:

  • 单机场景:Qdrant 全面占优,QPS 高 42%,P99 低 30%,内存省 29%,部署还简单(一个容器搞定,不用 etcd + minio)。
  • 集群场景:Milvus 反超,QPS 高 8%,写入吞吐高 6%,且它的存算分离架构在节点故障恢复上更成熟。
  • 运维成本:Milvus 依赖多(etcd、对象存储、pulsar/kafka 可选),Qdrant 单二进制,小团队更友好。

七、总结

最终我们选了 Qdrant 1.9.2 单机 + 冷备,因为当前 1200 万规模单机完全扛得住(P99 47ms,内存 13G),没必要上集群增加复杂度。如果未来涨到 5000 万以上、或者需要多租户强隔离,再切 Milvus 集群。

给同行的选型建议:

  1. 千万级以下、单机够用 → 优先 Qdrant,部署和调优都省心。
  2. 亿级以上、要横向扩展、要存算分离 → Milvus,集群能力更成熟。
  3. 无论选哪个,标量过滤字段一定要建索引,这是最容易踩的坑。
  4. HNSW 的 ef/ef_construct 别用默认值,召回率和延迟的平衡点要自己压测找。

向量库没有银弹,规模、团队运维能力、预算三者一权衡,答案自然就出来了。