ElasticsearchのSynonym Token Filterを使って類義語の検索と集計をしたい　その１

# はじめに
個人用に考えを整理するためにまとめました。
他に良い実現方法があるかと思いますが、その場合はご指摘をいただけると大変ありがたいです。

当記事は２部構成になっています。

- ElasticsearchのSynonym Token Filterを使って類義語の検索と集計をしたい　その１
- [ElasticsearchのSynonym Token Filterを使って類義語の検索と集計をしたい　その２](https://qiita.com/shinyashikis@github/items/dc52413dd4239c4fa869)

# やりたいこと
1. 類義語検索
  - 類義語であればどのワードでもヒットする
2. 類義語集計
  - 類義語のメインワードで検索結果を集計（Aggregation）する

たとえば、
　類義語 　　　： "iPhone", "アイフォン", "あいふぉん"
　メインワード ： "iPhone"
とした場合、

以下のドキュメント

```
{ "name" : "iPhoneだもの" }
{ "name" : "アイフォンだもの" }
{ "name" : "あいふぉんだもの" }
```

に対して、
"iPhone", "アイフォン", "あいふぉん" のいずれかで検索すると、３件すべてのドキュメントがヒットし、
集計結果として「iPhoneだもの」が以下のように集約されて返却されるようにしたい。

```
"buckets" : [ {
  "key" : "iPhoneだもの",
  "doc_count" : 3
}]
```

# 検証環境
- Docker(ホストOS:Ubuntu18.04.1、ゲストOS:Centos7.6)
- elasticsearch-2.3.3
- analysis-kuromoji-2.3.3

とりあえず環境はDockerで構築します。
また、Elasticsearchのバージョンについては、少々古いですが2.3.3を使います。

# 検証環境の構築
Elasticsearchについては公式の [Dockerイメージ](https://hub.docker.com/_/elasticsearch) があるのですが、今回はCentoOS7上にElasticsearchを構築します。

まずはDockerfileを作成。

```Dockerfile:Dockerfile
FROM centos:centos7

RUN yum install -y java wget

RUN wget https://download.elastic.co/elasticsearch/release/org/elasticsearch/distribution/tar/elasticsearch/2.3.3/elasticsearch-2.3.3.tar.gz \
  && tar zxvf elasticsearch-2.3.3.tar.gz \
  && /elasticsearch-2.3.3/bin/plugin install analysis-kuromoji --verbose

CMD ["/elasticsearch-2.3.3/bin/elasticsearch", "-Des.insecure.allow.root=true"]
```

Dockerコンテナの作成＆起動。

```bash
# 作成
$ docker build -t elasticsearch-2.3.3 .
# 起動
$ docker run -t -d -p 9200:9200 --name elasticsearch-2.3.3 elasticsearch-2.3.3
```

これで検証の準備が整いました。

# 1. 類義語検索
## 検証①
まずはそのままの設定で検証してみます。
### インデックス作成
string型フィールド `name` をもつインデックスを作成。

```bash
$ curl -XPUT 'localhost:9200/my_index1?pretty' -d '
{
  "mappings": {
    "my_data": {
      "properties": {
        "name": { "type": "string" }
      }
    }
  }
}'
```

### サンプルデータ投入
続いて３パターンの「iPhoneだもの」を投入します。

```bash
# 英語パターン
$ curl -XPOST 'localhost:9200/my_index1/my_data?pretty' -d '
{
  "name" : "iPhoneだもの"
}'

# カタカナパターン
$ curl -XPOST 'localhost:9200/my_index1/my_data?pretty' -d '
{
  "name" : "アイフォンだもの"
}'

# ひらがなパターン
$ curl -XPOST 'localhost:9200/my_index1/my_data?pretty' -d '
{
  "name" : "あいふぉんだもの"
}'

# 確認
$ curl -XGET 'localhost:9200/my_index1/my_data/_search?pretty' -d '
{
  "query": {
    "match_all": {}
  }
}'
```

### 検索
"アイフォン" で検索してみると、

```bash
$ curl -XGET 'localhost:9200/my_index1/my_data/_search?pretty' -d '
{
  "query" : {
    "match" : {
      "name" : "アイフォン"
    }
  }
}'
```

当然ですが「アイフォンだもの」 しかヒットしません。

```bash
{
  "took" : 3,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.5,
    "hits" : [ {
      "_index" : "my_index1",
      "_type" : "my_data",
      "_id" : "AWnwpj12j74hZnXCfWZc",
      "_score" : 0.5,
      "_source" : {
        "name" : "アイフォンだもの"
      }
    } ]
  }
}
```

類義語を設定していない状態では、「iPhoneだもの」「あいふぉんだもの」 はヒットしません。

## そもそも類義語をヒットさせるには
そもそも "アイフォン" で
「アイフォンだもの」「iPhoneだもの」「あいふぉんだもの」
をヒットさせるには、インデックス時と検索時に、以下の処理が必要です。

- インデックス時
 - 格納する文字列を単語に分割する
     - 「アイフォンだもの」 -> 　"アイフォン", "だ", "もの"
     - 「iPhoneだもの」　　-> 　"iPhone", "だ", "もの"
     - 「あいふぉんだもの」 -> 　"あいふぉん", "だ", "もの"

- 検索時
  - 検索文字列を単語に分割する
     - インデックス時と同様
  - 分割した文字を類義語に変換する
     - "アイフォン", "だ", "もの"　->　"アイフォン", "iPhone", "あいふぉん", "だ", "もの"　
     - "iPhone", "だ", "もの"　　 ->　"iPhone", "アイフォン", "あいふぉん", "だ", "もの"
     - "あいふぉん", "だ", "もの"　->　"あいふぉん", "アイフォン", "iPhone", "だ", "もの"

文字列を単語に分割したり類義語変換を行うために、Elasticsearchでは以下の機能があります。

|機能|概要|
|---|---|
|[kuromoji_tokenizer](https://www.elastic.co/guide/en/elasticsearch/plugins/2.3/analysis-kuromoji-tokenizer.html)|日本語の文字列を単語に分割する|
|[Synonym Token Filter](https://www.elastic.co/guide/en/elasticsearch/reference/2.3/analysis-synonym-tokenfilter.html)|類義語を変換するフィルタ|

これらの機能を組み合わせて使えばなんとなくやりたいことが実現できそうです。

## 検証②
それでは早速 `kuromoji_tokenizer` と `Synonym Token Filter` を使って検証してみます。

### カスタムAnalyzerの作成
`kuromoji_tokenizer` と `Synonym Token Filter` を利用するには、それらを使うAnalyzerを作成する必要があります。
またAnalyzerは、インデックス時に使用するケースと検索時に使用するケースがあります。

そこで今回は以下のような構成のAnalyzerを作成します。

#### インデックス時のAnalyzer
`my_index_analyzer`

|構成要素|モジュール|説明|
|---|---|---|
|Char Filter|指定なし|今回は使用しない|
|Tokenizer|kuromoji_tokenizer|日本語を単語に分割する|
|Token Filter|kuromoji_part_of_speech|分割された単語から品詞を除外する|

#### 検索時のAnalyzer
`my_search_analyzer`

|構成要素|モジュール|説明|
|---|---|---|
|Char Filter|指定なし|今回は使用しない|
|Tokenizer|kuromoji_tokenizer|日本語を単語に分割する|
|Token Filter|kuromoji_part_of_speech|分割された単語から品詞を除外する|
||my_synonym_filter|今回用に作成した類義語変換フィルタ|

#### 類義語変換フィルタ
"iPhone","あいふぉん","アイフォン" を類義語として設定した `my_synonym_filter` は以下のように定義します。

```bash
"filter": {
  "my_synonym_filter": {
    "type": "synonym", 
    "synonyms": [ 
      "iPhone,あいふぉん,アイフォン"
    ]
  }
}
```

### インデックス作成
それではインデックスの作成を行います。
各Analyzerと類義語フィルタを定義し、マッピング定義でフィールド `name` へのインデックス時と検索時にそれぞれのAnalyzerを使用するよう設定します。

```bash
$ curl -XPOST 'localhost:9200/my_index2?pretty' -d '
{
  "settings": {
    "analysis": {
      "filter": {
        "my_synonym_filter": {
          "type": "synonym", 
          "synonyms": [ 
            "iPhone,あいふぉん,アイフォン"
          ]
        }
      },
      "analyzer": {
        "my_index_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech"
          ]
        },
        "my_search_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech",
            "my_synonym_filter" 
          ]
        }
      }
    }
  },
  "mappings": {
    "my_data": {
      "properties": {
        "name": {
          "type": "string",
          "analyzer": "my_index_analyzer",
          "search_analyzer": "my_search_analyzer"
        }
      }
    }
  }
}'
```

### Analyzerの確認

実際にデータを投入して検証する前に、インデックス時と検索時のAnalyzerのふるまいを確認しておきます。

まずはインデックス時の `my_index_analyzer` から

```bash
$ curl -XGET 'localhost:9200/my_index2/_analyze?pretty' -d '
{
  "analyzer" : "my_index_analyzer",
  "text" : "アイフォンだもの"
}'
```

正常に "アイフォン" と "もの" に分割されますね。
ちなみに「アイフォンだもの」の "だ" は `kuromoji_part_of_speech` により除外されたようですね。

```bash
{
  "tokens" : [ {
    "token" : "アイフォン",
    "start_offset" : 0,
    "end_offset" : 5,
    "type" : "word",
    "position" : 0
  }, {
    "token" : "もの",
    "start_offset" : 6,
    "end_offset" : 8,
    "type" : "word",
    "position" : 2
  } ]
}
```

続いて検索時の `my_search_analyzer` 

```bash
$ curl -XGET 'localhost:9200/my_index2/_validate/query?explain&pretty' -d '
{
  "query": {
    "match" : { "name" : "アイフォンだもの" }
  }
}'
```

こちらも正常に "アイフォン" と "もの" に分割されてますね。
さらに "アイフォン" のほかに、類義語である "iPhone" と "あいふぉん" も追加されています。

```bash
{
  "valid" : true,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "failed" : 0
  },
  "explanations" : [ {
    "index" : "my_index2",
    "valid" : true,
    "explanation" : "(name:アイフォン name:iPhone name:あいふぉん) name:もの"
  } ]
}
```

### サンプルデータ投入

それでは先ほどと同様にサンプルデータを投入します。

```bash
$ curl -XPOST 'localhost:9200/my_index2/my_data?pretty' -d '
{
  "name" : "iPhoneだもの"
}'

$ curl -XPOST 'localhost:9200/my_index2/my_data?pretty' -d '
{
  "name" : "アイフォンだもの"
}'

$ curl -XPOST 'localhost:9200/my_index2/my_data?pretty' -d '
{
  "name" : "あいふぉんだもの"
}'
```

### 検索

さあ今回はどうでしょうか。

```bash
$ curl -XGET 'localhost:9200/my_index2/my_data/_search?pretty' -d '
{
  "query" : {
    "match" : {
      "name" : "アイフォン"
    }
  }
}'
```

「iPhoneだもの」までちゃんとヒットするようになりましたが、「あいふぉんだもの」がヒットされませんね。

```bash
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 0.04066637,
    "hits" : [ {
      "_index" : "my_index2",
      "_type" : "my_data",
      "_id" : "AWnwtRqXj74hZnXCfWZp",
      "_score" : 0.04066637,
      "_source" : {
        "name" : "アイフォンだもの"
      }
    }, {
      "_index" : "my_index2",
      "_type" : "my_data",
      "_id" : "AWnwtQMtj74hZnXCfWZo",
      "_score" : 0.04066637,
      "_source" : {
        "name" : "iPhoneだもの"
      }
    } ]
  }
}
```

「あいふぉんだもの」の分割が正常にできているか怪しいですね。

先程のやり方で、インデックス時のAnalyzerの確認をしてみます。

```bash
$ curl -XGET 'localhost:9200/my_index2/_analyze?pretty' -d '
{
  "analyzer" : "my_index_analyzer",
  "text" : "あいふぉんだもの"
}'
```

ん？何やらおかしな感じで「あいふぉんだもの」が分割されているようです。

```bash
{
  "tokens" : [ {
    "token" : "いふ",
    "start_offset" : 1,
    "end_offset" : 3,
    "type" : "word",
    "position" : 1
  }, {
    "token" : "ぉんだもの",
    "start_offset" : 3,
    "end_offset" : 8,
    "type" : "word",
    "position" : 2
  } ]
}
```

## 検証③
### ユーザ辞書の作成
検証②では、「あいふぉんだもの」が意図しない形で分割されてしまいました。
分割結果をみると、どうやら "あいふぉん" が単語として認識されず、おかしな分割のされかたとなっているようです。
そこで[ユーザ辞書](https://www.elastic.co/guide/en/elasticsearch/plugins/2.3/analysis-kuromoji-tokenizer.html)を使用して、"あいふぉん" が一つの単語であると認識させます。

それではユーザ辞書を以下のように作成します。

```bash:/elasticsearch-2.3.3/config/userdict_ja.txt
あいふぉん,あいふぉん,アイフォン,カスタム名詞
```

### インデックス作成
さきほど作成したユーザ辞書を指定したTokenizer `my_kuromoji_tokenizer` を新たに作成し、各Analyzerで指定します。

それではインデックスの作成を行います。

```bash
$ curl -XPOST 'localhost:9200/my_index3?pretty' -d '
{
  "settings": {
    "analysis": {
      "filter": {
        "my_synonym_filter": {
          "type": "synonym", 
          "synonyms": [ 
            "iPhone,あいふぉん,アイフォン"
          ]
        }
      },
      "tokenizer": {
        "my_kuromoji_tokenizer": {
          "type": "kuromoji_tokenizer",
          "mode": "search",
          "discard_punctuation": "false",
          "user_dictionary": "userdict_ja.txt"
        }
      },
      "analyzer": {
        "my_index_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech"
          ]
        },
        "my_search_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech",
            "my_synonym_filter" 
          ]
        }
      }
    }
  },
  "mappings": {
    "my_data": {
      "properties": {
        "name": {
          "type": "string",
          "analyzer": "my_index_analyzer",
          "search_analyzer": "my_search_analyzer"
        }
      }
    }
  }
}'
```

### Analyzerの確認

さきほどと同様、インデックス時と検索時のAnalyzerのふるまいを確認しておきます。

インデックス時の `my_index_analyzer` から。

```bash
$ curl -XGET 'localhost:9200/my_index3/_analyze?pretty' -d '
{
  "analyzer" : "my_index_analyzer",
  "text" : "あいふぉんだもの"
}'
```

正常に "あいふぉん" と "もの" に分割されますね。

```bash
{
  "tokens" : [ {
    "token" : "あいふぉん",
    "start_offset" : 0,
    "end_offset" : 5,
    "type" : "word",
    "position" : 0
  }, {
    "token" : "もの",
    "start_offset" : 6,
    "end_offset" : 8,
    "type" : "word",
    "position" : 2
  } ]
}
```

続いて検索時の `my_search_analyzer` 。

```bash
$ curl -XGET 'localhost:9200/my_index3/_validate/query?explain&pretty' -d '
{
  "query": {
    "match" : { "name" : "あいふぉんだもの" }
  }
}'
```

こちらも正常に "あいふぉん" と "もの" に分割されていて、
類義語である "iPhone" と "アイフォン" も追加されています。

```bash
{
  "valid" : true,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "failed" : 0
  },
  "explanations" : [ {
    "index" : "my_index3",
    "valid" : true,
    "explanation" : "(name:あいふぉん name:iPhone name:アイフォン) name:もの"
  } ]
}
```

### サンプルデータ投入

それでは先ほどと同様にサンプルデータを投入します。

```bash
$ curl -XPOST 'localhost:9200/my_index3/my_data?pretty' -d '
{
  "name" : "iPhoneだもの"
}'

$ curl -XPOST 'localhost:9200/my_index3/my_data?pretty' -d '
{
  "name" : "アイフォンだもの"
}'

$ curl -XPOST 'localhost:9200/my_index3/my_data?pretty' -d '
{
  "name" : "あいふぉんだもの"
}'
```

### 検索
さて、結果は、、、

```bash
$ curl -XGET 'localhost:9200/my_index3/my_data/_search?pretty' -d '
{
  "query" : {
    "match" : {
      "name" : "アイフォン"
    }
  }
}'
```
３件ともヒットしました！！

```bash
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : 0.04066637,
    "hits" : [ {
      "_index" : "my_index3",
      "_type" : "my_data",
      "_id" : "AWnxnY-dj74hZnXCfWaE",
      "_score" : 0.04066637,
      "_source" : {
        "name" : "iPhoneだもの"
      }
    }, {
      "_index" : "my_index3",
      "_type" : "my_data",
      "_id" : "AWnxnaq-j74hZnXCfWaF",
      "_score" : 0.04066637,
      "_source" : {
        "name" : "アイフォンだもの"
      }
    }, {
      "_index" : "my_index3",
      "_type" : "my_data",
      "_id" : "AWnxncJDj74hZnXCfWaG",
      "_score" : 0.04066637,
      "_source" : {
        "name" : "あいふぉんだもの"
      }
    } ]
  }
}
```

類義語検索は実現できましたね！

長くなったので、類義語集計については [その２](https://qiita.com/shinyashikis@github/items/dc52413dd4239c4fa869) で検証します。

# いったんまとめ
## 類義語検索の実現方法
- インデックス時の文字列と検索時の文字列を `kuromoji_tokenizer` を使用して分割
- 検索時は、さらに `Synonym Token Filter` を使用して分割した単語を類義語に変換
- 分割時に単語として認識されたいものはユーザ辞書で設定する

# 参考にしたサイト
[Elasticsearch 日本語で全文検索 その３](https://medium.com/hello-elasticsearch/elasticsearch-6d69b6ff5c26)
[ElasticSearch でインデックス時と全文検索時で異なる analyzer を設定する](https://blog.chocolapod.net/momokan/entry/116)