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

# はじめに
[ElasticsearchのSynonym Token Filterを使って類義語の検索と集計をしたい　その１](https://qiita.com/shinyashikis@github/items/7e51995b903b9bf110a8) 
のつづきとなります。

# 2. 類義語集計
## 検証③
まずは、単純に類義語検索に集計クエリ（Aggregation）を追加して確認してみます。

```bash
$ curl -XGET 'localhost:9200/my_index3/my_data/_search?pretty' -d '
{
  "query" : {
    "match" : {
      "name" : "あいふぉん"
    }
  },
  "aggregations" : {
    "name" : {
      "terms" : {
        "field" : "name"
      }
    }
  }
}'
```

当然ですが、そのままではだめですね・・・。

```bash
{
  "took" : 7,
  "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" : "あいふぉんだもの"
      }
    } ]
  },
  "aggregations" : {
    "name" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [ {
        "key" : "もの",
        "doc_count" : 3
      }, {
        "key" : "iPhone",
        "doc_count" : 1
      }, {
        "key" : "あいふぉん",
        "doc_count" : 1
      }, {
        "key" : "アイフォン",
        "doc_count" : 1
      } ]
    }
  }
}
```
分割された単語ごとに集計結果が返却されてるし、そもそも
"あいふぉん" や "アイフォン" が "iPhone" に集約されていません。

本来は、

```bash
{
  "key" : "iPhoneだもの",
  "doc_count" : 3
}
```
この形で返却されるのを期待してるのですが。

## 改めて実現したいこと
ここでもう一度実現したいことを確認します。

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

1と2を同時に実現するには、今のままの `Synonym Token Filter` の設定では無理そうです。
`Synonym Token Filter` の設定を考える必要があります。

## Synonym Token Filter の設定

https://www.elastic.co/guide/en/elasticsearch/guide/2.x/synonyms-expand-or-contract.html
を確認したところ、`Synonym Token Filter` には、

- Expansion(拡張)
- Contraction(収縮)

の設定パターンがあるようです。

類義語検索では、類義語としてグルーピングされたいずれかのワードであれば検索にヒットさせる必要があるので、
`Expansion(拡張)`方式を採用したほうがよさそうです。
つまりこんなイメージですね。

```bash
iPhone     ->  iPhone,アイフォン,あいふぉん
アイフォン  ->  アイフォン,iPhone,あいふぉん
あいふぉん  ->  あいふぉん,iPhone,アイフォン
```

一方、類義語集計では、インデックス時に類義語をメインワードに纏める必要があるので、
`Contraction(収縮)` 方式を採用したほうがよさそうです。

```bash
iPhone     ->  iPhone
アイフォン  ->  iPhone
あいふぉん  ->  iPhone
```

つまりインデックス時と検索時で、それぞれ `Synonym Token Filter` の設定を行う必要がありそうです。

## 検証④
### カスタムAnalyzerの作成
それでは、インデックス時と検索時それぞれでAnalyzerおよび `Synonym Token Filter` を作成していきます。
#### インデックス時
##### Analyzer
`my_index_analyzer`

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

##### Synonym Token Filter
`Contraction(収縮)` 方式で設定した `my_index_synonym_filter` は以下のように定義します。

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

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

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

##### Synonym Token Filter
`Expansion(拡張)` 方式で設定した `my_search_synonym_filter` は以下のように定義します。

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

### インデックス作成
上記のAnalyzerを指定したインデックスを作成します。

```bash
$ curl -XPOST 'localhost:9200/my_index4?pretty' -d '
{
  "settings": {
    "analysis": {
      "filter": {
        "my_index_synonym_filter": {
          "type": "synonym", 
          "synonyms": [ 
            "iPhone,あいふぉん,アイフォン => iPhone"
          ]
        },
        "my_search_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_index_synonym_filter"
          ]
        },
        "my_search_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech",
            "my_search_synonym_filter" 
          ]
        }
      }
    }
  },
  "mappings": {
    "my_data": {
      "properties": {
        "name": {
          "type": "string",
          "analyzer": "my_index_analyzer",
          "search_analyzer": "my_search_analyzer"
        }
      }
    }
  }
}'
```

### Analyzerの確認

インデックス時のAnalyzerを確認します。

```bash
$ curl -XGET 'localhost:9200/my_index4/_analyze?pretty' -d '
{
  "analyzer" : "my_index_analyzer",
  "text" : "アイフォンあいふぉんiPhoneだもの"
}'
```

ちゃんと "アイフォン" と "あいふぉん" が "iPhone" にContraction（収縮）してますね。

```bash
{
  "tokens" : [ {
    "token" : "iPhone",
    "start_offset" : 0,
    "end_offset" : 5,
    "type" : "SYNONYM",
    "position" : 0
  }, {
    "token" : "iPhone",
    "start_offset" : 5,
    "end_offset" : 10,
    "type" : "SYNONYM",
    "position" : 1
  }, {
    "token" : "iPhone",
    "start_offset" : 10,
    "end_offset" : 16,
    "type" : "SYNONYM",
    "position" : 2
  }, {
    "token" : "もの",
    "start_offset" : 17,
    "end_offset" : 19,
    "type" : "word",
    "position" : 4
  } ]
}
```

検索時のAnalyzerは、、

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

こちらも正常に "アイフォン" が "iPhone" "あいふぉん" にExpansion（拡張）してますね。

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

### サンプルデータ投入
それではデータを投入します。

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

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

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

### 検索
今度こそどうでしょうか、、、

```bash
$ curl -XGET 'localhost:9200/my_index4/my_data/_search?pretty' -d '
{
  "query" : {
    "match" : {
      "name" : "あいふぉん"
    }
  },
  "aggregations" : {
    "name" : {
      "terms" : {
        "field" : "name"
      }
    }
  }
}'
```

"アイフォン" "あいふぉん" "iPhone" が "iPhone" として纏まってますね！！

```bash
{
  "took" : 18,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : 0.08954354,
    "hits" : [ {
      "_index" : "my_index4",
      "_type" : "my_data",
      "_id" : "AWokNWbF2C2pl1zNWgfC",
      "_score" : 0.08954354,
      "_source" : {
        "name" : "iPhoneだもの"
      }
    }, {
      "_index" : "my_index4",
      "_type" : "my_data",
      "_id" : "AWokNWcs2C2pl1zNWgfD",
      "_score" : 0.08954354,
      "_source" : {
        "name" : "アイフォンだもの"
      }
    }, {
      "_index" : "my_index4",
      "_type" : "my_data",
      "_id" : "AWokNWdU2C2pl1zNWgfE",
      "_score" : 0.04066637,
      "_source" : {
        "name" : "あいふぉんだもの"
      }
    } ]
  },
  "aggregations" : {
    "name" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [ {
        "key" : "iPhone",
        "doc_count" : 3
      }, {
        "key" : "もの",
        "doc_count" : 3
      } ]
    }
  }
}
```

## もう一息
検証④でやりたいことがほぼ実現できました。
ですが、もう一息です。

Aggregationの結果、

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

を、

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

にしたいんですよね。

つまり、

- 形態素解析により分割された単語ではなく、分割される前の生データで集計したい
- しかも類義語のメインワードに変換した形で

を実現させる必要があります。

## マルチフィールドとは
https://www.elastic.co/guide/en/elasticsearch/guide/current/aggregations-and-analysis.html
https://www.elastic.co/guide/en/elasticsearch/reference/2.3/multi-fields.html
によると、
1つのフィールドからデータの型や形態素解析の方法が異なる複数のフィールドを生成する `マルチフィールド` という仕組みがあります。

例えば今回のケースだと、

- 形態素解析により単語に分割されたデータをインデックスするフィールド `name`
 - 検索時はこのフィールドに対して検索をかけることで類義語をヒットさせる
- 分割せず生のデータをそのままインデックスするフィールド `name.raw`
 - 集計時はこのフィールドに対して集計結果を取得する

この仕組みを使えば、うまくいけそうです。

## 検証⑤

### マッピング定義

それでは、実際にマルチフィールドの設定を行います。
`name` についてはこれまでどおりの設定となりますが、
`name.raw` では `index` を"not_analyzed"にすることで、形態素解析による単語の分割を防ぎ、
そのまま生データがインデックスされるようにします。

```bash
"mappings": {
  "my_data": {
    "properties": {
      "name": {
        "type": "string",
        "fields": {
          "raw": {
            "type": "string",
            "index": "not_analyzed"
          }
        },
        "analyzer": "my_index_analyzer",
        "search_analyzer": "my_search_analyzer"
      }
    }
  }
}
```

### インデックス作成
上記のマッピング定義を指定したインデックスを作成します。

```bash
$ curl -XPOST 'localhost:9200/my_index5?pretty' -d '
{
  "settings": {
    "analysis": {
      "filter": {
        "my_index_synonym_filter": {
          "type": "synonym", 
          "synonyms": [ 
            "iPhone,あいふぉん,アイフォン => iPhone"
          ]
        },
        "my_search_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_index_synonym_filter"
          ]
        },
        "my_search_analyzer": {
          "type": "custom",
          "tokenizer": "my_kuromoji_tokenizer",
          "filter": [
            "kuromoji_part_of_speech",
            "my_search_synonym_filter" 
          ]
        }
      }
    }
  },
  "mappings": {
    "my_data": {
      "properties": {
        "name": {
          "type": "string",
          "fields": {
            "raw": {
              "type": "string",
              "index": "not_analyzed"
            }
          },
          "analyzer": "my_index_analyzer",
          "search_analyzer": "my_search_analyzer"
        }
      }
    }
  }
}'
```

### サンプルデータ投入
データを投入します。

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

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

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

### 検索

検索フィールドは `name` 、集計フィールドは `name.raw` を指定します。
結果は、、、

```bash
$ curl -XGET 'localhost:9200/my_index5/my_data/_search?pretty' -d '
{
  "query" : {
    "match" : {
      "name" : "あいふぉん"
    }
  },
  "aggregations" : {
    "name" : {
      "terms" : {
        "field" : "name.raw"
      }
    }
  }
}'
```

だめでした。。。
分割された単語ではなく生のデータとして集計結果が返ってきましたが、類義語として集約されていませんね。

```bash
{
  "took" : 18,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 3,
    "max_score" : 0.08954354,
    "hits" : [ {
      "_index" : "my_index5",
      "_type" : "my_data",
      "_id" : "AWolAlau2C2pl1zNWgfN",
      "_score" : 0.08954354,
      "_source" : {
        "name" : "iPhoneだもの"
      }
    }, {
      "_index" : "my_index5",
      "_type" : "my_data",
      "_id" : "AWolAlcY2C2pl1zNWgfP",
      "_score" : 0.08954354,
      "_source" : {
        "name" : "あいふぉんだもの"
      }
    }, {
      "_index" : "my_index5",
      "_type" : "my_data",
      "_id" : "AWolAlbm2C2pl1zNWgfO",
      "_score" : 0.04066637,
      "_source" : {
        "name" : "アイフォンだもの"
      }
    } ]
  },
  "aggregations" : {
    "name" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [ {
        "key" : "iPhoneだもの",
        "doc_count" : 1
      }, {
        "key" : "あいふぉんだもの",
        "doc_count" : 1
      }, {
        "key" : "アイフォンだもの",
        "doc_count" : 1
      } ]
    }
  }
}
```

# まとめ
- インデックス時の文字列と検索時の文字列を `kuromoji_tokenizer` を使用して分割
- 分割時に単語として認識させたいものはユーザ辞書で設定
- 分割した単語は `Synonym Token Filter` を使用して類義語に変換
 - インデックス時は `Contraction(収縮)` 、検索時は `Expansion(拡張)` の設定
- 類義語集計を実現するには、形態素解析されたフィールドを指定する必要がある
 - 集計結果は形態素解析により分割された単語ごとに返却される
 - 形態素解析されていない状態の生データで類義語集計することはできない（と思われる）

# おわりに
類義語検索は実現できましたが、
類義語集計は完全には実現できませんでした。
考えてみれば当然で、`Synonym Token Filter` を使用して類義語に変換するわけですから、Tokenizerを使用して分割した単語に対して変換を行う必要があります。
よって、類義語を扱いたいイコール単語分割は必須となるわけで、そうなると形態素解析されていない生データで集計することはそもそも不可能となるわけです。

# 参考にしたサイト
https://www.elastic.co/guide/en/elasticsearch/guide/2.x/synonyms-expand-or-contract.html
https://www.elastic.co/guide/en/elasticsearch/guide/current/aggregations-and-analysis.html
https://www.elastic.co/guide/en/elasticsearch/reference/2.3/multi-fields.html