一、问题背景:为什么又要换向量数据库?

我们团队维护的智能客服系统,最近把Embedding模型从text2vec-large换成了bge-m3,向量维度从512升到了768,数据量也从300万涨到了1200万。原先用的FAISS单机版本开始吃力了——不是查询慢,而是构建索引时内存直接爆掉OutOfMemoryError)。

于是我们开始调研真正的向量数据库。候选名单里有MilvusQdrantWeaviate。因为团队里Java和Go的人各半,而Weaviate的Go客户端维护成本高,最后只留下了Milvus和Qdrant。

这次选型不是做Demo,是直接上生产。我们的核心场景是:文章实时写入,用户提问后2秒内必须返回Top-30相关段落。别问为什么是2秒,产品经理拍脑袋定的。

二、环境与版本:先亮底牌

测试机是公司淘汰下来的物理机:

  • CPU:Intel Xeon Gold 6248R @ 3.0GHz (32核)
  • 内存:64GB DDR4
  • 存储:1TB NVMe SSD (RAID 1)
  • OS:Ubuntu 22.04 LTS
  • Docker:24.0.7

数据库版本(都是当时稳定版):

  • Milvus:v2.4.11 (standalone模式,含etcd + minio)
  • Qdrant:v1.9.7 (单节点模式)

数据规模:12,000,000条,每条包含id (uint64)vector (float[768])payload (title, category, timestamp)。数据是从真实业务日志里脱敏抽样的。

三、方案设计:部署与索引策略

Milvus部署(我用了docker-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
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    command: minio server /minio_data --console-address ":9001"
  standalone:
    image: milvusdb/milvus:v2.4.11
    command: ["milvus", "run", "standalone"]
    ports:
      - "19530:19530"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    depends_on:
      - etcd
      - minio

Qdrant部署(单行命令搞定,这点确实比Milvus清爽):

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

索引参数(这是关键,直接影响查询性能):

  • Milvus:HNSWM=32efConstruction=400efSearch=128(我们测试后觉得efSearch=128是性价比平衡点,再高延迟翻倍但召回率只涨0.3%)。
  • Qdrant:HNSWm=32ef_construct=400,全量payload索引。

四、核心实现:数据灌入与查询压测

我们写了个Java压测工具(Spring Boot 3.2 + REST API),但核心逻辑在Python里调客户端库。注意,千万别用官方Benchmark工具,那是神仙配置,和自己业务差太远。

Milvus写入代码片段(pymilvus 2.4.x):

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

# 连接
client = MilvusClient(uri="http://localhost:19530")

# 建集合,启用动态字段
schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
schema.add_field(field_name="title", datatype=DataType.VARCHAR, max_length=512)
schema.add_field(field_name="category", datatype=DataType.VARCHAR, max_length=64)
schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=768)

# 创建HNSW索引
index_params = client.prepare_index_params()
index_params.add_index(field_name="vector", index_type="HNSW", 
                       metric_type="COSINE", params={"M": 32, "efConstruction": 400})

client.create_collection(collection_name="article_v2", 
                         schema=schema, index_params=index_params)

# 批量插入(这里用批量,单条插入会慢到怀疑人生)
batch_size = 10000
for i in range(0, 12000000, batch_size):
    batch_data = [
        {"id": j, "title": f"title_{j}", "category": f"cat_{j % 10}", 
         "vector": generate_vector()} for j in range(i, i+batch_size)
    ]
    client.insert(collection_name="article_v2", data=batch_data)
    print(f"Inserted {i+batch_size} records")

Qdrant写入代码片段(qdrant-client 1.9.7):

from qdrant_client import QdrantClient, models

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

# 建集合
client.recreate_collection(
    collection_name="article_v2",
    vectors_config=models.VectorParams(size=768, distance=models.Distance.COSINE),
    optimizers_config=models.OptimizersConfigDiff(
        indexing_threshold=20000,  # 每2万条自动建索引
        memmap_threshold=50000
    )
)

# 批量写入(Qdrant对批量大小敏感,实测1500-2000一条批最佳)
for i in range(0, 12000000, 2000):
    points = []
    for j in range(i, i+2000):
        points.append(models.PointStruct(
            id=j,
            vector=generate_vector(),
            payload={"title": f"title_{j}", "category": f"cat_{j % 10}"}
        ))
    client.upsert(collection_name="article_v2", points=points, wait=False)
    if i % 100000 == 0:
        print(f"Upserted {i+2000} records")

压测方式:模拟真实查询,生成1000条随机查询向量,分别跑纯向量检索向量+Payload过滤category="cat_5"timestamp在近7天内)。

五、踩坑与优化:血泪教训

Milvus的坑

  1. 磁盘占用超预期:Milvus的minio存储是完整副本,1200万条向量占用了41GB磁盘,而Qdrant只要19GB。原因是Milvus默认把原始数据+索引都存了,Qdrant只存索引。
  2. 批量插入必须调大client_timeout:默认10秒,插入1万条必超时。我改成了client = MilvusClient(uri="http://...", timeout=300)
  3. efConstruction不是越大越好:调到512后,索引构建时间从3小时涨到5.5小时,但查询召回率只提升0.1%。400足够。

Qdrant的坑

  1. 内存控制是真强,但慢在写入:Qdrant的写入瓶颈在memmap映射。实测单条插入延迟2ms,但批量1000条要1.8秒,比Milvus慢4倍。
  2. Payload过滤必须建索引:一开始没给category加索引,带过滤的查询直接跑全表,延迟飙到8秒。加了索引后降到116ms。
  3. wait=False能显著提升吞吐:异步写入时,Qdrant能达到1.2k/s,但同步写入只有300/s。Milvus批量写入一直稳在5k/s。

六、效果数据:最终用数字说话

指标 Milvus 2.4.11 Qdrant 1.9.7
数据导入耗时(1200万条) 42分钟 3小时17分钟
索引构建耗时 1小时10分钟 48分钟
纯向量检索 P99 延迟 42ms 58ms
带过滤检索 P99 延迟 187ms 116ms
单机 QPS(纯检索,32并发) 1850 960
单机 QPS(带过滤,32并发) 320 510
内存占用(稳定后) 22.4GB 10.1GB
磁盘占用 41GB 19GB
召回率(Top-30,与暴力搜索对比) 99.2% 99.1%

结论

  • 如果你们是写多读少(比如每天批量灌数据,查询集中在白天),无脑选Milvus。它的批量写入和纯向量检索性能太强了。
  • 如果你们是读多写多(比如实时增量同步+复杂过滤),且对内存敏感,Qdrant更合适。它的Payload过滤查询性能几乎是Milvus的两倍,内存只占一半。
  • 我们最终选了Qdrant。原因是我们的业务场景里,客服提问大概率带分类过滤(比如“退款政策”会强制过滤category="after_sales"),这个场景Qdrant赢麻了。至于写入慢,我们通过Kafka异步削峰解决了。

七、总结与后续计划

最后说点真心话:向量数据库选型,别信知乎吹的,也别信官方Benchmark。一定要拿自己的真实数据和真实查询模式去测。我们这轮对比花了整整两周,但排除了一个未来必然会炸的雷(如果当初选了Milvus,我们的内存只有64G,跑满带过滤的查询肯定会OOM)。

后续我们打算测试Qdrant的分布式模式(虽然单节点目前够了),以及对比一下Milvus的GPU_CAGRA索引(这是2.4版本的新特性,但需要A100)。等有结果了再来更新。

如果你也在纠结这两个库,欢迎评论区交流。特别是如果你用过Milvus的DiskANN或者Qdrant的BinaryQuantization,跪求分享经验。