一、问题背景:从ES暴力搜索到向量数据库

我们团队在做一个服装电商的以图搜图功能,最初用Elasticsearch的dense_vector插件,索引100万条128维特征时,单次查询延迟已经飙到800ms+,更致命的是内存占用直接吃满16G的测试机。于是开始调研专门的向量数据库,目标锁定在Milvus和Qdrant——前者是开源社区最活跃的,后者是Rust写的,性能口碑很好。

业务场景:500万条商品图特征(ResNet50提取的128维向量),需要支持“颜色=红色 AND 价格95%(相对暴力计算)。

二、环境与版本

  • 操作系统:Ubuntu 22.04 LTS(内核5.15)
  • Docker版本:24.0.5
  • Milvus:2.3.3(standalone模式,含etcd和minio)
  • Qdrant:1.7.3(单节点模式)
  • Python客户端:pymilvus 2.3.x / qdrant-client 1.9.x
  • 数据集:500万条随机生成的128维float向量,每条附带color(string)、price(float)两个标量字段

这里有个坑:Milvus 2.3.x的standalone模式默认会启动etcd和minio两个依赖容器,内存基线占用直接干到3.2G(含cache)。Qdrant单节点就一个容器,内存占用2.1G。这是后话,先部署。

三、方案设计:Docker Compose部署对比

两个系统都支持Docker部署,为了公平,统一用Docker Compose管理。Milvus的standalone部署需要三个服务编排:

# 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
      - ETCD_SNAPSHOT_COUNT=50000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd

  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]

  standalone:
    image: milvusdb/milvus:v2.3.3
    command: ["milvus", "run", "standalone"]
    ports:
      - "19530:19530"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    depends_on:
      - etcd
      - minio

Qdrant这边就简单粗暴了,一个服务完事:

# docker-compose-qdrant.yml
version: '3.5'
services:
  qdrant:
    image: qdrant/qdrant:v1.7.3
    ports:
      - "6333:6333"
      - "6334:6334"  # gRPC端口
    volumes:
      - ./qdrant_storage:/qdrant/storage
    command: ["qdrant", "--config-path", "/qdrant/config/production.yaml"]

部署完成后,用docker stats看基线内存:Milvus全家桶吃掉3.2G,Qdrant单容器2.1G。注意Milvus的minio是独立进程,会占额外内存。

四、核心实现:数据写入与查询对比

先看Milvus的写入和查询代码。这里有个关键参数index_typemetric_type的选择,直接决定性能和召回率:

# milvus_test.py
from pymilvus import (Collection, CollectionSchema, DataType, FieldSchema,
                      connections, utility)
import numpy as np
import time

# 连接Milvus
connections.connect(host='localhost', port='19530')

# 定义schema,注意标量字段要加index才能做过滤
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=128),
    FieldSchema(name="color", dtype=DataType.VARCHAR, max_length=32),
    FieldSchema(name="price", dtype=DataType.FLOAT),
]
schema = CollectionSchema(fields=fields, description="product search")
collection = Collection(name="products", schema=schema)

# 创建索引:IVF_SQ8在精度和内存间平衡,nlist=1024
collection.create_index(field_name="embedding",
                        index_params={"index_type": "IVF_SQ8", "metric_type": "L2", "params": {"nlist": 1024}})

# 写入500万条数据
BATCH_SIZE = 10000
for i in range(0, 5000000, BATCH_SIZE):
    vectors = np.random.randn(BATCH_SIZE, 128).astype(np.float32)
    colors = np.random.choice(['red', 'blue', 'green', 'black', 'white'], BATCH_SIZE)
    prices = np.random.uniform(10, 1000, BATCH_SIZE).astype(np.float32)
    data = [vectors, colors, prices]
    collection.insert(data)
    if i % 100000 == 0:
        print(f"inserted {i} rows")

collection.flush()
print(f"total rows: {collection.num_entities}")

# 带标量过滤的混合查询
collection.load()
query_vector = np.random.randn(128).astype(np.float32)
start = time.time()
results = collection.search(data=[query_vector], anns_field="embedding",
                            param={"metric_type": "L2", "params": {"nprobe": 32}},
                            limit=10,
                            expr='color == "red" and price < 500',
                            output_fields=["color", "price"])
print(f"query latency: {(time.time()-start)*1000:.1f} ms")
print(f"result count: {len(results[0])}")

Qdrant这边用Rust写的,Python客户端是异步的,写法不太一样。注意Qdrant的payload索引需要显式创建,否则标量过滤会全表扫描:

