一、问题背景

团队在做一个企业内部知识库的语义检索服务,数据规模预估在千万级向量,维度768(来自bge-large-zh-v1.5)。业务对延迟的要求是P99控制在100ms以内,同时希望运维成本别太高——我们只有一台16核64GB的物理机可用,短期内不打算上K8s集群。

候选方案锁定在两个:Milvus和Qdrant。两者都是当前社区活跃度很高的向量数据库,但设计哲学差别挺大。Milvus走的是云原生分布式路线,组件多、能力强;Qdrant是Rust写的单二进制服务,部署轻量。网上大部分对比文章要么只跑个demo,要么数据规模太小看不出差异,所以我决定自己按真实业务规模压一遍。

这篇博客不吹不黑,只讲我实测到的数字和踩过的坑。

二、环境与版本

  • 硬件:单机,AMD EPYC 7302 16核32线程,64GB DDR4,NVMe SSD 1TB
  • OS:Ubuntu 22.04 LTS
  • Docker:24.0.7
  • Milvus:2.4.4(standalone 模式,docker-compose 部署)
  • Qdrant:1.9.2(单容器部署)
  • 客户端:Python 3.11,pymilvus 2.4.4,qdrant-client 1.9.1
  • 数据集:1000万条 768 维 float32 向量,随机生成(正态分布),另附1000条查询向量

需要说明的是,Milvus 2.4 的 standalone 模式其实内部还是跑着 etcd、minio、pulsar 这一套依赖,只是打包在一个 compose 里。这点后面部署时会体现出来。

三、方案设计

两个库都使用 HNSW 索引,参数尽量对齐以便公平比较:

  • M(每层最大连接数):16
  • efConstruction:200
  • 查询时 ef:64
  • 距离度量:COSINE

数据写入采用批量插入,batch size 设为 2000。压测使用 1000 条查询向量,串行执行,统计 P50 / P95 / P99 延迟和 QPS。每轮压测前先 warmup 100 次查询。

四、核心实现

4.1 Qdrant 部署与写入

Qdrant 的部署非常直接,一条 docker 命令搞定:

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

写入和建索引的代码如下:

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

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

COLLECTION = "kb_vectors"
DIM = 768

# 建集合,指定 HNSW 参数
client.recreate_collection(
    collection_name=COLLECTION,
    vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
    hnsw_config={
        "m": 16,
        "ef_construct": 200,
        "full_scan_threshold": 10000,
    },
)

def batch_insert(vectors: np.ndarray, start_id: int, batch_size: int = 2000):
    for i in range(0, len(vectors), batch_size):
        chunk = vectors[i:i + batch_size]
        points = [
            PointStruct(
                id=start_id + i + j,
                vector=chunk[j].tolist(),
                payload={"source": "test"},
            )
            for j in range(len(chunk))
        ]
        client.upsert(collection_name=COLLECTION, points=points, wait=False)

# 假设 all_vectors 是 (10_000_000, 768) 的 np.ndarray
# batch_insert(all_vectors, 0)

注意 wait=False,写入是异步落盘的。全量写完后我调了一次 client.count() 确认数量,再等索引构建完成(Qdrant 会在后台自动建 HNSW)。

4.2 Milvus 部署与写入

Milvus standalone 需要下载官方 compose 文件:

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

启动后 docker ps 能看到三个容器:milvus-standalone、milvus-etcd、milvus-minio。内存占用从一开始就比 Qdrant 高不少。

写入代码:

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

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

COLLECTION = "kb_vectors"
DIM = 768

if utility.has_collection(COLLECTION):
    utility.drop_collection(COLLECTION)

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=DIM),
    FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=64),
]
schema = CollectionSchema(fields=fields)
collection = Collection(name=COLLECTION, schema=schema)

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

def batch_insert(vectors: np.ndarray, start_id: int, batch_size: int = 2000):
    for i in range(0, len(vectors), batch_size):
        chunk = vectors[i:i + batch_size]
        ids = [start_id + i + j for j in range(len(chunk))]
        entities = [
            ids,
            chunk.tolist(),
            ["test"] * len(chunk),
        ]
        collection.insert(entities)

collection.flush()

Milvus 的 flush() 会触发数据持久化和索引构建,1000万条数据这一步等了大概几分钟。

4.3 压测脚本

两个库共用一个压测函数,只替换 search 调用:

import time
import numpy as np

def benchmark(search_fn, queries: np.ndarray, top_k: int = 10):
    # warmup
    for q in queries[:100]:
        search_fn(q, top_k)

    latencies = []
    for q in queries:
        t0 = time.perf_counter()
        search_fn(q, top_k)
        latencies.append((time.perf_counter() - t0) * 1000)

    latencies = np.array(latencies)
    return {
        "p50": float(np.percentile(latencies, 50)),
        "p95": float(np.percentile(latencies, 95)),
        "p99": float(np.percentile(latencies, 99)),
        "qps": float(1000.0 / latencies.mean()),
    }

五、踩坑与优化

坑1:Milvus 的 insert 不落盘。 一开始我没调 flush(),直接搜发现召回为0。Milvus 是分段存储的,插入的数据在内存 buffer 里,必须 flush 或等 segment 达到阈值才会被索引。大批量导入后务必手动 flush 一次。

坑2:Qdrant 的 ef 参数位置。 Qdrant 查询时的 ef 不是建集合时设的,而是在 search_params 里传,容易搞混:

from qdrant_client.models import SearchParams

client.search(
    collection_name=COLLECTION,
    query_vector=query.tolist(),
    limit=10,
    search_params=SearchParams(hnsw_ef=64, exact=False),
)

坑3:内存。 1000万×768×4字节 ≈ 28.6GB 原始向量数据。Milvus 加上 etcd、minio 和 segment 索引开销,实测常驻内存 31GB 左右;Qdrant 用 mmap 存储,实测常驻 22GB,明显更省。我们64GB的机器跑 Milvus 时系统负载偏高,跑 Qdrant 则有余量。

优化: Qdrant 开启 on_disk_payload 并把向量也配置为 mmap 存储后,内存还能再降,但 P99 会上升约 15%。权衡后我们保留了内存模式。

六、效果数据

1000万条数据,1000次查询,top_k=10,统计如下:

指标 Milvus 2.4.4 Qdrant 1.9.2
P50 延迟 31 ms 21 ms
P95 延迟 45 ms 33 ms
P99 延迟 52 ms 38 ms
QPS(串行) 27 43
常驻内存 31 GB 22 GB
磁盘占用 34 GB 30 GB
全量导入耗时 约 18 分钟 约 12 分钟
冷启动到可服务 约 90 秒 约 8 秒

单机场景下 Qdrant 在延迟和内存上都占优,这和我预期一致——Rust + 单进程 + mmap 的架构在单机上是天然优势。Milvus 的价值在分布式扩展、多租户、标量过滤的成熟度,以及更完整的企业级能力,但这些在单机千万级场景里用不上。

七、总结

如果你的场景是单机或小规模集群、数据在千万级以内、追求低运维成本和低延迟,我这次的实测结论是选 Qdrant,部署简单、内存友好、P99 更稳。

如果你已经确定要上分布式、数据量会往亿级走、需要成熟的资源隔离和标量过滤能力,Milvus 更合适,代价是组件多、内存开销大、运维复杂度高。

我们最终选了 Qdrant,因为它匹配当前业务规模。但选型不是一锤定音的事,等数据量翻十倍,我会再回来测一轮 Milvus 集群模式。