
🚩 2026 年「术哥无界」系列实战文档 X 篇原创计划 第 177 篇,Milvus 最佳实战「2026」系列第 22 篇
大家好,欢迎来到 术哥无界 | ShugeX | 运维有术。
我是术哥,一名专注于 AI 编程、AI 智能体、Agent Skills、MCP、云原生、AIOps、Milvus 向量数据库的技术实践者与开源布道者!
Talk is cheap, let's explore。无界探索,有术而行。

如果你在用 ColBERT、ColPali 这类 late interaction 模型,大概率撞过这个墙:
一份文档不是 1 个向量,而是一组向量。ColBERT 把 query 和 document 各自独立编码成 token-level 向量,匹配时做 token-by-token 的最大相似度再累加;ColPali 把页面切成 patches,每页产出 (1031, 128) 这样的多向量集合。把它塞进向量数据库,不是简单的行数变多——而是把一个文档这条实体边界,和一组向量这件事,一起塞进去。
我在调研 Milvus 3.0 这套 Struct + EmbList 设计时,翻了一圈社区反馈、设计文档、源码、GitHub Issue,顺手抓了 Qdrant、Weaviate、LanceDB、pgvector 的多向量官方文档横向对比。结论很清晰:Milvus 选了用最少存储改动接住这条路——逻辑上是嵌套对象,物理上还是多列;查询语义上新增两种身份,索引路径上复用 knowhere 的单向量索引。下面是拆解。
在 Milvus 3.0 之前,业内遇到一行多向量,基本三条路。
第一条,展平——把一个文档的 512 个 token 向量拆成 512 行存进单向量字段。代价是实体边界被拆掉了:召回结果要去重、要 group by,score 还要按文档聚合,过滤条件怎么写到 chunk 粒度也成问题。Dev.to 上那篇 ColPali 教程的作者 badmonster0 在代码里写得很直白:Qdrant natively supports multi-vector fields, making it perfect for ColPali's multi-vector approach——他们压根没选展平方案。
第二条,塞 JSON——把整组向量序列化进一个 JSON 字段。代价是查询语义被掐死:向量数据库的索引吃不到这层结构,只能在应用层反序列化、再做相似度计算。一行 JSON 里塞 512 个 1536 维 float32,光是反序列化延迟就把 ANN 那点收益吃光了。
第三条,named vectors——Weaviate 和 Qdrant 都在做。代价在 Weaviate 论坛 #20817 上有用户原话:Named vectors must be defined at collection creation time - you cannot add them to an existing collection 加 Each named vector is independent and has its own index。这对每行一个 N 向量集合的诉求是脱节的:用户最终妥协方案是 chunking + foreignId 拼回去,而这跟第一条展平路径没有本质区别。
Milvus 维护者 xiaofan-luan 在 2024-04 提的 #31920 也承认了这件事。issue 里的原话是:Colbert is known for its high search quality due to late interaction... The problem of colbert is it takes too much memories and computation so it's not really good for production environment。提议的方案里有三条:支持 embedding arrays、用 binary embeddings 把 512 token × 1536 维 ≈ 700KB 单记录压下去、retrieve 时算 query token 与 topK 近邻的距离最后做 MAXSIM rerank。这个诉求在 Milvus 2.6.4 通过 Array of Structs + MAX_SIM 落地,3.x 推广开来。
主线就出来了:用最少的存储改动,把一行多向量 + 一行多子对象的建模和查询语义接进来。下面看它具体怎么接。
先看设计文档 docs/design-docs/design_docs/20260306-struct.md 的硬约束,引用第 23 行:
Struct can only be used as the
element_typeof aDataType.ARRAYfield. Each Struct field is defined by aStructFieldSchemathat specifies its sub-fields.
用户文档 array-of-structs.md 第 57 行再次确认:
However, you cannot add a StructArray to an existing collection, and Milvus does not support using the Struct type as the data type for a collection field.
也就是说,Struct 不是平级于 ARRAY 的新复合类型——它只能作为 ARRAY 里每个元素的存在方式。这样做的直接后果,是 Struct 字段本身在物理上是普通 ARRAY<T> 列,不是新发明的存储格式。设计文档第 60 行说得很坦白:
Each sub-field of a Struct is physically stored as an independent column, typed as
ARRAY of <field_type>. For example, aclip_id(INT64) sub-field under a Struct array is stored identically to a regularARRAY<INT64>column. This design avoids defining a new composite physical type and reuses the existing columnar storage and serialization infrastructure.
这个选择带来的好处很现实:存储路径零修改、序列化格式零修改、列存优化零修改。整个 Milvus 3.0 系列前几篇讲的 Storage V3、Streaming System、External Collection、Snapshot 这些存储基础设施,在这里都不需要为 Struct 重新搭一套。代价是嵌套 Struct 被禁止——设计文档明令不允许 Struct 子字段里再放 Struct、Array、JSON、primary key、auto-id。array-of-structs.md 第 71 行还规定:A StructArray field is not nullable and does not accept any default value——sub-field 也是如此。
为什么这么抠?因为如果允许深嵌套,三件事会出问题:物理存储路径无法处理深嵌套、element identity 难以递归定义、类型系统的复杂度会爆炸。这是结构性硬约束,不是工程取舍——可以把它理解为先把第一层接住,后面再说。如果未来要放开,大概率也得在 element_type 层做递归定义而不是把 Struct 提成 top-level type。
子字段的允许范围也窄:只允许 scalar types(INT64, VARCHAR, FLOAT 等)、scalar ARRAY、vector types(FLOAT_VECTOR 等)。vector 子字段必须单独指定 dim、单独建索引,这就把一行多向量这件事从数据模型层面坐实了——同一个 struct array 里,embedding 子向量和 element_embedding 子向量可以是不同维度、不同 metric,互不打扰。

