PHP から ElasticSearch で日本語検索をするためのインデックス作成と設定

この記事は ElasticSearch で日本語検索をするためのインデックス作成と設定です。

# 環境
* Elasticsearch はローカル開発環境については Laradock
* Dev環境と本番環境については AWS Elasticsearch Service を使います
* Elasticsearch のバージョンは 6.7とし、これは2019/8/2時点での AWS Elasticsearch Service の最新版です
* elasticsearch-php のバージョンは 7.1
* PHP のバージョンは 7.2　（これも現時点での ElasticBeanstalk の対応最新版です）

# プラグインなど環境整備

#### local開発環境の Laradock においては DockerFile に入れるプラグインを書いておきます

```
$ cat elasticsearch/Dockerfile
// ここのバージョンを書き換えてます
FROM docker.elastic.co/elasticsearch/elasticsearch:6.7.0

// 以下のプラグインを2つ入れます。日本語解析のためのものです。
RUN elasticsearch-plugin install analysis-kuromoji
RUN elasticsearch-plugin install analysis-icu

// 以下変更なし
EXPOSE 9200 9300
```

kibana も elasticsearch も Laradock に含まれているので引数指定すれば入ります。

```
$ docker-compose up -d nginx mysql workspace elasticsearch kibana
```

#### AWS ElasticSearch Service にはプラグインなどだいたい入ってそう
なのでコンソールでぽちぽちしてつながれば特にやることなし。ここについてはまた別の一つの記事になる感じのところなんでここでは書かない。

# elasticsearch-php について
### インストール
PHP から Elasticsearch に繋げるためのはこれを使います。
ref: https://github.com/elastic/elasticsearch-php

インストール方法などはREADMEがわかりやすいので略。

### 使い方資料
使い方のドキュメントが、TOPのREADMEだけではなくて [docsが別途あって](https://github.com/elastic/elasticsearch-php/tree/master/docs) ここにもたくさん資料あります。
また、Elasticsearch の方の本体のドキュメントにも見やすく書かれているので、そっちを見てみるのも良いかと思います。
https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/quickstart.html


# Index作成とSetting
Index作成とSettingをセットで行います。

```php

        // typeをtextにしたいリスト。analyzerを指定するときに同時にtypeが必須だったのでここにtextのものだけリストアップしている。
        $types = [
            'hoge' => 'text',
            'fuga' => 'text',
        ];
        $mappingProperties = [];
        foreach ($types as $key => $type) {
            $mappingProperties[$key] = [
                'search_analyzer' => 'my_ja_analyzer',
                'analyzer' => 'my_ja_analyzer',
                'type' => $type
            ];
        }

        // indexの作られていない初回のみ作成とsettingを投げ込む
        // settingは初回だけなので別の管理にする案もあったが、Index内容と密接に関係にしているのでここに一緒に書いておきたい
        $indexCheckResponse = $this->getClient()->indices()->get([
            'index' => 'your_index_name',
            'client' => ['ignore' => 404]
        ]);
        if (isset($indexCheckResponse['status']) && $indexCheckResponse['status'] == '404') {
            $this->getClient()->indices()->create([
                'index' => 'your_index_name',
                'body' => [
                    'settings' => [
                        "analysis" => [
                            "analyzer" => [
                                "my_ja_analyzer" => [
                                    "type" => "custom",
                                    "tokenizer" => "kuromoji_tokenizer",
                                    "char_filter" => [
                                        "icu_normalizer",
                                        "kuromoji_iteration_mark"
                                    ],
                                    "filter" => [
                                        "kuromoji_baseform",
                                        "kuromoji_part_of_speech",
                                        "ja_stop",
                                        "kuromoji_stemmer"
                                    ]
                                ]
                            ]
                        ]
                    ],
                    'mappings' => [
                        // この type 指定はelasticsearch 7系からは削除されています。あんまり意味のないパラメータのように見えるので適当な値を入れています。
                        '_doc' => [
                            'properties' =>
                                $mappingProperties
                        ]
                    ]
                ]
            ]);
        }
```

# import のコード
`$documentBatch` はDBから引っこ抜いてきたデータの集合です。
例えば以下のような構造をしています。

```php

[
  'id' = 1,
  'fields' => [
     'hoge' => 'ここにテキスト1',
     'fuga' => 'ここにテキスト2'
  ]
]
```

これを bulk import のエンドポイントを使って一気に入れます。

```php
        foreach ($documentBatch as $document) {
            $params['body'][] = [
                'index' => [
                    '_index' => 'your_index_name',
                    // この type 指定はelasticsearch 7系からは削除されています。あんまり意味のないパラメータのように見えるので適当な値を入れています。
                    '_type' => '_doc',
                    '_id' => $document['id']
                ]
            ];

            $params['body'][] = $document['fields'];
        }
        $this->getClient()->bulk($params);
```

# 単語検索のコード

```php
    public function search(string $words)
    {
        // ここについてはコード後述
        $queryString = $this->getWordQueryString($words);

        $searchResults = $this->getClient()->search([
            'index' => 'your_index_name',
            'from' => 0,
            // 設定可能上限値なので実質無制限扱い
            'size' => 10000,
            'body' => [
                'query' => [
                    'query_string' => [
                        // prefixで検索するためにワイルドカードを許可
                        'analyze_wildcard' => true,
                        // この設定は tokenize された後の文字断片の間の関係についてにも使われているっぽい（推測）
                        // tokenizer により意図しない分割をされた単語においては"OR"検索をされると無関係な場所までマッチしてしまうことになるので、デフォルトはANDにしておいたほうが良い。例えば（架空の会社名） "日伸" は "日"と"伸"になるが、これがORだと意図しない"日"へのマッチが紛れ込んでしまう
                        // ここで文字列として作成する queryString の中においては AND と OR は明示的に設定されるので影響しない
                        'default_operator' => "AND",
                        'query' => $queryString
                    ]
                ],
            ]
        ]);

        $idList = [];
        foreach ($searchResults['hits']['hits'] as $result) {
            $idList[] = $result['_id'];
        }

        return $idList;
    }
```

```php

    private function getWordQueryString(string $words): string
    {
        // 全角スペースを半角スペースに変換してexplodeでの分割がうまくいくようにしている
        // 使うためには composer で　ext-mbstring をインストールしておいてください
        $spaceConvertedWords = mb_convert_kana($words, 's');
        $splittedWords = explode(' ', $spaceConvertedWords);

        $queries = array_map(function ($splittedWords) {
            $trimmedWord = $this->trimEscapedChar($splittedWords);
            return "${trimmedWord}*";
        }, $splittedWords);
        $queryString = implode(' AND ', $queries);

        return $queryString;
    }

```
```php
    private function trimEscapedChar($word)
    {
        return str_replace(['\\', "'"], '', $word);
    }
```

# 参考にしたQiita
kuromoji解析周りは大体こちらにお世話になってます
https://qiita.com/shin_hayata/items/41c07923dbf58f13eec4