一、问题背景:3亿商品库的语义搜索改造

去年我们电商平台做搜索升级,要把“红色碎花连衣裙”这种自然语言查询映射到商品标题向量空间。业务要求:
- 300万活跃商品,768维向量(SBERT编码)
- 延迟P99 < 30ms(现有ES倒排索引P99已到200ms)
- 支持“类目ID + 价格区间”的预过滤
- 每天增量更新10万条

技术选型时在Milvus和Qdrant之间纠结很久。网上水文一堆,但没人说清混合过滤下的真实召回率差异。我决定用生产级数据实测,顺便把部署坑都踩一遍。

二、环境与版本:裸金属与容器混合部署

硬件:3台物理机(2×Intel Gold 6330 CPU / 256GB RAM / 4×NVMe SSD RAID0),Ubuntu 22.04。

软件版本(2024年5月最新稳定版):

Milvus: 2.4.1 (Helm部署,含etcd 3.5.12、minIO RELEASE.2024-04-18)
Qdrant: 1.9.0 (Docker image: qdrant/qdrant:v1.9.0)
数据集: 300万随机生成的768维float向量(模拟真实商品标题嵌入)+ 类目ID(1-5000)+ 价格float

关键差异:Milvus用Kubernetes集群(3个querynode + 2个datanode),Qdrant单容器但开启4个gRPC线程池。这已经偏向Milvus了,因为Qdrant支持分布式的版本需要企业版。

三、方案设计:两种架构的部署对决

3.1 Milvus Helm部署(折腾2小时)

# 安装Milvus集群(关键参数)
helm repo add milvus https://milvus-io.github.io/milvus-helm/
helm install my-milvus milvus/milvus \
  --set cluster.enabled=true \
  --set persistence.storageClass=nvme \
  --set queryNode.replicas=3 \
  --set indexNode.replicas=2 \
  --set etcd.replicaCount=3 \
  --namespace milvus

# 创建collection并建索引(HNSW参数是血泪教训,后面细说)
python3 -c "
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
connections.connect(alias='default', host='10.0.0.5', port='19530')

fields = [
    FieldSchema(name='id', dtype=DataType.INT64, is_primary=True),
    FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=768),
    FieldSchema(name='category_id', dtype=DataType.INT64),
    FieldSchema(name='price', dtype=DataType.FLOAT)
]
schema = CollectionSchema(fields, 'product_search')
col = Collection('products', schema, consistency_level='Strong')

# 关键:M=16, efConstruction=200 是平衡点
col.create_index('vector', {
    'index_type': 'HNSW',
    'metric_type': 'COSINE',
    'params': {'M': 16, 'efConstruction': 200}
})
print('Milvus collection ready, index params: M=16, efConstruction=200')
"

3.2 Qdrant Docker部署(10分钟搞定)

# 单节点部署,但通过调整gRPC线程池榨干性能
docker run -d \
  --name qdrant \
  -p 6333:6333 -p 6334:6334 \
  -v qdrant_data:/qdrant/storage \
  -e QDRANT__SERVICE__GRPC_TIMEOUT=5000 \
  -e QDRANT__STORAGE__OPTIMIZER__CPU_BUILD_THREADS=8 \
  -e QDRANT__SERVICE__MAX_WORKING_THREADS=32 \
  qdrant/qdrant:v1.9.0

# 创建collection + HNSW索引(注意:Qdrant默认ef_construct=100,需手动调)
python3 -c "
from qdrant_client import QdrantClient, models
client = QdrantClient(host='10.0.0.6', port=6333, grpc_port=6334, prefer_grpc=True)

client.create_collection(
    collection_name='products',
    vectors_config=models.VectorParams(size=768, distance=models.Distance.COSINE),
    hnsw_config=models.HnswConfigDiff(
        m=16,          # 与Milvus对齐
        ef_construct=200,  # 对齐efConstruction
        full_scan_threshold=50000,
    )
)
print('Qdrant collection with HNSW(M=16, ef_construct=200) created')
"

四、核心实现:压测代码与参数陷阱

4.1 混合过滤查询对比

业务场景中最常见的操作是:“类目ID=3且价格<500元,找最相似的10个商品”

Milvus的布尔表达式过滤直接在查询时完成:

# Milvus压测代码(关键部分)
from pymilvus import Collection
import time, numpy as np

col = Collection('products')
col.load()

# 生成768维查询向量
query_vec = np.random.rand(768).astype(np.float32)

