ミツカリ技術ブログ

株式会社ミツカリの開発チームのブログです

Elasticsearchでネットスーパーを題材にハイブリッド検索を試してみる

こんにちは、ミツカリCTOの塚本こと、つかびー(@tsukaby0) です。

先日友人たちと新年会をしてきたのですが、そこで検索技術に関する話を少ししました。

私は門外漢なのですが、友人は大手企業で検索を専門にしているスペシャリストです。そこでキーワード検索とセマンティック検索のハイブリッド検索を行っているという話を聞きました。興味がある領域ですが、Elasticsearch等は触れる機会が少ないので、今回はハイブリッド検索に挑戦してみることにします。

今回の記事ではデータセットをElasticsearchに投入し、キーワード検索のみ、セマンティック検索のみ、ハイブリッド検索などの複数手法の精度を比較してみたいと思います。

検索手法の解説

実験に入る前に、今回使う検索手法の用語と、それぞれの得意・不得意を整理しておきます。

キーワード検索(BM25)とは

ElasticsearchのデフォルトのスコアリングアルゴリズムはOkapi BM25です。

BM25のベースとなるアルゴリズムにTF-IDFがあります。これはクエリに含まれる単語がドキュメント内に何回出現するか(TF: Term Frequency)と、その単語がコーパス全体でどれだけ珍しいか(IDF: Inverse Document Frequency)を組み合わせて関連度スコアを計算します。しかし、この手法は1つの単語がドキュメント内に何度も出現する場合、有利になってしまうという欠点を持っていました。(一昔前のSEOのテクに同じ単語を何回も文書中で使うというのがありませんでしたっけ?)

BM25はそのような何度も出現する場合でも有利にならないような調整や、長い文章は単語を多く含むので有利にならないような正規化が加えられた改良版のアルゴリズムです。

BM25については以下のwm3さんの記事が参考になります。

zenn.dev

BM25では固有名詞の完全一致(例えば「アサヒスーパードライ」)や属性の絞り込み(例えば「低脂肪 牛乳」で、この場合牛乳より低脂肪の方がレア単語であるため、それがスコア的に優先される)は得意です。

逆に意味的には同じだが、単語的には別であるケース(例えばチキンと鶏肉)は苦手です。

セマンティック検索(ベクトル検索 / kNN検索)とは

セマンティック(semantic)は「意味の」という意味の英単語です。つまりセマンティック検索とは、文字列・キーワードの一致ではなく「意味」に基づいて検索する手法です。

具体的には、テキストをembeddingモデルで数値ベクトルに変換し、ベクトル同士の類似度で検索します。Elasticsearchではdense_vectorフィールドにベクトルを格納し、kNN検索を行います。

やり方については以下の記事が参考になると思います。

www.elastic.co

qiita.com

BM25が「同じ単語が含まれているか」を見るのに対し、セマンティック検索は「意味的に近いか」を見ます。これにより、語彙のミスマッチ問題を大幅に緩和できます。理論上は「チキン」→「鶏肉」や、意図ベースの検索である「カレー 材料」→じゃがいも、にんじん、カレールーといった検索が可能になります(ただし、embeddingモデルの品質に大きく依存します。これについては後述の実験で検証します)。

キーワード検索の上位互換のようにも思えますが、苦手な検索もあります。例えば固有名詞の正確なマッチです。「アサヒスーパードライ」と検索しても、意味的に近い「キリン一番搾り」や「サントリープレミアムモルツ」が混ざる可能性があります。他にも「低脂肪 牛乳」と検索しても、ベクトル空間上では普通の牛乳と低脂肪牛乳の距離が近いため、普通の牛乳が上位に来ることがあります。

BM25と違ってベクトル計算というオーバーヘッドもあります。

ハイブリッド検索(RRF)とは

ハイブリッド検索は、キーワード検索とセマンティック検索を組み合わせて、両方の強みを活かすアプローチです。

Elastic社が良い解説記事を出してくれているので詳細はそちらを読むと良いと思います。

www.elastic.co

Elasticsearchでは、RRF(Reciprocal Rank Fusion)というランキング統合手法が利用できます。

