一、问题背景

我们做的是一个图文内容推荐系统,需要根据用户最近点击的几十条内容,从约1000万条内容库里召回相似的候选集,再交给排序模型。向量是CLIP ViT-L/14输出的768维,每天新增约20万条。

最早我们用的是FAISS,但FAISS是库不是服务,多进程共享索引、增量更新、按条件过滤都很麻烦。于是决定换成向量数据库。候选主要是Milvus和Qdrant:

  • Milvus:CNCF毕业项目,生态成熟,支持存算分离、多种索引、标量过滤,社区大。
  • Qdrant:Rust写的,单机性能强,过滤和payload支持好,部署轻。

网上很多对比文章要么只讲概念,要么数据量太小(几万条),参考价值有限。所以我用生产同量级的数据做了一次实测,记录如下。

二、环境与版本

硬件:一台阿里云ECS,16 vCPU、64GB内存、ESSD PL2云盘,Ubuntu 22.04。

软件版本:
- Milvus 2.4.0(standalone,Docker Compose)
- Qdrant 1.9.0(Docker)
- Python 3.10,pymilvus 2.4.0,qdrant-client 1.9.0
- 数据:1000万条768维float32向量,随机生成但做归一化,模拟CLIP输出分布

索引配置:
- Milvus:HNSW,M=16,efConstruction=200,metric=IP
- Qdrant:HNSW,m=16,ef_construct=200,metric=Dot

两边都开标量过滤字段 category(约50个取值)。

三、方案设计

对比目标有三个:

  1. 单机部署难度和资源占用。
  2. 查询性能:不同 topK、是否带过滤、不同 ef/search_params 下的QPS和P99。
  3. 批量写入吞吐。

测试方法:用 locust 风格的 Python 脚本压测,固定并发32,每轮跑5分钟,取稳定段数据。查询向量从测试集中随机取,避免缓存命中偏差。

四、核心实现

4.1 部署

Milvus standalone 用官方 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
# 默认端口 19530 (gRPC), 9091 (metrics)

Qdrant 单容器:

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

4.2 建集合与写入

Milvus:

from pymilvus import connections, FieldSchema, CollectionSchema, DataType, Collection, utility

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

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="category", dtype=DataType.INT64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="content vectors")
col = Collection("content_milvus", schema)

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

# 分批写入,每批1万
import numpy as np
BATCH = 10000
for i in range(0, 10_000_000, BATCH):
    ids = list(range(i, i + BATCH))
    cats = np.random.randint(0, 50, BATCH).tolist()
    vecs = np.random.rand(BATCH, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    col.insert([ids, cats, vecs.tolist()])

Qdrant:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, HnswConfigDiff

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

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

BATCH = 10000
for i in range(0, 10_000_000, BATCH):
    vecs = np.random.rand(BATCH, 768).astype(np.float32)
    vecs /= np.linalg.norm(vecs, axis=1, keepdims=True)
    points = [
        PointStruct(
            id=i + j,
            vector=vecs[j].tolist(),
            payload={"category": int(np.random.randint(0, 50))},
        )
        for j in range(BATCH)
    ]
    client.upsert(collection_name="content_qdrant", points=points)

4.3 查询

Milvus 带过滤:

search_params = {"metric_type": "IP", "params": {"ef": 128}}
res = col.search(
    data=[query_vec],
    anns_field="embedding",
    param=search_params,
    limit=10,
    expr="category == 3",
    output_fields=["category"],
)

Qdrant 带过滤:

from qdrant_client.models import Filter, FieldCondition, MatchValue

hits = client.search(
    collection_name="content_qdrant",
    query_vector=query_vec.tolist(),
    limit=10,
    query_filter=Filter(
        must=[FieldCondition(key="category", match=MatchValue(value=3))]
    ),
    search_params={"hnsw_ef": 128},
)

五、踩坑与优化

坑1:Milvus standalone 内存暴涨。 写入阶段发现内存一路涨到20GB+,后来发现是 queryNode 的 cache 和 dataNode 的 flush 策略问题。把 queryNode.cache.enabled 调小、增大 dataCoord.segment.maxSize 到 1024MB,内存稳定在8GB左右。Qdrant 默认内存占用就温和很多。

坑2:Qdrant 批量 upsert 太大反而慢。 一开始 batch 设成5万,单批耗时抖动明显。改成1万后吞吐稳定。官方建议也是单请求不超过几百MB。

坑3:过滤性能差异。 Milvus 在带高选择性过滤(category命中约2%数据)时,会走标量索引+向量索引的组合,P99比无过滤高约60%。Qdrant 的 filterable HNSW 在payload过滤上表现更平滑,P99只高约35%。这一点在我们的场景很关键,因为推荐召回几乎总是带类目或时间过滤。

坑4:索引构建时间。 1000万条,Milvus HNSW 构建约42分钟,Qdrant约35分钟。两者都可以边写边建,但 Milvus 需要先 flush 再 index,Qdrant 是后台自动构建。

六、效果数据

稳定段实测(并发32,topK=10,ef=128,单位ms/QPS):

指标 Milvus 2.4.0 Qdrant 1.9.0
无过滤 QPS 168 212
无过滤 P99 72ms 48ms
带类目过滤 QPS 96 158
带类目过滤 P99 118ms 65ms
内存占用(稳定) 7.8GB 3.2GB
批量写入吞吐 约 4200 条/s 约 3000 条/s
索引构建时间 42min 35min
冷启动加载 约90s 约40s

写入方面 Milvus 更强,得益于它的日志结构和批量flush;查询尤其是带过滤查询,Qdrant 明显更优。资源占用 Qdrant 只有 Milvus 的约40%。

七、总结

选型不是非黑即白,看场景:

  • 如果你的数据量在千万级、单机或小集群、查询带较多标量过滤、对内存敏感,Qdrant 更合适。我们最终生产选了 Qdrant,P99从原来的110ms降到65ms,内存省了一半。
  • 如果你需要存算分离、十亿级以上、写入吞吐优先、团队已有K8s和Milvus运维经验,Milvus 的生态和扩展性更稳。

另外提醒两点:一是别只看官方benchmark,自己用生产分布的数据压一遍;二是索引参数(M、ef、ef_construct)对结果影响很大,本文数据仅代表上述配置。下一步我准备测试两者在十亿级分片下的表现,有兴趣可以关注。