早期向量数据库通常采用一种简化建模方式:一个实体(Entity)只对应一条向量(Embedding)。无论文档、商品还是多媒体内容,都会先被编码成单个向量,再通过 query vector 与这些 entity vectors 执行近邻检索。第一代语义搜索、推荐召回以及 RAG 检索增强生成,大多建立在这一模型之上。但这个模式有一个前提:实体本身足够简单,一条 embedding 就能较完整地表达原始内容。对于几百字的短文本,这种假设尚且可用;可一旦面对视频、长文档、多图商品等复杂数据,把全部信息压缩进一个向量,局部细节就容易被平均和稀释,而真正决定检索相关性的,往往恰恰是这些局部内容。一个常见思路是把局部内容拆开处理,例如将视频切分为多个 clips,并为每个 clip 单独保存 embedding、start_time、end_time、caption、scene_type 和 confidence 等信息。但这种“暴力拆分”也会引出新的问题:
- 同一条视频的多个 clips 可能会同时进入 Top-K 结果;
- 父级 metadata 需要在多条 records 中重复存储;
- 业务最终展示的是“视频”而不是“clip”,应用层还要自行完成 grouping、dedup 和 rerank;
- 数据库本身并不知道这些 clips 原本共同属于同一个逻辑对象。
从一条记录到一组有身份的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
>>
-
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)
如果后续需要进行向量搜索或高效过滤,还要显式创建索引。为了与后文的 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,再统计有多少元素满足条件,最后据此决定整个父实体是否通过过滤。例如
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 进行比较,并最终返回 entity 级搜索结果。
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 同时满足这两个条件。为此,Milvus 提供了多种聚合语义的 MATCH 表达式:
唐晨杰
Senior Software Engineer at Zilliz
阅读推荐官宣开源|Milvus 3.0 正式发布Milvus 3.0 开源解读之backfill|亿级 AI 数据,如何做高效特征回填Milvus 3.0 开源解读|从Kafka、Pulsar到Woodpecker,数据库如何低延迟、低成本的写入Milvus 3.0 开源解读之Manifest|AI 数据管理,应该彻底放弃文件中心架构Milvus 3.0 开源解读之,数据库原生聚合排序如何取代应用侧Pandas胶水代码Milvus 3.0开源解读之Regex |从=~到NGRAM,如何选择最优性价比的正则过滤Milvus 3.0 开源解读之Snapshot|无需复制embedding数据的Milvus Collection视图Milvus 3.0 开源解读|自定义词典如何优化BM25 与Text Match 的专业词理解能力Milvus 3.0 开源解读|如何借助原生 TEXT 类型和 LOB 高效管理原始文本Milvus 3.0 开源解读|词法+语义高亮,如何解决Agent的搜索噪音?
登录查看剩余 70% 内容
