OpenSearch の検索基盤

Lucene Internals

OpenSearch の検索基盤である Apache Lucene の内部構造を解説する。セグメントの不変性を出発点として、書き込み、データ構造、検索、最適化、削除、および OpenSearch との接続点を順に扱う。

01Lucene の位置づけ

OpenSearch における 1 つの shard は、1 つの Lucene インデックスに対応する。OpenSearch は分散処理、ノード間の調整、および永続性の保証を担い、検索とインデクシングの実体は Lucene が担う。

OpenSearch が持つ層と Lucene が持つ層。1 shard が 1 Lucene index に対応し、その中にセグメントが並ぶ
FIG 01OpenSearch と Lucene の担当範囲

Lucene の設計上の特徴の多くは、セグメント(segment)が不変(immutable)であるという性質から導かれる。インデックスはセグメントの集合であり、各セグメントは自己完結した転置インデックス(inverted index)である。セグメントは一度書き出されると変更されない。この性質は次の帰結をもたらす。

  • 読み取り側はロックを必要としない。書き込みと検索が競合しない
  • OS のページキャッシュと親和性が高い。変更されないファイルはキャッシュが無効化されない
  • ドキュメントの更新は「削除マークの付与と新規追加」として表現される。in-place update は存在しない
  • セグメント数が増加し続けるため、マージ(merge)が不可欠な処理となる

この設計の代償として、更新頻度の高いワークロードでは書き込み増幅(write amplification)が生じる。1 フィールドの変更であっても、ドキュメント全体を再インデックスする必要がある。

02書き込みパス

addDocument から DWPT、flush でセグメント、commit で fsync までの流れ。translog が並走する
FIG 02ドキュメントが永続化されるまで

インデクシングは IndexWriter を通じて行われる。addDocument で渡されたドキュメントは、まず DocumentsWriterPerThread(DWPT)が保持するメモリバッファに蓄積される。DWPT は DWPT プールからインデクシングスレッドに割り当てられ、各 DWPT は独立したバッファを持つため、スレッド間で共有状態をほとんど参照しない。

バッファが RAM バッファの上限に達すると、内容がセグメントとして書き出される。この処理を flush と呼ぶ。上限は Lucene では IndexWriterConfig.setRAMBufferSizeMB、OpenSearch では indices.memory.index_buffer_size によって制御される。

flush の時点では、セグメントを構成するファイルは fsync されていない。永続化は commit によって行われる。commit はセグメント一覧を記録した segments_N ファイルを書き出し、関連するすべてのファイルに fsync を発行する。commit はコストの高い操作であるため、OpenSearch は commit の頻度を抑え、その間の永続性を translog によって保証する。

検索可能になること(可視性)と、障害時に失われないこと(永続性)は独立に制御される。詳細は 09 章で述べる。

03用途ごとのデータ構造

Lucene は、1 つのフィールドを用途に応じて複数の形式で格納する。

転置インデックス、DocValues、BKD tree、stored fields の4つに、1つのフィールドが書き分けられる
FIG 031 つのフィールドが、用途に応じて複数の形式で格納される
転置インデックス
term からドキュメントへの対応を保持する。全文検索、完全一致、前方一致などに用いられる。
DocValues
ドキュメントから値への対応を列指向(column-oriented)で保持する。ソート、集約、スクリプトからの参照に用いられる。転置インデックスとは対応の向きが逆であるため、同じデータを二重に保持することになる。
BKD tree
数値、日付、地理座標を格納する。ディスク常駐の k-d 木(block k-d tree)であり、範囲検索を対数時間で処理できる。これらの型は転置インデックスには格納されない。
stored fields
ドキュメントの元の内容をチャンク単位で圧縮して保持する。_source の取得に用いられ、検索処理には関与しない。

型の選択はこの構造に基づいて行う。完全一致のみが必要であれば keyword 型(転置インデックスによる term lookup)が適し、範囲検索が必要であれば数値型(BKD tree)が適する。

同一データを複数の形式で保持するため、ディスク使用量と書き込みコストは累積する。index: falsedoc_values: false_source の無効化などにより、不要な構造を省略できる。

04term からドキュメントを引く経路

