はじめに
ElasticsearchのSynonym Token Filterを使って類義語の検索と集計をしたい その1
のつづきとなります。
2. 類義語集計
検証③
まずは、単純に類義語検索に集計クエリ(Aggregation)を追加して確認してみます。
$ curl -XGET 'localhost:9200/my_index3/my_data/_search?pretty' -d '
{
"query" : {
"match" : {
"name" : "あいふぉん"
}
},
"aggregations" : {
"name" : {
"terms" : {
"field" : "name"
}
}
}
}'
当然ですが、そのままではだめですね・・・。
{
"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" に集約されていません。
本来は、
{
"key" : "iPhoneだもの",
"doc_count" : 3
}
この形で返却されるのを期待してるのですが。
改めて実現したいこと
ここでもう一度実現したいことを確認します。
- 類義語検索
- 類義語であればどのワードでもヒットする
- 類義語集計
- 類義語のメインワードで検索結果を集計(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(拡張)
方式を採用したほうがよさそうです。
つまりこんなイメージですね。
iPhone -> iPhone,アイフォン,あいふぉん
アイフォン -> アイフォン,iPhone,あいふぉん
あいふぉん -> あいふぉん,iPhone,アイフォン
一方、類義語集計では、インデックス時に類義語をメインワードに纏める必要があるので、
Contraction(収縮)
方式を採用したほうがよさそうです。
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
は以下のように定義します。
"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
は以下のように定義します。
"filter": {
"my_search_synonym_filter": {
"type": "synonym",
"synonyms": [
"iPhone,あいふぉん,アイフォン"
]
}
}
インデックス作成
上記のAnalyzerを指定したインデックスを作成します。
$ 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を確認します。
$ curl -XGET 'localhost:9200/my_index4/_analyze?pretty' -d '
{
"analyzer" : "my_index_analyzer",
"text" : "アイフォンあいふぉんiPhoneだもの"
}'
ちゃんと "アイフォン" と "あいふぉん" が "iPhone" にContraction(収縮)してますね。
{
"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は、、
$ curl -XGET 'localhost:9200/my_index4/_validate/query?explain&pretty' -d '
{
"query": {
"match" : { "name" : "アイフォンだもの" }
}
}'
こちらも正常に "アイフォン" が "iPhone" "あいふぉん" にExpansion(拡張)してますね。
{
"valid" : true,
"_shards" : {
"total" : 1,
"successful" : 1,
"failed" : 0
},
"explanations" : [ {
"index" : "my_index4",
"valid" : true,
"explanation" : "(name:アイフォン name:iPhone name:あいふぉん) name:もの"
} ]
}
サンプルデータ投入
それではデータを投入します。
$ 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" : "あいふぉんだもの"
}'
検索
今度こそどうでしょうか、、、
$ curl -XGET 'localhost:9200/my_index4/my_data/_search?pretty' -d '
{
"query" : {
"match" : {
"name" : "あいふぉん"
}
},
"aggregations" : {
"name" : {
"terms" : {
"field" : "name"
}
}
}
}'
"アイフォン" "あいふぉん" "iPhone" が "iPhone" として纏まってますね!!
{
"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の結果、
"buckets" : [ {
"key" : "iPhone",
"doc_count" : 3
}, {
"key" : "もの",
"doc_count" : 3
} ]
を、
"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"にすることで、形態素解析による単語の分割を防ぎ、
そのまま生データがインデックスされるようにします。
"mappings": {
"my_data": {
"properties": {
"name": {
"type": "string",
"fields": {
"raw": {
"type": "string",
"index": "not_analyzed"
}
},
"analyzer": "my_index_analyzer",
"search_analyzer": "my_search_analyzer"
}
}
}
}
インデックス作成
上記のマッピング定義を指定したインデックスを作成します。
$ 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"
}
}
}
}
}'
サンプルデータ投入
データを投入します。
$ 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
を指定します。
結果は、、、
$ curl -XGET 'localhost:9200/my_index5/my_data/_search?pretty' -d '
{
"query" : {
"match" : {
"name" : "あいふぉん"
}
},
"aggregations" : {
"name" : {
"terms" : {
"field" : "name.raw"
}
}
}
}'
だめでした。。。
分割された単語ではなく生のデータとして集計結果が返ってきましたが、類義語として集約されていませんね。
{
"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