OpenSearchで日付・文字数条件を使ったデータ検索クエリの実装

# はじめに
本記事では、Docker上でOpenSearchを構築します。
OpenSearchのインデックスに登録したデータに対して、テキストフィールドの文字数を評価を条件に加えたクエリの実行します。

https://qiita.com/enumura1/items/5b7c57e216b0600ce1f5

# 環境
- Docker version 24.0.7

# ゴール
- 日付範囲による検索の実装
- 文字数による検索の実装（テキストフィールドの文字数評価）

# 作成するもの
日時データ、テキストデータの入ったOpenSearchのインデックス


# インデックスを作成
以下の3つのフィールドを持つインデックスを作成します。

- datetime：日付と時刻を表すフィールド
- title：タイトルを格納するテキストフィールド
- content：本文を格納するテキストフィールド（文字数評価用のkeywordサブフィールド付き）


```bash
curl -X PUT "https://localhost:9200/articles" \
  -H "Authorization: Basic $(echo -n "ユーザ:パスワード" | base64)" \
  -H "Content-Type: application/json" \
  --insecure \
  -d '{
    "mappings": {
      "properties": {
        "datetime": {
          "type": "date"
        },
        "title": {
          "type": "text"
        },
        "content": {
          "type": "text",
          "fields": {
            "keyword": {
              "type": "keyword"
            }
          }
        }
      }
    }
  }'
  ```

