Elasticsearchの類義語辞書を動的に更新する方法の調査: codelibs/elasticsearch-analysis-synonym

# 概要

elasticsearchで類義語を扱うための機能である[synonym token filter](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-synonym-tokenfilter.html)は、類義語辞書の内容の更新を検索に反映する場合、ノードの再起動か、インデックスのクローズ・オープンが必要となっています。
しかし、数十台からなるElasticsearchクラスタを運用している場合、クラスタの全ノードを再起動するとなると結構大変ですし、ましてやインデックスのクローズ・オープンはサービスの停止が必要になってしまうので、そう簡単にできるものでもないでしょう。。。
類義語辞書は日々更新されるものなので、できるだけ低コストで検索に反映したいものです。

というわけで、elasticsearchのsynonymの辞書を無停止で更新する方法について調べていたところ、下記のプラグインが見つかったので、調査します。
https://github.com/codelibs/elasticsearch-analysis-synonym

# codelibs/elasticsearch-analysis-synonymとは

指定時間ごとに辞書ファイルをリロード可能なngram_synonymというtokenizerと、synonym_filterというtoken filterが利用できるようになるプラグインのようです。

* ngram_synonym: ngramでトークナイズするタイミングで類義語辞書とマッチしたトークンの候補を検索クエリに追加する。
* synonym_filter: トークナイズ後に、類義語辞書にマッチしたトークンの候補を検索クエリに追加する。kuromojiでトークナイズした後に適用したりできる。

# 使用したプロダクトのバージョンなど

| リポジトリ | 名称 | バージョン |
|:-----------|:------------|:------------|
| elastic       | elasticsearch | 6.3.2 |
| codelibs     | elasticsearch-analysis-synonym | 6.3.1 |
| codelibs       | elasticsearch-analysis-kuromoji-neologd | 6.3.1 |

インストールはREADMEの通りで行けました。

# 動作確認
## ngram_synonymの動き
### 準備

類義語辞書の用意

```bash
cat ${ES_HOME}/config/synonym.txt
あ,かき
```

インデックスを用意します。下記の設定で10秒に1回synonym.txtを読み直してくれます。

```bash
curl -H "Content-Type:application/json" -XPUT localhost:9200/sample?pretty -d '{
  "settings":{
    "index":{
      "analysis":{
        "tokenizer":{
          "2gram_synonym":{
            "type":"ngram_synonym",
            "n":"2",
            "synonyms_path":"synonym.txt",
            "dynamic_reload":true,
            "reload_interval":"10s"
          }
        },
        "analyzer":{
          "2gram_synonym_analyzer":{
            "type":"custom",
            "tokenizer":"2gram_synonym"
          }
        }
      }
    }
  },
  "mappings":{
    "item":{
      "properties":{
        "id":{
          "type":"keyword"
        },
        "msg":{
          "type":"text",
          "analyzer":"2gram_synonym_analyzer"
        }
      }
    }
  }
}'

# ドキュメントを用意
curl -H "Content-Type:application/json" -XPOST localhost:9200/sample/item/1 -d '
{
  "id":"1",
  "msg":"あいうえお"
}'

```

### 検索

下記の検索クエリで、類義語辞書が使用され、「あいうえお」がヒットします。

```bash
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample/_search?pretty" -d '
{
   "query": {
      "match_phrase": {
         "msg": "かき"
      }
   }
}'
```

analyze APIで確認すると、「かき」とマッチした「あ」が追加されていることが分かります。

```bash
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample/_analyze?pretty" -d '
{
  "analyzer" : "2gram_synonym_analyzer",
  "text" : "かき"
}'
{
  "tokens" : [
    {
      "token" : "かき",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "あ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}
```

クエリ文字列を「かきい」にした場合、「かきい」と「あい」で検索され、「あいうえお」の「あい」の部分がマッチするのでヒットします。

```bash
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample/_analyze?pretty" -d '
{
  "analyzer" : "2gram_synonym_analyzer",
  "text" : "かきい"
}'
{
  "tokens" : [
    {
      "token" : "かき",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "あ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "い",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    }
  ]
}
```

クエリ文字列を「かきく」にした場合、「かきく」と「あく」で検索され、「あいうえお」はヒットしません。

