Milvus 折腾记

一、为什么要换

我的 RAG 系统一开始用的是 Redis Stack,RediSearch 的 HNSW 做向量检索,再搭上 ReJSON 存元数据。刚开始只有几万条文档 chunk,延时 5-8ms,完全够用。

等到文档库涨到 30 万条的时候,问题来了:查询延迟开始往 30-50ms 走。到了 50 万条,冷查询偶尔飙到 100ms 以上。最要命的是 HNSW 索引内存占用——1536 维向量,M=16,一条向量大概 147KB,50 万条就是 73GB 内存。Redis 是纯内存数据库,这个趋势下去迟早扛不住。

对比了一圈:

方案 50万向量内存 百万级延迟 能扛到多大规模
Redis Stack (HNSW) ~73GB 30-100ms ~100万,再往上内存吃不消
FAISS (内存) ~12GB (IVF) <5ms 受限于单机内存
Chroma 10万左右就不行了
Milvus ~6GB (IVF_SQ8) <10ms 亿级

Milvus 的核心优势就一点:向量不一定要全放内存。它可以落盘(MinIO/S3),内存只放索引。而且支持量化压缩(SQ8、PQ),1536 维的向量从浮点数压到 8bit,内存直接砍到 1/8。

二、安装

用的 Docker Compose 单机版,三个容器——etcd(存元数据)、MinIO(存向量数据)、Milvus 本身:

# docker-compose.yml
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:
      - etcd_data:/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:
    image: minio/minio:latest
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    volumes:
      - minio_data:/minio_data
    command: minio server /minio_data --console-address ":9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]

  milvus:
    image: milvusdb/milvus:v2.4.0
    depends_on:
      etcd:
        condition: service_started
      minio:
        condition: service_healthy
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"
      - "9091:9091"
    volumes:
      - milvus_data:/var/lib/milvus
    command: milvus run standalone

volumes:
  etcd_data:
  minio_data:
  milvus_data:
docker compose up -d
# 等大概半分钟
curl localhost:9091/healthz  # 返回 OK

如果只是本地开发测试,Milvus 2.4 开始支持 Lite 模式pip install milvus 之后一行代码就能跑,数据存在本地 SQLite 文件里,不需要 Docker、不需要 etcd、不需要 MinIO。开发者体验终于追上来了。

三、Schema 设计和数据迁移

我的 RAG 文档 Collection 的 schema:

from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType

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

fields = [
    FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=64, is_primary=True),
    FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=8192),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536),
    FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=64),      # 所属文档
    FieldSchema(name="chunk_type", dtype=DataType.VARCHAR, max_length=32),  # text/figure/table
    FieldSchema(name="created_at", dtype=DataType.INT64),                   # unix timestamp
]

schema = CollectionSchema(fields, description="RAG document chunks")
collection = Collection(name="rag_chunks", schema=schema)

这里有两点是我踩坑之后的教训:

  1. 主键用内容的 MD5,不要用 UUID 或 INT64 自增。同一段文本永远生成同一个 MD5,增量更新时自动覆盖旧 chunk,不用先 delete 再 insert。UUID 每次生成新 ID,同一条 chunk 入库两遍你都发现不了。INT64 自增在迁移和合并时更是噩梦。具体解释见 Embedding 向量化实战 的第九节。

  2. 标量字段只建你真需要过滤的。我最开始一股脑把所有 metadata(作者、日期、标签、语言、文件路径、页数、字数字数字数……)全部塞进 Milvus。后来发现大部分字段从来没人查,白白浪费了存储和索引开销。现在我只保留 doc_idchunk_typecreated_at 这三个真的在过滤条件里出现过的字段。其余全放 Redis,按 id 回查。

从 Redis Stack 迁移数据到 Milvus 我是写了一个很简单脚本:batch read Redis → 构造 Milvus entities → batch insert Milvus。50 万条大概跑了 8 分钟。迁移脚本的核心逻辑:

BATCH = 1000
for offset in range(0, total, BATCH):
    # 从 Redis 批量读
    keys = [f"chunk:{i}" for i in range(offset, offset + BATCH)]
    data = redis_client.json().mget(keys, "$")

    # 组织成 Milvus 格式
    ids = [d["id"] for d in data]
    texts = [d["text"] for d in data]
    embeddings = [d["embedding"] for d in data]
    doc_ids = [d["doc_id"] for d in data]
    types = [d.get("chunk_type", "text") for d in data]
    timestamps = [d.get("created_at", 0) for d in data]

    collection.insert([ids, texts, embeddings, doc_ids, types, timestamps])
    collection.flush()

四、索引选型——花了最多时间的地方

Milvus 的索引类型一大堆,选错了之后要么内存爆炸要么召回掉崖。我把每种都实测了一遍,跟你分享一下数据。

我的测试条件:100 万条 1536 维向量,阿里云 ECS 8 核 32G。

