Bering Note – formerly 流沙河鎮

情報技術系のこと書きます。

Lance / LanceDBとは何か


マルチモーダル AI 向けのデータフォーマット Lance と、その上に構築されたデータベース LanceDB についてまとめます。AI/ML や検索に関わる要件を Parquet や Avro、それらの上に成り立つ Iceberg で扱うことに限界を感じていたところ、Lance がそうした要件にフィットすることに気づき、調べた内容を整理しました。Lance は Iceberg を置き換えるものとは限らず、カタログの共有や将来的なファイルレベルの統合など、組み合わせて使う道も開かれています。

はじめに

LLM や画像生成モデルの普及にともない、「データ活用」の要件は更なる多様化を遂げています。従来の分析基盤が扱ってきたのは数値や文字列からなるテーブルデータが中心でした。一方で、AI ワークロードでは embedding(ベクトル表現)、画像、動画、音声といったマルチモーダルデータをテーブルデータと一緒に管理したいという要求が生まれます。

このとき課題の 1 つになるのが、アクセスパターンの相性です。現在のデータレイクは、列指向のファイルフォーマット(事実上の標準は Parquet)の上に、IcebergDelta Lake といったテーブルフォーマットを重ねる構成が主流です。この構成は「テーブル全体や特定パーティションをまとめてスキャンし、集計する」という分析ワークロードに最適化されており、その用途では今もよく機能します。一方で、AI ワークロードのアクセスパターンはこれとは大きく異なります。

たとえば、次のような操作が頻繁に発生します。

  • ベクトル検索でヒットした数十行を、テーブルのあちこちから個別に取り出す
  • モデルの学習時に、データセット全体をランダムな順序で繰り返し読み出す
  • 既存のテーブルに embedding やモデルのスコアといった列を後から追加する(特徴量エンジニアリング)
  • 数 KB〜数 GB の blob(画像や動画)をテーブルの 1 カラムとして扱う

最初の 2 つは、ストレージから見るとどちらもランダムアクセスです。読みたい行が連続しておらず、テーブル全体に散らばっています。Parquet でも統計情報を使った読み飛ばしはできるものの、圧縮や符号化はある程度の行数をまとめた単位で行われるため、数行を取り出すだけでも該当行を含むチャンクをまるごと読み込んで展開することになります。列の追加にも弱く、追加した列に値を埋めるには既存データファイルの書き直しが必要です。結果として、検索用にベクトル DB、学習用にオブジェクトストレージ、分析用にデータレイクと、同じデータを用途ごとに別のシステムへコピーする構造が生まれ、データサイロを再生産してしまいます。

Lance はこのギャップを埋めるために設計されたフォーマットです。本エントリでは、Lance がどのような仕組みでこれを実現しているのかを、フォーマットの構造から実際の動作まで順に見ていきます。

概要

Lance とは

Lance は、マルチモーダル AI ワークロード向けに設計されたオープンソースのカラムナフォーマットです。プロジェクト自身は "Open Lakehouse Format for Multimodal AI" と称しており、ファイルフォーマットからテーブルフォーマット、インデックス、カタログまでを含むスタック全体を指しています。Apache License 2.0 で公開されており、コア実装は Rust で書かれています。プロジェクトは 2022 年に始まり、当初は Python と C++ で実装されていましたが、2023 年初頭に Rust で書き直されたという経緯があります。開発を主導しているのは LanceDB 社で、pandas の主要な初期コントリビューターである Chang She らが創業しています。

Iceberg の本質がテーブル仕様であったのと同様に、Lance の本質も仕様の集合です。ただし Lance は単一のテーブルフォーマット仕様ではありません。複数の層からなる仕様のスタックとして定義されています。

(出典: Lance Format Specification

この図は Lance に関わるスタックの全体像です。ファイルフォーマット層には Parquet や ORC と並んで Lance File Format が、テーブルフォーマット層には Iceberg や Delta Lake と並んで Lance Table Format が置かれており、Lance が単一のフォーマットではなく各層に仕様を持つことが読み取れます。それぞれの層の役割は次のとおりです。

ファイルフォーマット : 列データを一定サイズのまとまり(ページ、デフォルト 8MB)ごとに区切って格納するフォーマット。Parquet に相当する層だが、ランダムアクセスに最適化されている。ページという用語はファイルフォーマットの節で詳しく扱う

テーブルフォーマット : ファイル群を 1 つのテーブルとして管理する層。Iceberg に相当し、ACID トランザクション、タイムトラベル、スキーマ進化を提供する

インデックスフォーマット : テーブルに対する問い合わせを高速化するための補助データ構造の仕様。ベクトル近傍検索、スカラー条件(等値・範囲)、全文検索それぞれに最適化されたインデックスが定義されている。Parquet + Iceberg の世界ではインデックスに相当する標準仕様が存在せず(Puffin File が近い位置づけ)、各エンジンが独自に持つ構造に依存している。Lance はこの領域を独立した層として仕様化し、インデックスをテーブルの一級オブジェクトとして管理する

カタログ / ネームスペース仕様 : 複数テーブルの管理層。ディレクトリベースのカタログと REST カタログに加えて、AWS Glue、Unity Catalog、Apache PolarisIceberg REST Catalog といった既存カタログを共通インターフェースで扱うクライアント仕様(Lance Namespace)が定義されている

各層は意図的に疎結合に設計されています。ファイルフォーマットだけを使うことも、テーブルフォーマットまで使うこともできます。カタログ層では、既存のカタログ基盤を Lance テーブルの台帳として利用できます。

Lance と Iceberg の関係は層によって異なります。テーブルフォーマット層では、Lance テーブルと Iceberg テーブルは独立した別物ですが、同じカタログ上で名前空間を共有できます(Iceberg REST Catalog に Lance テーブルを登録し、Lance クライアントがカタログ API 経由で発見する形)。ファイルフォーマット層では、Lance ファイルを Iceberg テーブルのデータファイルとして使う統合が進められています。この詳細はIceberg との関係で扱います。

LanceDB とは

LanceDB は、Lance フォーマットの上に構築されたデータベースです。Lance がフォーマット(仕様とライブラリ)であるのに対して、LanceDB はベクトル検索、全文検索、ハイブリッド検索、SQL 風フィルタリングといった検索 API を提供するレイヤーです。

LanceDB には OSS 版と商用の Enterprise 版があります。

LanceDB OSS : Apache 2.0 ライセンス。組み込み型のシングルプロセスライブラリで、SQLite や DuckDB と同様にサーバ不要でアプリケーション内から直接使える。Python、TypeScript、Rust の SDK が提供されている

LanceDB Enterprise : 商用プロダクト。OSS がシングルプロセスで動作するのに対して、Enterprise は複数ノードでの分散処理に対応する。顧客の環境にデプロイされる

SDK と対応エンジン

Lance フォーマット自体の SDK と、その上で動くエンジンの全体像も整理しておきます。コア実装は Rust の lance クレートで、公式バインディングとして Python(pylance)Java が提供されています。pylance は、lance を直接操作する低レベルな SDK です。先ほどの LanceDB の SDK はこのコアの上に検索 API をかぶせたもので、両者は別物である点に注意してください。

コア SDK の上には、既存のデータ処理エンジンとの連携が広がっています。

バルク処理・SQL エンジン : Apache Sparklance-spark)、TrinoApache Flink が大規模な ETL やバッチ処理を受け持つ。DuckDBApache DataFusion を使えば、手元の対話的な SQL 分析から Lance テーブルを直接読める

分散処理・学習フレームワーク : Ray(lance-ray)が分散前処理や分散インデックス構築を、PyTorch / TensorFlow のデータローダーや Hugging Face Hub 連携が学習データの読み出しを受け持つ

検索・クエリエンジン : LanceDB が、ベクトル検索・全文検索・ハイブリッド検索のクエリ層を受け持つ

このように、LanceDB は Lance エコシステムの唯一の入り口ではなく、検索ワークロードを担当する 1 つのエンジンです。Spark でデータを取り込んで embedding を付与し、Ray でインデックスを構築し、LanceDB で検索 API を提供し、DuckDB で分析する、という役割分担をしても、各エンジンは同じ Lance テーブルを直接読み書きします。最初に挙げた「用途ごとにデータをコピーして回す」構造をなくすことが、このエコシステム設計の狙いです。

特徴

Lance フォーマットスタック全体を通じた特徴は次の 5 点に集約されます。

高速なランダムアクセス : ページベースのレイアウトと適応的なエンコーディングにより、特定の行だけをピンポイントに読み出せる。スキャン性能を犠牲にしないことも設計目標になっている。仕組みはファイルフォーマットの structural encoding で説明する

ゼロコピーのデータ進化 : 列の追加・削除・埋め戻しがメタデータ操作と差分ファイルの追加だけで完結し、既存データの書き直しが発生しない。仕組みはテーブルフォーマットのフラグメントとデータファイルで説明する

バージョニングとタイムトラベル : すべての書き込みが新しいバージョンを作る MVCC 設計で、過去バージョンの参照、タグ付け、ブランチ作成ができる。仕組みはテーブルフォーマットのトランザクションとバージョニングで説明する

