Elasticsearchのおれ的Q&A【基本編】

## はじめに
Elasticsearchをキャッチアップするにあたり、早めに理解したかった基本部分をまとめました。
最後にPythonクライアントによるいくつかの操作を記載しています。
Elasticsearchのインストール方法は？プラグインって？というところには触れないのでそちらは他の方の記事をご参照ください。

## Elasticsearchってつまりどうなってんの？
イメージはこんな感じです。あくまでざっくりなものです。
<img width="700" alt="es-1.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365169/6df62855-1a7b-cb7d-d648-f31a4b35199d.jpeg">

## つまり何が大事なの？
検索の挙動には下記が大きく影響します。

- そもそもIndexに何を登録するか
- Searchにどんな文字列を受け取るか
- Analyzer(Index, Search)の処理に何を使うか
- Queryのスコア計算をどうするか

つまりインデックスに登録する単語とクエリで投げる単語の分解を揃えて、どのカラムでのヒットをどのぐらい優先するかを適切に決めればいい感じになりそうです。

## マッピングって変更できるの？
できません。別のインデックスを新たに作成するしかありません。
人参をみじん切りにした後に「やっぱ輪切りにしたい〜」的なイメージです。新しい人参が必要です。
既存のインデックスにあるドキュメントをそのまま使いたいときはreindexというAPIを使うと移植できます。

## N-gramとかと完全一致って両立できるの？
`"type": "text"`を指定している場合、ドキュメントはN-gram（その他アナライザ）でトークン化されるので、原型を留めていません。
ゆえにそのままでは完全一致を処理できないので、下記のようにmappingsにfieldsを用意し、queryでそのfieldsを指定します。

```json
{
    "mappings": {
        "type_name": {
            "properties": {
                "column_name": {
                    "type": "text",
                    "fields": {
                        "field_name": {
                            "type": "keyword"
                        }
                    }
                }, ...
```

```json
{
    "query": {
        "bool": {
            "should": [
                { "match": { "column_name": "東京都" } },
                { "match": { "column_name.field_name": "東京都" } }
            ]
        }
    }
}
```

## boolとかshouldとかなに？

`bool` → 指定することで下記4つのクエリと組み合わせて複数の条件が設定可能になります。

`filter` → AND条件・スコアは計算されません。
`must` → AND条件・スコアが計算されます。
`should` → OR条件・スコアが計算されます。
`must_not` → NOT条件

## function_scoreってなに？
こちらもあくまでイメージですが、こんな感じです。
queryに設定した条件にマッチすればするほどスコアが増加（演算方法はboostで指定）し、functionsに設定したものにマッチすればするほどこちらもスコアが増加（演算方法はscore_modeで指定）、queryのスコアとfunctionsのスコアが計算（演算方法はboost_modeで指定）され、最終的なスコアになります。

<img width="700" alt="es-2.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/365169/1c288a9c-6cc2-0a22-1a17-db8be363a5f3.jpeg">

## script_scoreってなに？どう書くの？
オリジナルな計算を行いたいときに便利なのがscript_scoreです。
セキュリティの観点から独自開発された`painless`で書きます。
`functions`のなかに下記のように定義します。（あくまで一例です）

```json
"functions": [
    {
        "script_score": {
            "script": {
                "lang": "painless",
                "params": { "param1": "value1" },
                "source": """
                    def weight = doc['columnA'].value;
                    if (params.param1 ==~ /Tokyo/) {
                        weight * 10;
                    } else { 
                        weight * 5;
                    }
                """
            }
        }
    }
],
```


## 検索結果って何個返ってくるの？
デフォルトで10個です。最大10000個まで取得できるようです。

```json
{
    "query": {...},
    "size": 10
}
```

## 濁点ってどうやって消すの？
後述するicu_foldingで対応できます。

## 名詞とか動詞だけ取り出せるの？
後述するkuromoji_part_of_speechで対応できます。

## アナライザーってなに？
Analyzerはドキュメントをトークン化してくれるものです。
1個のtokenizerと0個以上のchar_filter, filterで構成されていて、このAnalyzerを適切に設定することで下記のように検索の能力を向上させることができます。

- char_filter → 入力文字列全体の正規化
- tokenizer → 正規化された文字列をトークン化
- (token_)filter → トークン化された配列の要素を正規化

```text
'東京都' → char_filter → tokenizer → filter → ['とう', 'きょう', 'と']
```

```text
「東京都」を取得したいときに有効な検索ワード

Before: 「東京都」のみ
After : 「とう」「とうきょう」「東」「東京」
```

また、IndexのAnalyzerとSearchのAnalyzerを別々に設定できて、Searchを設定しない場合はIndexのAnalyzerが適用されます。

```json
{
    "mappings": {
        "type_name": {
            "properties": {
                "column_name": {
                    "type": "text",
                    "analyzer": "analyzer_name",
                    "search_analyzer": "analyzer_name"
                }, ...
```

## アナライザーで使える処理ってなにがあるの？
アルファベット順にいくつか紹介します。

### cjk_width
`使用可: filter`
半角を全角に統一します。数字は全角が半角に統一されます。

