OpenSearch(Elasticsearch)を使用した商品検索APIの作り方
1.はじめに
みなさんOpenSearch(Elasticsearch)使ってますか?OpenSearchはオープンソースの検索エンジンで、全文検索や商品検索、さらにはデータ分析まで、検索用途なら何でも幅広く活用することができます。今回はそのOpenSearchを使って、ECサイトで使うような商品検索APIを作る方法を紹介してみたいと思います。
2.OpenSearchとElasticsearchの関係
OpenSearchという言葉自体を聞き慣れない人もいるかもですが、これはElasticsearch(ES)とほぼ同じものです。
ESは元々Apache License 2.0に準拠したオープンソースソフトウェア(OSS)で、AWSも以前はこのOSS版のESをサービスとして提供していました。
しかし2021年1月、開発元のElasitic社はESのライセンスを変更し、OSSではない独自ライセンスにしてしまいました。これによりAWSはElasticsearchをサービスとして提供できなくなるため、最終のOSSバージョンであるES7.10をフォークしてOpenSearchというOSSの検索エンジンを新たに作成、2021年7月に公開しました。OpenSearchの開発にはAWSだけでなく色々な企業が参画しており、Apache License 2.0の純粋なOSSとなってます。
こういった経緯があるので、現時点ではOpenSearchの中身はESとほぼ同じです。今後は別物として進化していくので少しずつ機能に差異が出てくる可能性はありますが、個人的には特殊なライセンス体系となってしまい色々縛りがあるESよりも、純粋なOSSとして提供されているOpenSearchの方がよいかなと思っています。
3.今回のAPI作成にあたり前提となる情報
まずは前提となる情報(システム構成など)を記載しておきます。
3-1.システム構成
API基盤にAPI Gateway+Lambdaを使用し、ここからOpenSearchに対して検索を行う形になります。簡単な構成図は以下の通りです。
ちなみに今回はAPIの構築にサーバレス向けフレームワークのChaliceを使います。言語はPythonです。Chaliceって何よ?という方向けに、本記事の末尾に参考のリンクを貼ってますので興味があればご覧ください。
3-2.前提とするデータ
今回検索対象とするデータは以下の通りです。商品の属性値(ブランド、性別、カテゴリ、価格、・・・etc)で絞り込みを行い、結果をJSONで返却するイメージになります。
商品コード | 商品名 | ブランド | 性別 | カテゴリ | 価格(円) | 色 | サイズ |
---|---|---|---|---|---|---|---|
A001 | 無地スキニーパンツ | ブランドA | メンズ | パンツ | 2000 | ホワイト | S |
A002 | 無地スキニーパンツ | ブランドA | メンズ | パンツ | 2000 | ホワイト | M |
A003 | 無地スキニーパンツ | ブランドA | メンズ | パンツ | 2000 | ホワイト | L |
A004 | 無地スキニーパンツ | ブランドA | メンズ | パンツ | 2000 | ブラック | S |
A005 | 無地スキニーパンツ | ブランドA | メンズ | パンツ | 2000 | ブラック | M |
A006 | 無地スキニーパンツ | ブランドA | メンズ | パンツ | 2000 | ブラック | L |
B001 | デニムパンツ | ブランドB | レディース | パンツ | 2500 | ブラウン | S |
B002 | デニムパンツ | ブランドB | レディース | パンツ | 2500 | ブラウン | M |
B003 | デニムパンツ | ブランドB | レディース | パンツ | 2500 | ブラウン | L |
B004 | デニムパンツ | ブランドB | レディース | パンツ | 2500 | ホワイト | S |
B005 | デニムパンツ | ブランドB | レディース | パンツ | 2500 | ホワイト | M |
B006 | デニムパンツ | ブランドB | レディース | パンツ | 2500 | ホワイト | L |
C001 | 長袖Tシャツ | ブランドC | メンズ | Tシャツ | 1500 | ホワイト | S |
C002 | 長袖Tシャツ | ブランドC | メンズ | Tシャツ | 1500 | ホワイト | M |
C003 | 長袖Tシャツ | ブランドC | メンズ | Tシャツ | 1500 | ホワイト | L |
C004 | 長袖Tシャツ | ブランドC | キッズ | Tシャツ | 1000 | グレー | S |
C005 | 長袖Tシャツ | ブランドC | キッズ | Tシャツ | 1000 | グレー | M |
C006 | 長袖Tシャツ | ブランドC | キッズ | Tシャツ | 1000 | グレー | L |
4.商品検索APIの構築手順
前置きが長くなりましたが、ここからは実際の構築手順を書いていきます。早速やってみましょう。
4-1.OpenSearchクラスタの作成
まずはOpenSearchのクラスタを作成します。
AWSコンソール上でOpenSearchの画面を開き、右側上部にある「Create domain」ボタンを押下します。
続く画面でOpenSearchの名前やインスタンスタイプ、ストレージ容量等を指定します。今回はテスト用なので専用マスタノードは指定していません。この場合はデータノードがマスタノードを兼ねる形になり、検索パフォーマンスにいくらか影響が出る可能性があるので、本番用途であれば別途専用マスタノードを立てることを検討ください。
続いてアクセス権とセキュリティの設定を行います。OpenSearchの配置先はVPCまたはパブリック領域のどちらかを選べます。VPCだとセキュリティ的に固いのですが、ネットワークの設定が若干煩雑なので今回はパブリックに配置することにしました。その代わりに特定IPアドレスのみアクセスできるように制限を加えています。自分の開発用マシンなど、OpenSearchへアクセスする必要があるPCやネットワークのIPを指定してください。
続いてタグの追加画面が表示されます。必要に応じて設定しましょう。
最後に確認画面上で「確認」を押すとクラスタが作成されます。ステータスが「読み込み中」から「アクティブ」に変われば作成完了です。
4-2.OpenSearchのインデックス作成
続いて検索に使用するインデックスの作成を行います。
(1)Kibana(OpenSearch Dashboards)へのアクセス
KibanaはElasticsearchに同梱されているGUIの分析ツールです。直感的な操作で様々なグラフを生成できるためデータ分析などに活用できますが、これ以外にもElasticsearchに対して各種クエリ(データ検索や登録など)を実行したりできるので何かと便利です。
OpenSearchではKibanaから名前が変わって「OpenSearch Dashboards」になりましたが、今回はクラスタ作成時にElasticsearch 7.10を選択したので画面上でも「Kibana」という表現になります。若干ややこしいですがご了承ください。
Kinabaへアクセスするには以下ドメインの詳細画面を開き、「Kibana URL」のリンクをクリックします。
するとKibanaが開きます。画面左のバーガーメニューから「Dev Tools」を選択します。
以下の通り、Dev Toolsのコンソールが開きます。左側のエリアにOpenSearch用のクエリを入力して実行すれば、結果が右側のエリアに表示されます。以降の手順でOpenSearchのAPIを叩くときはこの画面を使用することを前提にしています。
(2)インデックス作成
OpenSearchのインデックスは、通常のデータベース(RDB)でいうところのテーブルにあたります。
前段に記載したサンプルデータ格納用のインデックスを作成してみましょう。クエリは以下の通りです。
PUT /test-products
今回はインデックス名をtest-productsにしてみました。この時点では定義も何もない空のインデックスができるだけなので、続く手順でデータ構造を定義していきます。
(3)Mapping作成
Mappingとは、インデックス内のフィールド等の構成を予め事前に定義したものです。RDBでいうところの、Create Table時に指定する中身(カラム名やデータ型等)に相当します。
OpenSearchには自動マッピング機能が備わっており、何もしなくてもデータ登録時に自動でMappingが生成されますが、その内容は最初に登録されるデータに依存します。場合によっては想定外の型が勝手に指定されてしまう恐れもあるため、基本的にはMappingは自分で作成した方が望ましいです。(この辺はESも同じ)
今回データ用のマッピング生成クエリは以下の通りです。
PUT /test-products/_mapping
{
"properties" : {
"productCode" : {"type" : "keyword"},
"productName" : {"type" : "keyword"},
"brand" : {"type" : "keyword"},
"gender" : {"type" : "keyword"},
"category" : {"type" : "keyword"},
"price" : {"type" : "integer"},
"color" : {"type" : "keyword"},
"size" : {"type" : "keyword"}
}
}
データ構造がネストしていないのでシンプルですね。今回は基本的に全文検索は不要なので、文字列型は全てkeyword、数値型はintegerにしました。
(4)インデックスへのデータ登録
続いてインデックスへのデータ登録を行います。今回は複数件を一括で登録したいのでBulk APIを使用します。
クエリは以下の通りで、データの内容は前段で紹介したものと同じです。インデックスのID(RDBのテーブルでいうところの主キーみたいなもの)は商品コードと同一としました。
POST /test-products/_doc/_bulk
{ "index" : {"_id" : "A001" } }
{"productCode": "A001","productName": "無地スキニーパンツ","brand": "ブランドA","gender": "メンズ","category": "パンツ","price": "2000","color": "ホワイト","size": "S"}
{ "index" : {"_id" : "A002" } }
{"productCode": "A002","productName": "無地スキニーパンツ","brand": "ブランドA","gender": "メンズ","category": "パンツ","price": "2000","color": "ホワイト","size": "M"}
{ "index" : {"_id" : "A003" } }
{"productCode": "A003","productName": "無地スキニーパンツ","brand": "ブランドA","gender": "メンズ","category": "パンツ","price": "2000","color": "ホワイト","size": "L"}
{ "index" : {"_id" : "A004" } }
{"productCode": "A004","productName": "無地スキニーパンツ","brand": "ブランドA","gender": "メンズ","category": "パンツ","price": "2000","color": "ブラック","size": "S"}
{ "index" : {"_id" : "A005" } }
{"productCode": "A005","productName": "無地スキニーパンツ","brand": "ブランドA","gender": "メンズ","category": "パンツ","price": "2000","color": "ブラック","size": "M"}
{ "index" : {"_id" : "A006" } }
{"productCode": "A006","productName": "無地スキニーパンツ","brand": "ブランドA","gender": "メンズ","category": "パンツ","price": "2000","color": "ブラック","size": "L"}
{ "index" : {"_id" : "B001" } }
{"productCode": "B001","productName": "デニムパンツ","brand": "ブランドB","gender": "レディース","category": "パンツ","price": "2500","color": "ブラウン","size": "S"}
{ "index" : {"_id" : "B002" } }
{"productCode": "B002","productName": "デニムパンツ","brand": "ブランドB","gender": "レディース","category": "パンツ","price": "2500","color": "ブラウン","size": "M"}
{ "index" : {"_id" : "B003" } }
{"productCode": "B003","productName": "デニムパンツ","brand": "ブランドB","gender": "レディース","category": "パンツ","price": "2500","color": "ブラウン","size": "L"}
{ "index" : {"_id" : "B004" } }
{"productCode": "B004","productName": "デニムパンツ","brand": "ブランドB","gender": "レディース","category": "パンツ","price": "2500","color": "ホワイト","size": "S"}
{ "index" : {"_id" : "B005" } }
{"productCode": "B005","productName": "デニムパンツ","brand": "ブランドB","gender": "レディース","category": "パンツ","price": "2500","color": "ホワイト","size": "M"}
{ "index" : {"_id" : "B006" } }
{"productCode": "B006","productName": "デニムパンツ","brand": "ブランドB","gender": "レディース","category": "パンツ","price": "2500","color": "ホワイト","size": "L"}
{ "index" : {"_id" : "C001" } }
{"productCode": "C001","productName": "長袖Tシャツ","brand": "ブランドC","gender": "メンズ","category": "Tシャツ","price": "1500","color": "ホワイト","size": "S"}
{ "index" : {"_id" : "C002" } }
{"productCode": "C002","productName": "長袖Tシャツ","brand": "ブランドC","gender": "メンズ","category": "Tシャツ","price": "1500","color": "ホワイト","size": "M"}
{ "index" : {"_id" : "C003" } }
{"productCode": "C003","productName": "長袖Tシャツ","brand": "ブランドC","gender": "メンズ","category": "Tシャツ","price": "1500","color": "ホワイト","size": "L"}
{ "index" : {"_id" : "C004" } }
{"productCode": "C004","productName": "長袖Tシャツ","brand": "ブランドC","gender": "キッズ","category": "Tシャツ","price": "1000","color": "グレー","size": "S"}
{ "index" : {"_id" : "C005" } }
{"productCode": "C005","productName": "長袖Tシャツ","brand": "ブランドC","gender": "キッズ","category": "Tシャツ","price": "1000","color": "グレー","size": "M"}
{ "index" : {"_id" : "C006" } }
{"productCode": "C006","productName": "長袖Tシャツ","brand": "ブランドC","gender": "キッズ","category": "Tシャツ","price": "1000","color": "グレー","size": "L"}
4-3.API作成
続いてChaliceを使った検索用APIの作成方法を解説します。
(1)ライブラリのインストールとプロジェクト作成
まずは開発環境のマシンにChaliceやOpenSearchクライアントなど、必要なライブラリをインストールします。
$ pip install chalice
$ pip install opensearch-py
$ pip install requests
$ pip install requests-aws4auth
続いて以下のコマンドでChaliceの新規プロジェクトを作成します。
$ chalice new-project product_search_sample
そうすると以下の構成でディレクトリとファイルが生成されます。
product_search_sample/
├── app.py
├── .chalice
│ └── config.json
└── requirements.txt
(2)検索API作成
続いて検索API用のコードを書いていきます。
今回使用するOpenSearch用のクエリはこんな感じです。_searchエンドポイントを使用しており、ブランドや性別などの属性値を指定することで条件に一致する商品の一覧が返ってくるようになっています。
GET /test-products/_search
{
"from" : 0,
"size": 50,
"track_total_hits" : true,
"sort" : [
{"productName" : {"order" : "asc"}}
],
"query" : {
"bool" : {
"must" : [
{
"terms" : {
"brand" : ["ブランドA", "ブランドB", "ブランドC"]
}
},
{
"terms" : {
"gender" : ["メンズ"]
}
},
{
"terms" : {
"category" : ["Tシャツ"]
}
},
{
"terms" : {
"color" : ["ホワイト"]
}
},
{
"terms" : {
"size" : ["L"]
}
},
{
"range" : {
"price" : {
"gte" : 0,
"lt" : 3000
}
}
}
]
}
}
}
クエリ内で指定している各条件の意味は以下の通りです。
項目 | 意味 |
---|---|
from | 指定した数字(番号)以降の検索結果を取得する。ページネーションなどで使用。 |
size | 検索結果として取得する件数を指定する。50の場合は上位50件だけ取得。 |
track_total_hits | trueに設定すると、1万件以上のデータを検索対象にするときに正確なcountが取れる。 |
sort | ソート順を指定する。 |
query | この中に検索条件を指定する。 |
bool, must | 複数の検索条件を連結する際に使用。mustの場合はAND条件になる。 |
terms | keyword型のフィールドに対する検索に使用。条件に完全一致するデータを抽出する。条件は配列で複数指定可能。 |
range | 数値型のフィールドに対する検索に使用。範囲指定できる。gteは"以上"、ltは"未満"の扱いになる。 |
以下、最終的なChaliceのコードを記載していきます。まずディレクトリ構成はこのような形です。新たにcustom-policy_dev.jsonを追加しています。
product_search_sample/
├── app.py ※コード修正
├── .chalice
│ ├── config.json ※コード修正
│ └── custom-policy_dev.json ※ファイル追加
└── requirements.txt ※コード修正
app.pyはこんな感じです。リクエストボディからJSON形式で検索条件を受け取り、OpenSearch用のクエリを生成して実行、結果を整形して返却します。今回はざっくりしたサンプルコードなので、モジュール分割などはせず全ての処理をapp.pyに押し込んでいます。またデータチェックやエラー制御などは端折ってますのでご了承ください。
import json
import traceback
import boto3
from opensearchpy import OpenSearch, RequestsHttpConnection
from requests_aws4auth import AWS4Auth
from chalice import Chalice
from chalice import Response
app = Chalice(app_name='product_search_sample')
# CloudFrontの制約で、GETではリクエストボディが使えないためPOSTにする
@app.route('/productSearch', methods=['POST'], cors=True)
def index():
try:
# 検索条件取得
searchCondition = getSearchCondition()
# 検索クエリの組み立て
query = createQuery(searchCondition)
# OpenSearchへの接続と検索
searchResultsFromOs = executeQuery(query)
# 返却用JSONの生成
responseData = createResponseData(searchResultsFromOs)
# API応答値の返却
return Response(
body = json.dumps(responseData, ensure_ascii=False),
headers = {'Content-Type': 'application/json'},
status_code = 200
)
except Exception as e:
# スタックトレース出力とエラー応答
print(traceback.format_exc())
responseData = {"message" : "内部エラーが発生しました"}
return Response(
body = json.dumps(responseData, ensure_ascii=False),
headers = {'Content-Type': 'application/json'},
status_code = 500
)
def getSearchCondition():
'''
リクエストボディから検索条件を抽出する
※入力チェックなどは特に行ってないので、必要に応じて実装
'''
body = app.current_request.json_body
# 念のためリクエストボディの内容を組み換え
searchCondition = dict()
if body.get("brand"):
searchCondition["brand"] = body["brand"]
if body.get("gender"):
searchCondition["gender"] = body["gender"]
if body.get("category"):
searchCondition["category"] = body["category"]
if body.get("price"):
searchCondition["price"] = body["price"]
if body.get("color"):
searchCondition["color"] = body["color"]
if body.get("size"):
searchCondition["size"] = body["size"]
return searchCondition
def createQuery(searchCondition):
'''
OpenSearchに対して投げるクエリを生成する
'''
# ベースとなるクエリ
query = {
"from" : 0,
"size": 50,
"track_total_hits" : True,
"sort" : [
{"productName" : {"order" : "asc"}}
],
"query" : {
"bool" : {
"must" : []
}
}
}
# 検索条件が存在する場合、MUST句(AND)に検索条件を詰め込む
if searchCondition:
for key in searchCondition.keys():
searchParameKey = key
searchParamValue = searchCondition.get(key)
if key == "price":
# 検索条件がpriceの場合は数値での条件を指定
query["query"]["bool"]["must"].append(
{
"range" : {
searchParameKey : {
"gte" : searchParamValue[0],
"lt" : searchParamValue[1]
}
}
}
)
else:
# price以外は文字列検索
query["query"]["bool"]["must"].append(
{
"terms" : {searchParameKey : searchParamValue}
}
)
return query
def executeQuery(query):
'''
OpennSearchへ接続し、検索クエリを投げて結果を返す
'''
# 接続文字列
host = 'xxxxxx.ap-northeast-1.es.amazonaws.com'
port = 443
region = 'ap-northeast-1'
service = 'es'
credentials = boto3.Session().get_credentials()
awsauth = AWS4Auth(credentials.access_key, credentials.secret_key, region, service, session_token=credentials.token)
indexName = 'test-products'
try:
# ES接続とクエリ実行
osClient = OpenSearch(
hosts = [{'host':host, 'port': port}],
http_auth = awsauth,
use_ssl = True,
verify_certs = True,
connection_class = RequestsHttpConnection
)
searchResultsFromOs = osClient.search(index=indexName, body=query)
return searchResultsFromOs
except Exception as e:
# スタックトレース出力
print(traceback.format_exc())
raise e
def createResponseData(searchResultsFromOs):
'''
OpenSearchの検索結果を受け取り、APIの応答値を生成する
'''
# 最終的な応答値の雛形を定義
responsData = {
"status" : "success",
"totalCount" : 0,
"results" : [],
}
# 検索結果が存在しない場合は早々に処理終了
totalCount = searchResultsFromOs["hits"]["total"]["value"]
if totalCount == 0:
return responsData
# 応答値の生成
responsData["totalCount"] = totalCount
for result in searchResultsFromOs["hits"]["hits"]:
source = result["_source"]
responsData["results"].append(
{
"productCode" : source["productCode"],
"productName" : source["productName"],
"brand" : source["brand"],
"gender" : source["gender"],
"category" : source["category"],
"price" : source["price"],
"color" : source["color"],
"size" : source["size"]
}
)
return responsData
config.jsonはこんな感じ。autogen_policyとiam_policy_fileを追加して、Lambadに割り当てるロールを自分で指定できるようにしています。ロールに割り当てる権限はcustom-policy_dev.jsonで定義します。
{
"version": "2.0",
"app_name": "product_search_sample",
"stages": {
"dev": {
"api_gateway_stage": "api",
"autogen_policy": false, ※追加
"iam_policy_file": "custom-policy_dev.json" ※追加
}
}
}
custom-policy_dev.jsonはこんな感じ。CloudWatch LogsとOpenSearch(Elasticsearch)へのアクセス権限を追加しています。今回はサンプルなのでOpenSearchの権限を広めにとっていますが、セキュリティが甘くなるのも微妙なのでこの辺は必要に応じて調整してください。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAccessCloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:CreateLogGroup",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
},
{
"Sid": "AllowElasticsearch",
"Effect": "Allow",
"Action": [
"es:*"
],
"Resource": "*"
}
]
}
requirements.txtはこんな感じ。Lambda実行時に必要になるライブラリを定義しています。
requests==2.26.0
requests-aws4auth==1.1.1
opensearch-py==1.0.0
(3)APIのデプロイと実行
コードができたら「chalice deploy」でデプロイしましょう。以下の通り、コマンド一発で必要なLambda関数とAPI Gateway定義が全て作成されます。
$ chalice deploy
Creating deployment package.
Creating IAM role: product_search_sample-dev
Creating lambda function: product_search_sample-dev
Creating Rest API
Resources deployed:
- Lambda ARN: arn:aws:lambda:ap-northeast-1:xxxxxxxxx:function:product_search_sample-dev
- Rest API URL: https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/
ではcurlコマンドで今回デプロイしたAPIを叩いてみましょう。まずは商品カテゴリが「Tシャツ」の商品一覧を取得してみます。クエリはこちら。
$ curl -H "Content-Type: application/json" \
-d '{"category" : ["Tシャツ"]}' \
-XPOST https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/productSearch
結果はこうなりました。ちゃんと取れましたね。
{
"status": "success",
"totalCount": 6,
"results": [
{
"productCode": "C001",
"productName": "長袖Tシャツ",
"brand": "ブランドC",
"gender": "メンズ",
"category": "Tシャツ",
"price": "1500",
"color": "ホワイト",
"size": "S"
},
{
"productCode": "C006",
"productName": "長袖Tシャツ",
"brand": "ブランドC",
"gender": "キッズ",
"category": "Tシャツ",
"price": "1000",
"color": "グレー",
"size": "L"
},
~~~(以下略)~~~
さらにその他の属性(ブランド、性別、価格)で試してみましょう。
$ curl -H "Content-Type: application/json" \
-d '{"brand": ["ブランドA", "ブランドB", "ブランドC"], "gender" : ["メンズ"], "price" : [0, 3000]}' \
-XPOST https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/productSearch
結果はこちら。ちゃんと取れてますね。
{
"status": "success",
"totalCount": 9,
"results": [
{
"productCode": "A001",
"productName": "無地スキニーパンツ",
"brand": "ブランドA",
"gender": "メンズ",
"category": "パンツ",
"price": "2000",
"color": "ホワイト",
"size": "S"
},
{
"productCode": "A002",
"productName": "無地スキニーパンツ",
"brand": "ブランドA",
"gender": "メンズ",
"category": "パンツ",
"price": "2000",
"color": "ホワイト",
"size": "M"
},
~~~(以下略)~~~
5.さいごに
今回はAWSのOpenSearch Serviceを使って簡単な商品検索APIを作ってみました。これだけだとRDB使った方が楽じゃんと思われるかもしれませんが、OpenSearchは大量データを取り扱った時にその真価を発揮します。商品点数が100万、1,000万と増えていっても安定した性能を出せるのがOpenSearchのいいところで、実際に使ってみた感じ、非力なマシンを使ってもRDBに比べて性能は出やすいように思えます。
さらにAggregation等の機能を使えば検索結果に対する集計や分析なんかもできるので、RDBだと処理が重くて諦めてしまいがちな高度な検索機能も実現できたりします。この辺りについてはまた別の記事に書けたらいいなと思っています。
この記事が誰かのお役に立てると幸いです。
Discussion