RRFはスコアの値そのものではなく、各検索手法での順位(ランキング)に基づいて統合を行います。

RRFについてはOpenSearch版ですが、以下の翻訳された記事が参考になると思います。

zenn.dev

Elastic社のブログによると、RRFを用いたハイブリッド検索はBM25単体と比べてnDCG@10が18%向上するという報告があります。

Reciprocal Rank Fusion increases average NDCG@10 by 1.4% over Elastic Learned Sparse Encoder alone and 18% over BM25 alone. 引用: https://www.elastic.co/search-labs/jp/blog/improving-information-retrieval-elastic-stack-hybrid

nDCG@10は検索精度の標準的な評価指標で、上位10件の検索結果がどれだけ正解と一致しているかを0〜1のスコアで表します。上位に正解が多いほどスコアが高くなるため、ランキングの質を測るのに適しています。

なお、nDCG@10は評価指標であって、正解はそれぞれ自身で定義・用意する必要があります。

ハイブリッド検索の利点はキーワード検索とセマンティック検索のいいとこどりができるという点です。

実験

ここまでの流れで検索手法は一通り予習できました。キーワード検索もベクトル検索もそれぞれプロコンがあるので、どちらかだけでは不十分な結果になり、両方を組み合わせたハイブリッド検索では精度が上がりそうです。

ここからは実際にテストデータを用意して、そのデータに対してそれぞれの検索を行うことで精度を検証してみたいと思います。

検索精度の評価には、本来であれば前述のnDCG@10といった情報検索の標準的な指標を使い、人手で作成した正解データと比較するのが正攻法です。しかし、正解データの作成にはドメイン知識を持つ人間がクエリごとに関連度を検討する必要があり、かなりの手間がかかります。

今回は簡略化のため、8つのテストクエリに対して各検索手法のTop 5結果を目視で比較し、「期待する商品が上位に出ているか」「ノイズ(無関係な商品)が混ざっていないか」を定性的に評価します。厳密なベンチマークではありませんが、各手法の得意・不得意の傾向を掴むには十分です。また、ハイブリッド検索を試すことが目的なので、精度は二の次とします。

今回利用するデータセット

今回は生成AIによって独自にデータを用意します。

zenn.dev

こちらのrejasupotaroさんが紹介されているAmazon ESCIデータはかなり魅力的ですが、ECサイトのデータはかなり種類が多いため、今回は使いません

LLMの力を借りてそれっぽいネットスーパーのデータを用意してみます。ネットスーパーを題材にする理由は私がよく利用するからです。また、身近な商品であり、ECより種類が少ないためです。オープンな日本の商品をまとめたデータは無いようなので、自前で用意するしかないですが、題材としては良いかなと思っています。

プロンプト

ネットスーパーの商品データを{category}カテゴリについて100件生成してください。

西友ネットスーパーの実際のカテゴリ構成に基づき、以下のカテゴリごとにこのプロンプトを実行します。
- 野菜 / 果物 / お肉 / お魚 / お惣菜・お弁当 / ハム・ソーセージ・チルド調理品
- 卵・牛乳・乳製品 / 豆腐・納豆・漬物・練物 / 冷凍食品・アイス
- お米・麺・パスタ / パン・ジャム・シリアル / 食油・カレー・スープ・調味料
- 缶詰・粉類・乾物 / お菓子・スイーツ / 飲料・お水 / お酒・ノンアルコール
- 紙・生理用品・介護 / 美容・衛生 / 日用品・雑貨 / キッチン用品 / ベビー / ペット

各商品には以下のフィールドを含めてください。
- product_id: "{category_prefix}-001"〜"{category_prefix}-100" の形式
- product_name: 商品名(例: "北海道産 特選ゴーダチーズ 200g")
- category: カテゴリ名
- price: 価格(整数、円単位)
- description: 商品の説明文(50〜100文字程度。原材料・産地だけでなく、おすすめの食べ方や用途も含めること)
- tags: 関連タグの配列(例: ["チーズ", "北海道", "おつまみ"])

