HN 日本語サマリー

← 一覧へ戻る
AI・機械学習

検索インデックスがS3上に存在する場合、何が変わるのか?

What changes when your search index lives on S3? (infino.ai)

14 pointsby tatvamasi11 コメント

要約

Infinoは、大規模なエージェントの質問応答のための検索エンジンを構築しており、その全文検索は最速クラスです。データがオブジェクトストレージに存在することが多いため、InfinoはS3、GCS、Azure Blob Storageをサポートするオブジェクトストレージ上に検索インデックスを構築することを決定しました。従来の検索エンジンがローカルディスクを前提としているのに対し、オブジェクトストレージ上ではランダム読み込みがネットワーク往復となるため、設計上の多くの決定がこの制約を最適化するために行われました。

全文翻訳

検索インデックスをS3に置くと何が変わるか?

2026年10月15日 · engineering

Infinoがどのように構築されているかについてのシリーズ第1部

Infinoでは、大規模なエージェントの質問に答えるための検索エンジンを構築しており、その全文検索は利用可能な中で最も高速なものの一つです[1]。エージェントは構造化、半構造化、非構造化データをクエリするため、SQL、ベクトル検索、BM25全文検索、およびこれらのクエリ形状の任意の組み合わせをサポートする必要があります。ほとんどの場合、データはすでにオブジェクトストアに存在しており、スケーラビリティと高可用性が保証されています。そのため、S3、GCS、Azure Blob Storageをサポートするオブジェクトストア上に検索インデックスを構築することにしました。

検索エンジンは伝統的に、インデックスをローカルディスクに保持してきました。Luceneは、ほとんどの検索エンジンがその上に構築されているか、またはそれをモデルにしているため、インデックスがCPUの隣のディスク上に存在し、ランダムシークが比較的安価であることを前提としています。しかし、インデックスをオブジェクトストレージに移動すると、すべてのランダム読み取りがネットワークの往復になるため、状況は異なります。この記事では、そのために私たちがどのように設計上の決定を異なったかについて説明します。

アーキテクチャ

[画像: S3内のファイルとして保存されたスーパーテーブル:各スーパーファイルと各ファイルのフッターおよびインデックスヘッダーをリストするマニフェスト、それらを格納するスーパーファイルに単語をマッピングするタームインデックス(refundはsf-03とsf-06に)、そして6つのスーパーファイル。各スーパーファイルは、行、全文インデックス、ベクトルインデックス、およびフッターを保持する通常のParquetファイルです。refundの検索はマニフェストを読み取り、タームインデックスからrefundがsf-03とsf-06にのみ含まれていることを知り、それらの2つのファイルのその部分のみを読み取ります。]

Infinoはデータをスーパーファイルに整理します。これはInfinoの全文インデックスとベクトルインデックスを含むParquetファイルです。多くのスーパーファイルがスーパーテーブルを形成します。各スーパーテーブルは、(a)スーパーファイルとそのフッター、インデックスヘッダー、列統計情報をリストするマニフェストと、(b)タームとその出現スーパーファイルおよびスコア境界情報を含むタームインデックスを持っています。

コストモデル

ローカルSSDでは、ページがキャッシュされていればランダム読み取りは無料であり、ページフォールトの場合でも1ミリ秒未満で済みます。そのため、SSD上にインデックスを持つ検索エンジンは、ランダム読み取りを惜しみなく行うことができます。S3、GCS、Azure Blobでは、すべてのランダム読み取りはHTTP GETリクエストであり、通常は数十ミリ秒かかり、返されるバイト数に関係なくリクエストごとに課金されます。これにより、オブジェクトストレージ上で検索エンジンを構築する際に最適化する必要があるものが変わります。最も希少なリソースはCPUでもバイト数でもなく、オブジェクトストアへの往復回数であり、以下の設計上の決定のほとんどはこのことから導かれています[2]。

インデックス単位あたりのファイルは1つ

検索インデックスは不変の単位として構築されます。ドキュメントのバッチを取り込むと、エンジンはそのための自己完結型インデックスを書き込みます。更新も新しい単位として書き込まれ、後でバックグラウンドのマージプロセスが古い単位を新しい不変の単位に結合します。Luceneでは、不変の単位はセグメントと呼ばれます。Infinoではスーパーファイルと呼ばれます。Infinoが使用するインデックスデータ構造は、後の記事のトピックですが、興味のある読者はここで読むことができます:スーパーファイルアーキテクチャ。

Luceneセグメントは一連のファイルです:ターム辞書(.tim)、ターム辞書へのインデックス(.tip)、投稿リスト(.doc)、位置情報(.pos)など。デフォルトでは、Luceneは小さなセグメントを複合ファイル(.cfs、エントリテーブルは.cfe)にパックしますが、大きなセグメントは個別のファイルを保持します。いずれにしても、オブジェクトストレージでセグメントを開くには複数のGETリクエストが必要です。

