7 ポイント 投稿者 GN⁺ 2025-03-06 | 1件のコメント | WhatsAppで共有

テキスト埋め込みをポータブルに扱う最良の方法はParquetとPolars

  • テキスト埋め込みは大規模言語モデルによって生成されたベクトルで、単語、文、文書を数値的に表現する方法
  • 2025年2月時点で、合計32,254件の「マジック:ザ・ギャザリング」カード埋め込みを生成
  • これにより、カードのデザインおよびメカニクス上の特性に基づく類似性を数学的に分析できる
  • 生成した埋め込みは2DのUMAP次元削減によって可視化できる
  • 使用した埋め込みモデルは gte-modernbert-base で、詳細な手順は GitHubリポジトリ にまとめられている
  • この埋め込みデータセットは Hugging Face で提供されている

ベクトルデータベースの必要性を見直す

  • 一般的にはベクトルデータベース(faissqdrantPinecone)を使って埋め込みを保存・検索する
  • しかしベクトルデータベースは設定が複雑で、クラウドサービスはコストが高くなることがある
  • 小規模データ(数万件レベル)であれば、ベクトルデータベースなしでもnumpyを使って高速な類似度検索が可能
  • numpyの dot product 演算を使えばシンプルなコサイン類似度計算ができ、32,254件の埋め込みに対して平均1.08msで処理できる
def fast_dot_product(query, matrix, k=3):  
    dot_products = query @ matrix.T  
  
    idx = np.argpartition(dot_products, -k)[-k:]  
    idx = idx[np.argsort(dot_products[idx])[::-1]]  
  
    score = dot_products[idx]  
  
    return idx, score  
  • ベクトルデータベースを使うと、特定のライブラリやサービスに依存する可能性が高い
  • GPUサーバーで埋め込みを生成してからローカルにダウンロードする場合、効率的なデータ保存・転送方式が必要になる

最悪の埋め込み保存方式

  • CSVファイル
    • 浮動小数点(float32)データをテキストとして保存すると、サイズが6倍以上に増える
    • OpenAIの公式チュートリアルでも、小さなデータセットにのみCSVの使用を推奨している
    • numpyの .savetxt() を使って保存すると、ファイルサイズは 631.5MB に増加する
  • pickleファイル
    • 高速に保存・読み込みできるが、セキュリティリスクがあり、バージョン互換性も低い
    • ファイルサイズは 94.49MB で元のメモリサイズと同じだが、移植性は低い

悪くはないが最適ではない保存方式

  • numpyの .npy 形式
    • allow_pickle=False 設定によってpickle保存を防げる
    • ファイルサイズと速度はpickle方式と同じで、個別のメタデータを一緒に保存しにくい
  • メタデータと分離された保存構造の問題点
    • numpy配列(.npy)で保存すると、カード情報(名前、テキストなど)と埋め込みが分離される
    • データが変更(追加・削除)された場合、メタデータと埋め込みの対応付けが難しくなる
    • ベクトルデータベースではメタデータとベクトルを一緒に保存し、フィルタリング機能も提供する

最適な埋め込み保存方式: Parquet + polars

Parquetファイル形式の紹介

  • Apache Parquet はカラム指向のデータ保存形式で、各カラムのデータ型を明確に指定できる
  • リスト形式(float32 配列)のデータを保存できるため、埋め込みの保存に適している
  • CSVより高速な保存・読み込み性能を提供し、一部のデータだけを選択的に読み込める
  • 圧縮機能もあるが、埋め込みデータは重複が少ないため圧縮効果は小さい

PythonでのParquetファイル活用

  • pandas を使ったParquetファイルの保存・読み込み:
    df = pd.read_parquet("mtg-embeddings.parquet", columns=["name", "embedding"])  
    df  
    
    • pandasはネストされたデータ(リスト)を効率的に処理できず、numpy object に変換される
    • numpy配列に変換する際に追加の演算(np.vstack())が必要となり、性能低下が起こりうる
  • polars を使ったParquetファイルの保存・読み込み:
    df = pl.read_parquet("mtg-embeddings.parquet", columns=["name", "embedding"])  
    df  
    
    • polarsは float32 配列をそのまま維持し、to_numpy() 呼び出し時に即座に2Dのnumpy配列を返せる
    • allow_copy=False 設定によって不要なデータコピーを防げる
    embeddings = df["embedding"].to_numpy(allow_copy=False)  
    
  • 新しい埋め込みを追加する場合も、簡単にカラムを追加して保存できる
    df = df.with_columns(embedding=embeddings)  
    df.write_parquet("mtg-embeddings.parquet")  
    