データの多様性について、以下の点を意識してください。
- 同じ食材でも複数の表現を使う(例: 鶏肉/チキン、トマト/ミニトマト/プチトマト、牛乳/ミルク)
- 説明文にはその商品が使われる料理名やシーンを含める(例: "カレーの具材に最適" "お弁当のおかずにぴったり")
- ブランド名や産地名をリアルに入れる

JSONL形式(1行1オブジェクト)で出力してください。

結構時間がかかりました。15分くらいでしょうか。このブログにはjsonlは添付できないのでファイルは割愛します。

テストクエリ

全ステップを通して、以下の8つのクエリで検索精度を比較します。

# クエリ タイプ 狙い
Q1 トマト 単純キーワード ベースライン。どのステップでもヒットするはず。セマンティックだとやや不利?
Q2 ニンジン 表記ゆれ データには「にんじん」(ひらがな, 千葉県産)と「ニンジン」(カタカナ, 茨城県産)が混在。BM25では片方(茨城県産)しかヒットしない?
Q3 チキン 同義語 商品名は「鶏肉」だが「チキン」で検索する人はいるはず。データとしてはどちらもある。BM25だと鶏肉はまずヒットしないだろう
Q4 シーチキン 俗称 データには「ツナ缶」しかない。辞書にない俗称をセマンティックで拾えるか。シーチキンは商標なので少し厳しいか?
Q5 カレー 材料 用途 descriptionに「カレー」と書かれたじゃがいも・にんじん・カレー粉などが上位に来るか?セマンティック検索でいけるかも
Q6 味噌汁の具 用途 豆腐・わかめ・ほうれん草・しめじなど、descriptionに「味噌汁」を含む商品を横断的に拾えるか
Q7 低脂肪 牛乳 属性付き 「小岩井 牛乳低脂肪」がピンポイントで上位に来るか
Q8 アサヒスーパードライ 固有名詞 完全一致。BM25が最も得意とするパターン

セットアップ

何度でもやり直しが効くように、検索パターンごとにインデックスを分けて全データを投入しておきます。1つのElasticsearchコンテナ内に3つのインデックスを共存させます。

インデックス名 用途 アナライザ ベクトル
products_default Step 1: BM25ベースライン Standard(デフォルト) なし
products_kuromoji Step 2: 日本語対応BM25 kuromoji + synonym なし
products_vector Step 3 & 4: ベクトル検索 / ハイブリッド kuromoji + synonym あり

Step 3(kNNのみ)とStep 4(RRF)は検索方法が違うだけなので、同じインデックスを使います。

セットアップは2段階です。まずベクトルの事前生成(Python)、次にElasticsearchの起動とデータ投入(bash)を行います。

ベクトル生成

Step 3・4のベクトル検索では、商品テキストの「意味」を数値ベクトルに変換して類似度で検索します。BM25がキーワードの一致を見るのに対し、ベクトル検索は「チキン」と「鶏肉」、「カレー 材料」と「じゃがいも」のような、言葉は違うが意味的に近い関係を捉えられます。

このベクトル化にはembeddingモデルが必要で、Elasticsearchに投入する前に事前計算しておきます。あわせて、テストクエリのベクトルも生成しておくことで、実験スクリプトをbashだけで実行できるようにします。

mkdir es_workspace
cd es_workspace

# cp 事前に生成AIで作成した商品データ(all_products.jsonl)をコピーしておく

pip install sentence-transformers

embed_products.py として以下のファイルを保存。

import json
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("intfloat/multilingual-e5-small")

# --- 商品データのベクトル化 ---
with open("all_products.jsonl", "r") as f:
    products = [json.loads(line) for line in f]

for p in products:
    text = f"{p['product_name']} {p['description']}"
    p["embedding"] = model.encode(f"passage: {text}").tolist()

with open("all_products_with_embeddings.jsonl", "w") as f:
    for p in products:
        f.write(json.dumps(p, ensure_ascii=False) + "\n")

print(f"商品ベクトル化完了: {len(products)}件, 次元数: {len(products[0]['embedding'])}")

