Elasticsearch 日本語でフレーズ検索が必要なわけ

日本語で検索した際に検索結果が異様に膨らむ理由と、正確にマッチさせたい場合の方法

Kunihiko Kido
Hello! Elasticsearch.

--

Elasticsearch クエリーのデフォルトのオペレーターは OR です。これ自体は問題ないのですが、このORが適用されるタイミングがとても違和感 … 。

どうやら、リクエストする単語が1単語でもアナライザーで解析され、分割された最小単語単位でORで検索されるみたい!

どういう事かと言うと、キーワード:「東京都 で検索した場合、アナライズ後、「東京」 と 「都」 の2単語にトークナイズされ、検索は 「東京 OR 都」で検索される動きをしているようです。

もちろんこの場合、「東京都」を含んでいるドキュメントもマッチしますが、「東京」だけ、「都」だけ、または、「東京 … 都」「都 … 東京」などの単語が離れているドキュメントにもマッチする可能性があります。

ネットを調べてもあまり見当たらず、みんなどうしているんだろう?当たり前の仕様なのか?この動き違和感ありありです。

デフォルトの仕様を確認

今回は、都道府県名を使って動作の確認をします。

データの準備:

次のコマンドで、pref_name と言う一つのフィールドをもつ47都道府県分のデータを作成します。動作の確認にはデフォルトのアナライザーで十分なので、特にカスタマイズしていません。

curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "北海道" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "青森県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "岩手県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "宮城県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "秋田県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "山形県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "福島県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "茨城県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "栃木県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "群馬県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "埼玉県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "千葉県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "東京都" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "神奈川県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "新潟県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "富山県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "石川県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "福井県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "山梨県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "長野県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "岐阜県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "静岡県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "愛知県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "三重県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "滋賀県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "京都府" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "大阪府" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "兵庫県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "奈良県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "和歌山県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "鳥取県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "島根県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "岡山県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "広島県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "山口県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "徳島県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "香川県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "愛媛県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "高知県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "福岡県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "佐賀県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "長崎県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "熊本県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "大分県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "宮崎県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "鹿児島県" }'
curl -XPOST 'localhost:9200/test/type/' -d '{ "pref_name" : "沖縄県" }'

アナライザーの解析結果確認:

まずは、_analyze API を使用して、日本語がどのように解析されるか確認してみます。

$ curl -s 'localhost:9200/test/_analyze?field=type.pref_name&pretty' -d '群馬県'|jq ".tokens[].token"
"群"
"馬"
"県"
$ curl -s 'localhost:9200/test/_analyze?field=type.pref_name&pretty' -d '東京都'|jq ".tokens[].token"
"東"
"京"
"都"

デフォルトのアナライザーを使用しているので、奇麗に1文字単位に分かれているのが確認できます。

「東京都」で検索リクエスト:

まずはベーシックに「東京都」で検索してみます。

curl -s 'localhost:9200/test/type/_search?pretty' -d '
{
"query":{
"query_string":{
"default_field" : "pref_name",
"query":"東京都"
}
}
}' | jq ".hits.hits[]|{score: ._score, pref_name: ._source.pref_name}"

「東京都」の検索結果:

2件かえってきました。一応スコアは、「東京都」の方が高くなっていますが、「京都府」もヒットしています。おそらく、「東京都」が1文字毎に分割されOR検索していると考えられます。「東 OR 京 OR 都」

{
"pref_name": "東京都",
"score": 2.551233
}
{
"pref_name": "京都府",
"score": 0.7797127
}

「都京東」で検索リクエスト:

「東 OR 京 OR 都」で検索されているなら、「東京都」の文字を入れ替えても同じ結果になるはずです。

curl -s 'localhost:9200/test/type/_search?pretty' -d '
{
"query":{
"query_string":{
"default_field" : "pref_name",
"query":"都京東"
}
}
}' | jq ".hits.hits[]|{score: ._score, pref_name: ._source.pref_name}"

「都京東」の検索結果:

先ほどの「東京都」で検索した結果とマッチしたドキュメント、スコアも全く同じです。

{
"pref_name": "東京都",
"score": 2.551233
}
{
"pref_name": "京都府",
"score": 0.7797127
}

