ElasticsearchでGEO系クエリで遊びます。ついでにfunction_scoreも勉強してみます。

Elasticsearch GEO系クエリとこの記事の概要

GEO系クエリにはいくつかありますが、さくっと試しやすいのが、 geo_bounding_boxとgeo_distanceクエリです。

前者は、検索したい四方の北西と南東の座標を検索条件にあたえて、そのBOX内の座標を持つドキュメントを検索します。

つまり、四角の検索です。

後者は、中心点の座標と距離を与えて検索します。

つまり、円形の検索です。

ここでは、前者を中心に試してみました。

また、距離による並べ替えなど、function_scoreなどスコアリング、加えてPainless Scriptによる検索時の動的フックも少し試して見ています。

今回、私が動作確認したlasticsearchのバージョンは6.4です。

www.elastic.co

ここでは紹介しませんが、ポリゴンで検索できたりといったこともできます。 また、geo_shapeクエリでは、「Spatial Relations/ Spatial Strategies」というオプションがあり、INTERSECTS、DISJOINT、WITHIN、CONTAINSの切り替えができるようです。 これらはつまるところ、なんらかの「検索条件の枠」と検索対象のドキュメント側の「地域の枠」どおしが、含まれるか、重なっているか...などによってマッチするかどうかの扱いをコントロールできるようです。 私は試していませんが、そういうユースケースもしばしばあると思いますし、SQLでは実現しにくい検索条件なので非常におもしろいですよね。((ちょっとパフォーマンスが気になりますね))

検索データの登録

とりあえずkibanaのDevToolsなどで、「loc」フィールドが geo_pointタイプになるように設定

PUT ginza
{
    "mappings": {
        "_doc": {
            "properties": {
                "loc": {"type": "geo_point"}
            }
        }
    }
}

東京メトロ銀座線のデータをバルクロードする。 *1

あと、銀座線はこちら。

www.tokyometro.jp

銀座線は渋谷を起点に、おおよそ西から東に向けて走っています。 ↓ https://www.google.co.jp/maps/search/%E9%8A%80%E5%BA%A7%E7%B7%9A/@35.6106894,139.6903769,12z/data=!3m1!4b1

今回用いるデータのバルクロードの例

POST ginza/_doc/_bulk
{"index":{"_id":1}}
{"id":1,"name":"渋谷","loc":[139.701238,35.658871], "x":5,"y":3 }
{"index":{"_id":2}}
{"id":2,"name":"表参道","loc":[139.712314,35.665247], "x":2 }
{"index":{"_id":3}}
{"id":3,"name":"外苑前","loc":[139.717857,35.670527], "x":0 }
{"index":{"_id":4}}
{"id":4,"name":"青山一丁目","loc":[139.724159,35.672765], "x":2 }
{"index":{"_id":5}}
{"id":5,"name":"赤坂見附","loc":[139.737047,35.677021], "x":4 }
{"index":{"_id":6}}
{"id":6,"name":"溜池山王","loc":[139.741419,35.673621], "x":3 }
{"index":{"_id":7}}
{"id":7,"name":"虎ノ門","loc":[139.749832,35.670236], "x":0 }
{"index":{"_id":8}}
{"id":8,"name":"新橋","loc":[139.758432,35.667434], "x":3, "y":2 }
{"index":{"_id":9}}
{"id":9,"name":"銀座","loc":[139.763965,35.671989], "x":2 }
{"index":{"_id":10}}
{"id":10,"name":"京橋","loc":[139.770126,35.676856], "x":0 }
{"index":{"_id":11}}
{"id":11,"name":"日本橋","loc":[139.773516,35.682078], "x":2 }
{"index":{"_id":12}}
{"id":12,"name":"三越前","loc":[139.773594,35.687101], "x":2,"y":1 }
{"index":{"_id":13}}
{"id":13,"name":"神田","loc":[139.770899,35.693587], "x":0, "y":1 }
{"index":{"_id":14}}
{"id":14,"name":"末広町","loc":[139.771713,35.702972], "x":0 }
{"index":{"_id":15}}
{"id":15,"name":"上野広小路","loc":[139.772877,35.70768], "x":2 }
{"index":{"_id":16}}
{"id":16,"name":"上野","loc":[139.777122,35.711482], "x":3,"y":2 }
{"index":{"_id":17}}
{"id":17,"name":"稲荷町","loc":[139.782593,35.711273], "x":0 }
{"index":{"_id":18}}
{"id":18,"name":"田原町","loc":[139.790897,35.709897], "x":0 }
{"index":{"_id":19}}
{"id":19,"name":"浅草","loc":[139.797592,35.710733], "x":2, "y":1 }

