ElasticSearch で文書を検索をしてみる
ElasticSearch
で提供されている代表的な文書の検索機能には、全文検索クエリと Term ベースクエリがあります。全文検索クエリは、検索時のキーワードが含まれているるドキュメントを探し出すための検索クエリです。一方 Term ベースクエリは、検索時のキーワードが完全に一致するドキュメントを探し出すための検索クエリになります。よくある検索エンジンのようにキーワードが含まれている WEB ページを探すというような用途では全文検索クエリが適していますが、ユニークなキー値のようなものをキーワードにして、そのキー値が割り当てられている文書を探すような用途では Term ベースクエリが適しています。全文検索クエリではあらかじめ文書が単語に分割されて、その単語に対して検索が行われますが、Term ベースクエリでは文書が単語に分割されず、キーワードと完全一致するかで検索されます。ユニークなキー値で検索を行う場合、そのキーワードの一部分ではなくすべてが完全に一致するドキュメントを検索しなければならないので、Term ベースクエリの方が適していることになります。
今日はこの全文検索クエリと Term ベースクエリについて、実際にインデックスを作りながら試していきたいと思います。
検証データを用意する
前提として、今回は以下のインデックスを使うものとします。$ curl -XPUT http://localhost:9200/samurai -H "Content-type: application/json" -d '{ "settings": { "number_of_replicas": 0, "number_of_shards": 1, "analysis": {}, "refresh_interval": "1s" }, "mappings": { "_doc": { "properties": { "name": { "type": "keyword" }, "description": { "type": "text" } } } } }'このマッピングでは、
name
フィールドは keyword
型、description
フィールドは text
型としています。また、このインデックスには以下のドキュメントを入れておきます。
$ curl -XPOST http://localhost:9200/_bulk -H "Content-type: application/json" -d ' { "index": { "_index": "samurai", "_type": "_doc" } } { "name": "真田昌幸", "description": "甲斐国の武田信玄の家臣となり信濃先方衆となった地方領主真田氏の出自で、真田信之、真田幸村の父。" } { "index": { "_index": "samurai", "_type": "_doc" } } { "name": "真田信之", "description": "真田昌幸の長男。徳川家康の養女を妻としていたため関ヶ原の戦いでは東軍につき、江戸時代には松代藩藩主をつとめた。" } { "index": { "_index": "samurai", "_type": "_doc" } } { "name": "真田幸村", "description": "真田昌幸の次男。豊臣家臣の大谷吉継の娘を妻としていたため関ヶ原の戦いでは西軍につき、父真田昌幸とともに戦死した。" } { "index": { "_index": "samurai", "_type": "_doc" } } { "name": "石田三成", "description": "豊臣政権の五奉行の一人。秀吉の死後、徳川家康を倒すため決起するが、関ヶ原の戦いで敗れ、その後処刑された。" } { "index": { "_index": "samurai", "_type": "_doc" } } { "name": "徳川家康", "description": "豊臣秀吉の天下統一後に台頭した武将で、関ヶ原の戦いに勝利し、江戸幕府を開いた。" } '
Term ベースクエリで検索してみる
最初は Term ベースクエリを試してみます。Term ベースクエリは、端的に言えば SQL でWHERE MY_COLUMN = X
と書くようなものです。ドキュメント中の検索したいフィールド値に対して完全一致でキーワードを評価します。また Term ベースクエリを利用する場合、検索対象とするフィールドのデータ型が
keyword
型でなければならないことに注意してください。今回のインデックスでは name
フィールドを keyword
型として定義しているので、name
フィールドに対して検索をかけていきます。Term ベースクエリにはさらにいくつかの種類があるので、それらを見ていきたいと思います。
term クエリ
term
クエリは代表的な Term ベースクエリで、検索キーワードが keyword
型フィールドの値に完全一致するドキュメントを検索します。term
クエリは、/{インデックス名}/_search
エンドポイントに以下のような JSON を POST することで利用できます。term
要素内には、検索対象となる keyword
型フィールドの名前と検索キーワードを記載します。$ curl -XPOST http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "term" : { "name" : "真田昌幸" } } }'この
term
クエリを実行すると、以下のドキュメントが検索されます。{ "hits": { "hits": [ { "_index": "samurai", "_type": "_doc", "_id": "8jzqXGYBEhcdh6j6Glmg", "_score": 1.3862944, "_source": { "name": "真田昌幸", "description": "甲斐国の武田信玄の家臣となり信濃先方衆となった地方領主真田氏の出自で、真田信之、真田幸村の父。" } } ] } }
keyword
型である name
フィールドにおいて、検索キーワード『真田昌幸』について term
クエリによる完全一致検索が行われるため、name
フィールド値が『真田昌幸』となっているドキュメントのみが返されます。まさに SQL で WHERE name = '真田昌幸'
と書いたものと同じです。逆に言うと、
term
クエリはあくまで完全一致での検索となるため、例えば検索キーワードを『真田』として検索しても、name
フィールドの値が『真田昌幸』『真田信之』『真田幸村』となっているドキュメントは結果セットに含まれません。$ curl -XPOST http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "term" : { "name" : "真田" } } }'この
term
クエリによって検索されるドキュメントはありません。ちなみに、
term
クエリで keyword
型ではないフィールドに対して検索をしても、同じく検索結果は空になります。$ curl -XPOST http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "term" : { "description" : "真田昌幸" } } }'このクエリは
text
型である description
フィールドに対して検索をしていますが、Term ベースクエリは keyword
型フィールドの検索を行うクエリなので、検索されるドキュメントはありません。terms クエリ
terms
クエリは Term ベースクエリの 1 つで、任意の数の検索キーワードを指定して、そのいずれかが検索対象の keyword
型フィールド値に完全一致するドキュメントを検索します。term
クエリとは異なり検索キーワードを複数個指定できるのが特徴です。SQL では WHERE name IN (X, Y)
と書いた条件文に相当します。$ curl -XPOST http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "terms" : { "name" : [ "真田昌幸", "真田幸村" ] } } }'この
terms
クエリでは検索キーワードに『真田昌幸』『真田幸村』を指定しているため、name
フィールドの値が『真田昌幸』か『真田幸村』であるドキュメントが検索結果として返されます。{ "hits": { "hits": [ { "_index": "samurai", "_type": "_doc", "_id": "8jzqXGYBEhcdh6j6Glmg", "_score": 1, "_source": { "name": "真田昌幸", "description": "甲斐国の武田信玄の家臣となり信濃先方衆となった地方領主真田氏の出自で、真田信之、真田幸村の父。" } }, { "_index": "samurai", "_type": "_doc", "_id": "9DzqXGYBEhcdh6j6Glmg", "_score": 1, "_source": { "name": "真田幸村", "description": "真田昌幸の次男。豊臣家臣の大谷吉継の娘を妻としていたため関ヶ原の戦いでは西軍につき、父真田昌幸とともに戦死した。" } } ] } }
range クエリ
range
クエリは Term ベースクエリの 1 つで、検索キーワードには不等式による値の範囲を指定します。検索対象となる keyword
型フィールド値 がこの範囲に含まれる(不等式の条件に一致する)場合に、そのドキュメントは検索結果に含まれます。range
クエリの条件として指定できるのは、〜より大きい gt
、〜より小さい lt
、〜以上である gte
、〜以下である lte
の 4 つです。検索対象のフィールドは
keyword
型でなければならないため、不等式での評価というのには違和感があるかもしれません。しかしこれは多くのプログラミング言語において文字列が不等式で評価される場合と同じように、文字列がそのまま数値として評価されます。文字 A
が 0x41
として評価されるのと同じです。range
クエリも Term ベースクエリの一種なので、不等式に指定する値も検索されるフィールド値も、文字列全体が数値に変換されて評価されます。range
クエリは一般的には 2018/10/10 12:00:00
というような日付書式に対して使われることが多いようですが、今回は用意しているデータが人名なので、人名を human readable なキー値に見立てて検索してみます。$ curl -XPOST http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "range" : { "name" : { "lte": "真田昌幸", "gte": "真田信之" } } } }'この
range
クエリによって検索されるドキュメントは以下の通りです。{ "hits": { "hits": [ { "_index": "samurai", "_type": "_doc", "_id": "8jzqXGYBEhcdh6j6Glmg", "_score": 1, "_source": { "name": "真田昌幸", "description": "甲斐国の武田信玄の家臣となり信濃先方衆となった地方領主真田氏の出自で、真田信之、真田幸村の父。" } }, { "_index": "samurai", "_type": "_doc", "_id": "8zzqXGYBEhcdh6j6Glmg", "_score": 1, "_source": { "name": "真田信之", "description": "真田昌幸の長男。徳川家康の養女を妻としていたため関ヶ原の戦いでは東軍につき、江戸時代には松代藩藩主をつとめた。" } }, { "_index": "samurai", "_type": "_doc", "_id": "9DzqXGYBEhcdh6j6Glmg", "_score": 1, "_source": { "name": "真田幸村", "description": "真田昌幸の次男。豊臣家臣の大谷吉継の娘を妻としていたため関ヶ原の戦いでは西軍につき、父真田昌幸とともに戦死した。" } } ] } }『昌』が 0x660c、『幸』が 0x5e78、『信』が 0x4fe1 なので、『真田幸村』もこの不等式には当てはまります。また、条件式が
lte
gte
であるため、検索結果には境界値も含まれています。全文検索クエリで検索してみる
ElasticSearch
の目玉となるクエリが全文検索クエリです。全文検索クエリでは、各ドキュメントのフィールド値である文字列をあらかじめ単語に分解しておき、その単語に対して検索キーワードによる検索を行います。Term ベースクエリでは検索対象となるフィールド値に対して完全一致で評価されますが、全文検索クエリではあらかじめ分割された単語に対して検索キーワードが評価されるため、感覚としては部分一致に近くなります。全文検索において文書を単語に分解しておくことは分かち書きと呼ばれます。英文では半角スペースや句読点で分割すれば分かち書きが可能ですが、日本語の文章ではどこまでを 1 単語とするかを機械的に判断しづらく、分かち書きの難易度が上がっています。
多くの全文検索エンジンでは、全文検索の対象となる文書について、分かち書きした各単語の出現位置を物理的なインデックスファイルとして作成します。これは転値インデックスと呼ばれています。一般的な RDB のインデックスではカラムの値全体からレコードに対してインデックスが張られますが、転値インデックスは分かち書きされた単語からそれが含まれるドキュメントに対して物理的なインデックスが張られます。
なお、全文検索クエリを利用する場合、検索対象とするフィールドのデータ型が
text
型でなければならないことに注意してください。今回のインデックスでは description
フィールドを text
型として定義しているので、description
フィールドに対して検索をかけていきます。Analyze API で分かち書きを確認する
全文検索クエリで重要となるのは実際に文書がどのように分かち書きされているかですが、これは Analyze API を使うと確認することができます。Analyze API は
/_analyze
エンドポイントに対して以下のような JSON を送ることで利用できます。JSON 中で text
値として記載しているのが分かち書きしたい文字列になります。$ curl -XGET 'http://localhost:9200/_analyze' -H "Content-type: application/json" -d ' { "analyzer" : "standard", "text" : "真田昌幸" }'これにより以下のようなレスポンスが返されます。
{ "tokens": [ { "token": "真", "start_offset": 0, "end_offset": 1, "type": "1 文字毎に分かち書きされているため、『真田昌幸』が氏名であることを知っている日本人からすると違和感がありますが、", "position": 0 }, { "token": "田", "start_offset": 1, "end_offset": 2, "type": " ", "position": 1 }, { "token": "昌", "start_offset": 2, "end_offset": 3, "type": " ", "position": 2 }, { "token": "幸", "start_offset": 3, "end_offset": 4, "type": " ", "position": 3 } ] }
ElasticSearch
標準の分かち書き機能ではこのような結果になります。<IDEOGRAPHIC>
と表記されていることから、表意文字である漢字は一律で 1 文字単位に分かち書きされるということなのかもしれません。同じように文字列『こんにちは』を Analyze API にかけたところ、やはりすべて 1 文字毎に分かち書きされ、type
値は <HIRAGANA>
となっていました。また、分かち書きされた単語が出現する位置として、いっしょに
position
値が返されていることが特徴的です。match クエリ
match
クエリは代表的な全文検索クエリで、text
型フィールドの値を分かち書きしたものに検索キーワードが含まれているドキュメントを検索します。エンドポイントは他の検索クエリと同じく
/{インデックス名}/_search
で、以下のように match
要素内に検索キーワードと検索対象の text
型フィールドの名前を書いた JSON を送ります。$ curl -XGET http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "match" : { "description" : "真田" } } }'この
match
クエリでは、検索キーワードも検索対象となる text
フィールド値もすべて分かち書きされた上で評価されます。前述の通りひらがなや漢字はすべて 1 文字単位で分かち書きされるため、検索キーワード『真田』に含まれるいずれかの文字を description
値に含むドキュメントが検索結果として返されます。{ "hits": { "hits": [ { "_index": "samurai", "_type": "_doc", "_id": "9DzqXGYBEhcdh6j6Glmg", "_score": 3.3232775, "_source": { "name": "真田幸村", "description": "真田昌幸の次男。豊臣家臣の大谷吉継の娘を妻としていたため関ヶ原の戦いでは西軍につき、父真田昌幸とともに戦死した。" } }, { "_index": "samurai", "_type": "_doc", "_id": "8zzqXGYBEhcdh6j6Glmg", "_score": 2.3840902, "_source": { "name": "真田信之", "description": "真田昌幸の長男。徳川家康の養女を妻としていたため関ヶ原の戦いでは東軍につき、江戸時代には松代藩藩主をつとめた。" } }, { "_index": "samurai", "_type": "_doc", "_id": "8jzqXGYBEhcdh6j6Glmg", "_score": 2.3322062, "_source": { "name": "真田昌幸", "description": "甲斐国の武田信玄の家臣となり信濃先方衆となった地方領主真田氏の出自で、真田信之、真田幸村の父。" } } ] } }
match
クエリでは、検索キーワード内の単語のいずれかが含まれるドキュメントが検索結果として返されます。つまり複数の単語による OR 条件によって検索されているということです。ここで、複数の単語による OR 条件で検索するか、AND 条件で検索するかは明示的に指定することもできます。$ curl -XGET http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "match" : { "description" : { "query" : "昌幸", "operator" : "and" } } } }'送信する JSON オブジェクト内で、
text
型フィールド名の要素内の operator
値として and
を指定すると AND 検索に、or
を指定すると or
検索になります。{ "hits": { "hits": [ { "_index": "samurai", "_type": "_doc", "_id": "9DzqXGYBEhcdh6j6Glmg", "_score": 1.8859537, "_source": { "name": "真田幸村", "description": "真田昌幸の次男。豊臣家臣の大谷吉継の娘を妻としていたため関ヶ原の戦いでは西軍につき、父真田昌幸とともに戦死した。" } }, { "_index": "samurai", "_type": "_doc", "_id": "8zzqXGYBEhcdh6j6Glmg", "_score": 1.3529665, "_source": { "name": "真田信之", "description": "真田昌幸の長男。徳川家康の養女を妻としていたため関ヶ原の戦いでは東軍につき、江戸時代には松代藩藩主をつとめた。" } } ] } }この例では
operator
値に and
を指定しているため、description
フィールドに『昌』と『幸』のすべてを含むドキュメントが検索結果に含まれることになります。match_phrase クエリ
match_pharse
クエリは全文検索クエリの 1 つで、text
型フィールドの値を分かち書きしたものの中に、検索キーワードを分かちした単語がその順番通りに含まれているドキュメントを検索します。$ curl -XGET http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "match_phrase" : { "description" : "豊臣秀吉" } } }'この
match_pharse
クエリで返される検索結果は以下のとおりです。{ "hits": { "hits": [ { "_index": "samurai", "_type": "_doc", "_id": "-zyIXWYBEhcdh6j64Fly", "_score": 2.4673853, "_source": { "name": "徳川家康", "description": "豊臣秀吉の天下統一後に台頭した武将で、関ヶ原の戦いに勝利し、江戸幕府を開いた。" } } ] } }
name
フィールド値が『石田三成』のドキュメントは description
フィールド値が『豊臣政権の五奉行の一人。秀吉の死後...』となっており、検索キーワード中の分かち書きされた単語すべてが出現しますが、単語間に他の文字があるため、単語の出現順序が違っています。このため、AND 条件での match
クエリでは検索できる文書も、この match_phrase
クエリでは検索結果には含まれません。match_all クエリ
match_all
クエリは、すべてのドキュメントを返す全文検索クエリです。一般的には、インデックスに入っているすべてのドキュメントをそのまま確認したいという場合に使われるようです。$ curl -XGET http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "match_all" : {} } }'ちなみになんらかの JSON オブジェクトを HTTP リクエストボディで送信することなく、単純に
/{インデックス名}/_search
にアクセスした際にもこの match_all
クエリが実行されます。match_none クエリ
match_none
クエリは、空の検索結果を返す(いずれのドキュメントも返さない)全文検索クエリです。こちらも match_all
クエリと同じく検証用という位置づけが強いようです。$ curl -XGET http://localhost:9200/samurai/_search -H "Content-type: application/json" -d '{ "query": { "match_none" : {} } }'
ElasticSearch コメント (1) 2018/10/10 20:00:43