Parquet + polarsを使った類似度検索とフィルタリング

  • 特定条件を満たすデータだけをフィルタリングしたうえで、類似度検索を実行できる
  • 例: 特定のカード(query_embed)に似たカードを探す際に、タイプが 'Sorcery' で色に 'Black' を含むカードだけを検索する
    df_filter = df.filter(  
        pl.col("type").str.contains("Sorcery"),  
        pl.col("manaCost").str.contains("B"),  
    )  
    
    embeddings_filter = df_filter["embedding"].to_numpy(allow_copy=False)  
    idx, _ = fast_dot_product(query_embed, embeddings_filter, k=4)  
    related_cards = df_filter[idx]  
    
  • 平均実行時間は 1.48ms で、全データ検索より37%遅いが、それでも十分高速

大規模ベクトルデータ処理のための代替案

  • Parquetとdot product方式は 数十万件の埋め込み までは十分に処理できる
  • より大きなデータセットを扱う場合は、ベクトルデータベースの利用が必要になる可能性がある
  • 代替案として、SQLite ベースの sqlite-vec を使えば、追加のベクトル検索やフィルタリングも可能

結論

  • ベクトルデータベースは必須ではない
  • Parquet + polars の組み合わせは、埋め込みを効率的に保存・検索・フィルタリングできる強力な代替手段
  • 特に小規模プロジェクトでは、Parquetファイルを活用するほうが高速でコスト効率も高い
  • プロジェクトに応じて、Parquetとベクトルデータベースのどちらが適切かを選ぶことが重要
  • GitHubリポジトリ でコードとデータを確認できる

1件のコメント

 
GN⁺ 2025-03-06
Hacker Newsのコメント
  • Parquetの問題は静的であること。継続的な書き込みや更新が必要な場合には向いていない。ただし、DuckDBとオブジェクトストレージ上のParquetファイルを使ったときは良い結果が得られた。ロード時間も速い

    • 独自の埋め込みモデルをホストするなら、numpy float32の圧縮配列をバイト列として送信し、その後numpy配列にデコードできる
    • 個人的にはSQLiteとusearch拡張を使う方が好み。バイナリベクトルを使ったあと、上位100件をfloat32で再ランキングする。約20,000件の項目で約2msかかり、LanceDBより速い。より大きなコレクションではLanceが勝つかもしれない。しかし私のユースケースでは、各ユーザーが専用のSQLiteファイルを持っているため、うまく機能している
    • 可搬性のためにはLitestreamがある
  • 本当に素晴らしい記事。長い間あなたの仕事を楽しんできた。SQLite実装に飛び込む人向けに付け加えると、DuckDBはParquetを読み込み、このユースケースを完璧に扱ういくつかのベクトル類似度機能を提供し始めている

  • 依然としてデータフレームはあまり好きではないが、Polarsはpandasよりはるかに良い

    • 時系列計算をしていて、基本的には単純な株価調整を行っていた
    • コードの読みやすさとテストのしやすさが実際に成立している点に驚いた
    • 実行速度があまりに速く、壊れているように見えた
  • Unumのusearchを見てみてほしい。何にでも勝てて、とても使いやすい。必要なことを正確にこなしてくれる

  • 試してみたいなら、HFから遅延ロードしてフィルタリングを適用できる

    • Polarsは使っていて素晴らしく、強くおすすめする。単一ノードでCPUを飽和させるのに優れており、処理を分散する必要があるなら、Ray ActorにPOLARS_MAX_THREADSを適用して、単一ノードの飽和度に応じて調整できる
  • 優れた発見がたくさんある

    • 構造化データを埋め込みAPIに渡す方がよいのか、非構造化データを渡す方がよいのか気になる。ChatGPTに聞くと、非構造化データを送る方がよいと言う
    • 私のユースケースはjsonresume向け。現在はjson全体を文字列として送って埋め込みを生成しているが、まずresume.jsonを全文テキスト版に変換してから埋め込みを生成するモデルも試している。結果はその方が良さそうだが、これについての具体的な意見は見たことがない
    • 非構造化データの方がよい理由は、自然言語によってテキスト的・意味論的な意味を含むからだ
  • Vespaのドキュメントには、ベクトルを2進数に変換したあと16進表現を使う巧妙なテクニックがある

    • このテクニックはペイロードサイズを減らすために使える。Vespaはこの形式をサポートしており、同じベクトルが文書内で複数回参照される場合に特に有用。ColBERTやColPaLiのようなケース(複数の埋め込みベクトルがある場合)では、ディスクに保存されるベクトルのサイズを大幅に削減できる
  • Polars + Parquetは可搬性と性能の両面で素晴らしい。この投稿はPythonの可搬性に重点を置いていたが、Polarsにはエンジンをさまざまな場所に組み込める使いやすいRust APIがある

  • Polarsの熱心なファンだが、これを使って埋め込みを保存する方法は考えたことがなかった(sqlite-vecで実験していた)。本当に興味深いアイデアだと思う

  • 優れた性能と、全文インデックス作成や変更履歴のバージョン管理といった機能を備えた別のライブラリとして、lancedbを勧める

    • ベクトルデータベースであり、より複雑ではあるが、インデックスを作成せずに使うこともでき、優れたpolarsおよびpandasのゼロコピーArrowサポートもある