ElasticsearchでNGram Tokenizerを試す

ElasticsearchでNGram Tokenizerを試してみたメモです。

ElasticsearchのアナライザーでNGram Tokenizerを試してみました。

Ubuntu上でElasticsearch5.4.0で試してみます。

N-Gram

日本語の文章は英文と違い空白による単語の分割が出来ないので、
何らかの方法で検索できる形に単語を分解する必要があります。
N-Gramは文字数単位(N文字単位)で文章を分割する手法です。

アナライザ

アナライザはインデクシング時や検索時にテキストを処理する機能です。
フィルター、トークナイザ、トークンフィルタの組み合わせで指定します。

トークナイザ

トークナイザは文字をトークン(単語)に分割して出力します。
デフォルトで色々なトークナイザがあり、NGram Tokenizerはデフォルトで入っています。
Tokenizers | Elasticsearch Reference [5.4] | Elastic

インストール・起動

Elasticsearch5.4.0をインストールします。
NGram Tokenizerはデフォルトで入っているのでインストール不要です。

インストール方法は下記と同様。
Elasticsearch5とKibana5をインストールしてCRUDを試す - abcdefg.....


ホストOSからゲストOS上のElasticsearchにアクセスできるように、
elasticsearch.ymlにゲストOSのIPを設定します。

$ vi /config/elasticsearch.yml
network.host : ["10.0.2.15", _local_]

起動。

$ ./elasticsearch

アナライザ・トークナイザ設定

アナライザとトークナイザを設定するためのjsonファイルを用意します。

analyzer
analyzerの項目にはmy_analyzerという名前を設定し、
typeにcustomを設定し、tokenizerにはmy_tokenizerを設定します。

tokenizer
tokenizerの項目にはmy_tokenizerという名前を設定し、
typeにngramを指定します。

min_gramとmax_gramは文字を分割する最小と最大の単位です。
どうやら日本語は2gramか3gramらしいので、それぞれ2と3に設定します。

token_charsにはトークンとして含める対象を配列で指定します。
指定できるのは下記のキャラクタークラスです。
今回はletterとdigitを指定します。

キャラクタークラス
letter a, b, ï or 京
digit 3, 7
whitespace " ", "\n"
punctuation  !, "
symbol $, √

analysis.jsonというファイル名で保存。

analysis.json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "type": "custom",
          "tokenizer": "my_tokenizer"
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": [
            "letter",
            "digit"
          ]
        }
      }
    }
  }
}

analysis.jsonを指定してmy_indexというインデックスを作成。

curl -X PUT 'localhost:9200/my_index' -d @analysis.json

インデックスを確認してみると、analyzerとtokenizerの項目に
指定したアナライザとトークナイザが設定されていることが分かります。

curl -X GET 'localhost:9200/my_index/?pretty'

{
  "my_index" : {
    "aliases" : { },
    "mappings" : { },
    "settings" : {
      "index" : {
        "number_of_shards" : "5",
        "provided_name" : "my_index",
        "creation_date" : "1495360973104",
        "analysis" : {
          "analyzer" : {
            "my_analyzer" : {
              "type" : "custom",
              "tokenizer" : "my_tokenizer"
            }
          },
          "tokenizer" : {
            "my_tokenizer" : {
              "token_chars" : [
                "letter",
                "digit"
              ],
              "min_gram" : "2",
              "type" : "ngram",
              "max_gram" : "3"
            }
          }
        },
        "number_of_replicas" : "1",
        "uuid" : "HjcLX2GrSo2uteIkNeGCOQ",
        "version" : {
          "created" : "5040099"
        }
      }
    }
  }
}

デフォルトのアナライザを設定したい場合は、下記の様に
setting.analysis.analyzer.defaultにアナライザを設定します。

default_analysis.json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "default": {
          "tokenizer": "my_tokenizer"
                }
            },
      "tokenizer": {
        "my_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": [
            "letter",
            "digit"
          ]
        }
      }
    }
  }
}

確認

アナライザを確認するには/_analyzeでanalyze APIのエンドポイントを指定します。
analyzerでmy_analyzerを指定し、textにアナライズ対象の文章を指定します。

実行してみると、2文字と3文字の単語に分割されていることが分かります。

curl -X POST 'localhost:9200/my_index/_analyze?pretty' -d '{"analyzer": "my_analyzer", "text": "吾輩は猫である。"}'
{
  "tokens" : [
    {
      "token" : "吾輩",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "吾輩は",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "輩は",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "輩は猫",
      "start_offset" : 1,
      "end_offset" : 4,
      "type" : "word",
      "position" : 3
    },
    {
      "token" : "は猫",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "word",
      "position" : 4
    },
    {
      "token" : "は猫で",
      "start_offset" : 2,
      "end_offset" : 5,
      "type" : "word",
      "position" : 5
    },
    {
      "token" : "猫で",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 6
    },
    {
      "token" : "猫であ",
      "start_offset" : 3,
      "end_offset" : 6,
      "type" : "word",
      "position" : 7
    },
    {
      "token" : "であ",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "word",
      "position" : 8
    },
    {
      "token" : "である",
      "start_offset" : 4,
      "end_offset" : 7,
      "type" : "word",
      "position" : 9
    },
    {
      "token" : "ある",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "word",
      "position" : 10
    }
  ]
}