```text
'ｱｲｽｺｰﾋｰください' → ['アイスコーヒーください']
```
```json
{
    "filter": [
        "cjk_width"
    ]
}
```

### edge_ngram
`使用可: tokenizer, filter`
キーワードを先頭から指定した文字数で分割します。

```text
'アイスコーヒーください' → ['アイ', 'アイス']
```
```json
{
    "tokenizer": {
        "type": "keyword"
    },
    "filter": [
        {
            "type": "edge_ngram",
            "token_chars": [
                "letter",
                "digit"
            ],
            "min_gram": "2",
            "max_gram": "3"
        }
    ]
}
```

### icu_folding
`使用可: filter`
文字列を正規化します。伸ばし棒「ー」や濁点「゛」、半濁点「゜」を取り除いてくれます。
その他にもラテン文字をアルファベットに正規化できたりします。

```text
'アイスコーヒーください' → ['アイスコヒくたさい']
```
```json
{
    "filter": [
        "icu_folding"
    ]
}
```

### icu_normalizer
`使用可: char_filter, filter`
文字列を正規化します。ngramと併用するとわかりやすく、濁点半濁点を1文字として扱ってくれます。

```text
'ください' → ['くた', 'くだ', 'だ', 'ださ', '゛さ', '゛さい', 'さい']
```
```json
{
    "char_filter": [
        {
            "type": "icu_normalizer",
            "name": "nfc",
            "mode": "decompose"
        }
    ],
    "tokenizer": {
        "type": "ngram",
        "min_gram": "2",
        "max_gram": "3"
    }
}
```

### icu_transform
`使用可: filter`
文字列を指定した表記に変換します。

```text
'アイスコーヒーください' → ['あいすこおひいください']
```
```json
{
    "filter": [
        {
            "type": "icu_transform",
            "id": "Katakana-Hiragana"
        }
    ]
}
```

### ja_stop
`使用可: filter`
指定した単語を削除します。

```text
'私はあのコーヒーにします' → ['私', 'コーヒー']
```
```json
{
    "tokenizer": {
        "type": "kuromoji_tokenizer"
    },
    "filter": [
        {
            "type": "ja_stop",
            "stopwords": [
              "_japanese_",
              "あの"
            ]
        }
    ]
}
```

### kuromoji_baseform
`使用可: filter`
動詞や形容詞を原型に変換します。

```text
'東京に行こ' → ['東京', 'に', '行く']
```
```json
{
    "tokenizer": {
        "type": "kuromoji_tokenizer"
    },
    "filter": [
        "kuromoji_baseform"
    ]
}
```

### kuromoji_iteration_mark
`使用可: char_filter`
踊り字（繰り返し表現）を正規化します。

```text
'東京に行こーゝ' → ['東京に行こーー']
```
```json
{
    "char_filter": [
        "kuromoji_iteration_mark"
    ]
}
```

### kuromoji_number
`使用可: filter`
漢数字を数字に直します。

```text
'十日後' → ['10', '日', '後']
```
```json
{
    "tokenizer": {
        "type": "kuromoji_tokenizer"
    },
    "filter": [
        "kuromoji_number"
    ]
}
```

### kuromoji_part_of_speech
`使用可: filter`
指定した品詞を抽出します。下記は名詞を抽出する場合です。

```text
'東京へ行く' → ['東京']
```
```json
{
    "tokenizer": {
        "type": "kuromoji_tokenizer"
    },
    "filter": [
        {
            "type": "kuromoji_part_of_speech",
            "stoptags": [
                "名詞-代名詞",
                "名詞-代名詞-一般",
                "名詞-代名詞-縮約",
                "名詞-数",
                "名詞-非自立",
                "名詞-非自立-一般",
                "名詞-非自立-副詞可能",
                "名詞-非自立-助動詞語幹",
                "名詞-非自立-形容動詞語幹",
                "名詞-特殊-助動詞語幹",
                "名詞-接尾",
                "名詞-接尾-一般",
                "名詞-接尾-人名",
                "名詞-接尾-地域",
                "名詞-接尾-サ変接続",
                "名詞-接尾-助動詞語幹",
                "名詞-接尾-形容動詞語幹",
                "名詞-接尾-副詞可能",
                "名詞-接尾-助数詞",
                "名詞-接尾-特殊",
                "名詞-接続詞的",
                "名詞-動詞非自立的",
                "名詞-引用文字列",
                "名詞-ナイ形容詞語幹",
                "接頭詞",
                "接頭詞-名詞接続",
                "接頭詞-動詞接続",
                "接頭詞-形容詞接続",
                "接頭詞-数接続",
                "動詞",
                "動詞-自立",
                "動詞-非自立",
                "動詞-接尾",
                "形容詞",
                "形容詞-自立",
                "形容詞-非自立",
                "形容詞-接尾",
                "副詞",
                "副詞-一般",
                "副詞-助詞類接続",
                "連体詞",
                "接続詞",
                "助詞",
                "助詞-格助詞",
                "助詞-格助詞-一般",
                "助詞-格助詞-引用",
                "助詞-格助詞-連語",
                "助詞-接続助詞",
                "助詞-係助詞",
                "助詞-副助詞",
                "助詞-間投助詞",
                "助詞-並立助詞",
                "助詞-終助詞",
                "助詞-副助詞／並立助詞／終助詞",
                "助詞-連体化",
                "助詞-副詞化",
                "助詞-特殊",
                "助動詞",
                "感動詞",
                "記号",
                "記号-一般",
                "記号-読点",
                "記号-句点",
                "記号-空白",
                "記号-括弧開",
                "記号-括弧閉",
                "記号-アルファベット",
                "その他",
                "その他-間投",
                "フィラー",
                "非言語音",
                "語断片",
                "未知語"
            ]
        }
    ]
}
```