Infinoのスーパーファイルは常に単一のオブジェクト、つまりプレーンなParquetファイルです。ドキュメントは通常の行グループであり、全文インデックスとベクトルインデックスは、フッターのメタデータ内のキーによって配置されたParquetフッターの直前にバイト領域として挿入されます。DuckDB、PyArrow、またはその他のParquetリーダーは、このファイルを通常のParquetファイルとして読み取ることができます。各セクションのバイトオフセットはメタデータに記録されているため、スーパーファイルのどのセクションでも単一の範囲指定GETで取得できます。

マニフェスト

各スーパーテーブルには、スーパーテーブルを構成するスーパーファイルをリストするマニフェストがあります。書き込みは新しいスーパーファイルを作成し、それらを含む新しいマニフェストを書き込み、その後、小さなポインターファイルを新しいマニフェストにアトミックにスワップするため、リーダーは常に一貫したスーパーファイルのセットを参照します。

全文インデックスとベクトルインデックスのバイトオフセットは、スーパーファイルの末尾にあるParquetフッターに格納されます。全文インデックスには、リーダーがタームを検索する前に必要とする小さなヘッダーもあります。クエリがこれらをすべてのスーパーファイルから読み取ると、検索作業が始まる前にスーパーファイルごとに余分なGETが必要になります。そのため、マニフェストは各スーパーファイルのParquetフッターとインデックスヘッダーをインラインで格納します。マニフェストは通常キャッシュされるため、すでにParquetフッターとインデックスヘッダーを保持していることから、スーパーファイルを開くのにGETは一切必要ありません。クエリがスーパーファイルに対して行う最初のGETは、投稿リストのような実際に必要なデータをフェッチします。これにより、各スーパーファイルのコールドコストが2回の往復から1回に削減されます。

マニフェストは、各スーパーファイルの列統計情報(各列の最小値、最大値、NULLカウントなど)も格納します。created_at > '2026-10-01'のようなフィルターを持つクエリは、その最大値よりも古いcreated_atを持つスーパーファイルを、開かずにスキップできます。しかし、列統計情報だけでは、スーパーファイルに「refund」という単語が含まれているかどうかはわかりません。これは次のセクションで説明するタームインデックスの役割です。

タームインデックス

タームインデックスがない場合、全文クエリは、クエリのタームが含まれているかどうかをすべてのスーパーファイルに問い合わせる必要があります。これには、各スーパーファイルのターム辞書を読み取る必要があり、タームが含まれていないことが判明したスーパーファイルであっても、スーパーファイルごとに少なくとも1回のGETが必要です。スーパーテーブルは数千のスーパーファイルを持つことができるため、これはすぐに高価になります。

そのため、各スーパーテーブルにはタームインデックスもあり、すべてのタームとその出現スーパーファイルがリストされています。各タームとスーパーファイルについて、それは(a)そのスーパーファイル内でそのタームを含むドキュメント数、(b)スコア境界、つまりそのスーパーファイル内のどのドキュメントにもそのタームが与えることができる最大のBM25スコア、および(c)スーパーファイル内のタームの投稿リストのバイトオフセットを格納します。タームインデックスはターム範囲によってスライスに分割されているため、クエリはインデックス全体ではなく、そのタームを含むスライスのみをフェッチします。

[画像: refundのクエリは、qからzまでのタームインデックススライスのみをフェッチします。その行は、sf-03(812ドキュメント、スコア境界7.4、バイト1.2Mの投稿リスト)とsf-06(95ドキュメント、スコア境界9.1、バイト88Kの投稿リスト)にrefundがあることを示しています。6つのスーパーファイルのうち、sf-06と次にsf-03のみが読み取られ、それぞれ1回の範囲指定GETで、残りはスキップされます。カウントクエリはファイルなしで済みます:812 + 95 = 907。]

これにより、「refund」のクエリは、まずタームインデックスで「refund」を検索し、sf-03とsf-06のみに含まれていることを発見します。他のすべてのスーパーファイルは開かずにスキップされます。タームインデックスには投稿リストのバイトオフセットも含まれているため、クエリはターム辞書を読み取ることなく、1回の範囲指定GETでsf-03とsf-06から「refund」の投稿リストをフェッチします。

ドキュメント数とスコア境界も役立ちます。トップkクエリの場合、クエリはスコア境界の降順でスーパーファイルを訪問し、スコア境界がそれまでに見つかったk番目に良いスコアよりも低い場合、スーパーファイルをスキップします。単一タームのカウントクエリは、ドキュメント数のみから、スーパーファイルを開くことなく回答できます[3]。テストの1つでは、141のスーパーファイルを持つスーパーテーブルに対する単一タームカウントが、141のスーパーファイルを開くことから、何も開かないようになりました。同じドキュメント数は、スコアリングに必要なコーパス全体の統計情報もBM25に提供します。

