文章摘要
文章深度解读了 Milvus 3.0 版本中 StructArray 功能在 AI 数据管理上的革新。该功能解决了传统向量数据库处理复杂内容的问题,能明确识别局部单元关联,支持多粒度搜索等。文中以视频为例介绍实体建模、结构化过滤、双模式搜索等,还提及搜索质量与成本平衡、混合搜索粒度转换等,最后指出其适用场景与使用约束。

在向量数据库的早期设计中,一个实体通常对应一条独立的向量,这种简化模型在处理短文本、标准化商品这类数据时尚且可行,但面对长视频、多图商品、长篇文档这类复杂内容时,就会暴露出明显的短板:将完整内容压缩为单条向量会丢失大量局部细节,关键的匹配点被平均抹平,而检索的相关性往往恰恰由局部内容决定。

为了解决这个问题,最直观的思路是将复杂实体拆分为多个独立的局部单元,比如将一段视频切割为多个片段,每个片段单独存储向量和元数据。但这种方式会带来新的矛盾:同一条源实体的多个局部单元可能同时出现在搜索结果中,父级的元数据需要在多个记录中重复存储,应用层还需要额外完成分组、去重和重排序的工作,更关键的是,数据库会丢失这些局部单元原本同属一个逻辑整体的关联信息。

基于这些痛点,向量数据库在3.0版本中引入了StructArray功能,它允许一个父实体内部保存一组彼此对齐的子元素,每个子元素可以包含受支持的标量元数据和向量子字段,同时仍然属于同一个父实体。这一设计让数据库能够明确识别局部单元之间的关联,同时支持在实体粒度和元素粒度之间灵活切换搜索、过滤和结果融合。

与普通数组或JSON格式相比,StructArray的核心优势在于它在Schema层就定义了子元素的完整结构,让数据库能够保证相同偏移量下的多个子字段属于同一个子元素。比如对于视频片段来说,`start_sec`、`caption`和向量数据必须绑定在同一个片段上,而不是分散存储在独立的数组中。

实体建模:以视频为例定义StructArray

以视频实体的建模为例,我们可以为每个视频定义一个包含多个片段的StructArray字段,同时为两种不同的搜索模式准备两个独立的向量子字段:一个用于整体列表匹配,另一个用于单个元素的精准检索。

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)

为了支持两种搜索模式,我们需要分别为两个向量子字段创建索引:

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)

插入数据时,用户可以按照最自然的实体结构写入完整的视频信息:

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最核心的过滤能力在于,它能保证当多个过滤条件同时作用时,这些条件会在同一个子元素上生效,而不是在父实体的不同子元素中分散匹配。比如用户想要查找“厨房场景且标签置信度高于0.8”的视频,传统的独立数组过滤只能确认视频中存在厨房场景的片段,也存在高置信度的片段,但无法保证这两个属性属于同一个片段,而StructArray的MATCH系列操作符可以精准实现这一点。

常见的MATCH操作符包括:

  • MATCH_ANY:至少有一个子元素满足条件

  • MATCH_ALL:所有子元素都满足条件

  • MATCH_LEAST:至少有指定数量的子元素满足条件

  • MATCH_MOST:最多有指定数量的子元素满足条件

  • MATCH_EXACT:恰好有指定数量的子元素满足条件

以MATCH_ANY为例,其语法示例如下:

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

这个表达式会先在每个子元素的偏移量上计算条件,只有当同一个片段同时满足场景和置信度的要求时,才会将该片段计入统计,最终根据规则判断整个父实体是否通过过滤。

双模式搜索:兼顾整体与局部检索

基于StructArray,向量搜索可以分为两种完全不同的语义:

EmbeddingList搜索:多向量整体匹配

EmbeddingList搜索的查询本身也是一组向量,比如将一段查询视频切割为多个片段,或者使用多张参考图进行检索。系统会将查询向量列表与实体中的子元素向量列表进行MaxSim类匹配,最终返回最相似的父实体,适合视频到视频、多图到商品这类需要整体匹配的场景。

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,
)

元素级搜索:精准定位匹配子元素

元素级搜索的查询是单个普通向量,系统会将每个子元素的向量作为独立的检索候选进行近邻匹配,返回结果会携带偏移量,明确告知应用命中的是父实体中的第几个子元素。同时可以使用element_filter限制参与检索的子元素范围。

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"],
)

如果需要仅让符合条件的子元素参与检索,可以添加过滤条件:

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"],
)