### kuromoji_readingform
`使用可: filter`
漢字をカタカナまたはローマ字読みに変換します。

```text
'東京へ行く' → ['トウキョウ', 'ヘ', 'イク']
```
```json
{
    "tokenizer": {
        "type": "kuromoji_tokenizer"
    },
    "filter": [
        {
            "type": "kuromoji_readingform",
            "use_romaji" : false
        }
    ]
}
```


### kuromoji_stemmer
`使用可: filter`
カタカナ単語の最後の長音を削除します。

```text
'アイスコーヒー' → ['アイスコーヒ']
```
```json
{
    "tokenizer": {
        "type": "keyword"
    },
    "filter": [
        "kuromoji_stemmer"
    ]
}
```


### kuromoji_tokenizer
`使用可: tokenizer`
漢字を含む文章をトークン化します。独自の辞書を設定することもできます。

```text
'東京へ行く' → ['東京', 'へ', '行く']
```
```json
{
    "tokenizer": {
        "type": "kuromoji_tokenizer"
    }
}
```


### ngram
`使用可: tokenizer, filter`
キーワードを指定した文字数で分割します。

```text
'とうきょうと' → ['とう', 'とうき', 'うき', 'うきょ', 'きょ', 'きょう', 'ょう', 'ょうと', 'うと']
```
```json
{
    "tokenizer": {
        "type": "keyword"
    },
    "filter": [
        {
            "type": "ngram",
            "token_chars": [
                "letter",
                "digit"
            ],
            "min_gram": "2",
            "max_gram": "3"
        }
    ]
}
```

## Pythonクライアントでの主要な操作は？
まずはインストールです。

```shell
$ pip install elasticsearch
```

個人的に主要だと思う操作はこちらです。
PEP8は順守していないのであらかじめご了承ください。

```python
from elasticsearch import Elasticsearch, helpers

# インスタンスを作成
es = Elasticsearch('http://localhost:9200')

# 既存のインデックスを取得
es.indices.get(index='*')

# インデックスのマッピングを取得
es.indices.get_mapping(index=index)

# 新たにインデックスを作成
es.indices.create(index=index, body=body)

# 再インデックスを行う
helpers.reindex(es, source_index=source_index, target_index=target_index)

# インデックスを削除する
es.indices.delete(index=index)

# アナライズする
es.indices.analyze(index=index, body=body)

# 検索する
es.search(index=index, body=body)
```

アナライズにjsonファイルを渡す場合は下記のように書きます。

```json
{
    "text": "解析したい文字",
    "char_filter": [
        "char_filter_name"
    ],
    "tokenizer": {
        "type": "tokenizer_name"
    },
    "filter": [
        "filter_name",
        {
            "type": "filter_name"
        }
    ]
}
```

またjsonファイルを読み込んでbodyに渡したい場合は下記です。

```python
import json

def parse_json(json_path):
    with open(json_path, 'r') as file:
        parsed = json.dumps(json.load(file)).encode('utf-8')
        return parsed

es.search(index=index, body=parse_json(json_path))
```

## 最後に
ご覧いただきありがとうございました。
内容は必要に応じて更新していきます。

## 参考
[Elasticsearch Reference](https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html)
[Python Elasticsearch Client](https://elasticsearch-py.readthedocs.io/en/master/)

また、こちらの記事を参考にさせていただきました。

[ElasticsearchのAnalyzer, Tokenizer, Token Filters, Char Filtersの一覧](https://qiita.com/wapa5pow/items/a11274d85a0359c423d9)
[pythonクライアントで始める「はじめてのElasticsearch」](https://qiita.com/ikawaha/items/c654f746cfe76b888a27)
[Elasticsearchで品詞分解① (テンプレート編) 【追記あり】](https://qiita.com/miz21358/items/c8973440f138919197b1)
[Elasticsearchでの文字列の正規化](https://christina04.hatenablog.com/entry/2016/08/03/123000)
[【基礎編】Elasticsearchの検索クエリを使いこなそう](https://medium.com/eureka-engineering/%E5%9F%BA%E7%A4%8E%E7%B7%A8-elasticsearch%E3%81%AE%E6%A4%9C%E7%B4%A2%E3%82%AF%E3%82%A8%E3%83%AA%E3%82%92%E4%BD%BF%E3%81%84%E3%81%93%E3%81%AA%E3%81%9D%E3%81%86-ace3e18c2174)