Elasticsearchの検索再現率を測定・改善する方法:ハイブリッド検索で0.43から0.75へ
Elasticsearchにおける検索再現率を測定および改善する方法を学びましょう。BM25の語彙検索とJina AIのベクトル埋め込みを組み合わせ、rank_eval APIを使用して実際の数値で改善効果を検証します。
BM25ランキングアルゴリズムを用いた語彙検索は、低コストで高速であり、幅広いクエリに対して非常に効果的です。しかし盲点があります。それは、ドキュメントとトークンを共有しないクエリです。この記事では、BM25が足りないところを正確に測定します。Elasticsearchの ランキング評価API(rank_eval)を使用し、Elastic Inference Service(EIS)を介してJina AIの埋め込みを追加することでそのギャップを埋めます。再現率スコアが 0.43 から 0.75 に上がるのを見て、その理由がわかるでしょう。
リコールとは何ですか?
再現率は、0から1のスケールで、ユーザーが実際に欲しいドキュメントが検索結果にどれだけ含まれているかを測定します。クエリで3点の製品が表示されるはずなのに、検索結果の上位10位に2つしか表示されない場合は、そのクエリの再現率はrecall@10 = 0.67となります。これは集合ベースの指標であり、そのk件の結果内の関連ドキュメントの位置は考慮されません。10番目の位置にある関連ドキュメントは、1番目の位置にある関連ドキュメントと同じものとして扱われます。再現率が高いということは、関連性の高い検索結果を見逃さないということです。

