Elasticsearchの Search Template (+ Search Application)について

# Search TemplateとSearch Applicationとは

Elasticsearchを使って検索機能を実装するとき、クエリー（QueryDSL）の記述が重要であることは当然です。そしてクエリーはクライアント側で組み立てることになります。しかしElasticsearchのクエリーは書き方によって関連性、速度、コスト等のパフォーマンスが大きく変わるファクターです。また同じ検索インデックスに対して複数のシステムから検索要求がある場合、同じ結果を返すためにはどのシステムからも同じ検索リクエストを受ける必要があります。それらのクエリーが同一であるかはElasticsearchの側からはコントロールできません。

そこでElasticsearchではこの課題を解決するために、Search Templateという機能を提供しています。

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-template.html

Search Templateを使うと、クエリーの構造をテンプレートとしてElasticsearch側で定義しておき、検索バーに入力されるクエリー文字列のような部分をパラメータとしてテンプレートに埋め込み、最終的なQueryDSLを作成して検索を実行できるようになります。

さらにバージョン8.8からSearch Applicationという機能も利用できるようになりました。（ただし8.15の時点ではまだBeta版であることに注意してください。）

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-application-apis.html

Search ApplicationはQueryDSLのみならず、検索機能を検索対象のインデックス（パターン）も含めてクライアントから隠蔽することができるようにする機能です。また、クライアントから渡されるパラメータの仕様についても定義することができ、これによってパラメータのバリデーションも実装することが可能です。

以下のSearch Applicationの定義例を見ると、どのような機能なのか雰囲気が掴めると思います。

```
PUT _application/search_application/my-app
{
  "indices": [ "index1", "index2" ],
  "template": {
    "script": {
      "source": {
        "query": {
          "query_string": {
            "query": "{{query_string}}",
            "default_field": "{{default_field}}"
          }
        }
      },
      "params": {
        "query_string": "*",
        "default_field": "*"
      }
    },
    "dictionary": {
      "properties": {
        "query_string": {
          "type": "string"
        },
        "default_field": {
          "type": "string",
          "enum": [
            "title",
            "description"
          ]
        },
        "additionalProperties": false
      },
      "required": [
        "query_string"
      ]
    }
  }
}
```

# この記事でカバーする範囲

Search Applicationを利用する場合にも、最も重要なパートはQueryDSLをテンプレート化するSearch Templateの機能です。そこでこの記事ではSearch Templateの定義方法について紹介したいと思います。


# Search Templateの使い方

以下、Search Templateの使い方についてサンプルを紹介します。

## テンプレートの作成

[公式ドキュメント](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-template.html)にある例を見てみましょう。

```
PUT _scripts/my-search-template
{
  "script": {
    "lang": "mustache",
    "source": {
      "query": {
        "match": {
          "message": "{{query_string}}"
        }
      },
      "from": "{{from}}",
      "size": "{{size}}"
    }
  }
}
```

まずテンプレートを作成します。ここでエンドポイントが_scriptsであることに注意してください。このエンドポイントでは実はSearch Template以外に、PainlessのスクリプトをStored Scriptとして保存することも可能です。そのためこのような名前になっているわけですね。

