一、问题背景:为什么又要在 Milvus 和 Qdrant 之间纠结

我们做的是一个图文内容平台,核心需求有三个:

  1. 对 1200 万条图文内容做语义去重,向量维度 768,模型是 bge-base-zh-v1.5;
  2. 支持“分类 + 发布时间 + 向量相似度”的混合过滤检索;
  3. 线上 QPS 不高,峰值约 100 并发,但要求 P95 延迟控制在 50ms 以内。

一开始团队内部有两派意见:一派认为 Milvus 生态成熟、国内资料多,出问题好查;另一派认为 Qdrant 部署轻、Rust 实现、单机性能好,运维成本低。于是我们决定不拍脑袋,直接在同一台机器上做一轮实测。

这里先说结论:没有绝对赢家,只有场景匹配。如果你要的是单机轻量部署、低延迟、过滤条件不太复杂,Qdrant 很舒服;如果你要的是大规模集群、丰富索引类型、强一致性和生态工具,Milvus 更稳。

二、环境与版本:同一台机器,尽量公平

为了避免“机器不同导致结论不可信”,我们统一环境:

  • 机器:阿里云 ECS,8 vCPU,32GB 内存,ESSD PL1 500GB
  • OS:Ubuntu 22.04 LTS
  • Docker:26.1.4
  • Milvus:2.4.9,单机 Docker Compose 部署,依赖 etcd 3.5.5、MinIO RELEASE.2024-05-10
  • Qdrant:1.9.2,单容器部署
  • 客户端:Python 3.10,pymilvus 2.4.4,qdrant-client 1.9.1
  • 数据集:1200 万条 768 维 float32 向量,另带 category_id(int)、publish_ts(int)两个标量字段

部署命令先放出来,后面直接可复现。

Milvus 单机部署:

# 下载官方 compose 文件
wget https://github.com/milvus-io/milvus/releases/download/v2.4.9/milvus-standalone-docker-compose.yml -O docker-compose-milvus.yml

# 启动
docker compose -f docker-compose-milvus.yml up -d

# 检查
docker ps | grep milvus

Qdrant 部署:

docker run -d --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v /data/qdrant_storage:/qdrant/storage \
  qdrant/qdrant:v1.9.2

# 检查
curl http://localhost:6333/collections

两者都关闭了不必要的日志和监控组件,尽量只比较核心能力。

三、方案设计:索引、参数与查询模式

Milvus 侧我们使用 HNSW,参数 M=16efConstruction=200,查询时 ef=128。标量字段建立倒排索引。Collection 的 consistency_level 设为 Bounded,兼顾性能和一致性。

Qdrant 侧同样使用 HNSW,m=16ef_construct=200,查询 hnsw_ef=128。标量字段开启 payload index。

查询模式设计为三类:

  1. 纯向量 TopK=10;
  2. 向量 + category_id 过滤;
  3. 向量 + category_id + publish_ts 范围过滤。

压测使用 locust,100 并发,持续 10 分钟,统计 P50、P95、P99 和 QPS。

四、核心实现:建表、写入与查询代码

先看 Milvus 的建表和查询:

from pymilvus import (
    connections, Collection, CollectionSchema, FieldSchema, DataType, utility
)
import random, time

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

collection_name = "content_vec_milvus"
if utility.has_collection(collection_name):
    utility.drop_collection(collection_name)

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="category_id", dtype=DataType.INT64),
    FieldSchema(name="publish_ts", dtype=DataType.INT64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields=fields, description="content vector")
collection = Collection(name=collection_name, schema=schema)

# 标量索引
collection.create_index("category_id", {"index_type": "INVERTED"})
collection.create_index("publish_ts", {"index_type": "STL_SORT"})

# 向量索引
collection.create_index(
    field_name="embedding",
    index_params={
        "index_type": "HNSW",
        "metric_type": "COSINE",
        "params": {"M": 16, "efConstruction": 200},
    },
)
collection.load()

# 批量写入 10 万条示例
batch_size = 10000
for batch in range(10):
    ids = [batch * batch_size + i for i in range(batch_size)]
    category_ids = [random.randint(1, 50) for _ in range(batch_size)]
    publish_ts = [int(time.time()) - random.randint(0, 86400 * 365) for _ in range(batch_size)]
    vectors = [[random.random() for _ in range(768)] for _ in range(batch_size)]
    collection.insert([ids, category_ids, publish_ts, vectors])
collection.flush()

# 查询
query_vec = [[random.random() for _ in range(768)]]
res = collection.search(
    data=query_vec,
    anns_field="embedding",
    param={"metric_type": "COSINE", "params": {"ef": 128}},
    limit=10,
    expr="category_id == 3 and publish_ts > 1700000000",
    output_fields=["category_id", "publish_ts"],
)
for hits in res:
    for hit in hits:
        print(hit.id, hit.distance, hit.entity.get("category_id"))

再看 Qdrant 的对应实现:

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, PointStruct, Filter,
    FieldCondition, MatchValue, Range, HnswConfigDiff
)
import random, time

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

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