転置インデックスは、term dictionary とポスティングリスト(posting list)から構成される。

検索 term が FST を走査して .tim のブロックに至り、そこから .doc のポスティングリストへ。.pos は必要になるまで読まない
FIG 04検索 term "lucene" を引く場合
.tip
term index。FST(Finite State Transducer、有限状態トランスデューサ)として実装され、term の接頭辞と .tim 内のブロック位置を保持する。共通接頭辞を状態として共有するため、辞書サイズに対してメモリ使用量が小さい。
.tim
term dictionary の本体。term をブロック単位で格納する。FST によってブロックが特定された後、ブロック内は線形探索される。
.doc
ポスティングリスト。各 term について、その term を含むドキュメントの ID と出現頻度(term frequency)を保持する。
.pos / .pay
term の出現位置と、ペイロードまたはオフセット。フレーズ検索やハイライトで参照され、必要になるまで読み込まれない。

term index が FST で構成されていることには、次の利点がある。前方一致クエリ、ワイルドカードクエリ、正規表現クエリは、FST 上でオートマトンを走査する処理に帰着するため、効率的に実行できる。一方、先頭にワイルドカードを含むクエリ(例:*foo)は探索空間を枝刈りできず、全 term の走査を要する。

05ポスティングリストの符号化とスキップ

doc ID を差分にし、128 件のブロックごとに FOR / PFOR で詰め、skip data でブロックごと読み飛ばす 3 段の流れ
FIG 05差分化、ブロック単位の符号化、ブロック単位のスキップ

ポスティングリスト中の doc ID は昇順に並ぶ。Lucene はこれを差分(delta)に変換したうえで、128 件ごとのブロックに分割し、ブロックごとに必要なビット幅で詰める。この符号化方式を FOR(Frame of Reference)と呼び、Lucene の実装では例外値を別途扱う PFOR(patched FOR)も併用される。差分化により値が小さくなり、ブロック単位でビット幅を決定するため、密な区間ほど高い圧縮率が得られる。128 件に満たない末尾は VInt で符号化される。

ブロック単位で格納する目的は圧縮率の向上だけではない。ポスティングリストにはブロック単位の読み飛ばしを可能にする skip data が付随しており、advance(target) の呼び出しによって途中のブロックを復号せずに読み飛ばすことができる。この性質は AND クエリにおいて特に有効である。

また、ブロック単位で読み飛ばせる性質は、07 章で述べる block-max WAND の前提となる。

06検索パス

クエリの実行は、Query、Weight、Scorer の 3 段階に分解される。

Query から Weight、セグメントごとの Scorer と DocIdSetIterator、集約まで。下段に TwoPhaseIterator の approximation と confirmation
FIG 06クエリ実行の構造と、二段階評価
Query
検索条件を宣言するオブジェクト。不変であるため、クエリキャッシュのキーとして用いられる。
Weight
Query を特定のインデックスに対して具体化したもの。docFreq や総ドキュメント数といったインデックス全体の統計量を確定させ、スコア計算に必要な値を保持する。
Scorer
1 つのセグメント上でドキュメントを列挙し、スコアを計算する。内部では DocIdSetIteratornextDoc() および advance() によってドキュメント ID を順に返す。

IndexSearcher は、セグメント(LeafReader)ごとに独立して Scorer を生成し実行する。この構造により、セグメント単位での並列実行が可能となる。各セグメントの結果は最後に集約され、上位 k 件が返される。

評価コストの高いクエリに対しては、TwoPhaseIterator による二段階評価が適用される。フレーズクエリを例にとると、対象の term をすべて含むドキュメントの列挙は低コストであるが、出現位置の隣接性の検証は高コストである。前者を approximation、後者を confirmation として分離し、候補が絞り込まれた後に confirmation を実行する。.pos ファイルの読み込みは confirmation の段階で行われる。

07block-max WAND

上位 k 件のみを求める検索において、すべてのドキュメントのスコアを計算する必要はない。Lucene 8 以降、ポスティングリストの各ブロックには、そのブロック内で到達しうる最大スコア(impact)が記録されている。

