一、问题背景:不是所有向量库都适合你的业务

我们团队负责一个跨境电商平台的语义搜索模块,需要将商品标题、描述转为向量并支持实时召回。最初用的是自建的FAISS + Redis缓存方案,但随着数据量涨到500万条,问题开始暴露:索引全量加载内存不够、增量更新要重建索引、过滤条件(如价格区间、类目)完全没法做。

于是我们启动了向量数据库选型。候选名单里有Milvus、Qdrant、Weaviate、Chroma,但最终锁定在Milvus和Qdrant之间——因为这两者都支持复杂的标量过滤和混合搜索,且社区活跃度最高。

我们的核心需求很明确:
- 500万条512维float向量
- 支持商品ID、价格、类目等标量字段的预过滤
- 写入吞吐要求不高(每天约10万增量),但查询P99需低于50ms
- 部署环境为单机,32G内存,SSD磁盘

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

测试环境统一使用同一台物理机,避免硬件差异影响判断:

CPU:Intel Xeon Gold 6248R @ 3.0GHz (16核分配)
内存:32GB DDR4
磁盘:NVMe SSD 1TB
OS:Ubuntu 20.04.6 LTS
Docker:25.0.3

向量数据库版本(均为各自最新稳定版):
- Milvus:2.4.1,部署方式为Docker Compose(含etcd、minio、standalone)
- Qdrant:1.9.2,部署方式为单节点Docker容器

数据集:从商品库中随机抽取500万条真实数据,使用bge-large-zh-v1.5模型生成512维向量,标量字段包括item_id(uint64)、price(float)、category(string)、status(int)。

三、方案设计:部署与参数配置的第一次博弈

3.1 Milvus部署

Milvus 2.x的部署比1.x复杂不少,因为它拆成了多个组件。官方推荐的standalone模式需要三个容器:etcd(元数据)、minio(存储)、milvus-standalone(计算)。docker-compose.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
  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
  standalone:
    image: milvusdb/milvus:v2.4.1
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"

启动后需要设置索引参数。我们用的是HNSW索引,M=16(每个节点的最大连接数),efConstruction=200(构建时的动态列表大小)。这两个参数直接影响召回率和内存占用。

3.2 Qdrant部署

Qdrant的部署就简单多了,一个容器搞定:

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

Qdrant的索引配置在创建collection时通过vectorshnsw_config指定。它的HNSW参数和Milvus基本类似,但有一个额外的m参数(每个节点的最大连接数)和ef_construct

四、核心实现:500万向量写入与查询实测

4.1 Milvus写入与索引构建

写入500万条数据用了约40分钟,主要是由于我们开启了sync=True(每次写入都强制落盘)。实际生产环境建议用批量插入,sync=False配合定时flush。

索引构建耗时:HNSW索引构建用了约35分钟。这里有个坑——Milvus构建索引期间会锁住collection的写入操作,官方文档说支持在线构建,但我们实测2.4.1版本在构建期间插入请求会超时。

4.2 Qdrant写入与索引构建

Qdrant支持wait=truewait=false两种写入模式。我们测试时用wait=false(异步写入),500万条数据写入仅耗时22分钟。索引构建是即时完成的(HNSW在插入时就实时建索引),不需要额外的构建步骤,这是一个重要的架构差异。

4.3 查询性能对比

使用相同的数据分布和查询模式,预热10分钟后用并发工具压测:

指标 Milvus 2.4.1 Qdrant 1.9.2
平均延迟 18ms 33ms
P99延迟 23ms 41ms
每秒查询数(QPS) 420 260
召回率(10条内) 0.93 0.91

查询代码(Milvus):

from pymilvus import connections, Collection

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

# 带标量过滤的搜索
results = collection.search(
    data=[query_vector],
    anns_field="embedding",
    param={"metric_type": "IP", "params": {"ef": 128}},
    limit=10,
    expr='price > 100 and category == "electronics"',
    output_fields=["item_id", "price"]
)

Qdrant查询代码(使用Python客户端):

from qdrant_client import QdrantClient

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

results = client.search(
    collection_name="product_emb",
    query_vector=query_vector,
    query_filter=models.Filter(
        must=[
            models.FieldCondition(key="price", range=models.Range(gte=100)),
            models.FieldCondition(key="category", match=models.MatchValue(value="electronics")),
        ]
    ),
    limit=10,
    with_payload=True,
)

五、踩坑与优化:三次真实的生产事故

5.1 Milvus内存暴涨问题

第一次压测Milvus时,32G内存直接被吃满,系统开始疯狂swap。排查后发现是HNSW的efConstruction参数设置过大(我们一开始设了500),导致索引构建时内存峰值极高。调回200后内存降到22G左右。

优化方案:Milvus的dataCoord.segment.maxSize参数也影响内存,默认1024MB,我们调小到512MB,内存占用进一步降到19G。

5.2 Qdrant写入丢数据

Qdrant的异步写入(wait=false)确实快,但测试中发现如果并发过高,部分写入会返回accepted但实际丢失。查文档才发现Qdrant的异步写入在单机模式下有一个隐藏的队列上限,默认10000条,超出的请求会被静默丢弃。

优化方案:改用wait=true写入,或者增加write_consistency_factor参数。我们最终选择了wait=true批量提交(每批1000条),性能损失约15%,但数据完整性有保障。

5.3 Milvus与Qdrant的过滤性能倒挂

在做价格区间过滤时发现一个反直觉的现象:当过滤后数据量小于10万条时,Qdrant比Milvus快得多;但当过滤条件很宽泛(比如过滤后仍有300万条),Milvus反而更快

原因是两者实现预过滤的方式不同:Milvus先走HNSW粗筛再按bitmap过滤,Qdrant则是先按标量索引过滤再走向量搜索。前者在过滤条件严格时浪费了大量向量距离计算,后者在过滤条件宽松时又产生了大量中间结果。

优化方案:针对我们的业务场景(价格+类目过滤后通常剩5-20万条),Qdrant的表现其实更符合预期。如果过滤条件非常宽泛,建议在Milvus中把expr里的索引字段改为in操作而非范围比较。

六、效果数据与最终选型

经过两周的对比测试,最终的决策依据如下:

资源占用对比(500万向量,16核32G环境)
- Milvus:内存峰值22.5G(含etcd+minio),磁盘占用约8GB(原始向量+索引)
- Qdrant:内存峰值13.2G(纯Qdrant进程),磁盘占用约10GB(因为Qdrant额外存了payload索引)

运维复杂度
- Milvus:三个容器组件,升级时需同步更新版本,etcd和minio的备份恢复比较麻烦
- Qdrant:单容器,升级只需换镜像,存储目录直接打包即是备份

功能完整度
- Milvus有完整的用户权限管理(RBAC),Qdrant在1.9版本还没有内置的认证机制(需要前置Nginx做basic auth)
- 两者都支持HNSW、IVF索引,但Milvus额外支持DiskANN(基于磁盘的索引),适合超大数据集

最终结论:我们的业务场景(过滤条件多、数据量适中、单机部署)选择了Qdrant。原因有三:
1. 内存占用少40%,可以在同一台机器上跑更多的模型服务
2. 部署简单,故障排查效率高
3. 过滤后的查询延迟虽比Milvus高,但仍在业务可接受的50ms以内

如果你们的场景是数据量过亿且需要水平扩展,那Milvus的分布式架构和DiskANN索引会是更好的选择。但如果只是千万级以下、单机或双机部署,Qdrant的生产力优势会明显胜出。

最后提醒一句:任何向量数据库的选型都别只看benchmark数字,用你们自己的数据和查询模式去实测,否则很容易踩到我们踩过的坑。