解决一行多向量的检索,核心是把查询 placeholder 类型和距离度量做一次双重确认。先看 Plan proto pkg/proto/plan.proto:42-54:
enum VectorType {
BinaryVector = 0;
FloatVector = 1;
Float16Vector = 2;
BFloat16Vector = 3;
SparseFloatVector = 4;
Int8Vector = 5;
EmbListFloatVector = 6;
EmbListFloat16Vector = 7;
EmbListBFloat16Vector = 8;
EmbListInt8Vector = 9;
EmbListBinaryVector = 10;
};5 种 EmbList placeholder 类型和 5 种普通向量 placeholder 类型并列存在,意图很明显:不是临时塞个新 case,而是给 plan 层开了一个独立的分支。internal/core/src/query/Plan.cpp:54-62 的判断函数:
static bool is_emb_list_placeholder(PlaceholderType type) {
return type == PHType::EmbListFloatVector ||
type == PHType::EmbListFloat16Vector ||
type == PHType::EmbListBFloat16Vector ||
type == PHType::EmbListBinaryVector ||
type == PHType::EmbListInt8Vector;
}而 Plan.cpp:142-158 那段 element_level 推导是整个 row / element 区分的源头:
if (field_meta.get_data_type() == DataType::VECTOR_ARRAY) {
auto& metric = plan->plan_node_->search_info_.metric_type_;
bool emb_list_metric = knowhere::get_el_metric_type(metric).has_value();
bool emb_list_ph = is_emb_list_placeholder(ph.type());
if (emb_list_metric != emb_list_ph) {
ThrowInfo(DataTypeInvalid, ...); // 不匹配 → 直接报错
}
element.element_level_ = !emb_list_metric;
}逻辑只有三条:
DataTypeInvalidinternal/core/src/common/Types.h:695-727 注册的 MAX_SIM 指标一共 6 个:Float 那 4 个 MAX_SIM, MAX_SIM_COSINE, MAX_SIM_IP, MAX_SIM_L2,Binary 那 2 个 MAX_SIM_HAMMING, MAX_SIM_JACCARD。索引支持 AUTOINDEX, HNSW, IVF_FLAT, DISKANN(array-of-structs.md 第 90-94 行)。
MAX_SIM 的数学语义在用户文档 search-with-embedding-lists.md 第 43 行说得很直接:
Once these tokens are vectorized, the vector embeddings of each query token are compared with those in the document to get a list of similarity scores. Then the highest scores from each score list are summed to produce the final score. The process for determining a document's final score is known as maximum similarity (MAX_SIM).
公式:score(q, d) = Σ_{i=1..m} max_{j=1..n} sim(e_qi, e_dj)。对每个 query token,在该行所有 sub-vector 里取最大相似度,然后 sum。这套算子直接下推到距离度量层,意味着应用层 rerank 这一步可以省了——搜索返回的 topK 文档,分数本身就是 ColBERT 风格的 late interaction score。
索引的物理构造也选了最少改动路线。设计文档第 66-70 行:
All vectors across array elements within a row are flattened and passed to knowhere along with per-row offset information to build the index:
float*— all vectors concatenatedoffsets— cumulative element counts per row, e.g., row sizes 3, 2 yield offsets 0, 3, 5
也就是说,EmbList 索引不是另起炉灶做的多向量索引——它把所有子向量拼成一维数组,带着 row offsets 继续走 knowhere 现有的 IVF / HNSW / DiskANN 索引。这条路和 Qdrant 1.10+ 用 MultiVectorConfig(comparator=MAX_SIM) 的实现思路在产物上很像:复用单向量索引,在搜索阶段用 MAX_SIM 做跨 row 聚合。LanceDB 的 multivector 列也是同一思路,只是它的 OSS 版只在跑 IVF_PQ 索引时才走通,无索引时直接 brute-force——文档里特意警告过 "Indexing matters more for multivector tables than for single-vector ones"。
这是整个设计里最反直觉、也最容易写错的部分。同一行 struct array,放进去的查询 placeholder 和距离度量不同,候选身份就分裂成两种。
看 internal/proxy/struct_hybrid_search.go 的分类:
type hybridSubSearchKind int
const (
hybridSubSearchNormal hybridSubSearchKind = iota
hybridSubSearchStructEmbList // embedding-list 搜索,行级
hybridSubSearchStructElement // element-level 搜索
)
func classifyHybridSubSearch(...) hybridSubSearchInfo {
field := typeutil.GetField(schema, fieldID)
if field == nil || field.GetDataType() != schemapb.DataType_ArrayOfVector {
return hybridSubSearchInfo{Kind: hybridSubSearchNormal}
}
parent, ok := getStructParentFieldName(schema, fieldID)
if !ok {
return hybridSubSearchInfo{Kind: hybridSubSearchNormal}
}
if placeholderType == 0 || isEmbeddingListPlaceholderType(placeholderType) {
return hybridSubSearchInfo{Kind: hybridSubSearchStructEmbList, ParentStructFieldName: parent}
}
return hybridSubSearchInfo{Kind: hybridSubSearchStructElement, ParentStructFieldName: parent}
}分类规则一句话:该 struct array 字段 + EmbList placeholder → 行级;+ 普通向量 placeholder → 元素级。普通标量字段走 hybridSubSearchNormal,不参与这套结构分类。
两种搜索产出的候选身份完全不同:
(primary_key) 单身份——一个文档对应一行结果(primary_key, parent_struct_field, element_index) 三元组——一行里的某个子元素对应一条结果对应的业务场景是这样分开的:
inferElementLevelHybrid(第 211-229 行)的混合搜索一致性检查更微妙:
func inferElementLevelHybrid(infos []hybridSubSearchInfo) bool {
if len(infos) == 0 { return false }
parent := ""
for _, info := range infos {
if info.Kind != hybridSubSearchStructElement {
return false // 任一不是 StructElement → 回落到 row-level
}
if parent == "" {
parent = info.ParentStructFieldName
continue
}
if info.ParentStructFieldName != parent {
return false // 父结构不一致 → 回落到 row-level
}
}
return true
}只有当所有 sub-search 都是 StructElement 且共享同一个父 struct 字段,才允许走 element-level hybrid;否则就回落到 row-level。这意味着在一次 hybrid search 里,如果某个 sub-search 用的是 EmbList,整次 hybrid 的候选身份就锁死在行级。
Element key 的编码格式也值得看一眼,makeHybridElementKey 第 231-240 行:
func makeHybridElementKey(pk any, elementIndex int64) string {
// 格式:__milvus_element_key\x1f<type>\x1f<pk>\x1f<element_index>
// 用 \x1f (US 控制字符) 避免 PK 内容冲突
}用 ASCII Unit Separator \x1f 做分隔符,是为了让 PK 本身的内容不会撞上 key 格式——这是工程细节,但写得很克制。
Collapse strategies 在 design doc 第 209-228 行给出 5 种:max, sum, avg, topk_sum, topk_avg,但有方向性约束:
sum / topk_sum 仅对正相关 metric (IP, COSINE) 有效
L2 等负距离 metric 必须用 max / avg / topk_avg这是个常被忽视的小坑——L2 距离里 element 分数求和会把远也累加成近,逻辑上不通。如果你写 hybrid search 用了 L2 又默认 sum,出来的 topK 文档全是噪声。