検索インデックスの統合 : ベクトルインデックス、スカラーインデックス、全文検索インデックスがテーブルフォーマットのライフサイクルに組み込まれており、データとインデックスの整合性がトランザクションで保証される。仕組みはインデックスフォーマットで説明する

マルチモーダルデータのネイティブサポート : 数 KB の文字列から数 GB の動画まで同じテーブルで扱える。大きな blob は専用のエンコーディングで格納される。仕組みはファイルフォーマットの blob エンコーディングで説明する

アーキテクチャ

ファイルフォーマット

Lance ファイル(.lance)は Parquet と同じ列指向フォーマットですが、設計の出発点が異なります。Parquet が集計のためのスキャンを前提とするのに対して、Lance は特定の行を高速に引き出す retrieval を中心に据えています。

Lance ファイルを構成する要素は 4 つです。これらは全て、ファイル上で連続するバイト列のまとまりとして配置されます。この「1 回の I/O で読み取れる連続バイト列の単位」を、以降バッファと呼びます。

ページ (page) : 1 つの列のデータを格納する連続バイト列。デフォルト 8MB 単位で区切られる。列ごとに独立しており、データ量が多い列ほどページ数が多くなる。ページ内は部分読み取りが可能で、全体をデコードせずに特定の行だけを取り出せる

Column Descriptor(列メタデータ) : ある列が持つ全ページについて、ファイル内の位置(オフセット)、行数、エンコーディング方式を記録した索引。行番号からページを特定するために使う。列ごとに独立した protobuf メッセージで、必要な列の Column Descriptor だけを読むこともできる

グローバルバッファ (global buffer) : ファイル内の任意の位置に配置できる汎用バイト列。スキーマ、辞書(カテゴリ列などで値の種類をまとめた一覧表)、blob の実データなどが格納される。固定の領域ではなくデータページの間に散在しうる。フッターがその位置を保持しており、必要に応じてアクセスする

フッター (footer) : ファイル末尾に置かれる固定長の構造体。Offset Arrays の位置、列数、フォーマットバージョンを保持し、ファイル全体への入り口になる。末尾のマジックバイト LANC で Lance ファイルであることを識別する

これらがファイル内に次の順序で並びます。

graph BT
    F["Footer (固定長)<br/>Offset Arrays の位置 / マジック LANC"]
    OA["Offset Arrays<br/>各 Column Descriptor の位置・サイズ"]
    CD["Column Descriptors<br/>各ページのオフセット・行数・encoding"]
    P["Pages + Global Buffers<br/>列ごとのデータページ、スキーマや辞書等"]
    F -->|"Points to"| OA
    OA -->|"Points to"| CD
    OA -->|"Points to"| P
    CD -->|"Points to"| P

