Elasticsearch によるサジェスト検索実装

## はじめに

[Solr によるサジェスト検索実装](https://qiita.com/ft28/items/50e0db325a857b0ce73a
)について書きましたが、Elasticserchで同じことをやってみました。

## 実験環境構築
https://github.com/ft28/practice/tree/master/es/suggest の [README.md](https://github.com/ft28/practice/tree/master/es/suggest/README.md) に従って、実験環境を構築します。

## 実験環境説明

### lucene-analyzers-kuromoji-7.2.1-SNAPSHOT.jar

前回作成した、各トークンの漢字・ひらがな・カタカナをローマ字に変換後、トークンを結合して1つのトークンを作成するモジュールを追加したjar ファイルです。

### AnalysisKuromojiPlugin.java

Elasticsearch の analysis-kuromoji pluginに上記moduleを呼び出すための名前と対応するクラスを登録します。

```
61         extra.put("kuromoji_concat_japanese", KuromojiConcatenateJapaneseReadingFilterFactory::new);
``` 

### KuromojiConcatenateJapaneseReadingFilterFactory.java

上で登録したクラスの実装ファイルです。このクラスの中で、luceneモジュールを生成し使えるようにします。

### build.gradle
analysis-kuromoji pluginコンパイル時に新しく作成したlucene-analyzer-kuromoji を使うよう設定します。

```groovy:build.gradle
 19 repositories {
 20   mavenLocal()
 21 }

 28 dependencies {
 29 // compile "org.apache.lucene:lucene-analyzers-kuromoji:${version.lucene}"
 30 // compile "test:lucene-analyzers-kuromoji:7.2.3"
 31    compile files("lucene-analyzers-kuromoji-7.2.1-SNAPSHOT.jar")
 32 }
```
## 実験
検索対象のフィールドword に対して、filterにローマ字読みサジェスト設定でよく使うkuromoji_readingformを使用したインデックスと、今回作成したkuromoji_concat_japaneseを使用したインデックスを作成するよう定義します。

```json:mapping.json
        "word": {
          "type": "text",
          "analyzer": "default_index_analyzer",
          "search_analyzer": "default_search_analyzer",
          "fields": {
             "roman1": {
               "type": "text",
               "analyzer": "roman1_index_analyzer",
               "search_analyzer": "roman1_search_analyzer"
             },
             "roman2": {
               "type": "text",
               "analyzer": "roman2_index_analyzer",
               "search_analyzer": "roman2_search_analyzer"
             }
          }
        }
```

|作成時|roman1_index_analyzer|roman2_index_analyzer|
|---|---|---|---|
|**tokenizer**|kuromoji_tokenizer|kuromoji_tokenizer|
|**filters**|kuromoji_readingform<br/>edgeNGram|kuromoji_concate_japanese<br/>edgeNGram|

|検索時|roman1_search_analyzer|roman2_search_analyzer|
|---|---|---|
|**tokenizer**|kuromoji_tokenizer|kuromoji_tokenizer|
|**filters**|kuromoji_readingform|kuromoji_concate_japanese|

**インデックス作成時はedgeNGramを作り、検索時はedgeNGramを作りません**

### analyzer 比較
Elasiticsearchの場合、各analyzerで元データに対して、どのようなトークンが作成されるか以下手順で確認出来ます。

```
curl -XGET 'localhost:9200/suggest/_analyze?pretty' -d '{"analyzer": "<analyzer名>", "text": "<元データ>"}' -H 'Content-Type: application/json'
```

インデックス作成時に「品川」に対して各analyzerで作成されるトークンは以下のようになります。

|analyzer名|roman1_index_analyzer|roman2_index_analyzer|
|---|---|---|
|トークン1|s|s|
|トークン2|sh|sh|
|トークン3|shi|shi|
|トークン4|shin|shin|
|トークン5|shina|shina|
|トークン6|shinaga|shinaga|
|トークン7|shinagaw|shinagaw|
|トークン8|shinagawa|shinagawa|

  * 「品川」はtokenizerから出力された時点で1つのトークンなので、romen1_index_analyzer、roman2_index_analyzerが作成するトークンは同じになります。

検索時の「しながわ」に対して各analyzer で作成されるトークンは以下のようになります。

|analyzer名|roman1_search_analyzer|roman2_search_analyzer|
|---|---|---|
|トークン1|shina|shinagawa|
|トークン2|ga||
|トークン3|wa||

   * 「しながわ」は、tokenizerで「しな」「が」「わ」に分解され、roman1_search_analyzerでは、ローマ字置換されたトークンがされていますが、roman2_search_analyzerでは結合して1つのトークンになっていることが分かります。

### 検索結果比較

* roman1 で検索

```
# クエリ
curl -XGET http://localhost:9200/suggest/keyword/_search?pretty -H 'Content-Type: application/json' -d '
{
   "query": {
     "match": { 
       "word.roman1": {
          "query": "しながわ",
          "operator": "AND"
       }
     }
   }
 }'
# 結果
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 0,
    "max_score" : null,
    "hits" : [ ]
  }
}
```

* roman2 で検索

```
# クエリ
curl -XGET http://localhost:9200/suggest/keyword/_search?pretty -H 'Content-Type: application/json' -d '
{
   "query": {
     "match": { 
       "word.roman2": {
          "query": "しながわ",
          "operator": "AND"
       }
     }
   }
 }'
# 結果
{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 6.3382335,
    "hits" : [
      {
        "_index" : "suggest",
        "_type" : "keyword",
        "_id" : "279",
        "_score" : 6.3382335,
        "_source" : {
          "word" : "品川"
        }
      }
    ]
  }
}
```

## まとめ
Elasticsearch でも Solrの時に作成したluceneモジュールを使って、サジェスト検索を改善できました。gradleによるビルドを今回はじめて触ったので、その点で少し苦労しましたが、それ以外は、Solr の時とほぼ同じ手順で組み込めました。

## 参考
[Elasticsearch キーワードサジェスト日本語のための設計](https://medium.com/hello-elasticsearch/elasticsearch-%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E3%82%B5%E3%82%B8%E3%82%A7%E3%82%B9%E3%83%88%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E8%A8%AD%E8%A8%88-352a230030dd)
  * Elasticsearch のサジェスト設定について参考にさせて頂きました。
  * 今回は比較対象モジュール部分に必要最小限の設定しか行っていないのですが、実用ではこちらのサイトにあるような設定を追加するのが良いと思われます。
 
[Solr にジェスト検索実装](https://qiita.com/ft28/items/50e0db325a857b0ce73a
)
  * luceneモジュールはこちらで作成したものを使っています。
  * luceneモジュールの作成方法はこちらの記事をご参照ください。