一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

KMLを元にしたSolrの空間検索に挑戦

この記事は一休.comアドベントカレンダー2017の22日目です。

いよいよ今年も終わりですね。 みなさん クリスマスの、忘年会のご予約はすみましたか?

というわけでアドベントカレンダー2打席目、一休.comレストラン 検索 & 集客担当のにがうりです。

一休の本社は赤坂見附の駅からほど近くにあり、お昼ごはんの選択肢が非常にバラエティに富んでいるのが嬉しいところです。 もちろん、その中には一休.comレストランにご加入いただいている店舗様もたくさんあります。

本エントリでは

  • 筆者のお昼休み中に通える範囲内にあり
  • 一休.comレストランでランチが予約できる

レストランがどのくらいあるのか、Solrの空間検索( Spatial Search )を利用して調べてみました。

なお、前回のエントリ同様、Solrのバージョンは7.1.0を前提としています。

事前準備

Solrのスキーマ構成

ひとまず、以下の項目を用意します (restaurant_idがuniqueKey)

  <field name="restaurant_id" type="string" indexed="true" required="true" stored="true"/> <!-- レストランのID -->
  <field name="restaurant_name" type="string" indexed="true" stored="true"/>  <!-- レストランの名称 --> 
  <field name="lat_lon"           type="location"   indexed="true" stored="true" /> <!-- 緯度、経度 -->
  <dynamicField name="*_coordinate" type="pdouble" indexed="true" stored="false"/>

対象となるデータ

「赤坂・永田町・虎ノ門」エリアで12時に予約できるお店が120件ほど見つかりました。 これらの店舗のID, 名前, 緯度経度をSolrに登録しておきます。

Solr検索のテスト

試しに3件取得

URL

http://localhost:8888/solr/ikyu-advent-2017-spatial/select?wt=json&echoParams=none&fl=restaurant_id,restaurant_name,lat_lon&q=*:*&rows=3

取得結果

無事に登録されていました

{
    responseHeader: {
        status: 0,
        QTime: 5
    },
    response: {
        numFound: 120,
        start: 0,
        docs: [{
            restaurant_id: "100152",
            restaurant_name: "春秋 溜池山王店",
            lat_lon: "35.6736091,139.740132"
        }, {
            restaurant_id: "100195",
            restaurant_name: "沖縄懐石 赤坂潭亭",
            lat_lon: "35.6685154,139.732890"
        }, {
            restaurant_id: "100197",
            restaurant_name: "赤坂浅田",
            lat_lon: "35.6738200,139.738229"
        }]
    }
}

オフィスの位置を中心に範囲検索

Spatial Searchのgeofilt を使い、

  • オフィスの場所を中心に
  • 半径400メートル以内の店舗を
  • 近い順に

探してみます

URL

http://localhost:8888/solr/ikyu-advent-2017-spatial/select?wt=json&echoParams=none&fl=restaurant_id,restaurant_name,lat_lon,geodist:geodist()&spatial=true&sfield=lat_lon_rpt&q={!geofilt}&pt=35.675471,139.737937&d=0.4&sort=geodist()%20asc&rows=3

pt=35.675471,139.737937 が一休本社オフィスの緯度経度、d=0.4 が400メートル以内という指定です

取得結果

{
  "responseHeader": {
    "status": 0,
    "QTime": 0
  },
  "response": {
    "numFound": 36,
    "start": 0,
    "docs": [{
      "restaurant_id": "100729",
      "restaurant_name": "個室会席 北大路 赤坂茶寮",
      "lat_lon": "35.6752587,139.738609",
      "geodist": 0.06510001  /* geodistは中心地からの距離 (km) */
    }, {
      "restaurant_id": "106301",
      "restaurant_name": "土佐料理 祢保希 赤坂店",
      "lat_lon": "35.6751768,139.737050",
      "geodist": 0.086561576
    }, {
      "restaurant_id": "107172",
      "restaurant_name": "赤坂 金舌",
      "lat_lon": "35.6746016,139.737555",
      "geodist": 0.10269355
    }]
  }
}

無事取得できました。 一見、後は半径を調整すればいけそうに思われます・・・が、一休本社の周辺マップはこのようになっています。

一休周辺地図 ※ 赤いピンが株式会社一休本社

ご覧の通り、

  1. 東側には大通り
  2. 大通りを抜けても日枝神社や大使館
  3. 北側もガーデンテラスの方向に抜けるためには何回か信号を渡る必要がある

となっているため、単純な半径の調整で良い具合に、というのは中々厳しいものがあります。 ここは、円形ではなく任意の範囲を指定して検索したいところです

