はじめに
Search Relevance Workbench(SRW)は、OpenSearch 3.1で導入された検索品質を評価するための機能です。クエリと検索設定、正解データを組み合わせてNDCGなどの指標を計算できます。正解データは自分で用意するのが一般的ですが、SRWにはLLMに検索結果の関連度を採点させて正解データを作る、LLM-as-a-Judgeという機能も用意されています。
英語の紹介記事はありましたが、日本語のデータで試した例は見当たらなかったので、今回はAmazonの商品検索データセット(ESCI)の日本語データを使って実際に動かしてみました。ESCIには人が付けた関連度ラベルがあるので、いくつかの例でLLMの採点と見比べてみます。
使ったものは以下のとおりです。
- OpenSearch / OpenSearch Dashboards 3.9.0(Docker)
- analysis-kuromoji 3.9.0
- Gemini 3.5 Flash-Lite(Gemini API)
- Shopping Queries Dataset(ESCI)の日本語データ
- Python 3.12(uv)
SRWとESCIについて
SRWは、以下の4つの部品を組み合わせて検索品質を評価します。
- Query set:評価に使うクエリ
- Search configuration:検索の設定(どのインデックスを、どんなクエリで検索するか)
- Judgment list:正解データ(クエリ・文書・評価値の組)
- Experiment:検索設定の結果を正解データで採点して、NDCGなどを計算する
正解データは、外部で作った評価値を取り込む(IMPORT_JUDGMENT)こともできますが、今回はLLMに検索結果を採点させるLLM_JUDGMENTを試します。採点は0〜1の段階評価(SCORE0_1)か、関連あり/なしの二択(RELEVANT_IRRELEVANT)を選べます。
ESCIはAmazonが公開している商品検索のデータセットで、クエリと候補商品の組に、人が以下の4段階のラベルを付けています。
- E(Exact):条件をすべて満たす
- S(Substitute):一部違うが代わりに使える
- C(Complement):一緒に使うもの
- I(Irrelevant):無関係
環境構築
Dockerを使ってOpenSearchとDashboardsを起動します。Dockerfileでは、OpenSearch公式のDockerイメージをベースに、日本語の形態素解析を行うkuromojiプラグインをインストールします。
ARG OPENSEARCH_VERSION=3.9.0
FROM opensearchproject/opensearch:${OPENSEARCH_VERSION}
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-kuromoji
# 3.9.0のSRWプラグインにはslf4j-apiが入っておらず、LLM_JUDGMENTでLLMの応答を受け取ると
# NoClassDefFoundError: org/slf4j/LoggerFactoryでOpenSearchが落ちる。ML Commonsのものをコピーして補う
RUN cp /usr/share/opensearch/plugins/opensearch-ml/slf4j-api-1.7.36.jar \
/usr/share/opensearch/plugins/opensearch-search-relevance/
1ノード構成でコンテナを定義します。今回は動作確認が目的なので、セキュリティ設定を無効化しています。
name: srw-esci-ja
services:
opensearch:
build:
context: ./opensearch
args:
OPENSEARCH_VERSION: 3.9.0
image: srw-esci-ja/opensearch-kuromoji:3.9.0
container_name: srw-opensearch
cpus: 4 # LLM_JUDGMENTが同時にLLMを呼ぶ数はCPUの数で決まるので、APIの回数制限に当たらないよう絞る
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
- DISABLE_INSTALL_DEMO_CONFIG=true # 動作確認用なのでdisableにする
- DISABLE_SECURITY_PLUGIN=true # 動作確認用なのでdisableにする
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-data:/usr/share/opensearch/data
ports:
- 9200:9200
dashboards:
image: opensearchproject/opensearch-dashboards:3.9.0
container_name: srw-dashboards
environment:
- OPENSEARCH_HOSTS=["http://opensearch:9200"]
- DISABLE_SECURITY_DASHBOARDS_PLUGIN=true # 動作確認用なのでdisableにする
ports:
- 5601:5601
depends_on:
- opensearch
volumes:
opensearch-data:
コンテナを起動します。
$ docker compose -f docker/docker-compose.yml up -d --build
商品データを入れる
ESCIのデータは、amazon-science/esci-dataのリポジトリからダウンロードします。parquetファイルはGit LFSで管理されているので、github.com/.../raw/...のURLを指定します。
$ curl -L -O https://github.com/amazon-science/esci-data/raw/refs/heads/main/shopping_queries_dataset/shopping_queries_dataset_examples.parquet
$ curl -L -O https://github.com/amazon-science/esci-data/raw/refs/heads/main/shopping_queries_dataset/shopping_queries_dataset_products.parquet
ESCIのラベルは、クエリごとの候補商品にしか付いていません。今回は日本語データから一部のクエリを選び、その候補商品だけをインデックスに入れます。
ここからのOpenSearchへのリクエストは、DashboardsのDev Tools(http://localhost:5601/app/dev_tools#/console)から実行します。
まず、以下のマッピングでproducts_kuromojiインデックスを作成します。テキストのフィールドにはkuromojiを使い、どのクエリの候補かを表すcandidate_queriesは、完全一致で検索するためにkeyword型にしています。
PUT products_kuromoji
{
"mappings": {
"properties": {
"product_id": { "type": "keyword" },
"product_title": { "type": "text", "analyzer": "kuromoji" },
"product_brand": { "type": "text", "analyzer": "kuromoji" },
"product_bullet_point": { "type": "text", "analyzer": "kuromoji" },
"product_description": { "type": "text", "analyzer": "kuromoji" },
"candidate_queries": { "type": "keyword" } // どのクエリの候補か
}
}
}
作成したインデックスに、候補商品をPythonで取り出して投入しました。
LLMをつなぐ
SRWは、ML Commonsのコネクタを経由して外部のLLMを呼び出します。ml-commonsのリポジトリにSRW用のGeminiコネクタの例があるので、それを参考にしました。今回はLLMにGemini 3.5 Flash-Liteを使います。APIキーはGoogle AI Studioで発行したものを使います。
まず、コネクタの接続先を許可します。
PUT _cluster/settings
{
"persistent": {
"plugins.ml_commons.trusted_connector_endpoints_regex": [
"^https://generativelanguage\\.googleapis\\.com/.*$"
],
"plugins.ml_commons.only_run_on_ml_node": false
}
}
次に、コネクタを登録します。例から変えたのは、タイムアウトと再試行の設定(client_config)と、LLMの応答を読み取る処理(post_process_function)の2か所です。デフォルトのタイムアウト(30秒)と再試行(0回)では足りなかったので延ばしています。また、Gemini 3.5 Flash-Liteでは、採点結果のJSONがエスケープされた形({\"ratings\": ...)で返ってくることがあり、そのままだとSRWが読めずに未採点になってしまうので、replaceで戻してから渡しています。
POST _plugins/_ml/connectors/_create
{
"name": "Gemini gemini-3.5-flash-lite",
"version": "1",
"protocol": "http",
"parameters": { "model": "gemini-3.5-flash-lite" },
"credential": { "gemini_key": "<API キー>" },
"client_config": {
"read_timeout": 180,
"max_retry_times": 5,
"retry_backoff_policy": "exponential_full_jitter",
"retry_backoff_millis": 5000,
"retry_timeout_seconds": 300,
"connection_timeout": 30
},
"actions": [
{
"action_type": "predict",
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/${parameters.model}:generateContent",
"headers": {
"Content-Type": "application/json",
"x-goog-api-key": "${credential.gemini_key}"
},
"request_body": "{\"systemInstruction\":{\"parts\":[{\"text\":\"${parameters.system_prompt}\"}]},\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"${parameters.user_prompt}\"}]}]}",
"post_process_function": "def text = params.candidates[0].content.parts[0].text.replace('\\\\\"', '\"'); return '{\"name\":\"response\",\"dataAsMap\":{\"response\":\"' + escape(text) + '\"}}'"
}
]
}
最後に、モデルとして登録してデプロイします。
POST _plugins/_ml/model_groups/_register
{ "name": "srw_llm_judgment" }
POST _plugins/_ml/models/_register?deploy=true
{
"name": "Gemini gemini-3.5-flash-lite",
"function_name": "remote",
"model_group_id": "<model_group_id>",
"connector_id": "<connector_id>"
}
LLMに採点させてみる
商品データを入れたproducts_kuromojiインデックスで、「電源なしスピーカー」の検索結果の上位5件をLLMに採点させてみます。
まず、クエリセットと検索設定を登録します。検索設定の%SearchText%には、クエリセットのクエリが入ります。
PUT _plugins/_search_relevance/query_sets
{
"name": "handson",
"sampling": "manual",
"querySetQueries": [{ "queryText": "電源なしスピーカー" }]
}
PUT _plugins/_search_relevance/search_configurations
{
"name": "bm25-kuromoji",
"index": "products_kuromoji",
"query": "{\"query\":{\"multi_match\":{\"query\":\"%SearchText%\",\"fields\":[\"product_title^2\",\"product_brand\",\"product_bullet_point\",\"product_description\"]}}}"
}
LLM_JUDGMENTで採点させます。
PUT _plugins/_search_relevance/judgments
{
"name": "handson-score",
"type": "LLM_JUDGMENT",
"modelId": "<model_id>",
"querySetId": "<query_set_id>",
"searchConfigurationList": ["<search_configuration_id>"],
"size": 5, // 検索結果の上位何件を採点するか
"contextFields": ["product_title", "product_brand", "product_bullet_point"], // LLMに渡すフィールド
"llmJudgmentRatingType": "SCORE0_1" // 0〜1の段階評価
}
採点は非同期で進むので、少し待ってから結果を取得します。
GET _plugins/_search_relevance/judgments/<judgment_id>
statusがCOMPLETEDになっていて、商品ごとに点数が付いていれば成功です。一部の商品が採点できなかった場合もCOMPLETEDになるので、failuresに商品が入っていないかも確認します。入っていた場合は、POST _plugins/_search_relevance/judgments/<judgment_id>/_retryで再実行できます。
結果を商品名と並べると、以下のようになりました。
| 順位 | 商品(タイトルの要約) | LLM |
|---|---|---|
| 1 | 木製無電源スピーカー | 1.0 |
| 2 | PC用スピーカーUSB電源 | 0.2 |
| 3 | ポータブルスピーカー(電源不要) | 0.8 |
| 4 | ミニスピーカーUSB電源 | 0.3 |
| 5 | 木製スピーカー(電源不要) | 1.0 |
電源を使わないスピーカーは0.8〜1.0と高く、USB給電のスピーカーは0.2〜0.3と低くなっていて、「電源なし」を文字どおりに解釈して採点しているようです。
人手ラベルと比べてみる
ESCIには人手で付けた関連度ラベルがあるので、LLMの採点と比べてみます。いくつか試してみた中で、うまくいった例と、人手ラベルとずれた例を見てみます。
LLM_JUDGMENTは、検索結果の上位の商品だけを採点します。今回は人手ラベルと同じ商品で比べたいので、検索の代わりに、そのクエリの候補商品(人手ラベルが付いている商品)をすべて返す検索設定を、先ほどのマッピングで用意したcandidate_queriesを使って作りました。
PUT _plugins/_search_relevance/search_configurations
{
"name": "esci-candidates",
"index": "products_kuromoji",
"query": "{\"query\":{\"term\":{\"candidate_queries\":\"%SearchText%\"}}}"
}
この検索設定を使って、LLM_JUDGMENTを実行します。
PUT _plugins/_search_relevance/judgments
{
"name": "full-score01",
"type": "LLM_JUDGMENT",
"modelId": "<model_id>",
"querySetId": "<query_set_id>",
"searchConfigurationList": ["<esci-candidates の ID>"],
"size": 40, // 候補の最大件数
"tokenLimit": 50000, // 1リクエストのトークン上限で、分割を減らすため大きめに設定
"contextFields": ["product_title", "product_brand", "product_bullet_point"],
"llmJudgmentRatingType": "SCORE0_1"
}
取得した点数を、ESCIの人手ラベルと並べてみます。
人手ラベルと傾向が近かった例:液体ミルク
人手ラベルがEの液体ミルクは、LLMもすべて1.0にしていました。Sの粉ミルクやキューブタイプのミルクは0.3〜0.4で、人手ラベルと整合的な傾向が見られました。
| 商品(タイトルの要約) | LLM | 人手ラベル |
|---|---|---|
| 明治 ほほえみ らくらくミルク240ml(液体ミルク) | 1.0 | E |
| アイクレオ 赤ちゃんミルク125ml(液体ミルク) | 1.0 | E |
| 明治 ほほえみ らくらくキューブ | 0.4 | S |
| 和光堂 レーベンスミルク はいはい(粉ミルク) | 0.3 | S |
人手ラベルとずれた例:電源なしスピーカー
先ほど試したクエリです。人手ラベルは多くの商品がEですが、LLMは電源をまったく使わないスピーカーだけを高く採点していました。
| 商品(タイトルの要約) | LLM | 人手ラベル |
|---|---|---|
| 木製無電源スピーカー | 1.0 | E |
| ミニスピーカーUSB電源 | 0.3 | E |
| Bluetoothスピーカー(充電式) | 0.2 | E |
| スピーカーケーブル | 0.1 | E |
人手ラベルでは、USB給電や充電式のスピーカーもEになっています。たとえば「ミニスピーカーUSB電源」は人手ラベルがEですが、LLMは0.3でした。USBから給電する点を、LLMは「電源あり」と判断したと考えられます。同じように「Bluetoothスピーカー(充電式)」もEですが、LLMは0.2で、充電が必要なことから低く採点したようです。「電源なし=コンセントが不要」と読めば、これらはEとも言えるので、ここはLLMと人手の解釈の違いのようです。一方で、「スピーカーケーブル」は人手ラベルがE、LLMは0.1でした。スピーカー本体ではないため、LLMの採点のほうが自然に見えます。ケーブルがEになっている点は解釈の違いでは説明しにくく、人手ラベル自体にも揺れがあるようです。
終わりに
今回は、OpenSearchのSRWのLLM-as-a-Judgeを日本語の商品データで試してみました。Gemini 3.5 Flash-Liteでの採点は、人手ラベルと傾向が近いクエリもあれば、大きくずれるクエリもあり、結果はモデルによっても変わりそうです。今後としては、上位のモデルやプロンプトを変えて、より多くのクエリで比較してみたいです。
参考
Discussion
