RDBデータをElasticsearchに投入して全文検索する

## はじめに

RDBで簡易的な検索機能を実装して使っていましたが、それだけでは柔軟な検索ができず
性能的にも問題が出てくるため、全文検索エンジンを導入することにしました。

この記事では、RDBに格納されているデータをElasticsearchに投入して全文検索できる
ようにするところまでを紹介したいと思います。

## 環境

* インフラは下記構成(全てAWSです)　※マネージドサービスは運用楽でいいですねー

| サービス名 | バージョン |
|:----------|:----------|
| Amazon RDS for PostgreSQL | 9.5.10 |
| Amazon Elasticsearch Service | 6.2 |
（以降、ElasticsearchはESと書きます。長いので^^;）

* ESはVPCアクセス（VPC内で動くモード）とし、VPC内からのアクセスは全て許可しています。
* PrivateDNSで下記CNAME設定しています（エンドポイント長いので）
    * RDS・・・ postgres.local
    * ES ・・・ ftsearch.local

ちなみに、この記事はPostgreSQLを前提にしていますが、データをJSONに変換できればいいので、
データソースはなんでもいいです。

## 全文検索要件

* 検索をかける側（アプリ）からREST APIによるドキュメントの登録／更新／削除／検索が可能
* 日本語全文検索に対応（形態素解析＋N-gram）
    * ただし、この記事では詳細な設定の説明はしません
    * 代わりにおすすめの記事紹介
        * [Hello! Elasticsearch](https://medium.com/hello-elasticsearch)　
            * 少し古いですが基礎的なとこから応用まで勉強になります
        * [Elasticsearchを日本語で使う設定のまとめ](https://qiita.com/shin_hayata/items/41c07923dbf58f13eec4)
            * kuromojiプラグイン／モジュールの説明がわかりやすく書かれています
* 運用中にアナライザ定義が変更可能
    * 運用していくと当然検索結果チューニング（アナライザ定義変更＆インデックス再構築）が  
      必要になるためエイリアスを使用する。（ドキュメント操作はエイリアスに対して行う）

## 手順

### 初回インデックス作成

#### DBからデータ抽出するSQL作成

インデックス化したいデータが入ったテーブルをカラム指定でSelectしてJson出力するSQLファイルを
作成します。
ここでは、groupsという名前のテーブルなので、groups.sqlとします。

```sql:groups.sql
SELECT array_to_json(array_agg(t))
  FROM (
    SELECT g.groupsno,
           g.groupname, g.mailaddress, g.city, g.address, g.telephone g.faxnumber,
      from groups g
  ) t;
```

#### PostgreSQLからJSON出力

psqlコマンドでSQLファイルを実行し、Json出力します。
その際、ESのBulk APIで投入できるような形式に加工したり、アクション行の追加もしています。

インデックス名は、テーブル名にバージョンを表す番号を付加したものとしました。
例）インデックス名=テーブル名＋バージョンNo
　　　　**groups-v01 = groups + v01**

```bash
INDEX=groups-v01
psql -h postgres.local -U appuser -d appdb -A -t -f groups.sql |\
sed -e 's/^\[//g' -e 's/\]$//g' -e 's/},{/}\n{/g' |\
while read -r LINE
do
    ID=$(echo $LINE | sed 's/.*groupsno":\([0-9]*\).*/\1/g')
    echo '{ "create" : { "_index": "'${INDEX}'", "_type": "_doc", "_id" : "'${ID}'" } }'
    echo ${LINE}
done > groups.json
```
※groupsnoの部分は適宜対象テーブルのプライマリキーに書き換えてください

また、他のデータソースからBulkインサート用のJSONファイルを作る場合は以下の
形式になるようにしてください。

```json:groups.json
{"create":{"_index":"groups-v01","_type":"_doc","_id":"12345"}}
{"groupsno":12345,"groupname":"ほげほげ協会","mailaddress":"12345@example.com","city":"千代田区","address":"1-2-345","telephone":"03-1234-5678","faxnumber":"03-1234-5678"}
〜略〜
```
* 上記２行で１レコード分としてレコード数分記載されたJSONファイルを準備してください。
    * 奇数行でアクションを指定（ここでは新規登録`create`、及び対象インデックス／タイプ／IDを指定）
        * _idにはプライマリキーの値（上記ではgroupsno:12345）と同じ値を指定
    * 偶数行で登録データを指定

#### マッピング定義作成

```bash
# インデックス名＝テーブル名＋バージョンNo
INDEX=groups-v01
curl -XPUT -H 'Content-Type: application/json' "http://ftsearch/${INDEX}/?pretty" -d '
{
  "settings": {
    "analysis": {
      "tokenizer":{
        "ja_tokenizer": {
          "type": "kuromoji_tokenizer",
          "mode": "search"
        },
        "ngram_tokenizer":{
          "type":"nGram",
          "min_gram":"2",
          "max_gram":"3",
          "token_chars":[
            "letter",
            "digit"
          ]
        }
      },
      "analyzer": {
        "ja_analyzer": {
          "type": "custom",
          "char_filter": [
            "icu_normalizer",
            "kuromoji_iteration_mark"
          ],
          "tokenizer": "ja_tokenizer",
          "filter": [
            "kuromoji_baseform",
            "kuromoji_part_of_speech",
            "ja_stop",
            "kuromoji_number",
            "kuromoji_stemmer"
          ]
        },
        "yomi_analyzer": {
          "type": "custom",
          "char_filter": [
            "icu_normalizer"
          ],
          "tokenizer": "ja_tokenizer",
          "filter": [
            "kuromoji_readingform",
            "kuromoji_number",
            "kuromoji_stemmer"
          ]
        },
        "ngram_analyzer": {
          "type":"custom",
          "tokenizer":"ngram_tokenizer"
        }
      }
    }
  },
  "mappings": {
    "_default_": {
      "_all": {
        "analyzer": "ja_analyzer"
      },
      "dynamic_templates": [
        {
          "special_string_fields": {
            "match": "*",
            "mapping": {
              "type": "text",
              "analyzer": "ja_analyzer",
              "fields": {
                "raw": {
                  "type": "keyword"
                },
                "ngram": {
                  "type": "text",
                  "analyzer": "ngram_analyzer"
                },
                "yomi": {
                  "type": "text",
                  "analyzer": "yomi_analyzer"
                }
              }
            }
          }
        }
      ]
    }
  }
}'
```

**この定義の意味を簡単に説明**

* settingsにアナライザ定義
    * 2~3文字でのN-Gram（ngram_analyzer）
    * kuromojiによる形態素解析（ja_analyzer）
    * 読み仮名の形態素解析定義（yomi_analyzer）
* mappingsで投入データの全フィールドに自動的にアナライズ処理を適用するよう定義
    * SQLで指定したカラム名をフィールド名として、ja_analyzerで形態素解析した結果を格納
    * {カラム名}.rawというフィールド名で、アナライズしない生の文字列を格納（完全一致検索用）
    * {カラム名}.ngramというフィールド名で、2~3gramで解析した結果を格納
    * {カラム名}.yomiというフィールド名で、形態素解析した結果の漢字の読み（カタカナ）を格納

#### データ投入

```bash
curl -XPOST -H'Content-Type: application/json' http://ftsearch/_bulk?pretty --data-binary @groups.json
```

#### エイリアス作成

```bash
# インデックスgroups-v01のエイリアスとして、RDSテーブル名と同じgroupsを指定する
curl -XPOST -H'Content-Type: application/json' http://ftsearch/_aliases?pretty -d '
{
  "actions": [
    {"add": {"index": "groups-v01", "alias": "groups"}}
  ]
}'
```
これでgroups(エイリアス)に対してインデックス／ドキュメント操作が可能となります。

### ドキュメント追加／更新／削除／検索

アプリから、下記のリクエストをESに投げる
#### ドキュメント追加／更新

リクエスト先のリソースID(12345の部分)とJSON内のgroupsnoは同じ値とする
そうしておくと、IDを指定して更新、削除ができるのでGood

```bash
curl -XPUT -H 'Content-Type: application/json' "http://ftsearch/groups/_doc/12345?pretty" -d '
{"groupsno":12345,"groupname":"ほげほげ協会","mailaddress":"12345@example.com","city":"千代田区","address":"1-2-345","telephone":"03-1234-5678","faxnumber":"03-1234-5678"}'
 => 200応答で正常
```

#### ドキュメント削除

```bash
curl -XDELETE -H 'Content-Type: application/json' "http://ftsearch/groups/_doc/12345?pretty"
 => 200応答で正常
```

#### ドキュメント検索

```bash
curl -XGET -H'Content-Type: application/json' http://ftsearch/groups/_search?pretty -d '
{
  "from":0, 
  "size":3,
  "query": {
    "query_string":{
      "analyze_wildcard": true,
      "default_operator": "OR",
      "fields":["group*^2","*"],
      "query":"8352"
    }
  }
}'
```

**検索パラメータ説明**

* fromでスコア降順の何件目から出力するか（Offset）
* sizeで何件出力するか（Limit）
* analyze_wildcardで クエリ文字列に ワイルドカードが指定可能になる
* fieldsで検索対象フィールドを指定できる
    * group*^2のようにフィールドに相対的重み付けをすることができます（相対値なので数字そのものに意味はない）
        * 検索チューニング（結果を意図に近づける）に使用
        * 例えば、ある数字で検索した時に電話番号を優先でヒットさせたい場合は telephone^3 をfields配列に追加する
* queryに検索したい文字列を指定する。スペース区切りでいくつでも指定可

### 再インデックス

#### [初回インデックス作成]手順のデータ投入まで実施
　**※必要に応じて、SQLやマッピング定義を修正して実行**

#### エイリアス切り替え

```bash
# エイリアス先のインデックス名を groups-v01からgroups-v02に切り替える場合
curl -XPOST -H'Content-Type: application/json' http://ftsearch/_aliases?pretty -d '
{
  "actions": [
    {"remove": {"index": "groups-v01", "alias": "groups"}},
    {"add": {"index": "groups-v02", "alias": "groups"}}
  ]
}'
```
これは内部的にアトミックな操作となるのでエイリアスアクセス中に切り替えても問題ありません。

#### 切り替え元インデックス削除

```bash
# 動作確認し、問題ないことが確認できればインデックス削除
curl -XDELETE -H'Content-Type: application/json' http://ftsearch/groups-v01/?pretty
```

### その他TIPS

#### インデックス一覧（サイズも）
```bash
curl -XGET -H'Content-Type: application/json' http://ftsearch/_cat/indices?v
==結果例==
health status index       uuid                   pri rep docs.count docs.deleted store.size pri.store.size
yellow open   groups-v02  qp5tPvW_TbSJBLfQ_gD82A   5   1      12839            0     22.5mb         22.5mb
yellow open   groups-v01  5tLiEFwKSXatyOznvQm8Cg   5   1          0            0      1.1kb          1.1kb
```

#### マッピング設定確認
```bash
curl -XGET -H 'Content-Type: application/json' "http://ftsearch/groups/_mapping?pretty"
```

## まとめ

RDSデータをESに投入して、簡単に全文検索できる状態にすることができました。
作ってみてやっぱり全文検索は、レスポンス速いし、いろんな条件で検索できるのでいいなぁ
と思いました（それ専用なので当たり前ですが ^^;）
あと、全てRESTで操作できるのも扱いやすくていいですね。

マッピング（アナライザ）定義の部分は、これから見直しながら、求める結果を返す検索システムに
育てていこうと思います。