# payload 索引
client.create_payload_index(collection_name, "category_id", "integer")
client.create_payload_index(collection_name, "publish_ts", "integer")

# 批量写入
batch_size = 1000
points = []
for i in range(10000):
    points.append(PointStruct(
        id=i,
        vector=[random.random() for _ in range(768)],
        payload={
            "category_id": random.randint(1, 50),
            "publish_ts": int(time.time()) - random.randint(0, 86400 * 365),
        },
    ))
    if len(points) == batch_size:
        client.upsert(collection_name=collection_name, points=points)
        points = []
if points:
    client.upsert(collection_name=collection_name, points=points)

# 查询
query_vec = [random.random() for _ in range(768)]
res = client.search(
    collection_name=collection_name,
    query_vector=query_vec,
    query_filter=Filter(
        must=[
            FieldCondition(key="category_id", match=MatchValue(value=3)),
            FieldCondition(key="publish_ts", range=Range(gt=1700000000)),
        ]
    ),
    limit=10,
    search_params={"hnsw_ef": 128},
)
for point in res:
    print(point.id, point.score, point.payload)

注意:Qdrant 1.9 的 Python 客户端里 search 仍然可用,但新版本更推荐 query_points,我们为了和线上代码保持一致,仍用 search

五、踩坑与优化:那些文档里不会写的事

Milvus 的坑:

  1. 单机 Docker Compose 默认会起 etcd 和 MinIO,内存占用比想象中高。空载时 Milvus 相关容器合计约 2.8GB,Qdrant 空载只有约 180MB。
  2. collection.load() 之后,第一次查询会触发 segment 加载,P99 会飙到 200ms 以上。我们的做法是预热:启动后先跑 200 次随机查询。
  3. 标量过滤字段如果没建索引,组合查询会退化成全量扫描。我们一开始漏了 publish_tsSTL_SORT,P95 直接从 29ms 涨到 180ms。
  4. consistency_levelStrong 时,写入后立刻查询能查到,但吞吐下降明显。改成 Bounded 后,写入吞吐提升约 35%。

Qdrant 的坑:

  1. payload index 不是自动生效的,必须显式创建。我们一开始只建了 collection,过滤查询 P95 达到 120ms,建完索引后降到 18ms。
  2. Qdrant 默认 hnsw_ef 是 128,但如果你的数据分布很散,可以调到 256,召回率会更好,但延迟会上升约 20%。
  3. 单容器部署时,/qdrant/storage 一定要挂载到宿主机,否则容器重建数据全丢。
  4. Qdrant 的批量 upsert 建议每批 1000 到 2000 条,太大容易触发 gRPC 消息上限。

共同优化:

  • 向量归一化后使用 COSINE,避免内积和余弦混用导致分数不可比;
  • 压测前都做预热;
  • 关闭 debug 日志,减少 I/O 干扰。

六、效果数据:1200 万向量、100 并发实测

写入 1200 万条数据后,两者磁盘占用和内存占用如下:

项目 Milvus 2.4.9 Qdrant 1.9.2
向量数据磁盘占用 约 38GB 约 36GB
空载内存 约 2.8GB 约 180MB
加载后内存 约 9.5GB 约 7.2GB
批量写入 1200 万耗时 约 42 分钟 约 35 分钟

查询性能,100 并发,TopK=10,单位 ms:

查询类型 Milvus P50 Milvus P95 Milvus P99 Qdrant P50 Qdrant P95 Qdrant P99
纯向量 12 29 47 8 18 31
向量 + category 15 34 55 9 21 36
向量 + category + 时间范围 18 41 68 11 26 44

QPS 方面,纯向量查询 Milvus 约 3200,Qdrant 约 5100;带双过滤条件时 Milvus 约 2100,Qdrant 约 3300。

从数据看,Qdrant 在单机、低并发、过滤条件适中的场景下,延迟和资源占用都更优。Milvus 的优势在于:

  • 集群化能力成熟,水平扩展路径清晰;
  • 索引类型更丰富,比如 DiskANN、GPU 索引;
  • 与 Flink、Spark 等生态集成更顺;
  • 标量过滤组合非常复杂时,稳定性更好。

我们最终的选择是:在线检索用 Qdrant,离线大规模批处理和未来集群扩展保留 Milvus 方案。这不是“谁替代谁”,而是按场景分工。

七、总结

如果你正在做向量数据库选型,建议先问自己四个问题:

  1. 数据量是百万级还是十亿级?
  2. 是单机还是要集群?
  3. 过滤条件是简单标量,还是多字段复杂组合?
  4. 团队更熟悉 Python 生态还是云原生生态?

我们的实测结论是:在 8C32G、1200 万 768 维向量、100 并发场景下,Qdrant 1.9.2 的 P95 约 18ms,内存占用约 180MB 空载,部署极简;Milvus 2.4.9 的 P95 约 29ms,但集群和生态更成熟。选型没有标准答案,只有和业务匹配的答案。