一、问题背景:不是所有ANN搜索都叫“向量数据库”

上个月接手一个服饰电商的“拍立淘”需求,图向量化后规模约1000万条,维度768(CLIP ViT-B/32输出)。业务方给的要求:单机版,QPS过百,P99延迟小于30ms,内存别超过32G(因为还跑着Redis)。

我起初想直接用faiss,但产品经理要求必须有“副本扩容”能力——虽然最后也没用上。于是候选锁定Milvus 2.4.1和Qdrant 1.9.0。这两兄弟代表两种设计哲学:Milvus是“重装甲”——依赖etcd、MinIO、Kafka(虽然单机版可简化),走的是存算分离路子;Qdrant是“轻骑兵”——纯Rust单二进制,一把梭。

先说结论:如果你的向量量级在5000万以下、且不想维护三件套中间件,Qdrant更省心;如果以后必然上亿且要上分布式,Milvus的架构迁移路径更平滑。 但具体到我的场景,结果比预想更微妙,下面用数据说话。

二、环境与版本:物理机裸跑,不用云RDS

  • 硬件:2 * Intel Xeon Gold 6230(40核80线程),256G DDR4,2 * NVMe SSD 1.5T(RAID0)
  • OS:Ubuntu 22.04 LTS,内核5.15
  • Docker:24.0.7,Compose v2.24
  • 数据:1000万随机向量,L2距离,每批1000条写入

版本锁定(避坑):

milvusdb/milvus:v2.4.1
qdrant/qdrant:v1.9.0

注意Milvus 2.4必须用etcd:3.5.5,用3.4会报mvcc: database space exceeded错。别问我怎么知道的。

三、方案设计:双轨部署,同量级压测

部署上搞了两个独立Docker Compose文件,都映射到宿主机独立端口(19530 vs 6333),避免端口冲突。数据目录挂载到/data/milvus/data/qdrant

写入策略:先建集合,关闭flush自动刷盘,每5000条手动flush一次,模拟真实批量导入。

查询压测用Python异步客户端,发10000次查询(随机向量作为query),记录延迟分布。先看部署,再看代码。

四、核心实现:部署与压测脚本

4.1 Milvus Compose(精简关键服务)

# docker-compose-milvus.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:
      - /data/milvus/etcd:/etcd
  minio:
    image: minio/minio:RELEASE.2024-01-16T16-07-38Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    command: minio server /minio_data
    volumes:
      - /data/milvus/minio:/minio_data
  milvus:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    ports:
      - "19530:19530"
    volumes:
      - /data/milvus/milvus:/var/lib/milvus
    depends_on:
      - etcd
      - minio

4.2 Qdrant Compose

# docker-compose-qdrant.yml
version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.9.0
    ports:
      - "6333:6333"
      - "6335:6335"  # gRPC端口
    volumes:
      - /data/qdrant/storage:/qdrant/storage
    command: ["./qdrant", "--storage", "optimizers:default:indexing_threshold=1000000"]

注意indexing_threshold默认是20000,不调大会频繁建索引导致CPU飙高。我设成1000000,构建HNSW一次到位。

4.3 压测脚本(核心节选)

# benchmark.py
import asyncio, time, random
from pymilvus import MilvusClient, DataType
from qdrant_client import AsyncQdrantClient, models
import numpy as np

DIM = 768
N_Q = 10000

async def test_milvus():
    client = MilvusClient(uri="http://localhost:19530")
    client.create_collection(
        collection_name="products",
        dimension=DIM,
        metric_type="L2",
        index_params={"index_type": "HNSW", "params": {"M": 32, "efConstruction": 512}}
    )
    # 模拟查询
    latencies = []
    for _ in range(N_Q):
        query = np.random.rand(DIM).astype(np.float32)
        t0 = time.perf_counter()
        client.search(collection_name="products", data=[query], limit=10, search_params={"ef": 128})
        latencies.append((time.perf_counter() - t0) * 1000)
    return sorted(latencies)