# --- テストクエリのベクトル事前生成 ---
queries = [
    "トマト", "ニンジン", "チキン", "シーチキン",
    "カレー 材料", "味噌汁の具", "低脂肪 牛乳", "アサヒスーパードライ",
]

query_vectors = {}
for q in queries:
    query_vectors[q] = model.encode(f"query: {q}").tolist()

with open("query_vectors.json", "w") as f:
    json.dump(query_vectors, f, ensure_ascii=False)

print(f"クエリベクトル生成完了: {len(queries)}件")

python embed_products.py で実行します。私のM4 Macbook Airで1分ほどでした。

商品ベクトル化完了: 2200件, 次元数: 384
クエリベクトル生成完了: 8件

成功したようです。

ES起動とインデックス作成、データ投入

次はESを起動してインデックスを作っていきます。

# Elasticsearch起動
docker run -d \
  --name elasticsearch \
  -p 9200:9200 \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  elasticsearch:9.3.0

# 起動完了まで待機(数十秒かかります)
until curl -s "http://localhost:9200" > /dev/null 2>&1; do sleep 2; done

# kuromojiプラグインインストール
docker exec elasticsearch elasticsearch-plugin install analysis-kuromoji
docker restart elasticsearch

# 再起動完了まで待機
until curl -s "http://localhost:9200" > /dev/null 2>&1; do sleep 2; done

# トライアルライセンスの有効化
curl -X POST "http://localhost:9200/_license/start_trial?acknowledge=true&pretty"

Step 4で使うRRF(Reciprocal Rank Fusion)はElasticsearchの有償機能(Enterprise相当)に含まれており、デフォルトのBasicライセンスでは利用できません。

上記のAPIを叩くと、メールアドレスなどの登録なしで30日間のトライアルが開始され、RRFを含むすべての有償機能が使えるようになります。なお、トライアルは同一クラスタでメジャーバージョンごとに1回限りなので注意してください。以下のURLもご覧ください。

https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-license-post-start-trial

# Step 1用: Standard Analyzer(デフォルト)
curl -s -X PUT "http://localhost:9200/products_default" -H "Content-Type: application/json" -d '{
  "mappings": {
    "properties": {
      "product_id":   { "type": "keyword" },
      "product_name": { "type": "text" },
      "category":     { "type": "keyword" },
      "price":        { "type": "integer" },
      "description":  { "type": "text" },
      "tags":         { "type": "keyword" }
    }
  }
}'