タームインデックスは、コミットごとに最新の状態に保たれます。スーパーファイルを追加するコミットは、それらのタームをタームインデックスに追加し、バックグラウンドの最適化ステップが定期的にこれらの追加を1つのコンパクトなインデックスにマージします。

必要なものだけを読む

スーパーファイルは1ギガバイトを超えることがありますが、クエリは通常その一部しか必要としません。そのため、Infinoはバイト範囲でスーパーファイルを遅延読み込みします。

クエリがタームを必要とする場合、そのタームの投稿リストのみをフェッチします。BM25がスコアリングに必要とするドキュメント長は、クエリがスコアを計算するときにのみフェッチされるため、カウントクエリはそれらを読み取ることはありません。位置情報は独自の領域に格納されており、フレーズクエリが単語の順序を確認する必要がある場合にのみフェッチされます。

クエリがファイル内で互いに近い複数のタームの投稿リストを必要とする場合、それらの間のギャップが小さい(最大64 KB)限り、Infinoはそれらを1回の要求でフェッチします。これは、数バイト余分に読み取る方が、別の往復を行うよりも安価であるためです。クエリがすでにフェッチしたバイトは、クエリ内で再利用されるため、何も2回フェッチされることはありません。

ディスクとメモリでのキャッシュ

数回のGETでもローカルディスクからの読み取りより遅くなります。そのため、Infinoはスーパーファイルをローカルディスクにもキャッシュします。クエリがスーパーファイルに初めてアクセスするとき、オブジェクトストレージから必要なバイト範囲を直接読み取りますが、Infinoはバックグラウンドでスーパーファイル全体をローカルディスクにダウンロードします[4]。ダウンロードが完了すると、スーパーファイルはメモリマップされ、それに対する後続のクエリは一切GETを行わなくなります。ディスクキャッシュにはサイズ予算(デフォルトで10 GBですが、ローカルディスクのサイズに合わせて設定可能)があり、いっぱいになると最も最近使用されていないスーパーファイルがエビクトされます。

メモリ内のキャッシュはメモリマップから発生します。オペレーティングシステムは、頻繁に読み取られるスーパーファイルのページをRAMに保持し、Infinoは最近クエリされていないスーパーファイルのページを解放します。マニフェストとタームインデックスは、通常すべてのクエリで読み取られるため、積極的にキャッシュされます。

結論

検索インデックスをオブジェクトストレージに移動すると、最適化する必要があるものが変わります。ローカルディスクでは、ランダム読み取りはほぼ無料ですが、オブジェクトストレージでは、すべてのランダム読み取りは数十ミリ秒かかり、リクエストごとに課金されるGETです。そのため、Infinoの設計のほとんどは、往復回数を減らすことにあります。スーパーファイルは単一のオブジェクトであるため、クエリは1つのインデックス単位に対して多くのファイルを開く必要がありません。マニフェストは各スーパーファイルのフッターとインデックスヘッダーを保持しているため、スーパーファイルを開くのにGETは必要ありません。タームインデックスは、クエリがどのスーパーファイルにタームが含まれているかを伝えるため、残りをスキップします。クエリは必要なバイト範囲のみを読み取り、ディスクとメモリのキャッシュにより、繰り返しクエリを行う場合、GETはほとんど行われません。

しかし、検索エンジンのコアは変わっていません。投稿リスト、BM25スコアリング、ブロックマックスプルーニング、クエリアルゴリズムは、Luceneが使用するのと同じアイデアであり、いくつかの場所でInfinoはLuceneが先駆けたアイデアを直接構築しています。変わったのはストレージレイヤーです。なぜなら、コストモデルが変わったのがそこだからです。

次の記事では、スーパーファイル内のインデックスデータ構造と、Infinoが多数のスーパーファイルにわたってクエリを並列実行する方法について詳しく説明します。

ここまでお読みいただき、当社のオープンソースリポジトリ(infino-ai/infino)をGitHubでスターしていただけると幸いです。

フットノート

  1. Search Benchmark Game:Infinoと他の検索エンジンの比較。2026年10月13日、500万件のWikipediaドキュメントで実行。infino-ai.github.io/search-benchmark-game/full/ ↩
  2. CPUとバイト数も依然として重要ですが、オブジェクトストアへの往復回数ほど重要ではありません。↩
  3. ドキュメントが削除されていない限り。Infinoのバックグラウンド最適化ステップを経ていない削除がある場合、クエリはまだスーパーファイルを開いて、どのマッチしたドキュメントがまだ有効かを確認する必要があります。↩
  4. バックグラウンドダウンロードは、クエリがまだスーパーファイルを遅延読み込みしている間待機するため、帯域幅を競合せず、クエリレイテンシを低く保ちます。トレードオフとして、スーパーファイルはオブジェクトストレージから2回読み取られる可能性があります:一度は範囲で、一度は全体で。↩

クイックスタート → github ↗ 無料で始める →