BGE-M3三种向量在Milvus怎么存(稠密、稀疏、多向量)

BGE-M3 三种向量在 Milvus 里怎么存:一篇讲清稠密、稀疏、多向量

一句话总结 :BGE-M3 一次能吐出三种向量,但它们在 Milvus 里的命运完全不同 ------稠密 直接进 FLOAT_VECTOR(1024 维);稀疏 进 SPARSE_FLOAT_VECTOR,但键必须从字符串转成整数 ({int(k): v}),这是最常见的失败原因;多向量(ColBERT)能存,但一个 256 token 的块会从 4 KB 膨胀到约 1 MB ,除非你确实需要 late interaction,否则别存。


📋 目录

  1. 先看结论:三种向量的三种命运
  2. 稠密向量:最简单,但维度别写错
  3. 稀疏向量:一个字类型能卡你半天 ⭐
  4. 多向量:能存,但大概率不该存
  5. 完整代码:一表两字段,双路召回
  6. 存储成本算一笔账
  7. 一个容易忽略的配置项
  8. 总结

一、先看结论:三种向量的三种命运

BGE-M3 输出 Milvus 字段类型 索引类型 度量 建议
dense_vecs FLOAT_VECTOR(dim=1024) HNSW / IVF_FLAT / AUTOINDEX COSINE 或 IP ✅ 必存
lexical_weights SPARSE_FLOAT_VECTOR SPARSE_INVERTED_INDEX IP ✅ 建议存
colbert_vecs 无原生简单字段(见第 4 节) --- --- ⚠️ 默认不存

为什么是这个结论?

  • 稠密:语义检索的主力,一个块只占 4 KB,性价比最高。
  • 稀疏 :字面匹配的补充,只存非零项 ,比稠密还小,几乎白捡。
  • 多向量 :精度最高,但每个 token 都要存一个 1024 维向量,成本和收益严重不匹配。

二、稠密向量:最简单,但维度别写错

2.1 字段定义

python 复制代码
schema.add_field(
    field_name="dense",
    datatype=DataType.FLOAT_VECTOR,
    dim=1024,          # ⚠️ BGE-M3 固定 1024,写错就插不进去
)

只有一条要记住 :BGE-M3 的稠密维度是 1024 ,不是 768 也不是 1536。写错了会在 insert 时报维度不匹配。

2.2 索引怎么建

python 复制代码
index_params.add_index(
    field_name="dense",
    index_type="HNSW",
    metric_type="COSINE",
    params={"M": 16, "efConstruction": 200},
)
场景 建议
通用(推荐) HNSW + COSINE
数据量小 / 追求绝对精确 FLAT
亿级以上 IVF_FLAT(nlist 128--4096)
不想选 AUTOINDEX(Milvus 自动决定)

💡 COSINE 还是 IP?

BGE-M3 的稠密向量通常已归一化 ,归一化后 COSINE ≡ IP,排序完全一致。IP 计算更快,COSINE 更保险(万一向量没归一化也对)。不确定就用 COSINE。
⚠️ Milvus Lite 只支持 FLAT ,写了 HNSW 也会自动退化成 FLAT。要在本地验证索引效果,请用 Docker 起 Standalone。


三、稀疏向量:一个字类型能卡你半天 ⭐

这一节是全文最值得看的。

3.1 坑在哪:BGE-M3 返回的 key 是字符串

BGE-M3 的 lexical_weights 长这样:

python 复制代码
{'1012': 0.42, '3398': 0.31, '7719': 0.18}

注意:键是"字符串形式的 token id",不是整数。

而 Milvus 的 SPARSE_FLOAT_VECTOR 要求的是 {int: float}。直接插进去,会报类型错误或插入失败。

解法只要一行:

python 复制代码
sparse = {int(k): float(v) for k, v in lexical_weights.items()}
# {'1012': 0.42, ...}  →  {1012: 0.42, ...}

🎯 这一行是本文最有价值的一行。