# Step 2用: kuromoji + synonym
curl -s -X PUT "http://localhost:9200/products_kuromoji" -H "Content-Type: application/json" -d '{
  "settings": {
    "analysis": {
      "tokenizer": {
        "kuromoji_tokenizer": { "type": "kuromoji_tokenizer", "mode": "search" }
      },
      "filter": {
        "synonym_filter": {
          "type": "synonym",
          "synonyms": [
            "鶏肉,チキン,とり肉",
            "豚肉,ポーク",
            "牛肉,ビーフ",
            "トマト,ミニトマト,プチトマト",
            "牛乳,ミルク",
            "じゃがいも,ポテト,馬鈴薯",
            "たまねぎ,玉ねぎ,オニオン"
          ]
        }
      },
      "analyzer": {
        "ja_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": ["kuromoji_baseform", "kuromoji_part_of_speech", "synonym_filter", "lowercase"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "product_id":   { "type": "keyword" },
      "product_name": { "type": "text", "analyzer": "ja_analyzer" },
      "category":     { "type": "keyword" },
      "price":        { "type": "integer" },
      "description":  { "type": "text", "analyzer": "ja_analyzer" },
      "tags":         { "type": "keyword" }
    }
  }
}'

# Step 3 & 4用: kuromoji + synonym + dense_vector
curl -s -X PUT "http://localhost:9200/products_vector" -H "Content-Type: application/json" -d '{
  "settings": {
    "analysis": {
      "tokenizer": {
        "kuromoji_tokenizer": { "type": "kuromoji_tokenizer", "mode": "search" }
      },
      "filter": {
        "synonym_filter": {
          "type": "synonym",
          "synonyms": [
            "鶏肉,チキン,とり肉",
            "豚肉,ポーク",
            "牛肉,ビーフ",
            "トマト,ミニトマト,プチトマト",
            "牛乳,ミルク",
            "じゃがいも,ポテト,馬鈴薯",
            "たまねぎ,玉ねぎ,オニオン"
          ]
        }
      },
      "analyzer": {
        "ja_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_tokenizer",
          "filter": ["kuromoji_baseform", "kuromoji_part_of_speech", "synonym_filter", "lowercase"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "product_id":   { "type": "keyword" },
      "product_name": { "type": "text", "analyzer": "ja_analyzer" },
      "category":     { "type": "keyword" },
      "price":        { "type": "integer" },
      "description":  { "type": "text", "analyzer": "ja_analyzer" },
      "tags":         { "type": "keyword" },
      "embedding":    { "type": "dense_vector", "dims": 384, "index": true, "similarity": "cosine" }
    }
  }
}'

次はデータを投入します。

# products_default と products_kuromoji には同じJSONLを投入
for INDEX in products_default products_kuromoji; do
  jq -c '{"index": {"_index": "'"$INDEX"'", "_id": .product_id}}, .' all_products.jsonl \
    | curl -s -X POST "http://localhost:9200/_bulk" -H "Content-Type: application/x-ndjson" --data-binary @- > /dev/null
done

# products_vector にはembedding付きデータを投入
jq -c '{"index": {"_index": "products_vector", "_id": .product_id}}, .' all_products_with_embeddings.jsonl \
  | curl -s -X POST "http://localhost:9200/_bulk" -H "Content-Type: application/x-ndjson" --data-binary @- > /dev/null

実験スクリプト(run.sh)

セットアップが完了したら、8クエリ × 4パターンを一括で実行し、結果を並べて比較します。

4つの検索パターンは以下の通りです。

Step 検索パターン インデックス 何を見るか
Step 1 BM25(デフォルト) products_default Standard Analyzerの素の状態。日本語トークン化なし
Step 2 BM25(kuromoji + synonym) products_kuromoji 日本語形態素解析と同義語辞書の効果
Step 3 kNN検索のみ products_vector ベクトル検索単体の精度。BM25は使わない
Step 4 ハイブリッド(RRF) products_vector BM25 + kNNの統合。両方の強みを活かせるか

手動でやるのは面倒なので、AIにスクリプトを用意してもらいました。以下のスクリプトを適当に run.sh として保存して実行します。

#!/bin/bash
QUERIES=("トマト" "ニンジン" "チキン" "シーチキン" "カレー 材料" "味噌汁の具" "低脂肪 牛乳" "アサヒスーパードライ")
QUERY_VECTORS=$(cat query_vectors.json)

search_bm25() {
  local index=$1 query=$2
  curl -s "http://localhost:9200/${index}/_search" -H "Content-Type: application/json" -d '{
    "query": { "multi_match": { "query": "'"$query"'", "fields": ["product_name", "description"] } },
    "size": 5, "_source": ["product_name"]
  }' | jq -r '.hits.hits[]._source.product_name'
}

search_knn() {
  local query=$1
  local vec=$(echo "$QUERY_VECTORS" | jq -c --arg q "$query" '.[$q]')
  curl -s "http://localhost:9200/products_vector/_search" -H "Content-Type: application/json" -d '{
    "knn": { "field": "embedding", "query_vector": '"$vec"', "k": 10, "num_candidates": 50 },
    "size": 5, "_source": ["product_name"]
  }' | jq -r '.hits.hits[]._source.product_name'
}

search_hybrid() {
  local query=$1
  local vec=$(echo "$QUERY_VECTORS" | jq -c --arg q "$query" '.[$q]')
  curl -s "http://localhost:9200/products_vector/_search" -H "Content-Type: application/json" -d '{
    "retriever": {
      "rrf": {
        "retrievers": [
          { "standard": { "query": { "multi_match": { "query": "'"$query"'", "fields": ["product_name", "description"] } } } },
          { "knn": { "field": "embedding", "query_vector": '"$vec"', "k": 10, "num_candidates": 50 } }
        ],
        "rank_window_size": 50,
        "rank_constant": 60
      }
    },
    "size": 5, "_source": ["product_name"]
  }' | jq -r '.hits.hits[]._source.product_name'
}