mapping定義

マッピングでフィールドごとにアナライザを設定できます。
nameとaddressというフィールドを持つfriendsタイプを定義します。
nameとaddressフィールドにはそれぞれanalyzerにmy_analyzerを指定します。

mapping.json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "my_analyzer": {
          "type": "custom",
          "tokenizer": "my_tokenizer"
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "ngram",
          "min_gram": 2,
          "max_gram": 3,
          "token_chars": [
            "letter",
            "digit"
          ]
        }
      }
    }
  },
  "mappings": {
    "friends": {
      "properties": {
        "name": {
          "type": "string",
          "index": "analyzed",
          "analyzer": "my_analyzer"
        },
        "address": {
          "type": "string",
          "index": "analyzed",
          "analyzer": "my_analyzer"
        }
      }
    }
  }
}

先ほど作成したインデックスを削除

curl -X DELETE 'localhost:9200/my_index'

mapping.jsonを指定してmy_indexを再作成。

curl -X PUT 'localhost:9200/my_index/' -d @mapping.json

インデックスを確認してみるとnameとaddressフィールドにmy_analyzerが設定されてるのが分かります。

curl -X GET 'localhost:9200/my_index/?pretty'
{
  "my_index" : {
    "aliases" : { },
    "mappings" : {
      "friends" : {
        "properties" : {
          "address" : {
            "type" : "text",
            "analyzer" : "my_analyzer"
          },
          "name" : {
            "type" : "text",
            "analyzer" : "my_analyzer"
          }
        }
      }
    },
    "settings" : {
      "index" : {
        "number_of_shards" : "5",
        "provided_name" : "my_index",
        "creation_date" : "1495365420134",
        "analysis" : {
          "analyzer" : {
            "my_analyzer" : {
              "type" : "custom",
              "tokenizer" : "my_tokenizer"
            }
          },
          "tokenizer" : {
            "my_tokenizer" : {
              "token_chars" : [
                "letter",
                "digit"
              ],
              "min_gram" : "2",
              "type" : "ngram",
              "max_gram" : "3"
            }
          }
        },
        "number_of_replicas" : "1",
        "uuid" : "wcSsZQ0PRey5ayaiAcWZ3A",
        "version" : {
          "created" : "5040099"
        }
      }
    }
  }
}

確認

確認用のドキュメントを登録。
"name": "山本太郎"
"address": "東京都港区赤坂1-12-32"
で登録します。

curl -X PUT 'localhost:9200/my_index/friends/1?pretty' -d '{"name": "山本太郎", "address": "東京都港区赤坂1-12-32"}'

simple_query_stringでaddressフィールドを指定して検索してみます。

"東京"で検索。

curl -X GET 'localhost:9200/my_index/_search?pretty' -d '
{
  "query": {
    "simple_query_string": {
      "fields": ["address"],
      "query": "東京"
    }
  }
}
'

2gramで分割しているので当然ヒットします。

{
  "took" : 6,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.28004453,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "friends",
        "_id" : "1",
        "_score" : 0.28004453,
        "_source" : {
          "name" : "山本太郎",
          "address" : "東京都港区赤坂1-12-32"
        }
      }
    ]
  }
}

"京都"で検索。

curl -X GET 'localhost:9200/my_index/_search?pretty' -d '
{
  "query": {
    "simple_query_string": {
      "fields": ["address"],
      "query": "京都"
    }
  }
}
'

こちらもヒットしました。
min_gramで2gramで分割しているので、"東京都"の"京都"がヒットしてしまいます。

{
  "took" : 3,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.28004453,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "friends",
        "_id" : "1",
        "_score" : 0.28004453,
        "_source" : {
          "name" : "山本太郎",
          "address" : "東京都港区赤坂1-12-32"
        }
      }
    ]
  }
}

"京"で検索。

curl -X GET 'localhost:9200/my_index/_search?pretty' -d '
{
  "query": {
    "simple_query_string": {
      "fields": ["address"],
      "query": ""
    }
  }
}
'

min_gramで最小を2gramで分割しているので、1文字の"京"はヒットしません。

{
  "took" : 14,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 0,
    "max_score" : null,
    "hits" : [ ]
  }
}

"東京都"で検索。

curl -X GET 'localhost:9200/my_index/_search?pretty' -d '
{
  "query": {
    "simple_query_string": {
      "fields": ["address"],
      "query": "東京都"
    }
  }
}
'

max_gramで3gramで分割しているので、3文字の"東京都"もヒットします。

{
  "took" : 12,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.84013355,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "friends",
        "_id" : "1",
        "_score" : 0.84013355,
        "_source" : {
          "name" : "山本太郎",
          "address" : "東京都港区赤坂1-12-32"
        }
      }
    ]
  }
}

終わり。