この記事では、Luceneのコアクラスの一つであるIndexWriterを深く掘り下げて、Luceneにおけるデータの書き込みとインデックス作成の全体像を探っていきたいと思います。
周 肇峰著
#序文
前回の記事では、Luceneの基本的な概要を紹介しました。今回は、Luceneのコアクラスの一つであるIndexWriterを掘り下げて、Luceneにおけるデータの書き込みとインデックス作成の全体像を探っていきたいと思います。
#IndexWriter
// initialization
Directory index = new NIOFSDirectory(Paths.get("/index"));
IndexWriterConfig config = new IndexWriterConfig();
IndexWriter writer = new IndexWriter(index, config);
// create a document
Document doc = new Document();
doc.add(new TextField("title", "Lucene - IndexWriter", Field.Store.YES));
doc.add(new StringField("author", "aliyun", Field.Store.YES));
//index a document
writer.addDocument(doc);
writer.commit();
まず、LuceneでIndexWriterを使ってデータを書き込む方法を見てみましょう。上記は簡単な呼び出しのサンプルコードです。全体の流れは主に3つのステップで構成されています。
1、初期化:IndexWriterの初期化に必要な要素は、DirectoryとIndexWriterConfigの2つです。DirectoryはLuceneにおけるデータ永続化層の抽象インターフェースです。ローカルファイルシステム、ネットワークファイルシステム、データベース、分散ファイルシステムなど、さまざまなタイプのデータ永続化層をこのインターフェース層を介して実装することができます。IndexWriterConfig には、高度なユーザーがパフォーマンスを最適化したり機能をカスタマイズしたりするための、高度な設定が可能な多くのパラメータが含まれています。いくつかの主要なパラメータについては、後ほど詳しく説明します。
2、ドキュメントの作成: Lucene では、ドキュメントは Document で表され、ドキュメントは Fields で構成されています。Lucene にはさまざまなタイプの Field が用意されており、FieldType によってサポートされるインデックスモードが決まりますが、その中には明らかにカスタムフィールドも含まれています。詳細は前回の記事を参照してください。
3、ドキュメントの書き方: addDocument 関数を使用して文書を書き込むと同時に、FieldType に応じて異なるインデックスが作成されます。新しく書き込まれたドキュメントは、IndexWriter のコミットが呼ばれるまで検索できません。コミットが完了すると、Lucene はドキュメントが永続的に検索可能であることを保証します。
上記はLuceneでデータを書き込む処理を簡潔にまとめた例です。IndexWriterが中心となっており、全体の手順を明確かつ簡潔に抽象化しています。よく設計されたライブラリの最大の特徴は、一般のユーザーがほとんどコストをかけずに学習して利用できる一方で、上級者には設定可能なパフォーマンスパラメータやカスタマイズ可能な機能を提供していることです。
#IndexWriterConfig
IndexWriterConfig は、パフォーマンスを最適化したり機能をカスタマイズしたりするために、上級者向けにいくつかのコアパラメータを提供しています。いくつかの例を見ていきます。
-
IndexDeletionPolicy:Luceneでは、スナップショットなどの機能を実装するためにコミットポイントの管理が可能になります。デフォルトでは、DeletionPolicy は Lucene の最後のコミットポイントのみを保持します。
類似性:関連性は検索の中核をなすものです。類似度は、スコアリングアルゴリズムの抽象的なインターフェイスです。デフォルトでは、Lucene は TF-IDF と BM25 アルゴリズムを使用します。関連性は、データの書き込み時と検索時にスコアリングされます。データ書き込み時にスコアリングを行うことをインデックスタイムブーストと呼びます。正規化は計算され、インデックスに書き込まれます。検索時のスコアリングはクエリタイムブーストと呼ばれます。 -
MergePolicy:Luceneの内部データ書き込みでは、多くのSegmentsが生成されます。クエリが行われると、多くのセグメントがクエリされ、その結果がマージされます。Segments の数がある程度クエリ効率に影響するため、セグメントをマージします。マージの手順は Merge と呼ばれ、MergePolicy は Merge がいつトリガーされるかを決定します。
-
MergeScheduler:MergePolicy が Merge をトリガーした後、MergeScheduler は Merge の実行を担当します。マージ プロセスは通常、CPU と I/O に大きな負荷をかけます。MergeScheduler はマージ プロセスのカスタマイズと管理を可能にします。
-
Codec:Codecは、Lucene のコアコンポーネントであり、Lucene の内部エンコーダーやデコーダーを定義します。Lucene は Config レベルでコーデックを設定します。主な目的は、異なるバージョンのデータを処理できるようにすることです。ほとんどの Lucene ユーザはこのレイヤーをカスタマイズする必要はほとんどなく、 コーデックの設定を行うのは上級者が中心です。
-
IndexerThreadPool:IndexWriter の内部インデックススレッドプール (DocumentsWriterPerThread) を管理します。これもLuceneの内部リソース管理のカスタマイズの一部です。
-
FlushPolicy: FlushPolicy は、Lucene の内部リソース管理のカスタマイズの一部です。FlushPolicy は、メモリ内バッファをいつフラッシュするかを決定します。デフォルトでは、そのタイミングは RAM のサイズやドキュメントの数に依存します。FlushPolicyは、ドキュメントの追加/更新/削除のたびに判断するために呼び出されます。
-
MaxBufferedDoc:Lucene が提供するデフォルトの FlushPolicy で実装されている FlushByRamOrCountsPolicy で DocumentsWriterPerThread が使用できる最大メモリ量。この値を超えるとフラッシュが発生します。
-
RAMBufferSizeMB :Lucene が提供するデフォルトの FlushPolicy で実装されている FlushByRamOrCountsPolicy で DocumentsWriterPerThread が使用できるドキュメントの最大数。この値を超えるとフラッシュが発生します。
-
RAMPerThreadHardLimitMB: FlushPolicy がフラッシュを決定するのと同様に、Lucene にも DocumentsWriterPerThread が使用するメモリ量を強制的に制限するためのメトリックがあります。閾値を超えると強制的にフラッシュが行われます。
-
Analyzer:トークナイザー。これは、特に異なる言語用にカスタマイズされることが多いです。
#コア操作
IndexWriter にはいくつかの簡単な操作インタフェースが用意されています。この章ではそれらの機能と用途を簡単に説明し、次の章ではそれらの内部の詳細な内訳を説明します。IndexWriterが提供するコアAPIは以下の通りです。
-
addDocument: Lucene にドキュメントを追加するための簡単な API です。Lucene は内部的に主キーインデックスを持ちません。新しく追加されたドキュメントはすべて新しいドキュメントとして扱われ、独立した docId が割り当てられます。
-
updateDocument: ドキュメントの更新を行います。ドキュメントの更新を行いますが、データベースの更新とは異なります。データベースの更新はクエリの後に行われますが、Lucene の更新はクエリの後に文書を削除して追加します。データベースはクエリの後に文書を削除して追加するのに対し、Lucene はクエリの後に文書を削除して追加するという処理を行います。しかし、この処理は単に delete → add と呼ぶのとは異なる効果があります。スレッド内での削除と追加のアトミック性が確保されるのは更新だけです。この処理の詳細は次の章で説明します。
-
deleteDocument: ドキュメントを削除します。これは、用語による削除とクエリによる削除の2つのタイプの削除をサポートしています。これら2種類の削除は、IndexWriterの内部では処理が異なります。詳細は次の章で説明します。
-
flush: ハードフラッシュをトリガして、スレッド内のすべてのメモリ内バッファをセグメントファイルにフラッシュします。このアクションはメモリを解放し、データを永続化します。
-
prepareCommit/commit/rollback:データはコミット後にのみ検索可能になります。コミットは2段階の操作です。最初の段階はprepareCommitで、commitはステップを完了するために呼び出すことができます。ロールバックは最後のコミットまでロールバックします。
-
maybeMerge/forceMerge: maybeMerge は MergePolicy の決定をトリガーし、forceMerge は強制的にマージを行います。
#データパス
最後の数章では、IndexWriterの基本的な処理や設定、コア・インターフェースについて紹介しました。これらは非常にシンプルで理解しやすいものです。この章では、カーネルの実装を探るために、IndexWriter の内部を詳しく見ていきます。
上図はIndexWriterの内部コアプロセスを示しています。次に、IndexWriterの内部データパスを、主な操作であるadd/update/delete/commitの観点から説明します。
###並行処理モデル
IndexWriter が提供するコアインターフェースはスレッドセーフで、マルチスレッド書き込みを最適化するための内部同時実行チューニングが含まれています。IndexWriter は、各スレッドが書き込みを行うための独立したスペースを開放します。これらのスペースはDocumentsWriterPerThreadによって制御されます。全体のマルチスレッド、データ処理プロセスです。
1、複数のスレッドが同時にIndexWriterのインターフェイスを呼び出し、IndexWriterの特定の内部リクエストがDocumentsWriterによって実行されます。DocumentsWriterが内部でリクエストを処理する前に、現在処理を実行しているスレッドに基づいてDocumentsWriterPerThreadを割り当てます。
2、各スレッドは、トークン化、関連性スコアリング、インデックス作成などを含め、それぞれ独立したDocumentsWriterPerThread空間でデータを処理します。
3、データ処理が終了すると、FlushPolicy の決定をトリガーするなど、いくつかの後処理が DocumentsWriter レベルで行われます。
DocumentsWriterPerThread (DWPT) を導入してから、Lucene は内部でデータを処理する際に第一ステップと第三ステップだけをロックするようになりました。二番目のステップは、各スレッドがそれぞれ独立した空間でデータを処理するため、ロックする必要はありません。一般的に、第一ステップと第三ステップは軽量であるのに対し、第二ステップは最も大きな演算能力とメモリを必要とします。このようにすることでロック時間が大幅に短縮され、同時実行効率が向上します。各DWPTには独自のインメモリバッファが含まれており、最終的には別の独立したセグメントファイルにフラッシュされます。
このソリューションにより、マルチスレッドの同時書き込みのパフォーマンスが大幅に向上します。新しい文書が追加されたときには、どのデータ書き込みでも競合が発生しないため、このシナリオはこのタイプのスペース分割書き込みに特に適しています。しかし、文書を削除する場合には、一度の削除操作で異なるスレッド空間にあるデータを削除してしまうことがあります。この場合、Lucene はロックオーバーヘッドを減らすために特別な相互作用の方法を採用しています。これについては、削除について議論する際に詳しく分析します。
検索シナリオでは、インデックス作成の全段階は基本的に新規ファイル書き込みであり、その後のインデックスインクリメント段階(特にデータソースがデータベースである場合)では、大量の更新・削除操作を伴います。原則的には、クロススレッドを避けるために、同じ独立したスレッド空間でデータが更新されるように、同じユニークな主キー語を含む文書に同じ処理スレッドを割り当てることがベストプラクティスの1つです。
###追加と更新
新しいドキュメントの追加にはaddインターフェースを、ドキュメントの更新にはupdateインターフェースを使用します。しかし、Lucene の更新方法はデータベースとは異なります。データベースはクエリの後に更新しますが、Lucene はクエリの後にドキュメントを削除したり再追加したりします。Lucene はドキュメントの内部ソートの更新をサポートしていません。処理は、用語ごとに削除してから文書を追加するというものです。
IndexWriterで提供されている追加・更新インタフェースは、すべてDocumentsWriterの更新インタフェースにマッピングされています。以下のインタフェース宣言を参照してください。
long updateDocument(final Iterable<? extends IndexableField> doc, final Analyzer analyzer,
final Term delTerm) throws IOException, AbortingException
この関数の内部処理は、
1、スレッドに応じてDWPTを割り当てる
2、DWPTで削除を実行
3、DWPTで追加を実行
削除操作については、次のまとめで詳しく説明します。追加操作は、DWPT内のインメモリバッファに直接文書を書き込みます。
###削除
addとupdateに比べて、deleteは全く別のデータパスです。更新と削除は内部的にデータを削除しますが、それらは別のデータパスです。ドキュメントを削除しても、インメモリバッファ内のデータには直接影響がないので、別の方法で削除する必要があります。
Deleteパスのキーとなるデータ構造は、Deletion queueです。IndexWriterではグローバル削除キューと呼ばれるグローバル削除キューが生成され、各DWPTにはペンディング・アップデートと呼ばれる独立した削除キューがあります。DWPTの保留中の更新とグローバル削除キューは双方向に同期していますが、これは、文書の削除はグローバルな範囲で行われ、DWPTに制限されないためです。
保留中の更新は、各削除を発生順に記録し、削除の影響を受ける文書の範囲をマークします。文書の範囲は、これまでに書き込まれた最大のDocId(DocId Upto)を記録することでマークされます。つまり、削除は、問題のDocId以下のDocIdを持つ文書にのみ影響するということです。
更新インターフェイスと削除インターフェイスはどちらも文書の削除に使用できますが、いくつかの違いがあります。
- update インターフェイスは用語によるドキュメントの削除のみを行いますが、delete は用語やクエリによる削除をサポートしています。
- update が削除する場合、最初に DWPT で動作し、次に Global で動作します。その後、他のDWPTとGlobalを同期させます。
- deleteインターフェイスは、最初にGlobalレベルで動作し、その後、非同期的にDWPTレベルまで変更を同期します。
updateプロセスとdeleteプロセスの違いは、その動作の違いにも関係しています。 updateは最初にDWPTの内部で動作し、addと同時に発生するため、DWPT内でのdeleteとaddのアトミック性が確保されます。つまり、追加が発生する前に、すべての修飾文書が確実に削除されることを保証します。
DWPT Pending Updates の削除操作が実際にデータに作用するのはいつでしょうか?Lucene Segment内のデータは実際には削除されません。Segment内にはlive docsという特殊なファイルがあり、内部的にはビットマップ構造になっていて、Segment内のどのDocIdsがliveでどのDocIdsが削除されたかを記録しています。つまり、削除の処理は、単にライブDocsのフラグのビットマップを構築する処理に過ぎません。データは実際には削除されませんが、そのフラグはライブDocsから削除されます。Term deletionとQuery deletionでは、異なる段階でライブドックスを構築します。ターム削除では、タームクエリで参照されるすべてのdocを列挙する必要があるので、これは明らかに転置インデックスを構築しているときに起こります。クエリの削除は、対応するdocIdsを取得するために完全なクエリを必要とし、セグメントがフラッシュされた後に発生します。フラッシュに基づいてインデックスファイルが構築された後、IndexReader は検索を完了させることができます。
ライブドキュメントは逆インデックスにしか影響しないので、ライブドキュメントから削除のフラグが立ったドキュメントを取得する方法はありませんが、保存されているフィールドはdoc idで問い合わせることができます。当然ながら、文書データは最終的に物理的に削除され、この処理はマージ時に行われます。
###Flush
Flushとは、DWPT内のインメモリバッファをデータ永続ファイルにするプロセスです。Flushは、FlushPolicy に基づいて新しいドキュメントが追加された後に自動的にトリガーされますが、IndexWriter のフラッシュ・インターフェイスを使用して手動でFlushをトリガーすることもできます。
すべての DWPT はセグメントファイルにフラッシュされます。セグメントファイルはFlushが完了すると検索できなくなり、コミット後にのみ検索可能になります。
###Commit
Commitによって強制的にデータがフラッシュされます。Commit後にのみ、それ以前にフラッシュされたデータが検索可能になります。Commitは、コミットポイントと呼ばれるファイルの生成をトリガーします。コミットポイントは IndexDeletionPolicy で管理します。Lucene のデフォルトのポリシーは、最後のコミットポイントのみを保持します。当然ながら、Lucene には他のポリシーも用意されています。
###Merge
Mergeとは、セグメントファイルをマージすることです。Mergeの利点は、クエリ効率を向上させ、削除されたドキュメントの一部を再利用できるようにすることです。Mergeがセグメントファイルをフラッシュすると、MergePolicy に自動トリガーを決定させ、IndexWriter を使用して強制的にマージすることもできます。
#IndexingChain
これまでのいくつかの章では、いくつかの主要な操作の処理を見てきました。この節では、Lucene の最もコアな部分である DWPT が、内部的にどのようにインデックス作成のプロセスを実装しているかを見ていきます。Lucene の内部的なインデキシングのキーコンセプトは IndexingChain であり、その名の通りチェーン的なインデキシングです。なぜチェーンなのか?これはLuceneのインデックスシステム全体の構造に関係しています。Luceneでは、転置インデックス、フォワードインデックス(列格納)、StoreField、DocValuesなど、さまざまなタイプのインデックスが用意されています。それぞれの異なるタイプのインデックスは、異なるタイプのインデックスアルゴリズム、データ構造、ファイルストレージに対応しています。その中にはカラムレベルのものもあれば、ファイルレベルのものもあり、文書レベルのものもあります。そのため、文書が書き込まれた後、それは多くの異なるタイプのインデックスによって処理され、その中にはメモリバッファを共有するものもあれば、完全に独立したものもあります。Lucene は理論的にはこのフレームワークに基づいて他のタイプのインデックスを拡張することができます。
IndexWriter内では、インデックス作成チェーン上のインデックス作成の順序は、転置インデックス、ストア・フィールド、doc値、ポイント値の順になります。インデックスの種類によっては、文書処理後に直接ファイル(主にストアフィールドや項ベクトル)にインデックスを書き込むのに対し、他のインデックスの種類では、文書の内容をメモリバッファに書き込んで、次回のフラッシュ時にファイルに書き込まれるようになっています。ファイルのインデックスを書き込めるのは通常、文書レベルのインデックスであり、インデックスは文書レベルでインクリメンタルに行うことができます。ファイルを書き込むことができないインデックス、例えば転置インデックスは、セグメント内のすべての文書が書き込まれるのを待たなければならず、その後、用語の完全なソートを実行して初めてインデックスを作成することができるので、すべての文書をキャッシュするためにメモリバッファが必要になります。
前回、IndexWriterConfigがコーデックの設定をサポートしていることを紹介しました。コーデックとは、インデックスの種類ごとのエンコーダとデコーダのことです。上の図を見てもわかるように、Lucene 7.2.1には主に以下のようなコーデックが用意されています。
- BlockTreeTermsWriter:BlockTreeTermsWriter: 転置インデックス用のコーデックで、このうち転置リストはLucene50PostingsWriter(ブロック書きの転置インデックスチェーン)とLucene50SkipWriter(SkipList Index for Block)を使用し、辞書はFST(転置インデックスのブロックレベル辞書検索)を使用しています。
- CompressingTermVectorsWriter: term vector indexのWriter。最下層は圧縮ブロック形式です。
- CompressingStoredFieldsWriter: Store fields indexのWriter。最下層は圧縮ブロック形式です。
- Lucene70DocValuesConsumer: doc values indexのWriter。
- Lucene60PointsWriter: point values indexのライター。
この章では、主にIndexing Chain内部のドキュメントインデックス処理について見ていきました。
#まとめ
この記事では、主にグローバルな視点からIndexWriterを見ていき、その構成、インターフェイス、同時実行モデル、そしてコア演算のデータパスとインデックスチェーンについて解説します。次の記事では、さまざまなタイプのインデックスのインデックス作成プロセスをより深く見ていき、メモリバッファ、インデックス作成アルゴリズム、データ保存形式の実装を探っていきます。
本ブログは英語版からの翻訳です。オリジナルはこちらからご確認いただけます。一部機械翻訳を使用しております。翻訳の間違いがありましたら、ご指摘いただけると幸いです。
アリババクラウドは日本に2つのデータセンターを有し、世界で60を超えるアベラビリティーゾーンを有するアジア太平洋地域No.1(2019ガートナー)のクラウドインフラ事業者です。
アリババクラウドの詳細は、こちらからご覧ください。
アリババクラウドジャパン公式ページ