Milvus 3.0开源解读 | 从Entity到Element,StructArray 如何重构多向量检索
向量数据库早期有一个默认的抽象:一个实体(Entity)= 一条向量(Embedding)。文档可以被编码成一个向量,商品也可以被编码成一个向量,搜索的基本动作就是把 query vector 与这些 entity vectors 做近邻搜索。第一代语义搜索、推荐召回和 RAG,也基本都建立在这个模型之上。
但它背后有一个隐含条件: 实体足够简单,一条embedding数据,就足以表示我们的完整原始数据信息。对一篇几百字的文章,这个假设还勉强成立。但对一段视频、一份长文档、一个包含几十张图片的商品来说,如果把所有内容强行压缩成一条向量,细节全就会被全部平均掉。而检索中,真正决定相关性的,往往是局部内容,而非整篇文档的单一向量。
一个暴力的办法是把这些局部内容全部拆开。例如,把一条视频拆成多个 clips,每个 clip 都作为独立 Entity 保存自己的 embedding、start_time、end_time、caption、scene_type 和 confidence。
这样局部搜索变得简单了,但新的问题随之出现:
- 同一条视频的多个 clips 可能同时进入 Top-K;
- 父级 metadata 需要在多个 records 中重复存储;
- 最终业务如果展示的是“视频”而不是“clip”,应用还要自己做 grouping、dedup 和 rerank;
- 更重要的是,数据库已经不知道这些 clips 原本共同属于一个什么样的逻辑对象。
这里存在一个很典型的粒度矛盾:业务消费的是整体,搜索命中的却往往是局部。
那么究竟要如何在数据库中兼顾局部信息精准性同时,还能理解这种整体与局部的关系?
基于这一背景,在Milvus 3.0 中,我们引入了StructArray。它允许一个 Entity 内保存一组彼此对齐的 Elements。每个 Element 可以包含受支持的 scalar metadata 和 vector sub-fields,同时仍然属于同一个 Parent Entity。
从而让数据库知道哪些数据在共同描述同一个对象,也可以决定搜索到底发生在 entity 还是 element 层面,抑或是在两种粒度之间完成过滤、排序和结果融合。
以下是关于 StructArray的详细介绍。
从一条记录到一组有身份的 Elements
还是以前文提到的视频检索为例。
一段 video entity 往往会被切成多个 clips。JSON 或多列 Array 都能保存这些数据,但却无法把同一个 clip 的多个字段作为一组关联数据来处理。
JSON 目前不支持向量子字段,因此无法在其中定义和索引 clip-level vector。多列 Array 则彼此独立,数据库无法保证相同 offset 的元素属于同一个 clip,例如 scene_type[3] 和 label_confidence[3] 的对应关系只能由应用维护,也因此难以支持后续 match_family 这类跨字段匹配。
StructArray 则把 Element 的结构直接定义在了 Schema 中,让数据库明确知道同一个 offset 上的多个 sub-fields 属于同一个 Element。
scene_type → VARCHAR
label_confidence → FLOAT
emb → FLOAT_VECTOR
也知道同一 offset 下的这些 subfields 属于同一个 Element。因此它们可以分别参与索引、向量搜索、过滤和输出。
在视频表示中, video entity 可以包含多个 clips:
clips: ARRAY<STRUCT<
clip_embedding_list: FLOAT_VECTOR,
clip_embedding: FLOAT_VECTOR,
start_sec: DOUBLE,
end_sec: DOUBLE,
caption: VARCHAR,
scene_type: VARCHAR,
label_confidence: FLOAT
>>
在后文的示例中,clip_embedding_list 和 clip_embedding 保存同一个 clip 的向量,但分别服务于两种不同的检索方式:前者用于 EmbeddingList search,后者用于 element-level search。
在这里:
clips是 parent field;clip_embedding、start_sec、caption等是 sub-fields;clips[0]是第一个 clip;clips[0][clip_embedding]和clips[0][caption]属于同一个 clip;clips[3][scene_type]和clips[3][label_confidence]属于另一个 clip。
建模:一个 Video Entity 如何保存多个 Clips
下面用一个简化版 PyMilvus 示例创建视频 Collection。
Collection 包含一个顶层向量字段,以及一个保存视频片段的 StructArray。
为了同时演示两种搜索方式,这里为 clip 定义两个独立的向量子字段。
from pymilvus import DataType, MilvusClient
client = MilvusClient(uri="http://localhost:19530")
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("title", DataType.VARCHAR, max_length=512)
schema.add_field("video_embedding", DataType.FLOAT_VECTOR, dim=768)
# struct 需要显式定义 schema
clip_schema = client.create_struct_field_schema()
clip_schema.add_field("clip_embedding_list", DataType.FLOAT_VECTOR, dim=768)
clip_schema.add_field("clip_embedding", DataType.FLOAT_VECTOR, dim=768)
clip_schema.add_field("start_sec", DataType.DOUBLE)
clip_schema.add_field("end_sec", DataType.DOUBLE)
clip_schema.add_field("caption", DataType.VARCHAR, max_length=2048)
clip_schema.add_field("scene_type", DataType.VARCHAR, max_length=128)
clip_schema.add_field("label_confidence", DataType.FLOAT)
schema.add_field(
"clips",
datatype=DataType.ARRAY,
element_type=DataType.STRUCT,
struct_schema=clip_schema,
max_capacity=1024,
)
client.create_collection("videos", schema=schema)
如果后面要做 vector search 或高效过滤,还需要显式创建索引。为了和后文的 embedding-list search 示例一致,这里给clips[clip_embedding_list]创建 MAX_SIM_COSINE 索引:
index_params = client.prepare_index_params()
# EmbeddingList search
index_params.add_index(
field_name="clips[clip_embedding_list]",
index_type="HNSW",
metric_type="MAX_SIM_COSINE",
index_name="clips_clip_embedding_list_maxsim_idx",
params={"M": 16, "efConstruction": 200},
)
# Element-level search
index_params.add_index(
field_name="clips[clip_embedding]",
index_type="HNSW",
metric_type="COSINE",
index_name="clips_clip_embedding_cosine_idx",
params={"M": 16, "efConstruction": 200},
)
client.create_index("videos", index_params=index_params)
这里需要分别为两个 vector sub-fields 建立索引。clips[clip_embedding_list] 使用 MAX_SIM_COSINE,服务于 EmbeddingList search;clips[clip_embedding] 使用普通的 COSINE metric,服务于 element-level search。之所以需要两个字段,是因为一个 vector sub-field 只能绑定一个索引,而两种搜索模式使用不同的 metric family。
插入数据时,用户可以按最自然的 entity 结构写入:
rows = [
{
"id": 1,
"title": "cooking tutorial",
"video_embedding": video_vec,
"clips": [
{
"clip_embedding_list": clip_vec_1,
"clip_embedding": clip_vec_1,
"start_sec": 0.0,
"end_sec": 8.0,
"caption": "A person washes vegetables.",
"scene_type": "kitchen",
"label_confidence": 0.92,
},
{
"clip_embedding_list": clip_vec_2,
"clip_embedding": clip_vec_2,
"start_sec": 8.0,
"end_sec": 16.0,
"caption": "A person cuts carrots on a board.",
"scene_type": "kitchen",
"label_confidence": 0.96,
},
],
}
]
client.insert("videos", rows)
client.flush("videos")
client.load_collection("videos")
基于StructArray的三种过滤与检索方式
从用户视角看,clips 就是一组结构化对象。
但有了这层结构以后,Milvus 可以围绕同一组 Elements 提供三种不同的执行语义:
- 在 Element 上判断条件,再过滤 Parent Entity;
- 把一组 Element embeddings 作为整体参与 Entity-level search;
- 让每个 Element embedding 独立参与 ANN,直接返回局部命中。
其中,第一类 MATCH _* 系列操作符,可以用于判断是否过滤父实体
MATCH_ANY、MATCH_ALL、MATCH_LEAST、MATCH_MOST 和 MATCH_EXACT 会在 Struct 元素上执行 predicate,统计有多少元素满足条件,再据此判断整个父实体是否通过过滤。
例如
Plaintext
MATCH_ANY(clips, $[scene_type] == "kitchen" && $[label_confidence] > 0.8)
这个表达式要求同一个 offset 上的 scene_type 和 label_confidence 同时满足条件,然后再把 element-level predicate 聚合成 entity-level predicate。它不是跨 clips 拼条件,也不是几列普通 arrays 各自独立过滤。
第二类, embedding-list search,则可以直接返回父实体
clips[clip_embedding_list] 中的多个向量共同构成这个视频的一个 EmbeddingList。查询本身同样是基于 EmbeddingList的。
Milvus 会使用 MAX_SIM* metric 比较查询 EmbeddingList 与实体中存储的 EmbeddingList,并最终返回实体级结果。
Plaintext
clips[clip_embedding_list] = [
embedding_0,
embedding_1,
embedding_2,
...
]
第三类是 element-level search;**element_filter** 可以进一步限制哪些 Elements 参与该搜索。
虽然 StructArray 可以把多个 embeddings 组织成 embedding list,Milvus 同样保留了单个 embedding 粒度的检索方式:让每个 element embedding 独立参与 ANN,返回结果携带 offset,告诉应用命中的是 parent entity 里的哪一个 element。
与此同时,element_filter 可以把标量过滤约束加在同一个 element 上,例如只让 scene_type == "kitchen" 且 label_confidence > 0.8 的 clips 参与 element-level search。
下面分别来看
MATCH:先在同一个 Element 上判断,再决定 Entity 是否命中
StructArray 在过滤上的核心价值,在于让数据库知道:当一个 filter 同时涉及多个 scalar sub-fields 时,这些条件应该在同一个 element 上一起判断,而不是分散到同一个 parent entity 的不同 elements 上。
比如视频检索里,用户可能想找“厨房场景,并且标签置信度足够高”的视频。这里真正需要判断的不是 entity 里是否出现过 kitchen,也不是 entity 里是否出现过某个高置信度值,而是是否存在同一个 clip 同时满足这两个条件。我们提供了多种聚合语义的 MATCH 表达式:
Plaintext
MATCH_ANY(clips, $[scene_type] == "kitchen" && $[label_confidence] > 0.8)
MATCH_ALL(clips, $[label_confidence] > 0.5)
MATCH_LEAST(clips, $[scene_type] == "sports", threshold=3)
MATCH_MOST(clips, $[label_confidence] < 0.2, threshold=1)
MATCH_EXACT(clips, $[scene_type] == "intro", threshold=1)
Milvus 会先在每个元素 offset 上计算 predicate,然后根据 any、all、at least、at most、exactly 的规则决定 parent entity 是否命中。
MATCH_ANY:至少一个元素满足条件;MATCH_ALL:所有元素都满足条件;MATCH_LEAST:至少有threshold个元素满足条件;MATCH_MOST:最多有threshold个元素满足条件;MATCH_EXACT:恰好有threshold个元素满足条件。
以第一句为例,scene_type == "kitchen" 和 label_confidence > 0.8 必须发生在同一个 offset 上;只要存在这样的 clip,MATCH_ANY 就让整个 video entity 命中。
形成对比,如果相同的数据只是存储成两个彼此独立的数组,下面这种写法只能说明同一个 entity 里存在某个 kitchen clip,也存在某个高置信度 clip,但不保证它们是同一个 clip:
Plaintext
array_contains(clips[scene_type], "kitchen")
AND
array_contains(clips[label_confidence], 0.9)
对于视频 clips、商品图片、文档 passages 这类数据来说,这个差别会直接影响结果正确性。StructArray 的过滤语义要保证的,正是多个条件在同一个 element 上成立,而不是在同一个 parent entity 内分散成立。
Search:embedding-list search 和 element-level search对应两种搜索语义
当一个 entity 内部有一组 element embeddings 时,vector search 首先要回答一个问题:这组 embeddings 是作为一个整体代表 parent entity 参与检索,还是让每个 element embedding 单独参与检索?
这对应 StructArray 上两种不同的搜索语义。前者返回 parent entity,适合多向量 query 和多向量 entity 之间的整体匹配;后者结果会携带 offset,适合找出 entity 内部最相关的 clip、image 或 passage。
1. embedding-list search:一组 query vectors 找一个 entity
embedding-list search中,它的 query 本身也是一组 vectors,例如一段查询视频被切成多个 query clips,或者一个多图商品 query 包含多张参考图。
Milvus 会把 query embedding list 和 entity 内部的 embedding list 做 MaxSim 类的匹配,返回最相似的 parent entity。
示意代码如下:
from pymilvus.client.embedding_list import EmbeddingList
query = EmbeddingList()
query.add(query_clip_vec_1)
query.add(query_clip_vec_2)
client.search(
collection_name="videos",
data=[query],
anns_field=clips[clip_embedding_list]
search_params={"metric_type": "MAX_SIM_COSINE"},
limit=10,
)
这种模式的结果是 entity-level 的。它回答的问题是:哪些视频整体上最匹配这一组 query clips?
它适合 的query 本身也是多向量集合的场景,例如视频到视频、多图到商品、多 passage 到文档,以及文档到图片或视频的多模态相似度匹配。
2. element-level search:一个 query vector 找 entity 内某个element
element-level search它的 query 是普通单个 vector。Milvus 会把 StructArray 中每个 element 的 vector 都当作独立候选参与 ANN。搜索结果携带 offset,表示命中的是 entity 内部第几个 element。
示意代码如下:
client.search(
collection_name="videos",
data=[query_vec],
anns_field="clips[clip_embedding]",
search_params={"metric_type": "COSINE"},
limit=10,
output_fields=["id", "title", "clips"],
)
如果只希望某些 elements 参与 element-level search,可以用 element_filter 作为同 element 的标量约束:
client.search(
collection_name="videos",
data=[query_vec],
anns_field="clips[clip_embedding]",
search_params={"metric_type": "COSINE"},
filter='element_filter(clips, $[scene_type] == "kitchen" && $[label_confidence] > 0.8)',
limit=10,
output_fields=["id", "title", "clips"],
)
这里 $[scene_type] 和 $[label_confidence] 仍然绑定在同一个 element 上。不同的是,element_filter 不负责把 predicate 聚合成 entity-level 判断,而是限制哪些 elements 可以参与 element-level vector search。
返回结果可以理解成:
Plaintext
id = 1, offset = 1, distance = 0.91
id = 8, offset = 4, distance = 0.88
id = 1, offset = 3, distance = 0.84
这意味着同一个 parent entity 可以出现多次,因为 entity 内部不同 element 都可能匹配 query。对于视频和长文档来说,用户不仅可以知道哪条视频或哪篇文档相关,还要知道命中的是哪个 clip 或哪个 passage。
两种搜索模式的差别可以总结成一张表:
| 维度 | embedding-list search | element-level search |
|---|---|---|
| query 输入 | 一组 vectors | 一个普通 vector |
| 典型 metric | MAX_SIM* | 普通 vector metric,如 COSINE / L2 / IP |
| 竞争单位 | parent entity 的 embedding list | entity 内部每个 element |
| 结果身份 | entity-level result | entity-level result with offset |
| 适合场景 | 多向量 query 匹配多向量 entity | 找到 entity 内部最相关片段 |
总而言之,StructArray 支持两种不同的 vector search 语义,但如果一个 Collection 需要同时支持两种模式,应使用两个独立的 vector sub-fields。clips[clip_embedding_list] 配合 EmbeddingList + MAX_SIM*,用于 entity-level EmbeddingList search;clips[clip_embedding] 配合普通 query vector 和 COSINE、IP、L2 等普通 vector metric,用于 element-level search。
两个字段可以保存同一份 element embedding,但分别绑定不同的索引,从而让两种搜索模式在同一个 Collection 中共存。
使用EmbeddingList 索引,如何在搜索质量与成本之间做取舍
当每个实体只有一个向量时,ANN 索引只需要寻找与查询向量接近的实体。但 EmbeddingList 模式下,相关性来自两个向量列表之间的大量MaxSim 匹配。需要精确计算所有 query vectors 和所有 entity vectors 的相似度,这样做的好处是质量最好,但在线遍历全量数据的成本也通常不低。
因此,Milvus 采用两阶段搜索模型:
- 首先通过近似方法召回一批候选父实体;
- 当启用
emb_list_rerank时,再在这些候选上重新计算 MaxSim,得到最终排序。
候选取得越多,越接近精确 MaxSim,但延迟和计算成本也越高。
目前我们提供的三种策略的核心区别,主要在于如何生成第一阶段的候选集。
| 策略 | 候选生成方式 | 适合的情况 | 需要注意 |
|---|---|---|---|
| TokenANN | 把 embeddinglist 里的 vectors 展开建 ANN,搜索时 query embedding-list 中的 embedding 独立做 ANN 搜索,收集命中的 parent entities,去重后再做 MaxSim rerank | list 较短、单个向量区分度高、质量优先 | 索引规模和搜索次数随 list 长度增长;虽然内部按向量检索,最终仍是 entity-level embedding-list search |
| MUVERA | 用随机投影把一组 vectors 编码成固定长度向量,再用普通 ANN 找候选 | 不想引入训练,同时希望在质量和成本之间折中 | 编码会有信息损失;参数越强,固定向量维度越高,ANN 成本也越高 |
| LEMUR | 用学习到的压缩模型把 embedding list 映射成固定长度向量,再用普通 ANN 找候选 | 低区分度 embedding 空间、embedding-list 长度不存在严重长尾现象 | 需要训练;在高区分度且长度分布长尾的数据上,可能学到长度偏置 |
一些使用心得总结如下:
- 如果数据规模允许,可以先用 TokenANN 作为质量优先的 baseline;
- 如果文档很短、query 简单,单个 dense embedding 已经能覆盖主要语义;多向量近似检索可以主要用于:长文档复杂 query、视觉文档、视频 clips、多图商品,或者任何需要保留多个局部匹配点的检索任务。
- 如果 embedding space 中,单个向量本身有很强的区分度,TokenANN 或 MUVERA是更优解;但如果向量空间区分度较低,或者 workload 以视觉和多模态为主,可以使用 LEMUR。
StructArray 解决的是数据模型和检索语义的问题:一个 parent entity 内部可以有一组对齐的、可过滤的 embeddings。索引策略解决的是另一个问题:在给定模型和数据分布下,如何用可接受的成本逼近 MaxSim 的排序质量。两者结合起来,embedding-list search 才能从模型能力变成可部署的检索能力。
hybrid search:Element 命中之后,什么时候需要回到 Entity?
生产检索不仅仅是单路向量搜索。一个视频搜索请求可能同时使用:
- 视频整体 embedding;
- clip-level embedding;
- caption 的全文信号;
- reranker 或 weighted ranker。
Element-level vector search 也支持 hybrid search,但也带来一个新问题:element-level 子搜索的结果如何参与融合?
Milvus 在这里区分两种情况。
如果所有子搜索都是同一个 parent StructArray 下的 element-level search,那么最终结果可以继续保持 element-level。也就是说,融合和去重的身份仍然携带 offset。这适合“我要找最相关片段”的场景。
如果 hybrid search 混入了普通 vector field、不同 parent struct,或者 embedding-list search,那么 element-level 结果需要 collapse 回 entity-level result。因为最终排序单位已经变成 entity,而不是单个 element。
collapse 的意义是:同一个 entity 内部可能有多个 element 命中 query,Milvus 需要把这些 element scores 聚合成一个 entity score。常见策略包括取最佳 element、求和、平均,或者只聚合 top-k 个 element。
| Collapse 策略 | 如何根据返回的元素 hit 计算实体分数 | 重要条件 |
|---|---|---|
| max | 取最佳元素得分 | 适用于支持的普通向量 metric |
| sum | 对所有返回元素的得分求和 | 适合 IP、COSINE 等正相关 metric |
| avg | 计算所有返回元素得分的平均值 | 适用于支持的普通向量 metric |
| topk_sum | 对最好的 K 个元素得分求和 | 要求 topk > 0;适合 IP 或 COSINE |
| topk_avg | 对最好的 K 个元素得分取平均 | 要求 topk > 0 |
需要特别注意:
collapse 只会处理该 ANN 子搜索已经返回的元素 hit,并不会在检索结束后重新扫描实体中的全部元素。
因此,请求中的 limit 会直接决定有哪些元素可以参与 collapse。因此,使用过程中:
- 如果应用要展示“哪个 clip 或 passage 命中了”,保留 element-level 更自然;
- 如果应用要展示“哪个 entity 最相关”,collapse 到 entity-level result 更自然;
- 如果多个检索信号的粒度不同,系统必须明确从 element score 到 entity score 的转换方式。
Milvus 如何让 StructArray 高效工作
对外使用 StructArray 时,用户看到的是 ARRAY<STRUCT>。但在系统内部,如果真的把整个 struct array 当作一个大 blob 存下来,索引、过滤和输出都会变得低效。
Milvus 采用的是逻辑 parent + 物理 child columns 的设计。
在 schema 层,clips 是一个逻辑 parent field。它描述了这组 elements 的存在、最大容量、nullable 等属性。它下面的 sub-fields 会被规范化成类似 clips[clip_embedding]、clips[scene_type]、clips[label_confidence] 的物理字段。
scalar sub-field 在物理上是每个 entity record 一个 scalar array。vector sub-field 在物理上是每个 entity record 一个 vector array。这样做的好处是,每个 sub-field 都能走自己对应的数据路径:scalar sub-field 可以做过滤和 scalar index,vector sub-field 可以做 vector index 和 ANN search。
在写入层,Proxy 会把用户传入的嵌套 struct list 展开成多个 typed child columns。
在执行层,Milvus 维护 entity record 和 element 之间的映射。可以把它理解成一张 offset map:
Plaintext
entity 0 -> elements [0, 1, 2]
entity 1 -> elements [3]
entity 2 -> elements []
entity 3 -> elements [4, 5, 6, 7]
当 element-level search 返回一个物理 element id,Milvus 可以把它映射回 parent entity id 和 element offset。当 element_filter 在 element 级别生成 bitset,系统也能把 entity-level visibility、delete、filter 和 element-level predicate 对齐起来。
输出时,Milvus 会按逻辑 schema 把 child columns 还原成用户写入时看到的 StructArray 形态。以 clips 为例,clips[caption]、clips[scene_type]、clips[clip_embedding] 会按相同 offset 合并回一个个 clip object。这样一来,系统内部可以按 sub-field 建索引、做过滤和搜索,用户侧仍然面对自然的嵌套对象模型。
StructArray 适合什么场景,又不适合什么场景?
StructArray 并不是只要一个 Entity 有多个向量就应该使用。它比较适合的是以下 workload:
- 业务存在清晰的 Parent Entity,例如视频、商品、长文档、视觉页面或 Memory record;
- Parent 内部包含一组有序、长度可变的局部 Elements;
- 每个 Element 有自己的 scalar metadata、vector,或者两者兼有;
- 多个过滤条件必须保证作用于同一个 Element;
- 搜索既可能关心 Parent Entity,也可能关心具体 Element。
反之,如果文档很短、query 很简单,一个 Dense Embedding 已经能够稳定表达主要语义,那么增加 StructArray 和多向量索引只会徒增存储和搜索成本。
此外,StructArray 不是文档数据库式的任意 nested object,在 Schema 和执行层面存在以下明确约束:
Struct目前只能作为Array的元素类型,不能直接作为 Collection 的顶层字段;- 同一个 StructArray 中的所有元素必须共享一套预定义 Schema;
- 必须指定
max_capacity,用于限制每个实体最多可以包含多少元素; - StructArray 内部暂不支持嵌套
Struct、Array、ArrayOfStruct和JSON子字段; - 一个向量子字段只能绑定一个索引。如果同时需要 EmbeddingList 和元素级搜索,应分别定义两个向量子字段;
- 向量子字段在搜索前必须建立索引。频繁参与过滤的标量子字段,也建议建立合适的标量索引;
- StructArray 创建后,子字段 Schema 固定不变,因此在正式上线前应提前规划好元素需要包含的属性。