索引 索引构建时间 内存占用 top-10 召回率 QPS(ef=64) 我的结论
FLAT 无(不做索引) 6GB 100% ~200 10万以下用这个,精准不废话
IVF_FLAT 8分钟 8GB 96% ~800 中规中矩,适合入门
IVF_SQ8 8分钟 4GB 93% ~900 对内存最友好的选择
HNSW 25分钟 14GB 99% ~2500 百万级的最优解
SCANN 18分钟 10GB 98% ~3000 数据再大时的首选

我的选择是 HNSW。理由:32G 内存扛得住 14G 的索引,99% 的召回率和 2500 QPS 对于我的场景来说是最优平衡。但如果你的机器只有 16G 内存,那就别纠结——老老实实用 IVF_SQ8,内存省一大半,召回率只掉 6 个百分点,实际体验里感知不出来。

HNSW 的参数调优:

index_params = {
    "index_type": "HNSW",
    "metric_type": "COSINE",
    "params": {
        "M": 16,              # 别设太高,M=16 到 M=64 提升不到 1% 召回,内存多 4 倍
        "efConstruction": 200 # 只影响建索引速度,不影响查询,200 够了
    }
}
collection.create_index(field_name="embedding", index_params=index_params)

查询的时候可以动态调 ef

# 高精度场景(ef=256,稍慢)
search_params = {"metric_type": "COSINE", "params": {"ef": 256}}

# 高吞吐场景(ef=64,快)
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}

五、查询和过滤的实际用法

基础检索没什么说的,就一行:

collection.load()   # 必须先 load,把索引加载进内存
results = collection.search(
    data=[query_embedding],
    anns_field="embedding",
    param={"metric_type": "COSINE", "params": {"ef": 128}},
    limit=10,
    output_fields=["text", "doc_id", "chunk_type"]
)

真正有用的是标量过滤——比如限定在某个文档内搜索:

results = collection.search(
    data=[query_embedding],
    anns_field="embedding",
    param=search_params,
    limit=10,
    expr='doc_id == "doc_123" and chunk_type == "text"',  # 过滤条件
    output_fields=["text", "doc_id"]
)

expr 的语法和 SQL WHERE 一样:==!=><inandornot,还支持 like。注意字符串值要加引号。

六、迁移过程中碰到的实际问题

问题一:插入时内存爆了。一开始我设了 BATCH=10000,跑了几个 batch 之后 Milvus 直接 OOM。后来降到 BATCH=1000,并且在每批之间加 collection.flush() 手动刷盘,就不爆了。

问题二:先插数据再建索引,比边插边建快一倍。Milvus 默认在插入时会自动建索引,这个行为在大量数据写入时极慢。我改成先关 compaction → 全量插入 → 手动建索引 → 开启 compaction,同批 50 万数据写入时间从 35 分钟降到了 8 分钟。

问题三:HNSW 的冷启动问题。刚建完 HNSW 索引的前几次查询延迟会比较高(50ms+),是因为图的节点还没全加载进内存。跑几分钟后就稳定在 10ms 以内了。这个是正常行为,不是什么 bug。

问题四:MinIO 挂了导致整个 Milvus 不可用。有一次 MinIO 因为磁盘满了宕掉,Milvus 直接报连接错误。虽然 Milvus 的内存里缓存了热数据,但写入链路完全断了。后来给 MinIO 加了 healthcheck + 自动重启,加了个 20G 的磁盘监控告警,就没再出过类似问题。

七、监控和运维

Milvus 在 9091 端口暴露了 Prometheus metrics,接入 Grafana 之后能看到 QPS、延迟分位数、内存占用、segment 数量等。

日常运维其实就是盯着两个指标:

内存使用率 < 80%      → 超过就扩容或换 IVF_SQ8
查询 P95 延迟 < 20ms   → 持续超标就调低 ef 或加 partition

还有一个小技巧:按时间建 Partition。比如按月分——2026_052026_06——查询的时候如果用户只需要最近三个月的数据,只搜三个 partition 而不是全量扫描,延迟少一半。

# 建 Partition
from pymilvus import Partition
partition = Partition(collection, "2026_06")

# 只搜指定分区
results = collection.search(
    data=[query_embedding],
    anns_field="embedding",
    param=search_params,
    limit=10,
    partition_names=["2026_05", "2026_06"],
    output_fields=["text"]
)

八、结论

Redis Stack → Milvus 的迁移不值得在项目早期就做。如果你的向量量还不到 30 万,别折腾,Redis Stack 完全够用,省心省力。

但当数据开始往 50 万、100 万走的时候,Milvus 的优势就非常明显了:内存省、延迟低、能横向扩展。迁移成本其实不高——主要是换一套 pymilvus 的 API,写个 batch 迁移脚本,半天就能搞定。真正花时间的是调索引参数和对标测试,但这是必须做的一次性投入。

我现在线上跑了一年多,100 万+ 向量,HNSW 索引,日常 P95 延迟稳定在 8-12ms,内存占用 14G。比 Redis Stack 时代省了一半内存、快了三倍、睡得安稳。