触って学ぶElasticsearch

全文検索システムを搭載した検索エンジンである Elasticsearch を使用するにあたって抑えておくべきことをまとめました。
今回は簡単な仕組みを説明した後に、実際に Elastisearch を用いてデータ操作してみながら要点を解説します。

## この記事で解説すること

- Elasticsearch の基本的な用語
- Elasticsearch のCRUD処理
- Elasticsearch の基本的な検索クエリ
- Kibana の使い方

## この記事で解説しないこと

- Elasticsearch の内部での細かな処理（検索方法、インデックス、形態素解析）
- 最適なクラスタ構成、各種設定

## Elasticsearch とは

*Elasticsearch*とは検索処理に特化したオープンソースのソフトウェアです。
Javaで記述されており、同じくJavaで記述された検索エンジンライブラリである*Apache Lucene*がベースとなっています。
データを格納できるので、DBとして使用する方もいるようです。

ちなみに前身である*Compass*という検索ソフトウェアは、*Elasticsearch*の生みの親である Shay Banon 氏が妻の料理レシピを検索するために開発したものだそう。

## 基礎用語

**ドキュメント**
Elasticsearchにおけるデータの単位です。JSON形式で表現されます。
RDBにおけるレコードに相当します。

**フィールド**
ドキュメントのkeyとvalueの組み合わせのことです。

*ドキュメントの例*

```json 
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
} 
```

**インデックス**
ドキュメントを格納する領域です。
RDBにおけるテーブルに相当します。

*02/06追記*
> RDBにおけるデータベース（スキーマ）に相当します。

と記述していましたが、タイプが使えないならテーブルに相当するよなと思って編集しました。

**タイプ**
Elasticsearch 5.X までは1つのインデックスに複数のタイプを定義できたので、格納されたドキュメントの分類を示すRDBでいうテーブルに相当する概念でした。
>Elasticではバージョン5.0よりElasticsearchのタイプ廃止に向けて取り組んできました。そして7.0より、タイプは完全に廃止されます。

https://www.elastic.co/jp/blog/moving-from-types-to-typeless-apis-in-elasticsearch-7-0

**ノード**
Elasticsearchが動作するサーバです。

**クラスタ**
![スクリーンショット 2021-02-07 0.36.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637012/9968d2d9-f97f-b463-f38b-973fe725e277.png)

複数のノードによって構成される、ノードのグループです。

**シャード**
![スクリーンショット 2021-02-07 0.37.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637012/c7500cee-37a3-12d3-92a7-7eda5592e822.png)

時と場合によっては、インデックスを分割して保存することもあります。
その際の分割されたデータの単位を指します。
各インデックスに対するシャードの数はインデックス作成時に指定しておく必要があります。

## 特徴

**分散配置機能**
![cluster_om_r_shard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637012/111136b9-ddc0-070c-8ea4-ab9b100d77ef.png)


作成したデータはシャード単位で複数のノードに分散して配置されます。これによって検索が並行処理で行われ検索パフォーマンスが向上します。また各シャードに対してレプリカを作成することができ、データの可用性を高めることも可能です。

**REST API**
REAT APIによるアクセスが可能であり、リソースをURLで指定して操作内容をHTTPメソッドで指定してCRUD処理を行えます。ドキュメントの取得・更新、クラスタの管理などがAPIによって操作可能です。よって外部システムとの連携も取りやすいです。

**スキーマ定義不要**
作成するドキュメントのスキーマ定義をする必要がありません。新たにインデックスを作成する際に、データの値から自動的にスキーマを生成してくれます。また、明示的にスキーマを定義することも可能です。

## 実践

まずは実際にリクエストとレスポンスを確認していきます。
公式のAPIドキュメントを参考にします。
https://www.elastic.co/guide/en/elasticsearch/reference/current/rest-apis.html

### 環境

動作環境はElasticsearch Service 7.7 です。