# qdrant_test.py
from qdrant_client import QdrantClient
from qdrant_client.models import (Distance, VectorParams, PointStruct,
                                  Filter, FieldCondition, MatchValue, Range)
import numpy as np
import time

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

# 创建collection,指定向量维度和距离函数
client.create_collection(
    collection_name="products",
    vectors_config=VectorParams(size=128, distance=Distance.L2),
)

# 创建标量索引,关键步骤!
client.create_payload_index(collection_name="products", field_name="color", field_schema="keyword")
client.create_payload_index(collection_name="products", field_name="price", field_schema="float")

# 批量写入,注意Qdrant的upsert接口
BATCH_SIZE = 10000
for i in range(0, 5000000, BATCH_SIZE):
    vectors = np.random.randn(BATCH_SIZE, 128).astype(np.float32)
    colors = np.random.choice(['red', 'blue', 'green', 'black', 'white'], BATCH_SIZE)
    prices = np.random.uniform(10, 1000, BATCH_SIZE).astype(np.float32)
    points = []
    for j in range(BATCH_SIZE):
        points.append(PointStruct(
            id=i+j,
            vector=vectors[j].tolist(),
            payload={"color": colors[j], "price": prices[j]}
        ))
    client.upsert(collection_name="products", points=points)
    if i % 100000 == 0:
        print(f"upserted {i} rows")

# 带标量过滤的查询
query_vector = np.random.randn(128).astype(np.float32)
query_filter = Filter(
    must=[
        FieldCondition(key="color", match=MatchValue(value="red")),
        FieldCondition(key="price", range=Range(lt=500))
    ]
)
start = time.time()
results = client.search(
    collection_name="products",
    query_vector=query_vector,
    query_filter=query_filter,
    limit=10,
    with_payload=True
)
print(f"query latency: {(time.time()-start)*1000:.1f} ms")
print(f"result count: {len(results)}")

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

第一个坑:Milvus的标量filter不建索引会拖垮查询。 刚开始我没给color和price加索引,查询延迟直接飙到1.2秒。后来用collection.create_index给标量字段加索引,延迟降到280ms。Qdrant这边更狠,直接拒绝执行不带索引的过滤查询(返回400错误),逼着你建索引。

第二个坑:Qdrant的WAL日志和段合并。 写入500万条数据时,Qdrant的磁盘占用一度膨胀到21G(因为WAL未合并),而Milvus通过minio存储,稳定在18G。后来发现Qdrant需要手动触发optimizerflush操作,或者调整optimizers_configdefault_segment_number参数。把段大小调大到200MB后,磁盘降到11G。

第三个坑:Milvus的nprobe参数是召回率的命根子。 默认nprobe=8,召回率只有87%,调到32后召回率到95%,但延迟从180ms涨到280ms。Qdrant的HNSW索引没有这个参数,但有个ef参数(查询时的搜索宽度),默认是128,调到256后召回率从92%涨到96%,延迟从150ms涨到240ms。两者调参思路类似,都是拿延迟换精度。

六、效果数据:500万向量实测对比

经过三轮调优后,最终性能对比如下(每轮跑1000次查询取P99):

指标 Milvus 2.3.3 Qdrant 1.7.3
写入吞吐(条目/秒) 12,500 15,800
纯向量查询P99 210ms 180ms
混合查询(标量过滤)P99 285ms 240ms
召回率(@10,相对暴力计算) 95.2% 95.8%
内存占用(docker stats) 3.2G(含etcd+minio) 2.1G
磁盘占用 18.5G 11.2G(调优后)
标量过滤支持 完整SQL表达式 支持,但语法受限
集群扩展 内置分片+副本 需独立部署分布式版

实际业务中我们选了Qdrant,理由是:内存占用少40%,纯查询性能快15%,而且部署运维简单——一个容器搞定。Milvus的标量过滤表达式更强大(支持inlike等),但我们的过滤逻辑就两种(颜色+价格区间),Qdrant够用了。

但如果你要上生产环境且数据量超过亿级,或者需要复杂的标量过滤逻辑,Milvus的集群方案更成熟。Qdrant的分布式版(v1.7+)还比较新,生产案例少,我们不敢赌。

最后提醒一句:向量数据库的性能和硬件强相关,我们测试的8核16G机器只是入门配置,如果你用SSD和更大的内存,数字会更好看。建议选型时用自己的业务数据和真实查询模式做benchmark,别直接抄别人的结论。