一、问题背景:为什么我们要换掉Faiss

我们做的业务是电商场景下的“以图搜图”和“语义相似商品推荐”。早期直接用Faiss做离线索引,每天全量重建一次。问题很快暴露:

  • 商品库每天新增约8万条向量,Faiss重建索引期间查询服务不可用
  • 无法做实时增删改,运营下架商品后向量还在
  • 多租户场景下,不同商家需要物理隔离,Faiss单索引做不到

于是开始评估专用向量数据库。候选有Milvus、Qdrant、Weaviate和PgVector。PgVector在50万向量以上时查询延迟明显抖动,Weaviate的GraphQL接口团队不喜欢,最终聚焦在Milvus和Qdrant。

选型核心诉求:
- 单节点能扛住100万条768维向量
- 支持实时增删改,延迟<100ms
- 内存占用可控,8GB机器能跑起来
- 过滤条件(如品类、价格区间)能和向量检索混合

二、环境与版本

所有测试在同一台机器上完成:

  • 云主机:AWS EC2 c6i.2xlarge(8 vCPU,16GB内存,GP3 500GB)
  • 操作系统:Ubuntu 22.04 LTS
  • Docker:24.0.7,Docker Compose v2.23.0
  • Milvus:2.4.1(standalone模式,etcd 3.5.5 + MinIO RELEASE.2023-03-20)
  • Qdrant:1.9.2(单节点,官方镜像)
  • 客户端:Python 3.10,pymilvus 2.4.3,qdrant-client 1.9.1
  • 测试数据集:100万条768维向量,随机生成,附带10万个过滤标签

两者都通过Docker Compose部署,避免污染宿主机。

三、方案设计

3.1 部署方案

Milvus standalone 需要三个容器:etcd、MinIO、milvus-standalone。官方compose文件直接可用,但要注意etcd和MinIO的健康检查顺序。

Qdrant 单容器即可,数据卷挂载到宿主机。没有外部依赖,部署难度低一个数量级。

3.2 索引与参数

为了公平对比,两者都使用HNSW索引:

  • Milvus:index_type=HNSWM=16efConstruction=200,查询时ef=128
  • Qdrant:hnsw_config.m=16ef_construct=200,查询时hnsw_ef=128

距离度量统一用COSINE。

3.3 测试方法

  • 写入:分批插入,每批5000条,记录总耗时和内存峰值
  • 查询:随机选1000条向量做top-10检索,计算P50/P95/P99延迟和QPS
  • 过滤查询:在向量检索基础上加category_id in [1,2,3]条件
  • 资源占用:用docker stats每10秒采样一次,取稳定后的平均值

四、核心实现(含代码)

4.1 Milvus部署与查询

部署命令:

wget https://github.com/milvus-io/milvus/releases/download/v2.4.1/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d

Python写入与查询:

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
import numpy as np, time

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

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="category_id", dtype=DataType.INT64),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
]
schema = CollectionSchema(fields, description="product_vectors")
collection = Collection(name="products", schema=schema)

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {"M": 16, "efConstruction": 200},
}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()

# 批量写入
vectors = np.random.rand(1000000, 768).astype(np.float32)
ids = list(range(1000000))
cats = np.random.randint(0, 50, 1000000).tolist()

t0 = time.time()
batch = 5000
for i in range(0, 1000000, batch):
    collection.insert([
        ids[i:i+batch],
        cats[i:i+batch],
        vectors[i:i+batch].tolist(),
    ])
collection.flush()
print(f"Milvus insert 1M vectors: {time.time()-t0:.1f}s")

# 查询
query_vec = vectors[0].tolist()
search_params = {"metric_type": "COSINE", "params": {"ef": 128}}
t0 = time.time()
res = collection.search(
    data=[query_vec],
    anns_field="embedding",
    param=search_params,
    limit=10,
    expr="category_id in [1,2,3]",
    output_fields=["category_id"],
)
print(f"Milvus search: {(time.time()-t0)*1000:.1f}ms")