geo_bounding_boxによるGEO検索

前セクションでインデックスに登録した銀座線の全部の駅を含むようなBOXの頂点ですが、

北西

"35.711482,139.701238"

南東

"35.658871,139.797592"

となりますね。

なので、ちょっとだけ余裕を持った

北西 35.712,139.7 南東 35.657,139.798

の範囲を検索してみます。

POST ginza/_doc/_search
{
  "query": {
    "bool": {
      "filter": [
        {
          "geo_bounding_box": {
            "loc": {
              "top_left": "35.712,139.7",
              "bottom_right": "35.657,139.798"
            }
          }
        }
      ]
    }
  }
}

↓ 

登録した19件が返ってきました。
(ちなみに、全てscoreは0)

「_geo_distance」プロパティを使った中心点からのソート

続いて、渋谷駅の座標を中心点にして、ソートします。

つまり、目論見どおりであれば、銀座線の始発から終点の浅草駅まで路線の並び順にソートされるハズ。

POST ginza/_doc/_search
{
  "size":100,
  "query": { "match_all" : {}},
    "sort": [
    {
      "_geo_distance": {
        "loc": "35.658871,139.701238",
        "order": "asc"
      }
    }
  ]
}  

↓ 戻り値イメージ

{
  "hits": {
    "hits": [
      {
        "_source": {
          "id": 1,
          "name": "渋谷"
        }
      },
      {
        "_source": {
          "id": 2,
          "name": "表参道"
        }
      },
      {
        "_source": {
          "id": 3,
          "name": "外苑前"
        }
      },
      {
        "_source": {
          "id": 4,
          "name": "青山一丁目"
        }
      },
      {
        "_source": {
          "id": 5,
          "name": "赤坂見附"
        }
      },
      {
        "_source": {
          "id": 6,
          "name": "溜池山王"
        }
      },
      {
        "_source": {
          "id": 7,
          "name": "虎ノ門"
        }
      },
      {
        "_source": {
          "id": 8,
          "name": "新橋"
        }
      },
      {
        "_source": {
          "id": 9,
          "name": "銀座"
        }
      },
      {
        "_source": {
          "id": 10,
          "name": "京橋"
        }
      },
      {
        "_source": {
          "id": 11,
          "name": "日本橋"
        }
      },
      {
        "_source": {
          "id": 12,
          "name": "三越前"
        }
      },

ちなみに、応答の「"sort"」の中に、メートル距離らしきものが入ってきています(ので、これを距離と見てもよさそうです)。

あと、本格的にいろいろ考える場合は、sortのところについて次の公式Rの「ignore_unmapped」のあたりの解説も確認しておきましょう。

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html#geo-sorting

また、そもそもこの公式Rの続きのところのscript_based_sortingのところにあるように、Painless Scriptでソートの条件を指定できます。 話の流れの都合でここでは述べませんが、後述のPainless Scriptの例などを使うと、「_geo_distance」をもっとひねったカスタムな条件のソートもできそうですね。

前2つを、組み合わせてみます。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
      "filter": [
        {
          "geo_bounding_box": {
            "loc": {
              "top_left": "35.712,139.7",
              "bottom_right": "35.657,139.798"
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "_geo_distance": {
        "loc": "35.658871,139.701238",
        "order": "asc"
      }
    }
  ]
}

↓
結果は略

距離の取得(script_fields、arcDistance、planeDistanceの利用)

距離によるソートはしないものの、検索の中心点からの距離が欲しい、というストーリーで、これらを個別に取得してみます。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
            "must": [
          {"match_all":{}}
      ]
    }
  },
  "sort": [
    "id"
  ],
  "script_fields": {
     "a_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].arcDistance(35.658871,139.701238)"
         }},
      "p_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].planeDistance(35.658871,139.701238)"
         }}
 },
 "_source":["name","loc"]
}

なお、arcDistance、planeDistanceというのは、painlessのGEO系のAPIです。

名前からなんとなくお察しします(手抜き)

https://www.elastic.co/guide/en/elasticsearch/painless/7.0/painless-api-reference.html

