Elasticsearch Learning to Rankプラグインのコードリーディング

# はじめに
Elasticsearch Learning to Rankプラグイン ([elasticsearch-learning-to-rank](https://github.com/o19s/elasticsearch-learning-to-rank)) は
機械学習モデル（XGBoost, Ranklib, Simple Linear）を使用しての検索結果のリスコアを行うことが出来るElasticsearchプラグインです。

今年の春頃にelasticsearch-learning-to-rankプラグインのデモを動かすところまで行いました（以下Qiita記事参照）。
もっと詳細に理解したいと思いつつもあまり調査が進まずにいたため、このタイミングでもっと詳細に理解すべくソースコードリーディングを行ったのでその内容をまとめていきます。

elasticsearch-learning-to-rankプラグインに関しては、以下など記事やTechBlogが既に公開されているため、内容を知りたい方はまずこちらを参照。

* [Elasticsearch Learning to Rankプラグインを試す@kimai07 \- Qiita](https://qiita.com/kimai07/items/2cdb3317573358b04936)
* [Elasticsearch Learning to Rankプラグインの使い方とポイント \- ZOZO TECH BLOG](https://techblog.zozo.com/entry/elasticsearch-learning-to-rank-plugin)


# 本記事について

プラグインの実装レベルの詳細な話は上記とは別のTechBlogに既にまとまっており、これを読めば大まかに理解できるものの、自分ではまだコードを読み込めていなかったため今回のテーマにしました。

* [Elasticsearch LTRプラグインと特徴量キャッシュ機能の基本 \- ZOZO TECH BLOG](https://techblog.zozo.com/entry/basics-of-elasticsearch-ltr-plugin-and-feature-score-cache)

内容としては以下となる。

* パッケージ構成、関係図
* プラグインで追加されるエンドポイント
* 主要処理詳細
    * Elasticsearch起動時
	* ltr用index作成
	* リスコア処理


調査対象のElasticsearchとプラグインのバージョンは、2023/12/19時点のプラグインの最新バージョンである8.9.2となります。

* https://github.com/elastic/elasticsearch/tree/v8.9.2
* https://github.com/o19s/elasticsearch-learning-to-rank/tree/v1.5.8-es8.9.2



# パッケージ構成、関係図

ソースコードを見る前にelasticsearch-learning-to-rankのパッケージ構成がどうなっているかをパッケージの関連図を通して見ていきます。

![packages.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/129694/07034ff7-fb32-b98b-5671-e422d96f52de.png)

restやqueryパッケージなど具体的な処理が書かれているパッケージは上の方に、utilsやrankerなど多くのパッケージから参照されているパッケージは下の方に記述されています。
これを見るとパッケージ間にどのようなつながりがありそうかを見て取れます。
各パッケージの内容は以下。

パッケージ|内容
---|---
action | FeatureStoreActionやCreateModelFromSetActionのようなrestパッケージから呼び出され具体的な処理が記述されたクラス
feature | FeatureやFeatureSet, StoredLtrModelなどのデータ用クラス
logging | LoggingSearchExtBuilderやLoggingFetchSubPhaseのような特徴量ログ出力関連のクラス
query | StoredLtrQueryBuilder, StoredLtrQueryBuilder, RankerQueryなどクエリ関連のクラス
ranker | LtrRankerや各種機械学習ライブラリ用のParser, Rankerクラス
rest | RestStoreManagerやRestFeatureManagerのようなAPIリクエストを受け付けるハンドラ関連のクラス
stats | LTRプラグイン用の統計情報をするための処理が記述されたクラス
utils | ユーティリティクラス



# プラグインで追加されるエンドポイント

elasticsearch-learning-to-rankのプラグインをインストールすると、ElasticsearchへのLTR用エンドポイントが追加されてリクエスト出来るようになります。
追加されるエンドポイントは以下の通り。これらのエンドポイントへリクエストしつつ処理を追っていくと理解が深まりやすいと思います。


### ＜特徴量セット、モデル関連＞

エンドポイント|説明
---|---
`PUT _ltr`| ストアを作成する<br>* 特徴量セットやモデルなどの情報を保存するための `.ltrstore` インデックスを作成する
`DELETE _ltr` | ストアを削除する<br>* `.ltrstore` インデックスを削除してプラグインをリセットする
`POST _ltr/_featureset/<特徴量セット名>` | 特徴量セットを作成（定義）する
`DELETE _ltr/_featureset/<特徴量セット名>` | 特徴量セットを削除する
`GET _ltr/_featureset/<特徴量セット名>` | 特徴量セットを取得する
`POST _ltr/_featureset/<特徴量セット名>/_createmodel` | モデルを作成（アップロード）する
`DELETE _ltr/_model/<モデル名>` | モデルを削除する
`GET _ltr/_model/<モデル名>` | モデルを取得する
`POST /_ltr/_feature/＜特徴量＞` | 特徴量を作成（定義）する。用途は再利用で利用するため
`DELETE /_ltr/_feature/＜特徴量＞` | 特徴量を削除する
`GET /_ltr/_feature/＜特徴量＞` | 特徴量を取得する
`POST /_ltr/_featureset/<特徴量セット名>/_addfeatures/<特徴量>` | 特徴量セットに指定した特徴量を追加・更新する

* ※検索結果のリスコア処理が行われる検索用エンドポイント (/{index}/_search) はプラグイン無しでも存在しているためこの一覧には記載していない。


### ＜統計情報、キャッシュ関連＞

エンドポイント|説明
---|---
`GET _ltr/_stats`,<br>`GET _ltr/_stats/<stat>`,<br>`GET _ltr/_stats/nodes/<nodeID>`,<br>`GET _ltr/_stats/<stat>/nodes/<nodeID>` | ノードやクラスター別のプラグイン用統計情報を取得する
`GET _ltr/_cachestats` | プラグイン用キャッシュとメモリの使用状況についての統計を返却する
`POST _ltr/_clearcache` | プラグイン用キャッシュをクリアする



# 主要処理詳細

それでは処理を順に見ていく。今回は以下3項目をピックアップして整理しました。

* Elasticsearch起動時
* ltr用index作成
* リスコア処理

特徴量セットやモデル周りの処理の実装レベルの記載も出来ればよかったのですが、ボリュームが出てしまうため省いています。

:::note warn
処理を追うにあたり、Elasticsearchとelasticsearch-learning-to-rankのソースコードの両方を見る必要があり、行き来しますがご了承ください。
:::


## Elasticsearch起動時

まず、Elasticsearch起動時にプラグイン関連でどのような処理が行われているか、主な処理としては以下3点があります。
LtrQueryParserPluginインスタンスを生成して、LtrQueryParserPluginクラスで定義されているgetメソッドが呼び出され登録される形となっています。

:::note info
ここで、前提としてElasticsearchにelasticsearch-learning-to-rankプラグインが既にインストールされているものとして進めます。
:::

#### ▼ LtrQueryParserPluginインスタンスの生成

* PluginServiceの `(Plugin) constructor.newInstance(settings)` ([code](https://github.com/elastic/elasticsearch/blob/v8.9.2/server/src/main/java/org/elasticsearch/plugins/PluginsService.java#L668)) の呼び出しで生成
* このタイミングでLtrRankerParser（モデルパース用）の登録も行う。Ranklib, Linear, XGboostがサポートされている


```java
    public LtrQueryParserPlugin(Settings settings) {
        caches = new Caches(settings);
        // Use memoize to Lazy load the RankerFactory as it's a heavy object to construct
        Supplier<RankerFactory> ranklib = Suppliers.memoize(RankerFactory::new);
        parserFactory = new LtrRankerParserFactory.Builder()
                .register(RanklibModelParser.TYPE, () -> new RanklibModelParser(ranklib.get()))
                .register(LinearRankerParser.TYPE, LinearRankerParser::new)
                .register(XGBoostJsonParser.TYPE, XGBoostJsonParser::new)
                .build();
    }
```

#### ▼ QueryParserの登録

*  プラグインロード時の `SearchModule searchModule = new SearchModule(settings, pluginsService.filterPlugins(SearchPlugin.class));` ([code](https://github.com/elastic/elasticsearch/blob/v8.9.2/server/src/main/java/org/elasticsearch/node/Node.java#L539))で以下処理が呼び出される

```java
public List<QuerySpec<?>> getQueries() {
        return asList(
                new QuerySpec<>(ExplorerQueryBuilder.NAME, ExplorerQueryBuilder::new, ExplorerQueryBuilder::fromXContent),
                new QuerySpec<>(LtrQueryBuilder.NAME, LtrQueryBuilder::new, LtrQueryBuilder::fromXContent),
                new QuerySpec<>(StoredLtrQueryBuilder.NAME,
                        (input) -> new StoredLtrQueryBuilder(getFeatureStoreLoader(), input),
                        (ctx) -> StoredLtrQueryBuilder.fromXContent(getFeatureStoreLoader(), ctx)),
                new QuerySpec<>(TermStatQueryBuilder.NAME, TermStatQueryBuilder::new, TermStatQueryBuilder::fromXContent),
                new QuerySpec<>(ValidatingLtrQueryBuilder.NAME,
                        (input) -> new ValidatingLtrQueryBuilder(input, parserFactory),
                        (ctx) -> ValidatingLtrQueryBuilder.fromXContent(ctx, parserFactory)));
    }
```

#### ▼ プラグインで追加されるエンドポイント用のHandler登録


* `plugin.getRestHandlers`で取得し、 `registerHandler.accept(handler)` ([code](https://github.com/elastic/elasticsearch/blob/v8.9.2/server/src/main/java/org/elasticsearch/action/ActionModule.java#L1008))で登録される
*  以下コードを見るとrestパッケージ関連のクラスが登録されている

```java
    @Override
    public List<RestHandler> getRestHandlers(Settings settings, RestController restController,
                                             ClusterSettings clusterSettings, IndexScopedSettings indexScopedSettings,
                                             SettingsFilter settingsFilter, IndexNameExpressionResolver indexNameExpressionResolver,
                                             Supplier<DiscoveryNodes> nodesInCluster) {
        List<RestHandler> list = new ArrayList<>();

        for (String type : ValidatingLtrQueryBuilder.SUPPORTED_TYPES) {
            list.add(new RestFeatureManager(type));
            list.add(new RestSearchStoreElements(type));
        }
        list.add(new RestStoreManager());

        list.add(new RestFeatureStoreCaches());
        list.add(new RestCreateModelFromSet());
        list.add(new RestAddFeatureToSet());
        list.add(new RestLTRStats());
        return unmodifiableList(list);
    }
```



## LTR用indexの作成

Elasticsearch起動が無事完了すると `PUT _ltr` リクエストが行えるようになり、 `.ltrstore` という名のLTR用インデックスを作成することが出来ます。
このリクエスト用の処理は [RestStoreManager](https://github.com/o19s/elasticsearch-learning-to-rank/blob/v1.5.8-es8.9.2/src/main/java/com/o19s/es/ltr/rest/RestStoreManager.java#L63) で行われ、CreateIndexRequestを生成してElasticsearchにリクエストします。
`PUT _ltr` のリクエストした結果は以下となります。

```
PUT _ltr

{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : ".ltrstore"
}
```

これにより、 .ltrstoreインデックスが作成され、`GET .ltrstore` リクエストを行うと通常インデックスと同様にmappingやsettingsを取得することが出来ます。
以下はmappingを取得した結果となります。

* feature, featureset, modelはobject型で定義されている

```
GET .ltrstore/_mapping

{
    ".ltrstore": {
        "mappings": {
            "dynamic": "strict",
            "properties": {
                "feature": {
                    "type": "object",
                    "enabled": false
                },
                "featureset": {
                    "type": "object",
                    "enabled": false
                },
                "model": {
                    "type": "object",
                    "enabled": false
                },
                "name": {
                    "type": "text",
                    "fields": {
                        "prefix": {
                            "type": "text",
                            "analyzer": "name_prefix",
                            "search_analyzer": "name_prefix_search"
                        }
                    },
                    "analyzer": "keyword"
                },
                "type": {
                    "type": "keyword"
                }
            }
        }
    }
}
```


.ltrstoreのmapping, settingsは以下で実装されており固定の定義が用意されています。

* mapping
  * [fstore-index-mapping.json](https://github.com/o19s/elasticsearch-learning-to-rank/blob/v1.5.8-es8.9.2/src/main/resources/com/o19s/es/ltr/feature/store/index/fstore-index-mapping.json) をロード

```java
private static String readResourceFile(String indexName, String resource) {
    try (InputStream is = IndexFeatureStore.class.getResourceAsStream(resource)) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        Streams.copy(is.readAllBytes(), out);
        return out.toString(StandardCharsets.UTF_8.name());
    } catch (Exception e) {
・・・
```

* settings

```java
private static Settings storeIndexSettings(String indexName) {
    return Settings.builder()
            .put(IndexMetadata.INDEX_NUMBER_OF_SHARDS_SETTING.getKey(), 1)
            .put(IndexMetadata.INDEX_AUTO_EXPAND_REPLICAS_SETTING.getKey(), "0-2")
            .put(STORE_VERSION_PROP.getKey(), VERSION)
            .put(IndexMetadata.SETTING_PRIORITY, Integer.MAX_VALUE)
            .put(IndexMetadata.SETTING_INDEX_HIDDEN, true)
            .put(Settings.builder()
                    .loadFromSource(readResourceFile(indexName, ANALYSIS_FILE), XContentType.JSON)
                    .build())
            .build();
}
```




## リスコア計算

コードリーディングする中で、一番気になったのがリスコア計算をどう行っているかについて気になったのでその部分を中心に見ていきたいと思います。
このあたりは、Elasticsearchの[リスコア機能](https://www.elastic.co/guide/en/elasticsearch/reference/8.9/filter-search-results.html#rescore)自体の実装でもあり、また[Elasticsearch LTRプラグインと特徴量キャッシュ機能の基本 \- ZOZO TECH BLOG](https://techblog.zozo.com/entry/basics-of-elasticsearch-ltr-plugin-and-feature-score-cache)でもご説明のあった処理となります。


Elasticsearchに検索リクエスト (/_search) を行うと、まず最初に以下コードでモデルがロードされます。

* [RanklibModelParser](https://github.com/o19s/elasticsearch-learning-to-rank/blob/v1.5.8-es8.9.2/src/main/java/com/o19s/es/ltr/ranker/ranklib/RanklibModelParser.java#L37-L45) でモデルをロード
* デモに続きRanklibを用いているため、RanklibModel関連のクラスのコードを通過

```java
    @Override
    public LtrRanker parse(FeatureSet set, String model) {
        Ranker ranklibRanker = factory.loadRankerFromString(model);
        int numFeatures = ranklibRanker.getFeatures().length;
        if (set != null) {
            numFeatures = set.size();
        }
        return new RanklibRanker(ranklibRanker, numFeatures);
    }
```

リスコア計算処理は以下の[QueryRescorer](https://github.com/elastic/elasticsearch/blob/v8.9.2/server/src/main/java/org/elasticsearch/search/rescore/QueryRescorer.java#L30-L70)のrescoreメソッドとなります。

```java
  @Override
  public TopDocs rescore(IndexSearcher searcher, TopDocs firstPassTopDocs, int topN)
      throws IOException {
    ScoreDoc[] hits = firstPassTopDocs.scoreDocs.clone();

    Arrays.sort(
        hits,
        new Comparator<ScoreDoc>() {
          @Override
          public int compare(ScoreDoc a, ScoreDoc b) {
            return a.doc - b.doc;
          }
        });

    List<LeafReaderContext> leaves = searcher.getIndexReader().leaves();

    Query rewritten = searcher.rewrite(query);
    Weight weight = searcher.createWeight(rewritten, ScoreMode.COMPLETE, 1);

    // Now merge sort docIDs from hits, with reader's leaves:
    int hitUpto = 0;
    int readerUpto = -1;
    int endDoc = 0;
    int docBase = 0;
    Scorer scorer = null;

    while (hitUpto < hits.length) {
      ScoreDoc hit = hits[hitUpto];
      int docID = hit.doc;
      LeafReaderContext readerContext = null;
      while (docID >= endDoc) {
        readerUpto++;
        readerContext = leaves.get(readerUpto);
        endDoc = readerContext.docBase + readerContext.reader().maxDoc();
      }

      if (readerContext != null) {
        // We advanced to another segment:
        docBase = readerContext.docBase;
        scorer = weight.scorer(readerContext);
      }

      if (scorer != null) {
        int targetDoc = docID - docBase;
        int actualDoc = scorer.docID();
        if (actualDoc < targetDoc) {
          actualDoc = scorer.iterator().advance(targetDoc);
        }

        if (actualDoc == targetDoc) {
          // Query did match this doc:
          hit.score = combine(hit.score, true, scorer.score());
        } else {
          // Query did not match this doc:
          assert actualDoc > targetDoc;
          hit.score = combine(hit.score, false, 0.0f);
        }
      } else {
        // Query did not match this doc:
        hit.score = combine(hit.score, false, 0.0f);
      }

      hitUpto++;
    }
```

#### ▼ combineメソッド

この中で最終スコアを算出している箇所が以下のcombineメソッド部分となります。


```java
        if (actualDoc == targetDoc) {
          // Query did match this doc:
          hit.score = combine(hit.score, true, scorer.score());
        } else {
```

combineメソッドの実装は[QueryRescorer](https://github.com/elastic/elasticsearch/blob/v8.9.2/server/src/main/java/org/elasticsearch/search/rescore/QueryRescorer.java#L42-L53)で定義されており以下のような実装になっています。

* combineメソッドの引数は以下
	* 第一引数：クエリにマッチしたドキュメントのスコア
	* 第二引数：secondPassMatches（rescoreスコアを組み合わせるかどうかのフラグ）
	* 第三引数：RankerScorer
* firstPassScoreとsecondPassScoreの値をscoreModeのcombineメソッドに渡している
*  scoreModeはTotalがセットされている

```java
public final class QueryRescorer implements Rescorer {

    public static final Rescorer INSTANCE = new QueryRescorer();

    @Override
    public TopDocs rescore(TopDocs topDocs, IndexSearcher searcher, RescoreContext rescoreContext) throws IOException {

            @Override
            protected float combine(float firstPassScore, boolean secondPassMatches, float secondPassScore) {
                if (secondPassMatches) {
                    return rescore.scoreMode.combine(
                        firstPassScore * rescore.queryWeight(),
                        secondPassScore * rescore.rescoreQueryWeight()
                    );
                }
                // TODO: shouldn't this be up to the ScoreMode? I.e., we should just invoke ScoreMode.combine, passing 0.0f for the
                // secondary score?
                return firstPassScore * rescore.queryWeight();
            }
・・・
```

ScoreMode Totalのcombineメソッドは[QueryRescoreMode](https://github.com/elastic/elasticsearch/blob/v8.9.2/server/src/main/java/org/elasticsearch/search/rescore/QueryRescoreMode.java#L52-L56)で定義されており足し合わせ。

```java
public enum QueryRescoreMode implements Writeable {
	・・・
    Total {
        @Override
        public float combine(float primary, float secondary) {
            return primary + secondary;
        }
・・・
```

#### ▼ elasticsearch-learning-to-rankによるリスコア計算

機械学習モデルを用いてのリスコア計算はRankerQueryのサブクラスである[RankerQuery](https://github.com/o19s/elasticsearch-learning-to-rank/blob/v1.5.8-es8.9.2/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L317-L360)で行われます。

```java
public class RankerQuery extends Query {
	・・・
    public static class RankerWeight extends Weight {
		・・・
        class RankerScorer extends Scorer {
			・・・
            @Override
            public float score() throws IOException {
                fv = ranker.newFeatureVector(fv);
                if (featureScoreCache == null) {  // Cache disabled
                    int ordinal = -1;
                    // a DisiPriorityQueue could help to avoid
                    // looping on all scorers
                    for (Scorer scorer : scorers) {
                        ordinal++;
                        // FIXME: Probably inefficient, again we loop over all scorers..
                        if (scorer.docID() == docID()) {
                            // XXX: bold assumption that all models are dense
                            // do we need a some indirection to infer the featureId?
                            fv.setFeatureScore(ordinal, scorer.score());
                        }
                    }
					・・・
                return ranker.score(fv);
            }
・・・
```

ここで、setFeatureScoreメソッドは[DenseProgramaticDataPoint](https://github.com/o19s/elasticsearch-learning-to-rank/blob/v1.5.8-es8.9.2/src/main/java/com/o19s/es/ltr/ranker/ranklib/DenseProgramaticDataPoint.java#L59-L63)で以下のように定義されており、feature毎のスコアを格納します。


```java
public class DenseProgramaticDataPoint extends DataPoint implements LtrRanker.FeatureVector {
	・・・
    @Override
    public void setFeatureScore(int featureIdx, float score) {
        // add 1 because RankLib features 1 based
        this.setFeatureValue(featureIdx+1, score);
    }
	・・・
}
```

一番最後の `return ranker.score(fv)` は[FVLtrRankerWrapper](https://github.com/o19s/elasticsearch-learning-to-rank/blob/v1.5.8-es8.9.2/src/main/java/com/o19s/es/ltr/query/RankerQuery.java#L464-L467)のscoreメソッドの引数にFeatureVectorをセットして計算します。以下のwrappedがRanklibRankerにあたります。


```java
public class RankerQuery extends Query {
 	・・・
    static class FVLtrRankerWrapper implements LtrRanker {
		・・・
        @Override
        public float score(FeatureVector point) {
            return wrapped.score(point);
        }
・・・
```


[RanklibRanker](https://github.com/o19s/elasticsearch-learning-to-rank/blob/v1.5.8-es8.9.2/src/main/java/com/o19s/es/ltr/ranker/ranklib/RanklibRanker.java#L69-L73)でのスコア計算は以下で行っているのですが、 `ciir.umass.edu.learning.Ranker` のevalまでは追いきれませんでした。


```java
import ciir.umass.edu.learning.Ranker;
・・・

public class RanklibRanker implements LtrRanker {
	private final Ranker ranker;
	・・・
    @Override
    public float score(FeatureVector point) {
        assert point instanceof DenseProgramaticDataPoint;
        return (float) ranker.eval((DenseProgramaticDataPoint) point);
    }
}
```


# おわりに

elasticsearch-learning-to-rankの詳細についてまだまだ細かいところまで見切れてはいないですが、実装レベルである程度知ることが出来ました。
リスコア周りはfirstPassScoreとsecondPassScoreのスコアを足し合わせるようなことをしていることも見ることが出来ました。
ただし、処理の複雑なところがあり、理解が追いつかず「こうなってました」で終わってしまったので、推測や工夫している箇所を読み取れるくらいまで理解を進めたいと思いました。
あと今回はelasticsearch-learning-to-rankプラグインをデバッグしながら読み進めることが出来ました。プラグインのデバッグをどうやったかについても近いうちに投稿出来ればと思ってます。