シンプルな全文検索エンジンMeiliSearchとはどんなもんかを試してみる

2021-11-28

なにこれ

MeiliSearchを使ってみるメモ。

現時点で知っているMeiliSearchの情報は以下の通り。

  • Rust製
    • 早そうだし、GCのstop the worldが無いの良い
  • 日本語対応している
    • しかも面倒な設定はしなくとも良いらしい
  • シンプル
    • Elasticsearchと比較してかなりシンプルらしい(触ってみた人談)
  • Dockerイメージ配布してる
    • ローカルで検証、開発しやすい
  • SaaS提供はしていない
    • 2021年11月現在ではクラウドホスト版のクローズドβを行っている状況

詳しくは公式ページでMeiliSearch

dockerで起動する

公式のinstallページ

がっつり書いています。

これをそのまま行えばMeiliSearchコンテナが立ち上がるのですが、データ永続化を行いたいし、Docker Composeで立ち上げたいので以下のファイルを用意します。

version: "3.7"
services:
  meilisearch:
    container_name: meilisearch
    image: getmeili/meilisearch:v0.24.0
    volumes:
      - example-meili-data:/data.ms
    environment: []
    ports:
      - 7700:7700
volumes:
  example-meili-data:
    driver: local

環境変数とかデータボリュームをどこに乗せるのかなどについてはyaginceさんの記事を参考にしました。 https://zenn.dev/yagince/articles/037746ab3ebfd1

そしてファイルができたらいつものようにdocker-compose upを行います。これでコンテナが立ち上がります。
http://localhost:7700にアクセスするとMini Dashboardという管理画面っぽいものが表示されます。
いくつかリンクをクリックするとなにを行えば良いかわかるように公式ページに飛んでくれます。

mini dashboard

ここでドキュメントを見て気になったのですが、v1.0までは各バージョンでの互換性は無いようです。
https://docs.meilisearch.com/learn/getting_started/installation.html#updating-meilisearch
ただアップデートガイドは存在するので、これを見ながら対応することがあるかもしれませんね。

ざっくりとAPIを眺めてみる

APIドキュメントを見てみます。
https://docs.meilisearch.com/reference/api/

リクエスト形式

REST APIの構成になっているようです。

APIにリクエストした内容は非同期に処理される構成をとっているので、沢山リクエストを行っても受け付けてくれて実際の処理はキューに突っ込んでくれるとのことです。

また、リクエストにはJSON, NDJSON, CSV形式のいづれかで行えるようです。レスポンスは常にJSONです。
NDJSONって何者でしょうか。

NDJSONは改行区切りJSONとのことで、newline delimited JSONを略しています。

配列の終了を待たずに1行づつ処理できるので順次処理をする場合に有用であるようです。

http://ndjson.org/ https://qiita.com/suin/items/246691382ea2a2b22031

APIを叩いてみる

叩いてみましょう。試してみないとなんとも言えないですしね。

index作成

indexを作成してみます。

indexはdocumentを最初に追加されたときか、index作成エンドポイントを叩いた時に作成されるとのことです。
ここではindex作成のエンドポイントを叩いてみます。

indexリソースについてのドキュメントはこちら。
https://docs.meilisearch.com/reference/api/indexes.html

index作成についてのドキュメントはこちら。
https://docs.meilisearch.com/reference/api/indexes.html#create-an-index

➜  ~ curl \
  -X POST 'http://localhost:7700/indexes' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "uid": "users",
    "primaryKey": "id"
  }' | jq

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   186  100   138  100    48   5307   1846 --:--:-- --:--:-- --:--:--  7153
{
  "uid": "users",
  "name": "users",
  "createdAt": "2021-11-28T09:18:54.587129500Z",
  "updatedAt": "2021-11-28T09:18:54.588908300Z",
  "primaryKey": "id"
}

成功しました。
リクエストボディについては以下の通りです。

  • uid
    • インデックス名。全体で一意になるようにセットする必要がある。
  • primaryKey
    • ドキュメントを一意に識別する主キー。設定しない場合はMeiliSearchが推論するらしい。

諸々ドキュメントを漁ったのですが、ElasticsearchのMappingのような記述は見当たりませんでした。
なので単純にindexを作成すればドキュメントを登録する準備ができるのか。
良し悪しは使っていくうちに見えてくると思いますが、シンプルなのは良いですね。

document登録

いくつかdocumentを登録してみます。

documentリソースのドキュメントはこちら。 https://docs.meilisearch.com/reference/api/documents.html

