推荐语
我们为什么决定重构Milvus的存储引擎?因为AI时代的数据形态,已远超传统数据库的承载能力。
核心内容:
1. 传统存储模型在AI时代面临的三大典型问题
2. 全新Loon引擎如何通过混合格式等方案解决这些问题
3. 以多模态训练为例的AI数据生产场景剖析
杨芳贤
53AI创始人/腾讯云(TVP)最具价值专家
过去八九年,我们一直在做一件事:把向量数据库从一个很小众的系统方向,做成 AI 基础设施里的关键组件。当然,也有很多人试图劝我们立刻原地倒闭,比如:传统数据库不是已经能存int、string、json了吗?现在不过多了一种 vector 字段。传统数据库加一个向量类型,再配一个ANN索引,你们就要完蛋了!的确,很多传统数据库也是这么做的:加一列 vector,接一个索引,demo 能跑,也有很多企业,在语义检索初期,会选择这样的产品。但跑到一定规模以后,就一定会发现传统数据库+补丁的方式,根本无法解决AI时代的新问题。向量不只是一种新的字段类型,更是一种新的数据形态。- 它规模巨大,要被 Spark、Ray、DuckDB、训练 pipeline 和评估系统反复读取和改写,所以像数据湖。
- 它的原始对象往往是视频、图片、PDF、音频,继续留在 S3、GCS、OSS 里,所以也像对象存储。
但它还多了一层传统系统没有处理好的东西:embedding、sparse vector、caption、vector index、text index、delete log、stats、外部对象引用,以及这些对象之间不断变化的版本关系。更不用提,当一份 AI 数据集持续演进、被多个系统共同读写、同时服务在线检索和离线分析时,旧的存储模型还能不能承担作为数据事实来源的责任。因此,沿用传统数据库、数据湖、对象存储的任何一种传统管理方式,最后都会半路失灵。接下来,本文将重点讲解传统存储模型在AI时代面临的三个典型问题:写放大、读路径冲突、多引擎协作。以及解决为了解决这些问题,我们是如何推出Loon存储引擎,用混合文件格式、row id 对齐和 Manifest 重新组织数据,解决海量向量数据的高效存储的。01
AI时代的生产场景是怎么使用数据的
我们最近和一个多模态训练数据团队聊过他们的工作流,大概是这样的:video→ clips→ metadata→ aesthetic_score→ caption→ embedding→ search / dedup / training data filtering
一段长视频先上传到对象存储。pipeline 按“换镜头”“换场景”把它切成约 10 秒的片段,去掉过长、过短或者质量太低的片段。剩下的片段会跑美学打分,再让 caption 模型生成文本描述。最后给每个片段算一份 embedding,写进向量库,用来做去重、检索和训练数据筛选。- 第一周:只跑了切帧和元数据,表里大致是 clip_id / video_id / start_offset / duration 这几列。
- 第二周:加上美学打分,每个片段补一列 aesthetic_score。
- 第三周:caption 模型跑通,每个片段补一列 caption,平均 200 字。
- 第四周:第一版 embedding 上线,每个片段补一列 768 维 CLIP 向量。
- 一个月后:换模型,重新算了一份 embedding_v2,1024 维。
- 两个月后:要支持 hybrid search,补一列 sparse vector。
- 三个月后:caption 做了一轮人工修订,要回填那一列。
发现没有,这份数据永远不会真正“写完”。这就是向量数据和传统业务数据的关键差异。传统业务表里,一行订单写完以后,状态可能变化,但它不会每隔几周长出一个几 KB 的新字段。向量数据不是这样,模型会换,特征会加,caption 会修,标签会补,索引会重建。同一批 row 会不断被重新解释。而且规模向量数据的规模通常不是百万级,多模态训练数据动辄亿级片段。参照 LAION-5B 这种 58 亿图文对的规模,非结构化训练数据从一开始就不是小数据。所以,向量存储的压力不在第一次 insert,而在后续持续演进。02
需求一:降低长字段带来的写放大
过去十年,lakehouse 生态里最常见的存储选择是 Parquet 这一类列存格式。它适合分析型负载:字段稳定,数据多读少改,扫描时只读需要的列,压缩率高,生态成熟。结构化数据里,TPC-H 是一个典型参照,过去几十年分析型存储格式基本都是围着它做优化的。它最大的表 lineitem 有 16 列,包括 4 个整数键、4 个 decimal、3 个日期、若干短字符串,还有一个 44 字节以内的 comment。一行未压缩大约 150 字节,压缩后通常在 60 到 80 字节之间。TPC-H 1GB scale 的 lineitem 大约 600 万行。这就是 Parquet 这类分析型存储格式假设的世界。默认 64 MB row group,在这种行宽下可以装下 40 万到 100 万行;按列压缩后,一整列的物理体积通常在几 MB 到几十 MB。LAION 是非结构化数据(图文对)经过向量化之后的代表,AI 工作流里很多 dataset 都是这个形状。LAION-5B 的每条记录包含一个 URL、一段 caption(几十到一百多字节)、几个数值字段(width、height、相似度、NSFW 标签等),加上一份 CLIP embedding。社区最常用的 ViT-L/14 是 768 维:fp16 存 1.5 KB,fp32 存 3 KB。 | TPC-H lineitem | LAION-5B 风格 |
|---|
单行未压缩字节 | ~150 | ~1.5–3 KB |
64 MB row group 装下的行 | 40 万–100 万 | 2 万–4 万 |
单看一行的字节数,LAION 就比 TPC-H 大了 20 倍上下,这 20 倍里,向量列就贡献了 95% 以上。如果换成 OpenAI text-embedding-3-large 这种 3072 维 fp32 embedding,一行向量直接到 12 KB。多模态训练里常见的 1024 到 2048 维 embedding,单条也有 4 到 8 KB。视频切帧场景里,每个片段除了 embedding,还会有 caption、aesthetic_score、原始视频引用、parser 版本、模型版本。结构化字段不一定少,向量列却贡献了绝大部分体积。Parquet 这一类格式的核心假设有两条:列基本不可变,schema 演进低频。在 TPC-H 这种“行宽 150 字节、列数固定”的世界里,这两条假设几乎不付代价。加一列就是写一个新文件,每个 row group 64 MB,总共也就几 GB——分析任务里几乎属于是背景噪声。改一行靠 delete log + compaction,重写的也只是 64 MB 这个量级,每行才 150 字节,搬几十万行,成本都还在可控范围内。假设有 1 亿个 clip。给每个 clip 补一列 1024 维 fp32 embedding,原始向量数据就是约 400 GB。这还只是数据本身。真实系统里还要写对象存储,更新一份 stats,建立索引,更新元数据,校验覆盖范围。如果每个月新增一到两列向量字段,比如 embedding_v2、sparse_vector、rerank feature,那么每次 schema 演进都意味着一次大型数据工程。前文中那个团队每个月加一到两列向量类型的字段,意味着每个月有 TB 级的写入和回填。列存系统里,删除或修改旧数据通常不是原地改,而是通过 delete log 加 compaction 完成。删除日志写进去,compaction 时把活着的行搬到新文件。列存里删除或更新旧数据,通常会暴力的简单重写文件(因为一行不大,重新生成数据——例如制作一张宽表——的成本不高)。在 TPC-H 那种列上,一行也就几十到几百字节,搬一批 row 成本也不算高。但向量数据不一样。一条 768 维 fp32 embedding 就有 3 KB;1024 到 2048 维 embedding 常见于多模态场景,单条就是 4 到 8 KB;如果是 3072 维 fp32,单条可以到 12 KB。这意味着,一次 caption 人工修订,表面上只是回填几百字节的文本;但在旧的统一存储布局里,它可能会触发上百万 row id 的物理重写。整体数据量可达 GB 级别,相当于大规模重新写入数据。如果文本、向量和其他派生特征共享同一批物理文件或 compaction 生命周期,那么修一列文本,就可能让同一批 row 上的 embedding、embedding_v2、sparse_vector 也被顺带重写。几百字节的更新,最后变成 GB 级 I/O。这就是为什么说写放大问题,在面对向量数据时需要被格外重视。传统分析表的 schema 可能一年改几次。向量数据不是。caption 模型会升级,embedding 模型会换,sparse vector 会后补,rerank feature 会增加,人工标注会修订,数据治理会补标签。这些动作都不是简单追加新行,每一件都同时是改一列和针对一批 row 重写。lakehouse 生态过去十年的优化方向是读得更快,不是改得更便宜。当主要写入模式从append-only 追加新行变成高频回填已有 row 的某几列,原本可控的写放大就会被向量列的长度放大成主导成本。所以一个矛盾就出现了:结构化数据上列存几乎没有写放大问题;同一种列存放在向量数据上,写放大却成了主导成本。03
需求二:一份数据既要能扫,也要能点查
WHERE aesthetic_score > 0.8 AND duration > 5
COUNTGROUP BY离线分析全量 embedding 评估BM25 统计bitmap 构建数据质量检查
这种读法的特点是:读很多行,每行只看少数几列。它喜欢连续 I/O、大 row group、列压缩、批量解码和向量化执行。ANN 检索返回一批 candidate row id 后,系统要回表拿读取:captionembeddingrerank featurevideo_urimetadata
这种读法的特点是:读的行不多(几百到几千),但要按 row id 精确拿到几列。它需要低延迟随机访问,希望直接定位到某一行某一列,只读需要的 byte range,而不是拉一个完整 row group。- 扫描希望 row group 大,多列在物理上挨在一起,做向量化执行省 cache miss。这样一次 I/O 可以拉很多连续数据,压缩率好,吞吐高。点查希望读取粒度小。最好不用依赖大 row group,而是能按 row id 找到 segment 或 byte range。
- 扫描喜欢重压缩,因为数据量大,少传输很重要。点查必须能在压缩块中间挑出一条记录,重压缩反而是负担。
- 扫描可以靠并发掩盖对象存储延迟。点查在对象存储上很敏感,因为每一次随机读都可能变成一次远程 range GET。row group 越大、单条 row 的位置越模糊,读放大越严重。
标量要的是宽和压缩,向量要的是窄和点查。一个文件格式不可能同时满足这两种需求。如果所有列都放进 Parquet,标量扫描会很舒服,但 ANN 召回后的向量回表会很麻烦。系统可能只想拿几百条向量,底层却要拉很多无关 row group。数据在本地 SSD 上时,还可以靠 cache 和 mmap 缓解。一旦数据上 S3,每一次 cache miss 都会变成远程读取,一次 ANN 查询触发几十 MB 的网络流量。一次召回要回表读 1000 条 row,可能要触发上千次 row group 拉取,每次几十 MB——总流量直接上 GB。如果为了点查把 row group 切得很小,点查变好了,但扫描又变差。大量小片段会拖垮连续读和压缩效率。更现实的是,hybrid search 会在同一次查询里同时触发两种读法。aesthetic_score > 0.8 AND duration > 5
再按 row id 回表取 caption、vector 和 metadata。也就是说,同一份数据在同一个查询里既像分析表,又像低延迟随机访问。同一份数据被同一个用户在 200 毫秒内两种读法各走一遍。这不是调 row group size 能解决的问题。04
需求三:让外部系统围绕同一份数据工作
前两个问题发生在数据库内部:怎么写,怎么读。第三个问题发生在系统边界上:以视频切帧场景为例,它从头到尾几乎没有一个环节,只发生在向量数据库内部。向量数据库只是整条链路中间的一段。它负责在线检索,但数据生产、数据修订、数据分析和数据治理都发生在外部。其中,原始视频在 S3 上;切帧在 PySpark 或者 Ray 里跑;美学打分用一个独立的 GPU 服务;caption 由一个 LLM inference pipeline 算;embedding 在另一个 GPU 池里算;sparse vector 在 SPLADE 服务上算。此外,检索完成后,检索结果要喂给训练数据筛选 pipeline;同一份数据要进入离线评估,跟人工标注比对;rerank model 要拿这批 vector 重新训练;caption 要做人工审核然后回填;数据治理要审计敏感内容。这些工作也都不在向量数据库里。数据库本身只占整条链路的中间一段——服务在线检索。它前面是 ML pipeline,后面是分析、评估、治理、回填。如果向量数据库的物理格式只有自己能读写,那么每次外部系统参与,都要导出、转换、复制、再导入。这就导致同一份 collection 可能同时存在于:数据库里、在 Spark 的临时目录里、在评估 pipeline 的本地拷贝里。那么,谁是 source of truth?哪一份对应的是上个月那个 caption 模型版本?人工修订之后,哪一批 row 已经被回填,哪一批还没?数据小的时候,我们只需要付出一些额外的时间成本,靠 Excel 和命名规范解决;数据规模大到亿级片段、TB 级 embedding以后,它会变成一致性问题。这时候,能解决它的,只有存储格式:它要回答的,是当前这份数据集是什么版本、有哪些列、覆盖哪些 row range、哪些 index 还有效、哪些原始对象还活着。这也是 lakehouse 生态过去十年解决的问题。从 Hive 表到 Iceberg、Delta Lake、Hudi,核心不是怎么把文件压得更小,而是多个引擎如何围绕同一份数据安全工作。向量数据库现在也走到了同一个路口,只是它要管理的对象更多。除了列和分区,还包括了 vector index、text index、sparse feature、删除日志,以及指向原始视频的 Blob 引用。所以问题不在于Spark 能不能读 Milvus 的文件。真正的问题是:Spark 在外部补完一列 sparse vector 以后,Milvus 怎么知道这列属于哪个版本,覆盖哪些 row,用了哪个模型,什么时候可以被线上查询使用。05
如何解决三大需求?需要重构表的格式
- 同一份数据上的两种读法天生相反——单一文件格式没法两边讨好。
- 数据在数据库之外被参与改写——私有格式成了系统级的债务。
听起来是三个独立的工程问题。可以一个一个打补丁,比如写放大上一些 batching,访问模式上做一个 cache 层,外部协作做一个导出工具。这条路过去几年向量数据库行业基本都走过,但都走不远。因为真正的问题是:一份向量数据集里住着有长有短、有冷有热、有适合扫描有适合点查的几种完全不同的数据,以视频切帧为例:clip_id / video_id / duration是短标量字段,适合过滤和分析。caption是文本字段,可能要做 BM25、审核、修订和回填。embedding是长向量字段,ANN 召回后需要低延迟点查。embedding_v2是新模型产生的新向量列,可能后补。sparse_vector服务 hybrid search,访问模式又不同。raw video应该继续留在对象存储里,数据库只记录引用。vector index / text index / stats / delete log是围绕数据产生的派生结构和版本语义。
它们只是共享同一个 row id,但需要的处理方式天差地别。如果把它们硬当成一张普通表,写放大会失控;如果把它们硬塞进同一种列存格式,点查会付出高读放大;如果把它们当成一堆对象文件,多引擎协作会变成版本噩梦。所以重新设计向量存储这件事,我们需要先承认一份向量数据集天生是异构的:- 混合文件格式承认不同列的访问模式不同。标量列适合扫描,向量列适合点查,原始大对象适合留在对象存储。
- 行对齐承认这些列虽然物理上分开,但逻辑上仍然共享同一批 row。
- Manifest 承认这份数据不是一次性写完的,而是会被多个系统、多个版本、多个任务持续修改。
基于这一判断,给现有引擎打补丁这条路已经走不通,我们需要的是一套全新的存储引擎。
06
我们推出了Loon ,一举解决三大困境
这个全新的存储模型。落到设计上,其实就是三个动作:把不同列放进不同物理格式(混合文件格式),让分散的列在逻辑上拼回一张表(行对齐),用一份声明出来的元数据描述整个数据集(Manifest)。能力上,它要允许一张逻辑 collection 在物理上拆成多个列组;允许每个列组选择合适的文件格式;允许这些列组通过 row id 对齐;允许索引、stats、delete log、外部对象引用都进入同一个版本视图;允许外部计算系统围绕同一份数据安全写入和读取。它延续了 Zilliz 的鸟类命名传统:Loon 是生活在湖泊上的潜水鸟。这个名字直接对应技术目标:向量数据库不该每次查询、回填、建索引都搬起整片数据湖;它应该先看清当前数据集由哪些版本、列、索引和对象引用组成,再潜下去读取真正需要的那一小部分。它替换的是 Milvus 原有的 segment binlog 存储层。上层组件——Proxy 路由、QueryCoord/DataCoord 调度、IndexNode 索引构建——接口没变;改变的是 DataNode(写路径)、QueryNode 与 segcore(读路径)、Compaction,以及外部 Spark / Ray connector,都改为通过同一份 stoRAGe abstraction 落到 Loon。Manifest 是数据集的版本视图;ColumnGroup 把逻辑表映射到符合访问模式的物理布局;格式层支持四种物理格式按列组挑选;下面是统一的 Filesystem 抽象。07
优化一:混合文件格式
既然不同列的访问模式不同,就不应该再用一种文件格式管理所有列。一份向量数据集应该按访问模式拆成多个 ColumnGroup。第一类是标量列、过滤字段、业务键、统计字段。这些字段经常用于过滤、扫描、聚合、分析,喜欢宽 row group、重压缩、向量化执行。Parquet 的压缩、列裁剪和生态互通正好适合它们。第二类是向量列、稀疏特征、rerank feature。访问模式是 ANN 召回后的行级点查,要的是低延迟随机访问、按 byte range 拉取、按需解码。这一组适合走段式(segment-based)的格式。Vortex 是当前正在成熟的一个方向,每段都有 offset 和 length,对象存储上可以直接 range GET。第三类是原始大对象(原始视频、PDF、图片)。这些对象本身不应该进入向量数据库的数据文件。它们应该继续留在对象存储里。数据库里记录的是 URI、checksum、mime type、parser version 和对应关系。- Vortex——向量、稀疏特征。当前用社区开源的。
- Lance(只读)——兼容已有 Lance 数据集,直接 mount 成一个 ColumnGroup。
视频切帧场景对应过来:clip_id / video_id / start_offset / duration / aesthetic_score 进 Parquet 列组;caption 也进 Parquet 列组;embedding、embedding_v2、sparse_vector 各自进段式列组;原始视频文件还在 S3,每个 clip_id 对应一个引用。加 embedding_v2 只是新增一个段式列组、登记 row id 范围、commit Manifest——既不动 caption 列组里的字段,也不动 embedding 列组里已有的 400 GB 向量。Parquet 这一边,Loon 没有用社区默认配置:row group 收紧到 1 MB(默认 64 MB 在向量列上等于每次回表都至少拉 64 MB),向量列上关掉 dict 等对随机数据没收益的编码。这些调整加起来,比起社区默认行为已经是相当大的优化。Vortex优化才是真正为向量存储做的重点工作。我们基于社区开源 Vortex 实现了一个行存与列存平衡的 layout:同一 row group 内各列的 segment 在文件中物理相邻,scan 时可以连续读;新增 sub-segment 读取,让 take 只拉 segment 内部的一段字节而不是整段。动作 | Vortex | Parquet | Vortex 优势 |
|---|
Take(索引回表,K=1000 随机行) | 5.8 ms | 144 ms | 25× |
Scan(向量列全表扫描) | 21 ms | 142 ms | 6.76× |
文件大小(约 21 MB 原始数据) | 6.62 MB | 7.16 MB | 小 7% |
三组差距背后是不同的存储机制:Take 的优势来自新的 layout 只需要访问命中的 row group 内部数据,而 Parquet 则要解析命中行所在的整个 row group;Scan 的优势来自 vortex 压缩算法和工程实现的优化。[1] 测试环境:8 vCPU Ubuntu 22.04 KVM。单文件 40,000 行,schema 为{id: int64, name: utf8, value: float64, vector: list[128]},row group 1 MB。基于本地文件系统测试。S3 上的性能由网络 IO 主导,Vortex 段式布局的读放大更小,相对 Parquet 的优势会被进一步放大。08
优化二:行对齐
混合文件格式解决了怎么拆。拆开以后,还要回答一个问题:怎么把它们重新看成一张表?每个列组在元数据里登记自己覆盖的 row id 范围——start_index / end_index。同一个 row id 空间里,多个列组分别管一部分列。这样,row id = 12345 的字段可以分散在多个文件、多个格式、多个列组里,但逻辑上通过 row id仍然是一行。新增 embedding_v2 时,只需要写一个新的向量列组,并登记它覆盖哪些 row id。老的 caption、metadata、embedding_v1 不需要重写。当前版本里哪些 row 不可见,可以先由 delete log 表达。真正的物理清理推迟到 compaction阶段。更重要的是,compaction 可以只触及相关列组,而不是每次都搬动整张表。ANN 召回 row id 后,系统可以从不同列组里分别取 caption、metadata、vector、video URI,再拼成结果。row id 在这里不是业务主键,更像是存储层的坐标系。没有这个坐标系,混合格式只是一堆散文件。有了 row id,对象存储上的多个文件、多种格式、多个列组才能重新组成一张逻辑 collection。要解决以上三个行对齐需求,落到Loon里,我们主要做了两件事。ColumnGroupFile 的结构——每个物理文件登记 path / start_index / end_index 三个数字(外加一段可选 metadata)。start_index / end_index 是行对齐的全部秘密:不同列组、不同文件、不同格式,通过同一套 row id 范围对齐。Packed Reader——上层读到的是统一的 Arrow RecordBatch stream,下面可能跨好几个列组。Packed Reader 干三件事:屏蔽下层格式差异(不管下面是 Parquet 还是别的列格式);按 row id 范围把多个列组拼齐;用最小堆调度多文件 I/O 控制内存。它还有另一个入口——按 row id 直接 take,定位到对应的 ColumnGroupFile,发起 range read 拿到具体数据。视频切帧场景下,ANN 召回回表读 caption + embedding + 视频引用:Packed Reader 拆成两次 range GET(一次标量列组、一次向量列组),不动其他列组任何字节。09
优化三:Manifest
混合文件格式解决物理布局,row id 解决逻辑对齐,Manifest 解决的则是事实来源问题。当一份数据横跨多个文件、多种格式、多个版本、多个写入者时,目录结构已经不能再说明真相。对象存储目录里可能有:旧版本文件、新版本文件、失败任务留下的文件、仍被旧 snapshot 引用的文件、还没清理的 delete log、临时输出文件,文件存在,不代表它属于当前版本。Manifest 描述的是数据集在某个版本上长什么样:- 有哪些列组、各自覆盖什么 row id 范围、用什么格式、文件落在哪。
- 有哪些 delta log 生效,是按主键删(PRIMARY_KEY)、按位置删(POSITIONAL),还是按值删(EQUALITY)。
- 有哪些统计可用——bloom filter、min/max。
- 引用了哪些外部 Blob,对应哪个 source object 版本。
Manifest 的形式是一个版本号 + 一份二进制结构,每次更新就是写一个新版本的 Manifest 文件。读者读到哪一版 Manifest,就看到哪一版数据集——读者之间互不打扰。如果只有数据库内部能写 Manifest,那它仍然是一份内部元数据,只是换了个名字。但如果 Manifest 的写入路径是开放的——Spark 跑完一列 sparse vector backfill 之后能生成新的列组、新的 stats、新的 Manifest,再通过乐观并发提交进同一个版本视图——那它就成了多引擎协作的接口。在线查询继续读旧版本不受影响;提交成功之后,新版本立刻成为事实来源。这套思路和 Iceberg、Delta Lake 类似。但向量数据库的 Manifest 管得更多。不只是表文件和分区,还包括 vector index、text index、sparse feature、delete log、stats、Blob 引用和 row id 范围。一定程度上,没有 Manifest,多格式、多列组、多引擎写入都会退化成文件约定和人工对账。而人工对账在 TB 级向量数据面前没有未来。具体来说,每个 Loon 数据集在对象存储上的目录是这样组织的:_metadata/ 放版本化的 Manifest 文件、_data/ 放列组数据文件、_delta/ 放删除日志、_stats/ 放统计文件、_index/ 放索引文件。这几个目录里的内容是否属于当前版本,全部由当前 Manifest 决定——读者不去 list 目录推测。Manifest body 用 Apache Avro 编码,按顺序排四块:- ColumnGroups——列、格式、文件、覆盖的 row id 范围。
- DeltaLogs——删除日志。三种语义对应三种典型来源:PRIMARY_KEY(客户端 delete)、POSITIONAL(compaction 内部位置删)、EQUALITY(Spark 等外部引擎按 predicate 删)。
- Stats——bloom filter、BM25、min/max 这一类。查询规划器在打开数据文件之前先用 Stats 做 pruning。
- Indexes——每个 index 记录覆盖列、类型(HNSW、IVF、inverted、bitmap 等)、参数、覆盖的 row id 范围。
每提交一次写一份新版本,旧版本继续在那里。这条性质让乐观并发提交能用最小语义实现:写者基于版本 N 生成新内容,提交时写入 manifest-{N+1}.avro——通过对象存储的 conditional write / generation match 能力保证"该版本已存在就失败"。冲突重试的代价远低于走强一致 CAS。10
Loon 现在走到了哪一步,未来会走向何方?
Loon 目前已经进入 Milvus 主干。已经完成的部分,主要集中在三层:Manifest 已经完成,包含 ColumnGroups、DeltaLogs、Stats、Indexes 四个 section。每次写入、回填、删除、索引更新,都会形成新的版本视图。写者基于旧版本生成新内容,再通过乐观并发提交新的 Manifest。读者只需要读到某一个 Manifest 版本,就能看到一份稳定的数据集。文件格式方面,Parquet 、Vortex 已经支持,我们会逐渐用 Vortex 替换 Parquet ,最终成为默认的格式;Lance 以只读方式接入,用来兼容已有 Lance 数据集。标量 stats、过滤索引、文本倒排索引已经可以按 row range 进入 Manifest 规划。向量索引的 lake-native 路径还在继续推进。HNSW / IVF 这类索引在对象存储上的访问模式不同,尤其是 HNSW 对随机访问和缓存更敏感,不能简单照搬本地 SSD 上的布局。发布节奏会跟随 Milvus release。具体启用版本和能力边界,以对应 release note 为准。多引擎写入 SDK——Spark / Ray 端给出 first-class 的 Manifest 写入路径,让外部 backfill 不需要走 C/C++ 接口。Iceberg / Delta Lake 互通——把向量列组对接到 lakehouse 的现有 catalog 体系,让 Trino / DuckDB / Athena 这一族引擎能围绕同一份数据工作。索引支持——图索引在对象存储上的延迟特性和 IVF 不同,需要单独的 layout + 缓存策略。这是当前最直接的瓶颈:HNSW 在线召回还吃本地缓存,对象存储优势暂时主要兑现在 batch 路径上。LOB类型支持——原始视频、PDF、音频等大对象的引用、版本化、删除连带语义。尾声
如何使用Loon
对应用层来说,Loon 不应该成为一个新的概念负担。因此,在我们的设计中,collection、insert、search、hybrid search 这些 API 表面不会变。应用不需要知道 Manifest、ColumnGroup、row id range这些内部概念。新建 collection 时,在启用 Loon 的 Milvus 版本里,Loon 会作为默认存储引擎,对用户透明。存量 Milvus 2.x collection 可以通过迁移工具接入。迁移过程走后台 compaction,把旧存储布局逐步转换为 Loon 的列组布局,业务层读写接口保持不变。Zilliz Cloud 里的 Vector Lakebase 也是基于这套存储模型构建。同一份数据集可以被线上 ANN 查询消费,也可以被 Spark / Ray 等外部引擎围绕同一份 Manifest 协作。它会随着 Milvus 版本在新集群上逐步默认启用。换句话说,Loon 对用户暴露的不是一组新 API,而是一组原来很难做、现在可以自然发生的动作。以前给一批老数据补一列 embedding_v2,要导出旧 collection,跑模型,生成新向量,再把数据重新导入或者通过 SDK 批量 update。中间要处理版本、失败重试、索引重建,还要尽量不影响线上查询。在 Loon 里,这件事变成:通过 schema evolution 添加字段;物理上写一个新列组,老列零搬动以前对老 row 做 backfill,往往要走 client SDK 批量 update,写放大严重;在 Loon 里,Spark / Ray 这样的外部引擎可以直接生成新的列组和 stats,再通过 Manifest 提交。数据库不再是所有数据改写的唯一入口,而是和外部计算系统共享同一份版本视图。以前做离线评估或数据分析,往往要先 dump 到 Parquet。线上 collection 是一份,分析目录里又是一份。只要中间有人工修订、模型换版、delete log 没同步,就会出现到底哪份数据是准的的问题。在 Loon 里,分析引擎可以直接读 Manifest 和相关列组。只读需要的 projection,只扫需要的 row range。以前删除一批 row,也会遇到类似问题。delete log 可以先表达逻辑删除,但 compaction 时常常会牵动整批数据。只要 caption、embedding、sparse vector 被绑在同一个生命周期里,删改短字段也可能拖着长向量一起重写。在 Loon 里,delete log 仍然存在,但 compaction 可以只触及相关列组。没有被影响的列组不需要重写。这样,删除、回填、修订这些日常动作不会每次都演变成一次大规模数据搬迁。更重要的是,它把很多过去需要 ops、ETL、停机窗口和人工对账的基础设施级动作,变成了围绕 Manifest 的普通 commit。而创新的加速,也往往是从这些看似不起眼地方开始的。