この図は2つの集合を示しています。1つは関連するすべてのドキュメント(左側)、もう1つはBM25が実際に取得したドキュメント(上位10件、右側)です。再現率に影響する交差部分、prod_1とprod_2が見つかり、prod_3、prod_4、prod_6は完全に見落とされました。結果:Recall@10 = 2/5 = 0.40。
要件
再現率がどのように機能するのかをよりよく理解するために、本題に入りましょう。このデモンストレーションではPythonを使用します。付属のノートブック(notebook.ipynb)で一緒に進めることができます。すべてのコードブロックは、実行準備が整ったセルです。
提供されたコードでは、以下を使用しています。
Elasticsearch 9.3以降
Python 3.10+
pip install elasticsearch pandas plotly python-dotenvElasticsearchの認証情報が含まれる
.envファイル
ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-keyデータセット
靴、電子機器、工具など、さまざまなカテゴリーにわたる1,000点の製品を掲載した製品カタログを使用します。
各ドキュメントには4つのフィールドがあります。
フィールド | タイプ |
|---|---|
`タイトル` | テキスト |
`description` | テキスト |
`ブランド` | キーワード |
`category` | キーワード |
データセットは dataset.csvから読み込まれます。
語彙検索の力と限界
BM25は、Elasticsearchおよびほとんどの検索エンジンのデフォルトのランキングアルゴリズムです。ドキュメントは、クエリ用語がどれだけ頻繁に出現するかによってスコアリングされます。これは、ドキュメントの長さと、インデックス全体でのそれらの用語の頻度に合わせて調整されます。さらに、小文字への正規化、語幹抽出、ストップワード除去といったアナライザーも搭載されています。「running shoes」というクエリは、「Running Shoes」と一致し、おそらく「run」とも一致します。
これは、多くの種類のクエリに対して有効です。
「running shoes」はタイトルにこれらのトークンを含む商品を即座にマッチングします。
「Bluetoothスピーカー」は、トークンが逐語的に表示されるため、携帯オーディオ製品として表示されます。
結果は決定論的で説明可能です。つまり、検索クエリに含まれる用語がドキュメント中に含まれているからこそ、そのドキュメントは上位にランク付けされるのです。関連性のデバッグは単純明快です。
問題が発生する箇所
では、同じカタログに対してこれらのクエリを試してみましょう。
「スキンケアルーティン」:どの製品名にも「ルーティン」という単語は含まれません。BM25は、「スキンケア」で部分一致することができますが、フェイス美容液、ボディオイル、モイスチャライザーは、「ビタミンC」、「レチノール」、または「ブライトニング」のような用語を使用して説明されており、いずれもクエリと重複しません。完全なスキンケアルーティンを形成する製品は、共通のトークンを持たずにインデックス全体に散らばっています。
ID: B06XX6DS3P, Score: 9.0552, Title: Replenix Retinol Smooth + Tighten Body Lotion - Collagen-Boosting, Regenerating Anti-Aging Body Cream, Reduces Appearance of Stretch Marks, 6.7 oz.
ID: B08XMPKJ1L, Score: 5.2699, Title: Bio-Oil Skincare Body Oil (Natural) Serum for Scars and Stretchmarks, Face and Body Moisturizer Hydrates Skin, with Organic Jojoba Oil and Vitamin E, For All Skin Types, 6.7 oz
ID: B01CY764KQ, Score: 5.0057, Title: Nike Up Or Down Men Deodorant - Pack of 2 | Long-Lasting Fragrance, Body Spray Combo for Men | Deodorant for Active Living | Nike Men's Deo Set | Ultimate Odor Protection | Grooming Essentials | Signature Nike Scent | High-Performance Men's Deodorant「ペット用旅行用品」:これはユースケースのグループ分けであり、製品カテゴリではありません。犬用スリングキャリア、ペット用カーシート、旅行用クレートはどれも関連性のあるアイテムですが、それらの説明文は「旅行用品」というよりは、携帯性、安全性、快適性について述べています。BM25は「ペット」と大まかに一致しますが、旅行固有の商品を他のペットカタログと区別するシグナルはありません。
ID: B0BVV7BKTW, Score: 7.4371, Title: Large Foldable Travel Duffel Bag with Shoes Compartment
ID: B07TNPHYNV, Score: 6.6455, Title: 40 Pieces Christmas Bronze Jingle Bells Craft Small Bells
ID: B08R8FRW53, Score: 6.6335, Title: CUBY Dog and Cat Sling Carrier
ID: B08QMCQYGM, Score: 6.5259, Title: YTFGGY Whiteboard Pinstripe Tape 6 Rolls 1/8"
ID: B0CP3LQSWM, Score: 6.2994, Title: Portable Dog Water Bottle 32 Ozこれは 再現率の問題です。関連するドキュメントはインデックスに存在しますが、ユーザーの言葉と文書の単語が十分に一致しないため、BM25はそれらを見つけることができません。
同義語を追加することは、既知の事例に役立ちますが、ユーザーが意図を表現するすべての方法を列挙することはできません。そこでベクトルが役立ちます。
リコールを測定すべき理由
問題を解決する前に、まずそれを定量化する必要があります。
Recall@kは、ユーザーが実際に求めているドキュメントのうち、検索結果に表示されるドキュメントの数を測定します。正式には:
Recall@k = (relevant documents found in top k) / (total relevant documents)Precision@kは、上位k件の結果のうち、実際にどれだけ関連性があるかを測定します。
Precision@k = (relevant documents in top k) / k高精度とは、得られる結果が良好であることを意味します。電子商取引においては、関連商品を見落とすこと(再現率の低さ)は、多少不完全な結果を表示すること(精度の低さ)よりも深刻です。なぜなら、商品が表示されないということは、販売機会の損失を意味するからです。
Elasticsearchのrank_eval APIは、両方を体系的に測定できます。それぞれに評価されたドキュメントを含むクエリのリストを提供すると、Elasticsearchがすべてのクエリのメトリックを計算します。
評価の設定
rank_eval APIには評価データセットが必要です。これは、クエリとそれに関連するドキュメントのマッピング、および関連性グレード(0 = 関連性なし、1 = 関連性あり、2 = 非常に関連性あり)で構成されます。
ノートブックでは、これは判断リストです。
judgments = [
# Query 1: "running shoes" BM25 handles well (tokens appear in product titles)
{"query_id": "q1", "doc_id": "B09NQJFRW6", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B08JMD4LMM", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B08VRJ6F2Q", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B07S8NRRWR", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B01HD620I8", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B07DX86321", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B0968YVLQ8", "grade": 1, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B093QJ39ZS", "grade": 1, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B096FGSC39", "grade": 1, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B01GVQWVV2", "grade": 1, "query": "running shoes"},
# Query 2: "skincare routine" intent-based, "routine" never appears in product titles
{"query_id": "q2", "doc_id": "B08XMPKJ1L", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B0BN3WQB92", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B0BT7B7P5T", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B00NPA2WEY", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B06XX6DS3P", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B07PDRD1KT", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B074J7869B", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B08JV31QW4", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B00K3TVJMQ", "grade": 1, "query": "skincare routine"},
# Query 3: "study desk setup" intent-based, products are desks/stands/organizers
{"query_id": "q3", "doc_id": "B08CS35J2T", "grade": 2, "query": "study desk setup"},
{"query_id": "q3", "doc_id": "B09B3LFDXJ", "grade": 2, "query": "study desk setup"},
{"query_id": "q3", "doc_id": "B07W58LMND", "grade": 1, "query": "study desk setup"},
{"query_id": "q3", "doc_id": "B0CHYDX91L", "grade": 1, "query": "study desk setup"},
# Query 4: "pet travel accessories" use-case grouping, products are carriers/crates/seats
{"query_id": "q4", "doc_id": "B08R8FRW53", "grade": 2, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B01MYUYX33", "grade": 2, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B003C5RKE4", "grade": 2, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B09GF8GBF6", "grade": 1, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B0CP3LQSWM", "grade": 1, "query": "pet travel accessories"},
]この組み合わせは意図的なものです。 q1はBM25が適切に処理するクエリ(製品タイトル内の正確なトークン)であり、 q2 、 q3 、 q4はユーザーの意図が特定の製品キーワードではなく概念として表現されるインテントベースのクエリです。
BM25ベースライン再現率の測定
まず、Elasticsearchクライアントを設定し、生のテキストデータをインデックス化します。
import os
import json
import pandas as pd
import plotly.graph_objects as go
from elasticsearch import Elasticsearch, helpers
from dotenv import load_dotenv
load_dotenv()
es = Elasticsearch(
os.getenv("ELASTICSEARCH_URL"),
api_key=os.getenv("ELASTICSEARCH_API_KEY")
)
INDEX_NAME = "ecommerce-products"次にBM25のrank_evalリクエストを作成しましょう。リスト内の各リクエストは、クエリとその評価を組み合わせたものです。
judgments_df = pd.DataFrame(judgments)
bm25_requests = []
for query_id, query_text in (
judgments_df[["query_id", "query"]].drop_duplicates().values
):
relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
ratings = [
{"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
for _, row in relevant_docs.iterrows()
]
bm25_requests.append({
"id": query_id,
"request": {
"query": {
"multi_match": {
"query": query_text,
"fields": ["title", "description"]
}
}
},
"ratings": ratings,
})
bm25_eval = {
"requests": bm25_requests,
"metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}
bm25_result = es.rank_eval(index=INDEX_NAME, body=bm25_eval)
print("BM25 Recall@10:", bm25_result.body["metric_score"])次の結果が得られます。
BM25 Recall@10: 0.430.43 は、4つのクエリすべてで、BM25が見つけるべきドキュメントの43%しか見つけられないということです。不足している点は、意図に基づくクエリに集中している。「スキンケアルーティン」という検索では、「ルーティン」が製品タイトルに含まれていないため、フェイス美容液やボディオイルが見つかりません。また、「ペット用旅行用品」という検索では、旅行用品ではなく携帯性や安全性の観点から説明されているキャリーケースやクレートが見つからない一方で、関連性のないペット用品が検索結果に表示されます。
これがベースラインです。これで、破るべき数値ができました。
Jina埋め込みを用いたベクトル検索機能の追加
Vector search はドキュメントとクエリを高次元ベクトルとしてエンコードします。高次元ベクトルとは、数百または数千の数値から構成されるベクトルの一種で、各数値はそれが表すデータの特定の特徴をエンコードします。意味が似ているドキュメントは、たとえ共通の単語が一つもなくても、ベクトル空間上では互いに近い位置に配置されます。「ジム器具」と「ダンベルセット」は、概念が関連しているため、近くに配置されるでしょう。私がベクトルデータベースとしてElasticsearchを選んだ理由は、ハイブリッド検索をサポートしており、セマンティックな理解とキーワードの精度をすぐに実現できるからです。
EISには、推論APIを通じてモデル埋め込みを標準でサポートする機能が含まれています。
ステップ1:Jina埋め込みv5を推論エンドポイントとして使用する
INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"クラスターにGPUリソースがある場合(Elastic CloudおよびElasticsearch 9.3以降で利用可能)、埋め込みはGPU上で生成されます。これはCPU推論よりも大幅に高速であり、従来、大規模な環境でベクトル処理を高価にしていたパフォーマンスのトレードオフを解消します。
なぜJina埋め込みが特に重要なのでしょうか。JINA-embeddings-v5-textは、32,000トークンのコンテキストウィンドウを持ち、タスク固有の Low-Rank Adaptation (LoRA) アダプターをサポートする多言語モデル(119言語以上に対応)です。短い商品説明であれば、そのままでも十分に機能します。jina-embeddings-v5-textモデルの詳細についてはこちらをご覧ください。
ステップ2:セマンティックフィールドを持つインデックスを作成
index_mappings = {
"mappings": {
"properties": {
"title": {"type": "text", "copy_to": "semantic_field"},
"description": {"type": "text", "copy_to": "semantic_field"},
"brand": {"type": "keyword"},
"category": {"type": "keyword"},
"semantic_field": {
"type": "semantic_text",
"inference_id": INFERENCE_ENDPOINT_ID,
},
}
}
}
if not es.indices.exists(index=INDEX_NAME):
es.indices.create(index=INDEX_NAME, body=index_mappings)
print(f"Created index: {INDEX_NAME}")ここで重要なのは semantic_text フィールドタイプです。これはdense_vectorよりも高レベルの抽象化です。推論エンドポイントを指定すると、Elasticsearchが埋め込みの生成を自動的に行います。
copy_toプロパティがtitleとdescriptionにある場合、両方のフィールドのコンテンツがsemantic_fieldに流れ込んで埋め込まれるため、単一のベクトルで製品の完全な表現を捉えることができます。
ステップ3:製品をインデックス化する
def bulk_index(products, index_name):
actions = []
for product in products:
doc_id = product.get("_id")
source = {k: v for k, v in product.items() if k != "_id"}
action = {"_index": index_name, "_source": source}
if doc_id:
action["_id"] = doc_id
actions.append(action)
success, failed = helpers.bulk(es, actions, raise_on_error=False)
if failed:
for error in failed:
print(f"Error: {error}")
else:
print(f"Successfully indexed {success} documents")
bulk_index(products, INDEX_NAME)インデックス時には、Elasticsearchは各文書の推論エンドポイントを呼び出し、その結果得られた埋め込みをsemantic_fieldに保存します。余計なコードは必要ありません。
ハイブリッド検索:BM25とベクトルおよびRRFの組み合わせ
ベクトルを追加すると再現率は向上しますが、ベクトルのみを使用すると、完全一致クエリの精度が低下するリスクがあります。「ランニングシューズ」は、依然として逐語的に一致するものを最優先にランク付けするべきです。ハイブリッド検索は、その精度を維持するために、語彙要素を意図的に保持します。
ハイブリッド逆順位融合(RRF)検索は、両方の長所を保持します。
BM25は正確およびほぼ正確なクエリを高精度で処理します。
セマンティック検索は、意図に基づく多言語クエリを高い再現率で処理します。
RRFは2つのランキングリストを1つのランキングに統合します。
RRF式は、各結果リストのランクに基づいて各文書にスコアを割り当てます。
score = sum(1 / (rank_constant + rank))両方のリストで上位に表示されるドキュメントほど、統合スコアは高くなります。rank_constantは、より低いランクの文書に与えられる重みを制御します。
hybrid_requests = []
for query_id, query_text in (
judgments_df[["query_id", "query"]].drop_duplicates().values
):
relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
ratings = [
{"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
for _, row in relevant_docs.iterrows()
]
hybrid_requests.append({
"id": query_id,
"request": {
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": query_text,
"fields": ["title", "description"],
}
}
}
},
{
"standard": {
"query": {
"match": {
"semantic_field": {"query": query_text}
}
}
}
},
],
"rank_window_size": 50,
"rank_constant": 5,
}
}
},
"ratings": ratings,
})
hybrid_eval = {
"requests": hybrid_requests,
"metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}
hybrid_result = es.rank_eval(index=INDEX_NAME, body=hybrid_eval)
print("Hybrid Recall@10:", hybrid_result.body["metric_score"])次の結果が得られます。
Hybrid Recall@10: 0.75ハイブリッドはBM25(0.43)よりも大幅に改善されており、「ランニングシューズ」のような完全一致クエリに対しても精度を維持します。
結果:ビフォーアフター
3つのアプローチの完全な比較は次のとおりです。
methods = {
"BM25 (Lexical)": bm25_requests,
"Hybrid (BM25 + Vectors)": hybrid_requests,
}
recall_metric = {"recall": {"k": 10, "relevant_rating_threshold": 1}}
comparison_data = []
for method_name, requests in methods.items():
result = es.rank_eval(
index=INDEX_NAME,
body={"requests": requests, "metric": recall_metric}
)
comparison_data.append({
"method": method_name,
"recall@10": result.body["metric_score"]
})
comparison_df = pd.DataFrame(comparison_data)
print(comparison_df.to_string(index=False))次の結果が得られます。
メソッド | Recall@10 |
|---|---|
BM25(語彙) | 0.43 |
ハイブリッド(BM25 + ベクター) | 0.75 |

クエリごとに分解すると次のようになります。

まとめ
この記事を通して、BM25の語彙検索は、ユーザーが正確なクエリを入力する場合には信頼性が高いものの、キーワードではなく意図に基づいて検索する場合は再現率が低下することがわかりました。rank_evalを用いて、そのギャップを実数で測定するための再現可能な基準を確立しました。そこから、Jina埋め込みを利用したsemantic_textフィールドを追加し、評価を再度実行しました。結果:ハイブリッド検索により、0.43から0.75への再現率が向上し、正確な一致クエリでの精度を維持しました。ただし、実際の差はクエリの組み合わせによって異なります。
このパターンは、この例を超えてスケールします。ユーザーの実際のクエリから判断を収集し、rank_evalをベースラインとして実行し、semantic_textを追加して再度測定します。何がどれだけ改善したかが正確にわかるでしょう。
今後の見通し
再現率とベクトル検索についてさらに詳しく:再現率とベクトル検索の量子化(Jeff Vestal著)
上位結果の精度をさらに向上させるために再ランキング機能を追加
Elasticsearchハイブリッド検索のドキュメントをご覧ください。