登録のドキュメントはこちら。 https://docs.meilisearch.com/reference/api/documents.html#add-or-replace-documents

POSTは追加or置換とのことです。これはドキュメントを完全に上書きします。
PUTは追加or更新です。こっちは部分的にドキュメントを更新します。
普通のRESTですね。

早速JSONで思いつく型で元々登録してみましょう。

まずはPOSTから。

➜  ~ curl \
  -X POST 'http://localhost:7700/indexes/users/documents' \
  -H 'Content-Type: application/json' \
  --data-binary '[{
    "id": 1,
    "name": "鈴木 一郎",
    "name_kana": "スズキ イチロウ",
    "job_change_count": 2,
    "address": {
      "id": 1,
      "label": "東京都"
    },
    "email": [
      "test+1@example.com",
      "test+2@example.com"
    ],
    "experience_jobs": [
      {
        "id": 100,
        "label": "営業(toB)"
      },
      {
        "id": 200,
        "label": "経営企画"
      }
    ]
  }]' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   445  100    14  100   431   1750  53875 --:--:-- --:--:-- --:--:-- 55625
{
  "updateId": 0
}

登録できました。Mini Dashboardを見てみるとこんな感じ。
document mini dashborad

次はPUT

➜  ~ curl \
  -X PUT 'http://localhost:7700/indexes/users/documents' \
  -H 'Content-Type: application/json' \
  --data-binary '[
    {
      "id": 2,
      "name": "骨川 スネ夫",
      "name_kana": "ホネカワ スネヲ",
      "job_change_count": 0,
      "address": {
        "id": 1,
        "label": "東京都"
      },
      "email": [
        "test+3@example.com"
      ],
      "experience_jobs": [
        {
          "id": 999,
          "label": "職歴なし"
        }
      ]
    },
    {
      "id": 3,
      "name": "野原 ひろし",
      "name_kana": "ノハラ ヒロシ",
      "job_change_count": 0,
      "address": {
        "id": 2,
        "label": "埼玉県"
      },
      "email": [
        "test+4@example.com"
      ],
      "experience_jobs": [
        {
          "id": 100,
          "label": "営業(toB)"
        }
      ]
    }
  ]' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   768  100    14  100   754   1555  83777 --:--:-- --:--:-- --:--:-- 96000
{
  "updateId": 1
}

updateIdが1つしか返ってきていませんが、2つのオブジェクトが登録されていました。
恐らく返ってきているのはジョブのidかな?
まあ、無事に登録できました🎉

searchしてみる

ドキュメントを検索してみましょう。

searchエンドポイントのドキュメントはこちら。 https://docs.meilisearch.com/reference/api/search.html

どうやらPOSTで取得することを推奨しているのでPOSTで試します。

➜  ~ curl \
  -X POST 'http://localhost:7700/indexes/users/search' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "q": "スネ夫"
  }' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   624  100   598  100    26   116k   5200 --:--:-- --:--:-- --:--:--  121k
{
  "hits": [
    {
      "id": 2,
      "name": "骨川 スネ夫",
      "name_kana": "ホネカワ スネヲ",
      "job_change_count": 0,
      "address": {
        "id": 1,
        "label": "東京都"
      },
      "email": [
        "test+3@example.com"
      ],
      "experience_jobs": [
        {
          "id": 999,
          "label": "職歴なし"
        }
      ]
    },
    {
      "id": 1,
      "name": "鈴木 一郎",
      "name_kana": "スズキ イチロウ",
      "job_change_count": 2,
      "address": {
        "id": 1,
        "label": "東京都"
      },
      "email": [
        "test+1@example.com",
        "test+2@example.com"
      ],
      "experience_jobs": [
        {
          "id": 100,
          "label": "営業(toB)"
        },
        {
          "id": 200,
          "label": "経営企画
        }
      ]
    }
  ],
  "nbHits": 2,
  "exhaustiveNbHits": false,
  "query": "スネ夫",
  "limit": 20,
  "offset": 0,
  "processingTimeMs": 1
}"

検索文字列を入れて検索するだけならすごく簡単ですね。
曖昧検索はこんな感じですね。何も設定しなくて日本語検索できるのか。すごい。
細かな調整などは必要になってくるとは思いますが、一旦はこんな感じでおk。

ただ、完全一致で検索したい場合はどうするのでしょうか?
Filterを利用すれば良いようです。
https://docs.meilisearch.com/reference/features/filtering_and_faceted_search.html

Filterを利用する流れは、以下のようです。

  1. indexにfilterableAttributesを追加しておく。
  2. search時にfilterパラメタを設定して検索する。