大量教程里写着"直接把 lexical_weights 插进去",但在 FlagEmbedding 的实际输出上会失败。Zilliz 官方的 BGE-M3 示例里也是这么转的。

3.2 字段定义与索引

python 复制代码
# 字段:不需要 dim,稀疏向量只存非零项
schema.add_field(field_name="sparse", datatype=DataType.SPARSE_FLOAT_VECTOR)

# 索引:注意度量只能用 IP
index_params.add_index(
    field_name="sparse",
    index_type="SPARSE_INVERTED_INDEX",
    metric_type="IP",
)

两个必须知道的规则:

规则 说明
不能指定 dim 稀疏向量长度等于词表大小,不固定
度量只能用 IP 稀疏向量的内积 ≈ 词级加权匹配分,L2/COSINE 对它没有意义

3.3 为什么稀疏几乎"白捡"

它只存非零项。

一段 256 token 的文本,稠密向量要存 1024 个浮点(4 KB);而稀疏向量通常只有几百个非零项 ,每项约 8 字节(int32 索引 + float32 权重),总共 1--2 KB,比稠密还小。

💡 这是 BGE-M3 最划算的设计 :零额外模型、几乎零额外存储,就换来一路字面召回。


四、多向量:能存,但大概率不该存

4.1 它到底是什么

colbert_vecs 的形状是 [token数, 1024] ------不是一个向量,而是每个 token 一个向量。

查询时用 MaxSim 打分:查询的每个 token,在文档的所有 token 里找最像的那个,把最高分加起来。

精度确实最高,因为它保留了 token 级的细粒度对齐。

4.2 代价:存储不是"5 倍",是"几百倍"

算一下就知道:

类型 一个 256 token 的块存多少 大小(float32)
稠密 1 个 1024 维向量 4 KB
稀疏 约 200 个非零项 约 1.6 KB
多向量 256 个 1024 维向量 约 1 MB

🚨 差 250 倍以上。

网上有文章说"多向量存储膨胀 5--10 倍",那是压缩后 或者极短文本 的数字。按 float32 原始存储,一个正常长度的块就是几百倍。

一个 10 万块的知识库 :稠密约 400 MB,多向量约 100 GB。

4.3 Milvus 里到底怎么存

Milvus 没有"多向量字段"这种简单类型 (FLOAT_VECTOR 的维度必须是固定的)。目前有三条路:

方案 做法 评价
① 不存(推荐) 只用 dense + sparse,需要精度就上 reranker ✅ 95% 的项目应该选这个
② 自己压平存 存成 ARRAY<FLOAT> 或 JSON,查询时取回在 Python 里算 MaxSim ⚠️ 能跑,但完全用不上 Milvus 的索引,只能暴力算
③ 用原生多向量 Milvus 2.6.4+ 的 Array of Structs + MAX_SIM_COSINE 度量 ✅ 真正的 late interaction 支持,但版本门槛高、索引成本大

关于方案 ③:

  • Milvus 2.6.4 引入 "Struct in ARRAY",一行可以存多个向量,并支持 MAX_SIM / MAX_SIM_COSINE / MAX_SIM_IP 度量做实体级打分
  • Milvus 3.0 把它改名为 StructArray ,还加了 TokenANN / Muvera / Lemur 三种加速策略(在索引大小、训练成本、召回之间权衡)
  • 门槛:需要 2.6.4 以上;且为每个 token 建索引很贵,官方也提供了压缩方案来缓解

📌 具体 schema 语法请查官方 "Array of Structs" 文档------这块 API 在 2.6 → 3.0 之间有过命名调整,本文不写死代码,避免你抄到过期写法。

4.4 我的建议:用 reranker 替代它

同样是为了"精度",两条路的成本差了一个数量级:

存 ColBERT 多向量 用 Reranker 精排
存储 膨胀几百倍 0(不占存储)
额外模型 无(BGE-M3 自带) 需要 bge-reranker-v2-m3
耗时 查询时算 MaxSim 每个候选一次前向(O(N))
工程复杂度 高(版本、索引、调参) 低(几行代码)

