向量数据库进阶实战:Milvus 集群部署 + 混合检索,支撑亿级向量相似度搜索

3 阅读6分钟

当你的向量数据从百万级飙到亿级,单机 Milvus 开始喘气了?本文手把手带你部署 Milvus 集群,并实战混合检索(向量 + 标量过滤),让推荐系统、RAG 应用轻松抗住高并发。

为什么要上 Milvus 集群?

去年我给一个电商客户做商品推荐系统,初期 50 万 SKU,单机 Milvus 跑得飞起。结果双十一前 SKU 膨胀到 8000 万,向量维度 768,单机查询延迟从 20ms 飙到 1200ms,CPU 直接打满。

单机瓶颈在哪?

  • 内存不够:8000 万条 768 维 float32 向量 ≈ 220GB,还没算索引
  • 查询并发上不去:单实例只能吃满一个 CPU 核的查询线程
  • 无高可用:实例挂了,整个推荐系统凉凉

集群化之后的好处:

能力单机集群
数据容量< 5 亿条(受内存限制)理论上无限(可横向扩展)
查询 QPS500~200010000+
高可用多副本自动故障转移
写入吞吐5000 条/秒50000+ 条/秒

架构一览:Milvus 集群长什么样?

Milvus 2.x 是存算分离架构,核心组件:

  • Proxy:查询入口,无状态,可水平扩展
  • QueryNode:执行向量检索,CPU/GPU 混合
  • DataNode:处理写入和增量数据
  • IndexNode:构建索引(IVF、HNSW 等)
  • MinIO / S3:向量数据持久化存储
  • etcd:元数据管理
  • Pulsar / Kafka:消息队列(写吞吐削峰)
        ┌─────────────────────────────────────┐
        │          Load Balancer              │
        └──────────────┬──────────────────────┘
                       │
        ┌──────────────┼──────────────────────┐
        │         Proxy 实例 × N              │  ← 无状态,随便扩
        └──────────────┬──────────────────────┘
                       │
        ┌──────────────┼──────────────────────┐
        │    QueryNode × N   DataNode × N     │  ← 有状态,扩之前要迁移
        └──────────────┬──────────────────────┘
                       │
        ┌──────────────┴──────────────────────┐
        │  MinIO(S3)  etcd  Pulsar            │  ← 基础设施,建议独立集群
        └─────────────────────────────────────┘

实战:Docker Compose 快速拉起 3 节点测试集群

生产建议用 K8s + Helm,但本地验证先用 Docker Compose 把架构跑通。

docker-compose.yml

version: '3.5'

services:
  etcd:
    container_name: milvus-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
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd

  minio:
    container_name: milvus-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
    ports:
      - "9000:9000"
      - "9001:9001"

  pulsar:
    container_name: milvus-pulsar
    image: apachepulsar/pulsar:2.8.2
    command: bin/pulsar standalone
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/pulsar:/pulsar/data

  # 3个 Proxy 实例(无状态,可扩展)
  milvus-proxy-1:
    container_name: milvus-proxy-1
    image: milvusdb/milvus:v2.4.17
    command: ["milvus", "run", "proxy"]
    environment:
      - ETCD_ENDPOINTS=etcd:2379
      - MINIO_ADDRESS=minio:9000
      - PULSAR_ADDRESS=pulsar:6650
    depends_on:
      - etcd
      - minio
      - pulsar
    ports:
      - "19530:19530"   # 客户端连接端口

  milvus-proxy-2:
    container_name: milvus-proxy-2
    image: milvusdb/milvus:v2.4.17
    command: ["milvus", "run", "proxy"]
    environment:
      - ETCD_ENDPOINTS=etcd:2379
      - MINIO_ADDRESS=minio:9000
      - PULSAR_ADDRESS=pulsar:6650
    depends_on:
      - etcd
      - minio
      - pulsar

  # 2个 QueryNode(负责向量检索)
  milvus-querynode-1:
    container_name: milvus-querynode-1
    image: milvusdb/milvus:v2.4.17
    command: ["milvus", "run", "querynode"]
    environment:
      - ETCD_ENDPOINTS=etcd:2379
      - MINIO_ADDRESS=minio:9000
      - PULSAR_ADDRESS=pulsar:6650
    depends_on:
      - etcd
      - minio
      - pulsar

  milvus-querynode-2:
    container_name: milvus-querynode-2
    image: milvusdb/milvus:v2.4.17
    command: ["milvus", "run", "querynode"]
    environment:
      - ETCD_ENDPOINTS=etcd:2379
      - MINIO_ADDRESS=minio:9000
      - PULSAR_ADDRESS=pulsar:6650
    depends_on:
      - etcd
      - minio
      - pulsar

networks:
  default:
    name: milvus-cluster

启动:

docker-compose up -d
docker ps | grep milvus  # 确认 4 个 Milvus 实例都起来了

Python 实战:插入亿级向量 + 混合检索

1. 连接集群并创建 Collection

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

# 连接集群(指向任意一个 Proxy 实例)
connections.connect(
    alias="default",
    host="localhost",
    port="19530"
)

