暇 ひまネタNEWS(^o^) | スキマ時間にちょっとしたネタを提供スキマ時間に、話題のまとめを一気読み 全サイト自動巡回中
HIMA NETA ORIGINAL READER
本文だけモード Zenn IT・ガジェット 約20分で読めます

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=["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での採点は、人手ラベルと傾向が近いクエリもあれば、大きくずれるクエリもあり、結果はモデルによっても変わりそうです。今後としては、上位のモデルやプロンプトを変えて、より多くのクエリで比較してみたいです。

参考

nextbeat Tech Blog

株式会社ネクストビート所属のエンジニアが運営するテックブログです。ネクストビートは、テクノロジーと推進力、そして熱いベンチャーマインドで、ライフイベント・地方創生・グローバルの各事業に取り組んでいます。 なお、投稿内容は社員個人の見解であり、所属する組織を代表するものではありません。

Discussion

元のページを確認する

必要な場合のみ、広告などを含む配信元ページをフレームで表示できます。