ローカルで動作環境を作りたい場合はこちらの記事が参考になります。
[はじめての Elasticsearch](https://qiita.com/nskydiving/items/1c2dc4e0b9c98d164329#elasticsearch-%E3%81%A8%E3%81%AF)

なお今回はKibanaというElasticsearchで扱うデータを可視化するツールを利用して、動作を確認します。
ローカルでElasticsearchと一緒にKibanaを起動してください。

### Kibanaの使い方

KibanaにアクセスできたらダッシュボードのStack Managementを選択しindex patternを作成します。*を用いて複数のindexを対象とするパターンを作成することもできます。
ここで作成できるindexはすでにElasticsearch内に存在するindexのみです。

ダッシュボードのDiscoverを選択すると作成したindex paternの一覧が表示された画面に遷移します。
インデックス内のデータやフィールドを確認するのに便利です。
![スクリーンショット 2020-12-27 20.09.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637012/305dbb64-fbef-4fe8-de06-25898986827a.png)

ダッシュボードのDev Toolsを選択するとAPI操作をコンソール上で行える画面に遷移できます。
補完も効いてリクエストヘッダの設定等も不要なの非常に便利です。
![スクリーンショット 2020-12-27 20.12.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637012/acbe62dc-88a9-6d43-7f57-ef343175fb72.png)

### Create

- Documentの新規作成はPUTもしくはPOSTを利用します。

#### PUTの場合

```
PUT /member/_doc/1
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
} 
```

```
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 0,
  "_primary_term" : 1
}

```

以下の形式でも結果は同じです。

```
PUT /member/_create/1
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}
```

#### POSTの場合

POSTを用いる場合はIDを指定しなくても良いです。指定がない場合は自動採番されます。

```
POST /member/_doc/
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
} 
```

```
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "4_1FfHcBun3pb7RqaiAK",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 2,
  "_primary_term" : 1
}

```

以下の形式でも結果は同じです

```
POST /member/_create/1
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}
```

### READ

```
GET /member/_doc/1
```

```
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "_seq_no" : 0,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "name" : "Beats",
    "age" : 26,
    "birthday" : "1994-10-29",
    "blog_url" : "https://beatsbeats.hatenablog.com/",
    "job" : [
      "monk",
      "developer"
    ],
    "favorite_song" : {
      "title" : "fav_song",
      "created_at" : "2000-01-01"
    }
  }
}
```

こっちの形式を使えばインデックスの名前などのメタデータを除いた形で取得可能です。

```
GET /member/_source/1
```

```
{
  "name" : "Beats",
  "age" : 26,
  "birthday" : "1994-10-29",
  "blog_url" : "https://beatsbeats.hatenablog.com/",
  "job" : [
    "monk",
    "developer"
  ],
  "favorite_song" : {
    "title" : "fav_song",
    "created_at" : "2000-01-01"
  }
}

```

### UPDATE

フィールドを指定して更新する場合は以下の形式のリクエストを実行します。

```
POST /member/_update/1
{ 
  "doc": {
    "name": "Beeeats", 
    "age" : 27, 
    "birthday": "1995-10-29",
    "blog_url": "",
    "job": ["developer"], 
    "favorite_song": {"title":"fav_song","created_at":"2000-01-03"}
  }
}
```

```
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 2,
  "result" : "updated",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 1,
  "_primary_term" : 1
}
```

Document全体を新たなDocumentに更新する場合は以下の形式のリクエストを実行します。

```
PUT /member/_doc/1
{ 
  "name": "Beats", 
  "age" : 27, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}
```

```
POST /member/_doc/1
{ 
  "name": "Beats", 
  "age" : 27, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}
```

### DELETE

```
DELETE /member/_doc/1
```

```
{
  "_index" : "membe",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 3,
  "result" : "deleted",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 2,
  "_primary_term" : 1
}
```

### 検索(GET or POST)

検索処理にはGETメソッドもしくはPOSTメソッドを使用して、Bodyにクエリを埋め込みます。
検索用のクエリも他のデータ操作時と同じようにJSON形式で記述します。
検索用のクエリのフォーマットを**クエリDSL**と呼びます。
URLの末尾には`_search`をつける必要があり、URLのパスパラメータにより検索対象範囲を指定します。
今回は`member`というインデックスに対して検索をかけたいので、パスは`member/_doc/_search`となります。
全てのインデックスに対して検索をかけたい場合はインデックスやタイプはパスに含めず、`末に/_search`のみを付けます。
またクエリDSLには複数種類のクエリが存在するのですが、今回は`match_all`,`match`,`term/terms`,`from/size`,`sort`,`range`,`bool`を紹介します。
結果が見やすいように事前に以下二件のドキュメントを作成しておきます。

```
POST /post/_doc
{
    "title": "this is a pen",
    "body": "this pen is cool",
    "created_at": "2020-10-29",
}

POST /post/_doc
{
    "title": "that is a pen",
    "body": "that pen is cute"
    "created_at": "2021-11-24",
}
```

#### match_all
全件取得するクエリです。

**Request**

```
POST /post/_doc/_search
{
    "query": {
        "match_all": {}
    }
}
```

**Response**

```
{
  "took" : 10,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 3,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "o3XwfHcBFY01GkKP0sAj",
        "_score" : 1.0,
        "_source" : {
          "title" : "this is a pen",
          "body" : "this pen is cool",
          "created_at" : "2020-10-29"
        }
      },
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "pXXwfHcBFY01GkKP3MDy",
        "_score" : 1.0,
        "_source" : {
          "title" : "that is a pen",
          "body" : "that pen is cute",
          "created_at" : "2021-11-24"
        }
      },
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "LnX2fHcBFY01GkKPgsG5",
        "_score" : 1.0,
        "_source" : {
          "query" : {
            "match_all" : {
              "field" : ""
            }
          }
        }
      }
    ]
  }
}

```


hitsフィールド内が検索結果のメタデータであり、続いてヒットしたデータが並んでいます。
hits.total.valueはヒットした件数を示し、hits.hits._scoreはクエリ条件とデータの一致度を数値化したものです。

### match

特定のフィールドを対象として、キーワード検索を行うクエリです。
またキーワードを空白文字で区切ることで複数キーワードを用いた検索が可能です。(OR条件)

**Request**

```
POST /post/_doc/_search/
{
    "query": {
        "match": {
          "title": "this pen"
        }
    }
}
```
**Response**

```
{
  "took" : 65,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 0.8754687,
    "hits" : [
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "o3XwfHcBFY01GkKP0sAj",
        "_score" : 0.8754687,
        "_source" : {
          "title" : "this is a pen",
          "body" : "this pen is cool",
          "created_at" : "2020-10-29"
        }
      },
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "pXXwfHcBFY01GkKP3MDy",
        "_score" : 0.18232156,
        "_source" : {
          "title" : "that is a pen",
          "body" : "that pen is cute",
          "created_at" : "2021-11-24"
        }
      }
    ]
  }
}

```


`this` OR `pen`で検索したことと同義なのでもちろん二件ともヒットします。
なお、検索結果はデフォルトではスコア順でソートされています。

ORだけでなくANDに変更することも可能です。

**Request**

```
POST /post/_doc/_search/
{
    "query": {
        "match": {
          "title": {
            "query": "this pen",
            "operator": "and" 
          }
        }
    }
}
```

**Response**

```
{
  "took" : 15,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.8754687,
    "hits" : [
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "o3XwfHcBFY01GkKP0sAj",
        "_score" : 0.8754687,
        "_source" : {
          "title" : "this is a pen",
          "body" : "this pen is cool",
          "created_at" : "2020-10-29"
        }
      }
    ]
  }
}

```

titleに`this`も`pen`も含む片方のドキュメントのみ取得できました。

しかしmatchで検索した場合、指定した文字列を解析して検索が実行されてしまいます。
*野球*を検索ワードに指定した場合、*野*と*球*に分割して検索が実行されるので*卓球*や*野鳥*もヒットします。
検索ワードの解析を行わずに検索を実行したい場合は、`term/terms`を使用します。

### range

範囲を指定して検索を実行するクエリです。
値の範囲指定には以下のオペレータを用います。

- get(~以上)
- lte(~以下)
- gt(より大きい)
- lt(より小さい)

**Request**

```
POST /post/_doc/_search
{
    "query": {
        "range": {
            "created_at": {
              "gte": "2020-10-01",
              "lte": "2020-11-01"
            }
        }
    }
}
```

**Response**

```
{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "o3XwfHcBFY01GkKP0sAj",
        "_score" : 1.0,
        "_source" : {
          "title" : "this is a pen",
          "body" : "this pen is cool",
          "created_at" : "2020-10-29"
        }
      }
    ]
  }
}
```

### bool
基本クエリを組み合わせたクエリです。
複数条件を組み合わせた細かな検索をしたいときに用いる、少し複雑なクエリです。
boolクエリの中でさらに4つのクエリが用いられます。

```
{
    "query": {
        "bool": {
            "must": {},
            "should": {},
            "must_not": {},
            "fileter": {}
        }
    }
}
```

#### must
基本クエリのAND条件となるクエリです。

#### should
基本クエリのOR条件となるクエリです。

#### must_not
基本クエリのNOT条件となるクエリです。

#### filter
基本クエリのAND条件となるクエリですがスコアの加算がされません。
つまり、スコアの取得が必要なければmustの代わりにこちらを使用しても良いということです。
完全一致検索時もスコアは必要ないのでfilterの使用がベストです。

これら4つを組み合わせることでより細かな検索もできます。
titleに`is`と`pen`を含み、bodyに"cute"を含まないドキュメントを絞り込んでみます。

**Request**

```
POST /post/_doc/_search/
{
    "query": {
        "bool": {
            "must": [
              {"match": {"title": "is"}},
              {"match": {"title": "pen"}}
            ],
            "must_not": [
              {"match": {"body": "cute"}}
            ]
        }
    }
}
```

**Response**

```
{
  "took" : 15,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.36464313,
    "hits" : [
      {
        "_index" : "post",
        "_type" : "_doc",
        "_id" : "o3XwfHcBFY01GkKP0sAj",
        "_score" : 0.36464313,
        "_source" : {
          "title" : "this is a pen",
          "body" : "this pen is cool",
          "created_at" : "2020-10-29"
        }
      }
    ]
  }
}

```


titleに`is`も`pen`も含み、bodyに"cute"を含まない片方のドキュメントのみ取得できました。

### おまけ

サンプルなどの不要なインデックスを掃除しましょう。

```
DELETE /post/
```

簡単ですね。

# 参考

[Elasticsearch実践ガイド](https://book.impress.co.jp/books/1117101041)
[はじめての Elasticsearch](https://qiita.com/nskydiving/items/1c2dc4e0b9c98d164329#elasticsearch-%E3%81%A8%E3%81%AF)
[【基礎編】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)
[初心者のためのElasticsearchその2 -いろいろな検索-](https://dev.classmethod.jp/articles/es-02/)
[Elasticsearchの確認系コマンド６つ](https://blog.kozakana.net/2019/10/elasticsearch-confirmation-commands/)