任意の範囲を指定して検索

任意の範囲を検索するためには、

  1. どうやって任意の範囲を指定するのか
  2. 任意の範囲を使った検索をどのように行うか

という2つの課題をクリアする必要があります。 幸い、1はGoogleマイマップ、 2はJTSを利用したSpatial Search を使うことで対応できました。

Googleマイマップで範囲データを作成

Googleマイマップでは地図上に自由にラインを引くことでき、さらにそれをKML形式のデータとして出力することが可能です。 ※ Googleマイマップの使い方については本稿の主旨と異なるため、割愛します

ランチタイムの徒歩行動圏をこのような枠線で表現しました。 お昼の行動半径

こちらをKML形式でエクスポートした結果が以下です。

ikyu-advent-2017-spatial.kml

<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>ikyu-advent-2017</name>
    <Style id="poly-000000-1200-77-nodesc-normal">
      <LineStyle><color>ff000000</color><width>1.2</width></LineStyle>
      <PolyStyle><color>4d000000</color><fill>1</fill><outline>1</outline></PolyStyle>
      <BalloonStyle><text><![CDATA[<h3>$[name]</h3>]]></text></BalloonStyle>
    </Style>
    <Style id="poly-000000-1200-77-nodesc-highlight">
      <LineStyle><color>ff000000</color><width>1.8</width></LineStyle>
      <PolyStyle><color>4d000000</color><fill>1</fill><outline>1</outline></PolyStyle>
      <BalloonStyle><text><![CDATA[<h3>$[name]</h3>]]></text></BalloonStyle>
    </Style>
    <StyleMap id="poly-000000-1200-77-nodesc">
      <Pair><key>normal</key><styleUrl>#poly-000000-1200-77-nodesc-normal</styleUrl></Pair>
      <Pair><key>highlight</key><styleUrl>#poly-000000-1200-77-nodesc-highlight</styleUrl></Pair>
    </StyleMap>
    <Placemark>
      <name>ランチエリア</name>
      <styleUrl>#poly-000000-1200-77-nodesc</styleUrl>
      <Polygon>
        <outerBoundaryIs>
          <LinearRing>
            <tessellate>1</tessellate>
            <coordinates>
              139.7344959,35.6802109,0
              139.7360516,35.6778317,0
              139.7344422,35.6766465,0
              139.7351772,35.6739926,0
              139.7361803,35.6719924,0
              139.7388196,35.67116,0
              139.7404772,35.672341,0
              139.7392058,35.673352,0
              139.7372103,35.680516,0
              139.7344959,35.6802109,0
            </coordinates>
          </LinearRing>
        </outerBoundaryIs>
      </Polygon>
    </Placemark>
  </Document>
</kml>

「coordinates」の中に経度、緯度の羅列が入っています。こちらが枠線の情報のようです。

ちなみに、この地図に対して前述の120件のレストランをプロットするとこのようになりました

f:id:ikyu_com:20171222135449p:plain

範囲広すぎましたね・・・ 気を取り直して続けます。

検索の下準備

インストール直後のSolrでは空間検索ができない状態でした。 空間検索を行うためには、JTSを入手する必要があります。

JTSの入手 & 設定

  1. https://repo1.maven.org/maven2/com/vividsolutions/jts-core/ より jts-core-{バージョン}.jar をダウンロード (本稿作成時点では1.14.0)
  2. SOLRインストールディレクトリ/server/solr-webapp/webapp/WEB-INF/lib/ にコピー ※ SOLRインストールディレクトリ/server/lib と間違えないように

SolrにJTSを反映

1. 定義済みの型「location_rpt」に対しspatialContextFactory="JTS" を追記 (これをやらないとエラーになります)
<!-- 変更前 -->
<fieldType name="location_rpt" class="solr.SpatialRecursivePrefixTreeFieldType" geo="true" maxDistErr="0.001" distErrPct="0.025" distanceUnits="kilometers" />
<!-- 変更後 -->
<fieldType name="location_rpt" class="solr.SpatialRecursivePrefixTreeFieldType" geo="true" maxDistErr="0.001" distErrPct="0.025" distanceUnits="kilometers" spatialContextFactory="JTS"/>
2. location_rpt型の列を追加

lat_lon_rptという列を追加しました。 データを作り直すのは面倒なので、copyFieldでlat_lonの値をコピーさせています

  <copyField source="lat_lon" dest="lat_lon_rpt"/>
  <field name="lat_lon_rpt"  type="location_rpt"   indexed="true" stored="true" />
3. Solr再起動

この後データを再度登録し、lat_lon_rptにデータが入っていることを確認して下準備は完了 

