













Lance 是一个面向 AI/ML 数据的列式数据格式。它经常强调高性能随机访问、索引能力和多模态数据支持。乍看起来,这和我们对“列式格式”的传统印象有些冲突:列式格式通常擅长全量扫描、聚合分析,而随机访问往往被认为是行式存储或数据库索引的强项。
Lance 论文 Lance: Efficient Random Access in Columnar Storage through Adaptive Structural Encodings 想回答的正是这个问题:列式格式是否真的不适合随机访问?如果不是,问题到底出在哪里?Lance 又是如何在随机访问和全量扫描之间取得平衡的?
本文围绕这篇论文,解释 Lance 为什么要强调随机访问,以及这种能力在 AI 系统里到底用在哪里。
在 AI 检索系统里,常见链路并不是“扫完整张表”,而是:
索引找到候选 row ids
-> 随机回表读取 text / vector / metadata / blob
-> rerank 或返回结果
索引只回答“哪些行可能相关”。真正给下游模型或用户使用的,往往是这些行对应的完整内容,比如原始文本、标题、来源、权限信息、图片、音频、embedding 或业务 metadata。
如果底层文件格式随机访问很慢,那么即使向量索引或全文索引很快,最终返回结果仍然会被“回表读取”拖慢。
RAG 是最典型的场景。一次用户查询通常会经历:
用户 query
-> 生成 query embedding
-> vector index 找到 top-k chunk ids
-> 随机回表取 chunk text / source / metadata
-> rerank
-> 拼 prompt
-> LLM 生成答案
向量索引返回的可能只是 row id 和 distance。LLM 真正需要的是 chunk 文本、文档标题、URL、section、时间、权限等字段。这些 top-k chunk 在物理文件里通常并不连续,所以回表就是随机访问。
以图搜图、视频检索、商品搜索、素材检索也有类似模式:
输入图片或文本
-> encoder 得到 embedding
-> vector index 找相似 row ids
-> 回表读取缩略图 / 原图 blob / caption / 标签 / 商品信息
-> 返回结果
多模态字段通常很大,比如图片、视频帧、音频片段、embedding、OCR 文本。此时随机访问不仅要快,还要避免读取大量无关数据。
训练前后也经常需要“先找候选,再取原始样本”:
这些任务可能发生在数据清洗、active learning、hard negative mining、评测失败分析、数据集调试等阶段。索引负责定位候选,随机访问负责取回样本本体和上下文。
训练本身也可能需要随机访问:
DataLoader shuffle
-> 生成随机 row ids
-> 回表取 image / text / vector / label
-> 组成 mini-batch
这类随机访问不一定来自向量索引,但同样要求底层数据格式能高效读取分散样本。
LanceDB 是数据库/API 层,提供建表、向量检索、全文检索、过滤、rerank 等能力。Lance 是底层数据格式和表格式,负责把数据组织成适合 AI/ML workload 的文件布局。
可以简单理解为:
LanceDB:
负责用户 API、索引、查询、检索体验
Lance:
负责底层数据文件、随机访问、扫描、版本、结构编码
LanceDB 的典型查询链路是:
vector / FTS / scalar index 找到 row ids
-> Lance 文件随机读取字段
-> 返回结果或进入 rerank
因此,Lance 的随机访问能力直接支撑 LanceDB 的检索体验。
论文提出了一个关键视角:列式格式的性能不仅由压缩算法决定,还由 structural encoding 决定。
可以把列式编码分成两层:
structural encoding:
决定嵌套结构、null、offset、repetition/definition 等结构信息如何落盘
compressive encoding:
决定具体值如何压缩,比如 bit packing、FSST、dictionary、LZ4
随机访问时,真正影响性能的是:
这也是 Lance 论文最重要的观点:列式格式不是天然不能随机访问,但结构编码如果不合适,随机访问会非常昂贵。
Parquet 会把嵌套结构 flatten 成 leaf columns。每个 leaf column 包含 values、repetition levels、definition levels,并按 page 组织。
随机访问时,Parquet 依赖 page offset index:
row id -> 找到所在 page -> 读取整个 page -> 解码目标值
这套机制有明显优点:
但它也有权衡:
论文并不是说 Parquet 一定慢。相反,Parquet 调优后随机访问可以比默认配置快很多。问题在于,这种性能依赖配置,并且面对 AI 数据里常见的大字段、多类型、嵌套结构时,配置权衡会变复杂。
Arrow 的内存布局非常适合内存计算。它通常会把数据拆成:
在内存里,访问这些 buffer 成本很低。但在磁盘或对象存储上,一个逻辑值可能需要多次 I/O。
以 List<String> 为例,读取第 10 行可能需要:
目标:读取第 10 行的 List<String>
┌──────────────────────┐
│ list validity bitmap │ 判断 row 10 是否为 NULL
└──────────┬───────────┘
v
┌──────────────────────┐
│ list offsets │ 读取 offsets[10], offsets[11]
└──────────┬───────────┘
v
┌──────────────────────┐
│ string validity │ 判断这些 string item 是否为 NULL
└──────────┬───────────┘
v
┌──────────────────────┐
│ string offsets │ 得到每个 string 的 byte 范围
└──────────┬───────────┘
v
┌──────────────────────┐
│ string data bytes │ 读取真正的字符串字节
└──────────────────────┘
关键问题是依赖链:
不知道 list offsets -> 就不知道要读哪些 string offsets
不知道 string offsets -> 就不知道要读 string data 的哪段字节
所以它不是一次随机读,而是多阶段随机读。嵌套越深,类似的 validity / offsets 链条越长。
这就是为什么 Arrow 是优秀的内存格式,但不适合作为复杂嵌套数据的直接磁盘随机访问布局。
Lance 2.1 的目标很明确:
它的核心思想是:把一个列值所需的结构信息和值信息尽量放近,避免 Arrow 那种多 buffer、多阶段读取,同时又不能为了随机访问牺牲扫描性能。
Full Zip 用于大字段,比如:
假设有一个简化的 List<String> 列:
row 0 = ["AB", "C"]
row 1 = NULL
row 2 = []
row 3 = ["DE"]
Full Zip 更像把结构信息和值按 value 粒度交错排布:
Full Zip buffer, simplified:
┌────────────┬────────┬──────────────┐
│ control │ length │ value bytes │
├────────────┼────────┼──────────────┤
│ row0/new │ 2 │ "AB" │
│ row0/cont │ 1 │ "C" │
│ row1/null │ - │ - │
│ row2/empty │ - │ - │
│ row3/new │ 2 │ "DE" │
└────────────┴────────┴──────────────┘
┌─────────────────────────────────────┐
│ repetition index │
│ row0 -> offset 0 │
│ row1 -> offset of row1/null control │
│ row2 -> offset of row2/empty control │
│ row3 -> offset of row3/new control │
└─────────────────────────────────────┘
读取 row 3:
1. 读 repetition index,找到 row3 对应的 byte offset
2. 读 Full Zip buffer 中 row3 附近的 control + length + bytes
Full Zip 的直觉是:
row id -> repetition index -> 目标值附近的一段连续 bytes
它适合大字段,因为每个 value 本来就很大,比如 3KiB vector 或 20KiB image。control word、length 等额外信息的成本相对很小,但能显著减少随机读取路径上的分散 buffer 访问。
repetition index 可以理解成:
row id -> 这个 row 在 Full Zip buffer 里的起始 offset
它主要解决变长 / 嵌套数据没法直接用乘法定位的问题。
固定宽度列可以这样算:
offset = row_id * fixed_width
但 List<String> 不行,因为每一行长度不同:
row 0 = ["AB", "C"] # 两个字符串
row 1 = NULL # 没有字符串内容
row 2 = [] # 空 list
row 3 = ["DE"] # 一个字符串
所以 Full Zip 需要一个辅助索引:
repetition index:
row 0 -> offset 0
row 1 -> offset 11
row 2 -> offset 12
row 3 -> offset 13
为什么叫 repetition?因为嵌套 list 会把一行展开成多个底层 value:
row 0 = ["AB", "C"]
展开后:
"AB" -> row0 的第一个元素
"C" -> row0 的后续元素
系统需要知道哪些 value 是新 row / 新 list 的开始,哪些 value 只是当前 list 的延续。repetition level 记录这种结构状态,而 repetition index 则把这些结构状态整理成可随机定位的 offset 表。
Miniblock 用于小字段,比如 int、date、小 string。
Miniblock 更像 Parquet page:把一组 values 放进一个小块,块内仍然保留多个 buffer。随机访问某个值时,先定位 chunk,再把整个 chunk 读出来解码。
Miniblock chunk, simplified:
┌──────────────────────────────────────────────┐
│ chunk header │
│ - number of buffers │
│ - size of each buffer │
├──────────────────────────────────────────────┤
│ repetition levels buffer │
│ row0/new, row0/cont, row1/null, ... │
├──────────────────────────────────────────────┤
│ definition levels buffer │
│ valid, valid, null, empty, valid │
├──────────────────────────────────────────────┤
│ string lengths / offsets buffer │
│ 2, 1, 2 │
├──────────────────────────────────────────────┤
│ string data buffer │
│ "AB" "C" "DE" │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────┐
│ miniblock metadata / search cache │
│ row range -> chunk byte range │
└──────────────────────────────────────┘
读取 row 3:
1. 读 search cache,知道 row3 在哪个 miniblock chunk
2. 读整个 miniblock chunk
3. 在内存里解码 chunk,取出 row3
Miniblock 的直觉是:
row id -> chunk metadata -> 读取整个小块 -> 内存中解码目标值
它适合小字段,因为每个 value 很小。如果对小字段也使用 Full Zip,按 value 一个个处理会带来过高 CPU 开销。Miniblock 可以批量解码,对扫描更友好。
| 维度 | Full Zip | Miniblock |
|---|---|---|
| 适合数据 | 大字段,如 vector、image、large text | 小字段,如 int、date、小 string |
| 布局直觉 | 每个 value 附近放 control + value | 一个小 chunk 里放多组 buffers |
| 随机读取 | 定位 value offset 后读目标值附近 | 读整个 chunk,再解码目标值 |
| I/O 放大 | 较小,尤其适合大字段 | 有 chunk 级 read amplification |
| 扫描性能 | 小字段上 per-value unzip 成本较高 | 批量解码,对扫描更友好 |
| search cache | full zip 本身不依赖 page offset cache | 需要 chunk metadata |
一句话总结:
Full Zip = 为大字段减少随机读取路径上的分散 buffer 访问。
Miniblock = 为小字段保留批量解码效率,接受小范围读放大。
Struct packing 是另一个有意思的设计点:把 struct 的多个字段作为一个整体存储,而不是完全拆成 leaf columns。
它的收益是,当随机访问经常需要整个 struct 时,可以减少 I/O 次数。例如搜索结果常常需要同时取 title、text、metadata,那么把相关字段打包可能更适合随机回表。
代价是列裁剪能力下降。如果扫描时只需要 struct 中一个字段,也必须读整个 struct。
这说明 Lance 并不是把“列式”和“行式”看成绝对对立,而是在文件格式层面提供更细粒度的布局选择。
论文的实验比较了 Lance 2.1、Lance 2.0 的 Arrow-style layout,以及 Parquet。
随机访问方面:
压缩方面:
全量扫描方面:
Struct packing 方面:
论文不是说 Parquet 一定慢。相反,Parquet 调优后随机访问可以很强。
Lance 相比 Parquet 的最大优势在于:它用 adaptive structural encodings,在随机访问、全量扫描、RAM/search cache、复杂嵌套数据之间取得更稳定的平衡。
具体体现为:
可以这样概括:
Parquet 是优秀的分析列式格式,调优后也能随机访问。
Lance 是面向 AI/检索 workload 重新设计结构编码的列式格式,
目标是同时服务 scan 和 random access,
尤其适合 vector / RAG / multimodal 回表场景。
Lance 之所以强调随机访问和索引能力,是因为 AI 系统越来越多地采用“索引召回 + 随机回表 + rerank/生成”的模式。这个模式同时需要:
传统列式格式主要为分析扫描优化,而 Lance 试图证明:通过更合适的结构编码,列式格式也可以成为 AI 检索和多模态数据管理的底层格式。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。