上記クエリの例だと、a_dist、p_distというフィールドに距離が取得できます。

        "_source": {
          "loc": [
            139.701238,
            35.658871
          ],
          "name": "渋谷"
        },
        "fields": {
          "p_dist": [
            0.001059014854358351
          ],
          "a_dist": [
            0
          ]
        },
        "sort": [
          1
        ]
      },
      {
        "_index": "ginza",
        "_type": "_doc",
        "_id": "2",
        "_score": null,
        "_source": {
          "loc": [
            139.712314,
            35.665247
          ],
          "name": "表参道"
        },
        "fields": {
          "p_dist": [
            1226.3385782660855
          ],
          "a_dist": [
            1226.3385739214464
          ]
        },

渋谷が0、表参道が1.2kmぐらいというところなのであっているのかな。

painlessで距離取得の補足事項

ここで次のドキュメントをインデックスに追加します。

座標情報無しですね。....

POST ginza/_doc/_bulk
{"index":{"_id":20}}
{"id":20,"name":"惑星プロメシューム"}

上記のデータを追加したのち、先ほどと同様に、(全件取得しつつ)取得できた駅への渋谷駅からの距離を取得します。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
            "must": [
          {"match_all":{}}
      ]
    }
  },
  "sort": [
    "id"
  ],
  "script_fields": {
     "a_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].arcDistance(35.658871,139.701238)"
         }},
      "p_dist": {
         "script": {
             "lang": "painless",
             "source": "doc['loc'].planeDistance(35.658871,139.701238)"
         }}
 },
 "_source":["name","loc"]
}

↓

一応クエリは返ってきますが、次のfailuresを含みます。

    "failures": [
      {
        "shard": 2,
        "index": "ginza",
        "node": "NHxID4CmSDa21PyG_F0kHg",
        "reason": {
          "type": "script_exception",
          "reason": "runtime error",
          "script_stack": [
            "org.elasticsearch.index.fielddata.ScriptDocValues$GeoPoints.arcDistance(ScriptDocValues.java:501)",
            "doc['loc'].arcDistance(35.658871,139.701238)",
            "          ^---- HERE"
          ],
          "script": "doc['loc'].arcDistance(35.658871,139.701238)",
          "lang": "painless",
          "caused_by": {
            "type": "null_pointer_exception",
            "reason": null
          }
        }
      }
    ]

つまるところ、座標が無い、先ほど追加したid=20のレコードが処理対象になってしまうみたいですね。

では、painless scriptでnull判定を行ってエラーにならないようにしましょう。

POST ginza/_doc/_search
{
  "size":100,
  "query": {
    "bool": {
      "must": [
          {"match_all":{}}
      ]
    }
  },
  "sort": [
    "id"
  ],
  "script_fields": {
     "a_dist": {
         "script": {
             "lang": "painless",
             "source": "if(doc['loc'].value != null ){ return doc['loc'].arcDistance(35.658871,139.701238) } else{ return null }"
         }}
 },
 "_source":["name","loc"]
}

↓

結果は略するが、エラーは解消。

なお、公式だと、この類のものはsize()が0かどうか判定するという例が記載されています。

https://www.elastic.co/guide/en/elasticsearch/painless/7.0/painless-examples.html#_missing_values

渋谷駅から近い順に加点する

function_scoreおよびそれの実際の設定であるfunctionsを使って加点の指定を行います。

なお、元情報が数値や緯度経度、座標に関しては、いずれも中心点にあたるものからの「距離」が遠ざかるほど、減点されるというスコアリングが有用です。

また、Elasticsearchのクエリとしても、実際に常套句として「減衰関数」のfunctionクエリが用意されています。

下記の例では、「gauss」という減衰関数(decay function)を使っています。

多分ガウス関数などで見られるS字の曲線そのものだと思うのですが、中心点にある程度近いものは減点がなだらか、ある値周辺を境にしばらく急な傾きで減点が続いて、ある値周辺を境に再び減点がなだらかになります。

つまるところ、例えば

徒歩で歩ける範囲は、

3分の距離と5分の距離はそれほど違いがないと感じるものの

10分を超えると少しめんどくささい気持ちが加速して、

逆に30分以上は、歩くつもりはない(ので興味がない... もしくはそれでも向かわなければならないモチベーションがあるなら、車で移動するのでその場合は徒歩40分も60分も車であれば大差ない...)