```bash
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample/_analyze?pretty" -d '
{
  "analyzer" : "2gram_synonym_analyzer",
  "text" : "かきく"
}'
{
  "tokens" : [
    {
      "token" : "かき",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "あ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "く",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    }
  ]
}
```

「さし」を足して10秒待ちます。

```bash
cat config/synonym.txt
あ,かき,さし
```

クエリ文字列を「さし」にした場合、「さし」「あ」「かき」で検索されるようになります。

```bash
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample/_analyze?pretty" -d '
{
  "analyzer" : "2gram_synonym_analyzer",
  "text" : "さし"
}'

{
  "tokens" : [
    {
      "token" : "さし",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "あ",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "かき",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}
```


## synonym_filterの動き

ここでは、kuromoji_neologd_tokenizerでトークナイズした結果について、synonym_filterで類義語辞書を適用してみます。

### 準備
インデックスを用意します。下記の設定で10秒に1回synonym.txtを読み直してくれます。

```bash
curl -H "Content-Type:application/json" -XPUT localhost:9200/sample_filter?pretty -d '{
  "settings":{
    "index":{
      "analysis":{
        "tokenizer":{
          "kuromoji_tokenizer":{
            "type":"kuromoji_neologd_tokenizer"
          }
        },
        "analyzer":{
          "kuromoji_neologd":{
            "type":"custom",
            "tokenizer":"kuromoji_tokenizer",
            "filter": [
              "synonym_filter"
            ]
          }
        },
        "filter":{
          "synonym_filter": {
            "type": "synonym_filter",
            "synonyms_path":"synonym.txt",
            "dynamic_reload":true,
            "reload_interval":"10s"
          }
        }
      }
    }
  },
  "mappings":{
    "item":{
      "properties":{
        "id":{
          "type":"keyword"
        },
        "msg":{
          "type":"text",
          "analyzer":"kuromoji_neologd"
        }
      }
    }
  }
}'

curl -H "Content-Type:application/json" -XPOST localhost:9200/sample_filter/item/1 -d '
{
  "id":"1",
  "msg":"TOKYO JAPAN"
}'

curl -H "Content-Type:application/json" -XPOST localhost:9200/sample_filter/item/2 -d '
{
  "id":"2",
  "msg":"東京の気温"
}'

```

### 検索

類義語辞書に単語が登録されていない状態だと、「東京の気温」しかヒットしないです。

```bash
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample_filter/_analyze?pretty" -d '
{
  "analyzer" : "kuromoji_neologd",
  "text" : "東京"
}'
{
  "tokens" : [
    {
      "token" : "東京",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    }
  ]
}
```

「東京,TOKYO」を足して10秒待ちます。

```bash
cat config/synonym.txt
あ,かき,さし
東京,TOKYO
```

今度は類義語が反映されます。

```bash
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample_filter/_analyze?pretty" -d '
{
  "analyzer" : "kuromoji_neologd",
  "text" : "東京"
}'
{
  "tokens" : [
    {
      "token" : "東京",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "TOKYO",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0
    }
  ]
}

# どっちもヒットするようになります。
$ curl -H "Content-Type:application/json" -XGET "http://localhost:9200/sample_filter/_search?pretty" -d '
{
   "query": {
      "match_phrase": {
         "msg": "東京"
      }
   }
}'
{
  "took" : 13,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 0.43648314,
    "hits" : [
      {
        "_index" : "sample_filter",
        "_type" : "item",
        "_id" : "1",
        "_score" : 0.43648314,
        "_source" : {
          "id" : "1",
          "msg" : "TOKYO JAPAN"
        }
      },
      {
        "_index" : "sample_filter",
        "_type" : "item",
        "_id" : "2",
        "_score" : 0.4254794,
        "_source" : {
          "id" : "2",
          "msg" : "東京の気温"
        }
      }
    ]
  }
}
```

# まとめ

辞書が大きくなった時とか、検索による負荷が高い時に更新の反映が上手くいくのかなど、若干まだ気になるところはありますが、期待した通りの動きではあるようでした。
Elasticsearchの辞書の更新方法についての話題ってあまり見ない印象があり、停止メンテナンスしにくい大規模サービスでどのように運用されているのか気になっています。。。
良い方法などご存知の方おりましたらご教示いただけるとありがたいです。