ブロックごとの最大スコアを棒グラフで並べ、現在の第 10 位のスコアの横線に届かないブロックは評価せずにスキップする
FIG 07上位 10 件を求める場合、閾値に満たないブロックは評価しない

検索の進行中、現在の第 k 位のスコアが閾値となる。あるブロックの最大スコアが閾値に満たない場合、そのブロック内のドキュメントはいずれも上位 k 件に入りえないため、ブロック全体を評価せずにスキップできる。この手法を block-max WAND(Weak AND)と呼ぶ。

この最適化は、総ヒット数を正確に求めない場合にのみ適用できる。OpenSearch の track_total_hits: true は全件の評価を要求するため、最適化は無効となる。既定値が 10000 で打ち切りとなっているのはこのためである。

08削除とマージ

セグメントが不変であるため、削除はデータの除去ではなくマークの付与として実装される。削除されたドキュメントは .liv ファイル(live docs ビットセット)において 0 としてマークされ、ポスティングリストからは除去されない。

更新は .liv への削除マークと新セグメントへの追加。削除マークされたドキュメントの影響と、TieredMergePolicy によるマージ
FIG 08削除・更新とマージ

この方式は次の帰結をもたらす。

  • 削除マークされたドキュメントもディスク領域を占有し続ける
  • ポスティングリストのイテレーションにおいて、削除済みドキュメントも走査対象に含まれる
  • term 統計(docFreq)には削除が反映されない
  • 物理的な削除はマージ時に行われる

更新頻度の高いインデックスにおいて検索性能が低下する典型的な原因はここにある。

マージは、複数のセグメントを 1 つに統合し、削除マークされたドキュメントを除外する処理である。既定のマージポリシーは TieredMergePolicy であり、サイズの近いセグメントを統合する。マージはバックグラウンドで実行されるが、I/O と CPU を消費する。インデクシング性能の低下が観測された場合、その原因が書き込み処理ではなくマージにあることは珍しくない。

force_merge は適用対象を選ぶ必要がある。書き込みが完了した時系列インデックスを 1 セグメントに統合する用途には有効である。一方、書き込みが継続するインデックスに適用すると巨大なセグメントが生成され、以降のマージ効率が著しく低下する。

09refresh、flush、commit

OpenSearch の refresh と flush、および Lucene の commit は、それぞれ異なる処理を指す。

refresh、flush、Lucene commit を「検索可能」「永続化」の 2 軸で並べた表と、translog のみが永続性を保証する区間を示すタイムライン
FIG 09各操作を、Lucene 側で生じる処理によって整理する
OpenSearch の操作Lucene で生じる処理検索可能永続化
refreshNRT reader を再オープンする×
flushLucene の commit を実行し、translog を切り詰める
(Lucene commit)segments_N の書き出しと fsync

refresh は、NRT(Near Real-Time)reader を再オープンし、flush 済みのセグメントを検索対象に含める操作である。fsync は行われないため、この時点では永続化されていない。OpenSearch の既定では 1 秒間隔で実行され、インデクシング後 1 秒で検索可能になるという動作はこの refresh によるものである。

flush は、Lucene の commit を実行し、その後 translog を切り詰める操作である。commit により、セグメントは fsync され永続化される。

translog は Lucene の外部に位置する追記ログであり、OpenSearch が管理する。refresh から flush までの間、書き込みの永続性は translog のみによって保証される。Lucene の commit が高コストであるという制約を、translog によって補完する構成である。

可視性(refresh)と永続性(flush / translog)は独立に制御される。

10運用上の指針

以上の構造から、次の指針が導かれる。

  • セグメント数を監視する。セグメント数の増加は検索レイテンシの悪化に直結する
  • 更新中心のワークロードは Lucene の設計と相性が悪い。追記中心の設計に寄せられないか検討する
  • 総ヒット数が不要であれば track_total_hits を無効にする。block-max WAND が有効になる
  • 不要な構造を省略する。doc_valuesnorms_sourceindex: false は、ディスク使用量と書き込みコストに直接影響する
  • index sorting を検討する。セグメント内をソート順に配置することで、上位 k 件の取得を早期に打ち切れる場合がある
  • force_merge は書き込みが完了したインデックスにのみ適用する