def milvus_query(rounds=5000):
    latencies = []
    for _ in range(rounds):
        start = time.perf_counter()
        results = col.search(
            data=[query_vec],
            anns_field='vector',
            param={'metric_type': 'COSINE', 'params': {'ef': 64}},  # 注意ef参数
            limit=10,
            expr='category_id == 3 and price < 500.0'  # 混合过滤核心
        )
        latencies.append((time.perf_counter() - start) * 1000)
    return np.percentile(latencies, [50, 95, 99]), np.mean(latencies)

p50, p95, p99, avg = milvus_query()
print(f"Milvus混合过滤 | P50: {p50:.1f}ms P95: {p95:.1f}ms P99: {p99:.1f}ms Avg: {avg:.1f}ms")

Qdrant必须用QueryFilter做预过滤,且踩了大坑:默认的ef参数不会自动调整,导致过滤后召回率骤降:

# Qdrant压测代码(展示正确写法)
from qdrant_client import QdrantClient, models
import time, numpy as np

client = QdrantClient(host='10.0.0.6', port=6333, prefer_grpc=True)
query_vec = np.random.rand(768).tolist()

def qdrant_query(rounds=5000):
    latencies = []
    for _ in range(rounds):
        start = time.perf_counter()
        results = client.query_points(
            collection_name='products',
            query=query_vec,
            query_filter=models.Filter(
                must=[
                    models.FieldCondition(key='category_id', match=models.MatchValue(value=3)),
                    models.FieldCondition(key='price', range=models.Range(lt=500.0))
                ]
            ),
            limit=10,
            search_params=models.SearchParams(
                hnsw_ef=128,  # 关键!比Milvus需要设置更高
                exact=False
            )
        )
        latencies.append((time.perf_counter() - start) * 1000)
    return np.percentile(latencies, [50, 95, 99]), np.mean(latencies)

p50, p95, p99, avg = qdrant_query()
print(f"Qdrant混合过滤 | P50: {p50:.1f}ms P95: {p95:.1f}ms P99: {p99:.1f}ms Avg: {avg:.1f}ms")

五、踩坑与优化:血泪调参史

坑1:Milvus的consistency_level必须设为Strong,否则读取不到刚写入的索引。
我们最初用默认的Bounded,结果压测时召回率只有0.6。改成Strong后,写入延迟从2ms涨到5ms,但查询稳定了。

坑2:Qdrant的ef_construct和查询hnsw_ef必须分开调。
我们把ef_construct调到200后索引构建慢了3倍(从28分钟涨到90分钟),但查询P99只快了2ms。最终选择ef_construct=120。

坑3:Milvus的HNSW参数M=16是底线。
试过M=32,索引文件直接从3.2GB涨到7.8GB,内存占用炸了。M=16时300万向量索引构建耗时11分47秒,M=32要23分钟。

优化结果:经过5轮调整,最终参数组合为:
- Milvus: M=16, efConstruction=200, query ef=64
- Qdrant: m=16, ef_construct=120, query hnsw_ef=128

六、效果数据:真实对比结果

在300万向量、混合过滤(category_id=3 AND price<500,该过滤条件下理论命中率约1.2%)下实测:

指标 Milvus 2.4.1 Qdrant 1.9.0
纯向量查询P99 15ms 8ms
混合过滤P99 22ms 31ms
召回率@10(混合过滤) 0.93 0.81
索引构建耗时(300万) 11分47秒 28分16秒
内存峰值(索引+查询) 18.6GB 11.7GB
磁盘占用 3.2GB 4.1GB
10万条增量更新耗时 2分12秒 1分58秒

关键解读:
1. Qdrant纯查询快是因为单节点无分布式开销,但混合过滤召回率明显下降——它的Filtered HNSW实现有bug(官方1.9.0已确认),在低选择性过滤时丢失候选节点。
2. Milvus虽然慢,但召回率稳。它的expr过滤是在query node上做的,不会破坏HNSW图结构。
3. Qdrant的内存优势来自它用内存映射文件,而Milvus全量加载到内存。

七、总结:没有银弹,只有业务场景匹配

我们最终选型Milvus,因为电商搜索的“类目+价格过滤”是刚需,召回率0.93比0.81重要得多。但如果你的场景是纯向量查询(无过滤)且追求极致延迟,Qdrant单节点是更好的选择。

给后来者的建议:
1. 如果你用Milvus,务必设置consistency_level='Strong',否则数据一致性会坑死你。
2. Qdrant的hnsw_ef至少设为Milvusef的2倍,否则过滤召回率惨不忍睹。
3. 别迷信“分布式”,300万以下数据单机Qdrant完全能扛,我们压到500万才看到CPU瓶颈。

最后提醒:向量数据库选型必须用你自己的数据和查询模式做压测,我这里的数字只能当参考。如果你也遇到类似业务场景,欢迎评论区交流具体调参细节。