テンプレートの作成には、テンプレート記述言語が必要です。Elasticsearchでは[Mustache](https://mustache.github.io/)を採用しています。langとしてmustacheを指定していますが、それ以外のテンプレート言語には対応していません。（Stored Scriptを保存する際はpainlessを指定します。）

作成したテンプレートでは、messageフィールドに対して`{{query_string}}`パラメータとして渡される文字列を検索クエリーをかけ、また`{{from}}`および`{{size}}`パラメータを使ってページネーションの設定ができるようになっています。そしてクライアントは、検索実行時にQueryDSL全体を発行するのでなく、これらのパラメータのみを送信すれば良いわけです。

## テンプレートのテスト

通常テンプレートを作成したら、そのテンプレートが期待した通りのクエリーを作成してくれるのかテストしますね。テストには以下のような[Render search template API](https://www.elastic.co/guide/en/elasticsearch/reference/current/render-search-template-api.html)を利用します。

```
POST _render/template
{
  "id": "my-search-template",
  "params": {
    "query_string": "hello world",
    "from": 20,
    "size": 10
  }
}
```

レスポンスは以下のようになります。

```json
{
  "template_output": {
    "query": {
      "match": {
        "message": "hello world"
      }
    },
    "from": "20",
    "size": "10"
  }
}
```

確かにテンプレートで定義した`{{query_string}}`などのプレースホルダーが具体的な文字列に置き換わっていることがわかりますね。

## 検索実行

定義は良さそうなのでこれで実際に検索を実行してみましょう。テンプレートを利用した検索には`_search/template`エンドポイントを利用します。

```
GET my-index/_search/template
{
  "id": "my-search-template",
  "params": {
    "query_string": "hello world",
    "from": 0,
    "size": 10
  }
}
```

`id`として作成したテンプレートのIDを指定し、`params`としてパラメータを指定します。検索結果は通常の検索と全く同じなので割愛します。

このように、Search Templateを利用すると、クライアント側から実際に発行されるクエリーを隠蔽することができます。したがって検索する側のアプリケーションでは、論理的に必要なパラメーターを指定することだけを考えれば良くなり、効率的なクエリーを提供することは検索エンジンのエンジニア側に任せることができます。また検索エンジンチームではアプリケーション側に変更を求めることなくクエリーのチューニングを実施することが可能になります。

## 作成したSearch Templateの一覧を取得する

作成したものは普通一覧で取得したいですね。`PUT _scripts/{id}`で作成したんだったら`GET _scripts`で一覧が取得できそうなものなのですが、どういうわけか一覧取得のための専用APIは提供されていません。そこで以下のAPIでクラスターのメタデータとして取得する必要があります。

```
GET _cluster/state/metadata?filter_path=metadata.stored_scripts
```

気持ち悪いですがそういうものと思って諦めてください。レスポンスは以下のようになります。

```
{
  "metadata": {
    "stored_scripts": {
      "my-search-template": {
        "lang": "mustache",
        "source": """{"query":{"match":{"message":"{{query_string}}"}},"from":"{{from}}","size":"{{size}}"}""",
        "options": {
          "content_type": "application/json;charset=utf-8"
        }
      }
    }
  }
}
```

## テンプレートの削除

作成したSearch templateを削除するには[Delete stored script API](https://www.elastic.co/guide/en/elasticsearch/reference/current/delete-stored-script-api.html)を使います。これは素直ですね。

```
DELETE _scripts/my-search-template
```


# 具体的なサンプル

Search Templateがどういうものかわかったところで、もう少し現実のアプリケーションでありそうな具体例を見てみましょう。

## インデックス

ここでのサンプルは以下のインデックスを利用します。

```
PUT ja_text
{
  "aliases": {},
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "index": false,
        "fields": {
          "kuromoji": {
            "type": "text",
            "analyzer": "kuromoji"
          },
          "bigram": {
            "type": "text",
            "analyzer": "bigram_analyzer"
          }
        }
      },
      "content": {
        "type": "text",
        "index": false,
        "fields": {
          "kuromoji": {
            "type": "text",
            "analyzer": "kuromoji"
          },
          "bigram": {
            "type": "text",
            "analyzer": "bigram_analyzer"
          }
        }
      }
    }
  },
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "bigram_analyzer": {
            "tokenizer": "bigram_tokenizer"
          }
        },
        "tokenizer": {
          "bigram_tokenizer": {
            "token_chars": [
              "letter",
              "digit"
            ],
            "min_gram": "2",
            "type": "ngram",
            "max_gram": "2"
          }
        }
      }
    }
  }
}
```

以下のドキュメントをインデックスしておきます。

```
POST ja_text/_doc/1
{
  "title": "Elasticsearchの Search Template (+ Search Application)について",
  "content": "Search Templateを使うと、クエリーの構造をテンプレートとしてElasticsearch側で定義しておき、検索バーに入力されるクエリー文字列のような部分をパラメータとしてテンプレートに埋め込み、最終的なQueryDSLを作成して検索を実行できるようになります。"
}
```

## JSON構造を変えないクエリー

MustacheでQueryDSLの構造自体を変更するわけではない場合、おそらく文字列の置換する範囲はJSONの`”`で括られた値（KeyまたはValue）の内部にとどまります(そうでない場合があるかもしれませんが思いつきません)。その場合、テンプレートのクエリーはJSONオブジェクトとして定義できます。

```
PUT _scripts/kuromoji_or_bigram
{
  "script": {
    "lang": "mustache",
    "source": {
      "query": {
        "bool": {
          "{{#op_or}}should{{/op_or}}{{^op_or}}must{{/op_or}}": [
            {
              "match": {
                "title.{{#use_bigram}}bigram{{/use_bigram}}{{^use_bigram}}kuromoji{{/use_bigram}}": {
                  "query": "{{query_string}}",
                  "boost": 5
                }
              }
            },
            {
              "match": {
                "content.{{#use_bigram}}bigram{{/use_bigram}}{{^use_bigram}}kuromoji{{/use_bigram}}": "{{query_string}}"
              }
            }
          ]
        }
      },
      "from": "{{from}}",
      "size": "{{size}}"
    }
  }
}
```

このテンプレートを使った検索は以下のようになります。

```
GET ja_text/_search/template
{
  "id": "kuromoji_or_bigram",
  "params": {
    "query_string": "plate",
    "use_bigram": false,
    "op_or": true,
    "from": 0,
    "size": 10
  }
}
```

このテンプレートでは以下のよう検索ができます。

- 検索対象をKuromojiを使ったフィールドかBigramを使ったフィールドのいずれか選択できる (use_bigram)
- AND検索かOR検索かを選択できる (op_or)


## JSON構造を変えるクエリー

MustacheによってQueryDSLのJSONのデータ構造を変える必要がある場合、テンプレートのsourceはJSONではなく全体を文字列として定義する必要があります。

```
PUT _scripts/select_field
{
  "script": {
    "lang": "mustache",
    "source": """
{
  "query": {
    "bool": {
      "should": [
        {{#target_fields}}{
          "match": {
            "{{field_name}}": "{{query_string}}"
          }
        }{{^last}},{{/last}}
        {{/target_fields}}
      ]
    }
  }
}
    """
  }
}
```

この例では`target_fields`で渡されるフィールドに対してOR検索を行います。いくつtarget_fieldに渡されるかは実行時までわかりません。Mustacheの繰り返しの記法でmatchオブジェクトを複製していますが、ポイントは`{{^last}},{{/last}}`の箇所ですね。JSONでは要素が繰り返された最後の`,`は文法エラーとなってしまいます。そこでここでは最後の要素を表す`last`という属性がtrueの場合に`,`を出力しないように制御しています。これもあまり美しい実装とは言えませんが、Mustacheを使う上での制限です。

このテンプレートを以下のパラメータでレンダリングしてみましょう。

```
POST _render/template
{
  "id": "select_field",
  "params": {
    "query_string": "template",
    "target_fields": [
      {
        "field_name": "title.ngram"
      },
      {
        "field_name": "content.ngram"
      },
      {
        "field_name": "content.kuromoji",
        "last": true
      }
    ]
  }
}
```

結果は以下のようになります。

```
{
  "template_output": {
    "query": {
      "bool": {
        "should": [
          {
            "match": {
              "title.ngram": "template"
            }
          },
          {
            "match": {
              "content.ngram": "template"
            }
          },
          {
            "match": {
              "content.kuromoji": "template"
            }
          }
        ]
      }
    }
  }
}
```

意図通り動いているようです。

ここでは例は出さないものの、もちろん全体をMustacheのテンプレート文字列として定義しているので、さらにドラスティックに構造の異なるクエリーに変換することも可能です。


# まとめ

再三説明したとおり、Search Templateを使うと検索クライアントから具体的なクエリーを隠蔽し、アプリケーションロジックとクエリーを分離することができます。また同一の検索ロジックを複数のアプリケーションから簡単に使い回すことができるため、再利用性の観点でも非常におすすめできます。

いわゆる検索機能を実装するときは、特別な理由がない場合はSearch Template（あるいはSearch Application）を利用することをおすすめします。