RSS2.0

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 なので、『真田幸村』もこの不等式には当てはまります。また、条件式が ltegte であるため、検索結果には境界値も含まれています。

全文検索クエリで検索してみる

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": "",
      "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
    }
  ]
}
1 文字毎に分かち書きされているため、『真田昌幸』が氏名であることを知っている日本人からすると違和感がありますが、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


公開範囲:
2019/11/15 00:24:48   ゲストさん 公開範囲: すべて, 承認済み
とても見やすい記事で勉強になります。
あまりこの手の内容でわかりやすい記事が見つかりづらく。。
プロフィール HN: ももかん
ゲーム作ったり雑談書いたり・・・していた時期が私にもありました。
カレンダー
<<2024, 12>>
1234567
891011121314
15161718192021
22232425262728
2930311234