这种模式的返回结果会包含实体ID、子元素偏移量和匹配距离,同一个父实体可能会多次出现在结果中,因为其内部的多个子元素都可能匹配查询。

EmbeddingList搜索的质量与成本平衡

与单向量检索不同,EmbeddingList搜索需要计算两组向量列表之间的最大相似度,直接遍历全量数据的成本较高,因此采用了两阶段的搜索模型:首先通过近似方法召回一批候选父实体,当启用重排序功能时,再在这些候选实体上重新计算MaxSim相似度,得到最终的排序结果。候选集的规模越大,结果越接近精确匹配,但延迟和计算成本也会相应提升。

目前提供了多种候选召回策略,不同策略适用于不同的场景:如果数据规模允许,可以优先选择TokenANN作为质量优先的基线方案;如果文档较短、查询逻辑简单,单条密集向量已经可以覆盖主要语义;当向量空间的区分度较高时,TokenANN或MUVERA是更优的选择,而对于视觉、多模态这类向量区分度较低的场景,LEMUR会是更合适的方案。

混合搜索中的粒度转换

在实际的生产场景中,检索往往不会只依赖单一路的向量搜索,可能同时结合视频整体向量、片段向量、文本全文信号以及重排序模型。当混合搜索中包含元素级子搜索时,需要根据最终的展示需求选择不同的结果粒度:

  • 如果所有子搜索都是同一个StructArray下的元素级搜索,可以保留元素级结果,直接展示具体匹配的片段或段落。

  • 如果混合了普通向量字段、不同结构的实体或EmbeddingList搜索,则需要将元素级结果聚合为父实体级结果,将多个子元素的得分合并为一个父实体的最终得分,常见的合并策略包括取最佳匹配得分、求和、平均或仅聚合Top-K个子元素。

需要注意的是,聚合操作只会处理当前检索返回的元素命中结果,不会重新扫描实体中的全部子元素,因此请求的限制参数会直接影响最终的聚合结果。

高效的底层存储与映射逻辑

对外暴露的StructArray看起来是一个嵌套的数组结构,但在系统内部,它采用了逻辑父字段+物理子列的设计来保证高效的检索性能。在Schema层,父字段仅描述子元素的存在、最大容量等属性,而实际的子字段会被规范化为独立的物理列,比如`clips[clip_embedding]`、`clips[scene_type]`等。

标量子字段在物理上存储为每个实体记录对应的标量数组,向量子字段则存储为每个实体记录对应的向量数组,这样每个子字段都可以独立使用对应的索引路径:标量子字段可以使用标量索引进行过滤,向量子字段可以使用向量索引进行近邻搜索。在写入阶段,代理层会将用户传入的嵌套结构展开为多个类型化的子列;在执行阶段,系统会维护实体记录与子元素之间的偏移映射,将物理元素ID映射回父实体ID和子元素偏移量;在输出阶段,系统会将物理子列重新组合为用户预期的嵌套StructArray结构。

适用场景与使用约束

StructArray并非适用于所有的向量检索场景,它更适合以下工作负载:

  • 业务存在清晰的父实体,比如视频、商品、长文档、视觉页面等;

  • 父实体内部包含一组有序、长度可变的子元素;

  • 每个子元素都有自己的标量元数据、向量字段,或者两者兼具;

  • 多个过滤条件必须绑定在同一个子元素上才能生效;

  • 检索既需要关注整体父实体,也需要定位具体的子元素。

而对于文档较短、查询逻辑简单的场景,单条密集向量已经可以稳定表达主要语义,此时使用StructArray只会增加不必要的存储和检索成本。此外,StructArray也存在明确的使用约束:

  • Struct类型只能作为数组的元素类型,不能直接作为集合的顶层字段;

  • 同一个StructArray中的所有子元素必须共享一套预定义的Schema;

  • 必须指定最大容量,限制每个父实体可以包含的子元素数量;

  • 暂不支持嵌套的Struct、数组、结构数组和JSON子字段;

  • 每个向量子字段只能绑定一个索引,如果需要同时支持两种搜索模式,需要定义两个独立的向量子字段;

  • 向量子字段在搜索前必须创建索引,频繁参与过滤的标量子字段也建议创建对应的标量索引;

  • StructArray创建后,子字段的Schema无法修改,需要在正式上线前规划好所有需要的属性。

以上内容不代表本平台立场,仅供读者参考