Meilisearch 検証雑記

[Meilisearch](https://github.com/meilisearch/meilisearch)の検証雑記です。

https://github.com/meilisearch/meilisearch

MeilisearchはRust製の全文検索エンジンで日本語の検索においても良さそうということを聞いて、実際に少し検証を行いました。さらに、関わっているプロジェクトへの導入が決定したため、その観点からも改めて検証を進めました。

日本のコミッターの方の改善により、日本語ドキュメントの精度が上がっているようです。本当にありがたいことです🙌

## セルフホスト型 Meilisearch
Meilisearch Cloudもあるようですが、今回はセルフホスト型で検証を進めます。

まずは[ドキュメントのセットアップ手順](https://www.meilisearch.com/docs/learn/self_hosted/getting_started_with_self_hosted_meilisearch)に従って、Meilisearchをセットアップします。
```bash
curl -L https://install.meilisearch.com | sh
...

# master-key は管理者パスワードみたいなものなので任意のキーを指定
./meilisearch --master-key="<管理者パスワード的なもｎ>"

888b     d888          d8b 888 d8b                                            888
8888b   d8888          Y8P 888 Y8P                                            888
88888b.d88888              888                                                888
888Y88888P888  .d88b.  888 888 888 .d8888b   .d88b.   8888b.  888d888 .d8888b 88888b.
888 Y888P 888 d8P  Y8b 888 888 888 88K      d8P  Y8b     "88b 888P"  d88P"    888 "88b
888  Y8P  888 88888888 888 888 888 "Y8888b. 88888888 .d888888 888    888      888  888
888   "   888 Y8b.     888 888 888      X88 Y8b.     888  888 888    Y88b.    888  888
888       888  "Y8888  888 888 888  88888P'  "Y8888  "Y888888 888     "Y8888P 888  888

Config file path:       "none"
Database path:          "./data.ms"
Server listening on:    "http://localhost:7700"
Environment:            "development"
Commit SHA:             "unknown"
Commit date:            "unknown"
Package version:        "1.13.3"

Thank you for using Meilisearch!
...
```
サーバーはローカルホスト:7700 で起動し、デフォルトでは、`data.ms/` バージョンによっては `meili_data/` ディレクトリが作成されます。これらのディレクトリにインデックスなどのデータが保存されるようです。
なお、Meilisearch を起動するときに `--db-path` や、環境変数 `MEILI_DB_PATH`で保存されているデータの参照先を指定する事ができます。

起動できたら早速ドキュメントを追加します。
追加するドキュメントは[こちらのリポジトリ](https://github.com/ku-nlp/JMRD)のものを自身でCSVに加工したデータセットを使います。(Jsonでも大丈夫)
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/e0dcef13-04cc-4f4b-93f3-d4496445a1c0.png)

MeilisearchのAPIを使用しますが、Meilisearchは多様なSDKを提供しています。今回はPythonのSDKを利用しています。
```python:python
import meilisearch

client = meilisearch.Client('http://localhost:7700', '<設定したマスターキー>')

with open('movie.csv', 'rb') as csv_file:
    csv_bytes = csv_file.read()

# CSVデータをMeilisearchに追加 (IndexName: movies)
task = client.index('movies').add_documents_csv(csv_bytes)
print(f"Task ID: {task.task_uid}")
print(f"Task status: {task.status}")
```
> すべてのインデックスには、そのインデックス内のすべてのドキュメントで共有される属性である[主キー](https://www.meilisearch.com/docs/learn/getting_started/primary_key#primary-field)が必要です。インデックスにドキュメントを追加しようとしたときに、1 つでも主キーが欠落していると、ドキュメントは保存されません。主キーを明示的に設定しない場合、Meilisearch はデータセットから主キーを[推測します。](https://www.meilisearch.com/docs/learn/getting_started/primary_key#meilisearch-guesses-your-primary-key)

ドキュメントに上記の注意書きが記載されていますが、主キーを特に指定しなくても、カラム名「id」を自動的に判別して主キーとして設定してくれました。

実行後、タスクは待機状態となりましたが、追ってステータスを確認したところ成功の応答を得られました。
```python:python
# 状態確認
client.get_task(0)
```
```:結果
...
Task(uid=0, 
	index_uid='movies', 
	status='succeeded', 
	type: "documentAdditionOrUpdate", 
	details={
	'receivedDocuments': 260, 
	'indexedDocuments': 260
	}, 
	error=None,
	...以後省略
```
ドキュメントの格納が済んだようなので、検索を実行します。
```python:python
client.index('movies').search('ゴースト')
```
```bash: 結果
{'hits': [
	{
		'id': '3247',
	   'movie_title': 'ゴーストバスターズ',
	   'Year': '１９８４年',
	   'Director': 'アイヴァン・ライトマン',
	   'DirectorSummary': 'チェコスロヴァキア生まれの映画監督、映画プロデューサー。',
	   'cast': 'ビル・マーレイ, ダン・エイクロイド',
	   'CastOutline': 'アメリカ合衆国出身のコメディアン、俳優、映画監督、脚本家, カナダ出身のコメディアン、俳優、脚本家、ミュージシャン',
	   'genre': 'ホラー, コメディ',
	   'review': 'さえない男たちが金儲けのために幽霊退治をします。ボスキャラのマシュマロマンの映像は一度は見たことあるはず。, 超常現象を専門とする研究者たちがお化け退治する現実離れした作品です。, メカニックを使ってお化けを退治していくのでＳＦとホラーを掛け合わせたような内容で面白いです。, いかにもアメリカ映画で、単純明快, 監督・キャストともに豪華で、次回作も見たくなるような作品です。',
	   'summary': 'ニューヨークのコロンビア大学で超常現象や幽霊・霊体の研究を行っていたピーター・ヴェンクマン博士（ビル・マーレイ）、レイモンド・スタンツ博士（ダン・エイクロイド）、イゴン・スペングラー博士（ハロルド・ライミス）の冴えない研究者３人。, ある日、「経費の無駄遣い」と一方的に研究費を打ち切られ大学を追い出されたことをきっかけに、借金を重ね、科学的に超常現象全般を扱い幽霊退治を行う会社「ゴーストバスターズ」を開業。, 当初は資金もなく依頼もゼロに近かったが、自宅での怪奇現象に悩むディナ（シガニー・ウィーバー）が調査を依頼し、ピーターは一目ぼれ。, とあるホテルでの幽霊退治をきっかけにビジネスは大当たり、メディアや行政からも注目視され、多忙になり、新メンバーのウィンストン（アーニー・ハドソン）も加わる。, そんな中、謎の巨大霊的エネルギーが接近していた。, その正体は破壊の神ゴーザ（スラビトザ・ジャバン）で、番犬である雌の「門の神ズール」と雄の「鍵の神ビンツ」の二頭に、ディナとその隣人のルイス（リック・モラニス）が取り憑かれてしまう。, 新人ウィンストンを加えて絶好調のゴーストバスターズだったが、市環境保護局局長のウォルター・ペック（ウィリアム・アザートン）に目を付けられ、地下室の幽霊保管庫の電源を切られて大爆発を起こし、幽霊が保管庫からビルの屋上を突き破って逃走、ニューヨーク中に出没するようになってしまう。, ゴーストバスターズは爆発物所持等の容疑で拘留されるが、「門の神ズール」にとり憑かれたディナと「鍵の神ビンツ」にとり憑かれたルイスが出会うことでゴーザが復活すること、二人の住む高層ビルは、ゴーザを崇拝しこの世の終りを祈る秘密結社の信者イヴォが特殊な設計によって建築したもので、屋上が異次元と現実世界との接点になっていたことをつきとめる。, ４人はレニー市長（デヴィッド・マーギュリーズ）の希望で呼び出され、幽霊騒ぎを収拾するために活動を再開する。, そのころディナの住むビルでも爆発が起き、黒い雲が覆い、ゴーザが現れようとしていた。'},
  {
	  'id': '2334',
	   'movie_title': 'ミッション：インポッシブル／ゴースト・プロトコル',
	   'Year': '2011年12月16日',
	   'Director': 'ブラッド・バード',
	   'DirectorSummary': 'アメリカ合衆国のモンタナ州カリスペル生まれの、映画監督、脚本家、アニメーション作家',
	   'cast': 'トム・クルーズ, ポーラ・パットン',
	   'CastOutline': 'アメリカ合衆国ニューヨーク州シラキュース出身の俳優・映画プロデューサー・歌手, アメリカ合衆国の女優。身長１７１ｃｍ。',
	   'genre': 'アクション, サスペンス',
	   'review': 'トム・クルーズの大ビットアクションシリーズ。回を増す毎に過激になるアクションにトム・クルーズ自身がスタントしている。, トムクルーズがロシア語を喋っているシーンがあります。吹替ではなく字幕で観ることをおススメします。, スパイアクション作品ですが、所々にユーモアのあるシーンで笑わせてくれます。, 前作までのシリーズとの繋がりもあり、シリーズ通して見てる人には嬉しい展開。もちろん本作だけでも楽しめますが、見たら前作シリーズも見てみたくなると思います。, ド派手なアクションとユーモアの組み合わせが秀逸。ブルジュハリファの高層ビルでのスタントはひやひやしながら楽しめます。',
	   'summary': 'ＩＭＦ（ＩｍｐｏｓｓｉｂｌｅＭｉｓｓｉｏｎｓＦｏｒｃｅ、不可能作戦部隊）エージェントのトレヴァー・ハナウェイはブダペストで「コバルト」というコードネームの人物に渡されるはずの秘密ファイルを奪う任務に就いていた。, だが、同ファイルを狙う別組織の乱入の果てに、ハナウェイは女殺し屋のサビーヌ・モローによって殺害され、ファイルを奪われてしまう。, 事態収拾のため、ＩＭＦは私的にモスクワの刑務所に服役中のイーサン・ハントを、ハナウェイのチームで働いていたジェーン・カーターと、新たに現場エージェントに昇格したベンジー・ダンに脱獄させる。, イーサンは、最愛のジュリアを殺され、犯人たちに復讐したのだという。, ＩＭＦは「コバルト」の正体の手掛かりを手に入れるためクレムリンへの潜入任務を命じる。, 首尾よく進めるイーサンであったが、既に目的の資料は奪われていた上に、真犯人は痕跡を消すためにクレムリンを大きく爆破させる。, 爆破に巻き込まれ病院で目を覚ましたイーサンは、ロシア諜報員のアナトリー・シディロフに爆破テロの首謀者だと決め付けられ、イーサンはその場から逃亡し、ＩＭＦに救助を求める。, 偶然、ロシアへ来ていたＩＭＦ長官と合流したイーサンは、長官より事態の深刻さを告げられる。, クレムリン爆破をアメリカの犯行と疑うロシアに対し、アメリカ政府は「ゴースト・プロトコル」を発動させＩＭＦを解体し、政府による一切の関与を否定、長官はイーサンへ政府やＩＭＦのバックアップ無しでの任務継続を暗に命じる。, その矢先、シディロフ率いる部隊に襲撃されて長官は死亡、イーサンは分析官のウィリアム・ブラントと共に脱出する。'},
  {
	  'id': '4933',
	   'movie_title': 'シックス・センス',
	   'Year': '１９９９年',
	   'Director': 'Ｍ・ナイト・シャマラン',
	   'DirectorSummary': 'インド系アメリカ人の映画監督・脚本家・映画プロデューサー',
	   ...以後省略
```
デフォルトでは上位20件の結果が表示されます。

## フロントエンドに組み込む
https://www.meilisearch.com/docs/guides/front_end/front_end_integration

上記のドキュメントにあるサンプルを、そのままコピペして一部書き換えます。

<details><summary>サンプル書き換え後</summary>

```html:html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@meilisearch/instant-meilisearch/templates/basic_search.css" />
  </head>
  <body>
    <div class="wrapper">
      <div id="searchbox" focus></div>
      <div id="hits"></div>
    </div>
  </body>
  <script src="https://cdn.jsdelivr.net/npm/@meilisearch/instant-meilisearch/dist/instant-meilisearch.umd.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4"></script>
  <script>
    const { searchClient } = instantMeiliSearch(
        "http://localhost:7700",
        "<検索用APIキー>"
    )
    const search = instantsearch({
      indexName: "movies",
      searchClient,
      });

      search.addWidgets([
        instantsearch.widgets.searchBox({
          container: "#searchbox"
        }),
        instantsearch.widgets.configure({ hitsPerPage: 20 }),
        instantsearch.widgets.hits({
          container: "#hits",
          templates: {
          item: `
            <div>
            <div class="hit-name">
                  {{#helpers.highlight}}{ "attribute": "movie_title" }{{/helpers.highlight}}
            </div>
            </div>
          `
          }
        })
      ]);
      search.start();
  </script>
</html>

```

</details>

因みに、検索用のAPIキーは以下のエンドポイントを叩くことで取得できます。
```bash
curl -X GET http://localhost:7700/keys \
  -H 'Authorization: Bearer <マスターキー>'

...
# response
{
	"results":[
		{
			"name":"Default Search API Key",
			"description":"Use it to search from the frontend",
			"key":"×××××××××××××××××××××××××××××××××××××××××××",
			"uid":"×××××××××",
			"actions":["search"],
			"indexes":["*"],
			"expiresAt":null,
			"createdAt":"××××××",
			"updatedAt":"××××××"
			},
			{
				"name":"Default Admin API Key",
				"description":"Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend",
				"key":"×××××××××××××××××××××××××××××××××××××××××××",
				"uid":"×××××××××",
				"actions":["*"],
				"indexes":["*"],
				"expiresAt":null,
				"createdAt":"×××××××××",
				"updatedAt":"×××××××××"
				}
		],
		"offset":0,
		"limit":20,
		"total":2
	}
```

説明文に記載されているとおり、Default Search API Keyを使用します。

https://www.meilisearch.com/docs/learn/security/basic_security#master-key-and-api-keys

実際に動作させるとこんな感じです。画質粗くてごめんなさい💦

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/853875de-c273-46f9-ad21-7fe52af002d0.gif" width=100%>

## ベクトル検索を組み合わせる

https://www.meilisearch.com/docs/learn/ai_powered_search/getting_started_with_ai_search

Meilisearchのインデックス設定を更新することでベクトル検索とのハイブリット検索ができるようです。以下のJsonfileのように設定を記載し、APIを叩く事でMeilisearchのインデックス設定を更新します。
```json: embedder-config.json
{
    "products-openai":
    {
        "source": "openAi",
        "apiKey": "<openai apikey>",
        "model": "text-embedding-3-small",
        "dimensions": 1536,
        "documentTemplate": "内容はこちらを参照してください:'{{doc.review}}'"
    }
}
```
```bash:bash
curl -X PATCH 'http://localhost:7700/indexes/<index-name>/settings/embedders' \
  -H 'Authorization: Bearer <master-key>' \
  -H 'Content-Type: application/json' \
  -d @./embedder-config.json
```

埋め込みを使用したのハイブリッド検索をおこなう場合は、`hybrid` パラメータを利用するようです。

https://www.meilisearch.com/docs/reference/api/search#hybrid-search

```python:python
# ハイブリッド検索
client.index('movies').search(
    '優しい雰囲気の映画',
    {
        "hybrid": {
            "semanticRatio": 1,
            "embedder": "products-openai"
        },
    }
)
```
```bash:結果

{'hits': [
	{'id': '3782',
   'movie_title': '借りぐらしのアリエッティ',
   'Year': '2010年7月17日',
  },
	{
	 'id': '2002',
   'movie_title': 'メアリと魔女の花',
   'Year': '2017年',
  },
  {'id': '2421',
   'movie_title': '塔の上のラプンツェル',
   'Year': '2010年11月24日',
   ...
```
通常の検索と比較し、違いを把握しました。(詳細は割愛)

## (ECS) サイドカーコンテナとして動作させる
既存のアプリケーションにどのように組み込むか検討していきます。

Meilisearchを起動すると`data.ms/`もしくは`meili_data/`ディレクトリ内にインデックスデータ等が作成されます。これらのデータの管理方法が課題です。

この課題に対して、普段使用しているAWS ECS Fargateでの運用を想定すると、ボリュームのマウントで対応できそうです。

### やること概要
- Meilisearchをアプリケーションのサイドカーコンテナとして起動
- EFSをマウントしてインデックスデータを永続化
- スケーリングした際の挙動確認

### 手順
こちらも詳細は割愛しますが、大まかに以下手順で進めました。

**① メインとなるアプリ(APIのサンプル)を作成**
<details><summary>サンプル</summary>

```python:main.py
from fastapi import FastAPI, Request, Body
from fastapi.responses import JSONResponse
import uvicorn
from pydantic import BaseModel, Field
import meilisearch
from dotenv import load_dotenv
import os

load_dotenv()
app = FastAPI(title="FatAPI Sample")
key = os.getenv('MEILI_MASTER_KEY')
index_name = os.getenv('INDEX_NAME', 'movies')
meilisearch_url = os.getenv('MEILISEARCH_URL', 'http://localhost:7700')

client = meilisearch.Client(meilisearch_url, key)
index = client.index(index_name)

class ResearchRequest(BaseModel):
    query: str = Field(..., description="検索クエリ")

@app.get("/health")
async def root():
    return {"status": "ok"}

@app.post("/research")
async def echo(request: Request, data: ResearchRequest):
    res = index.search(data.query)
    return JSONResponse(content=res)

```
</details>

**② インデックス作成サンプルコードを作成**

<details><summary>サンプル</summary>

```python:create_index.py
import meilisearch
import os
from dotenv import load_dotenv

load_dotenv()

key = os.getenv('MEILI_MASTER_KEY', '')
file_path = os.getenv('FILE_PATH', '')
meilisearch_url = os.getenv('MEILISEARCH_URL', 'http://localhost:7700')

client = meilisearch.Client(meilisearch_url, key)

with open(file_path, 'rb') as csv_file:
    csv_bytes = csv_file.read()

task = client.index('movies').add_documents_csv(csv_bytes)
print(f"Task status: {task.status}")
```
</details>

**③ これらをコンテナイメージにしてECRにプッシュ**

**④ ECS Fargateの環境、EFSの作成、SFTPサーバー経由でEFSに元データをプッシュ**

**⑤ タスク定義でボリュームのマウント、Meilisearchをサイドカーとして定義**

<details><summary>タスク定義ファイル</summary>
    
```json:json
    {
        "family": "test",
        "containerDefinitions": [
            {
                "name": "python",
                "image": "",
                "cpu": 1024,
                "memory": 2048,
                "memoryReservation": 2048,
                "portMappings": [
                    {
                        "name": "8000",
                        "containerPort": 8000,
                        "hostPort": 8000,
                        "protocol": "tcp",
                        "appProtocol": "http"
                    }
                ],
                "essential": true,
                "environment": [
                    {
                        "name": "INDEX_NAME",
                        "value": "movies"
                    },
                    {
                        "name": "MEILI_MASTER_KEY",
                        "value": "<meili-master-key>"
                    },
                    {
                            "name": "FILE_PATH",
                            "value": "mnt/efs/movie.csv"
                    }
                ],
                "mountPoints": [
                    {
                        "sourceVolume": "test-efs",
                        "containerPath": "/app/mnt/efs",
                        "readOnly": false
                    }
                ],
                "dependsOn": [
                    {
                        "containerName": "mailsearch",
                        "condition": "START"
                    }
                ],
                "logConfiguration": {
                    "logDriver": "awslogs",
                    "options": {
                        "awslogs-group": "/ecs/test-mailsearch",
                        "mode": "non-blocking",
                        "awslogs-create-group": "true",
                        "max-buffer-size": "25m",
                        "awslogs-region": "ap-northeast-1",
                        "awslogs-stream-prefix": "ecs"
                    },
                },
            },
            {
                "name": "mailsearch",
                "image": "getmeili/meilisearch:v1.13",
                "cpu": 1024,
                "memory": 2048,
                "memoryReservation": 2048,
                "portMappings": [
                    {
                        "name": "7700",
                        "containerPort": 7700,
                        "hostPort": 7700,
                        "protocol": "tcp",
                        "appProtocol": "http"
                    }
                ],
                "essential": true,
                "environment": [
                    {
                        "name": "MEILI_LOG_LEVEL",
                        "value": "INFO"
                    },
                    {
                        "name": "MEILI_DB_PATH",
                        "value": "/meili_data"
                    },
                    {
                        "name": "MEILI_MASTER_KEY",
                        "value": "<meili-master-key>"
                    }
                ],
                "mountPoints": [
                    {
                        "sourceVolume": "test-efs",
                        "containerPath": "/meili_data",
                        "readOnly": false
                    }
                ],
                "logConfiguration": {
                    "logDriver": "awslogs",
                    "options": {
                        "awslogs-group": "/ecs/side/test-meilisearch",
                        "mode": "non-blocking",
                        "awslogs-create-group": "true",
                        "max-buffer-size": "25m",
                        "awslogs-region": "ap-northeast-1",
                        "awslogs-stream-prefix": "ecs"
                    },
                },
                "healthCheck": {
                    "command": [
                        "CMD",
                        "curl",
                        "-f",
                        "http://localhost:7700/health"
                    ],
                    "interval": 30,
                    "timeout": 30,
                    "retries": 3,
                    "startPeriod": 300
                },
            }
        ],
        "taskRoleArn": "<role arn>",
        "executionRoleArn": "<role arn>",
        "networkMode": "awsvpc",
        "volumes": [
            {
                "name": "test-efs",
                "efsVolumeConfiguration": {
                    "fileSystemId": "<EFS ID>",
                    "rootDirectory": "/"
                }
            }
        ],
        "requiresCompatibilities": [
            "FARGATE"
        ],
        "cpu": "2048",
        "memory": "4096",
        "runtimePlatform": {
            "cpuArchitecture": "X86_64",
            "operatingSystemFamily": "LINUX"
        },
        "enableFaultInjection": false
    }

```

</details>

**⑥ サービスの起動 → ECS Exec コンテナ接続 → 初回 Index作成コードを実行**

**⑦ 動作確認**

サイドカーコンテナとしてMeilisearchを動作させ、EFSをマウントした場合、タスク数が1つの場合問題なく動作しました。しかし、タスク数を増やしてみて動作させたところ、2つめ、3つめのタスク起動で以下エラーを吐いてコケるようになりました。
```
Error: Internal error: Resource temporarily unavailable (os error 11)
```
> "Resource temporarily unavailable (os error 11)" というエラーは、一般的にシステムリソースが一時的に利用できない状態を示しています。このエラーは多くの場合、以下のような状況で発生します：
> 
> 1. ファイルディスクリプタの枯渇：
> • プロセスが開けるファイルの最大数（ulimit）に達した
> • システム全体のファイルディスクリプタ制限に達した
> 2. ソケット接続の問題：
> • 同時接続数が多すぎる
> • ポートの再利用（TIME_WAIT状態）の問題
> • 接続キューがいっぱいになっている
> 3. プロセス/スレッドの制限：
> • プロセスやスレッドの最大数に達した
> • システムリソース（メモリなど）の制限に達した
> 4. ロックの競合：
> • 複数のプロセスが同じリソースにアクセスしようとしている
> • デッドロックやリソースの競合状態
> 5. I/O操作の問題：
> • ディスクI/Oの輻輳
> • ネットワークI/Oの問題

複数プロセスが同時に同じデータ (`meili_data/`) を参照することによる問題だろうと考え、試しにDocker Composeを使って同じ状況をローカルで再現したところ同様のエラーとなり、２つめのMeilisearchコンテナが起動しませんでした。

過去、同じような内容で議論になっていたっぽいです。現在も未解決なのかな。
- https://github.com/meilisearch/meilisearch/discussions/1111?sort=new
- https://github.com/meilisearch/meilisearch-kubernetes/issues/25

これらの検証結果から、現状ではECS等、コンテナでの運用は断念し、[公式のドキュメント通り](https://www.meilisearch.com/docs/guides/deployment/aws#part-2-configure-production-settings)、仮想マシン(EC2) で一旦運用してみることにしました。。。

## 参考にしたドキュメントや記事
https://www.meilisearch.com/docs?utm_campaign=oss&utm_source=github&utm_medium=meilisearch&utm_content=docs

https://hub.docker.com/r/getmeili/meilisearch/tags

https://qiita.com/mosuka/items/fbda479b25a7ccd7c350

https://awstip.com/persistent-meili-server-scaling-in-kubernetes-using-efs-e4390183b564