Skip to content
Published on

ハイブリッド検索完全ガイド:BM25とベクター検索を組み合わせてRAGを改善する

Authors

純粋なベクター検索が失敗する瞬間

RAGシステムを初めて構築する際に最も戸惑う瞬間の一つは、ベクター検索が明らかに正しいドキュメントを見つけられない場合だ。

具体例を挙げよう。製品DBに「iPhone 15 Pro Max 256GB ストレージ容量」というドキュメントがあり、ユーザーが「iPhone 15 Pro Max 256GBは在庫ありますか?」と質問した。

ベクター検索ではこれが上位に来ないことがある。なぜか?ベクター空間では「iPhone 14 Pro Max 512GB」も意味的に非常に近いからだ。どちらも「Pro Maxスマートフォン」という意味クラスターに入っている。

しかしユーザーが求めているのはちょうど256GBの15だ。その数字とモデル名が正確にマッチする必要がある。

これが純粋なベクター検索の根本的な限界だ:正確なキーワードマッチが必要な場合に弱い。 製品コード、モデル番号、固有名詞、日付、バージョン番号などがすべてこのカテゴリーに入る。

BM25とは何か

BM25(Okapi BM25)は1994年にRobertsonらが開発したキーワード検索アルゴリズムだ。30年が経過した今も、最強のキーワード検索として君臨している。Elasticsearch、Solr、Apache Luceneのデフォルト検索エンジンがBM25ベースだ。

数式を見ると理解が早い:

score(D, Q) = Σ IDF(qi) × [f(qi, D) × (k1 + 1)] / [f(qi, D) + k1 × (1 - b + b × |D| / avgdl)]

ここで:
  qi        = クエリの各単語
  f(qi, D)  = ドキュメントDでの単語qiの出現頻度(Term Frequency)
  |D|       = ドキュメントDの長さ
  avgdl     = コーパス全体の平均ドキュメント長
  k1, b     = チューニングパラメータ(通常k1=1.5, b=0.75  IDF(qi)   = log((N - df + 0.5) / (df + 0.5))

BM25の核心は2つだ:

1. TF飽和(Saturation):単語が多く出るほどスコアは上がるが、対数曲線のように飽和する。1回出るのと100回出るのでは差が線形ではない。

2. ドキュメント長の正規化:長いドキュメントで単語が多く出るのは当然だ。BM25はドキュメント長で正規化して不公平なアドバンテージを排除する。

この2つが単純なTF-IDFよりBM25が圧倒的に優れている理由だ。

各アプローチの弱点

ベクター検索の弱点:
- 正確なキーワードマッチングに弱い(モデル番号、製品コード)
- 「意味的ドリフト」:似ているが異なる概念を取得してしまう
- 希少語、新造語、略語に脆弱

BM25の弱点:
- 同義語を理解できない("automobile""car"- 文脈を理解できない(語順を無視)
- タイポに脆弱
- 多言語処理に弱い

この弱点が互いを補完する。ベクター検索が意味を捉え、BM25が正確なキーワードを捉える。2つの結果を合わせると、どちらか一方だけを使う場合より大幅に優れた結果が得られる。

RRF:2つの検索を組み合わせる方法

2つの検索結果を組み合わせる最もシンプルで効果的な方法が**RRF(Reciprocal Rank Fusion)**だ。

RRFのアイデアはエレガントだ:各結果リストでの順位を逆数に変換して合算する。

def reciprocal_rank_fusion(results_list: list, k: int = 60) -> list:
    """
    複数の順位付き結果リストをRRFで結合。

    RRFスコア = Σ 1/(k + rank_i)
    k=60は低順位アイテムの影響を和らげる定数

    Args:
        results_list: 各検索結果リストのリスト(doc_idの順序リスト)
        k: 順位安定化定数(デフォルト60)

    Returns:
        RRFスコアで降順ソートされたdoc_idのリスト
    """
    scores: dict = {}

    for results in results_list:
        for rank, doc_id in enumerate(results, start=1):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)

    return sorted(scores.keys(), key=lambda x: scores[x], reverse=True)


# 具体的な例
vector_results = ["doc_A", "doc_C", "doc_B"]  # 埋め込み検索結果
bm25_results   = ["doc_B", "doc_A", "doc_D"]  # BM25キーワード検索結果

# スコア計算:
# doc_A: 1/(60+1) + 1/(60+2) = 0.01639 + 0.01613 = 0.03252
# doc_B: 1/(60+3) + 1/(60+1) = 0.01587 + 0.01639 = 0.03226
# doc_C: 1/(60+2) = 0.01613
# doc_D: 1/(60+2) = 0.01613

fused = reciprocal_rank_fusion([vector_results, bm25_results])
print(fused)
# ['doc_A', 'doc_B', 'doc_C', 'doc_D']
# doc_Aは両方の検索で上位なので1位
# doc_BはBM25で1位だがベクターで3位 → 2位

k=60の理由:この値は実験的に決定されたもので、低順位アイテムの貢献を適切に抑制する。小さすぎると低順位アイテムが過剰な点数を受け取り、大きすぎると全アイテムが同じようなスコアになる。

LangChainでの実装

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

# 前提: docsはLangChain Documentオブジェクトのリスト
embedding_model = OpenAIEmbeddings()

# BM25リトリーバー作成
bm25_retriever = BM25Retriever.from_documents(
    docs,
    k=5  # 上位5件を返す
)

# ベクターストアとリトリーバー設定
vectorstore = FAISS.from_documents(docs, embedding_model)
vector_retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 5}
)