という感ににそっていると思います。

減衰関数は、他にもlinearやexpがありますが、私見ではgaussが一番、上記のユースケースにそっているような気がします。

詳しくは、公式の

Function Score Query | Elasticsearch Reference [7.0] | Elastic

のセクションの最後の方にある、「decay」「scale」「offset」「origin」の説明も兼ねた関数のグラフの画像がわかりやすいと思います。

で、その減衰関数を使った距離加点(中心点から離れると減点)の例です。

POST ginza/_doc/_search
{
  "size": 100,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "match_all": {}
            }
          ],
          "filter": [
            {
              "geo_bounding_box": {
                "loc": {
                  "top_left": "35.712,139.7",
                  "bottom_right": "35.657,139.798"
                }
              }
            }
          ]
        }
      },
      "boost": "1",
      "boost_mode": "multiply",
      "score_mode": "sum",
      "functions": [
        {
          "gauss": {
            "loc": {
              "origin": "35.658871,139.701238",
              "offset": "0km",
              "scale": "5km",
              "decay": 0.01
            }
          },
          "weight": 1
        }
      ]
    }
  },
  "script_fields": {
    "a_dist": {
      "script": {
        "lang": "painless",
        "source": "doc['loc'].arcDistance(35.658871,139.701238)"
      }
    }
  },
  "_source":["name"]
}

※ 2019/4/26 
    誤記で
       decay:0
 となっているところがあったので、0.01に見直しました。

↓ 結果

{
  "hits": {
    "hits": [
      {
        "_score": 1,
        "fields": {
          "a_dist": [
            0.001059014854358351
          ]
        }
      },
      {
        "_score": 0.7549775,
        "fields": {
          "a_dist": [
            1226.3385782660855
          ]
        }
      },
      {
        "_score": 0.60371536,
        "fields": {
          "a_dist": [
            1983.4067075198245
          ]
        }
      },
      {
        "_score": 0.48381922,
        "fields": {
          "a_dist": [
            2583.4874809390394
          ]
        }
      },
      {
        "_score": 0.23820671,
        "fields": {
          "a_dist": [
            3812.779265059126
          ]
        }
      },
      {
        "_score": 0.20415527,
        "fields": {
          "a_dist": [
            3983.2068896046344
          ]
        }
      },
      {
        "_score": 0.08726448,
        "fields": {
          "a_dist": [
            4568.245932624003
          ]
        }
      },
      {
        "_score": 0,
        "fields": {
          "a_dist": [
            8035.270236780703
          ]
        }
      },
      {
        "_score": 0,
        "fields": {
          "a_dist": [
            10439.71264168335
          ]
        }
      },
      {
        "_score": 0,
        "fields": {
          "a_dist": [
            5253.984620891842
          ]
        }
      },
     ..........


なお、script_fieldsの「a_dist」は、ここでは加点とは関係ありません。

これがあると検索結果に距離が含まれるので、実際の「減点傾向」がよくわかるためです。

また、boost、boots_mode、score_modeは、(説明がめんどくさいので)ここではおまじないだと思ってください。 (本バージョンのElasticsearchだとおそらくデフォルト設定と同じなので指定しなくても同じ動きになります。)

ここでは、geo検索まわりの説明に絞っているのでそれほど意味が感じられませんが、他の加点要素との組み合わせのパラメータになります。

これらのパラメータについては、詳しくは、次のページの最初のセクションをごらんください。

www.elastic.co

さて、再びGEO検索目線み戻って、追加の解説をします。

減衰関数(ここではgauss)のところにoffset、scale、decayというサブプロパティを設定しています。

これらは、expやlinearでも共通設定項目です。

gauss、exp、linearそれぞれの性質によりますが、いずれも用いたとしても、このoffset、scale、decayのような値を設定した場合、中心点は1点として、5kmぐらいまで減点して、5km離れたあたり以降は全て0点とするようなイメージの設定です。

あえて数式のことを考えなければ、ただし、先述リンク先のグラフの画像のイメージが頭にあればという前提ですが、「decay」は0点にするつもりの0を設定するイメージで0よりちょっと大きい値を設定するという間に合わせ理解でも「使うだけなら」問題ないかと思います。*2

function_scoreによるgeo系検索でぼちぼちあるあるなスコアリング設定の例

冒頭にインデックスにバルクロードしたデータに「x」と「y」というプロパティがありました。

これは、その駅で乗り換えられる路線数です。「x」がJR・私鉄の合計、「y」がJRの乗り換え可能路線数です。

この値から連想して次のような加点を考えてみます。

  1. 乗り換えられる路線が多い駅は加点する。
  2. JRに乗り換えられる駅はさらに加点する。ここで、JRに乗り換えられない駅は加点しない。
  3. 乗り換えられる路線が3つ以上の駅は、さらにボーナス加点する。

および、そもそも前述の例と同様に渋谷駅から近い方の駅を減衰関数で加点する。

それぞれ、script_score、field_value_factor、(function_scoreの)filterという仕掛けを使います。 (script_scoreはちょいと手抜きです。ポイントはscoring コンテキストでの、painless scriptが使えるというところです。)

つまるところ、渋谷駅から近い方が嬉しいが、近い駅で乗り換え選択肢が少ないよりは、乗り換えの選択が多い駅を優先(ただしあんまり遠いのは嫌)のような例になります。

↓実際のクエリは下記のとおり。

POST ginza/_doc/_search?filter_path=*.*._s*,*.*.f*
{
  "size": 100,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "match_all": {}
            }
          ],
          "filter": [
            {
              "geo_bounding_box": {
                "loc": {
                  "top_left": "35.712,139.7",
                  "bottom_right": "35.657,139.798"
                }
              }
            }
          ]
        }
      },
      "boost": "1",
      "boost_mode": "multiply",
      "score_mode": "sum",
      "functions": [
        {
          "gauss": {
            "loc": {
              "origin": "35.658871,139.701238",
              "offset": "0km",
              "scale": "5km",
              "decay": 0.001
            }
          },
          "weight": 1
        },
        {
          "script_score": {
            "script": {
              "source": "doc['x'].value"
            }
          }
        },
        {
          "field_value_factor": {
            "field": "y",
            "factor": 0.8,
            "missing": 0
          }
        },
        {
           "filter": { "range": { "x": { "gte": 3 } }},
            "weight": 2
        }
      ]
    }
  },
  "script_fields": {
    "a_dist": {
      "script": {
        "lang": "painless",
        "source": "doc['loc'].arcDistance(35.658871,139.701238)"
      }
    }
  },
  "_source": [
    "name"
  ]
}