for query in "${QUERIES[@]}"; do
  echo ""
  echo "========================================"
  echo "Q: $query"
  echo "========================================"
  echo "--- Step 1: BM25 (default) ---"
  search_bm25 "products_default" "$query"
  echo "--- Step 2: BM25 (kuromoji+synonym) ---"
  search_bm25 "products_kuromoji" "$query"
  echo "--- Step 3: kNN ---"
  search_knn "$query"
  echo "--- Step 4: Hybrid (RRF) ---"
  search_hybrid "$query"
done

結果

run.sh の結果は以下のような出力になりました。

========================================
Q: トマト
========================================
--- Step 1: BM25 (default) ---
高知産 トマト
高知産 トマト
--- Step 2: BM25 (kuromoji+synonym) ---
トマトペースト
トマトペースト
千葉産 プチトマト
トマトスープ 粉末
トマトスープ 粉末
--- Step 3: kNN ---
カットトマト缶 400g
カットトマト缶 400g
トマトペースト
トマトペースト
高知産 トマト
--- Step 4: Hybrid (RRF) ---
トマトペースト
トマトペースト
トマトスープ 粉末
カットトマト缶 400g
高知産 トマト

========================================
Q: ニンジン
========================================
--- Step 1: BM25 (default) ---
茨城産 ニンジン
--- Step 2: BM25 (kuromoji+synonym) ---
茨城産 ニンジン
--- Step 3: kNN ---
茨城産 ニンジン
高知産 生姜
ジンジャーエール
ジンジャーエール
青森産 にんにく
--- Step 4: Hybrid (RRF) ---
茨城産 ニンジン
高知産 生姜
ジンジャーエール
ジンジャーエール
青森産 にんにく

========================================
Q: チキン
========================================
--- Step 1: BM25 (default) ---
--- Step 2: BM25 (kuromoji+synonym) ---
熊本産 鶏肉ももステーキ
熊本産 鶏肉ももステーキ
大分産 鶏肉焼き肉用
大分産 鶏肉焼き肉用
北海道産 鶏肉もも肉
--- Step 3: kNN ---
ローストチキン
国産 鶏肉皮
国産 チキンナゲット
ローストチキン
ローストチキン
--- Step 4: Hybrid (RRF) ---
国産 鶏肉皮
国産 チキンナゲット
ローストチキン
宮崎産 鶏肉細切れ
国産 鶏肉手羽先

========================================
Q: シーチキン
========================================
--- Step 1: BM25 (default) ---
--- Step 2: BM25 (kuromoji+synonym) ---
--- Step 3: kNN ---
ローストチキン
国産 鶏肉皮
ローストチキン
ローストチキン
国産 鶏肉手羽先
--- Step 4: Hybrid (RRF) ---
ローストチキン
国産 鶏肉皮
ローストチキン
ローストチキン
国産 鶏肉手羽先

========================================
Q: カレー 材料
========================================
--- Step 1: BM25 (default) ---
ホールトマト缶 400g
カットトマト缶 400g
ホールトマト缶 400g
カットトマト缶 400g
長野産 メークイン
--- Step 2: BM25 (kuromoji+synonym) ---
バーモントカレー 甘口
ゴールデンカレー 甘口
ゴールデンカレー 辛口
バーモントカレー 甘口
ゴールデンカレー 甘口
--- Step 3: kNN ---
ホールトマト缶 400g
ホールトマト缶 400g
カットトマト缶 400g
カットトマト缶 400g
カレーパン スパイシー
--- Step 4: Hybrid (RRF) ---
ゴールデンカレー 甘口
カレーパン スパイシー
ゴールデンカレー 甘口
カレーパン スパイシー
カレーフレーク 甘口

