上一篇列了多模态数据的六处差异,其中三处落在存储上:训练要随机抽样,表结构天天变、训练集要能复现,「像不像」成了一种查询。数仓的 Parquet + Hive / Iceberg 为扫描聚合设计,这三件事正好都不擅长。这一篇讲 Lance 怎样解决,它同时是文件格式和表格式,可以先把它理解成「为随机读和多模态重新设计的 Parquet + Iceberg」。

例子沿用机器人数据:帧表(一个相机时刻一行)和 episode 表(一条 episode 一行,带着三路视频)。

先看训练是怎么读数据的

存储怎么设计,取决于谁来读、怎么读。训练机器人策略时,数据加载器每一步要做的是:把全部样本打乱,随机抽出一批(比如 256 个),每个样本再取最近几帧画面、当前关节角和接下来一小段动作。切到「训练样本」看一个样本到底要读什么:

一条 episode 的数据拖动时间点、切换数据层,看同一时刻在原始消息、明细、汇总和训练样本里各是什么样。
  1. 原始消息
  2. 对齐明细
  3. 汇总与标签
  4. 训练样本

episode「把杯子放进水槽」· 从第 12.0 秒的「训练样本」看起

一个样本要读 9 张图(三路相机 × 三个时刻)、1 行关节角、16 行动作,而且这些行在整个数据集里是随机分布的;一个 epoch 要把几百万帧都这样读一遍。数仓格式擅长的恰好相反:顺序扫描、只读几列、大块压缩。

核心原理一:列式文件的最小读取单位

Parquet 和 Lance 都是列式格式,同一列的数据放在一起,这一点和数仓的经验一样。区别在于:为了交出一个值,最少要读多少字节。


Parquet

Lance(2.1 文件格式)

文件怎么组织

文件 → 行组(通常每组几十到几百 MB)→ 列块 → 页(默认约 1 MB)

文件 → 列 → 按值的大小选不同的布局

最小解码单位

一页:压缩和编码以页为单位,要读一个值就得解开整页

小值(不到 256 字节):一个 mini-block,压缩后不到 32 KB

大值怎么存

和小值一样放在页里,一页能塞几张图就塞几张

full-zip 布局:先查一次位置索引,再只读这个值本身

随机读一个值的代价

一次请求,读一整页(有页索引时);没有页索引要读整个列块

小值一次请求读一个小块;大值两次请求、几乎只读值本身

适合

扫描聚合:顺序读、压缩率高

随机点查,同时扫描也不差

下面的实验比较随机读一批帧时,两种格式各要从存储里读多少字节、发多少次请求。条形图是对数刻度。

随机读取:Parquet 与 Lance训练时随机取一批帧,两种格式各要读多少字节、发多少次请求。
  1. 随机抽 256 行
  2. Parquet 读整页
  3. Lance 按值读取

状态 + 图像 · 每帧图像 60 KB

  1. 默认:每帧图像 60 KB,Parquet 的页是 1 MB。为了一张图和一行状态,Parquet 要把它们各自所在的整页读进来,总共放大三十多倍;Lance 对图像用 full-zip 布局直接定位,只放大一点五倍左右,多出来的主要是状态列的 mini-block。

  2. 切到「只读状态」:每行状态只有 128 字节,Parquet 为它读进 1 MB 的页,放大几千倍;Lance 读一个 32 KB 的小块,同样有放大,但比 Parquet 小三十多倍。

  3. 把 Parquet 页大小调到 64 KB:放大降下来了。但页越小,压缩率和扫描性能越差——Parquet 只能在两种读法之间取舍。

  4. 把「Parquet 页索引」切到「用不上」:只能把整个行组的列块读进来,随机读几乎变成全表扫描。这也是很多团队干脆把图片存成对象存储上的小文件、表里只放路径的原因——代价是几亿个小文件。

视频放进表里:Blob 列

episode 表要放三路视频,每个几十 MB。Lance 把这种大对象放在 Blob 列里:表里每一行只记一个很小的引用,视频字节放在单独的区域,读的时候按需流式读取,不会因为扫描别的列而把视频读出来。Blob 既可以把字节写进 Lance 自己的文件,也可以只登记对象存储上已有文件的位置——贴源层里的 MP4 不用搬,由 Lance 统一管理。episode 表长这样:

列

类型

例子

episode

字符串

ep-000123

机器人、任务、场景

字符串

arm-07,把杯子放进水槽,厨房

开始时间、帧数