# 定义 Schema:主键 + 向量 + 标量字段
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
    FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64),   # 标量过滤字段
    FieldSchema(name="price", dtype=DataType.FLOAT),                         # 标量过滤字段
    FieldSchema(name="create_time", dtype=DataType.INT64),
]
schema = CollectionSchema(fields, description="电商商品向量库")

# 创建 Collection,设置 2 个分片(数据分布到不同 QueryNode)
collection = Collection(
    name="ecommerce_products",
    schema=schema,
    shards_num=2   # 关键:分片数 >= QueryNode 数量,才能并行查询
)
print(f"Collection 创建成功,分片数:{collection.num_shards}")

2. 批量插入 100 万条测试数据

import random
from tqdm import tqdm

BATCH_SIZE = 5000
TOTAL = 1_000_000

for start in tqdm(range(0, TOTAL, BATCH_SIZE), desc="插入向量中"):
    end = min(start + BATCH_SIZE, TOTAL)
    batch_size = end - start
    
    entities = [
        [i for i in range(start, end)],                          # id
        np.random.rand(batch_size, 768).tolist(),                # embedding
        [f"category_{random.randint(1,20)}" for _ in range(batch_size)],  # category
        [round(random.uniform(10.0, 9999.0), 2) for _ in range(batch_size)],  # price
        [1715000000 + random.randint(0, 86400*365) for _ in range(batch_size)],  # create_time
    ]
    
    collection.insert(entities)

print(f"✅ 插入完成,共 {collection.num_entities} 条")

3. 创建索引(IVF_SQ8 适合大规模数据)

# 向量索引
collection.create_index(
    field_name="embedding",
    index_params={
        "metric_type": "IP",        # 内积(余弦相似度需先归一化)
        "index_type": "IVF_SQ8",    # 量化索引,节省内存
        "params": {"nlist": 2048}  # 聚类中心数量,经验值:sqrt(N) ~ N/1000
    }
)

# 标量索引(加速混合检索中的过滤)
collection.create_index(field_name="category", index_params={"index_type": "Trie"})
collection.create_index(field_name="price", index_params={"index_type": "STL_SORT"})

collection.load()  # 加载到内存,才能查询
print("✅ 索引创建完成,Collection 已加载")

4. 混合检索实战(向量 + 标量过滤)

纯向量检索(不推荐,召回不准):

results = collection.search(
    data=[query_embedding],
    anns_field="embedding",
    param={"metric_type": "IP", "params": {"nprobe": 16}},
    limit=10
)

混合检索(精准!):

# 表达式语法和 SQL WHERE 类似
expr = "category == 'category_3' and price >= 100 and price <= 500"

results = collection.search(
    data=[query_embedding],
    anns_field="embedding",
    expr=expr,              # 👈 混合检索核心:标量过滤 + 向量检索
    param={
        "metric_type": "IP",
        "params": {"nprobe": 32}   # nprobe 越大越准但越慢
    },
    limit=10,
    output_fields=["category", "price"]  # 返回标量字段
)

for hit in results[0]:
    print(f"ID: {hit.id}, 距离: {hit.distance:.4f}, "
          f"分类: {hit.entity.get('category')}, 价格: {hit.entity.get('price')}")

性能调优 5 个关键参数

参数默认值推荐值(亿级数据)说明
nlist163844096~8192太大索引构建慢,太小查询精度低
nprobe816~64查询时扫描的聚类中心数,精度和速度的平衡点
shards_num12~4分片数,建议等于 QueryNode 数量
index_typeIVF_FLATIVF_SQ8 / HNSW亿级数据用 SQ8(量化压缩),内存不够用 HNSW
M(HNSW 专用)1648~64HNSW 的图连接数,越大召回率越高但占用内存越多

实测数据(8000 万条 768 维):

  • IVF_SQ8,nprobe=32:查询延迟 45ms,召回率 94%
  • HNSW,M=64:查询延迟 18ms,召回率 98%,但内存占用是 IVF 的 3 倍

生产环境部署建议

  1. QueryNode 内存计算:每 1 亿条 768 维向量(IVF_SQ8)约占用 4GB 内存,预留 50% 缓冲
  2. Proxy 前置负载均衡:用 Nginx 或云 LB 把流量分到多个 Proxy
  3. Kubernetes 部署:官方 Helm Chart 支持,QueryNode 用 HPA 根据 CPU 自动扩缩
  4. 监控指标:重点关注 milvus_proxy_sq_latency(查询延迟)和 milvus_querynode_cache_hit_rate(缓存命中率)

小结

Milvus 集群部署的核心要点:

  • Proxy 随便扩(无状态),QueryNode 谨慎扩(涉及数据迁移)
  • 混合检索用 expr 参数,标量字段一定要建索引
  • 亿级数据优先选 IVF_SQ8(内存友好),延迟敏感场景用 HNSW
  • 分片数设置 = QueryNode 数量,才能把查询并行化

👤 作者简介

一枚在大中原腹地(河南)卖公有云的从业者,主营腾讯云/阿里云/火山云,曾踩坑无数,现专注AI大模型应用落地。关注公众号「公有云cloud」,围观AI前沿动态~