位置:首页 > 技术资讯 > Milvus 3.0开源解读:StructArray重构多向量检索机制

Milvus 3.0开源解读:StructArray重构多向量检索机制

时间:2026-08-21  |  作者:半糖攻略君  |  阅读:0

向量数据库早期将实体简化为单向量,致使复杂数据的局部信息丢失。而Milvus 3.0通过StructArray重构多向量检索,实现了整体与局部粒度的平衡。
核心内容如下:

  1. 向量数据库Entity设计的局限:复杂数据的局部细节丢失。
  2. 暴力拆分方案的不足:存在数据关联与索引问题。
  3. StructArray的核心设计:支持Entity内对齐Elements及多粒度检索。

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

向量数据库早期有一个默认抽象:一个实体(Entity)=一条向量(Embedding)

文档、商品等都可编码成向量。搜索的基本动作,是将query vector与这些entity vectors做近邻搜索。第一代语义搜索、推荐召回和RAG,基本都建立在这一模型上。

但这个模型有一个隐含前提:实体足够简单,一条embedding就足以表示完整原始数据信息。对几百字文章,这个假设勉强成立。可对视频、长文档、多图商品等复杂数据而言,把所有内容压缩成一条向量,细节会被平均掉。

而检索中真正决定相关性的,往往正是局部内容。

单向量Entity模型的局限

一个直接的办法,是把局部内容拆开。比如将视频拆成多个clips。每个clip保存自己的embedding、start_time、end_time、caption、scene_type和confidence等信息。

但这样做又会带来新的问题:

  • 同一条视频的多个clips可能同时进入Top-K;
  • 父级metadata需要在多个records中重复存储;
  • 最终业务展示的是“视频”而非“clip”,应用需自己做grouping、dedup和rerank;
  • 数据库不知道这些clips原本共同属于一个什么样的逻辑对象。

这里的核心矛盾是:业务消费的是整体,搜索命中的却往往是局部。

那么,如何在数据库中兼顾局部信息的精准性,同时理解整体与局部的关系?基于此背景,Milvus 3.0引入了StructArray。

StructArray解决什么问题

StructArray允许一个Entity内保存一组彼此对齐的Elements。每个Element可包含受支持的scalar metadata和vector sub-fields,同时仍属于同一个Parent Entity。

这意味着,数据库能够明确知道哪些数据是在共同描述同一个对象。也可以决定搜索发生在entity层、element层,或在两种粒度之间完成过滤、排序和结果融合。

从一条记录到一组有身份的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
      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提供三种不同的执行语义:

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

其中,第一类MATCH_*系列操作符,可用于判断是否过滤父实体。MATCH_ANY、MATCH_ALL、MATCH_LEAST、MATCH_MOST和MATCH_EXACT会在Struct元素上执行predicate,统计有多少元素满足条件,再据此判断整个父实体是否通过过滤。

例如:

    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,并最终返回实体级结果。

    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表达式:

  • MATCH_ANY:只要存在任意一个Element满足条件;
  • MATCH_ALL:要求所有Element都满足条件;
  • MATCH_LEAST:至少有指定数量的Element满足条件;
  • MATCH_MOST:至多有指定数量的Element满足条件;
  • MATCH_EXACT:恰好有指定数量的Element满足条件。

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

精选合集

更多

大家都在玩