lakehouse/21
已用本机实测说明:同一批 768 维向量,Parquet 随机取 100
行与全扫同量级,Lance take
低三个数量级。该篇把「专用向量引擎」留给本系列。本文回答两个更具体的问题:Lance
的表格式规范里,一条写入具体落在哪个文件、由谁记录版本;以及湖侧
snapshot 与 Milvus
在线引擎的可见性水位如何对齐,而不是停在「Lance
是格式、Milvus 是引擎」这句定性描述上。
本文是「向量检索引擎」系列第 16 篇(共 19 篇)。→ 系列目录
版本锚定:湖侧数字引用 lakehouse/21(PyArrow 24 / Lance 7.0.0 本机中位数);Lance 表格式与索引机制引用官方 Table Format、Layout、Vector Indexes(A 级)。不在此重跑实验,除非主版本跨越需更新 lakehouse/21。
一、三层不要混叫「向量库」
| 层 | 代表 | 解决什么 |
|---|---|---|
| 列式/向量友好格式 | Lance | 随机行寻址、可选 ANN 索引随数据集、版本 |
| 嵌入/本地库 | LanceDB 等 | 在格式之上提供检索 API,常同进程或轻量服务 |
| 专用分布式引擎 | Milvus(本系列)、Qdrant(第 15 篇) | 持续写入、一致性、多租户、集群生命周期 |
Lance 首先是 存储格式(可对标 Parquet 的定位差异),不是「另一个 Milvus」。LanceDB 把检索 API 叠在格式上,仍通常假设 数据以数据集文件为中心,而不是 Proxy–Worker–对象段状态机。
二、最小故事:同一批 embedding,写入两套系统分别经过什么
设想同一批 10 万条 768 维向量,一份写进 Lance 数据集,一份写进 Milvus collection。
写进 Lance:调用方把这批向量组织成 Arrow
Table,lance.write_dataset 或增量
insert 会把数据切成一个或多个新的
fragment(每个 fragment
是若干列式数据文件,外加可选的删除文件);写完后生成一个新的
manifest,其中记录完整
schema、这个版本包含的全部 fragment
列表、一个单调递增的版本号,以及可选的索引元数据引用。manifest
文件落在数据集根目录的 _versions/
下,用支持原子提交的命名方案(例如反转版本号的 V2
命名,便于在对象存储上 \(O(1)\) 找到最新版本而不用 LIST
全部历史版本)。这一步一旦提交成功,这批向量就是数据集某个具体版本的一部分,可以立即读到。
写进 Milvus:同一批向量经 Proxy 拆包,进 Streaming Node 的 WAL,提交后应用到 Growing Segment——这一步之后即可被增量查询路径搜到(第 3、5 篇),但要等 flush 才落对象存储变成 Sealed,再要等 Data Node 建索引、Coordinator handoff,才进入「有索引、被 Query Node 服务」的状态。
两条路径的关键差别不是「哪个更快」,而是可见性单元不同:Lance
的「可见」以数据集版本(manifest)为粒度——一次写入提交后,整个数据集原子性地进入新版本,读者可以选择读最新版本或任意历史版本;Milvus
的「可见」以一致性级别裁剪出的水位为粒度(第
12
篇),同一时刻不同客户端按不同一致性级别可能看到不同的「已提交」集合,且「能搜到」与「索引已就位」是两次独立交付(第
10 篇)。Lance 没有类似 Growing/Sealed
那种「先能搜、后有索引」的中间态——一个 fragment
一旦提交进某个 manifest
版本,它对该版本的读者就是完整可见的普通数据;只是这批新数据默认还没有被纳入已训练好的
ANN 索引(下一节展开),走 ANN 搜索时可能需要一次
optimize 或重建索引才被覆盖,纯 scan/filter
访问则不受影响。
三、Lance 表格式:fragment、manifest、version
官方 Table Format 规范把数据集定义为 fragment、数据文件、删除文件与索引的版本化集合,每个版本由一份不可变的 manifest 描述:
| 概念 | 官方定义要点 |
|---|---|
| Manifest | 描述数据集的一个版本:完整 schema(含嵌套字段)、该版本包含的 fragment 列表、单调递增的版本号、可选的索引元数据引用 |
| Fragment | 数据集的一次水平分区:一个或多个存列数据的文件,加一个可选的删除文件;fragment id 在数据集内唯一,按当前最大 fragment id 递增分配 |
| Deletion file | 记录该 fragment 内被删除的行位置;每个 fragment 每个版本最多一个删除文件 |
| Index | 与某个版本关联的索引元数据,实际索引内容存在
_indices/{UUID}/ 下 |
数据集根目录的物理布局(官方 Layout)把这些概念对应到具体路径:
{dataset_root}/
data/ -- *.lance 数据文件,存列数据
_versions/ -- *.manifest,每个版本一份;命名方案支持 O(1) 定位最新版本
_transactions/ -- *.txn,提交协调用的事务文件
_deletions/ -- *.arrow 或 *.bin,删除向量文件
_indices/ -- {UUID}/,每种索引类型的内容各自组织
_refs/ -- tags/、branches/,命名版本与分支元数据
3.1 删除文件的两种格式与「软删的代价」
Table Format
规范明确删除文件有两种存储格式:Arrow
IPC(.arrow,存一个稀疏的行偏移数组,适合稀疏删除)与
Roaring
Bitmap(.bin,压缩位图,适合密集删除)。读者在扫描该
fragment 时需要过滤掉出现在删除文件里的行偏移。
规范同时点明一个关键代价:删除可以通过重写数据文件、物理移除已删行来「物化」,但这会使行地址失效,并要求重建索引,代价可能很高。这与
Milvus 的软删机制(第 8、13
篇)是同一类工程权衡——先用一个轻量的可见性标记(Roaring
Bitmap 或
bitset)把删除变成「查询时跳过」,而不是立刻重写底层结构;只有在后台整理(Lance
的 optimize/重写,Milvus 的
compaction)时才真正回收空间,并且这一步都要付出重建索引的代价。两边的差异在于触发时机与调用方——Lance
的物化通常是用户显式调用维护操作,Milvus 的 compaction 由
Coordinator 按策略自动调度(第 10 篇)。
3.2 版本、tag 与分支
_refs/ 下的 tags/ 与
branches/ 让 Lance
数据集支持给某个版本号打一个人类可读的名字,或者从某个版本拉出一条独立的写入分支——这类似给一个不可变版本号附加语义标签,方便后续引用而不用记住数字版本号。本篇不展开分支写入的合并语义(非本系列覆盖的湖侧运维细节),只强调一点:manifest
版本号本身已经是数据集状态的完整快照标识,tag
只是给它起了别名。
3.3 把 fragment 与 version 的演进摆成一张表
光看概念定义容易忘记 fragment id 和 version 号是两条互相独立递增的编号。用一段连续的写入操作把两者的演进具体化:
| 操作 | Fragment 变化 | Manifest 版本 |
|---|---|---|
初始 write_dataset(写入 3 个
fragment) |
新增 fragment 0、1、2 | version 1:包含 fragment {0,1,2} |
增量 insert 一批新数据 |
新增 fragment 3(id 按当前最大值 +1 分配,不重用旧 id) | version 2:包含 fragment {0,1,2,3} |
对 fragment 1 执行 delete |
fragment 1 关联一个新的删除文件,fragment 本身不变 | version 3:包含 fragment {0,1,2,3},fragment 1 的删除文件引用更新 |
触发 optimize 物化删除、合并小
fragment |
fragment 1 被重写(若物化)或被合并进新 fragment 4,旧 fragment 1、可能还有 0 被标记不再属于新版本 | version 4:包含新的 fragment 集合,例如 {2,3,4} |
这张表想说明两件事:fragment id 一旦分配就不会因为删除或合并而被重新利用(版本 4 里即便 fragment 0、1 已经不在当前版本的 fragment 列表里,它们的 id 依然是历史上分配过的,不会被后续新 fragment 复用);每一次会改变数据集内容的操作,都会产生一个新的 manifest 版本,即便这次操作只是标记删除而没有物理重写任何数据文件。这与 Milvus 的 segment id 分配(第 3 篇)逻辑上同构——都是”只增不重用”的编号策略,方便任何历史版本都能通过 id 精确定位到对应的物理对象,不会因为 id 复用产生歧义。
四、索引:IVF_PQ/HNSW 随数据集异步管理
Lance 官方索引文档(Vector Indexes、pylance Indexing and Searching)给出的机制要点:
- 索引不是默认创建的:LanceDB OSS
需要显式调用
create_index();托管版本可以自动推断向量列并创建优化过的IVF_PQ索引。 - 索引构建是异步的:
create_index()调用会立即返回,真正的构建在后台进行;如需等待构建完成,可传入wait_timeout。 - 支持的索引族:
IVF_PQ(默认)、IVF_HNSW_FLAT、IVF_HNSW_PQ、IVF_HNSW_SQ、IVF_SQ、IVF_RQ等,按召回/延迟/压缩目标切换。 - 训练参数:
num_partitions控制 IVF 粗分区数,num_sub_vectors控制 PQ 子向量数(压缩粒度);GPU 加速(accelerator="cuda")目前只支持IVF_PQ的分区训练,其它索引类型指定 accelerator 会退回 CPU 构建。 - 再次调用会替换旧索引:
create_index()用不同参数再调用一次,会替换掉该列已有的索引,不是叠加多份。
「索引构建异步、与数据写入解耦」这一点与 Milvus Data Node 对 Sealed segment 异步建索(第 8、10 篇)同构:两边都不要求「写完立刻可用最优索引」,都允许「数据已可读(或已可搜)、索引还在后台构建」的中间态存在。差异在于索引的作用范围:Lance 的索引是整个数据集版本级别的对象(构建时基于当时的全部 fragment 训练),新增数据默认不会自动纳入已有索引,需要新一轮索引维护;Milvus 是每个 Sealed segment 一份独立索引(第 8 篇),新数据只需要为新产生的段单独建索引,不必重新训练整个 collection 的索引。这个差异直接决定了两边「新写入数据多久能被 ANN 索引覆盖」的运维模型不同:Lance 更接近「批量重建」节奏,Milvus 更接近「持续滚动」节奏。
五、lakehouse/21 已钉住的结构性事实
环境与口径见原文;此处只复述结论骨架:
| 指标 | Parquet | Lance |
|---|---|---|
顺序扫 vec |
1283.7 ms | 430.0 ms |
| 随机 take 100 行 | 1258.5 ms | 1.2 ms |
原文要点:
- Lance 用稳定行寻址,使
take(row_ids)接近点查而非扫滤。 - 可原生挂 IVF-PQ / HNSW 等 ANN,索引随数据集管理(第四节已展开异步构建细节)。
- 自带 dataset versioning(第三节已展开 manifest/fragment 结构);与 Iceberg 主数据湖可并存,用 id + snapshot 对齐。
- 湖上 ANN 与表格式元数据如何统一 仍是开放问题(Iceberg Puffin 等装不下完整图索引)。
这些数字是 微基准、本地盘、随机向量,不代表生产对象存储吞吐;结构性差异(随机点取)由格式设计决定。
六、版本/snapshot 对齐:Lance manifest version 与 Milvus GuaranteeTs
三套系统各自维护一条独立的「版本轴」:Iceberg/Parquet 主表用 snapshot id,Lance 数据集用 manifest 版本号,Milvus 在线引擎用 GuaranteeTs / 一致性水位(第 12 篇)。它们互不感知对方的版本号,架构上常见的对齐方式是都携带同一个业务 id 与时间戳,靠应用层拼接:
flowchart LR
subgraph iceberg ["Iceberg/Parquet main table"]
snap["Snapshot id N<br/>row: business_id, feature, ts"]
end
subgraph lance ["Lance vector subset"]
manifest["Manifest version M<br/>fragment: business_id, vec, ts"]
end
subgraph milvus ["Milvus online engine"]
guaranteeTs["GuaranteeTs watermark<br/>segment: business_id, vec, ts"]
end
snap -->|"same business_id + ts"| manifest
manifest -->|"ETL / sync job"| guaranteeTs
snap -.->|"same business_id + ts"| guaranteeTs
对齐逻辑落地时要回答三个问题:
- 同一批数据在三边的时间戳是否来自同一次业务事件(例如同一次 embedding 生成任务),而不是各自系统各记各的写入时刻。
- 从 Lance 同步到 Milvus 的任务是否是 exactly-once——如果同步失败重试,Milvus 侧是否会出现重复向量或漏同步的向量,这需要业务 id 上的幂等写入(第 13 篇 upsert)配合。
- 版本回退时三边是否能一起回退——Iceberg snapshot 可以时间旅行,Lance manifest 版本也可以 checkout 到历史版本,但 Milvus 的 GuaranteeTs 水位只能前进不能后退(一致性模型不支持「回到过去某个可见集合」),这意味着三边对齐不是对称的:湖侧可以回看历史,在线引擎默认只服务当前水位之后的状态。
这一节要传达的核心结论是:不存在一个自动生效的统一版本号,湖侧两套系统(Iceberg snapshot、Lance manifest version)与在线引擎(Milvus GuaranteeTs)各自演进,对齐永远是应用层的显式设计,通常靠「同一业务 id + 同步任务记录的时间戳/版本号映射表」实现,而不是某种协议层面的自动版本传播。
七、与 Milvus 引擎的边界
| 问题 | Lance / 湖侧 | Milvus 引擎 |
|---|---|---|
| 主数据是否已在湖上 | 天然契合 | 需 ETL/同步进 Collection |
| 在线 QPS、多租户、一致性级别 | 弱于专用引擎产品面 | 第 12–14 篇专长 |
| 持续高写入 + 实时可搜 | 取决于 LanceDB/服务封装;索引默认异步、批量重建节奏 | Growing + WAL,段级持续滚动更新索引(第 5、8 篇) |
| 随机取训练样本 / 特征 | 强项(fragment + 稳定行寻址) | 杀鸡用牛刀 |
| 十亿级在线检索 + 过滤 | 常仍要专用引擎或分层 | 本系列主线 |
| 删除/软删的物化代价 | 重写 fragment + 重建索引(第 3.1 节) | compaction + 建索队列(第 10、13 篇),机制同构 |
架构上常见分层(lakehouse/21 已写),把职责边界画成图更容易在做架构评审时对齐——每一层只对自己框内的问题负责,跨过边界的问题(例如「湖侧向量多久同步到在线引擎」)必须由应用层显式设计,不会自动解决:
flowchart TB
subgraph lakeside ["Lake side: format owns version & random access"]
iceberg["Iceberg/Parquet<br/>main table + features<br/>snapshot id"]
lance["Lance dataset<br/>fragment + manifest<br/>row take, optional IVF_PQ/HNSW"]
end
subgraph onlineside ["Online engine: Milvus owns serving lifecycle"]
segment["Segment: Growing/Sealed<br/>WAL, GuaranteeTs"]
knowhere["Knowhere<br/>per-segment ANN index"]
filterEngine["Bitset filter + consistency<br/>见第 11、12 篇"]
end
iceberg -->|"same business id"| lance
lance -->|"ETL / sync job, not automatic"| segment
segment --> knowhere
segment --> filterEngine
图中箭头「ETL / sync job, not automatic」是这条边界最重要的标注:没有协议层面的自动同步,湖侧格式与在线引擎各自演进,谁负责同步、多快同步、失败怎么重试,都是应用层的显式设计(第六节继续展开版本对齐的三个具体问题)。
八、何时可以「不上向量集群」
同时满足时,优先考虑 Lance(± LanceDB)而不是先上 Milvus:
- 向量主要服务 训练、批推理、离线召回评测,不是高并发在线;
- 已有湖仓治理,要 同一 snapshot 复现;
- 访问模式以 按 id 取向量 或中小规模 ANN 为主,且能接受索引批量重建的节奏;
- 团队运维预算不允许 etcd/对象段/多 Worker。
一旦出现:多租户在线 QPS、强 Session/Strong 可见性、独立建索队列与读写隔离、新数据需要持续滚动纳入 ANN 索引而非批量重建——回到 Milvus/Qdrant(第 15、18 篇)。
九、常见误解
误解一:Lance 的 dataset versioning 和 Milvus 的一致性级别是同一层面的东西,可以互相替代。 Lance 版本是数据集整体的离散快照,读者选择读哪个版本号,语义接近「时间旅行」;Milvus 一致性级别控制的是同一份持续演进数据的可见性水位裁剪,是连续的、只能前进的(第六节 3 点)。两者解决的是不同问题:一个给你「回到过去某个完整状态」,一个给你「在当前状态里愿意等多久换取多新鲜」。
误解二:Lance 支持向量索引,所以往 Lance 里灌数据后 ANN 检索会像 Milvus 一样「持续新鲜」。 第四节已说明索引构建异步且以数据集版本为整体训练单元,新增 fragment 默认不会自动进入已有索引;要覆盖新数据通常需要重新触发索引构建。这与 Milvus 「每个新 Sealed segment 单独建索引、持续滚动」的模型不同,直接套用 Milvus 的运维直觉(「写完等一下就有索引」)会在 Lance 上落空。
误解三:Lance 的软删(deletion file)和 Milvus 的 bitset 软删完全等价,选哪个都行。 机制相似(都是「先标记、后台物化」),但触发方式不同:Lance 的物化通常需要用户显式发起维护操作,且规范明确物化会使行地址失效并要求重建索引;Milvus 的 compaction 由 Coordinator 按策略自动调度(第 10 篇),运维介入程度不同,容量规划时不能假设两边的空间回收节奏一致。
误解四:fragment id 和 manifest 版本号是同一个计数器,看到 fragment id 大概能推算出数据集的版本号。 3.3 节的表格已经说明这是两条互相独立递增的编号:fragment id 只在有新数据写入或重写发生时才分配新值,manifest 版本号在任何改变数据集内容的操作(包括只打删除标记、不新增物理文件)后都会递增。同一个版本号下可能引用一批 id 跳跃很大的 fragment(因为中间有过多次合并与物化),不能从 fragment id 反推版本号,两者要分别读取。
十、学术谱系与开放问题
10.1 谱系
| 层 | 代表 | 本篇角色 |
|---|---|---|
| 列式格式的不可变文件 + 增量 | Parquet row group/page(对照 lakehouse 系列) | Lance 用 fragment/manifest 做出不同的随机访问权衡 |
| 版本化元数据 | Iceberg snapshot/manifest 谱系(lakehouse 系列已覆盖) | Lance manifest 是同一类思想在向量友好格式上的实现 |
| 软删 + 后台整理 | O’Neil et al., LSM-Tree, Acta Informatica 1996(第 10 篇已引) | Lance deletion file 物化代价与 Milvus compaction 同构 |
| 向量索引随数据集管理 | IVF-PQ(Jégou et al.)、HNSW(Malkov & Yashunin, TPAMI 2020) | 算法细节外链 frontier/08;本篇只钉训练/构建的工程时机 |
Lance 与 Iceberg/Parquet 共享「不可变文件 + 版本化 manifest」的谱系起点,但为「按行随机访问」这一新目标重新设计了物理布局(细粒度 fragment、稳定行地址),这是本系列反复出现的模式——同一类工程问题(不可变性 + 后台整理)会在不同的访问模式假设下长出不同的具体结构(对照第 3 篇 Growing/Sealed 与 LSM MemTable/SST 的类比)。
10.2 开放问题
- Lance 索引与 Iceberg
事务提交如何原子对齐?——两边各有自己的提交协议(Lance
_transactions/事务文件 vs Iceberg 的原子性 snapshot 替换),跨系统的原子性目前依赖应用层协调,没有统一协议。 - 对象存储上 Lance 随机 take 的 API 次数与缓存策略(对照第 6 篇 Milvus 对象寻址代价)——lakehouse/21 的 1.2 ms 是本地盘结果,对象存储上的随机 take 延迟与请求次数尚无本系列可引用的实测。
- 从 Lance 增量同步到 Milvus 的 exactly-once 边界,以及同步延迟窗口内两边 ANN 索引覆盖率不一致时,查询侧该如何声明这一批数据「湖侧可查、在线引擎暂不可搜」的过渡态。
- Lance 索引批量重建的节奏与 Milvus 持续滚动建索的节奏,是否存在中间形态(例如增量索引合并到已有索引,而非整体重训),目前官方文档未展开。
十一、小结
三句话小结:Lance 补的是 湖侧随机访问与可选 ANN,manifest + fragment 的版本化设计让「回到某个历史快照」和「按行 O(1) 取值」成为一等能力,但索引构建以数据集版本为单位、默认异步且不自动覆盖新增数据;Milvus 补的是 在线引擎生命周期,索引按 Sealed segment 持续滚动构建,可见性由 GuaranteeTs 连续裁剪而非离散快照;两边的软删物化都要付出重建索引的代价,只是触发方与调度自动化程度不同。实测口径以 lakehouse/21 为准。
下一篇:生产排障。
参考资料
- lakehouse/21 湖上 AI 与向量(本机 Lance vs Parquet 实测)。
- Lance Documentation, Table Format(manifest、fragment、deletion file 的规范定义,Arrow IPC / Roaring Bitmap 两种删除文件格式,物化代价)。
- Lance Documentation,
Layout(数据集根目录结构:
data/、_versions/、_transactions/、_deletions/、_indices/、_refs/)。 - LanceDB Documentation, Vector Indexes;pylance
Documentation, Indexing and
Searching(
create_index异步构建、IVF_PQ/IVF_HNSW_*索引族、num_partitions/num_sub_vectors、GPU 仅加速 IVF_PQ 训练)。 - O’Neil, P. et al., The Log-Structured Merge-Tree (LSM-Tree), Acta Informatica 33(4), 1996(软删 + 后台整理谱系;经第 10 篇引入)。
- 本系列第 1、3、6、8、10、12、13 篇;系列 index。
返回 系列目录 | 上一篇:Qdrant 对照 | 下一篇:生产排障
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【向量检索引擎】对象存储上的 Segment 布局:快照、索引与寻址代价
以 flush 后去对象存储里查看文件为线索,拆解 Milvus 2.6.x 的对象路径结构(insert_log/delta_log/stats_log/index_files)、V1 按字段拆分与 V2 按段整合 Parquet 的寻址代价差异,并给出官方文档中可核对的 API 调用量级引用。
【向量检索引擎】选型与阅读地图:决策树、RAG 回链与开放问题
扩展选型决策树:从单机原型到十亿级多租户,逐层加入湖上格式、SQL 同进程、存算分离运维、多一致性级别四个判断轴;用一个团队规模演进的最小故事串起决策点,并回链 llm-infra RAG 与本系列全部核心论文谱系。
【向量检索引擎】向量引擎全景:算法、RAG 与专用引擎之间的一层
定位专用向量检索引擎相对 ANN 算法、RAG 应用与湖仓格式的分工;以 Milvus 2.6.x 四层架构与 insert/search 最小故事建立坐标系,并交代从 SIGMOD 2021 到 Streaming 演进的谱系与常见误解。
【向量检索引擎】ANN 算法工程接口:从 HNSW/IVF 到 Knowhere 契约
把 HNSW、IVF、DiskANN、Flat 收成引擎侧 Train/Build/Load/Search 契约与构建期/查询期参数面;用生命周期图与召回–QPS–内存三角说明索引如何贴着 Segment,并与 db-frontier/08、第 8 篇 Knowhere 分工。