========================================
Q: 味噌汁の具
========================================
--- Step 1: BM25 (default) ---
味噌汁の素 即席
味噌汁の素 即席
味噌 白み味噌 500g
味噌 白み味噌 500g
相模屋 油揚げ 薄揚げ
--- Step 2: BM25 (kuromoji+synonym) ---
味噌汁の素 即席
味噌汁の素 即席
相模屋 油揚げ 薄揚げ
相模屋 油揚げ 薄揚げ
相模屋 油揚げ 薄揚げ
--- Step 3: kNN ---
ミツカン 焼き豆腐 枚
ミツカン 焼き豆腐 枚
ミツカン 焼き豆腐 枚
ミツカン 焼き豆腐 枚
ミツカン 焼き豆腐 枚
--- Step 4: Hybrid (RRF) ---
ミツカン 焼き豆腐 枚
ミツカン 焼き豆腐 枚
紀文 厚揚げ 焼き厚揚げ
ミツカン 焼き豆腐 枚
ミツカン 焼き豆腐 枚

========================================
Q: 低脂肪 牛乳
========================================
--- Step 1: BM25 (default) ---
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
--- Step 2: BM25 (kuromoji+synonym) ---
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
--- Step 3: kNN ---
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
--- Step 4: Hybrid (RRF) ---
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪
小岩井 牛乳低脂肪

========================================
Q: アサヒスーパードライ
========================================
--- Step 1: BM25 (default) ---
アサヒスーパードライ 350ml缶
アサヒスーパードライ 350ml缶
--- Step 2: BM25 (kuromoji+synonym) ---
アサヒスーパードライ 350ml缶
アサヒスーパードライ 350ml缶
アサヒ クリアアサヒ 350ml缶
アサヒ クリアアサヒ 350ml缶
スーパードライドライセブン 350ml缶
--- Step 3: kNN ---
アサヒスーパードライ 350ml缶
アサヒスーパードライ 350ml缶
アサヒ0.00 350ml缶
アサヒ0.00 梅 350ml缶
アサヒ クリアアサヒ 350ml缶
--- Step 4: Hybrid (RRF) ---
アサヒスーパードライ 350ml缶
アサヒスーパードライ 350ml缶
アサヒ クリアアサヒ 350ml缶
アサヒ クリアアサヒ 350ml缶
アサヒ0.00 350ml缶

※LLMで生成したテストデータに重複があり、同じ商品名が複数件存在しています(「高知産 トマト」「小岩井 牛乳低脂肪」「ローストチキン」など)。そのためTop 5に同じ商品名が繰り返し出ていますが、検索自体は正常に動作しています。本来であればデータのクレンジングを行うか、Elasticsearchのcollapse機能で重複を排除すべきですが、今回はそのまま比較しています。

考察

トマト

BM25 (default) では高知産のトマトしかヒットしていませんが、その他の検索ではプチトマトやトマト缶がヒットしているので良いですね。特にハイブリッド検索では4種類もヒットしているのは良いと言えそうです。実際のネットスーパーではおそらくトマトで検索したらトマトを買いたいでしょうから、高知産トマトが一番上に来てほしい気はします。

ニンジン

残念ながらkuromojiを入れていますが、「ニンジン」と「にんじん」の表記揺れが解消されていません。千葉県産のにんじんはヒットしませんでした。kNN以降は生姜やにんにくがベクトル的に近いと判定されているようですね。これは普通に考えるとダメですが、一番ダメなのはテストデータです。にんじんというワードを含むような他の商品やキャロットジュースなどのデータがないため、苦肉の策としてこれらが出てきたのだと思います。一応野菜ジュースはあるのですが、野菜ジュースよりにんにくがベクトル的に近いのはまあ分からんでもないという感じです。

チキン

これは素晴らしいです。ハイブリッド検索が一番良い結果を出していそうです!

まずproducts_defaultにはsynonym辞書が設定されていないのでBM25(default)が0件なのは仕方ないですね。その後synonym付きのBM25でもだいぶマシです。

