Milvus 3.0开源解读 | 从Entity到Element,StructArray 如何重构多向量检索

2026-08-187 分钟阅读

向量数据库早期有一个默认的抽象:一个实体(Entity)= 一条向量(Embedding)。文档可以被编码成一个向量,商品也可以被编码成一个向量,搜索的基本动作就是把 query vector 与这些 entity vectors 做近邻搜索。第一代语义搜索、推荐召回和 RAG,也基本都建立在这个模型之上。

但它背后有一个隐含条件: 实体足够简单,一条embedding数据,就足以表示我们的完整原始数据信息。对一篇几百字的文章,这个假设还勉强成立。但对一段视频、一份长文档、一个包含几十张图片的商品来说,如果把所有内容强行压缩成一条向量,细节全就会被全部平均掉。而检索中,真正决定相关性的,往往是局部内容,而非整篇文档的单一向量。

一个暴力的办法是把这些局部内容全部拆开。例如,把一条视频拆成多个 clips,每个 clip 都作为独立 Entity 保存自己的 embeddingstart_timeend_timecaptionscene_typeconfidence

这样局部搜索变得简单了,但新的问题随之出现:

  • 同一条视频的多个 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_embeddingstart_seccaption 等是 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 提供三种不同的执行语义:

  1. 在 Element 上判断条件,再过滤 Parent Entity;
  2. 把一组 Element embeddings 作为整体参与 Entity-level search;
  3. 让每个 Element embedding 独立参与 ANN,直接返回局部命中。

其中,第一类 MATCH _* 系列操作符,可以用于判断是否过滤父实体

MATCH_ANYMATCH_ALLMATCH_LEASTMATCH_MOSTMATCH_EXACT 会在 Struct 元素上执行 predicate,统计有多少元素满足条件,再据此判断整个父实体是否通过过滤。

例如

Plaintext
MATCH_ANY(clips, $[scene_type] == "kitchen" && $[label_confidence] > 0.8)

这个表达式要求同一个 offset 上的 scene_typelabel_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 searchelement-level search
query 输入一组 vectors一个普通 vector
典型 metricMAX_SIM*普通 vector metric,如 COSINE / L2 / IP
竞争单位parent entity 的 embedding listentity 内部每个 element
结果身份entity-level resultentity-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 和 COSINEIPL2 等普通 vector metric,用于 element-level search。

两个字段可以保存同一份 element embedding,但分别绑定不同的索引,从而让两种搜索模式在同一个 Collection 中共存。

使用EmbeddingList 索引,如何在搜索质量与成本之间做取舍

当每个实体只有一个向量时,ANN 索引只需要寻找与查询向量接近的实体。但 EmbeddingList 模式下,相关性来自两个向量列表之间的大量MaxSim 匹配。需要精确计算所有 query vectors 和所有 entity vectors 的相似度,这样做的好处是质量最好,但在线遍历全量数据的成本也通常不低。

因此,Milvus 采用两阶段搜索模型:

  1. 首先通过近似方法召回一批候选父实体;
  2. 当启用 emb_list_rerank 时,再在这些候选上重新计算 MaxSim,得到最终排序。

候选取得越多,越接近精确 MaxSim,但延迟和计算成本也越高。

目前我们提供的三种策略的核心区别,主要在于如何生成第一阶段的候选集

策略候选生成方式适合的情况需要注意
TokenANN把 embeddinglist 里的 vectors 展开建 ANN,搜索时 query embedding-list 中的 embedding 独立做 ANN 搜索,收集命中的 parent entities,去重后再做 MaxSim reranklist 较短、单个向量区分度高、质量优先索引规模和搜索次数随 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:

  1. 业务存在清晰的 Parent Entity,例如视频、商品、长文档、视觉页面或 Memory record;
  2. Parent 内部包含一组有序、长度可变的局部 Elements;
  3. 每个 Element 有自己的 scalar metadata、vector,或者两者兼有;
  4. 多个过滤条件必须保证作用于同一个 Element;
  5. 搜索既可能关心 Parent Entity,也可能关心具体 Element。

反之,如果文档很短、query 很简单,一个 Dense Embedding 已经能够稳定表达主要语义,那么增加 StructArray 和多向量索引只会徒增存储和搜索成本。

此外,StructArray 不是文档数据库式的任意 nested object,在 Schema 和执行层面存在以下明确约束:

  • Struct 目前只能作为 Array 的元素类型,不能直接作为 Collection 的顶层字段;
  • 同一个 StructArray 中的所有元素必须共享一套预定义 Schema;
  • 必须指定 max_capacity,用于限制每个实体最多可以包含多少元素;
  • StructArray 内部暂不支持嵌套 StructArrayArrayOfStructJSON 子字段;
  • 一个向量子字段只能绑定一个索引。如果同时需要 EmbeddingList 和元素级搜索,应分别定义两个向量子字段;
  • 向量子字段在搜索前必须建立索引。频繁参与过滤的标量子字段,也建议建立合适的标量索引;
  • StructArray 创建后,子字段 Schema 固定不变,因此在正式上线前应提前规划好元素需要包含的属性。

AI Assistant