上一篇列了多模态数据的六处差异,其中三处落在存储上:训练要随机抽样,表结构天天变、训练集要能复现,「像不像」成了一种查询。数仓的 Parquet + Hive / Iceberg 为扫描聚合设计,这三件事正好都不擅长。这一篇讲 Lance 怎样解决,它同时是文件格式和表格式,可以先把它理解成「为随机读和多模态重新设计的 Parquet + Iceberg」。
例子沿用机器人数据:帧表(一个相机时刻一行)和 episode 表(一条 episode 一行,带着三路视频)。
先看训练是怎么读数据的
存储怎么设计,取决于谁来读、怎么读。训练机器人策略时,数据加载器每一步要做的是:把全部样本打乱,随机抽出一批(比如 256 个),每个样本再取最近几帧画面、当前关节角和接下来一小段动作。切到「训练样本」看一个样本到底要读什么:
- 原始消息
- 对齐明细
- 汇总与标签
- 训练样本
episode「把杯子放进水槽」· 从第 12.0 秒的「训练样本」看起
一个样本要读 9 张图(三路相机 × 三个时刻)、1 行关节角、16 行动作,而且这些行在整个数据集里是随机分布的;一个 epoch 要把几百万帧都这样读一遍。数仓格式擅长的恰好相反:顺序扫描、只读几列、大块压缩。
核心原理一:列式文件的最小读取单位
Parquet 和 Lance 都是列式格式,同一列的数据放在一起,这一点和数仓的经验一样。区别在于:为了交出一个值,最少要读多少字节。
Parquet | Lance(2.1 文件格式) | |
|---|---|---|
文件怎么组织 | 文件 → 行组(通常每组几十到几百 MB)→ 列块 → 页(默认约 1 MB) | 文件 → 列 → 按值的大小选不同的布局 |
最小解码单位 | 一页:压缩和编码以页为单位,要读一个值就得解开整页 | 小值(不到 256 字节):一个 mini-block,压缩后不到 32 KB |
大值怎么存 | 和小值一样放在页里,一页能塞几张图就塞几张 | full-zip 布局:先查一次位置索引,再只读这个值本身 |
随机读一个值的代价 | 一次请求,读一整页(有页索引时);没有页索引要读整个列块 | 小值一次请求读一个小块;大值两次请求、几乎只读值本身 |
适合 | 扫描聚合:顺序读、压缩率高 | 随机点查,同时扫描也不差 |
下面的实验比较随机读一批帧时,两种格式各要从存储里读多少字节、发多少次请求。条形图是对数刻度。
- 随机抽 256 行
- Parquet 读整页
- Lance 按值读取
状态 + 图像 · 每帧图像 60 KB
默认:每帧图像 60 KB,Parquet 的页是 1 MB。为了一张图和一行状态,Parquet 要把它们各自所在的整页读进来,总共放大三十多倍;Lance 对图像用 full-zip 布局直接定位,只放大一点五倍左右,多出来的主要是状态列的 mini-block。
切到「只读状态」:每行状态只有 128 字节,Parquet 为它读进 1 MB 的页,放大几千倍;Lance 读一个 32 KB 的小块,同样有放大,但比 Parquet 小三十多倍。
把 Parquet 页大小调到 64 KB:放大降下来了。但页越小,压缩率和扫描性能越差——Parquet 只能在两种读法之间取舍。
把「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。旧文件不会被原地修改,所以旧清单一直能读出旧表的样子,直到被清理。
从「首次写入」开始,依次点下面的五步,看每一步写了哪些文件、每一版清单引用了哪些文件:
- 首次写入
- 追加
- 加列
- 删除
- 压缩
每一步只写新文件,每一版清单都还能读
「追加一批」:只写新片段,旧文件一个字节都不动。
「加一列」:每个片段多一个只含新列的小文件,和原文件按行号对齐拼在一起读。几 MB 的 embedding 不必为了它重写几百 GB 的视频——这在机器人数据里是日常:模型一升级,所有标签和向量都要重算。
「删除两条」:只写一个很小的删除标记,读的时候跳过这两行。
「压缩整理」:删除标记多了会拖慢读取,压缩把相关片段重写干净。注意 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:让解码和推理流起来,计算这一侧怎样解决。
参考资料
Lance 文件格式规范:编码:mini-block 每块压缩后不到 32 KiB;超过 256 字节的值用 full-zip 布局,随机读一个值要两次请求。
Lance 表格式规范:Blob v2:Blob 可以写进 Lance 自己的文件,也可以只引用外部对象存储上的文件。
全文评论
0正在加载评论…