时间、整数

2026-10-12 14:03:27,900

质检结论

布尔

通过

头部视频

Blob

→ s3://robot-ods/2026-10-12/ep-000123/head.mp4(21 MB)

左腕、右腕视频

Blob

→ 同目录下的 wrist_left.mp4、wrist_right.mp4

episode 向量

512 个 float32

[0.08, -0.12, 0.31, …](第 04 篇算出来)

帧要不要也存成视频?这是一个取舍。视频体积小,但上一篇说过,随机取一帧要从关键帧解起;逐帧存 JPEG 大好几倍,但每帧都能单独读。常见做法是:明细层存视频,省空间;训练用的热数据按训练帧率抽出来存成 JPEG,换取随机读的速度。

核心原理二:片段、清单和版本

Lance 表不是一个文件,而是一组文件加一份份清单:

  • 片段(fragment):一批行,比如 1000 条 episode。一个片段可以由几个数据文件组成,每个文件存这批行的一部分列。

  • 删除标记:删掉某几行时,不改数据文件,只记下「这个片段的第几行作废了」。

  • 清单(manifest):每次写入、加列、删除都会生成一份新清单,列出此刻这张表由哪些文件组成——每份清单就是一个版本。

  • 标签:给某个版本起个名字,比如 train-v1。旧文件不会被原地修改,所以旧清单一直能读出旧表的样子,直到被清理。

从「首次写入」开始,依次点下面的五步,看每一步写了哪些文件、每一版清单引用了哪些文件:

Lance 表的文件与版本追加、加列、删除、压缩:每一步写了哪些文件,每一版清单引用哪些文件。
  1. 首次写入
  2. 追加
  3. 加列
  4. 删除
  5. 压缩

每一步只写新文件,每一版清单都还能读

  1. 「追加一批」:只写新片段,旧文件一个字节都不动。

  2. 「加一列」:每个片段多一个只含新列的小文件,和原文件按行号对齐拼在一起读。几 MB 的 embedding 不必为了它重写几百 GB 的视频——这在机器人数据里是日常:模型一升级,所有标签和向量都要重算。

  3. 「删除两条」:只写一个很小的删除标记,读的时候跳过这两行。

  4. 「压缩整理」:删除标记多了会拖慢读取,压缩把相关片段重写干净。注意 train-v1 那一版仍然引用旧文件,所以用它训练的模型随时能复现,直到你主动清理旧版本。

核心原理三:向量索引,把「像不像」变成一次查询

最直接的办法是把查询向量和池子里每一个向量都算一遍距离。几十万条还行,到了上亿帧就太慢了。向量索引的思路和数仓的分区裁剪很像——先确定大概在哪儿,再在小范围里细找。Lance 内置的 IVF-PQ 索引分两层:


做什么

类比

IVF:分桶

先用聚类把全部向量分成几百个桶;查询时只看离查询向量最近的几个桶

分区裁剪:只扫可能命中的分区

PQ:压缩

把 512 维切成 16 段,每段用一个 0–255 的编号近似代替,一个 2 KB 的向量压成 16 字节,距离可以用查表快速估算

列存的字典编码

代价

结果是「近似」最近邻:可能漏掉个别真正最近的;多看几个桶、用原始向量复核,就能用速度换准确

采样估算 vs 精确计算

检索时还可以带过滤条件,比如「质检通过且不是重复」。先过滤再搜(预过滤),能保证返回的 k 条都满足条件;先搜再过滤,可能一条都不剩。

向量本身是怎么来的、为什么相似的画面向量相近,见上一篇的差异五。

和数仓的区别


Parquet + Hive / Iceberg

Lance

设计目标

扫描聚合

随机读取和多模态,扫描也不差

读一个值

读一整页

小值读一个小块,大值直接定位

视频、图片

通常存成小文件,表里放路径

Blob 列,可以内嵌,也可以只登记对象存储上的位置

加一列并填上值

Hive 要重写数据;Iceberg 加列只改元数据,但给历史数据填值仍要重写

只为新列写新文件

删除

Hive 重写分区;Iceberg v2 可以写删除文件

写删除标记

版本与回溯

Hive 靠分区快照;Iceberg 有快照和标签

每次提交一份清单,可以打标签

向量检索

需要另外的向量数据库

表里直接建向量索引,检索时可以先过滤

下一篇:Daft:让解码和推理流起来,计算这一侧怎样解决。

参考资料


全文评论

0

正在加载评论…