Lucene (Elasticsearch, Solr) のインデックスセグメント管理

OSS検索エンジンElasticsearchやApache Solrが依存するOSS検索ライブラリ**Apache Lucene**は，ドキュメントを複数の**インデックスセグメント**に分割して保存する．

（これは，全ドキュメントを複数のシャードに分割して並行処理するシャーディングとは**異なり**，逐次処理する単位の中でも複数のセグメントに分割されているということである）

本稿は，検索エンジンに対して一般的に行われるドキュメントの追加，更新，削除などの操作に対して，Luceneのセグメントがどのように管理されるかの覚え書きである．（コードを追ったというよりは，私の中のメンタルモデルを整理したもの）

# 基本情報

Luceneではドキュメントを通し番号で管理する．通し番号にはインデックスセグメント内でのドキュメントの通し番号と，検索対象の全ドキュメントの通し番号がある．

「ある時点で検索対象の全ドキュメント」は，**IndexSearcherオブジェクト**によってセグメント単位で管理される．IndexSearcherは，自身が検索対象とするセグメントそれぞれに，以下が成り立つように**docBase**という番号を付加する．

**セグメントのdocBase + セグメント内でのドキュメントの通し番号 = 検索対象の全ドキュメントの通し番号**

3つのセグメント（それぞれ4件，5件，3件のドキュメントを含む）を検索対象とするIndexSearcherを図示すると以下のようになる．

![Luceneのセグメント管理_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155352/579e16e2-bec2-7e9a-8b71-d6d64cc56f27.png)

後述するが，コミットを行うと，セグメントのdocBaseやセグメント内でのドキュメントの通し番号は変化することがあり，したがって検索対象の全ドキュメントの通し番号も変化することがあるので，これらをキーとするキャッシュを独自に実装する場合などは注意が必要である．

ここでいう通し番号は，外部から見た**ドキュメントID**（例えばSolrのuniqueKey）とは**異なる**．あるドキュメントを指定して削除や更新するときに使うのはドキュメントIDである．またドキュメントIDは，コミットをしても不変であり，シャードをまたいでもユニークである．

# Add（ドキュメントの追加）

検索対象のインデックスセグメントとは別に，現在進行形で構築されているセグメントが存在する．ドキュメントの追加は，現在進行形で構築されているセグメントに対して行われる．このため，追加されたドキュメントは，直ちに検索可能になるわけではない．

![Luceneのセグメント管理_add.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155352/671e6438-c25e-a87b-3d1c-01edd491cac2.png)

# Delete（ドキュメントの削除）

インデックスセグメントにはドキュメントごとの有効フラグを入れるビット (live docs) が存在し，ドキュメントの削除は，単にそのビットをクリアする操作である．IndexSearcherは有効フラグをキャッシュしているため，この操作をしても，直ちに検索不可能になるわけではない．

![Luceneのセグメント管理_delete.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155352/a6f116f2-c817-fb61-c33d-440bb23340b4.png)

# Update（ドキュメントの更新）

インデックスセグメント内のドキュメントは基本的には真に更新されることはなく，更新は削除してから追加することによって行われる．

これには例外もある．例えばSolrのin-place updateは例外である．https://solr.apache.org/guide/8_6/updating-parts-of-documents.html#in-place-updates

# Commit（コミット）

現在進行形で構築されているインデックスセグメントを完成とし，それを含む既存のセグメントを検索対象とする新しいIndexSearcherを作成する処理である．コミット以降のリクエストの処理には新しいIndexSearcherが使われる．これによって，コミット以降のリクエストに関しては，コミットに至るまでのドキュメントの追加，削除，更新などが検索結果に反映される．

![Luceneのセグメント管理_commit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155352/41c498bf-d4e5-69d6-d231-40d49791e6e4.png)

古いIndexSearcherは，参照されなくなった時点で削除される．例えば古いIndexSearcherが処理中のリクエストが残っている場合は，処理が終わるまで削除されない．

ちなみにSolrにはsoft commitとhard commitがあるが，前者がここでいうコミットである．後者においては，新しいセグメントをストレージに書き出す (fsync) ことが保証される．また，この書き出し自体がコストなので，さらに追加のコストがかかるのを避けるために，あえて新しいIndexSearcherを作成しない (openSearcher=false) こともできる．

# Expunge（削除済みドキュメントの除去）

上述の通り，削除済みドキュメントは真にインデックスセグメントから除かれるわけではないので，存在するとオーバーヘッドが発生する．Expungeは，あるセグメントを元に，削除済みドキュメントを真に除いた新しいセグメントを作成する操作である．

![Luceneのセグメント管理_expunge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155352/5f5a41ee-ca6c-bfcf-7929-9dfdf67e787e.png)

この操作により元のセグメントが「古くなり」，次回のコミット以降は検索対象にならなくなる（下図）．古いIndexSearcherと同様，古いセグメントも，参照されなくなった時点で削除される．

![Luceneのセグメント管理_expunge_commit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155352/5ec867f0-b6e8-1c22-861d-f4f2b79753ae.png)

# Merge（インデックスセグメントのマージ）

複数のインデックスセグメントをマージする操作．複数のセグメントを処理するにはオーバーヘッドが生じる．ということは，複数のセグメントは単一のセグメントにまとめたほうが検索パフォーマンスが向上する．マージはこれを実際に行う操作であり，複数のセグメントをまとめた単一の新しいセグメントを作成する操作である．

![Luceneのセグメント管理_merge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155352/7f102d4c-20ac-acd3-fd75-1bf3d7976dce.png)

マージ時にexpungeも行うこともできる．

# Optimize（インデックスの最適化）

インデックスを最適化する操作．具体的には，インデックスセグメントが指定の個数になるようにマージを繰り返す操作で，典型的には1つになるまで，expungeを行いつつ繰り返す．この場合，全ドキュメントは巨大な単一のセグメントになり，削除済みドキュメントは無くなり，オーバーヘッドは最小化される．

しかし，最適化後にドキュメントの出し入れを行うと，そのうち通常より大きいセグメントをマージやexpungeする必要が生じるため，**定常的にドキュメントの出し入れが起こる環境には適さない**．


# 参考情報
Apache Lucene - Index File Formats
 https://lucene.apache.org/core/8_7_0/core/org/apache/lucene/codecs/lucene87/package-summary.html#package.description