Elasticsearchで位置情報の検索結果をベクトルタイルで配信

## TL;DR

- 2021年9月リリースのElasticsearch7.15から、位置情報の検索結果を`MapboxVectorTile`形式（以下「ベクタータイル」）で配信することが可能となった
- タイルインデックスを用いたリクエストのおかげで、バウンディングボックスによる検索時と比べて、
リクエスト数が減ることが期待される（これは条件次第）
ブラウザキャッシュによる無用な再リクエストの削減・フロントエンドの実装簡略化が期待される
- ベクタータイルは、JSONテキストよりも配信データサイズ・ブラウザでのデコードの面ではるかに有利
- 150万件データに対する検索で検証、レスポンス速度などかなり実用的

## ElasticsearchがMapboxVectorTileに対応

https://www.elastic.co/guide/en/elasticsearch/reference/7.16/release-notes-7.15.0.html#release-notes-7.15.0

https://www.elastic.co/guide/en/elasticsearch/reference/7.15/search-vector-tile-api.html


地図アプリケーションでElasticsearchを用いた検索をすると、地図領域が変わるたびに、その領域でElasticsearchへリクエストを飛ばし、検索結果を取得・描画する…こととなります。

また、ElasticsearchのレスポンスはJSON形式のテキストです。JSONテキストは、データサイズとブラウザでのデコード速度で不利です(というより、ベクタータイル(`ProtocolBuffers`)が有利:[参考](https://github.com/mapbox/pbf#performance))。データの件数がそれほど大きくない場合は気にする必要はありませんが、件数が増えるにつれパフォーマンスの悪化につながります。

ここで、Elasticsearchがネイティブでベクタータイルでのレスポンスに対応しました。このことは、上記を踏まえ下記のメリットがあります。

- 配信データサイズの圧縮・デコード速度の向上
- タイルインデックスを用いたリクエストが可能となる

前者は上述のとおりです。後者は言い換えると<b>バウンディングボックスを用いたリクエストをする必要がない</b>ということを意味します。`Mapbox`ないし`MapLibre`をはじめとした地図ライブラリはベクタータイルの対応が進んでいるため、ライブラリの標準機能の範疇でElasticsearchへのリクエストを行えるということです（特別な実装を必要としない）。また、一度読み込んだタイルデータをキャッシュ出来るというメリットもあります（バウンディングボックスでJSONを取得しているとこうはいかない）。

> ちなみに元同僚の先行事例がありましたのでリンクしておきます。
https://qiita.com/JinIgarashi/items/a7f826d3c1ba6c848140

### 補足:対応しているtypeについて

Elasticsearchでは位置情報のtypeは`geo_point`か`geo_shape`の2つであり、前者は点のみ、後者が点・線・面すべて（GISの概念そのもの）となります。ここで現状、サブスクリプション無しでベクタータイルで配信できるのはgeo_pointのみのようです。なので本記事ではgeo_pointでのベクタータイル配信を紹介します。

> 2022-05-19追記
サブスクリプションが必要なのは、geo_shapeでaggregationを行う場合でした。リクエスト時にgrid_precision=0と設定することで、ポリゴンであってもベクタータイルで配信することができます。

https://www.elastic.co/jp/subscriptions


## Elasticsearchの起動

https://qiita.com/kiyokiyo_kzsby/items/344fb2e9aead158a5545

こちらの記事に沿って、docker-composeでElasticsearchを起動します。ただし今回は、イメージのバージョンを`7.16.3`とします。

```Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:7.16.3
```

## インデックスの作成

> 以下はOpenStreetMapの日本全国の地名150万件が入った自作データ（poi.geojsonl）を用いた例です。データの作成方法については触れません。

位置情報データからインデックスを作成する際は、`GDAL(ogr2ogr)`を用いるのが簡単です。GDALのインストールにはこの記事では触れません([参考](https://qiita.com/Kanahiro/items/4b5f8db5743c69741220#%E3%81%A4%E3%82%89%E3%81%BFgdal%E3%81%AE%E7%92%B0%E5%A2%83%E6%A7%8B%E7%AF%89))。また、マッピング定義は今回は追求していません、GDALのデフォルト値を用います。

```sh
ogr2ogr -progress -lco BULK_SIZE=5000000 -lco GEOM_MAPPING_TYPE=GEO_POINT ES:http://localhost:9200 poi.geojsonl
```

このケースだと、poiという名前のインデックスが作成されます。

```sh
# 取り込まれた件数を確認してみる
curl http://localhost:9200/poi/_count
{"count":1525221,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0}}

# テキトーに検索してみる
curl http://localhost:9200/poi/_search -d '{"from":0,"size":3,"query":{"match":{"name":"サッポロファクトリー"}}}'  -H "Content-type: application/json"
{"took":4,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":10,"relation":"eq"},"max_score":17.550877,"hits":[{"_index":"poi","_type":"_doc","_id":"L0KGqX4BvWG3qujRa3fN","_score":17.550877,"_source":{ "ogc_fid": 218485, "geometry": { "type": "Point", "coordinates": [ 141.3631322, 43.0652757 ] }, "name": "サッポロファクトリー", "type": "building" }},{"_index":"poi","_type":"_doc","_id":"a0KGqX4BvWG3qujRcb8P","_score":17.550877,"_source":{ "ogc_fid": 236977, "geometry": { "type": "Point", "coordinates": [ 141.3627555, 43.0642391 ] }, "name": "サッポロファクトリー", "type": "department_store" }},{"_index":"poi","_type":"_doc","_id":"7kSGqX4BvWG3qujRkUed","_score":17.550877,"_source":{ "ogc_fid": 337460, "geometry": { "type": "Point", "coordinates": [ 141.3625939, 43.0658504 ] }, "name": "サッポロファクトリー", "type": "bus_stop" }}]}}i
```

ではベクタータイルのエンドポイントにリクエストしてみます。[札幌駅周辺のタイル座標](https://maps.gsi.go.jp/development/tileCoordCheck.html#14/43.0639/141.3542)は14/14625/6016なので

```sh
# 14-14625.6016.pbfとしてベクタータイルを保存
curl http://localhost:9200/poi/_mvt/geometry.coordinates/14/14625/6016 --output 14-14625-6016.pbf
```

このファイルを[QGIS](https://qgis.org/ja/site/forusers/download.html)で開いてみます。

![スクリーンショット 2022-02-04 0.49.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/7274069b-493b-1d9e-230e-d41d130bae63.png)

狙ったタイル座標のデータが正しく配信されていることがわかります。上記では当該タイルに含まれる全件が返ってきています。POSTで絞り込んでみます。

```sh
curl http://localhost:9200/poi/_mvt/geometry.coordinates/14/14625/6016 --output 14-14625-6016.pbf -d '{"query":{"match":{"name":"サッポロファクトリー"}}}' -H "Content-type: application/json"
```

![スクリーンショット 2022-02-04 0.53.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/e5c3fe10-ea89-ae87-fa21-02e89be604a4.png)

タイル内の、ヒットした地物だけが返ってくることがわかります。
このとき、エンドポイントに含まれる`geometry.coordinates`は、ドキュメントの`geo_point`を参照していることに留意してください。

### 留意: 地図ライブラリからのリクエスト

地図アプリケーションに見識のある方なら、ここまでの説明で実装のイメージがつくと思いますが、ひとつ問題があります。Elasticsearchの検索はPOSTでクエリデータを送る必要がありますが、Mapbox（もしくはMapLibre）ではタイルへのリクエストはGETしか想定されていません。ブラウザからElasticsearchに直接リクエストする構築はあり得ないとは思いますが、タイルを配信するエンドポイントはGETリクエストである必要があります。

## タイル配信サーバーの構築

Expressでの実装例を示します。Elasticsearchのレスポンスがバイナリデータであることと、同じデータをブラウザへ送信する必要があることに留意しつつ、下記のようなサーバーを書いてみます。

```Dockerfile:Dockerfile
FROM ubuntu:20.04
ENV DEBIAN_FRONTEND=noninteractive

RUN apt update
RUN apt -y upgrade
RUN apt install -y curl

RUN curl -o nodejs.deb https://deb.nodesource.com/node_14.x/pool/main/n/nodejs/nodejs_14.15.2-deb-1nodesource1_amd64.deb
RUN apt install -y ./nodejs.deb
RUN rm nodejs.deb
RUN rm -rf /var/lib/apt/lists/

COPY . /usr/src/app
WORKDIR /usr/src/app

RUN npm install
CMD ["node", "app.js"]
```

```yml:docker-compose.yml
# Elasticsearchのdocker-compose.ymlに追加
  express:
    build: ./api
    volumes:
      - ./api:/usr/src/app
    ports: 
      - 3000:3000
    networks:
      - esnet
    depends_on:
      - es01
```

```json:package.json
    "dependencies": {
        "express": "^4.17.2",
        "node-fetch": "^3.2.0"
    }
```

```javascript:./api/app.js
import express from 'express';
import fetch from 'node-fetch';

const app = express();

app.get('/api/mvt/:z/:x/:y', async (req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE');
    res.header(
        'Access-Control-Allow-Headers',
        'Content-Type, Authorization, access_token',
    );
    const { x, y, z } = req.params;

    const { search } = req.query;
    const phrases = search?.replace('　', ' ').split(' ') || [];
    const body = {
        query: {
            bool: {
                must: phrases
                    .map((phrase) => [{ match_phrase: { name: phrase } }])
                    .flat(),
            },
        },
    };
    const url = `http://es01:9200/poi/_mvt/geometry.coordinates/${z}/${x}/${y}`;
    const options = phrases
        ? { method: 'GET' }
        : {
              method: 'POST',
              headers: {
                  Accept: 'application/json',
                  'Content-Type': 'application/json',
              },
              body: JSON.stringify(body),
          };
    const data = await fetch(url, options);

    const b = await data.arrayBuffer();
    res.set({ 'Content-Disposition': `attachment; filename=${y}.pbf` });
    res.send(Buffer.from(b));
});

app.listen(3000);


```

これで、`http://localhost:3000/api/mvt/{z}/{x}/{y}?search=${query}`というエンドポイントでベクタータイルが配信されます。

## フロントエンドでの参考実装

```html:index.html
<html>
    <head>
        <title>elasticsearch</title>
        <script src="https://unpkg.com/maplibre-gl@2.1.1/dist/maplibre-gl.js"></script>
        <link
            href="https://unpkg.com/maplibre-gl@2.1.1/dist/maplibre-gl.css"
            rel="stylesheet"
        />
    </head>
    <body>
        <input type="text" id="query" name="query" placeholder="query" />
        <button type="button" id="search-btn">search</button>
        <div id="map" style="height: 100vh"></div>
        <script type="text/javascript" src="app.js"></script>
    </body>
</html>
```

```javascript:app.js
const makeStyle = (query = '') => {
    const esSourceUrl =
        query === ''
            ? 'http://localhost:3000/api/mvt/{z}/{x}/{y}'
            : `http://localhost:3000/api/mvt/{z}/{x}/{y}?search=${query}`;
    return {
        version: 8,
        sources: {
            osm: {
                type: 'raster',
                tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
                attribution: '<a href="http://osm.org/copyright">© OpenStreetMap contributors</a>',
            },
            elasticsearch: {
                type: 'vector',
                tiles: [esSourceUrl],
            },
        },
        layers: [
            {
                id: 'osm',
                type: 'raster',
                source: 'osm',
            },
            {
                id: 'result',
                type: 'circle',
                source: 'elasticsearch',
                'source-layer': 'hits',
                paint: {
                    'circle-color': '#f00',
                },
            },
        ],
    };
};

const map = new maplibregl.Map({
    container: 'map',
    style: makeStyle(),
    center: [140, 40],
    zoom: 9,
});

const search = () => {
    const textinput = document.querySelector('#query');
    const query = textinput.value;
    const style = makeStyle(query);
    map.setStyle(style);
};

const searchBtn = document.querySelector('#search-btn');
searchBtn.onclick = search;
```

クエリなし

![スクリーンショット 2022-02-04 1.36.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/781739bf-ad56-5e25-433c-d72dfd00df3d.png)

セイコーマートの分布（関東にもあるんですね）

![スクリーンショット 2022-02-04 1.36.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/75b766bc-bb95-2e04-e93a-f870445482fe.png)

デイリーヤマザキ（阿寒湖にあるらしい！）

![スクリーンショット 2022-02-04 1.38.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/e92a1fe7-79ac-ace0-e210-b36a38d95053.png)

### 補足

- 負荷対策などは一切考慮していませんが、検索レスポンスは高速
- 1タイルあたり地物数は1万で足切りされる模様
- ヒットした地物数が多いと当然タイルサイズが大きくなり（MB級）、ダウンロードにやや遅れが出るが、描画は速い
- 地物の属性は、idとインデックス名だけの相当簡素なもの

## 終わりに

今回はじめてElasticsearchを触りましたが、空間検索やGDALなど、GISデータ対応がとても良い印象を受けました（そしてドキュメントもとてもよく整備されている）。また、ベクタータイル配信もかなり実用的だと思います。