(出典: Lance File Format

エンコーディング(ページ内のバイト列をどう解釈するか)はページ単位で切り替えられるため、同じ列の中でも値の性質が変われば異なる方式を適用できます(詳細は後述)。ある列のページがファイル内のどこにあるかは絶対オフセットで管理されるため、行番号さえわかれば必要なページだけを少数の I/O で取得できます。

(出典: Lance File Format

この図はアクセス経路を示しています。右から左に、Footer → Offset Arrays → Column Descriptors → Pages の順にポインタをたどります。Footer は固定長なのでファイル末尾を 1 回読むだけで位置がわかります。Footer は Offset Arrays(各 Column Descriptor のファイル内位置とサイズの配列)を指し、Offset Arrays を読むことで必要な列の Column Descriptor だけを取得できます。Column Descriptor にはその列の各ページのファイル内位置が記録されているので、最終的にデータページに到達します。スキーマはグローバルバッファに格納されるため、ファイル単体で自己記述的です。

Column Descriptor には、その列の各ページについて以下の情報が記録されています。

  • ページデータのファイル内オフセットとサイズ(どこにあるか)
  • ページに含まれる行数
  • エンコーディング方式
  • ページ先頭の行番号(仕様では priority と呼ばれる。そのページが何行目から始まるかを示す)

n 行目のデータを読み取る手順は次のとおりです。

  1. Column Descriptor のページ先頭行番号を走査し、n を含むページを特定する(例: 先頭行番号が 0, 1000, 2000 のページがあり n=1500 なら、先頭行番号 1000 のページ)
  2. ページ内でのオフセットを計算する(n からそのページの先頭行番号を引く: 1500 - 1000 = 500番目)
  3. 固定長の値(integer, float 等)の場合: ページ内オフセット × 値のバイト幅でページ内のバイト位置が確定する。ページの該当範囲を 1 回読むだけで値に到達する
  4. 可変長の値(string 等)の場合: 値のバイト長が行ごとに異なるため、位置を計算で求められない。各行の値の開始バイト位置を記録した固定幅の配列(オフセット配列)が、値の実体とは物理的に別の場所に格納されている。まずオフセット配列から該当行の開始位置を読み(1回目の I/O)、次に値の実体の該当位置を読む(2回目の I/O)。2 箇所がファイル内で離れているため、計 2 回の I/O が必要になる

この手順で毎回 Column Descriptor をファイルから読み直すのは非効率なため、クライアント実装では Column Descriptor を初回アクセス時にメモリ上にキャッシュし、以降の読み取りではキャッシュからページを特定します(Lance ではこのキャッシュを search cache と呼びます)。フルスキャン(全行を順に読む)の場合はページを先頭から順に読めばよいため、このキャッシュのロード自体を省略できます。

エンコーディングとバージョン体系

前節まではページの「配置」の話でした。ここからはページの「中身」の話に入ります。

ページに格納されたバイト列をどう解釈するか、つまりどんな方式でデータを表現しているかを決めるのがエンコーディングです。たとえば同じ整数の列でも、値をそのまま並べる方式(Flat Encoding)と、連続する同じ値を「値 + 繰り返し回数」にまとめる方式(Run Length Encoding)ではページ内のバイト列の並び方が異なります。

(出典: Lance Encoding Strategy

図は同じ 16bit 整数配列を 2 つのエンコーディングで格納した例です。Flat Encoding は値を固定幅で一列に並べるだけです。Run Length Encoding は「どの値が」と「何回繰り返すか」を別々の領域に分けて格納します。どちらの場合もファイルの物理構造(Footer, Offset Arrays, Column Descriptors の配置)は変わりません。

Lance のファイルフォーマットにはバージョン体系があり、この「物理配置」と「エンコーディング」の分離を反映しています。major 番号がファイル構造(ページ・Offset Arrays・Footer の配置)の変更、minor 番号がエンコーディングの追加を表します。現行の安定版は 2.1(2.2 が次期安定候補)です。major が同じであればファイルの開き方は同一なので、minor だけが上がった場合、古い読み手は「ページの位置まではたどれるが、そのページ内のエンコーディングが未対応」という形で graceful に失敗できます。

圧縮とランダムアクセスの両立: structural encoding

エンコーディングは圧縮も担います。列指向フォーマットではストレージ効率のためにデータを圧縮するのが一般的ですが、圧縮されたブロックの中から 1 つの値だけを取り出すにはブロック全体をデコードする必要があり、ランダムアクセスとのトレードオフが生じます。Lance はファイルフォーマット v2.1 でこの問題に対処するため、エンコーディングを 2 つの層に分離しました(詳細記事)。

構造的エンコーディング (structural encoding) : ページ内のデータをどのように区切ってバッファに分割するかを決める層。バッファをいくつ作り、それぞれに何を入れるかによって、ランダムアクセス時に必要な I/O 回数が決まる

圧縮エンコーディング (compressive encoding) : 個々のバッファをどう圧縮するかを決める層。transparent(bitpacking 等、圧縮後もランダムアクセス可能)と opaque(delta encoding 等、ブロック全体のデコードが必要)に分類され、構造的エンコーディングの選択によってどちらが許されるかが決まる

構造的エンコーディングの目的は「1 つの値を読むために必要な I/O 回数を最小化する」ことです。基本的な考え方は単純で、1 つの値を読むために必要な情報を、できるだけ少ない数のバッファにまとめてファイルに書き込みます。バッファが 1 つなら 1 回の I/O で読め、2 つに分かれていれば 2 回必要になるからです。

ネスト型(struct や list)を扱う場合はこの工夫がより重要になります。Arrow の場合、list のオフセット配列、struct の NULL を示す validity ビットマップ、値本体がそれぞれ独立したバッファに分かれるためバッファ数が増えます。Lance ではこれらの構造情報を Parquet と同様の repetition levels / definition levels(ネストの深さや NULL の有無を行ごとの整数値として表現する方式)に変換し、値データと一緒に 1 つのバッファにまとめます。

(出典: Lance File 2.1: Smaller and Simpler

図は "List of Tags"(文字列のリスト型)を例に、4 つの方式でバッファの分け方がどう異なるかを比較しています。Arrow はバッファが 5 つに分かれるため 1 つの値を読むのに複数回の I/O が必要です。Parquet はバッファ数は少ないものの、1 つの値を取り出すためにそのバッファ全体(同じ行グループ内の他の行も含む)を読む必要があります。Lance の Mini Block(値が小さい場合に複数の値を小ブロックにまとめる方式)と Full Zip(値が大きい場合に値ごとに連結して並べる方式)はこの中間で、値のサイズに応じて使い分けます。

具体的な structural encoding は 4 方式あり、格納する値のバイトサイズに応じて選択されます。

mini-block : 値のサイズが 256 バイト未満の固定幅データに使われる。ページ内のデータを複数の小さなブロック(各ブロックは 4〜32KiB で、128〜8192 個の値を含む)に分割し、各ブロックに rep/def levels と圧縮済み値をまとめて格納する。ある行の値を読むには、その行を含むブロック全体をデコードする必要があるが、ブロックが小さいため読み込み量は限定される。ブロックごとの位置情報(サイズと値の個数)が search cache にロードされるため、行番号から該当ブロックを即座に特定できる

(出典: Lance Encoding Strategy

図は mini-block の内部構造です。block header に続いて rep levels、def levels、圧縮済みデータバッファが並びます。

(出典: Lance Encoding Strategy

ブロックメタデータは各ブロックあたり 16bit(12bit = 8バイトワード数、4bit = log2(値数))で、search cache に常駐します。行番号からどのブロックを読めばよいかを即座に特定できます。

full-zip : おおむね 256 バイト以上の値に使われる。各値を「制御ワード(rep/def 情報を数バイトにパックしたヘッダ)+ 値のサイズ + 圧縮済み値本体」の形にして、ページ内に値ごとに連結して並べる。固定幅データ(ベクトル embedding 等)では制御ワードもサイズも不要になり、単純なフラット配列として格納される

(出典: Lance Encoding Strategy

図のように、各値の先頭に制御ワード(図中 "Control Word")が付き、その後に値本体のバイト列が続きます。

(出典: Lance Encoding Strategy

可変幅データの場合、行番号から値の位置を特定するために repetition index(各行のバイトオフセットを格納した固定幅配列)が同じページ内の別バッファとして存在します。ランダムアクセスは 2 回の I/O で完了します。1回目で repetition index から該当行のオフセットを読み、2回目で値の実体の該当位置を読みます。

constant : ページ内のすべての値が同一、またはすべて NULL の場合に使われる。値を 1 つだけ記録すれば済むため、実質的にデータを格納しない

blob : 1MiB 以上の巨大なバイナリに使われる。実データはグローバルバッファに置き、ページには位置(ファイル内絶対オフセット)とサイズだけを記録する。1 回の I/O で直接読み取れる

これら 4 方式の結果として、データ型によらず固定幅なら 1 回、可変幅なら 2 回の I/O で任意の行の値に到達できます(Weston Pace はこれを "the 1-2 IOP challenge" と呼んでいます)。ベクトル検索でヒットした行がテーブル内に散在していても、各行を 1-2 回の I/O で取り出せます。学習時のシャッフル読み出しも同様に、行の位置によらず一定のコストでアクセスできます。

(出典: Lance File 2.1: Smaller and Simpler

図は文字列のリスト型を full-zip で格納した場合のランダムアクセスです。repetition index を 1 回読むことでデータ範囲が確定し、残りの情報(制御ワード + 値本体)は連結されているため追加の 1 回の I/O で取得できます。

ここまで説明した structural encoding がバッファの分割方法を決めた後、各バッファの内部ではさらに圧縮が適用されます。使われる圧縮方式は FSST、RLE、bitpacking、byte stream split、LZ4 / Zstd 等で、データの性質に応じて選択されます。これが先述の「圧縮エンコーディング」層の役割です。画像や動画のような巨大な値は前述の blob 方式で格納され、テーブルの 1 列として保持しながらも位置情報をたどった直接アクセスで読み出せます。

semi-structural transformations

structural encoding に渡す前の前処理として、データの性質に応じた変換が適用されることがあります(仕様では semi-structural transformations と呼ばれています)。structural encoding は「バッファに分割して I/O を最適化する」ことに専念しますが、その前段でデータの表現を変えておくとより効率的に処理できる場合があります。以下の 3 種が定義されています。

辞書エンコーディング (dictionary encoding) : 値の種類が少ない列(カテゴリ列、ステータスコード等)に適用される。ユニークな値の一覧(辞書)を作り、各行にはその辞書への整数インデックスだけを格納する。たとえば文字列列に 10 種類の値しかなければ、数百バイトの文字列の代わりに 4bit のインデックスで済む。辞書は search cache にロードされるため、ランダムアクセス時にも辞書を毎回読み直す必要がない。辞書化後のインデックス配列は小さな固定幅データになるため、mini-block で効率的に処理される

struct packing : struct 型のフィールドを列方向(フィールドごとに別のバッファ)ではなく行方向(1行分の全フィールドをまとめて 1 つのバッファ)に変換する。通常 Lance は列指向なので struct のフィールド A とフィールド B は別バッファに格納されるが、struct packing を有効にすると A と B が交互に並ぶ行指向の配置になる。これにより struct の全フィールドを一緒に読む場合の I/O 回数が削減される。ただし個々のフィールドだけを読む操作は効率が下がるため、常に全フィールドを一緒に使うユースケース(座標データ等)でのみオプトインで有効化する

fixed size list の平坦化 : 固定長リスト(ベクトル embedding の FixedSizeList<float32, 128> 等)を、内部的にリスト構造を持たない平坦な配列に変換する。ランダムアクセスの際は、行番号にリストの要素数を掛けるだけで該当要素の位置が計算できる。圧縮層はリスト構造を意識する必要がなくなり、通常の固定幅配列と同じ方法で処理できる

参考論文

ここまで説明してきた structural encoding によるランダムアクセス最適化の背景は "Lance: Efficient Random Access in Columnar Storage through Adaptive Structural Encodings" (2025) にまとまっています。論文によれば、Parquet もチューニング次第でデフォルト設定の 60 倍以上のランダムアクセス性能を出せます。ただしその場合はスキャン性能やメモリとのトレードオフが避けられず、Lance はこのトレードオフなしに同等以上の性能を達成すると主張されています。

ファイルフォーマットが持たないもの

Lance のファイルフォーマットは、統計情報(min/max 等)や検索構造(インデックス)を持ちません。これらはテーブルフォーマット層やインデックス層に委譲されています。ファイル自体がページのレイアウトとエンコーディングだけに専念することで、ファイルフォーマットを変更せずに新しいインデックスを追加できる構造になっています。

Parquet との比較

Parquet とのアーキテクチャ上の大きな違いは row group を持たないことです。Parquet のファイル構造は File → Row Group → Column Chunk → Page という階層になっています。ファイル内の行をまず row group(たとえば数十万〜数百万行ごと)に水平分割し、各 row group の中で列ごとに column chunk としてまとめます。column chunk はさらに page に分割され、page が圧縮・エンコーディングの最小単位です。I/O の単位は column chunk、MapReduce レベルの並列化の単位は row group です。

row group には次の利点があります。

  • row group 単位で独立に処理できるため、分散処理の並列化粒度になる
  • row group ごとの min/max 統計情報を使い、条件に該当しない row group をまるごと読み飛ばせる(row group skipping)
  • 同じ row group に属する複数列の column chunk がファイル内で近い位置にあるため、複数列を同時に読むスキャンで I/O が局所化する

一方で、row group のサイズはファイル書き込み時に決める固定パラメータであり、最適値をスキーマやワークロードによらず決めることが難しいというジレンマがあります。row group が小さすぎると、列数が多いスキーマでは column chunk 1 つあたりのサイズが小さくなり、クラウドストレージへの HTTP GET リクエストが大量に発生します。逆に大きすぎると、書き込み時に row group 全体をメモリ上にバッファする必要があるためメモリ消費が増大します。

Lance は v1 では Parquet と同様に row group を持っていましたが、ファイルフォーマット v2 でこの区切りを廃止しています。row group が担っていた役割は、ファイルフォーマットの外にある上位層に委譲されました。

  • 並列化: テーブルフォーマット層のフラグメントが分散処理の分割粒度を担う。Lance のファイルフォーマット単体には並列化の境界がなく、これはテーブルフォーマットとセットで使うことを前提とした設計上のトレードオフである
  • 統計ベースのスキップ: インデックス層の Zone Map がフラグメント単位やページ単位の統計を持ち、不要な範囲の読み飛ばしを実現する
  • スキャン時の I/O 効率: 各列のページが 8MB と十分大きいため、列単位の連続読み取り効率は保たれる。ただし row group のように複数列の物理的近接性は保証されないため、多数の列を同時に読むスキャンでは Parquet の方が有利になる場面もありうる
テーブルフォーマット

テーブルフォーマットは、Lance ファイルの集合を 1 つのテーブル(データセット)として管理する層です。Iceberg や Delta Lake に相当し、ACID トランザクション、スキーマ進化、タイムトラベル、インクリメンタルな更新を提供します。設計上のコンセプトは 3 つあります(仕様)。

Two-Dimensional Storage : 行方向(フラグメント)と列方向(データファイル)の 2 次元でデータを管理する。列の追加や埋め戻しが既存データの書き直しなしで実現する

First-Class Indices : テーブルに対する検索を高速化するための補助データ構造(インデックス)のライフサイクルをテーブルのトランザクションに統合する。インデックスの具体的な中身は別仕様に委譲し、テーブルフォーマットは発見と管理だけを担う

External Manifest Store : オブジェクトストレージへの直接コミットに加え、外部システム(キーバリューストア等)を介したコミット調整を可能にする。アクセス制御やガバナンスの実装に使われる

テーブルフォーマットの構造を見ていく前に、2 つの設計上の特徴を押さえておきます。

互換性管理: feature flags

テーブルフォーマットでは feature flags(ビットフラグ)で互換性を制御します(仕様)。マニフェスト内に reader_feature_flags と writer_feature_flags の 2 つのフラグフィールドがあり、そのバージョンのテーブルを正しく読み書きするために必要な機能を示します。古いライブラリが未知のフラグを検出した場合、読み書きを拒否するため、新機能が古い実装にデータを壊させることを防ぎます。ファイルフォーマットが major/minor のバージョン番号で互換性を管理するのに対して、テーブルフォーマットではこのフラグ方式を採用しています。

カタログなしでの動作

Iceberg ではカタログが current snapshot pointer を管理し、コミットの原子性もカタログに依存します。Lance のテーブルフォーマットはこれと異なり、オブジェクトストレージが原子的書き込み操作(put-if-not-exists や rename-if-not-exists)を提供していれば、テーブルフォーマット単体でコミットの原子性を保証できます(トランザクション仕様のコミットプロトコル)。ストレージがこれらの操作をサポートしない場合は、External Manifest Store(外部キーバリューストア)にコミット調整を委譲することで同等の保証を得られます。例えば S3 は 2024年8月に conditional writes をサポートしたため現在はカタログなしで動作しますが(対応する Lance 実装)、それ以前は DynamoDB 等を External Manifest Store として使う必要がありました。カタログ(Lance Namespace)の役割は「テーブルを名前で発見する」ことだけで、テーブルの状態管理やトランザクション制御には関与しません。

物理構造の全体像

以上のコンセプトがどのような物理ファイルとして具現化されるかを見ていきます。Lance テーブルの実体は、オブジェクトストレージ上の 1 つのディレクトリ内にあるファイル群です。

ファイル 格納場所 形式 役割
マニフェスト _versions/{version}.manifest Protocol Buffers バイナリ テーブルの 1 バージョンの全メタデータ。スキーマ、フラグメント一覧、インデックス参照を 1 ファイルに内包する
バージョンヒント _versions/latest_version_hint.json JSON 最新バージョン番号のキャッシュ(オプション)。このファイルを読めば最新マニフェストのパスが直接計算できるため、ディレクトリ内のファイル一覧を取得せずに済む。なくても動作に影響はない
データファイル data/{uuid}.lance Lance ファイルフォーマット 列データの実体
削除ファイル _deletions/{fragment_id}-{version}-{id}.arrow/.bin Arrow IPC / Roaring Bitmap 削除された行の位置情報
トランザクションファイル _transactions/{read_version}-{uuid}.txn Protocol Buffers バイナリ コミット操作の内容。衝突検出とリベースに使う
タグ / ブランチ _refs/tags/*.json, _refs/branches/*.json JSON バージョンへの名前付き参照
インデックス _indices/{uuid}/ インデックス固有 検索用補助データ構造

これらの参照関係は以下のとおりです。

graph TD
    M["マニフェスト (.manifest)<br/>protobuf バイナリ"]
    M -->|"fragments[].files[].path"| D["データファイル (.lance)"]
    M -->|"fragments[].deletion_file"| DEL["削除ファイル (.arrow/.bin)"]
    M -->|"index_section"| IDX["インデックス (_indices/)"]
    M -->|"transaction_file"| TXN["トランザクションファイル (.txn)"]

テーブルのある時点の状態は、対応するバージョンのマニフェスト 1 ファイルを読むことで把握できます。マニフェストが全てのデータファイルや削除ファイルへの参照を内包しているため、マニフェストを起点に全てのファイルにたどり着けます。各構成要素の詳細は以降のサブセクションで説明します。

マニフェスト

マニフェストは Protocol Buffers バイナリファイルで、テーブルに対する書き込み操作(行の追加、削除、列の追加、インデックス作成等)が 1 回コミットされるたびに新しいマニフェストが 1 つ生成されます。つまり Lance における「バージョン」とはコミットの単位であり、バージョン N のマニフェストを読めば「N 回目のコミットが完了した時点でのテーブルの完全な状態」が得られます。過去のマニフェストは削除されずに残るため、任意のバージョンの状態を再現できます。

Iceberg では metadata.json(テーブルレベルのメタデータとスナップショット履歴)→ manifest list(あるスナップショットを構成する manifest file の一覧とその統計)→ manifest file(データファイル群のパスと統計情報)の 3 層構造でテーブルの状態が表現されます。Lance のマニフェストはこれら全ての役割を 1 ファイルに統合しています。スキーマ、全フラグメント(データファイルパス含む)、インデックス参照が単一の protobuf メッセージに収まるため、テーブルの状態を知るために複数ファイルを辿る必要がありません。

クライアントが特定バージョンのマニフェストにアクセスする方法も単純です。マニフェストのファイル名にバージョン番号が埋め込まれており(V2 命名スキーム: _versions/{u64::MAX - version}.manifest)、バージョン番号からファイルパスが一意に計算できます。バージョン一覧のようなメタデータを辿る必要はありません。最新バージョンの発見には、V2 命名スキームにより辞書順の先頭が常に最新になるため、_versions/ ディレクトリの一覧取得(オブジェクトストレージの LIST 操作)で最初に返されるファイルが最新です。latest_version_hint.json にはキャッシュされた最新バージョン番号が記録されており、これを読めば LIST 操作自体を省略してバージョン番号から直接マニフェストのパスを計算できます。

主なフィールドは以下のとおりです。

フィールド 内容
fields テーブルの全フィールド定義。各フィールドは不変のID、親フィールドへの参照、名前、論理型を持つ
fragments フラグメントの配列。各フラグメントのID、データファイル一覧、削除ファイル参照、物理行数を含む
version 単調増加するバージョン番号とタイムスタンプ
index_section インデックスメタデータの格納位置
reader_feature_flags / writer_feature_flags このバージョンの読み書きに必要な機能を表すビットフラグ。未知のフラグを見た古いライブラリは読み書きを拒否し、新機能がデータを壊すことを防ぐ
base_paths データファイルの配置先定義。別バケットや別データセットのファイルを参照でき、shallow clone やマルチリージョン配置の基盤になる
next_row_id Stable Row ID(後述)の採番カウンタ
スキーマとフィールド ID

テーブルのスキーマは Arrow の型システムのサブセットに対応するフィールド定義の集合です(仕様)。各フィールド(struct のサブフィールドや list の要素を含む)には不変の整数 ID が割り当てられます。テーブル作成時にフィールドを上から順に(ネストがあれば子を先に)採番していき、以降追加されたフィールドにはインクリメンタルに新しい ID が付与されます。

この不変 ID により、フィールドのリネームや並べ替えはメタデータの変更だけで済みます。データファイルはフィールド名ではなく ID で列を参照するため、既存のデータファイルは影響を受けません。

Lance はオプションとして非強制主キー (unenforced primary key) を定義できます。これは merge-insert 操作での重複排除や、後述する MemWAL の last-write-wins セマンティクスに使われます。「非強制」とは、Lance が常にユニーク制約を検証するわけではないことを意味し、必要な場合はユーザーが merge-insert 等の操作を通じて強制します。

フラグメントとデータファイル

フラグメントはテーブル全体の行を分割して管理する単位です。テーブルが100万行あれば、例えばフラグメント0が最初の50万行、フラグメント1が残りの50万行を持つ、という形になります。

フラグメントの主なフィールドは以下のとおりです。

フィールド 内容
id フラグメントの一意な識別子(uint64)
files データファイルの配列。各データファイルにはファイルパス、含まれるフィールドIDの一覧、ファイルサイズが記録される
deletion_file 削除ファイルへの参照。ファイル種別(Arrow IPC / Roaring Bitmap)、読み取りバージョン、削除行数を含む
physical_rows フラグメント内の物理行数(削除済み行を含む総数)。現在の有効行数は physical_rows から削除行数を引いて求まる
row_id_sequence Stable Row ID が有効な場合、フラグメント内の各行に対応する row ID の列

データファイルは Lance ファイルフォーマットで列データを格納する実体で、フラグメント内の一部の列だけを持つことができます。「このファイルがどのフィールド ID の列を含むか」を files[].fields 配列として記録しているため、読み手はフィールド ID でファイルを選択できます。

1 つのフラグメントが複数のデータファイルを持てることが、データ進化(列の追加・更新を書き直しなしで行う)の鍵です。embedding やモデルのスコアを後から列として追加する場合でも、既存データファイルには一切触れずに新しいファイルを追記するだけで済みます。

(出典: Lance Table Format

図のフラグメントは xvector を持つデータファイルに加え、後から追加された y 列だけのデータファイルで構成されています。3 行目の削除は削除ファイルに位置(0始まり)を記録するだけです。

列の追加 : 新しいフィールド ID を割り当ててスキーマに追加し、新列の値だけを格納したデータファイルを各フラグメントに追記する。既存のデータファイルは無修正

列の削除 : スキーマからフィールドを外すだけ。実データは物理的に残るが読み手からは見えなくなる

列の更新(上書き) : 古いデータファイル内の該当フィールド ID を tombstone 値(削除済みを示す特殊マーカー、値は -2)に設定し、新しい値を持つデータファイルをフラグメントに追記する。読み手は tombstone を無視して新しいファイルから列を読む

削除ファイル

削除ファイルは行の削除をデータファイルの書き直しなしで表現する仕組みです。各フラグメントは 1 バージョンにつき最大 1 つの削除ファイルを持ちます。

2 つの格納形式があります。

Arrow IPC(.arrow : 削除された行のオフセット(0始まりの整数配列)を Apache Arrow IPC(Arrow のバイナリシリアライゼーション形式)のファイルとして格納する。削除が疎(全体のごく一部)な場合に効率的

Roaring Bitmap(.bin : 削除された行のオフセットを Roaring Bitmap として圧縮格納する。削除が密(多くの行が削除済み)な場合に効率的

読み手はフラグメントのデータを読んだ後、削除ファイルに含まれるオフセットの行を結果から除外します。削除を物理的に反映するには、データファイルを書き直して削除行を除いたコンパクション(Rewrite トランザクション)を行いますが、これはインデックスへの影響があるため必要な場合にのみ実施します。

トランザクションと並行制御

Lance は MVCC(Multi-Version Concurrency Control)による楽観的並行制御を採用しています(仕様)。各書き込みはトランザクションとして記録され、新しいイミュータブルなマニフェストを原子的に作成することでコミットされます。

コミットの原子性を保証する方法は 2 つあります。1 つはオブジェクトストレージの原子的操作(put-if-not-exists または rename-if-not-exists)を直接使う方法で、追加インフラなしで動作します。もう 1 つは External Manifest Store(外部キーバリューストアや REST Catalog)にコミット調整を委譲する方法で、ガバナンスやアクセス制御が必要な場合や、ストレージが原子的操作をサポートしない場合に使われます。External Manifest Store を使う場合、4 ステップのプロトコル(stage → commit to external → finalize in object store → update pointer)でコミットが調整されます。

(出典: Lance Transactions

いずれの方法でも、複数のライターが同じバージョンのマニフェストを同時に作成しようとした場合、1つだけが成功し、他はトランザクションファイルを読んで衝突を検出します。

トランザクションの種類は 15 種あり、主なものは以下です。

Append : 新しいフラグメントの追加。最も一般的な操作で、ほとんどの他のトランザクションと互換

Delete : 削除ファイルの付与。削除対象の行を既存フラグメントに記録する

Rewrite : コンパクション等のデータ再編成。row address が変わるためインデックスへの影響がある

CreateIndex : インデックスの追加・削除。インデックスは全フラグメントをカバーする必要がなく、未カバー部分は総当たり検索される

Update : 行の値の更新。削除 + 新規書き込みの組み合わせとして実装される

Clone : shallow clone または deep clone の作成

衝突が発生した場合、3 種類の解決方法が定義されています。

Rebasable(リベース可能) : 両方の変更をマージして自動的にコミットをリトライできる。例: 異なる行への 2 つの削除は削除ファイルをマージすれば両立する

Retryable(リトライ可能) : 自動マージはできないが、アプリケーションが最新バージョンを読み直して操作をやり直せば同等の結果が得られる。例: コンパクション中に対象フラグメントが更新された場合

Incompatible(非互換) : 根本的に両立しない衝突。リトライしても意味論的に異なる結果になる可能性がある。例: Restore(過去バージョンへの復元)中に削除を試みた場合

コンパクション

トランザクション型の Rewrite について、もう少し掘り下げます。コンパクションは、テーブルのデータレイアウトを意味的な変更なしに最適化する操作で、トランザクション仕様では Rewrite トランザクションとして定義されています。具体的には以下の処理を含みます。

  • 小さなフラグメントの統合(多数の小さな append が蓄積した場合の解消)
  • 削除済み行のパージ(削除ファイルで論理削除されていた行の物理的な除去)
  • 行の並べ替え(クエリパターンに最適なレイアウトへの再編成)

コンパクションは旧フラグメントを新フラグメントに置き換えるため、行の物理位置(row address)が変わります。これはインデックスに影響を与えます。インデックスは row address を参照しているため、コンパクション後にインデックスが指す行が存在しなくなる可能性があります。この問題への対処はインデックスフォーマットで説明します。

並行制御の観点では、コンパクション中に対象フラグメントが他のトランザクション(Delete や Update)によって変更された場合、Retryable な衝突として処理されます。コンパクションの実行前にフラグメント ID を予約する必要があり(ReserveFragments トランザクション)、これにより他のライターとの ID 衝突を防ぎます。

Storage Layout

ここまで登場したファイル群が、実際のストレージ上でどのようなディレクトリ構造に配置されるかを見ていきましょう(仕様)。

{dataset_root}/
    data/           -- データファイル (.lance)
    _versions/      -- マニフェスト (1バージョンにつき1ファイル)
    _transactions/  -- トランザクションファイル (.txn)
    _deletions/     -- 削除ファイル (.arrow / .bin)
    _indices/       -- インデックスデータ
    _refs/          -- タグ・ブランチのメタデータ (JSON)
    tree/           -- ブランチごとのデータセット

マニフェストの base_paths フィールドにより、データファイルをテーブルのルートディレクトリ以外の場所(別バケット、別リージョン等)に配置できます。これを利用した構成として、hot/cold ティアリング(アクセス頻度によるストレージ階層分離)、マルチリージョン配置、shallow clone(メタデータだけコピーしてデータは元テーブルを参照)があります。

データファイルの命名は UUID ベースで、先頭 3 バイトを 24 文字のバイナリ文字列に変換することで S3 の内部パーティショニングに最適化されています。マニフェストは V2 命名スキーム({u64::MAX - version}.manifest)を使い、ファイル一覧を辞書順で取ると最新バージョンが先頭に来る設計です。

テーブルは全ファイルパスが相対パスで記録されるため、ディレクトリごとコピーするだけで別の場所に移動できます(Lance ではこの性質を "dataset portability" と呼びます)。

バージョニング・ブランチ・タグ

マニフェストはイミュータブルで、_versions/ にバージョンごとに 1 ファイルずつ残ります。過去バージョンの読み出しは、そのバージョンのマニフェストを読むだけで完了します(タイムトラベル)。

ブランチは、メインのバージョン履歴から分岐した独立した履歴を持つ仕組みです。実装としてはメインテーブルの shallow clone で、tree/{branch_name}/ に独自の _versions/, _transactions/, _deletions/, _indices/ を持ちます。データファイルは base_paths を通じてメインテーブルのものを参照するため、ブランチ作成時にデータのコピーは発生しません。ブランチのメタデータは _refs/branches/{name}.json に JSON で格納されます。

タグは、特定のバージョンへの名前付き参照です。_refs/tags/{name}.json に、参照先のブランチ名とバージョン番号が記録されます。

行の識別と追跡

ここまではテーブル全体やバージョンの管理を見てきました。次に、テーブル内の個々の行をどう識別・追跡するかについてです。

Lance の行には 2 種類の識別子があります(仕様)。いずれもファイルフォーマット層ではなくテーブルフォーマット層の情報です。

Row Address : 行の物理的な位置を表す 64bit の整数です。上位 32bit にフラグメント ID、下位 32bit にフラグメント内の行オフセットを格納した (fragment_id << 32) | local_row_offset という構造になっています。ファイルに書き込まれるのではなく、フラグメント ID と行の位置がわかれば計算で求められます。現在のインデックス(ベクトルインデックス、スカラーインデックス、全文検索インデックス)は全てこの row address で検索結果の行を参照しています。コンパクションで行が別のフラグメントに移動すると row address が変わるため、インデックスが指す先が無効になる問題が生じます。この対処には Fragment Reuse Index(後述のインデックスフォーマットで説明)を使います

Stable Row ID : 行の論理的な識別子です。テーブル作成時にオプトインで有効化すると、各行に単調増加する u64 の ID が割り当てられます。row address と異なり、コンパクションや更新で行の物理位置が変わっても ID は変わりません。ID はマニフェスト内のフラグメントメタデータ(row_id_sequence フィールド)に格納されます。行が更新された場合、新しい物理行に同じ ID が引き継がれ、古い物理行は削除ファイルでマークされます。Stable Row ID を有効にすると Row ID Index(row ID から現在の row address へのマッピング)が構築され、行バージョン追跡も利用可能になります。ただしテーブル作成後に後から有効化することはできません。また、現時点ではインデックス自体は row address ベースで動作しており、Stable Row ID ベースのインデックスへの移行は進行中です

Stable Row ID を有効にすると、行レベルのバージョン追跡も利用できるようになります。各行に _row_created_at_version(その行が最初に作成されたバージョン番号)と _row_last_updated_at_version(最後に更新されたバージョン番号)が記録されます。たとえば「バージョン 5 からバージョン 10 の間に追加・変更された行だけを取得する」といった差分クエリが、これらのフィールドでフィルタするだけで実現できます(Lance ではこの機能を Change Data Feed と呼びます)。

MemTable & WAL(Experimental)

通常の Lance テーブルへの書き込みは Append トランザクションとしてコミットされます。これはバッチ書き込みには適していますが、1秒あたり数千〜数万件のレコードを絶え間なく受け取るストリーミング取り込みでは、コミットごとのオーバーヘッドがボトルネックになります。

この問題に対し、Lance は MemTable & WAL 仕様で LSM-tree(Log-Structured Merge Tree)アーキテクチャを実験的に定義しています。ここまで見てきたテーブルフォーマットの仕様がストレージ上のファイル配置だけで完結していたのに対し、MemWAL 仕様はメモリバッファを持つ常駐プロセスの存在を前提とし、ストレージレイアウトに加えてプロセス間の協調プロトコル(排他制御、マージ順序、読み取り時の結合ルール)も標準化しています。MemTable のインメモリ実装やバックグラウンドジョブのスケジューリングは仕様のスコープ外であり、異なる実装がプロトコルに従えば相互運用できる設計です。

LSM-tree はデータベースの世界で広く使われる設計パターンで、「書き込みをまずメモリ上のバッファに貯め、一定量たまったらストレージに書き出し、バックグラウンドで本体にマージする」という多段構造で書き込みスループットを高めます。

(出典: Lance MemTable & WAL

構造は以下のとおりです。通常の Lance テーブルを base table と呼び、その上に複数のシャード(MemWAL Shard)が配置されます。

  • シャード(MemWAL Shard): 書き込みを受け付ける単位。書き込みの並列度を上げるために複数のシャードを並べられる。各シャードには同時に 1 つのライター(書き込みプロセス)だけが接続できる
  • MemTable: 各シャード内のメモリ上のバッファ。書き込みはまずここに格納される
  • WAL(Write-Ahead Log): MemTable への書き込みと同時にストレージ上のログファイルにも記録される。プロセスがクラッシュして MemTable(メモリ上のデータ)が失われても、WAL からデータを復元できる
  • Flushed MemTable: MemTable が一定量に達するとストレージに書き出される。書き出された結果は小さな Lance テーブルとして保存される
  • Base Table へのマージ: Flushed MemTable はバックグラウンドジョブによって非同期に base table へ統合される

読み取り時は、base table・Flushed MemTable・メモリ上の MemTable の 3 層のデータを統合して返します。テーブルに主キーが設定されている場合、同じ主キーの行が複数層に存在するときは最新の書き込みが優先されます(last-write-wins)。

現時点では実験的な仕様であり、本番利用には注意が必要です。

なお、通常のテーブルフォーマット(Lance 本体や Iceberg を含む)はストレージ上のファイル配置だけを定義し、計算リソースには依存しません。一方 MemWAL 仕様は、メモリバッファを持つ常駐プロセスが存在することを前提に、ストレージレイアウトに加えて書き込みプロセス間のプロトコル(フェンシングによる排他制御、マージ順序、読み取り時の結合ルール)も標準化しています。MemTable のインメモリ実装やバックグラウンドジョブのスケジューリングは仕様のスコープ外とされており、異なる実装がストレージレイアウトとプロトコルに従えば相互運用できる設計です。

インデックスフォーマット

インデックスフォーマット仕様は、テーブル上の検索を高速化する補助データ構造の形式とライフサイクルを定義します。Lance のインデックスはファイルフォーマットとは独立したレイヤーとして設計されており、テーブルの行識別子(row address)の上に構築されます。

設計コンセプト

インデックスの設計は以下のコンセプトに基づいています(仕様)。

オンデマンドロード : テーブルはインデックスをロードせずに読み取れる。インデックスはクエリが利益を得る場合にのみロードされる

漸進的ロード : インデックス全体をメモリに載せず、クエリに必要な部分だけを段階的にロードする。例えば BTree では最初にページテーブルだけを読み、該当ページだけを追加ロードする

フラグメントを超えた統合 : インデックスセグメントは複数フラグメントをカバーできる。フラグメントごとに独立したインデックスファイルを持つ必要がない

イミュータブル : インデックスファイルは書き込み後に変更されない。新しいファイルを作成することでのみ更新される。メモリやディスクにキャッシュしても整合性の問題が起きない

基本概念

インデックスはユーザーが付けた名前(例: id_idx, vec_idx)で識別され、特定の列に対して定義されます。1 つのインデックスは複数のインデックスセグメントから構成されます。各セグメントは UUID で識別され、テーブルの一部のフラグメント群を対象とした独立したインデックスファイルです。セグメント同士は対象フラグメントが重複しないように分割されています。


(出典: Lance Index Formats

各セグメントがどのフラグメントを対象としているかは、マニフェスト内のインデックスメタデータ(IndexMetadata)の fragment_bitmap フィールドに Roaring Bitmap で記録されます。

重要な点として、全セグメントを合わせてもテーブル全体をカバーしている必要はありません。上図の例では id_idx はフラグメント 0 と 1 をカバーするがフラグメント 2 は未カバーで、vec_idx は全フラグメントをカバーしています。未カバーのフラグメントに対してクエリが来た場合、エンジンはそのフラグメントのデータを直接走査し、インデックス経由の結果と統合します。一見非効率に思えますが、この設計により新しいフラグメント(append 直後のデータ)がインデックスに含まれていなくてもクエリは正しい結果を返せます。つまり、書き込みのたびにインデックスを再構築する必要がありません。

ストレージと管理

インデックスセグメントのデータは _indices/{UUID}/ ディレクトリに格納されます(仕様)。内部のファイル形式はインデックスタイプによって異なりますが、多くの場合 Lance ファイルとして書かれており、既存の Lance ファイル読み書きコードを再利用できます。

インデックスのメタデータはマニフェストの IndexSection(protobuf)に格納されます(table.proto)。各 IndexMetadata には、セグメントの UUID、対象列、fragment_bitmap、フォーマットバージョン、そしてインデックス固有の設定を格納する index_details(protobuf Any 型)が含まれます。index_details は BTree や IVF など各インデックスタイプごとに異なる protobuf メッセージを入れられる汎用フィールドで、エンジンはその型 URL を見てインデックスタイプを判定します。未知のタイプやバージョンのセグメントに遭遇した場合、エンジンはそのセグメントをスキップし、対象フラグメントを直接走査して結果を補完します。これにより、新しいインデックスタイプが追加されても古いエンジンが壊れることはありません。

インデックスの作成と更新は、テーブルフォーマットで説明したトランザクション機構と同じ仕組みで行われます。新しいセグメントのファイルを _indices/{UUID}/ に書き出し、そのメタデータを含む新しいマニフェストを原子的にコミットします。マニフェストにインデックスメタデータが含まれるため、タイムトラベルで過去バージョンに戻れば、その時点のインデックス状態も再現されます。

削除行・無効行の処理

インデックスセグメントはイミュータブルなので、作成後に削除・更新された行への参照を含む場合があります。クエリ実行時にはこれらの無効な参照を除外する必要があり、仕様は 3 つの状況を想定しています(仕様)。


(出典: Lance Index Formats

1つ目は、フラグメント内の一部の行が削除された場合です。インデックスから返された row address のうち、削除ファイルに記録されているものを結果から除外します。

2つ目は、フラグメント全体が削除された場合です。fragment_bitmap に記録されているフラグメント ID が現在のデータセットに存在しなければ、そのフラグメントに属する row address は全て無効と判断できます。

3つ目は、インデックス対象の列がインプレース更新された場合です。列が更新されると該当フラグメントは fragment_bitmap から外されるため、fragment_bitmap に含まれないフラグメントの row address をフィルタすることで古い値への参照を除外します。

コンパクションとの連携

コンパクションでフラグメントが統合されると row address が変わり、既存のインデックスセグメントが指す行が存在しなくなります。仕様は 3 つの対処法を定義しています(仕様)。

  1. 何もしない: 該当フラグメントをインデックスの対象外にする。単純だがインデックスカバレッジが即座に劣化する
  2. 即座にリマップ: インデックスセグメントを新しい row address で書き直す。インデックスは常に最新だが、コンパクションの書き込み増幅が大きい
  3. Fragment Reuse Index を作成する: 旧 row address → 新 row address の対応表を補助インデックスとして記録し、読み取り時にメモリ上でリマップする。書き込み増幅を避けつつインデックスの有効性を維持する

ここからは、Lance が提供する具体的なインデックスタイプを見ていきます。大きくスカラーインデックス、ベクトルインデックス、システムインデックスの 3 種類があります。前の 2 つはユーザーが定義するクエリ高速化用のインデックスで、システムインデックスはテーブル内部のメンテナンスに使われる補助構造です。

スカラーインデックス

スカラーインデックスは整数・タイムスタンプ・文字列等のスカラー型に対する述語(等値、範囲、集合所属、トークンマッチ等)を高速化します。仕様化されているタイプは以下の通りです。

タイプ 用途 仕様
BTree 等値・範囲検索の汎用インデックス ソート済みページと検索ページテーブルの 2 層構造
Bitmap 低カーディナリティ列の等値・IN 検索 値ごとに row address の Roaring Bitmap を格納
Label List タグ配列型(List\<String> 等)の等値検索 ラベルごとに row address のリストを格納
Zone Map 範囲述語によるフラグメント読み飛ばし フラグメントごとの min/max 統計
Bloom Filter 存在判定(false positive あり) 確率的データ構造で高速な非存在証明
Full Text Search BM25 ランキングの全文検索 転置インデックス + トークナイザ
N-gram 部分文字列マッチ(LIKE '%query%') 文字 n-gram の転置インデックス
R-Tree 地理空間クエリ(矩形範囲検索) 空間分割木
ベクトルインデックス

ベクトルインデックス仕様は高次元 embedding に対する近似最近傍検索(ANN)を定義します。仕様はインデックスを 3 つの構成要素に分離しています。

クラスタリング(Clustering) : ベクトル空間を互いに重複しないクラスタ(パーティション)に分割する層。現在は IVF(Inverted File)をサポートしており、k-means でベクトルをクラスタリングし、検索時はクエリに近い nprobe 個(走査するクラスタ数を指定するパラメータ)のクラスタだけを走査する

サブインデックス(Sub-Index) : クラスタ内でのベクトル探索方法。FLAT(全数走査)と HNSW(Hierarchical Navigable Small World グラフによる近似探索)の 2 種がある

量子化(Quantization) : ベクトルの圧縮方法。PQ(Product Quantization: サブベクトルごとの代表点 ID に置換)、SQ(Scalar Quantization: 次元ごとのスカラー量子化)、RQ(RabitQ: ランダム回転 + バイナリ量子化)、FLAT(圧縮なし)の 4 種がある

インデックスタイプは {clustering}_{sub_index}_{quantization} の組み合わせで命名されます(サブインデックスが FLAT の場合は省略)。代表的な組み合わせは IVF_PQ、IVF_HNSW_SQ、IVF_SQ、IVF_RQ です。

物理的には各ベクトルインデックスセグメントは 2 つの Lance ファイルで構成されます(V3 レイアウト、仕様)。

index.idx(インデックスファイル) : 検索構造(HNSW グラフまたは FLAT マーカー)を格納する。グローバルバッファに IVF メタデータ(各パーティションのセントロイド、オフセット、長さ)を protobuf で持つ

auxiliary.idx(補助ファイル) : 量子化されたベクトルデータを格納する。各行は _rowid(row address)と量子化コード列を持つ。グローバルバッファに量子化のコードブック(PQ)または回転行列(RQ)を格納する

インデックスが返すのは候補行の row address と距離スコアまでです。実際の行データが必要になった時点で、ファイルフォーマットのランダムアクセス経路が呼び出されます。

日本語全文検索とハイブリッド検索

FTS インデックスの内部実装には Tantivy(Rust 製全文検索エンジン)が使われています。tokenizer はカスタマイズ可能で、日本語向けには Lindera(Rust 製形態素解析器)を指定できます(tokenizer 設定ガイド)。ipadic や unidic 等の辞書を配置し、create_fts_index()base_tokenizer"lindera/ipadic" を指定するだけで日本語の形態素解析に基づく全文検索が動作します。

ベクトルインデックスと FTS インデックスを組み合わせたハイブリッド検索も可能です。ベクトル検索の結果と FTS の結果を RRF(Reciprocal Rank Fusion、各検索結果の順位の逆数を合算してスコアを統合する手法)等のリランカーで統合することで、意味的な類似度とキーワードマッチの両方を活かした検索を実現します。

LanceDB 上で Lindera を使った日本語全文検索・ハイブリッド検索の具体的な手順は AI エージェントのための軽量ハイブリッド検索 : LanceDB と Lindera を使って日本語全文検索が参考になります。

システムインデックス: Fragment Reuse Index

Fragment Reuse Index(FRI)は、コンパクションによるインデックスの無効化を防ぐための内部インデックスです。

コンパクションがフラグメント A, B を統合してフラグメント C を生成した場合、FRI は「A, B 内の旧 row address → C 内の新 row address(または削除済み)」の対応表を記録します。読み取り時にインデックスをメモリにロードした後、FRI を適用して row address を現在の値に変換します。これにより、コンパクションとインデックス構築が並行して実行可能になり(従来はコンパクションが全インデックスをリマップするため衝突していた)、テーブルレイアウトの劣化を防ぎます。

FRI はコンパクションのたびに新しい reuse version を蓄積します。全てのスカラー/ベクトルインデックスが特定の reuse version 以降に再構築されれば、その version は不要になりトリミングできます。

カタログとネームスペース

ここまでの説明は 1 つのテーブルの中で閉じていました。複数のテーブルを管理する場合、テーブルを名前で発見し、場所(URI)を解決し、作成・削除・コミットを調整する層が必要になります。Lance ではこの層を 2 つの仕様で定義しています。カタログの物理フォーマットを定める Catalog Specs と、クライアントがカタログとやり取りするための抽象インタフェースを定める Namespace Client Spec です。

Catalog Specs: カタログの実装方式

Lance は 2 種類のカタログ実装を仕様化しています(仕様)。

Directory Catalog : ストレージ(ローカルファイルシステム、S3、GCS 等)だけで動作するカタログ。追加のサービスが不要。カタログのメタデータ自体が Lance テーブルとして保存されるため、テーブル一覧の更新にもトランザクションとスナップショット分離がそのまま適用される。ML/AI ワークロードのように運用依存を最小化したい場面に適している

REST Catalog : OpenAPI ベースのクライアント・サーバ型プロトコル。アクセス制御、監査、ガバナンスといったエンタープライズ要件をサーバ側に実装できる。テーブルバージョン管理 API は External Manifest Store としても機能し、コミット調整とガバナンスポリシーの適用を兼ねることができる

Namespace Client Spec: クライアント抽象

Lance のネイティブカタログは上で見た Directory Catalog と REST Catalog ですが、実際の環境では Unity Catalog や Apache Polaris など既存のカタログシステムと連携したいケースがあります。Namespace Client Spec は、これらを含む様々なカタログシステムを LanceNamespace という単一のクライアントインタフェースで操作できるようにする仕様です。


(出典: Lance Namespace Client Spec

「Catalog」ではなく「Namespace」という用語を使っているのは、カタログシステムごとにテーブルまでの階層構造が異なるためです。

システム 階層構造
Directory Storage Root → Table(1階層)
Apache Hive Metastore Metastore → Database → Table
Unity Catalog Metastore → Catalog → Schema → Table
Apache Polaris Catalog → Namespace(任意の深さ)→ Table

Lance Namespace はこれらの差異を抽象し、どのシステムでも同じインタフェースでテーブルの発見・作成・コミットが行えます。

Namespace Client が提供する主要な操作は、ネームスペースの作成・一覧・削除、テーブルの作成・一覧・記述・削除・リネーム、テーブルバージョンの作成・一覧、インデックスの作成・削除、タグの管理、クエリ実行などです。各操作のリクエスト/レスポンスは Models として OpenAPI スキーマで定義されています。

SDK は Java(org.lance:lance-namespace-core、Spark/Flink/Trino 等のコネクタ向け)、Python(lance_namespace、Ray/Dask/MLflow 向け)、Rust(lance-namespace、コア実装)が提供されています。

サポートされるカタログ

Lance ネイティブの Directory Catalog と REST Catalog に加え、外部カタログシステムとの統合仕様が Supported Catalogs として定義されています。各統合仕様は、外部システムのオブジェクトを Lance Namespace の概念にどうマッピングするか、各操作をどう実現するかを記述します。

カタログ 説明
Lance Directory Catalog ストレージのみで動作するネイティブカタログ
Lance REST Catalog OpenAPI ベースのネイティブカタログ
Apache Iceberg REST Catalog Iceberg の REST Catalog プロトコルとの統合
Apache Polaris マルチエンジンガバナンス向けカタログ
Unity Catalog Databricks Unity Catalog との統合
Apache Gravitino Apache Gravitino との統合
Apache Hive MetaStore レガシーウェアハウス互換(V2/V3)
AWS Glue AWS Glue Data Catalog との統合
Google BigLake Google BigLake との統合
Google Dataproc Google Dataproc との統合
Microsoft OneLake Microsoft OneLake との統合

既存の Iceberg カタログや Unity Catalog を運用している環境であれば、そこに Lance テーブルを登録して名前空間を共有できます。カタログを新たに構築する必要がありません。この点は後述する Iceberg との関係でも重要です。

ハンズオン

ここからは実際に動かして、各操作がストレージ上のファイルにどう反映されるかを観察します。Lance フォーマットを直接操作する pylance と、その上の LanceDB を順に試します。

セットアップ
pip install pylance lancedb pandas numpy pyarrow

動作確認したバージョン: pylance 7.0.0、lancedb 0.33.0(2026年6月時点)。

Lance(pylance)
データセットの作成
import lance
import pandas as pd

df = pd.DataFrame({
    "id": range(5),
    "text": ["apple", "banana", "cherry", "durian", "elderberry"],
})
ds = lance.write_dataset(df, "sample.lance")

この時点でのディレクトリ構造:

sample.lance/
├── data/
│   └── 0000011...61.lance          # フラグメント0のデータファイル
├── _versions/
│   ├── 18446744073709551614.manifest  # v1 のマニフェスト
│   └── latest_version_hint.json
└── _transactions/
    └── 0-93b2f0da-...txn           # v1 のトランザクションファイル

マニフェストのファイル名 18446744073709551614u64::MAX - 1 = バージョン 1 です。辞書順ソートで最新バージョンが先頭に来る V2 命名スキームの仕組みです。

Append
df2 = pd.DataFrame({"id": range(5, 8), "text": ["fig", "grape", "honeydew"]})
ds = lance.write_dataset(df2, "sample.lance", mode="append")

この操作で data/ に 2 つ目のデータファイル(フラグメント 1)が追加され、_versions/ に v2 のマニフェストが追加されます。既存のファイルは一切変更されません。

列の追加
ds = lance.dataset("sample.lance")
ds.add_columns({"text_len": "length(text)"})

この操作で data/ にデータファイルが 2 つ追加されます(フラグメント 0 用とフラグメント 1 用に各 1 つ)。これらは text_len 列だけを格納したファイルで、既存の id/text 列のファイルは書き直されていません。テーブルフォーマットの「1 フラグメントに複数データファイル」構造がここで機能しています。

sample.lance/data/
├── 0000011...61.lance   # フラグメント0: id, text (v1で作成)
├── 111010...9e.lance    # フラグメント1: id, text (v2で作成)
├── 001010...fc.lance    # フラグメント0: text_len (v3で作成)
└── 110101...ac.lance    # フラグメント1: text_len (v3で作成)
削除
ds = lance.dataset("sample.lance")
ds.delete("id = 3")

この操作で _deletions/ ディレクトリに削除ファイルが 1 つ追加されます。データファイルは変更されません。

sample.lance/_deletions/
└── 0-3-7954388244994285661.arrow   # フラグメント0, バージョン3起点

ファイル名 0-3-...arrow は「フラグメント 0 に対する、バージョン 3 で作成された削除ファイル」を意味します。中身は Arrow IPC 形式で、削除された行のオフセット配列が格納されています。

バージョンとタイムトラベル
ds = lance.dataset("sample.lance")
for v in ds.versions():
    print(f"v{v['version']}")
# v1, v2, v3, v4

# 過去バージョンの読み出し
old = lance.dataset("sample.lance", version=1)
old.count_rows()  # 5(削除前の状態)
ランダムアクセス
ds.take([0, 3]).to_pandas()
#    id        text  text_len
# 0   0       apple         5
# 1   4  elderberry        10

take はスキャンではなく、指定した行だけをランダムアクセスで読み出します。id=3 は削除済みなので、論理的な行 3 は id=4 の行です。

LanceDB
テーブルの作成
import lancedb

db = lancedb.connect("lancedb-demo")
table = db.create_table("docs", data=[
    {"vector": [0.1, 0.2, 0.3, 0.4], "text": "Tokyo is the capital of Japan", "year": 2024},
    {"vector": [0.2, 0.1, 0.4, 0.3], "text": "Osaka is a city of commerce", "year": 2023},
    {"vector": [0.9, 0.8, 0.7, 0.6], "text": "Kyoto is the ancient capital", "year": 2024},
])

ディレクトリ構造:

lancedb-demo/
├── __manifest/             # ディレクトリカタログ(テーブル一覧の管理)
│   ├── _versions/
│   └── _transactions/
└── docs.lance/             # "docs" テーブルの実体
    ├── data/
    │   └── 111100...43.lance
    ├── _versions/
    └── _transactions/

__manifest/ディレクトリカタログの実体です。lancedb.connect("ディレクトリ") と書くだけでテーブルの作成・一覧ができるのは、この仕組みによるものです。

ベクトル検索 + フィルタ
table.search([0.1, 0.2, 0.3, 0.4]).where("year = 2024").limit(2).to_pandas()
#                             text  year  _distance
# 0  Tokyo is the capital of Japan  2024        0.0
# 1   Kyoto is the ancient capital  2024        1.2
全文検索
table.create_fts_index("text", use_tantivy=False)
table.search("capital", query_type="fts").limit(2).to_pandas()
#                             text    _score
# 0  Tokyo is the capital of Japan  0.470004
# 1   Kyoto is the ancient capital  0.470004

FTS インデックス作成後のファイル変化:

docs.lance/_indices/37ff8055-.../
├── metadata.lance       # インデックスメタデータ
├── part_0_docs.lance    # ドキュメントストア
├── part_0_invert.lance  # 転置インデックス
└── part_0_tokens.lance  # トークン辞書

インデックスフォーマットの説明通り、_indices/{UUID}/ ディレクトリに Lance ファイルとして格納されています。なお、デフォルトのトークナイザは英語向けのため、日本語テキストを扱う場合は Lindera 等の設定が必要です(日本語全文検索とハイブリッド検索を参照)。

ベクトルインデックス(IVF_PQ)
import numpy as np

big = db.create_table("embeddings", data=[
    {"vector": np.random.rand(128).tolist(), "id": i} for i in range(1000)
])
big.create_index(index_type="IVF_PQ", num_partitions=16, num_sub_vectors=8)

ファイル変化:

embeddings.lance/_indices/99d93b7d-.../
├── index.idx        # 検索構造(IVFメタデータ + FLATマーカー)
└── auxiliary.idx    # 量子化ベクトルデータ(PQコード + row address)

ベクトルインデックス仕様で説明した V3 レイアウト通り、index.idxauxiliary.idx の 2 ファイル構成です。

big.search(np.random.rand(128).tolist()).limit(3).to_pandas()
#     id  _distance
# 0  147  13.361778
# 1  169  13.781700
# 2  258  13.803561

Iceberg との関係

記事中で何度か Iceberg に触れてきたので、両者の位置づけを整理します。概要の節で見たレイクハウススタックの図に沿って、層ごとの対応を並べると次の通りです。

従来のスタック Lance
ファイルフォーマット Parquet、ORC、Avro Lance File Format
テーブルフォーマット Iceberg、Delta Lake、Hudi Lance Table Format
インデックス 標準仕様なし(統計情報を格納する Puffin が近い位置づけ) Lance Index Formats
カタログ Iceberg REST Catalog、AWS Glue、Polaris、Unity Catalog 等 Lance Namespace(左の既存カタログをバックエンドにできる)

ファイルフォーマット層とテーブルフォーマット層では、Lance は Parquet/Iceberg と同じ役割を担う別仕様であり、どちらを選ぶかという関係です。インデックス層は Lance 独自の領域で、Iceberg 側に対応する標準仕様はありません。カタログ層では競合せず、Lance Namespace は Iceberg REST Catalog を含む既存カタログをそのまま利用できます。Lance は中間 2 層を置き換えつつ、上下の層(カタログとオブジェクトストレージ)は既存のエコシステムと共有する構造です。

Lance と Parquet/Iceberg は対象ワークロードが異なります。大量データの集計・スキャンが中心の BI/分析ワークロードでは Parquet + Iceberg のエコシステム(Spark、Trino、Snowflake、BigQuery 等のエンジン対応)が成熟しています。一方、ベクトル検索、学習データの管理、embedding の頻繁な追加といった AI ワークロードでは、Lance のランダムアクセス性能とインデックス統合が有効です。

両者は対立ではなく統合に向かっています。Apache Iceberg の PMC メンバーでもある LanceDB の Jack Ye 氏が Apache Iceberg Meetup で報告した内容(セッションレポート)によると、統合は 3 つの層で並行して進んでいます。

  1. ファイルフォーマット統合: Lance ファイルを Iceberg テーブルのデータファイルとして使用する。マルチモーダルデータの格納とランダムアクセス性能が主なユースケース。Lance 2.1 で圧縮率を改善し、ZoneMap/Bloom Filter を追加してフラグメントを Parquet に近い振る舞いにしたことで実用段階に入っている
  2. カタログ統合: Iceberg REST Catalog、Polaris、Gravitino、AWS Glue、Unity Catalog 等の主要カタログに Lance テーブルを登録し、Iceberg テーブルと同居させる。Lance Namespace の REST API は Iceberg REST Catalog の規格に寄せて設計されている
  3. CDC による同期: Lance の行リネージ(Stable Row ID)に基づく Change Data Capture で、Lance テーブルと Iceberg テーブルを双方向に同期する。フォーマット統合に踏み込まない場合の代替手段

Netflix、Uber、ByteDance、Alibaba、Luma AI 等がこの統合に協力しており、コミュニティ主導で進行しています。

さいごに

本エントリでは Lance と LanceDB について、登場の背景からフォーマットの構造、実際の動作までを確認しました。要点をまとめます。

  • Lance はマルチモーダル AI ワークロード向けのレイクハウスフォーマットで、ファイル / テーブル / インデックス / カタログの疎結合な仕様スタックとして設計されている
  • ファイルフォーマットは row group を廃したページベースの設計と適応的なエンコーディングにより、スキャン性能を保ったままランダムアクセスを高速化している
  • テーブルフォーマットは「1 フラグメントに複数データファイル」という構造により、列の追加・埋め戻しを書き直しなしで実現している
  • インデックスはテーブルの一級オブジェクトであり、データと同じトランザクション・バージョニングの管理下に置かれる
  • LanceDB はその上の組み込み型データベースで、ベクトル検索・全文検索・フィルタリングをサーバプロセスなしで提供する

Lance 周辺は動きが速く、2026 年に入ってからも Hugging Face Hub のネイティブ対応Git 風のブランチとシャロークローンDuckDB 連携Apache Spark 連携などが進んでいます。Iceberg との統合も含め、今後も注視していく予定です。

参考資料