簡単ですね。
現状ではネストされた配列とオブジェクトのフィルタリングはサポートしていないとのことです。
ここら辺はindexを設計するときに考慮するポイントになりますね。
なのでaddressexperience_jobsなどはフィルタリング不可になります。
フィルタしたいのであればデータの持ち方を変える必要がありますね。

早速試してみましょう。

filterableAttributesの追加

➜  ~ curl \
  -X POST 'http://localhost:7700/indexes/users/settings' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filterableAttributes": [
      "name",
      "name_kana",
      "email"
    ]
  }' | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   102  100    14  100    88   2000  12571 --:--:-- --:--:-- --:--:-- 14571
{
  "updateId": 2
}

filterパラメタを指定して検索

名前で完全一致検索する場合

➜  ~ curl \
  -X POST 'http://localhost:7700/indexes/users/search' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filter": "name = \"骨川 スネ夫\""
  }' | jq

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   366  100   317  100    49  63400   9800 --:--:-- --:--:-- --:--:-- 73200
{
  "hits": [
    {
      "id": 2,
      "name": "骨川 スネ夫",
      "name_kana": "ホネカワ スネヲ",
      "job_change_count": 0,
      "address": {
        "id": 1,
        "label": "東京都"
      },
      "email": [
        "test+3@example.com"
      ],
      "experience_jobs": [
        {
          "id": 999,
          "label": "職歴なし"
        }
      ]
    }
  ],
  "nHits": 1,
  "exhaustiveNbHits": false,
  "query": "",
  "limit": 20,
  "offset": 0,
  "processingTimeMs": 0
}

OR検索する場合

➜  ~ curl \
  -X POST 'http://localhost:7700/indexes/users/search' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filter": "name = \"野原 ひろし\" OR name = \"鈴木 一郎\""
  }' | jq

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   667  100   589  100    78   143k  19500 --:--:-- --:--:-- --:--:--  162k
{
  "hits": [
    {
      "id": 1,
      "name": "鈴木 一郎",
      "name_kana": "スズキ イチロウ",
      "job_change_count": 2,
      "address": {
        "id": 1,
        "label": "東京都"
      },
      "email": [
        "test+1@example.com",
        "test+2@example.com"
      ],
      "experience_jobs": [
        {
          "id": 100,
          "label": "営業(toB)"
        },
        {
          "id": 200,
          "label": "経営企画"
        }
      ]
    },
    {
      "id": 3,
      "name": "野原 ひろし",
      "name_kana": "ノハラ ヒロシ",
      "job_change_count": 0,
      "address": {
        "id": 2,
        "label": "埼玉県"
      },
      "email": [
        "test+4@example.com"
      ],
      "experience_jobs": [
        {
          "id": 100,
          "label": "営業(toB)"
        }
      ]
    }
  ],
  "nbHits": 2,
  "exhaustiveNbHits": false,
  "query": "",
  "limit": 20,
  "offset": 0,
  "processingTimeMs": 0
}

配列の中を検索する場合

➜  ~ curl \
  -X POST 'http://localhost:7700/indexes/users/search' \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filter": "email = \"test+2@example.com\""
  }' | jq

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   425  100   372  100    53  93000  13250 --:--:-- --:--:-- --:--:--  103k
{
  "hits": [
    {
      "id": 1,
      "name": "鈴木 一郎",
      "name_kana": "スズキ イチロウ",
      "job_change_count": 2,
      "address": {
        "id": 1,
        "label": "東京都"
      },
      "email": [
        "test+1@example.com",
        "test+2@example.com"
      ],
      "experience_jobs": [
        {
          "id": 100,
          "label": "営業(toB)"
        },
        {
          "id": 200,
          "label": "経営企画"
        }
      ]
    }
  ],
  "nbHits": 1,
  "exhaustiveNbHits": false,
  "query": "",
  "limit": 20,
  "offset": 0,
  "processingTimeMs": 0
}

他にも諸々試しましたがfilterで=を利用すれば完全一致で検索できますね。
ドキュメントを読むと他にもいくつもルールがあります。

ざっとした使い方はこんなもんかなと。

終わりに

シンプルですね。
凝ったことをする時に物足りなくなるかもしれませんが、基本的な機能は備えていると思います。
曖昧検索についてどれくらい設定出来るかはもう少し調ベたいですが、好印象です。
Elasticsearchは多機能な分、選択肢が多いと思っているのでこのくらいシンプルに使えるの嬉しいです。