🎯 结论 :先上 dense + sparse + reranker。 只有当你的召回率已经很高、瓶颈确实卡在"排序不够细"时,才考虑多向量。别一上来就存 ColBERT。


五、完整代码:一表两字段,双路召回

5.1 建表

python 复制代码
from pymilvus import MilvusClient, DataType, AnnSearchRequest, RRFRanker
from FlagEmbedding import BGEM3FlagModel

model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
client = MilvusClient("bge_m3.db")

schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field(field_name="id",     datatype=DataType.INT64, is_primary=True)
schema.add_field(field_name="text",   datatype=DataType.VARCHAR, max_length=8192)
schema.add_field(field_name="dense",  datatype=DataType.FLOAT_VECTOR, dim=1024)
schema.add_field(field_name="sparse", datatype=DataType.SPARSE_FLOAT_VECTOR)

index_params = client.prepare_index_params()
index_params.add_index(
    field_name="dense", index_type="HNSW",
    metric_type="COSINE", params={"M": 16, "efConstruction": 200},
)
index_params.add_index(
    field_name="sparse", index_type="SPARSE_INVERTED_INDEX", metric_type="IP",
)

client.create_collection(collection_name="kb", schema=schema, index_params=index_params)

5.2 写入

python 复制代码
docs = [
    "Milvus 是一款开源向量数据库,支持 IVF_FLAT 和 HNSW 索引",
    "BGE-M3 支持 100 多种语言,最大输入 8192 token",
]

out = model.encode(
    docs,
    return_dense=True,
    return_sparse=True,
    return_colbert_vecs=False,     # ⚠️ 默认关掉,省存储
    max_length=8192,
)

rows = []
for i, (text, dense, lex) in enumerate(
    zip(docs, out["dense_vecs"], out["lexical_weights"])
):
    rows.append({
        "id": i,
        "text": text,
        "dense": dense.tolist() if hasattr(dense, "tolist") else list(dense),
        # ⭐ 关键:key 从字符串转 int
        "sparse": {int(k): float(v) for k, v in lex.items()},
    })

client.insert(collection_name="kb", data=rows)

💡 一行的字段顺序必须和 schema 一致?用 dict 就不需要担心顺序 ,但字段名必须完全对得上。

5.3 双路召回 + 融合

python 复制代码
query = "向量数据库用什么索引"

q = model.encode([query], return_dense=True, return_sparse=True)
q_dense = q["dense_vecs"][0]
q_sparse = {int(k): float(v) for k, v in q["lexical_weights"][0].items()}

reqs = [
    AnnSearchRequest(
        data=[q_dense], anns_field="dense",
        param={"metric_type": "COSINE"}, limit=20,
    ),
    AnnSearchRequest(
        data=[q_sparse], anns_field="sparse",
        param={"metric_type": "IP"}, limit=20,
    ),
]

res = client.hybrid_search(
    collection_name="kb",
    reqs=reqs,
    ranker=RRFRanker(60),       # 按排名融合,不用管两路分数尺度不同
    limit=5,
    output_fields=["text"],
)

for hits in res:
    for hit in hits:
        print(f"{hit['distance']:.4f}  {hit['entity']['text']}")

两个容易踩的点:

问题 解法
param 里的 metric_type 必须和建索引时一致 稠密 COSINE、稀疏 IP,别写反
两路分数量纲不同(余弦 [-1,1] vs 内积 0~∞) 用 RRFRanker 按排名融合,别直接加权求和

5.4 只用稀疏检索也可以

python 复制代码
res = client.search(
    collection_name="kb",
    data=[q_sparse],
    anns_field="sparse",
    limit=5,
    search_params={"metric_type": "IP"},
    output_fields=["text"],
)

适合场景 :查型号、错误码、法条编号这类精确匹配。


六、存储成本算一笔账

以 10 万个 256-token 的块为例:

方案 单块大小 总量 相对成本
仅稠密 4 KB 约 400 MB 1×
稠密 + 稀疏(推荐) 约 5.6 KB 约 560 MB 1.4×
稠密 + 稀疏 + 多向量 约 1 MB 约 100 GB 约 250×

