6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-04-16

はじめに

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
}

この形で返却されるのを期待してるのですが。

改めて実現したいこと

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

  1. 類義語検索
  • 類義語であればどのワードでもヒットする
  1. 類義語集計
  • 類義語のメインワードで検索結果を集計(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

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?