struct array 字段的标量子字段(clip_id、element_embedding 等)还涉及嵌套过滤语义。Proto 定义在 pkg/proto/plan.proto:258-278:
message ElementFilterExpr {
Expr element_expr = 1;
string struct_name = 2;
Expr predicate = 3; // predicate 用 $[field] 引用 sub-field
}
enum MatchType {
MatchAll = 0; // 所有 element 都满足
MatchAny = 1; // 至少一个 element 满足
MatchLeast = 2; // 至少 N 个 element 满足
MatchMost = 3; // 至多 N 个 element 满足
MatchExact = 4; // 恰好 N 个 element 满足
}
message MatchExpr {
string struct_name = 1;
Expr predicate = 2;
MatchType match_type = 3;
int64 count = 4;
}array-of-structs.md 第 1126-1134 行对 element_filter 位置有个硬性约束:element_filter 必须在整个表达式最后,否则和 entity-level filter 顺序混乱。这条规则不写到文档里,基本踩坑——前置的 entity-level filter 会被强制按 entity 评估,然后才把 element_filter 应用到剩下的子元素上,语义直接错位。
MatchExpr 的语义保证很关键:predicate 是 per-element evaluation,MatchType 是跨 element 的 quantifier。也就是说,"所有 element 都满足 clip_id = 100"这种条件,物理上是对每一行里的每个 element 跑一次 predicate,逻辑上用 MatchAll 聚合回整行。这是 late interaction + 强类型过滤同时成立的关键——既保留了 ColBERT 风格的向量打分,又能用 SQL-like 谓词精确卡掉结构化条件。
嵌套标量索引的策略在 design doc 第 79-100 行有细节:每个 array element 索引为独立 "document",保留位置信息。这样查询时 offset 数组可以把 element ID 映射回 row ID。这跟 EmbList 的 flatten + offsets 是同一思路在两个层面的复用——scalar nested 走 element 索引支持 Match 算子,vector flatten 走 knowhere 单向量索引支持 MAX_SIM。两种索引共用一套偏移机制,但产出的 identity 不同:scalar 走 element-level,vector 走 row-level 或 element-level 看 metric 选择。
调研到 GitHub Issue #51414 时,看到 master-20260715-6ca0350944 上有个静默错误,值得写进任何一个 Milvus 3.0 多向量部署 checklist 里。
复现脚本构造了 6 行最小数据集:
id: 0 1 2 3 4 5
profile: NULL [] [A] [B] [] [C]3 个非空 embedding list 在 ID 2、3、5。limit=3 的 MAX_SIM_COSINE 搜索,理论上应当返回 [2, 3, 5]。
实际表现:
[2, 3, 5] ✅[3, 4] ❌复现条件很窄:VECTOR_ARRAY 字段可空 + brute-force 搜索 + sealed segment load;JSON / Parquet import 直接以 sealed segment 加载也会复现,因为 import 直接生成 sealed segment,绕过了 growing → sealed 的转换。compaction 不是必要条件——单段 single-batch insert + flush + release/load 已经够复现。
排查路径顺藤摸瓜也很有教学意义:ArrayOffsets.cpp:333 的 BuildFromColumn 日志里打印了 row_count=6, total_elements=3,看到 6 和 3 的差就能猜到 offset 体系把 logical row 6 和 physical element 3 混着用。关联 Issue 给的代码定位:SearchOnSealed.cpp:190-345 把 chunk size 从 6 个 logical row 变成 5 个有效行,但仍把 logical VectorArrayOffsets 传给 brute-force 搜索;SearchBruteForce.cpp:117-131 的 PrepareBFDataSet 在 num_raw_data=5 时读了 offset 2 而不是终止 offset 3,最后一根有效向量被吞掉。
变体实验也能给出几条有用信息:把 NULL 行换成空数组 → 错位消失;走 element-level search(同 struct 兄弟字段)→ 返回正确;行级 embedding-list 搜索只发生在 row-level 输出里。这些对照说明问题局限在 nullable + row-level + sealed 三个条件的交集里。
写成 checklist 的建议是:

