🦕

ElasticCloudをDenoで使ってみる

2023/08/23に公開

お手軽に検索サービスを利用したいとなると候補になってくるのはElasticsearchです。ただし今の時代自分で運用はしたくないです。

Elasticsearchをいい感じの管理された環境で使いたい……と思ったときに色々調べていると、そういえば公式が単体で出してたな。ということを思い出したので、公式提供のElasticSearchのクラウドサービスであるElasticCloudをDenoから使ってみたいと思います。

プロジェクトの作成

まずはアカウントの作成を行ってください。

https://cloud.elastic.co/

作成完了後とりあえず以下ページに入ってみます。

https://cloud.elastic.co/home

こちらがダッシュボードでプロジェクトの作成ができます。

色々設定はありますがとりあえず名前を入れればOKです。

最後にパスワードが表示されます。ダウンロードでCSV形式のものが入手できるので今のうちに保存しておきましょう。

後はしばらく待てば使えるようになるので右上のContinueが青色になったらクリックします。

Elasticsearchとの接続

またダッシュボードに戻りましょう。

https://cloud.elastic.co/home

先程作ったプロジェクトがあるので右の方にある Manage をクリックしてください。

すると接続情報などが取得できるので、Elasticsearchの Copy endpoint をクリックしてURLをコピーします。

では接続確認をしていきましょう。方法は2つです。

ブラウザを使う方法

先程のエンドポイントのURLをそのままブラウザに貼り付けてください。

Basic認証がでてくるのでダウンロードしたユーザー名とパスワードを入れてください。すると以下のようなJSONが表示されます。

{
  "name": "instance-XXXXXXXXXXX",
  "cluster_name": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "cluster_uuid": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "version": {
    "number": "8.9.1",
    "build_flavor": "default",
    "build_type": "docker",
    "build_hash": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "build_date": "20XX-XX-XXTXX:XX:XX.XXXXXXXXXXXZ",
    "build_snapshot": false,
    "lucene_version": "9.7.0",
    "minimum_wire_compatibility_version": "7.17.0",
    "minimum_index_compatibility_version": "7.0.0"
  },
  "tagline": "You Know, for Search"
}

fetchを使う方法

Basic認証ならさくっと突破できるので以下のように実装します。

health.ts
// deno run --allow-net health.ts

const ENDPOINT = 'https://xxxxx.example.com';
const USER = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';
const PASSWORD = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';

fetch(ENDPOINT, {
	headers: {
		Authorization: 'Basic ' + btoa(`${USER}:${PASSWORD}`),
	},
}).then((response) => {
	console.log(response.status);
	return response.json();
}).then((json) => {
	console.log(json);
}).catch((err) => {
	console.error(err);
});

ちなみに認証に失敗するとステータスコード 401 で以下のようなJSONが返却されます。

{
  "error": {
    "root_cause": [
      {
        "type": "security_exception",
        "reason": "missing authentication credentials for REST request [/]",
        "header": {
          "WWW-Authenticate": [
            "Basic realm=\"security\" charset=\"UTF-8\"",
            "Bearer realm=\"security\"",
            "ApiKey"
          ]
        }
      }
    ],
    "type": "security_exception",
    "reason": "missing authentication credentials for REST request [/]",
    "header": {
      "WWW-Authenticate": [
        "Basic realm=\"security\" charset=\"UTF-8\"",
        "Bearer realm=\"security\"",
        "ApiKey"
      ]
    }
  },
  "status": 401
}

日本語の有効化

英語とは異なり日本語は単語分割が難しいです。
適切に単語分割ができると検索精度を上げることができるので、日本語を分解(形態素解析)してくれるプラグインを入れて日本語対応を追加したいと思います。

またダッシュボードから Manage をクリックしてください。

https://cloud.elastic.co/home

左のサイドバーに Edit というボタンがあるのでクリックします。

次にElasticsearchの項目の右にある Manage user settings and extensions (0) をクリックします。

右から設定エリアが出てくるので Extensions をクリックしてタブを切り替えて analysis-icuanalysis-kuromoji を選択後下の Back ボタンを押します。

その後一番下までスクロールして Save ボタンをクリックします。

