kuromoji辞書のメンテナンス

## はじめに

Elasticsearchで日本語をうまい具合に検索しようとした場合、通常はN-Gramかkuromojiを使うかと思います。
今回は、このkuromoji、特に辞書のメンテナンスについて書いてみます。
といっても、目新しいことはないですかね・・・
(2019.04.23 `_update_by_query`すれば既存のドキュメントも改めてアナライズされる旨を追記)

## 辞書の読み込みタイミング

辞書っていつ読み込まれるのでしょう。
1. Index作成時
2. Index作成後の再起動時
3. Index作成後にクローズして、再度オープンした時
4. Index作成後にフリーズしたあとで、アンフリーズした時

一つ目は、まぁそうですね。二つ目も。三つ目は、二つ目と同じ感じでしょうか。
四つ目は意外でした。フリーズしても検索はできるのですが、その段階では、もし辞書ファイルが書き換わっていても新しい辞書は読み込まれていません。
ですが、アンフリーズすると新しい辞書が読み込まれます。

ま、だから何だって話ですが・・・

## 辞書を配置しなければならないノード

Elasticsearchは、役割として

- マスターノード
- データノード
- コーディネーターノード

があります（MLノードは無視します）。

この中で辞書を置かなければならないのは、

- マスターノード
- データノード

の二つとなります。
データノードはわかるんですが、マスターノード、特に「マスター」として選出されたノードにも必要です。中身はからでも良いのですが、マスターノードにも配置されていないと、そもそもIndexが作成できません。
ちなみに、データノードに辞書ファイルがない場合は、Indexは作成できますが、IndexのステータスはRedのままとなります（マスターノードでは、エラーが出まくります）。

## 辞書更新に伴う注意点

運用中に辞書を更新すると、思いがけない検索結果になる可能性があります。
以下のようなIndexを考えて見ます（[ここにあるの](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji-tokenizer.html)をちょっと修正したものです）。

```json
PUT kuromoji_sample
{
   "settings": {
     "index": {
       "analysis": {
         "tokenizer": {
           "kuromoji_user_dict": {
             "type": "kuromoji_tokenizer",
             "mode": "extended",
             "discard_punctuation": "false",
             "user_dictionary": "userdict_ja.txt"
           }
         },
         "analyzer": {
           "my_analyzer": {
             "type": "custom",
             "tokenizer": "kuromoji_user_dict"
           }
         }
       }
     }
   },
   "mappings": {
     "_doc": {
       "properties": {
         "text": {
           "type": "text",
           "analyzer": "my_analyzer"
         }
       }
     }
   }
}
```

辞書ファイル（`userdict_ja.txt`）の中身は空のままとします。

### テスト

この状態でテストして見ます。

```json
GET kuromoji_sample/_analyze
{
   "analyzer": "my_analyzer",
   "text": "東京スカイツリー"
}

{
   "tokens" : [
     {
       "token" : "東京",
       "start_offset" : 0,
       "end_offset" : 2,
       "type" : "word",
       "position" : 0
     },
     {
       "token" : "スカイ",
       "start_offset" : 2,
       "end_offset" : 5,
       "type" : "word",
       "position" : 1
     },
     {
       "token" : "ツリー",
       "start_offset" : 5,
       "end_offset" : 8,
       "type" : "word",
       "position" : 2
     }
   ]
}
```

3つのトークン「東京」「スカイ」「ツリー」に分割されることがわかります。

### データ投入

ではデータを投入します。

```json
POST kuromoji_sample/_doc
{
   "text": "東京スカイツリー"
}
```

それでは検索して見ましょう。

### 検索1

```json
GET kuromoji_sample/_search
{
   "query": {
     "match": {
       "text": "スカイ"
     }
   }
}
```

`match`クエリなので、クエリに指定された文字列である「スカイ」もanalyzeされますが、結果は「スカイ」となります。そして、Index内には「スカイ」が登録されているので、このクエリの結果は1件となります。

### 検索2

```json
GET kuromoji_sample/_search
{
   "query": {
     "match": {
       "text": "ツリー"
     }
   }
}
```

これは[検索1](#検索1)と同様に「ツリー」がIndex内にもあるので結果は1件となります。

### 検索3

```json
GET kuromoji_sample/_search
{
   "query": {
     "match": {
       "text": "スカイツリー"
     }
   }
}
```

「スカイツリー」はanalyzeにより「スカイ」と「ツリー」に分割されます。`match`クエリでは、この分割された「スカイ」と「ツリー」は`or`条件でIndex内を検索します。
Index内には「スカイ」も「ツリー」もありますので、これも検索結果は1件です。

### 検索4

```json
GET kuromoji_sample/_search
{
   "query": {
     "term": {
       "text": "スカイ"
     }
   }
}
```

これは、まんま[検索1](#検索1)と同じです。

### 検索5

```json
GET kuromoji_sample/_search
{
   "query": {
     "term": {
       "text": "スカイツリー"
     }
   }
}
```

これは検索結果が0件となります。
`term`クエリはクエリに指定された文字列はanalyzeされませんので、「スカイツリー」がIndex内にあるか検索しますが、[テスト](#テスト)で見た通り、Index内には「スカイツリー」はありません。

### 辞書の更新

では、辞書ファイルを以下の内容で更新します。

```
東京スカイツリー,東京 スカイツリー,トウキョウ スカイツリー,カスタム名詞
```

このファイルを保存して、`kuromoji_sample`をクローズ＆オープンします。
そしてテストして見ます。

```json
GET kuromoji_sample/_analyze
{
   "analyzer": "my_analyzer",
   "text": "東京スカイツリー"
}

{
   "tokens" : [
     {
       "token" : "東京",
       "start_offset" : 0,
       "end_offset" : 2,
       "type" : "word",
       "position" : 0
     },
     {
       "token" : "スカイツリー",
       "start_offset" : 2,
       "end_offset" : 8,
       "type" : "word",
       "position" : 1
     }
   ]
}
```

前回の[テスト](#テスト)とは違い、辞書通りに「東京」と「スカイツリー」という2つに分割されます。
では検索して見ましょう。

### 新しい辞書を使った検索

といっても、新しい辞書が影響を受けるのはクエリ、もしくは、更新された辞書が適用されてからIndexに登録されたものだけです。
ですが、検索1〜5は結果は以前と同じとなります。
では、以下のような検索ではどうでしょうか。

```json
GET kuromoji_sample/_search
{
  "query": {
    "match": {
      "text": {
        "query": "東京スカイツリー",
        "operator": "and"
      }
    }
  }
}
```

これは検索結果が0件となります。新しい辞書のもとでは`query`の値である「東京スカイツリー」は「東京」と「スカイツリー」となり、`operator`が`and`なので、Index内に「東京」と「スカイツリー」があるものを検索します。
しかし、古い辞書のもとでは「東京」「スカイ」「ツリー」がIndexに格納されているのでマッチしません。

マッチさせるには、[ここ](https://www.elastic.co/jp/blog/dictionary-update-behavior-for-elasticsearch-cjk-language-analyzers)の一番最後に書かれているとおり、`_update_by_query`を実行すると改めてアナライズが走るようで、ちゃんと検索結果が1件となります（知らんかった・・・）。

## 最後に

辞書は途中でメンテナンスすることが必要になるかと思いますが、変な動作を起こさないようクエリには気をつけましょう。
もしくは、ちゃんと`reindex`か`_update_by_query`しましょう（どっちにしろ、件数が多ければ時間がかかりますが）。