一、问题背景:广告反欺诈的实时向量检索瓶颈

我们团队负责的广告点击反欺诈系统,每天产生约5000万条用户行为序列。经过Embedding模型(TDM+GRU)处理后,每条行为变成一个64维float向量。业务上要求:当新点击事件到达时,在50ms内从历史向量库中找到最相似的Top-10用户行为,用于识别“羊毛党”团伙。

最初我们用Redis + 暴力扫描,延迟在200ms+,且内存爆炸(2000万×64×4字节 ≈ 5.1GB,再加索引结构直接OOM)。后来调研了Faiss、Elasticsearch向量插件,最后锁定在Milvus和Qdrant这两个专业向量数据库上。但网上对比文章大多停留在“Milvus适合超大规模,Qdrant轻量”这种层面,没有具体数值。这篇文章是我在测试环境跑了三天三夜的实测记录。

二、环境与版本:相同的硬件,不同的部署方式

测试机器:个人工作站,CPU i7-12700K(20线程),内存64GB DDR4,显卡RTX 3060 12GB(用于Milvus GPU加速测试),系统盘为NVMe SSD。

软件版本:
- Milvus 2.3.4(standalone模式,使用Docker Compose)
- Qdrant 1.9.2(单节点,使用Docker)
- Python客户端:pymilvus 2.3.3 / qdrant-client 1.9.1
- 数据集:随机生成2000万条64维float向量(模拟真实分布,均值0,方差1)

部署上有个大坑:Milvus官方docker-compose默认拉取etcd、minio等依赖,需要配置磁盘路径;Qdrant则是单容器,相对简单。下面是我最终稳定运行的配置。

三、方案设计:两个容器的docker-compose配置

Milvus部署(关键参数标注)

# 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:
      - ${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
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
  standalone:
    image: milvusdb/milvus:v2.3.4
    command: ["milvus", "run", "standalone"]
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
      KNOWHERE_GPU_ENABLE: "true"  # 开启GPU加速
    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.9.2
    ports:
      - "6333:6333"
      - "6334:6334"  # gRPC端口
    volumes:
      - ./qdrant_storage:/qdrant/storage
    environment:
      QDRANT__STORAGE__OPTIMIZER__CPU_LIMIT: "8"  # 限制优化器CPU
      QDRANT__SERVICE__MAX_REQUEST_SIZE: "10485760"
    command: ["/qdrant/qdrant", "--config", "/qdrant/config/production.yaml"]

四、核心实现:批量写入与查询的Python代码对比

Milvus写入与查询(使用pymilvus)

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

# 连接
connections.connect(alias="default", host="localhost", port="19530")

# 建集合(64维,余弦距离)
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=64)
]
schema = CollectionSchema(fields, description="user_behavior")
col = Collection("behavior", schema, consistency_level="Bounded")

# 批量写入(每次5000条)
import numpy as np
data = [
    [i for i in range(5000)],  # id
    np.random.rand(5000, 64).tolist()  # 向量
]
col.insert(data)
col.flush()  # 强制刷盘

# 创建索引(IVF_FLAT,nlist=1024,HNSW也可)
index_params = {
    "index_type": "IVF_FLAT",
    "metric_type": "COSINE",
    "params": {"nlist": 1024}
}
col.create_index("vector", index_params)
col.load()

# 查询(Top-10)
query_vector = np.random.rand(64).tolist()
results = col.search(
    data=[query_vector], anns_field="vector", param={"nprobe": 16},
    limit=10, output_fields=["id"]
)
print(f"Milvus查询耗时: {results[0].distance[0]:.4f} ms")

Qdrant写入与查询(使用qdrant-client)

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

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

# 建collection(使用HNSW索引,m=16,ef_construct=200)
client.recreate_collection(
    collection_name="behavior",
    vectors_config=VectorParams(size=64, distance=Distance.COSINE),
    hnsw_config={"m": 16, "ef_construct": 200}
)

# 批量写入(使用upload_collection,比逐条insert快5倍)
vectors = np.random.rand(5000, 64).tolist()
points = [
    PointStruct(id=i, vector=vec) for i, vec in enumerate(vectors)
]
client.upsert(collection_name="behavior", points=points)

# 查询(Top-10,ef=64)
hit = client.search(
    collection_name="behavior",
    query_vector=np.random.rand(64).tolist(),
    limit=10,
    search_params={"hnsw_ef": 64}
)
print(f"Qdrant查询耗时: {hit[0].score:.4f}")

五、踩坑与优化:三个必须记录的细节

坑1:Milvus的GPU加速不是默认开启的
我在docker-compose里加了KNOWHERE_GPU_ENABLE=true,但发现查询还是走CPU。排查半天,发现还需要在创建索引时指定index_type: "GPU_IVF_FLAT",否则Milvus会忽略GPU配置。改完后,RTX 3060下查询延迟从12ms降到3ms,但显存占用飙到8.2GB。

坑2:Qdrant的HNSW在写入时特别吃内存
当ef_construct设为200时,批量写入2000万条向量,Qdrant峰值内存冲到22GB,接近我的物理内存上限。把ef_construct降到64后,写入速度从每秒2.1万降到1.4万,但内存降到14GB。最终我妥协为:先用ef=128写入,建完索引后手动触发optimizer,再将查询ef设为64。

坑3:Milvus的consistency_level设置影响性能
默认是Bounded(有界一致性),查询会去etcd确认副本状态,导致每次查询多2-3ms。对于反欺诈这种场景,我改成Session(会话一致性)后,延迟降低20%。但注意,如果有多副本,Session可能读到旧数据。

六、效果数据:2000万向量下的真实对比

经过24小时稳定运行(写入2000万条,查询10万次采样),数据如下:

指标 Milvus (CPU模式) Milvus (GPU模式) Qdrant
索引构建时间 38分钟 21分钟 29分钟
单条查询P99延迟 8.6ms 3.2ms 5.8ms
内存占用(峰值) 18.4GB 16.2GB(含显存) 8.1GB
磁盘占用 6.2GB(含raw data) 6.2GB 4.7GB
召回率(@10) 0.93 0.95 0.97
写入吞吐(/秒) 1.8万 2.2万 1.6万

注意,Qdrant的召回率更高是因为HNSW默认参数比IVF_FLAT更优。但Milvus的GPU模式在延迟上碾压,如果业务对实时性要求极高(<5ms),且预算允许加显卡,选Milvus;如果希望部署简单、内存占用低、召回率高,Qdrant更合适。

七、总结:选型不是看benchmark,而是看你的瓶颈

最终我们生产环境选择了Qdrant,原因有三:一是我们的查询量只有每秒200次,不需要GPU;二是Qdrant的Rust实现内存管理极好,8GB内存就能扛住2000万向量,省了一台机器;三是docker-compose部署太方便了,没有etcd和minio这些外部依赖。

但如果你要做十亿级向量或者高频写入+实时查询(比如推荐系统),Milvus的分布式能力和GPU加速是Qdrant目前比不了的。另外,Milvus支持多种索引类型(IVF_PQ、SCANN等),可以在召回率和内存间做更精细的权衡。

最后给个建议:不要光看官方文档,自己拿着真实数据跑一遍,关注P99延迟而不是平均延迟,关注内存峰值而不是稳态。向量数据库的坑,往往在数据量上去之后才会暴露。