これで必要な拡張機能が有効になりました。再起動が走るのでしばらく待ちます。

実際に使う

実際に使ってみます。手順としては以下になります。

  • マッピング定義からインデックスの作成
    • インデックス=MySQLにおけるテーブルのようなものでマッピング定義はカラム情報
      • 作らなくても良いですが今回は設定をしたいのでちゃんとマッピング定義を行います。
  • ドキュメントの追加
    • ドキュメント=MySQLにおけるレコードで1件のデータのこと
  • ドキュメントの検索

マッピング定義

今回は簡単なWebページのクリッピングを行う想定で以下のようなデータ構造にします。

{
  "title": "日本語の文章",
  "body": "日本語の文章",
  "url": "そのまま使われるテキスト"
}

日本語の文章は先程有効にした拡張機能を使って解析が必要です。
ではマッピング定義を作ってみます。

mapping.json
{
	"mappings": {
		"properties": {
			"title": {
				"type": "text",
				"analyzer": "kuromoji"
			},
			"body": {
				"type": "text",
				"analyzer": "kuromoji"
			},
			"url": {
				"type": "keyword"
			}
		}
	}
}

もう少し細かく調整も可能ですが一旦今回はこれでいきます。実際にこれを使ってインデックスを作ってみます。
上のマッピングはJSONとして保存してそれを読み込むこととします。

index.ts
// deno run --allow-net index.ts

const ENDPOINT = 'https://xxxxx.example.com';
const USER = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';
const PASSWORD = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';

import MAPPING from './mapping.json' assert { type: 'json' };

const indexName = 'clips';

fetch(`${ENDPOINT}/${indexName}`, {
	method: 'PUT',
	headers: {
		Authorization: 'Basic ' + btoa(`${USER}:${PASSWORD}`),
		'Content-Type': 'application/json',
	},
	body: JSON.stringify(MAPPING),
}).then((response) => {
	console.log(response.status);
	return response.json();
}).then((json) => {
	console.log(json);
}).catch((err) => {
	console.error(err);
});

やっていることは PUTエンドポイント/インデックス名 に対してマッピング定義をしたJSONを送っているだけです。

今回はインデックス名を clips にしたので、成功結果は以下のようになります。

{
  "acknowledged": true,
  "shards_acknowledged": true,
  "index": "clips"
}

ちなみにうまく kuromoji が有効になっていないと以下のようなエラーがでます。

{
  "error": {
    "root_cause": [
      {
        "type": "mapper_parsing_exception",
        "reason": "Failed to parse mapping: analyzer [kuromoji_tokenizer] has not been configured in mappings"
      }
    ],
    "type": "mapper_parsing_exception",
    "reason": "Failed to parse mapping: analyzer [kuromoji_tokenizer] has not been configured in mappings",
    "caused_by": {
      "type": "illegal_argument_exception",
      "reason": "analyzer [kuromoji_tokenizer] has not been configured in mappings"
    }
  },
  "status": 400
}

ドキュメントの追加

では早速ドキュメントを追加してみます。
ElasticsearchではインデックスとドキュメントIDを指定してPOSTやPUTを使うことでデータを追加・更新できます。
色々ややこしいので今回はIDを自動発行してもらう方法で追加しようと思います。

document.ts
// deno run --allow-net document.ts

const ENDPOINT = 'https://xxxxx.example.com';
const USER = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';
const PASSWORD = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';

const indexName = 'clips';

const document = {
	title: 'DenoでElasticsearchを使ってみる。',
	body: [
		'## DenoでElasticsearchを使ってみる。',
		'',
		'### はじめに',
		'',
		'検索機能をお手軽に使う方法としてElasticsearchがあります。今回はDenoからElasticsearchを使ってみます。',
	].join('\n'),
	url: 'https://example.com/test',
};

// URLが ENDPOINT/インデックス名/_doc になっている
fetch(`${ENDPOINT}/${indexName}/_doc`, {
	method: 'POST',
	headers: {
		Authorization: 'Basic ' + btoa(`${USER}:${PASSWORD}`),
		'Content-Type': 'application/json',
	},
	body: JSON.stringify(document),
}).then((response) => {
	console.log(response.status);
	return response.json();
}).then((json) => {
	console.log(json);
}).catch((err) => {
	console.error(err);
});

