検索順位を自在に操る
検索順位を決める要素
Elasticsearchは、スケーラブルで高速なリアルタイムの検索・分析エンジンです。文字列、数値、日時、地理情報など、指定された条件にしたがってインデックスされたドキュメントを検索し、一致するものを利用者に返します。数値、日時、地理情報であれば、「価格が1000円」以上、「今週入荷した商品」、「東京都庁から100km以内」などといった条件をもとに検索ができ、条件に一致するドキュメントのスコアは全て同一です。文字列を条件に指定した場合には、検索対象の文字列の長さや、条件に一致した句の数や頻度などに応じて、それぞれのドキュメントのスコアは異なり、順位付けがなされます。
ただ、検索機能をアプリケーションに実装する場合、検索順位を制御したい場合が多々あります。「1000円以上の商品を安い順」「今週入荷した商品を新しい順」「東京都庁から近い順」に並べるといった場合です。それらのうち、複数の条件を組み合わせたい場合もあるでしょう。Elasticsearchを使用して、どのように実装したら良いでしょうか。
フルーツのオンラインショッピングサイトを想定し、以下のような商品テーブルを用意します。
| arrival_date | name | origin.prefecture | origin.location | price | promotion |
|---|---|---|---|---|---|
| 2018-12-02 | Tsugaru Apple | Aomori | 40.82,140.73 | 310 | 2 |
| 2018-11-29 | Shinano Apple | Nagano | 36.65,138.17 | 280 | 10 |
| 2018-12-04 | Fuji Apple | Akita | 39.69,139.78 | 150 | 1 |
| 2018-12-04 | Mikkabi Mandarine Orange | Shizuoka | 34.97,138.38 | 80 | 1 |
POST items/doc/_bulk
{"index":{}}
{"arrival_date":"2018-12-02","name":"Tsugaru Apple","origin":{"prefecture":"Aomori","location":"40.82,140.73"},"price":310,"promotion":2}
{"index":{}}
{"arrival_date":"2018-11-29","name":"Shinano Apple","origin":{"prefecture":"Nagano","location":"36.65,138.17"},"price":280,"promotion":10}
{"index":{}}
{"arrival_date":"2018-12-04","name":"Fuji Apple","origin":{"prefecture":"Akita","location":"39.69,139.78"},"price":150,"promotion":1}
{"index":{}}
{"arrival_date":"2018-12-04","name":"Mikkabi Mandarine Orange","origin":{"prefecture":"Shizuoka","location":"34.97,138.38"},"price":80,"promotion":1}
商品の「入荷日(arrival_date )」、「商品名(name)」、「生産地(origin.location)」、「価格(price)」、「販促度(promotion)」フィールドを用意しました。利用者は「商品名」のみで検索しますが、「入荷日」が新しいもの、フラッシュセール用の「販促度」が高いものが、より上位に表示されるように試みてみます。
注意:本項に使用しているインデックスやクエリはこちらで入手できます。予期した通り動作させるためには、適切に「入荷日(arrival_date )」などを調整する必要があります。クエリのnowを2018-12-06とすることもできます。
好ましくない方法 - スクリプトスコア(Script Score)
まずはじめに思いつくのは、ユーザが検索したキーワードに該当するドキュメントから、「入荷日(arrival_date)」と「販促度(promotion)」を要素としてスクリプトにより点数付けし、各ドキュメントのスコアを上書きするものです。これは、Function Scoreクエリの、Script Scoreを用いて実現できます。
アプリケーションは、Elasticsearchに以下のようなクエリをリクエストすることができます。
GET items/_search
{
"query": {
"function_score": {
"score_mode": "sum",
"query": {
"match": {
"name": "apple"
}
},
"script_score": {
"script": "doc['promotion'].value - (new Date().getTime() - doc['arrival_date'].value.toInstant().toEpochMilli()) / 1000000 / 60"
}
}
}
}
script_scoreで、「入荷日(arrival_date)」から現在の経過日数を求め、「販促度(promotion)」から引いています。「販促度(promotion)」が高く、より新鮮な商品が上位に表示されることになります。
実際に多くElasticsearchユーザーが、このような方法を用いています。では、なぜ好ましくないのでしょうか。それは、Elasticsearchはスクリプトを実行するために、マッチクエリーで一致したドキュメント全ての、「入荷日(arrival_date)」フィールドと、「販促度(promotion)」にアクセスし、それぞれのドキュメントでスクリプトを用いて計算を行い、求められた値にしたがって検索順位を並べ替える必要があるからです。プロファイルAPIを用いて観察してみると、scoreに多くの時間(本例では267,863ナノ秒)が割かれていることがわかります。
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 3,
"max_score" : 2.0,
"hits" : [
{
"_index" : "items",
"_type" : "doc",
"_id" : "2",
"_score" : 2.0,
"_source" : {
"arrival_date" : "2018-11-29",
"name" : "Shinano Apple",
"origin" : {
"prefecture" : "Nagano",
"location" : "36.65,138.17"
},
"price" : 280,
"promotion" : 10
}
},
{
"_index" : "items",
"_type" : "doc",
"_id" : "3",
"_score" : 0.0,
"_source" : {
"arrival_date" : "2018-12-04",
"name" : "Fuji Apple",
"origin" : {
"prefecture" : "Akita",
"location" : "39.69,139.78"
},
"price" : 150,
"promotion" : 1
}
},
{
"_index" : "items",
"_type" : "doc",
"_id" : "1",
"_score" : -2.0,
"_source" : {
"arrival_date" : "2018-12-02",
"name" : "Tsugaru Apple",
"origin" : {
"prefecture" : "Aomori",
"location" : "40.82,140.73"
},
"price" : 310,
"promotion" : 2
}
}
]
},
"profile" : {
"shards" : [
{
"id" : "[3AvCgesDSpqiSKQR2y_qPA][items][0]",
"searches" : [
{
"query" : [
{
"type" : "FunctionScoreQuery",
"description" : "function score (name:apple, functions: [{scriptScript{type=inline, lang='painless', idOrCode='doc['promotion'].value - (new Date().getTime() - doc['arrival_date'].value.toInstant().toEpochMilli()) / 1000000 / 60', options={}, params={}}}])",
"time_in_nanos" : 366579,
"breakdown" : {
"score" : 267863,
"build_scorer_count" : 7,
"match_count" : 0,
"create_weight" : 4120,
"next_doc" : 16184,
"match" : 0,
"create_weight_count" : 1,
"next_doc_count" : 6,
"score_count" : 3,
"build_scorer" : 78395,
"advance" : 0,
"advance_count" : 0
}
}
],
"rewrite_time" : 3536,
"collector" : [
{
"name" : "CancellableCollector",
"reason" : "search_cancelled",
"time_in_nanos" : 286452,
"children" : [
{
"name" : "SimpleTopScoreDocCollector",
"reason" : "search_top_hits",
"time_in_nanos" : 275455
}
]
}
]
}
],
"aggregations" : [ ]
}
]
}
}
減衰(Decay)関数を検討する
Function Scoreクエリでは、指定した値から遠ざかるほどスコアが下がる、Decay Functionを利用できます。指定した原点から遠ざかるほど、検索スコアが下がり、複数の条件を指定したり、減衰度を調整することができます。以下のようなクエリで実現することができます。
GET items/_search
{
"query": {
"function_score": {
"score_mode": "sum",
"query": {
"match": {
"name": "apple"
}
},
"functions": [
{
"linear": {
"arrival_date": {
"origin": "now",
"scale": "7d",
"offset": "0d"
}
}
},
{
"linear": {
"promotion": {
"origin": "10",
"scale": "10",
"offset": "0"
}
}
}
]
}
}
}
まず、「商品名(name)」にappleが含まれるものを検索します。さらに「入荷日(arrival_date)」が現在より遠ざかるほど、直線的(Linear)にスコアが下がります。そして同様に、「販促度(promotion)」が10から遠ざかるに連れて、スコアが下がり、これら3つのスコアを足した(sum)ものをスコアとします。スコア自身はscript_scoreとは異なりますので、検索の順位は異なる可能性がありますが、「入荷日」が新しいもの、フラッシュセール用の「販促度」が高いものを上位にするという要件を満たせることがわかります。
さらに、プロファイルAPIを使用すると、より少ないコスト(計算時間)のscoreで(本例では24,824ナノ秒)、レスポンスが得られることが確認できます。
{
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 3,
"max_score" : 0.58585125,
"hits" : [
{
"_index" : "items",
"_type" : "doc",
"_id" : "2",
"_score" : 0.58585125,
"_source" : {
"arrival_date" : "2018-11-29",
"name" : "Shinano Apple",
"origin" : {
"prefecture" : "Nagano",
"location" : "36.65,138.17"
},
"price" : 280,
"promotion" : 10
}
},
{
"_index" : "items",
"_type" : "doc",
"_id" : "3",
"_score" : 0.5511543,
"_source" : {
"arrival_date" : "2018-12-04",
"name" : "Fuji Apple",
"origin" : {
"prefecture" : "Akita",
"location" : "39.69,139.78"
},
"price" : 150,
"promotion" : 1
}
},
{
"_index" : "items",
"_type" : "doc",
"_id" : "1",
"_score" : 0.5164573,
"_source" : {
"arrival_date" : "2018-12-02",
"name" : "Tsugaru Apple",
"origin" : {
"prefecture" : "Aomori",
"location" : "40.82,140.73"
},
"price" : 310,
"promotion" : 2
}
}
]
},
"profile" : {
"shards" : [
{
"id" : "[3AvCgesDSpqiSKQR2y_qPA][items][0]",
"searches" : [
{
"query" : [
{
"type" : "FunctionScoreQuery",
"description" : "function score (name:apple, functions: [{org.elasticsearch.index.query.functionscore.DecayFunctionBuilder$NumericFieldDataScoreFunction@84ef9e63}{org.elasticsearch.index.query.functionscore.DecayFunctionBuilder$NumericFieldDataScoreFunction@7ed62d90}])",
"time_in_nanos" : 148424,
"breakdown" : {
"score" : 24824,
"build_scorer_count" : 7,
"match_count" : 0,
"create_weight" : 55157,
"next_doc" : 2485,
"match" : 0,
"create_weight_count" : 1,
"next_doc_count" : 6,
"score_count" : 3,
"build_scorer" : 65941,
"advance" : 0,
"advance_count" : 0
}
}
],
"rewrite_time" : 3466,
"collector" : [
{
"name" : "CancellableCollector",
"reason" : "search_cancelled",
"time_in_nanos" : 37957,
"children" : [
{
"name" : "SimpleTopScoreDocCollector",
"reason" : "search_top_hits",
"time_in_nanos" : 30003
}
]
}
]
}
],
"aggregations" : [ ]
}
]
}
}
減衰(Decay)関数を地理情報に応用する
減衰(Decay)ファンクションが適用可能なのは、数値や日付時刻だけに限らず、地理情報にも適用できます。ある地点から遠ざかるほどスコアが下がるという検索は、タクシーの配車やイベントのチケット販売、デーティングアプリケーションなど、様々なケースで容易に応用できます。フルーツのオンラインショッピングサイトにおいて、生産者直送の地産地消を推進するのであれば、以下のようなクエリで、購入者から地理的に近い商品を勧めることもできます。
GET items/_search
{
"query": {
"function_score": {
"score_mode": "sum",
"query": {
"match": {
"name": "apple"
}
},
"linear": {
"origin.location": {
"origin": "35.68,139.69",
"offset": "0",
"scale": "300km"
}
}
}
}
}
まとめ
検索順位を柔軟に、かつ自在に制御するヒントは得られましたでしょうか。パフォーマンスの観点からは、なるべくスクリプトによるスコア計算は、避けることが望ましいですし、Elasticsearchが提供している減衰(Decay)ファンクションは、より低コストで検索順位を制御できる機会を提供しますので、第一の候補として検討してください。
また、アプリケーション検索に特化した当社のサービスである、Elastic App Searchを用いると、GUIを用いて関連性をチューニングすることができます。条件に応じた検索結果をその場でリアルタイムに確認できたり、アプリケーション開発者の手を煩わせることなく検索順位を制御することができます。この他にも便利なAPIや検索UIのリファレンスなども提供していますので、アプリケーション開発の工数を著しく削減します。ぜひお試しください。