「東 OR 京 OR 都」で検索リクエスト:

しつこいですが、いっそのこと「東 OR 京 OR 都」で検索してみます。これも今までの結果と全く同じになるはずです。

curl -s 'localhost:9200/test/type/_search?pretty' -d '
{
"query":{
"query_string":{
"default_field" : "pref_name",
"query":"東 OR 京 OR 都"
}
}
}' | jq ".hits.hits[]|{score: ._score, pref_name: ._source.pref_name}"

「東 OR 京 OR 都」の検索結果:

やはり、今までと全く同じ結果でした。

{
"pref_name": "東京都",
"score": 2.551233
}
{
"pref_name": "京都府",
"score": 0.7797127
}

以上のことから、

やっぱり、リクエストする単語が1単語でもアナライザーで解析され、分割された最小単語単位でORで検索されていますよ!

検索結果の詳細を確認する方法(explain)

いくつかのパターンを検証して、検索にマッチした内容を確認しましたが、そんなことしなくても、explain オプション を使って、キーワードのマッチした場所、スコアリングなどの詳細を簡単に確認することが出来ます。

詳細を確認したいクエリに explain オプションを true に設定するだけです。

$ curl 'localhost:9200/test/type/_search?pretty' -d '
{
"explain": true,
"query":{
"query_string":{
"default_field" : "pref_name",
"query":"東京都"
}
}
}'

全結果の内容は長くなってしまうので省略しますが、次の結果のように1文字毎にマッチしているのが確認できます。

{
...
"description" : "weight(pref_name: in 6) [PerFieldSimilarity], result of:",
...
"description" : "weight(pref_name: in 6) [PerFieldSimilarity], result of:",
...
"description" : "weight(pref_name: in 6) [PerFieldSimilarity], result of:",
...
}

参考:
Search Request ExplainExplain API

正確にマッチさせたい場合はフレーズ検索

上記の例はデフォルトのアナライザーを使用しているので、1文字単位と極端な例ですが、kuromoji などの形態素解析やNグラムを使用する場合でも、文字列の分割単位は違えど同じような動きをしますので、正確にマッチさせるにはフレーズで検索する方法があります。

注意:形態素解析を使用している場合、正確にマッチと行っても、入力されるキーワードと、検索ドキュメント内の単語の出現場所によっては、単語の分割のされ方が異なればマッチしないこともありますのでご注意を。

フレーズで簡単に検索するには、次の例のようにダブルクォーテーションで単語を囲みます。

curl -s 'localhost:9200/test/type/_search?pretty' -d '
{
"explain": true,
"query":{
"query_string":{
"default_field" : "pref_name",
"query":"\"東京都\""
}
}
}'

NOTE:JSON形式なので、ダブルクォーテーションは、バックスラッシュでエスケープします。

explain 有効にしていれば、フレーズでマッチしていることが確認できます。もちろんこのときの検索結果は、「東京都」の1件のみです。

{
...
"description" : "weight(pref_name:\"東 京 都\" in 6) [PerFieldSimilarity], result of:",
...
}

また、フレーズ検索になっているので文字を入れ替えると、期待通り検索結果は0件になります。

複数のキーワードでもちゃんと機能します。次の例は「東京都 または 群馬県」で検索する例です。「東京都」と「群馬県」の2件のみヒットします。

curl -s 'localhost:9200/test/type/_search?pretty' -d '
{
"explain": true,
"query":{
"query_string":{
"default_field" : "pref_name",
"query":"\"東京都\" \"群馬県\""
}
}
}'

ちなみにこのクエリ、フレーズで検索しないと45件ヒットします。上位2件は「東京都」と「群馬県」になりますが。。

まとめ

検索エンジンは検索結果の上位のドキュメントが重要なので、これらの仕様が悪いわけではないですが、知らないとハマりそうです … 。

フレーズで検索しない方が便利なケースもありますし。フレーズで検索するのか、デフォルトの仕様で検索するのかは要件次第ですね。

Search Request Explain や Explain API も用意されているので、検索結果がおかしいなと思ったら、これらを使用して都度確認ですね。やっぱり検索エンジンと言うか日本語って難しいです。

--

--