【基礎編】Elasticsearchの検索クエリを使いこなそう

Elasticsearch-Logo-Color-V

こんにちは! Pairsの検索アルゴリズムの新規開発・改修を担当している小島です。
 

この記事は eureka Advent Calendar 6日目の記事です。

5日目は癒し系エンジニア鉄本さんの 「社内ツールを駆使してExcelへのレポートを自動化した話」でした。

今年に入ってからElasticsearchについてしかブログを書いていません。
もちろん今回もElasticsearchについて書きます。
 

前回はElastic Stack 5.0 のセットアップについて書きましたが、今回は初心に戻り、検索クエリの使い方についてお話します。
 

【応用編】Elasticsearchの検索クエリを使いこなそう

環境

Elasticsearch version 5.0
ローカル環境のポート9200番で実行

準備

まずは検索を試すためのデータを用意します。
僕は以下のelastic公式が出しているレポジトリ内にあるレシピのサンプルデータを使用します。

サンプルデータ
 

データの作成にはいくつかの方法があります。今回は詳細を省略しますが、例として以下のようなリクエストでElasticsearchにデータをポストできます。インデックス名、タイプ名はデータにあった適当なものを設定しましょう。
 

僕は以下のように名前をつけます。
インデックス名:sample_20161206
タイプ名:recipes

(例1
curl -XPOST "http://localhost:9200/sample_20161206/recipes --data-binary @./path/to/sample_data.json

マッピングの確認

以下のクエリで作成したデータの構成を確認しておきます。

curl -XGET "http://localhost:9200/<index名>/<type名>/_mapping"

サンプルデータをいれた場合は以下のようなデータ構成になっています。

{
"sample_20161206": {
"mappings": {
"recipes": {
"properties": {
"author": {
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"url": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
},
"cook_time_min": {
"type": "long"
},
"description": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"directions": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"ingredients": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"prep_time_min": {
"type": "long"
},
"servings": {
"type": "long"
},
"source_url": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"tags": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"title": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
}
}

通常検索

検索を行うには以下のようなリクエストを使用します。

curl -XGET "http://localhost:9200/sample_20161206/recipes/_search"

複数インデックス、複数タイプにまたがって検索する場合は以下のようなリクエストで検索が可能です。

# 複数インデックスにまたがって検索する場合
curl -XGET "http://localhost:9200/_search
# 同じインデックス内の複数タイプにまたがって検索する場合
curl -XGET "http://localhost:9200/<インデックス名>/_search"

一致(数値)

レシピデータの中には調理時間(分): cook_time_min の数値型フィールドが定義されています。以下のクエリで調理時間を条件に検索ができます。

jsonでtermを指定することで 一致したデータを検索できます。

# (例2 調理時間が10分のレシピを検索
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"term": {
"cook_time_min": 10
}
}
}

複数一致(数値)

termsを指定することで 複数条件のどれかに一致したデータを検索できます。

# (例3 調理時間が10分,15分,20分のどれかのレシピを検索
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"terms": {
"cook_time_min": [10, 15, 20]
}
}
}

範囲(数値)

数値で範囲検索をする場合の例です。

# (例4 調理時間が10分未満のレシピを検索
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"range": {
"cook_time_min": {"lt":10}
}
}
}'

上限、下限両方を指定することもできます。

# (例5 調理時間が10分以上30分未満のレシピを検索
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"range": {
"cook_time_min": {
"gte":10,
"lt":30
}
}
}
}'
lt : less than
[ field < value ]
lte : less than equal
[ field <= value ]
gt : greater than
[ field > value ]
gte : greater than equal
[ field >= value ]

文字列検索

文字列も数値同様 term, termsでの検索ができます。

# (例6 タイトルに「bean」の含まれるレシピを検索
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"term": {
"title": "bean"
}
}
}'

データ作成後文字列は以下のようにマッピングされていました。

"title": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}

Elasticsearch version 5.0以前では、文字列を完全一致と部分一致で分けるために手動でmappingする設定が必要でした。しかし、5.0から文字列にはKeyword と Textの2種類の型ができ、自動マッピングでそのどちらも作成されるので、デフォルトで完全一致、部分一致の両方が可能になりました。

複数条件で検索

これまでのクエリでは一つの条件しかつけられませんでしたが、boolクエリを使うことで複数の条件を設定することが可能です。

