一、问题背景:粗排链路为什么让我重新审视向量数据库

上个月我们做广告召回后的粗排改造,需要把用户行为序列Embedding化后,从1000万广告向量中找Top50相关候选。业务特点很明确:标量过滤重(广告行业标签、价格区间、投放状态),写多读多(每小时全量更新,QPS峰值1.2万),资源受限(只能给到8C16G的物理机)。

我提前调研了Milvus和Qdrant。Milvus是老牌选手,架构重但功能全;Qdrant是Rust写的,轻量且性能激进。但网上水文太多,我决定自己部署压测,用真实数据说话。

二、环境与版本:裸机Docker Compose部署

服务器配置:Intel Xeon 3114 (8核16线程),32G内存(限制容器使用16G),SSD NVMe 500G。OS为Ubuntu 22.04 LTS。

版本选择:
- Milvus 2.3.3(standalone模式,含etcd和minio)
- Qdrant 1.7.3(单节点模式)

Docker Compose部署Milvus:

version: '3.5'
services:
  etcd:
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
  minio:
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
  milvus:
    image: milvusdb/milvus:v2.3.3
    command: ["milvus", "run", "standalone"]
    ports:
      - "19530:19530"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
      interval: 30s
      start_period: 90s

Qdrant部署极其简单:

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

三、方案设计:为什么用Glove-200d和混合查询压测

我用GloVe-200d真实词向量(100万条,每条向量200维,带8个标量字段),模拟广告向量场景。

压测场景设计:
1. 纯向量查询:只按向量相似度取Top50,测试基础性能
2. 混合查询:带category=electronicsprice<100的标量过滤,再向量检索——这是我们的真实业务场景

关键配置差异:
- Milvus创建Collection时需显式定义标量字段的索引类型(我用autoindex
- Qdrant需要在创建Collection时指定payload字段的索引(keyword和integer类型)

四、核心实现:Python初始化代码与查询对比

Milvus 2.3.3初始化与混合查询

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

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

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=200),
    FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64),  # 标量过滤
    FieldSchema(name="price", dtype=DataType.FLOAT)  # 范围过滤
]
schema = CollectionSchema(fields, "ad_vectors")
col = Collection("ad_collection", schema)

# 关键:为标量字段建索引,否则过滤性能极差
col.create_index("category", {"index_type": "Trie"})
col.create_index("price", {"index_type": "StructuredSort"})
col.create_index("embedding", {"index_type": "HNSW", "params": {"M": 16, "efConstruction": 200}})

# 混合查询
res = col.search(
    data=[query_vector],
    anns_field="embedding",
    param={"metric_type": "IP", "params": {"ef": 100}},
    limit=50,
    expr='category == "electronics" and price < 100.0',  # 标量过滤表达式
    output_fields=["id", "price"]
)

Qdrant 1.7.3初始化与混合查询

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, PointStruct, Filter,
    FieldCondition, Range, KeywordCondition
)
import numpy as np

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

client.recreate_collection(
    collection_name="ad_vectors",
    vectors_config=VectorParams(size=200, distance=Distance.DOT),
    # 关键:必须提前定义payload索引,否则过滤会全表扫描
    payload_schema={
        "category": PayloadSchemaType.KEYWORD,
        "price": PayloadSchemaType.FLOAT
    }
)

# 混合查询
results = client.search(
    collection_name="ad_vectors",
    query_vector=query_vector,
    query_filter=Filter(
        must=[
            FieldCondition(key="category", match={"value": "electronics"}),
            FieldCondition(key="price", range=Range(lt=100.0))
        ]
    ),
    limit=50,
    with_payload=["id", "price"]
)

五、踩坑与优化:那些文档没写明白的细节

Milvus的坑
1. Segment合并延迟:默认segmentSealProportion=0.3,意味着写入30%就封存segment,导致大量小segment。我改成0.6后,查询性能提升20%。需要在milvus.yaml里改common.retentionDurationdataCoord.segment.sealProportion
2. HNSW内存爆炸efConstruction从200降到128,内存占用从2.8G降到1.8G,召回率只损失0.3%。
3. 标量过滤必须用expr:Milvus的布尔表达式解析有点慢,过滤字段越多越明显。实测3个标量条件时,P95从15ms涨到28ms。

Qdrant的坑
1. Payload索引必须显式创建:很多人漏了这一步,结果过滤查询直接全表扫描,P95飙到800ms。
2. 默认内存映射on_disk_payload默认是false,导致所有payload都进内存。我改成true后,内存降到620M,但查询P95增加了3ms。
3. Rust的极限压榨:Qdrant的optimizers线程占用CPU很凶,部署时建议限制--optimizers-threads 2,否则会干扰查询线程。

六、效果数据:性能与资源的真实对比

用100万条向量、100个并发线程、每个线程查询100次,压测结果如下:

指标 Milvus 2.3.3 Qdrant 1.7.3
纯向量查询P95 (ms) 18 12
混合查询P95 (ms) 26 31
混合查询吞吐量 (QPS) 8500 6300
常驻内存 (GB) 1.8 0.62
数据导入耗时 (min) 12 9
索引构建耗时 (min) 6 4

结论:如果你的场景是纯向量检索(比如问答相似度),无脑选Qdrant,又轻又快。但像我们这种重标量过滤的广告场景,Milvus的表达式查询优化更好,吞吐量高出35%。不过Qdrant的内存优势太明显,后续如果扩展数据量到亿级,我可能会把Qdrant的on_disk_payload打开,用磁盘换内存。

七、总结

最后说点实际的。选型别只看benchmark,一定要拿自己的真实数据压测。我们最终选了Milvus,因为广告业务的过滤条件会越来越复杂。但如果你做的是纯向量检索,Qdrant的轻量部署和性能会让你惊喜。另外提一句,Milvus 2.3+的restful接口比之前好用多了,但和Qdrant的OpenAPI规范比起来还是不够优雅。下次如果数据量到5000万,我可能会再测一下Qdrant的分片能力,毕竟它支持分布式后性能提升会更明显。