OpenSearchのLLM-as-a-Judgeを日本語の商品検索で動かして、人手ラベルと比べてみた
はじめに
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プラグインをインストールします。
docker/opensearch/Dockerfile
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ノード構成でコンテナを定義します。今回は動作確認が目的なので、セキュリティ設定を無効化しています。
docker/docker-compose.yml
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=["opensearch"]
- 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 github.com/amazon-science/esci-data/raw/refs/head…
$ curl -L -O github.com/amazon-science/esci-data/raw/refs/head…
ESCIのラベルは、クエリごとの候補商品にしか付いていません。今回は日本語データから一部のクエリを選び、その候補商品だけをインデックスに入れます。
ここからのOpenSearchへのリクエストは、DashboardsのDev Tools(localhost)から実行します。
まず、以下のマッピングで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": [
"^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": "generativelanguage.googleapis.com",
"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での採点は、人手ラベルと傾向が近いクエリもあれば、大きくずれるクエリもあり、結果はモデルによっても変わりそうです。今後としては、上位のモデルやプロンプトを変えて、より多くのクエリで比較してみたいです。
参考
amazon-science/esci-data(ライセンス:Apache-2.0)
Reddy et al., Shopping Queries Dataset: A Large-Scale ESCI Benchmark for Improving Product Search (2022)
OpenSearch Docs: Search Relevance Workbench
OpenSearch Docs: Judgments
ml-commons: Google Gemini connector blueprint for LLM judgment
Mentions
OpenSearchのLLM-as-a-Judgeを日本語の商品検索で動かして、人手ラベルと比べてみた はじめに 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プラグインをインストールします。 docker/opensearch/Dockerfile 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ノード構成でコンテナを定義します。今回は動作確認が目的なので、セキュリティ設定を無効化しています。 docker/docker-compose.yml 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=["opensearch"] - 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 github.com/amazon-science/esci-data/raw/refs/head… $ curl -L -O github.com/amazon-science/esci-data/raw/refs/head… ESCIのラベルは、クエリごとの候補商品にしか付いていません。今回は日本語データから一部のクエリを選び、その候補商品だけをインデックスに入れます。 ここからのOpenSearchへのリクエストは、DashboardsのDev Tools(localhost)から実行します。 まず、以下のマッピングで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": [ "^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": "generativelanguage.googleapis.com", "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での採点は、人手ラベルと傾向が近いクエリもあれば、大きくずれるクエリもあり、結果はモデルによっても変わりそうです。今後としては、上位のモデルやプロンプトを変えて、より多くのクエリで比較してみたいです。 参考 amazon-science/esci-data(ライセンス:Apache-2.0) Reddy et al., Shopping Queries Dataset: A Large-Scale ESCI Benchmark for Improving Product Search (2022) OpenSearch Docs: Search Relevance Workbench OpenSearch Docs: Judgments ml-commons: Google Gemini connector blueprint for LLM judgment