4.2 Qdrant部署与查询

部署命令:

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

Python写入与查询:

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchAny
import numpy as np, time

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

client.recreate_collection(
    collection_name="products",
    vectors_config=VectorParams(size=768, distance=Distance.COSINE),
    hnsw_config={"m": 16, "ef_construct": 200},
)

vectors = np.random.rand(1000000, 768).astype(np.float32)
cats = np.random.randint(0, 50, 1000000).tolist()

t0 = time.time()
batch = 5000
for i in range(0, 1000000, batch):
    points = [
        PointStruct(id=j, vector=vectors[j].tolist(), payload={"category_id": cats[j]})
        for j in range(i, i+batch)
    ]
    client.upsert(collection_name="products", points=points)
print(f"Qdrant upsert 1M vectors: {time.time()-t0:.1f}s")

# 查询
query_vec = vectors[0].tolist()
t0 = time.time()
res = client.search(
    collection_name="products",
    query_vector=query_vec,
    limit=10,
    query_filter=Filter(
        must=[FieldCondition(key="category_id", match=MatchAny(any=[1,2,3]))]
    ),
    search_params={"hnsw_ef": 128},
)
print(f"Qdrant search: {(time.time()-t0)*1000:.1f}ms")

五、踩坑与优化

Milvus的坑:

  1. 首次查询慢:collection.load()后第一次查询要等段加载,P99会飙到2秒。解决办法是预热:load后先跑100次空查询。
  2. etcd磁盘写入:默认etcd数据在容器内,重启丢元数据。必须挂载volume,否则collection schema全丢。
  3. 内存溢出:100万×768维float32约3GB原始数据,Milvus加载索引后实测占用7.2GB。8GB机器跑100万向量很勉强,建议16GB起步。
  4. 过滤性能expr里的标量过滤在Milvus 2.4中走的是“先过滤再检索”,当过滤命中率低于10%时延迟反而比纯向量检索高。可以开启partition_key做分区裁剪。

Qdrant的坑:

  1. upsert不是幂等覆盖:相同id会覆盖,但payload是整体替换不是合并。要合并得用set_payload
  2. 内存映射:Qdrant默认把向量存磁盘并mmap,内存占用看着低,但首次查询会触发磁盘IO。设置on_disk: false强制常驻内存后,100万向量占用约4.8GB。
  3. 过滤索引:category_id这种低基数字段,建payload索引反而慢。高基数字段(如商家id)建索引后过滤查询快3倍。

共同优化:
- 批量写入用5000-10000条一批,太小网络开销大,太大客户端内存涨
- 查询时ef/hnsw_ef从64开始调,128是召回率和延迟的平衡点,256以上延迟翻倍但召回只涨0.3%

六、效果数据

100万条768维向量,top-10检索,1000次查询统计:

指标 Milvus 2.4.1 Qdrant 1.9.2
写入总耗时 412s 287s
索引构建时间 186s 94s
P50延迟(纯向量) 8.2ms 6.7ms
P99延迟(纯向量) 24ms 18ms
QPS(纯向量) 892 1120
P99延迟(带过滤) 41ms 29ms
内存占用(稳定) 7.2GB 4.8GB
磁盘占用 5.1GB 3.9GB
部署容器数 3 1

结论:
- 单节点100万向量以内,Qdrant在延迟、内存、部署复杂度上全面占优
- Milvus的优势在分布式:我们后来压到500万向量时,Milvus集群版QPS到3400,Qdrant单节点掉到480且P99到120ms
- 如果业务量在200万向量以下且不想运维复杂中间件,选Qdrant
- 如果预期半年内破千万向量,或者需要多租户物理隔离、一致性快照,直接上Milvus分布式

最终我们选了Qdrant,因为当前商品库只有80万向量,且团队只有2个后端,不想维护etcd和MinIO。等破500万再迁Milvus,Qdrant的snapshot可以导出,迁移成本可控。