そこからさらにkNNでローストチキンやチキンナゲットが出てきたのは素晴らしいです。普通はネットスーパーでチキンと検索したら生肉というよりは加工された鳥食品を買いたいような人が多い気がします。これはセマンティック検索の強みが出ました。

最後にハイブリッド検索です。チキンで検索して鶏皮を買いたい人はあまりいないような気はしますが、幅広くヒットしているのは素晴らしいと思います。

シーチキン

流石に無理でした!BM25では当然0件ですし、kNNもハイブリッドも「ローストチキン」「鶏肉皮」などチキン(鶏肉)系の商品ばかりが返ってきており、ツナ缶にはたどり着けていません。

なぜベクトル検索でも拾えなかったのでしょうか。今回使ったembeddingモデル(intfloat/multilingual-e5-small)は多言語対応の汎用モデルであり、「シーチキン」が日本で「ツナ缶」を指す商品名(はごろもフーズの登録商標)であるという知識を持っていません。モデルにとっては「シーチキン」は単純に「チキン」を含む複合語であり、鶏肉方面にベクトルが寄ってしまったと考えられます。

改善するとしたら、synonym辞書に「シーチキン,ツナ缶」を追加するのが最も手軽です。ただし、俗称や商標をsynonymで網羅するのは現実的ではないので、根本的にはドメイン特化のembeddingモデルを使う、あるいは汎用モデルを日本の食品データでファインチューニングするといったアプローチが必要になりそうです。

カレー 材料

これは微妙な結果になりました。BM25 (kuromoji+synonym)が一番良い結果かもしれません。

ホールトマト缶などはdescriptionにカレーという単語があるのでそれでヒットしたのでしょう。それは良いと思います。カレーパンはベクトルとしてはそうなのですが、材料ではないですね。

カレーという単語が使われている野菜などはもっと色々あるため、それらがヒットしてほしいところでした。実際にはこういうクエリでネットスーパーを使うケースはほぼないと思いますが、カレーの食材を全て覚えていないケースや、商品名をド忘れしてそれっぽい言い換えた単語で検索するケースなどは考えられますし、場合によっては必要に思えます。

ちなみに「カレー 材料」で検索すると西友のネットスーパーでは具材はヒットしません。OKストアのネットスーパーでは多数ヒットするものの、ほとんどはカレールー、スパイスであり、にんじんが最後の方に一つだけヒットしていました。この辺りの検索精度はまだまだのようです。

味噌汁の具

割愛します。ほとんどカレーと同じですね。ハイブリッドだからといって良い結果とは言えなさそうです。

低脂肪 牛乳

これは私が用意したデータがダメでした。同じデータが五件入っているので、差が出ていない感じですね。

ちなみにペット用の低脂肪食品もありますが、牛乳ではないですし、それがヒットしなかったのは良かったです。

アサヒスーパードライ

ちょっと微妙なところはありますが、これも ハイブリッド検索が一番良い結果かもしれません

完全な指名検索なのでBM25 (default) の結果が一番良いということも考えられますが、クリアアサヒにしようと思うユーザーもいるとは思います。また、アサヒスーパードライといいながらそのノンアルを求めている人もいそうなので、ハイブリッドの結果が一番良いと言えそうです。

検索においてはどこまでノイズが減らせるかも大事だとは思いますが、私はこの結果はノイズではないように思います。

ちなみにキリンやエビスもデータとしてはあるのですが、それらはkNNで出ていないですね。

おわり

主にテストデータの品質が悪く、その後の実験結果が生煮えのような感じになってしまいました。

しかし、ハイブリッド検索自体は試せましたし、一部良い感じの結果も出たので満足はしています。

そのほか、今回の調査過程でTF-IDFで止まっていた知識を少しアップデート(BM25)できましたし、RRFなども知ることができて良かったです。

近年は生成AIの注目によってRAG含め、検索技術にも注目が集まっているように感じます。今後も重要な技術だとは思うので、定期的に追いかけていきたいです。

なお今回は友人がElasticsearchを使っているので私もそれを使いましたが、PostgreSQLでも同じことができます。

以下のki2kaさんの記事などをご参照ください。

zenn.dev


現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!

herp.careers