BGE-M3 三种向量在 Milvus 里怎么存:一篇讲清稠密、稀疏、多向量
一句话总结 :BGE-M3 一次能吐出三种向量,但它们在 Milvus 里的命运完全不同 ------稠密 直接进
FLOAT_VECTOR(1024 维);稀疏 进SPARSE_FLOAT_VECTOR,但键必须从字符串转成整数 ({int(k): v}),这是最常见的失败原因;多向量(ColBERT)能存,但一个 256 token 的块会从 4 KB 膨胀到约 1 MB ,除非你确实需要 late interaction,否则别存。
📋 目录
- 先看结论:三种向量的三种命运
- 稠密向量:最简单,但维度别写错
- 稀疏向量:一个字类型能卡你半天 ⭐
- 多向量:能存,但大概率不该存
- 完整代码:一表两字段,双路召回
- 存储成本算一笔账
- 一个容易忽略的配置项
- 总结
一、先看结论:三种向量的三种命运
| 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。
📚 参考资料
- BGE-M3 模型主页:https://huggingface.co/BAAI/bge-m3
- Zilliz 官方 BGE-M3 集成示例(含
int(k)转换写法):https://zilliz.com/ai-models/bge-m3 - Milvus 稀疏向量文档:https://milvus.io/docs/sparse_vector.md
- Milvus Array of Structs(多向量 / late interaction):https://milvus.io/docs/array-of-structs.md
- Milvus 度量类型说明:https://milvus.io/docs/metric.md
- FlagEmbedding 仓库:https://github.com/FlagOpen/FlagEmbedding
说明 :lexical_weights 的键为字符串形式、需转 int 后再写入 Milvus,已对照 Zilliz 官方 BGE-M3 示例核实。多向量支持的时间线:Milvus 2.6.4 引入 Array of Structs 与 MAX_SIM_* 度量,3.0 更名为 StructArray 并新增 TokenANN / Muvera / Lemur 加速策略。存储成本为按 float32 原始大小估算,实际会因压缩、索引类型不同而变化;请以你所用版本的官方文档与实际压测为准。
如果这篇对你有帮助,欢迎点赞 + 收藏。