Amazon Elasticsearch Serviceで辞書登録が出来ない場合の検索精度向上について

[2019 elasticsearchのAdvent calendar](https://qiita.com/advent-calendar/2019/elasticsearch)の7日目の記事です

今仕事で携わっている教育プラットフォームでは検索にAmazon Elasticsearch Serviceを使ってます。検索対象はテスト、問題、アップロードされたファイル等になります。
ただ使っているバージョンが2.3と古いものでした。こちらのアップデート対応時に出た検索精度の問題について今回書こうと思います。

# 前提
使っている環境はAmazon Elasticsearch Serviceです。EC2上に構築することもあると思いますが、運用の負荷など考えてAmazon Elasticsearch Serviceで構築・運用することにしました。
またAmazon Elasticsearch Serviceへ検索する処理を行なっているのはRailsで実装されたAPIサーバになります。このRailsが使っているElasticsearch用のgemとRails自体のバージョンの関係で今回は6.8系まであげるようにしました。(Railsのバージョンが6系になたら、Elasticsearch用のgemもバージョンアップしてElasticsearchの7系も使えるようになりますが、それはまだ少し先になりそうです）

# 課題
今回のバージョンアップするに当たって、そもそも解決したい問題がありました。それが上でも述べた検索精度についてです。
これは以前からあった問題で、あるキーワードで検索すると検索結果にヒットしないが、そのキーワードに含まれる一部のワードだとヒットするというものでした。これは原因は明らかでkuromojiプラグインの辞書にその検索ワードが含まれていないためです。

```例
$ curl -XGET "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d'
{ "analyzer": "kuromoji", "text": "ホゲホゲ星人" }'

{
  "tokens" : [
    {
      "token" : "ホゲホゲ",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "星",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "人",
      "start_offset" : 5,
      "end_offset" : 6,
      "type" : "word",
      "position" : 2
    }
  ]
}
```

じゃ、辞書に追加すれば良いということになりますが、使っているのがAmazon Elasticsearch Serviceであるためそうはいきません。辞書の追加が出来ないためQueryで何とか解決しないといけません。
友人に相談したところngramを使うのはどうかという助言をいただき、検討した結果その方法を採用することにしました。

# やったこと
## ngram使う
対策としてやったことは、まずtokenizerにkuromojiだけでなくngramも併用して使うようにしました。text search対象のfieldでtitleというのがあるするとtitle2みたいなのを追加で定義してそこのanalyzerはngramにします。

```
indexes :title,  type: 'text', analyzer: 'kuromoji_analyzer'
indexes :title2, type: 'text', analyzer: 'ngram_analyzer'
```
そして次に検索時にはこの二つのfieldを見るようにして対応しました。

```
{"min_score":0.1,
  "query":{"function_score":
      {
        "score_mode":"sum",
        "boost_mode":"multiply",
        "query":{
          "bool":{
            "must":[
                {"term":{"user_id":139}},
                {"simple_query_string":
                    {"query":"/'#{keyword}/'",
                     "fields":
                    ["title^10","title2^3"],
                     "default_operator":"or"
                    }
                }
             ]
           }
        },
        "functions":[]
      }
    },
  }
```

ここで1点補足です。query中で<code>title^10</code>と書いてますが、この「10」は何を表すかというと検索結果の重み付けを表します。上の例だとtitleフィールドの検索の方に10、title2フィールドの方に3の重み付けをしています。
どうしてこのような重み付けしているかというと検索結果の最適化のためです。ngramの検索は検索対象のデータを機械的にN語で分割し転置インデックスを作成します。そのため最近の言葉、特殊な言葉など一般的な言葉でなくても機械的に分割して転置インデックス作るので検索することは出来ます。ただ本来意図しないデータも検索で引っかかることがあります。
対してkuromojiを採用している形態素解析は辞書を使って文字列分割します。そのため辞書に単語がないとダメなのですが、精度は良いです。
以上の点からただ併用して使えば良いという訳ではなく、チューニング作業が必要になります。

## 実験
ここで少し実験してみようと思います。

### 検索Query
```query
.{"min_score":0.1,
  "query":{"function_score":
      {
        "score_mode":"sum",
        "boost_mode":"multiply",
        "query":{
          "bool":{
            "must":[
                {"term":{"user_id":139}},
                {"simple_query_string":
                    {"query":"/'#{keyword}/'",
                     "fields":
                    ["title^10","title2^3",
                     "photos.description^8","photos.description2^3"],
                     "default_operator":"or"
                    }
                }
             ]
           }
        },
        "functions":[]
      }
    },
  }
```
### テストデータ
rubyで検証用のテストを実装したので、そのコードの抜粋になります。

```
      let(:album1) { create(:album, title: '映画仮面ライダーとホゲホゲ星人の戦い', user: user) }
      let(:photo1_1) { create(:photo, description: 'ホゲホゲ星人との場面1',
                              album: album1, user: user) }
      let(:photo1_2) { create(:photo, description: 'ホゲホゲ星人との場面2',
                               album: album1, user: user) }
      let(:album2) { create(:album, title: 'ターミネータVS仮面ライダー', user: user) }
      let(:photo2_1) { create(:photo, description: 'ターミネータとホゲホゲ星人の戦いの場面',
                              album: album2, user: user) }
      let(:photo2_2) { create(:photo, description: 'ホゲホゲ星人を助ける仮面ライダーの場面',
                              album: album2, user: user) }
      let(:album3) { create(:album, title: '鉄仮面のライダー', user: user) }
      let(:album4) { create(:album, title: 'ライダーが仮面を被ったら', user: user) }
      let(:album5) { create(:album, title: '仮面を被ったライダーの写真', user: user) }
      let(:photo5_1) { create(:photo, description: 'ライダーの写真',
                              album: album5, user: user) }
      let(:album6) { create(:album, title: 'ライダーの写真集', user: user) }
      let(:photo6_1) { create(:photo, description: '仮面をつけたライダーも写ってます',
                              album: album6, user: user) }
      let(:other_album) { create(:album, title: 'ホゲホゲ星人の冒険', user: other_user) }
      let(:other_photo) { create(:photo, description: '冒険の記録1',
                                  album: other_album, user: other_user) }
```

### 結果
右の数値は検索時に算出されるscoreになります。

keywordを「仮面」で検索した場合

```
鉄仮面のライダー: 11.709004
ライダーが仮面を被ったら: 11.371845
仮面を被ったライダーの写真: 10.375977
映画仮面ライダーとホゲホゲ星人の戦い: 8.224535
ターミネータVS仮面ライダー: 2.6144085
```

仮面というワードに近いものがscoreが高くなっていて、仮面ライダーは下の方になりますね。

一方keywordを「仮面ライダー」で検索した場合だと

```
ターミネータVS仮面ライダー: 40.157257
映画仮面ライダーとホゲホゲ星人の戦い: 17.230406
鉄仮面のライダー: 10.584366
ライダーが仮面を被ったら: 9.0324745
仮面を被ったライダーの写真: 8.719972
ライダーの写真集: 8.502096
```

ngramでもみているので、最後の二つも引っかかります。もしこの結果をよしとしないのであれば、min_scoreを設定して最低スコアを定義して調整するのも良いかと思っています。

keywordを「ライダー」で検索した場合

```
鉄仮面のライダー: 14.960871
ライダーの写真集: 14.960871
ライダーが仮面を被ったら: 13.746138
仮面を被ったライダーの写真: 12.806761
映画仮面ライダーとホゲホゲ星人の戦い: 10.416438
ターミネータVS仮面ライダー: 6.8164654
```

この場合も「仮面ライダー」は下の方に言って「ライダー」が上位に来てます。
ではもし重みつけをしないで検索した場合、どのようになるのでしょうか。実際試して見ると次のようになりました。

```
鉄仮面のライダー: 4.146576
ライダーの写真集: 4.146576
ライダーが仮面を被ったら: 3.7416654
仮面を被ったライダーの写真: 3.590652
映画仮面ライダーとホゲホゲ星人の戦い: 3.1220098
ターミネータVS仮面ライダー: 2.9388218
```

そこまで各検索結果でスコアに差は出ませんでした。重みつけをすることで、どの結果をより上位に出したいかscoreに反映させられます。
最後に辞書に含まれてない単語を検索した場合です。キーワードに「ホゲホゲ星人」にして検索した場合、以下のようになります。

```
映画仮面ライダーとホゲホゲ星人の戦い: 48.348312
```

kuromojiだけだと辞書にないから検索出来ないのですが、ngramを使うことで辞書にない言葉でも検索できるようになりました。これも踏まえ、どのようなデータを検索結果として優先するか重みつけやscore計算処理を使ってスコアを算出しチューニングすることでより検索したいデータを検索結果上位に表示していくことが出来ます。

# 今後
検索精度については他の方々も同じようなことをされていると思い、今回の対応方法は目新しい物ではなかったかもしれません。ただkuromojiとngramを併用して検索させるという方法があまり情報として公開されてなかったので今回書くことにしました。
何かしら役に立つことがあれば幸いです

明日は[t_oogi](https://qiita.com/t_oogi)さんです。