# EnsembleRetriever:重み付きで両検索を組み合わせ
hybrid_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6]  # BM25 40%, ベクター 60%
)

# 使用例
query = "iPhone 15 Pro Max 256GB 在庫"
results = hybrid_retriever.get_relevant_documents(query)

重みの選択基準

  • 正確なキーワードが重要(製品コード、モデル名):BM25の比重を上げる(0.5-0.6)
  • 意味的理解が重要(顧客クエリ、自然言語):ベクターの比重を上げる(0.6-0.7)
  • 一般的な企業文書:0.4/0.6または0.5/0.5から始める

Elasticsearchでのプロダクション実装

LangChainのEnsembleRetrieverは便利だが、大規模なプロダクションではElasticsearchがより適切だ。ESはBM25とベクター検索の両方をネイティブにサポートしている。

from elasticsearch import Elasticsearch

es_client = Elasticsearch(["http://localhost:9200"])

def hybrid_search_es(query: str, query_embedding: list, k: int = 5):
    response = es_client.search(
        index="documents",
        body={
            "query": {
                "bool": {
                    "should": [
                        # BM25キーワード検索
                        {
                            "match": {
                                "content": {
                                    "query": query,
                                    "boost": 0.4
                                }
                            }
                        }
                    ]
                }
            },
            # KNNベクター検索(ES 8.x以降)
            "knn": {
                "field": "content_vector",
                "query_vector": query_embedding,
                "k": k,
                "num_candidates": 100,
                "boost": 0.6
            },
            "size": k
        }
    )
    return response["hits"]["hits"]

パフォーマンスベンチマーク

BEIR(Benchmarking IR)データセット基準の研究結果:

検索手法nDCG@10(平均)特に強い領域
BM25のみ43.0キーワードマッチング、事実検索
ベクターのみ47.8意味的類似性、多言語
ハイブリッド(RRF)52.1全体的にバランスが良い

ハイブリッドは平均でおよそ21%高いnDCG@10を示す。特にどんなクエリが来るか予測しにくい汎用RAGシステムでの差が大きい。

個人的なプロダクション経験では、ベクターのみからハイブリッドに切り替えた際、「関連性のない回答」に関する顧客からの不満が約30%減少した。

いつどの検索が有利か

状況推奨手法理由
製品カタログ検索ハイブリッド(BM25強調)モデル番号や仕様が正確である必要がある
FAQチャットボットハイブリッド(ベクター強調)多様な表現で質問が来る
法律文書検索ハイブリッド(BM25強調)正確な法律用語マッチが必要
感情分析ベースの検索ベクターのみキーワードより意味が重要
コード検索ハイブリッド(BM25強調)関数名や変数名の正確なマッチが必要
多言語文書ベクターまたは多言語BM25言語をまたいだ意味検索が必要

プロダクションのヒント:トークナイザーの最適化

BM25のデフォルトトークナイザーはスペースベースだ。技術文書の場合、より良いトークナイザーで大幅に改善できる:

import nltk
from nltk.tokenize import word_tokenize
from nltk.stem import PorterStemmer
from nltk.corpus import stopwords

nltk.download('punkt')
nltk.download('stopwords')

stemmer = PorterStemmer()
stop_words = set(stopwords.words('english'))

def technical_tokenizer(text: str) -> list:
    tokens = word_tokenize(text.lower())
    tokens = [
        stemmer.stem(token)
        for token in tokens
        if token.isalnum() and token not in stop_words
    ]
    return tokens

# カスタムトークナイザーを適用
bm25_retriever = BM25Retriever.from_documents(
    docs,
    preprocess_func=technical_tokenizer,
    k=5
)

コード検索の場合は、camelCaseとsnake_caseの分割も考慮したい:getUserById["get", "user", "by", "id"]

まとめ

ハイブリッド検索はRAGシステムで最も投資対効果の高い改善の一つだ。実装の複雑さに対してパフォーマンス向上が大きい。

推奨アプローチ:

  1. まずベクター検索でベースラインRAGを構築
  2. RAGASで現在のパフォーマンスを測定
  3. ハイブリッド検索(BM25 + ベクター)に切り替え
  4. 再度測定して改善を確認

「感覚」で良くなったと判断してはいけない。次の記事ではRAGASを使った定量的なRAG評価方法を詳しく解説する。