↓ 結果: 新橋や上野など節目の駅が加点によりせり上がってきている(...と言えると思う)


{
  "hits": {
    "hits": [
      {
        "_score": 10.4,
        "_source": {
          "name": "渋谷"
        },
        "fields": {
          "a_dist": [
            0
          ]
        }
      },
      {
        "_score": 6.6004868,
        "_source": {
          "name": "新橋"
        },
        "fields": {
          "a_dist": [
            5253.984537158694
          ]
        }
      },
      {
        "_score": 6.6,
        "_source": {
          "name": "上野"
        },
        "fields": {
          "a_dist": [
            9010.821109908235
          ]
        }
      },
      {
        "_score": 6.0180106,
        "_source": {
          "name": "赤坂見附"
        },
        "fields": {
          "a_dist": [
            3812.779221045536
          ]
        }
      },
      {
        "_score": 5.0124764,
        "_source": {
          "name": "溜池山王"
        },
        "fields": {
          "a_dist": [
            3983.2068434617145
          ]
        }
      },
      {
        "_score": 2.8000004,
        "_source": {
          "name": "三越前"
        },
        "fields": {
          "a_dist": [
            7250.651361098846
          ]
        }
      },
      {
        "_score": 2.8,
        "_source": {
          "name": "浅草"
        },
        "fields": {
          "a_dist": [
            10439.711719992803
          ]
        }
      },
      {
        "_score": 2.6599808,
        "_source": {
          "name": "表参道"
        },
        "fields": {
          "a_dist": [
            1226.3385739214464
          ]
        }
      },
      {
        "_score": 2.1581507,
        "_source": {
          "name": "青山一丁目"
        },
        "fields": {
          "a_dist": [
            2583.487466760376
          ]
        }
      },

以上で終わりです

あとで、Painless Scriptのその他読みこなしておくと便利な公式リファレンスへのポインタを自分用に貼り付ける予定。

*1:駅データ.jpのフリー版データの銀座線の駅一覧を利用させてもらいました

*2:つまり私のことか!