23
15

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を使って類義語の検索と集計をしたい その1

Last updated at Posted at 2019-04-06

はじめに

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

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

やりたいこと

  1. 類義語検索
  • 類義語であればどのワードでもヒットする
  1. 類義語集計
  • 類義語のメインワードで検索結果を集計(Aggregation)する

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

以下のドキュメント

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

に対して、
"iPhone", "アイフォン", "あいふぉん" のいずれかで検索すると、3件すべてのドキュメントがヒットし、
集計結果として「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イメージ があるのですが、今回はCentoOS7上にElasticsearchを構築します。

まずは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コンテナの作成&起動。

# 作成
$ 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 をもつインデックスを作成。

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

サンプルデータ投入

続いて3パターンの「iPhoneだもの」を投入します。

# 英語パターン
$ 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": {}
  }
}'

検索

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

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

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

{
  "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 日本語の文字列を単語に分割する
Synonym Token Filter 類義語を変換するフィルタ

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

検証②

それでは早速 kuromoji_tokenizerSynonym Token Filter を使って検証してみます。

カスタムAnalyzerの作成

kuromoji_tokenizerSynonym 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 は以下のように定義します。

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

インデックス作成

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

$ 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 から

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

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

{
  "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

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

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

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

サンプルデータ投入

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

$ 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" : "あいふぉんだもの"
}'

検索

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

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

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

{
  "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の確認をしてみます。

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

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

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

検証③

ユーザ辞書の作成

検証②では、「あいふぉんだもの」が意図しない形で分割されてしまいました。
分割結果をみると、どうやら "あいふぉん" が単語として認識されず、おかしな分割のされかたとなっているようです。
そこでユーザ辞書を使用して、"あいふぉん" が一つの単語であると認識させます。

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

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

インデックス作成

さきほど作成したユーザ辞書を指定したTokenizer my_kuromoji_tokenizer を新たに作成し、各Analyzerで指定します。

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

$ 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 から。

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

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

{
  "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

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

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

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

サンプルデータ投入

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

$ 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" : "あいふぉんだもの"
}'

検索

さて、結果は、、、

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

3件ともヒットしました!!

{
  "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" : "あいふぉんだもの"
      }
    } ]
  }
}

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

長くなったので、類義語集計については その2 で検証します。

いったんまとめ

類義語検索の実現方法

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

参考にしたサイト

Elasticsearch 日本語で全文検索 その3
ElasticSearch でインデックス時と全文検索時で異なる analyzer を設定する

23
15
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
23
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?