BM25とは何か?RAGでなぜチャンクに分けるのかを整理する
はじめに
RAGを実装していると、よく以下のような処理が出てきます。
hits = bm25_search(query, n_results=3)
また、BM25検索の前処理として、文書を chunk に分割する処理もよく登場します。
for i, chunk_text in enumerate(split_chunks(doc["text"])):
...
最初はここが分かりにくいです。
- なぜ文書全体をそのまま検索しないのか?
- なぜわざわざチャンクに分ける必要があるのか?
- BM25は何を見てスコアを決めているのか?
- Vector検索と何が違うのか?
この記事では、BM25の基本原理と、RAGでチャンク分割が必要になる理由を整理します。
BM25を一言でいうと
BM25は、検索キーワードに対して、どの文書がより関係ありそうかをスコア化する検索アルゴリズムです。
例えば、ユーザーが次のように質問したとします。
育児休業給付金はいくらもらえますか?
BM25は、この質問に含まれる単語と、文書中の単語を比較して、関連度の高い文書を上位に出します。
イメージとしては、以下のような検索です。
検索クエリ:
育児休業給付金 いくら
文書A:
育児休業給付金の支給額は、休業開始時賃金日額に支給日数を乗じて...
文書B:
育児休業の申出は、原則として休業開始予定日の1か月前までに...
文書C:
介護休業給付金は、介護休業を取得した場合に...
この場合、BM25はおそらく文書Aを高く評価します。
理由は、検索クエリに含まれる重要な単語である「育児休業給付金」や「支給額」に近い内容が含まれているためです。
BM25が見ている3つの要素
BM25のスコアは、ざっくりいうと以下の3つで決まります。
| 要素 | 意味 | 直感的な説明 |
|---|---|---|
| TF | Term Frequency | 検索語がその文書に何回出てくるか |
| IDF | Inverse Document Frequency | その検索語がどれくらい珍しいか |
| 文書長補正 | Length Normalization | 長い文書が有利になりすぎないようにする |
1. TF:検索語が何回出てくるか
TFは、検索語が文書中に出てくる回数です。
例えば、検索語が「育児休業給付金」の場合、次のような文書は関連度が高そうに見えます。
育児休業給付金は、育児休業を取得した場合に支給されます。
育児休業給付金の支給額は、賃金日額をもとに計算されます。
「育児休業給付金」という言葉が複数回出てくるため、関連している可能性が高いです。
ただし、BM25では、単語が出れば出るほど無限にスコアが上がるわけではありません。
同じ単語が何度も出ても、一定以上は効果が小さくなります。
これをTFの飽和と考えると分かりやすいです。
1回出る → かなり意味がある
2回出る → さらに関連しそう
10回出る → さすがに増えた分だけ強く評価しすぎない
2. IDF:珍しい単語ほど重要
IDFは、その単語がどれくらい珍しいかを表します。
例えば、以下の単語を比べます。
制度
申請
育児休業給付金
出生時育児休業
「制度」や「申請」は、多くの文書に出てくる可能性があります。
一方で、「育児休業給付金」や「出生時育児休業」は、特定の制度に関する文書に出てくる可能性が高いです。
つまり、珍しい単語が一致した方が、検索意図に合っている可能性が高いです。
BM25では、このような珍しい単語をより強く評価します。
多くの文書に出る単語 → 重要度は低め
一部の文書にだけ出る単語 → 重要度は高め
3. 文書長補正:長い文書が有利になりすぎないようにする
長い文書は、単語数が多いため、検索語がたまたま含まれやすいです。
例えば、以下の2つの文書を考えます。
文書A: 300文字
文書B: 30,000文字
文書Bは長いため、多くの単語が含まれます。
そのため、検索語もたまたま含まれやすいです。
もし単純に「検索語が出てきた回数」だけで評価すると、長い文書が常に有利になってしまいます。
そこでBM25では、文書の長さを考慮してスコアを補正します。
BM25のざっくりした式
BM25の式を厳密に理解する必要はありません。
イメージとしては以下です。
BM25スコア =
検索語が文書に出てくる強さ
× その検索語の珍しさ
× 文書長の補正
少し数式っぽく書くと、以下のような考え方になります。
score = TFの強さ × IDFの強さ × 文書長補正
つまりBM25は、単純なキーワード一致ではなく、以下を同時に見ています。
- 検索語が出ているか
- 何回出ているか
- その単語は珍しいか
- 文書が長すぎないか
なぜRAGではチャンクに分けるのか
ここが重要です。
RAGでは、文書全体をそのままLLMに渡すのではなく、文書を小さな単位に分けて検索することが多いです。
この小さな単位を chunk と呼びます。
元文書
↓
chunk 1
chunk 2
chunk 3
chunk 4
では、なぜチャンクに分ける必要があるのでしょうか。
理由1:文書全体だと検索対象が大きすぎる
厚生労働省の資料や社内規程のような文書は、1つのファイルに複数のテーマが含まれていることが多いです。
例えば、1つの文書に以下の内容が含まれているとします。
- 育児休業の取得条件
- 育児休業給付金
- 社会保険料免除
- 申請手続き
- 介護休業
- 短時間勤務制度
この文書全体を1つの検索対象にすると、質問に対して本当に必要な箇所だけを取り出しにくくなります。
例えば、質問がこれだったとします。
育児休業給付金はいくらもらえますか?
欲しいのは「育児休業給付金の支給額」に関する部分だけです。
しかし文書全体を検索対象にすると、検索結果として返ってくるのは「文書全体」になります。
その結果、LLMに渡すcontextが大きくなり、不要な情報も大量に含まれてしまいます。
理由2:BM25は検索単位ごとにスコアを付ける
BM25は、検索対象の単位ごとにスコアを計算します。
つまり、検索対象が「文書全体」なら文書全体にスコアが付きます。
文書A → score 12.5
文書B → score 8.3
文書C → score 2.1
一方、検索対象が「チャンク」なら、チャンクごとにスコアが付きます。
文書A_chunk1 → score 3.2
文書A_chunk2 → score 15.8
文書A_chunk3 → score 1.4
文書B_chunk1 → score 9.6
この方が、質問に関係する具体的な箇所を取り出しやすくなります。
理由3:長い文書では重要箇所が埋もれる
文書全体が長いと、質問に関係する部分が一部にしか含まれていない場合があります。
例えば、30ページの資料の中に、育児休業給付金について書かれている箇所が1ページだけあるとします。
この場合、文書全体を1つの検索対象にすると、関係ない部分が多すぎて、検索スコアやLLMへの入力がぼやけます。
一方で、チャンクに分けておけば、育児休業給付金について書かれたチャンクだけを取り出せます。
文書全体:
取得条件、申請手続き、社会保険料、給付金、介護休業...
チャンク:
chunk1: 取得条件
chunk2: 申請手続き
chunk3: 育児休業給付金
chunk4: 社会保険料免除
この場合、質問に対して chunk3 を取得できればよいです。
理由4:LLMに渡す情報量を減らせる
RAGでは、検索結果をLLMのプロンプトに入れます。
【資料】
検索で取得したchunk
【質問】
ユーザーの質問
もし文書全体を渡すと、プロンプトが長くなります。
プロンプトが長くなると、以下の問題が起きます。
- コストが増える
- 応答が遅くなる
- 重要な情報が埋もれる
- 関係ない情報を使って回答する可能性がある
チャンクに分けておけば、質問に関係する部分だけをLLMに渡しやすくなります。
理由5:根拠を示しやすくなる
チャットボットでは、回答の根拠が重要になります。
特に社内制度や法令関連では、以下を示したいです。
どの資料の
どの部分を根拠に
回答したのか
チャンク単位で検索していれば、回答根拠を示しやすくなります。
例えば、検索結果に以下の情報を持たせます。
{
"id": "mhlw_001_chunk3",
"title": "育児休業給付について",
"url": "https://...",
"text": "...",
"score": 12.3
}
このようにしておけば、LLMの回答に根拠資料を添えやすくなります。
今回のコードで何が起きているか
今回のBM25検索コードは、以下のような流れになっています。
def _build_corpus() -> tuple[list[dict], BM25Okapi]:
docs = load_documents()
chunks = []
for doc in docs:
for i, chunk_text in enumerate(split_chunks(doc["text"])):
chunks.append({
"id": f"{doc['id']}_chunk{i}",
"text": chunk_text,
"url": doc["url"],
"title": doc["title"],
})
tokenized = [tokenize(c["text"]) for c in chunks]
bm25 = BM25Okapi(tokenized)
return chunks, bm25
この処理では、まず load_documents() で文書を読み込みます。
次に、split_chunks(doc["text"]) で文書をチャンクに分けます。
そして、各チャンクをBM25の検索対象にしています。
つまり、BM25は文書全体ではなく、チャンク単位で検索しているということです。
検索処理の流れ
検索時の処理は以下です。
def bm25_search(query: str, n_results: int = 3) -> list[dict]:
chunks, bm25 = _build_corpus()
scores = bm25.get_scores(tokenize(query))
ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
hits = []
for idx, score in ranked[:n_results]:
hits.append({
"id": chunks[idx]["id"],
"title": chunks[idx]["title"],
"url": chunks[idx]["url"],
"text": chunks[idx]["text"],
"score": score,
})
return hits
流れとしては以下になります。
ユーザー質問
↓
Janomeでトークン化
↓
各chunkとBM25スコアを計算
↓
スコアが高いchunkを上位から取得
↓
LLMに渡すcontextとして利用
日本語ではトークン化が重要
英語では、空白で単語を分けやすいです。
child care leave benefit
しかし、日本語は単語の間に空白がありません。
育児休業給付金はいくらもらえますか
そのため、日本語でBM25を使う場合は、文章を単語に分ける処理が必要になります。
今回のコードでは、Janomeを使っています。
from janome.tokenizer import Tokenizer
_tokenizer = Tokenizer()
def tokenize(text: str) -> list[str]:
return [token.surface for token in _tokenizer.tokenize(text)]
これにより、日本語文を単語単位に分解して、BM25で扱える形にしています。
BM25が得意なこと
BM25は、キーワードが明確な検索に強いです。
例えば、以下のような質問です。
育児休業給付金の支給率は?
出生時育児休業とは?
社会保険料免除の条件は?
このように、制度名や重要語がクエリに含まれている場合、BM25は有効です。
特に以下のような情報を検索したい場合に強いです。
- 制度名
- 固有名詞
- 数値
- 条文
- 用語定義
- 申請書名
- 法令名
BM25が苦手なこと
一方で、BM25は意味検索ではありません。
そのため、言い換えや省略に弱いです。
例えば、ユーザーがこう聞いた場合。
育休中にもらえるお金はいくら?
文書側に以下のように書かれているとします。
育児休業給付金の支給額は...
この場合、人間には同じ意味だと分かります。
しかし、BM25は基本的に単語の一致を見ているため、以下の対応が必要になります。
育休 → 育児休業
お金 → 給付金 / 支給額
いくら → 支給率 / 上限額
つまり、BM25ではQuery ExpansionやQuery Rewriteが有効になります。
Query Expansionとの関係
BM25はキーワード一致に強いため、検索前にクエリを少し補正すると効果が出やすいです。
例えば、ユーザーの質問が以下だったとします。
育休の給付金はいくら?
そのまま検索するよりも、以下のように補正した方がよい場合があります。
育児休業 育児休業給付金 支給額 支給率 上限額
ただし、Agent SDKを使っている場合は注意が必要です。
Agent SDKでは、LLMが検索結果を見ながら、必要に応じて検索クエリを変えて再検索できます。
そのため、初期段階から大規模なQuery Expansionを入れると、Agentの探索ループと役割が重複する可能性があります。
初期実装では、以下のような軽量な補正に留めるのがよいです。
育休 → 育児休業
給付金 → 育児休業給付金
いくら → 支給額 / 支給率 / 上限額
パパ育休 → 出生時育児休業
チャンクサイズをどう考えるか
チャンクは小さければよいわけではありません。
小さすぎると、文脈が欠けます。
支給額は、次の通りです。
このようなチャンクだけ取れても、「何の支給額か」が分かりません。
一方で、大きすぎると、不要な情報が混ざります。
育児休業、介護休業、時短勤務、給付金、社会保険料...
このようなチャンクでは、質問に必要な情報が埋もれます。
そのため、チャンク設計では以下のバランスが重要になります。
| チャンクサイズ | メリット | デメリット |
|---|---|---|
| 小さい | ピンポイントに検索しやすい | 文脈が欠けやすい |
| 大きい | 文脈を保持しやすい | 不要情報が混ざりやすい |
| 中程度 | 検索精度と文脈保持のバランスがよい | 設計・評価が必要 |
実務ではメタデータも重要
RAGでは、チャンクの本文だけでなく、メタデータも重要です。
例えば、以下の情報を持たせます。
{
"id": "doc001_chunk003",
"title": "育児休業給付について",
"url": "https://...",
"section": "支給額",
"text": "...",
}
メタデータがあると、以下がやりやすくなります。
- 回答に出典を付ける
- 文書種別で検索対象を絞る
- 古い資料を除外する
- 章や節単位で根拠を示す
- 検索ログを分析する
BM25とVector検索の違い
RAGではBM25だけでなく、Vector検索もよく使われます。
両者の違いは以下です。
| 観点 | BM25 | Vector検索 |
|---|---|---|
| 見ているもの | 単語の一致 | 意味の近さ |
| 得意 | 固有名詞、制度名、数値、条文 | 言い換え、抽象的な質問 |
| 苦手 | 表記揺れ、同義語、言い換え | 正確なキーワード一致、数値、固有名詞 |
| 例 | 「育児休業給付金」「67%」を拾う | 「育休中にもらえるお金」を意味で拾う |
そのため、実務ではBM25とVector検索を組み合わせたHybrid検索が有効です。
Hybrid検索 =
BM25検索
+ Vector検索
+ 結果統合
まとめ
BM25は、検索クエリと文書中の単語の一致をもとに、関連度の高い文書を上位に出す検索手法です。
BM25は主に以下を見ています。
- 検索語が文書に出てくるか
- 何回出てくるか
- その検索語がどれくらい珍しいか
- 文書が長すぎないか
RAGでBM25を使う場合、文書全体ではなくチャンク単位で検索することが多いです。
理由は以下です。
- 質問に関係する箇所をピンポイントに取得できる
- 長い文書で重要箇所が埋もれるのを防げる
- LLMに渡すcontextを小さくできる
- 不要な情報を減らせる
- 回答根拠を示しやすくなる
ただし、BM25はキーワード一致に強い一方で、言い換えや省略には弱いです。
そのため、実務では以下を組み合わせるとよいです。
- 軽量なQuery Expansion
- 表記揺れ辞書
- Vector検索
- Hybrid検索
- Agent SDKによる再検索
BM25は古典的な検索手法ですが、RAGにおいても非常に重要です。
特に、制度名・数値・条文・キーワードを正確に拾いたい業務用途では、Vector検索だけでなくBM25を組み合わせる価値があります。
参考
- Okapi BM25
- rank-bm25
- Elasticsearch BM25
- RAG
- Hybrid Search
- Query Expansion