把这套设计回看一遍,可以抽象出三条取舍。
存储路径零修改。Struct 物理上就是普通 ARRAY<T> 列,EmbList 索引是把子向量 flatten + offsets 后塞进 knowhere。这是一条用现有基础设施接住新需求的工程取舍,代价是嵌套 Struct 被禁止、element identity 不能递归。
late interaction 进 search path。MAX_SIM metric + EmbList placeholder 的双重确认,把 ColBERT / ColPali 的 token-by-token max-similarity-then-sum 算子直接下推到距离度量层。Qdrant 在 1.10+ 走了 MultiVectorComparator.MAX_SIM 几乎相同的产物——这说明这个路线是行业共识,不是 Milvus 一家拍脑袋。LanceDB 选 cosine-only + IVF_PQ 是另一条路线,pgvector 至今没原生支持。
candidate identity 二分。embedding-list search 产出行身份,element-level search 产出 (pk, parent_field, element_index) 三元组身份。混合搜索时,只要任一 sub-search 用 EmbList,整次 hybrid 就锁在行级。这是反直觉的设计——也是为什么 element_scope 只对 element-level 有效,embedding-list search 默认走 row-level(struct_hybrid_search.md 第 92-93 行)。
至于 #51414 那个静默错位,把它当成演进过程中的取舍看比当成Milvus 3.0 不成熟看更合适。Milvus 2.6.4 才把 Array of Structs + MAX_SIM 推上线,3.x 在扩展 Sealed segment 的 nullable vector 映射,master 还在合并 #49999 的统一映射设计——这条路径的工程边界还在收窄。BeIR 公开测评目前 Milvus 这边也对不上 Qdrant 那套——Qdrant 官方文章里 bge-small-en 在 SciFact 上从 0.68213 提升到 0.73696(+5.5%),在 NFCorpus 上从 0.29696 提升到 0.37502(+7.8%)是公开数据,Milvus 官方博客里 Wikipedia / ColPali 的案例数据直连 403 没法独立复核。这条线我下篇再用 benchmark 跑一跑,先把源码这边讲清楚。
说明:本文基于 Milvus 3.0 官方源码、官方文档仓库镜像、Design Docs(
20260306-struct.md、20260602-struct_hybrid_search.md)以及 GitHub Issues(#51414 / #31920 / #51381)整理而成。文中的机制描述、源码路径与设计取舍均为源码级分析,未经生产环境全场景压测验证。 Milvus 官方对 Array of Structs 暂无第三方独立 BeIR 测评数据,Qdrant 的 BeIR 实测数字仅作为同范式参照。实际效果请以你的版本、环境和业务数据为准,如果有踩坑或验证经验,欢迎在评论区分享交流。
好啦,谢谢你观看我的文章,如果喜欢可以点赞转发给需要的朋友,我们下一期再见!敬请期待!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。