- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 純粋なベクター検索が失敗する瞬間
- BM25とは何か
- 各アプローチの弱点
- RRF:2つの検索を組み合わせる方法
- LangChainでの実装
- パフォーマンスベンチマーク
- いつどの検索が有利か
- プロダクションのヒント:トークナイザーの最適化
- まとめ
純粋なベクター検索が失敗する瞬間
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システムで最も投資対効果の高い改善の一つだ。実装の複雑さに対してパフォーマンス向上が大きい。
推奨アプローチ:
- まずベクター検索でベースラインRAGを構築
- RAGASで現在のパフォーマンスを測定
- ハイブリッド検索(BM25 + ベクター)に切り替え
- 再度測定して改善を確認
「感覚」で良くなったと判断してはいけない。次の記事ではRAGASを使った定量的なRAG評価方法を詳しく解説する。