任意の範囲を検索

説明 を読むと、

&q=:&fq={!field f=geo}Intersects(POLYGON((-10 30, -40 40, -10 -20, 40 20, 0 0, -10 30)))

という指定で範囲の指定ができるようです。 つまり、Intersects(POLYGON((...))) の中に、先のkmlのcoordinatesの内容を使えばいけそうです。 手で加工するのも面倒なので、Pythonの力を借りて検索しちゃいましょう。

ikyu-advent-2017-spatial.py (Python3.6.xで実行)

import re
import urllib.request
import urllib.error
import xml.etree.ElementTree as et

def main():
    root = et.parse('ikyu-advent-2017-spatial.kml').getroot()  # 先程のkmlファイルを読み込み
    polygon = root.findtext(".//{http://www.opengis.net/kml/2.2}coordinates")  # coordinates内の文字列を取得

    polygon = re.sub(r'\s+', '\n', polygon)  # スペースをトリミング
    polygon = re.sub(r',0$', '', polygon, flags=re.MULTILINE)  # 行末の.0を削除
    lon_lats = [_.split(",") for _ in polygon.split('\n')]
    lon_lat_str = ",".join([f'{_[0]} {_[1]}'  for _ in lon_lats if len(_) == 2])  # lon1 lat1,lon2 lat2,lon3 lat3... の組み合わせの文字列を生成

    query = (
        ('wt', 'json'),
        ('echoParams', 'none'),
        ('rows', '120'),
        ('fl', 'restaurant_id, restaurant_name,lat_lon'),
        ('q', '*:*'),
        ('fq', f'{{!field f=lat_lon_rpt}}Intersects(POLYGON(({lon_lat_str})))'),
    )

    url = "http://localhost:8888/solr/ikyu-advent-2017-spatial/select?" + urllib.parse.urlencode(query)
    print(url)
    print("-------------------")

    with urllib.request.urlopen(url) as req:
        try:
            response = req.read().decode('utf-8')
            print(response)
        except urllib.error.HTTPError as e:
            print("HTTPError")
            print(e.reason)
        except Exception as e:
            print(e)

if __name__ == '__main__':
    main()

出力されたURL

http://localhost:8888/solr/ikyu-advent-2017-spatial/select?wt=json&echoParams=none&rows=120&fl=restaurant_id, restaurant_name,lat_lon&q=*:*&fq={!field f=lat_lon_rpt}Intersects(POLYGON((139.7344959 35.6802109,139.7360516 35.6778317,139.7344422 35.6766465,139.7351772 35.6739926,139.7361803 35.6719924,139.7388196 35.67116,139.7404772 35.672341,139.7392058 35.673352,139.7372103 35.680516,139.7344959 35.6802109)))

取得結果

45件取得されました

{
  "responseHeader": {
    "status": 0,
    "QTime": 1
  },
  "response": {
    "numFound": 45,
    "start": 0,
    "docs": [{
      "restaurant_id": "100197",
      "restaurant_name": "赤坂浅田",
      "lat_lon": "35.6738200,139.738229"
    }, {
      "restaurant_id": "100729",
      "restaurant_name": "個室会席 北大路 赤坂茶寮",
      "lat_lon": "35.6752587,139.738609"
    }, 
    /* ---- 略 ---- */
    {
      "restaurant_id": "107172",
      "restaurant_name": "赤坂 金舌",
      "lat_lon": "35.6746016,139.737555"
    }, {
      "restaurant_id": "107347",
      "restaurant_name": "ビストロMATSU",
      "lat_lon": "35.6760843,139.735606"
    }, 
    /* ---- 略 ---- */
    {
      "restaurant_id": "108549",
      "restaurant_name": "鉄板焼877",
      "lat_lon": "35.676792,139.73558"
    }, {
      "restaurant_id": "108862",
      "restaurant_name": "焼肉しゃぶしゃぶシャンボール",
      "lat_lon": "35.6745112,139.736611"
    }]
  }
}

この45件のみで地図にプロットしなおすとこの通り。

f:id:ikyu_com:20171222135928p:plain

見事、徒歩圏内のレストランのみに絞り込むことに成功しました。 45件、コンプリートの道は遠そうです。

まとめ

  • 範囲指定の情報の作成はGoogleのマイマップを使うと楽
  • 任意の範囲で検索するためにはJTSが必要
  • KMLデータをSolrの検索条件に変換する処理はPythonなりで自動化可能

この結果を活かして、検索をもっと使いやすく、便利にしていきたいと思います。

明日は id:ryo-utsunomiya さんの「一休.comスマホ版予約入力画面リニューアルの舞台裏」です。