🎯 这张表就是本文的全部结论 :

加上稀疏向量只多花 40% 的存储,就换来一路字面召回------极其划算。

加上多向量要多花 250 倍存储,只换来一点精度------除非你有明确证据,否则不值。


七、一个容易忽略的配置项

一个 Collection 里可以放几个向量字段?

由 proxy.maxVectorFieldNum 控制(当前文档给出的值是 10)。

这对"dense + sparse 双字段"完全够用。但如果你打算放 dense + sparse + 多个 colbert 字段,就要注意这个上限------而且向量字段越多,写入和索引的开销越大。

一个务实的判断 :两个向量字段(dense + sparse)是甜点区。 超过三个向量字段,先问自己"是不是该用 reranker 了"。


八、总结

三种向量的一句话存法

向量 怎么存
稠密 FLOAT_VECTOR(dim=1024) + HNSW + COSINE
稀疏 SPARSE_FLOAT_VECTOR + SPARSE_INVERTED_INDEX + IP ,键要 int(k)
多向量 默认不存 ;要存就用 2.6.4+ 的 Array of Structs + MAX_SIM_*

✅ 应该做的

  • 稠密维度写 1024
  • 稀疏索引度量用 IP,不要用 COSINE
  • {int(k): float(v) for k, v in lex.items()} ------ 这一行必须写
  • return_colbert_vecs=False ------ 默认就关着,别手贱打开
  • 双路融合用 RRFRanker,别直接加权求和
  • 想要精度优先上 reranker,而不是存 ColBERT

❌ 不要做的

  • ❌ 把 lexical_weights 原样插入(key 是字符串,会失败)
  • ❌ 给 SPARSE_FLOAT_VECTOR 指定 dim
  • ❌ 用 COSINE 或 L2 给稀疏向量建索引
  • ❌ 默认打开 colbert_vecs(存储翻几百倍)
  • ❌ 以为"多向量只占 5--10 倍"------按 float32 算,是几百倍

🎯 最后一句

BGE-M3 真正的性价比,在于"稠密 + 稀疏"这个组合,而不是多向量。

前者多花 40% 存储就换来两路召回;后者多花 250 倍存储,换来的精度大概率还不如加一个 reranker 。

先跑通 dense + sparse,再谈 ColBERT。


📚 参考资料

说明 :lexical_weights 的键为字符串形式、需转 int 后再写入 Milvus,已对照 Zilliz 官方 BGE-M3 示例核实。多向量支持的时间线:Milvus 2.6.4 引入 Array of Structs 与 MAX_SIM_* 度量,3.0 更名为 StructArray 并新增 TokenANN / Muvera / Lemur 加速策略。存储成本为按 float32 原始大小估算,实际会因压缩、索引类型不同而变化;请以你所用版本的官方文档与实际压测为准。


如果这篇对你有帮助,欢迎点赞 + 收藏。

相关推荐
唠点键盘之外的2 天前
29 切片的权限隔离(实战篇):Milvus + Spring AI,从 0 搭一条带权限过滤的检索链路
人工智能·spring·milvus
ai大模型-探索者2 天前
Milvus向量数据库(介绍与使用)
数据库·milvus
Crayon Lin3 天前
Milvus向量数据库入门
数据库·milvus
一木 之林5 天前
LangChain 第9章 RAG 与项目实战:文档切分、嵌入、Milvus 与客服知识库
人工智能·计算机视觉·langchain·milvus
今年下半年5 天前
AI智能体项目实战记录(05):RAG项目实战之向量知识库milvus部署
人工智能·llm·milvus·rag
BD_Marathon5 天前
Milvus数据库的DQL操作
milvus
xian_wwq5 天前
【学习笔记】Milvus 是什么?为什么 RAG 系统经常使用它?-14
笔记·学习·milvus
BD_Marathon6 天前
Milvus数据库的DML操作
数据库·milvus
BD_Marathon6 天前
Milvus使用指南
milvus