:::note info
- [textフィールド](https://opensearch.org/docs/latest/field-types/supported-field-types/text/)：文字列を分析し単語ごとに分割したものを格納
- [keywordフィールド](https://opensearch.org/docs/latest/field-types/supported-field-types/keyword/)：文字列を分割せずそのまま格納

よって文字数を評価するために、サブフィールドとして`keyword` を使用します。
```
"fields": {
    "keyword": {
      "type": "keyword"
    }
  }
```
このkeywordサブフィールドを活用し、`content.keyword.length()` による文字数の評価をクエリで行います。
:::

# テストデータの準備と投入

作成したインデックスに投入するデータを準備します。

```
{"index":{}}
{"datetime":"2024-11-02T10:00:00","title":"朝のミーティング","content":"今日のプロジェクトについて話し合いました。"}
{"index":{}}
{"datetime":"2024-11-02T12:30:00","title":"ランチタイム","content":"新しいカフェでランチを楽しみました。"}
{"index":{}}
{"datetime":"2024-11-02T14:00:00","title":"コーディング","content":"新機能の実装を進めています。"}
{"index":{}}
{"datetime":"2024-11-02T16:00:00","title":"レビュー会議","content":"コードレビューを実施しました。"}
{"index":{}}
{"datetime":"2024-11-02T18:00:00","title":"終業報告","content":"本日の作業をまとめています。"}
```

データを投入します。

```bash
curl -X POST "https://localhost:9200/articles/_bulk" \
-H "Authorization: Basic $(echo -n "ユーザ:パスワード" | base64)" \
-H "Content-Type: application/json" \
--insecure \
--data-binary "@data.json"
```

"result":"created" でデータの投入に成功しました。

```
{"took":5,"errors":false,"items":[{"index":{"_index":"articles","_id":"1xsG65IBExavxlFHS8pd","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1,"status":201}},{"index":{"_index":"articles","_id":"2BsG65IBExavxlFHS8pd","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1,"status":201}},{"index":{"_index":"articles","_id":"2RsG65IBExavxlFHS8pd","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":2,"_primary_term":1,"status":201}},{"index":{"_index":"articles","_id":"2hsG65IBExavxlFHS8pd","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":3,"_primary_term":1,"status":201}},{"index":{"_index":"articles","_id":"2xsG65IBExavxlFHS8pd","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":4,"_primary_term":1,"status":201}}]} 
```


# クエリを実行

2件ほどクエリを実行してみます。

# クエリ1：時間範囲と文字数制限の組み合わせ1
下記の2点の条件に一致するデータをインデックスから抽出します。
- `datetime`：2024/11/2の13:00～2024/11/3の00:00の範囲
- `content`：10文字以上、20文字以下

```bash
$ curl -X GET "https://localhost:9200/articles/_search" \
  -H "Authorization: Basic $(echo -n "ユーザ:パスワード" | base64)" \
  -H "Content-Type: application/json" \
  --insecure \
  -d '{
  "query": {
    "bool": {
      "must": [
        {
          "range": {
            "datetime": {
              "gte": "2024-11-02T13:00:00",
              "lt": "2024-11-03T00:00:00"
            }
          }
        }
      ],
      "filter": {
        "script": {
          "script": {
            "source": "doc['"'"'content.keyword'"'"'].value.length() >= 10 && doc['"'"'content.keyword'"'"'].value.length() <= 20",
            "lang": "painless"
          }
        }
      }
    }
  }
}'
```

3件ヒットします。

```bash
{"took":2,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":3,"relation":"eq"},"max_score":1.0,"hits":[{"_index":"articles","_id":"2RsG65IBExavxlFHS8pd","_score":1.0,"_source":{"datetime":"2024-11-02T14:00:00","title":"コーディング","content":"新機能の実装を進めています。"}},{"_index":"articles","_id":"2hsG65IBExavxlFHS8pd","_score":1.0,"_source":{"datetime":"2024-11-02T16:00:00","title":"レビュー会議","content":"コードレビューを実施しました。"}},{"_index":"articles","_id":"2xsG65IBExavxlFHS8pd","_score":1.0,"_source":{"datetime":"2024-11-02T18:00:00","title":"終業報告","content":" 本日の作業をまとめています。"}}]}}
```

両方の条件（13:00以降、10-20文字）を満たす3件のデータが取得できており想定通りの結果です。

| No| テキスト| 文字数 | 時間   | 結果                         |
|-----|------------------------------------|--------|--------|------------------------------|
| 1   | 今日のプロジェクトについて話し合いました。 | 21     | 10:00  | 対象外（文字数超過かつ時間範囲外） |
| 2   | 新しいカフェでランチを楽しみました。      | 18     | 12:30  | 対象外（時間範囲外）           |
| 3   | 新機能の実装を進めています。             | 14     | 14:00  | ✅ ヒット   |
| 4   | コードレビューを実施しました。           | 15     | 16:00  | ✅ ヒット         |
| 5   | 本日の作業をまとめています。            | 14     | 18:00  | ✅ ヒット         |


# クエリ2：時間範囲と文字数制限の組み合わせ2

下記の2点の条件に一致するデータをインデックスから抽出します。
- `datetime`：2024/11/2の00:00～2024/11/3の00:00の範囲
- `content`：16文字以上

```bash
$ curl -X GET "https://localhost:9200/articles/_search" \
  -H "Authorization: Basic $(echo -n "ユーザ:パスワード" | base64)" \
  -H "Content-Type: application/json" \
  --insecure \
  -d '{
  "query": {
    "bool": {
      "must": [
        {
          "range": {
            "datetime": {
              "gte": "2024-11-02T00:00:00",
              "lt": "2024-11-03T00:00:00"
            }
          }
        }
      ],
      "filter": {
        "script": {
          "script": {
            "source": "doc['"'"'content.keyword'"'"'].value.length() >= 16",
            "lang": "painless"
          }
        }
      }
    }
  }
}'
```

2件ヒットします。

```bash
{"took":5,"timed_out":false,"_shards":{"total":1,"successful":1,"skipped":0,"failed":0},"hits":{"total":{"value":2,"relation":"eq"},"max_score":1.0,"hits":[{"_index":"articles","_id":"1xsG65IBExavxlFHS8pd","_score":1.0,"_source":{"datetime":"2024-11-02T10:00:00","title":"朝のミーティング","content":"今日のプロジェクトについて話し合いました。"}},{"_index":"articles","_id":"2BsG65IBExavxlFHS8pd","_score":1.0,"_source":{"datetime":"2024-11-02T12:30:00","title":"ランチタイム","content":"新しいカフェでランチを楽しみました。"}}]}} 
```

インデックスに投入したデータは下記の通りなので想定通りの結果です。

| No | テキスト                           | 文字数 | 時間   | 結果                               |
|----|-----------------------------------|--------|--------|------------------------------------|
| 1  | 今日のプロジェクトについて話し合いました。 | 21     | 10:00  | ✅ ヒット  |
| 2  | 新しいカフェでランチを楽しみました。      | 18     | 12:30  | ✅ ヒット            |
| 3  | 新機能の実装を進めています。            | 14     | 14:00  | 文字数対象外                  |
| 4  | コードレビューを実施しました。          | 15     | 16:00  | 文字数対象外                          |
| 5  | 本日の作業をまとめています。           | 14     | 18:00  | 文字数対象外                   |


# 片づけ

インデックスを削除します。

```bash
$ curl -X DELETE "https://localhost:9200/articles" \
  -H "Authorization: Basic $(echo -n "admin:vu1ioJn[;PT31031" | base64)" \
  --insecure
```

```bash
{"acknowledged":true}
```

## コンテナの停止と削除

起動していたdockerコンテナを停止します。

```bash
docker stop opensearch-node
```

起動中のコンテナが無くなったか確認します。

```bash
docker ps
```

起動中のコンテナは無くなりました。

```
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
```

コンテナを削除します。

```
docker rm CONTAINER ID
```

コンテナが完全に削除されたことを確認できます。

```
docker ps -a
```

# Dockerイメージの削除

Dockerイメージを確認します。

```
$ docker images
REPOSITORY                     TAG       IMAGE ID       CREATED        SIZE
opensearchproject/opensearch   latest    XXXXX   2 weeks ago    1.34GB
```

OpenSearchイメージを削除します。

```
docker rmi opensearchproject/opensearch:latest
```

# 参考
- [OpenSearch Docker Image](https://hub.docker.com/r/opensearchproject/opensearch)
- [OpenSearch Range Query](https://opensearch.org/docs/latest/query-dsl/term/range/)
- [OpenSearch Bulk API](https://opensearch.org/docs/latest/api-reference/document-apis/bulk/)
- [Mappings and field types](https://opensearch.org/docs/latest/field-types/)
- [Execute Painless script](https://opensearch.org/docs/latest/api-reference/script-apis/exec-script/)
- [Running containers](https://docs.docker.com/engine/containers/run/)
- [docker container stop](https://docs.docker.com/reference/cli/docker/container/stop/)
- [docker container rm](https://docs.docker.com/reference/cli/docker/container/rm/)
- [docker image rm]()
- https://qiita.com/enumura1/items/5b7c57e216b0600ce1f5