async def test_qdrant():
    client = AsyncQdrantClient(url="http://localhost:6333")
    if not (await client.collection_exists("products")):
        await client.create_collection(
            collection_name="products",
            vectors_config=models.VectorParams(size=DIM, distance=models.Distance.L2),
            hnsw_config=models.HnswConfigDiff(m=32, ef_construct=512)
        )
    latencies = []
    for _ in range(N_Q):
        query = np.random.rand(DIM).astype(np.float32)
        t0 = time.perf_counter()
        await client.query_points(collection_name="products", query=query, limit=10, search_params=models.SearchParams(ef=128))
        latencies.append((time.perf_counter() - t0) * 1000)
    return sorted(latencies)

if __name__ == "__main__":
    for name, func in [("Milvus", test_milvus), ("Qdrant", test_qdrant)]:
        lat = asyncio.run(func())
        p50, p99 = lat[int(N_Q*0.5)], lat[int(N_Q*0.99)]
        print(f"{name}: P50={p50:.2f}ms, P99={p99:.2f}ms, 吞吐={N_Q/(sum(lat)/1000):.0f} QPS")

五、踩坑与优化:两个系统的“隐藏关卡”

Milvus的段文件合并灾难:首次导入1000万数据后,查询QPS只有预期一半。查监控发现segment数量超过3000个,查询要并发扫描所有段文件。解决:手动触发合并——调用client.compact(collection_name="products"),等is_compacting=False后再查询。合并后段数量降到97个,QPS从150提升到320。

Qdrant的Rust内存魔法:Qdrant默认启用mmap存储,这意味着索引文件不常驻内存,缺页中断换入。但代价是查询偶发“毛刺”——P99从14ms跳到45ms。优化方案:把mmap关掉,全量载入内存。启动参数加--storage mmap_interval_threshold=0,让所有数据常驻RAM。内存占用从35G降到28G(因为省了Rust的Arc开销),P99稳定在18ms。

写入吞吐对比:Milvus批量写入(5000条/批)吞吐2800条/秒,Qdrant 3500条/秒。但Milvus的flush操作间隔越短,吞吐下跌越明显。我把dataCoord.segment.maxSize从默认的1024MB调到2048MB,减少小段产生,写入提升60%。

六、效果数据:量化对比与决策

最终压测数据(1000万向量,768维,HNSW-M=32,efSearch=128):

指标 Milvus 2.4.1 Qdrant 1.9.0
索引构建耗时 58分钟 41分钟
P50 查询延迟 7.8ms 9.2ms
P99 查询延迟 12.3ms 18.6ms
峰值QPS(并发64) 1280 940
内存占用(含系统缓存) 31.2G 19.8G
磁盘占用 7.1G 5.3G

关键发现:Milvus的查询延迟优势源于它的Knowhere索引实现比Qdrant的HNSW更激进地使用SIMD指令集。但Qdrant的Rust异步模型让内存占用低37%,且部署仅需一个容器,运维负担几乎为零。

最终选型:我选了Qdrant。理由不是性能——Milvus更快——而是因为业务方要求内存预算32G内,且团队没有专职运维去盯etcd和MinIO。Qdrant的单二进制部署太香了,出了问题重启就行。Milvus适合数据量奔着亿级去、且已有中间件运维能力的中大厂。

七、总结与观点

向量数据库选型,本质是查询延迟与运维复杂度的交换。我的实战体验:

  1. Milvus是“全能战士”,功能全但组件多,单机版也逃不掉etcd+MinIO的宿命。适合有K8s编排能力、且未来必然上分布式的团队。
  2. Qdrant是“精准刺客”,核心场景做得极致。如果你只要一个快速的ANN检索API,别犹豫,直接上Qdrant。

最后泼盆冷水:如果你的向量少于500万,且允许离线召回,直接用faisshnswlib嵌入业务进程,啥数据库都不用加。向量数据库是给“需要动态增删”的场景准备的,别为了用而用。

(欢迎评论区交流,特别是关于Milvus 2.5的GPU索引或者Qdrant的二进制量化,我还在研究。)