結果は以下です。

{
  "_index": "clips",
  "_id": "ltlrH4oBGjsVxm4uG1Gz",
  "_version": 1,
  "result": "created",
  "_shards": {
    "total": 2,
    "successful": 2,
    "failed": 0
  },
  "_seq_no": 0,
  "_primary_term": 1
}

追加されてそうですね。

ドキュメントの検索

ではドキュメントの検索をしてみます。今回はタイトルと本文があるのでそれを検索していきたいのですが、Elasticsearchはちょっと検索クエリを作るのが大変なので、一旦今回のクエリを見てみます。

{
  "size": 3,
  "query": {
    "bool": {
      "should": [
        {
          "terms": {
            "title": ["検索"]
          }
        },
        {
          "terms": {
            "body": ["検索"]
          }
        }
      ]
    }
  }
}
  • size
    • 返す最大件数
  • query
    • 検索クエリ
  • query.bool
    • ANDやOR検索をするためのグループのようなもの
  • query.bool.should
    • 中にある要素でOR検索を行う
  • terms
    • フィールドと検索ワードを指定する
    • term だと1つになるので今回は複数を配列指定できるものを使用

これを エンドポイント/ドキュメント名/_searchエンドポイント/_search で検索していきます。実際にやってみましょう。

search.ts
// deno run --allow-net search.ts

const ENDPOINT = 'https://xxxxx.example.com';
const USER = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';
const PASSWORD = 'XXXXXXXXXXXXXXXXXXXXXXXXXX';

const searchWords = ['検索'];

const searchQuery = {
	size: 3,
	query: {
		bool: {
			should: [
				{
					terms: {
						title: searchWords,
					}, 
				},
				{
					terms: {
						body: searchWords,
					}, 
				},
			],
		},
	},
};

fetch(`${ENDPOINT}/_search`, {
	method: 'POST',
	headers: {
		Authorization: 'Basic ' + btoa(`${USER}:${PASSWORD}`),
		'Content-Type': 'application/json',
	},
	body: JSON.stringify(searchQuery),
}).then((response) => {
	console.log(response.status);
	return response.json();
}).then((json) => {
	console.log(json);
}).catch((err) => {
	console.error(err);
});

結果は以下です。

{
  "took": 22,
  "timed_out": false,
  "_shards": {
    "total": 13,
    "successful": 13,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "clips",
        "_id": "ltlrH4oBGjsVxm4uG1Gz",
        "_score": 1,
        "_source": {
          "title": "DenoでElasticsearchを使ってみる。",
          "body": "## DenoでElasticsearchを使ってみる。\n\n### はじめに\n\n検索機能をお手軽に使う方法としてElasticsearchがあります。今回はDenoからElasticsearchを使ってみます。",
          "url": "https://example.com/test"
        }
      }
    ]
  }
}

色々書いていてわかりにくいかもしれませんが、見るべきは hits の中です。ここに検索結果が入っているので適宜利用していきます。

ハイライト対応

このままだとどこがヒットしたかわからないのでハイライトを入れることも可能です。

{
  "size": 3,
  "query": "省略",
  "highlight": {
    "fields": {
      "title": {},
      "body": {}
    }
  }
}

このように highlight を追加すると検索結果が以下のようになります。

{
  "その他": "色々省略",
  "hits": {
    "その他": "色々省略",
    "hits": ["色々省略"],
    "highlight": {
      "body": [
        "### はじめに\n\n<em>検索</em>機能をお手軽に使う方法としてElasticsearchがあります。今回はDenoからElasticsearchを使ってみます。"
      ]
    }
  }
}

結果の highlight の部分を見ると、どの項目がヒットしたか、またヒットした場所はどこかを <em> タグで表示してくれます。
これをうまく使えば検索結果をハイライト表示できると思います。

まとめ

ElasticCloudでElasticsearchを立ち上げ、日本語対応した状態で使ってみました。
REST API経由で様々な操作ができるので接続さえできればお手軽に使えるかなと思います。

検索クエリの組み立てが非常に大変ですが、高度な検索をサクッと使えるので今後色々活用していきたいなと思います。

Discussion