# (例7 タイトルにbeanが含まれていて、調理時間が30分未満のレシピを検索
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"bool": {
"filter": [
{
"term": {
"title": "bean"
}
},
{
"range": {
"cook_time_min": {
"lt": 30
}
}
}
]
}
}
}'

スコアリング

Elasticsearchには検索条件との一致度合いをスコアとして計算し、そのスコアの高い順番に並び替えてくれる機能があります。 

例2で使った「調理時間が10分のレシピを検索」の場合、検索結果には以下のようにドキュメント毎に _score 要素があり、スコアが収められています。

"_index": "sample_20161206",
"_type": "recipes",
"_id": "AVi4vFfvn4YDYE1ItT1u",
"_score": 1,
"_source": {
・・・

_scoreがそのデータにつけられたスコアです。
上記の場合は検索条件が数値でシンプルな検索なので検索結果のスコアはどれも1になっています。

boolクエリの応用

次は複数条件の場合ですが、boolクエリは4つのクエリと組み合わせることができます。

Filter

複数条件の例で使用していたクエリで、ここに指定した条件はAnd条件となります。
そして、filterクエリで指定した条件はスコアには影響しません。Filterで指定した条件に一致してもスコアは0点のままです。

Must

ここに指定した条件はAnd条件となります。
filterと違うのはここに指定された条件によってスコアが計算されることです。

Should

ここに指定した条件はOr条件となります。
Must同様こちらも指定した条件によりスコアが計算されます。

Must Not

この中に書いた条件はNot条件となります。
 

boolクエリは以上の4つと組み合わせることで検索結果をコントロールすることができます。
 

参考: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-bool-query.html 
 

また、スコアは計算されるフィールドの型によって計算方法がかわります。
以下の例でlong型とkeyword型を比較してみます。

# (例8-1 数値でのスコアリング
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"function_score": {
"query": {
"bool": {
"must": [
{
"term": {
"prep_time_min": 15
}
}
]
}
}
}
}
}'
# (例8-2 keyword型でのスコアリング
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"function_score": {
"query": {
"bool": {
"must": [
{
"term": {
"title": "shrimp"
}
}
]
}
}
}
}
}'
(例8-1 long型でのスコアリングの検索結果
"_score": 1,
(例8-2 keyword型でのスコアリングの検索結果
"_score": 1.0594962,

僕の理解ですが、long型の場合は一致したかどうか、keyword型の場合は一致下かどうかだけではなく、アナライザで単語分解した結果とどれだけ一致しているかや、近似値を見ているためスコアに差が出るのではと思っています。

ファンクションスコアクエリ

参考: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html

さらにスコアを細かく操作したい場合はファンクションスコアクエリを使用します。function_score内にあるquery内にはこれまで通り一致条件を指定し、必要なデータのみを検索対象とし、オプションをつけることで、条件にマッチしたデータのスコアを上下させることができます。

#(例9 tagにvegetarianが含まれているレシピの中で、
# 調理時間が10分以内なら10点、
# 準備時間が20分以内なら10点、
# 材料の中ににんにくがあれば5点
# スコアの高い順版に並べ替える
curl -XGET "http://localhost:9200/sample_20161206/recipes/_search" -d'
{
"query": {
"function_score": {
"query": {
"bool": {
"must": [
{
"term": {
"tags": "vegetarian"
}
}
]
}
},
"score_mode": "sum",
"boost_mode": "multiply",
"functions": [
{
"filter": {
"term": {
"cook_time_min": 10
}
},
"weight": 10
},
{
"filter": {
"range": {
"prep_time_min": {
"lte": 20
}
}
},
"weight": 10
},
{
"filter": {
"match": {
"ingredients": "garlic"
}
},
"weight": 5
}
]
}
}
}'

ファンクションスコアクエリにはいくつかの設定項目があります。

boost

query 内の条件にスコアの重み付けをします。

"query": {
"bool": {
"must": [
{
"range": {
"prep_time_min": {"lt":30}
}
},
{
"range": {
"cook_time_min": {"lt":30}
}
}
]
}
},
"boost":10

以上のように指定したクエリで検索した場合は、
 

prep_time_min cook_time_min が共に 30分以内ならば 20点
prep_time_min cook_time_min どちらかが 30分以内ならば 10点
 

のようqueryのスコアが計算されます。

weight

functions 内の条件にスコアの重み付けをします。
boostとは違い、条件それぞれに設定できます。
 

例9では以下のように指定してありました。

"functions": [
{
"filter": {
"term": {
"cook_time_min": 10
}
},
"weight": 10
},
{
"filter": {
"range": {
"prep_time_min": {
"lte": 20
}
}
},
"weight": 10
},
{
"filter": {
"match": {
"ingredients": "garlic"
}
},
"weight": 5
}
]

prep_time_minが10分以内で10点
cook_time_minが10分以内で10点
 
ingredientsに「garlic」が含まれている場合に5点がスコアになります。

score_mode

上記の weightで条件毎のスコア配分が定義できたので、score_modeでfunctions 内のスコアの計算方法を設定します。以下のパラメータを設定できます。

multiply  乗算 (デフォルト) 
sum 加算
avg 平均
max 最大値
min 最小値
first 最初にマッチした条件のスコア

例9のクエリを使用したfunctions内の最高得点はそれぞれ
 

multiplyの場合 10 * 10 * 5 = 500
sumの場合 10 + 10 + 5 = 25
 

となります。

boost_mode

score_modeがquery内以外の条件での配点計算方法を指定していたので、こちらはboostの値の計算方法を指定と名前的にも勘違いしがちですが、「queryの合計スコア」と「functionsの合計スコア」の計算方法を設定します。以下のパラメータを設定できます。

multiply  乗算 (デフォルト)  
sum 加算
avg 平均
max 最大値
min 最小値
replace queryのスコアは使用せず、functionsの合計スコアのみ使用

random_score

ランダムな値を取得し、スコアに反映させることができます。
詳細な計算方法は分かりませんが、random_scoreを指定した条件のスコアとそれぞれ乗算されるようです。

field_value_factor

Elasticsearchで用意されているいくつかの数式を使用してスコアを計算することが可能です。
 
field_value_factorの活用事例があまりなく、良い使用例も浮かばないので公式からの引用とさせていただきました。

---- 引用ここから ----
"field_value_factor": {
"field": "popularity",
"factor": 1.2,
"modifier": "sqrt",
"missing": 1
}
上記のクエリは以下のような数式に直せます。
sqrt(1.2 * doc['popularity'].value)
---- 引用ここまで ----

以下の値を設定することができます。

field     計算に使用するフィールド
factor フィールドの値に掛ける数値(デフォルト値: 1)
missing ドキュメントに指定したフィールドがなかった場合に使用する値
modifier 計算方法 none, log, log1p, log2p, ln, ln1p, ln2p, square, sqrt, reciprocal (デフォルト値: none)

decay functions

いつかの複雑な計算が必要な場合に用意されているメソッドを使用することができます。linear, exp, gauss のどれかを指定します。
こちらもfield_value_factor同様公式からの引用とさせていただきました。

---- 引用ここから ----
"gauss": {
"date": {
"origin": "2013-09-17",
"scale": "10d",
"offset": "5d",
"decay" : 0.5
}
}
---- 引用ここまで ---

公式ドキュメントに詳しく記載されているので使用する場合は確認しましょう。
https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#function-decay

script_score

ドキュメントが持っているフィールドを加工した値をスコアに使用したい、もう少し複雑な条件を加えたいという場合は、スクリプトスコアクエリを使用します。

{
"script_score": {
"script": {
"lang": "painless",
"inline": "_score - (doc['prep_time_min'].value + doc['cook_time_min'].value)"
}
}
}

スクリプトクエリをうまく利用することにより、最適な検索結果をElasticsearchから取得することができます。

スコアリングクエリを活用することで、例えばPairsのようにユーザーのプロフィールを検索する場合には、検索者のプロフィールをスコアリングクエリに入れて、検索者と似ているユーザーを検索することができます。逆に似ている場合はユーザーを弾いたり、真逆のプロフィールを設定している人を上位にするなど、精度はさておき、それだけで簡単なレコメンデーション機能を提供することができます。
 

ただどうしても速度、負荷とのトレードオフになるのでそこは注意しましょう。

最後に

今回は簡単な例ばかりで、詳細な説明は省いた部分はありますが、検索クエリの使い方を雰囲気だけでも掴んでいただけたらうれしいです。
 
Elasticsearchの検索は方法が多種用意されているので、一つ一つ理解するのには少し大変ですがその分検索の利用に幅が生まれるので僕もどんどん活用していきたいです。

明日はインフラエンジニアからPMまでなんでもこなす山下さんの「【Go言語】append使い分けのススメ 〜スライスの先頭へ要素を追加するとき、中身の型は固定長?可変長?〜」です! お楽しみに。

Like what you read? Give eureka_developers a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.