BM25Retrieverは、LangChainでキーワード一致による文書検索を受け持つRetrieverです。埋め込みモデルを使わないため、型番・エラーコード・社内用語のように「その語を含む文書」を探す検索に向き、RAGではベクトル検索と組み合わせて使われます。この記事では2026年10月時点の langchain 1.4.7・langchain-community 0.4.2 で全コードを実行し、日本語で検索が効かない原因、小さなコーパスで結果が逆順になる罠、スコアの取り出し方、EnsembleRetriever によるハイブリッド検索までを実行結果つきで解説します。
まとめ
- import:
from langchain_community.retrievers import BM25Retriever。計算はrank_bm25のBM25Okapiが担うのでpip install rank_bm25も必要です。 - 日本語:既定の前処理は
str.split()で、空白の無い日本語は文全体が1語になり一致しません。Sudachiなどで分かち書きするpreprocess_funcを必ず渡します。 - 返り値:スコアは返らず、一致が0件でも文書数を上限として最大
k件を返します。スコアが要るならvectorizer.get_scores()を使うか、スコア0を落とす派生クラスを作ります。 - v1での変更:
EnsembleRetrieverはlangchain_classic.retrieversから import します。get_relevant_documents()は削除され、同期検索はinvoke()、非同期検索はainvoke()を使います。 - 今後:
langchain-communityは2026年5月22日に終了が告知され、公式ドキュメントのBM25ページも削除されました。0.4.2は掲載環境で動作し、本記事の合成コーパス10万件では1クエリ約0.2秒でした。大きなコーパスは bm25s や Elasticsearch への移行を検討します。
BM25Retrieverとは|LangChainのキーワード検索Retriever
BM25Retrieverは、検索語が文書に何回出るか、その語がコーパス全体でどれだけ珍しいか、文書がどれだけ長いかの3つからスコアを計算し、上位 k 件の Document を返します。ソース(langchain_community/retrievers/bm25.py)は約120行で、from_texts で各文書を preprocess_func に通し、その結果で rank_bm25.BM25Okapi を作るだけの薄いラッパーです。索引はメモリ上にしか持たず、文書の追加や削除のメソッドもありません。文書が変わったら作り直します。
BM25のスコアとk1・bの役割
BM25はTF-IDFを改良した順位付けの式で、同じ語が何度出ても点数が増え続けないよう k1 で頭打ちにし、長い文書が有利になりすぎないよう b で文書長を補正します。rank_bm25 の既定は k1=1.5・b=0.75・epsilon=0.25 です。式の導出や rank_bm25 と bm25s の計算の違いはBM25の計算式とPython実装の落とし穴で詳しく扱っています。TF-IDFとの違いから押さえたい場合はTF-IDFの仕組みと使い方が前提知識になります。
ベクトル検索ではなくBM25Retrieverを選ぶ場面
BM25Retrieverが正解を返しやすいのは、利用者が文書中の語そのものを打ち込む検索です。エラーコード E1203、製品型番、条文番号、社内のシステム名などは、意味が近いだけの文書より語が一致する文書が正解になります。一方で「解約」と「退会」のような言い換えは拾えません。FAQの問い合わせ文のように言い回しが毎回違う検索だけをBM25で受けると取りこぼしが出るため、その場合は後半のハイブリッド検索を使います。
同名のLlamaIndex BM25Retrieverとの違い
「BM25Retriever」という名前のクラスはLlamaIndexにもあります。こちらは llama-index-retrievers-bm25(2026年10月時点で0.8.0)というパッケージで、内部の計算に bm25s を使い、persist() によるディスク保存や英語のステミングに対応しています。LangChain版とはimport元も引数も違うため、検索で見つけたコードがどちらのものかを最初に確認してください。この記事はLangChain版だけを扱います。
BM25Retrieverのインストールと基本実装
インストールと動作確認した版
この記事のコードは、次の版をPython 3.12の仮想環境に入れて実行しました。langchain-community 0.4.2 は langchain-classic を依存に含むため、EnsembleRetriever もこの1行で使えるようになります。
pip install langchain==1.4.7 langchain-core==1.7.0 langchain-community==0.4.2 langchain-classic==1.0.8 rank_bm25==0.2.2 sudachipy==0.7.0 sudachidict_core==20261015 numpy==2.5.4
| パッケージ | 版 | 公開日 |
|---|---|---|
| langchain | 1.4.7 | 2026-10-21 |
| langchain-core | 1.7.0 | – |
| langchain-community | 0.4.2 | 2026-05-22 |
| langchain-classic | 1.0.8 | 2026-06-10 |
| rank-bm25 | 0.2.2 | 2022-02-16 |
| sudachipy | 0.7.0 | – |
langchain_community を import すると、0.4.2 では DeprecationWarning として終了告知の文面が表示されます。動作には影響しません。
from_textsによる最小の検索実装
まず既定の前処理(空白区切り)がそのまま使える英語の文で動作を確認します。返す件数は k(既定4)で指定します。
from langchain_community.retrievers import BM25Retriever
texts = [
"BM25 ranks documents by keyword overlap",
"Vector search finds documents by meaning",
"Hybrid search combines BM25 and vectors",
"Rerankers reorder the retrieved candidates",
"Chunking splits long documents into passages",
]
retriever = BM25Retriever.from_texts(texts, k=2)
for doc in retriever.invoke("BM25 keyword"):
print(doc.page_content)
print(retriever.vectorizer.get_scores(retriever.preprocess_func("BM25 keyword")))
BM25 ranks documents by keyword overlap
Hybrid search combines BM25 and vectors
[1.41315624 0. 0.3313309 0. 0. ]
「BM25」と「keyword」の両方を含む1件目が1.41点、「BM25」だけを含む3件目が0.33点です。最後の行は、後述するスコアの取り出し方で出した各文書の点数です。
from_documentsによるメタデータ付き文書の検索
出典やIDを結果に残したいときは、Document にメタデータを持たせて from_documents で渡します。Document の id も引き継がれます。
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
docs = [
Document(page_content="reset your password from the login page", metadata={"source": "faq-02"}),
Document(page_content="download receipts as PDF", metadata={"source": "faq-03"}),
Document(page_content="error E1203 means the auth token expired", metadata={"source": "faq-05"}),
]
retriever = BM25Retriever.from_documents(docs, k=1)
print(retriever.invoke("E1203")[0].metadata)
{'source': 'faq-05'}
小さなコーパスで結果が逆順になる原因
BM25Retrieverを日本語の2〜3文書で試すと、関係の無い文書が先頭に来ることがあります。日本語の2文書を既定の前処理で検索した例と、その実行結果です。
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
docs = [
Document(page_content="請求書の再発行はマイページから", metadata={"source": "faq-01"}),
Document(page_content="パスワードの再設定手順", metadata={"source": "faq-02"}),
]
retriever = BM25Retriever.from_documents(docs, k=2)
query = "請求書 再発行"
print(retriever.preprocess_func(docs[0].page_content))
print(retriever.vectorizer.get_scores(retriever.preprocess_func(query)))
print([d.metadata["source"] for d in retriever.invoke(query)])
['請求書の再発行はマイページから']
[0. 0.]
['faq-02', 'faq-01']
「請求書 再発行」で検索したのに、パスワードの文書(faq-02)が先頭に来ました。原因は2つ重なっています。1つは、空白区切りでは日本語の文全体が1語になり、クエリの語と一致しないことです。もう1つは、全文書が0点の同点になり、並び順が rank_bm25 の get_top_n の np.argsort(scores)[::-1] 任せになることです。この2文書では入力と逆順になりました。ただし、既定のargsortは同点要素の順序を保証しないため、常にリストの後ろから返る仕様ではありません。
分かち書きをしても、2文書では解決しません。BM25Okapi の語の重み(IDF)は ln((N - n + 0.5) / (n + 0.5)) で、Nは文書数、nはその語を含む文書数です。N=2で片方にだけ出る語は ln(1.5 / 1.5) = 0 となり、どの語も点数になりません。Janomeで分かち書きした同じ2文書では、全語のIDFが0か負になり、スコアは -0.059 と -0.077 でした。動作確認には、検索語が全文書の半分未満にしか出ないよう、少なくとも5件程度の文書を用意してください。
日本語文書の前処理(preprocess_func)の比較
日本語では、文を語のリストに分ける preprocess_func(Callable[[str], list[str]])を渡します。同じ関数がクエリにも適用されます。問い合わせFAQ 8件に対して、既定の空白区切りと4種類の代替前処理で1位の文書を比べました。
| 前処理 | 請求書 再発行 | E1203 | 解約したい | ログインできない |
|---|---|---|---|---|
| 空白区切り(既定) | ×(全件0点) | ×(全件0点) | ×(全件0点) | ×(全件0点) |
| Janome 表層形すべて | ○ | ○ | ○ | ×(領収書の文書) |
| Janome 品詞絞り+原形 | ○ | ○ | ○ | ×(領収書の文書) |
| Sudachi Cモード+正規化形 | ○ | ○ | ○ | ○ |
| 文字bigram | ○ | ○ | ○ | ○ |
Janomeで「ログインできない」が外れたのは、「できない」の「できる」が領収書の文書(「ダウンロードできます」)と一致したためです。Janomeの品詞体系では「できる」が自立した動詞になるため、品詞で絞っても残ります。Sudachiでは「できる」が「非自立可能」に分類されるので除外でき、Cモードは「請求書」「再発行」を1語のまま切り出します。文字bigramは辞書が不要で取りこぼしにくい反面、「ログ」「グイ」のような意味の無い断片も一致に数えます。
このFAQ例では、Sudachi(Cモード・正規化形・内容語だけ)を使います。ただし、以下の関数は数詞を除外するため、条文番号や数値を検索条件にする場合は数詞を残す前処理へ変更してください。正規化形は「附属」と「付属」のような表記ゆれもそろえます。
from sudachipy import dictionary, tokenizer
_tokenizer = dictionary.Dictionary().create()
_MODE = tokenizer.Tokenizer.SplitMode.C
_KEEP = ("名詞", "動詞", "形容詞")
_DROP = ("非自立可能", "数詞")
def preprocess_ja(text: str) -> list[str]:
return [
m.normalized_form()
for m in _tokenizer.tokenize(text, _MODE)
if m.part_of_speech()[0] in _KEEP and m.part_of_speech()[1] not in _DROP
]
# ja_tok.py に上の preprocess_ja を保存しておく
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Document
from ja_tok import preprocess_ja
faq = [
"請求書の再発行はマイページの契約情報から行えます",
"パスワードの再設定はログイン画面のリンクから行えます",
"領収書はPDFでダウンロードできます",
"解約の手続きはサポート窓口で受け付けています",
"エラーコードE1203は認証トークンの期限切れを示します",
"支払い方法はクレジットカードと口座振替に対応しています",
"契約プランの変更は翌月から反映されます",
"退会するとデータは30日後に削除されます",
]
docs = [Document(page_content=t, metadata={"id": f"faq-{i:02d}"}) for i, t in enumerate(faq, 1)]
print(preprocess_ja("請求書を再発行したい"))
retriever = BM25Retriever.from_documents(docs, preprocess_func=preprocess_ja, k=2)
for q in ["請求書を再発行したい", "ログインできない"]:
print(q, [d.metadata["id"] for d in retriever.invoke(q)])
['請求書', '再発行']
請求書を再発行したい ['faq-01', 'faq-08']
ログインできない ['faq-02', 'faq-08']
2件目のfaq-08(退会の文書)は検索語と1語も一致していない0点の文書です。これが次の章の問題につながります。前処理の関数は、ファイルの先頭で def で定義してください。lambda で渡すと、後で pickle 保存するときに PicklingError になります。
Sudachiの分かち書きは、FAQ程度の短文で1,000文あたり0.16秒でした(Intel Core i9-9880H)。この短文FAQの計測値は索引作成時間の目安ですが、長文や頻繁な再構築では負荷が変わります。実際の文書長と更新頻度に合わせて計測してください。Elasticsearchで同じ問題を解く場合の構成はElasticsearchの日本語全文検索とkuromojiの設定で解説しています。
BM25Retrieverのスコア取得と足切り
invoke() が返すのは Document のリストだけで、スコアは含まれません。さらに、一致が0件のクエリでも、文書数を上限として最大 k 件を返します。「まったく無関係な質問」で検索すると全文書が0点のまま、リストの末尾から3件(faq-08・07・06)が返りました。LLMに渡す文脈に無関係な文書が混ざるため、スコアを見て足切りする仕組みが要ります。
スコアは retriever.vectorizer.get_scores(retriever.preprocess_func(query)) で全文書分を配列として取れます。毎回これを書く代わりに、_get_relevant_documents を上書きした派生クラスを作ると、スコアがmetadataに入り、0点の文書は返らなくなります。
import numpy as np
from langchain_community.retrievers import BM25Retriever
from langchain_core.callbacks import CallbackManagerForRetrieverRun
from langchain_core.documents import Document
class ScoredBM25Retriever(BM25Retriever):
"""スコアをmetadataに入れ、min_score以下の文書を返さないBM25Retriever"""
min_score: float = 0.0
def _get_relevant_documents(
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
) -> list[Document]:
scores = self.vectorizer.get_scores(self.preprocess_func(query))
top = np.argsort(scores)[::-1][: self.k]
return [
Document(
id=self.docs[i].id,
page_content=self.docs[i].page_content,
metadata={**self.docs[i].metadata, "bm25_score": float(scores[i])},
)
for i in top
if scores[i] > self.min_score
]
# docs と preprocess_ja は前章のもの
retriever = ScoredBM25Retriever.from_documents(docs, preprocess_func=preprocess_ja, k=3)
print([(doc.metadata["id"], round(doc.metadata["bm25_score"], 3)) for doc in retriever.invoke("請求書を再発行したい")])
print(retriever.invoke("まったく無関係な質問"))
[('faq-01', 2.916)]
[]
from_documents は cls を使って生成するので、派生クラスからそのまま呼べます。BM25のスコアは文書数や文書長で尺度が変わるため、min_score は2.0のような固定値にせず、この例では0点以下だけを除外して件数を k で絞っています。ただし、BM25Okapiでは語が一致していてもIDFの影響で0点や負点になるため、この足切りは一致判定の代わりにはなりません。一致文書を必ず残したい場合は、前処理後の検索語と文書のトークンの共通部分で判定してください。
bm25_paramsによるk1・bの調整
k1・b・epsilon は bm25_params の辞書にまとめて渡します。中身はそのまま BM25Okapi(...) のキーワード引数になります。
from langchain_community.retrievers import BM25Retriever
texts = ["BM25 ranks documents by keyword overlap", "Vector search finds documents by meaning", "Hybrid search combines keyword and vector search"]
retriever = BM25Retriever.from_texts(
texts,
bm25_params={"k1": 1.2, "b": 0.75},
k=3,
)
print(retriever.vectorizer.k1, retriever.vectorizer.b)
k1 を上げると同じ語を繰り返し含む文書がより優遇され、下げると1回出れば十分という評価に近づきます。b は1に近いほど長い文書を減点し、0なら文書長を無視します。FAQやチャンク分割済みの文書のように長さがそろっているなら b を下げても差は小さく、記事全文とFAQが混ざったコーパスでは既定の0.75から始めます。チャンクの長さをどうそろえるかはチャンク分割の手法とサイズ設計を参照してください。
注意点が1つあります。2026年1月に公式ドキュメントへ bm25_variant="plus" でBM25Plusに切り替える例が追加されましたが、リリース済みの0.4.2にも、langchain-community の main ブランチにもこの引数の実装はありません。bm25_variant="plus" だけを渡すとエラーにならず無視されて BM25Okapi のまま動き、bm25_params={"delta": 0.5} を足すと TypeError: BM25Okapi.__init__() got an unexpected keyword argument 'delta' になりました。BM25Plusが必要なら、rank_bm25.BM25Plus で作った索引を BM25Retriever(vectorizer=..., docs=..., preprocess_func=...) に直接渡します。
EnsembleRetrieverによるBM25とベクトル検索の統合
langchain_classicからのimportと最小コード
BM25とベクトル検索の結果を統合するのが EnsembleRetriever です。LangChain 1.x では langchain.retrievers というモジュールが無く、from langchain.retrievers import EnsembleRetriever は ModuleNotFoundError: No module named 'langchain.retrievers' になります。LangChain v1の移行ガイドのとおり langchain_classic.retrievers から import します。v1で移った他のモジュールはLangChain v1.0の変更点にまとめています。
from langchain_classic.retrievers import EnsembleRetriever
from langchain_core.embeddings import DeterministicFakeEmbedding
from langchain_core.vectorstores import InMemoryVectorStore
from ja_tok import preprocess_ja
# docs は前章のFAQ 8件、ScoredBM25Retriever は前章のクラス
bm25 = ScoredBM25Retriever.from_documents(docs, preprocess_func=preprocess_ja, k=3)
# 動作確認用のダミー埋め込み。本番は OpenAIEmbeddings などに置き換える
vector = InMemoryVectorStore.from_documents(
docs, DeterministicFakeEmbedding(size=64)
).as_retriever(search_kwargs={"k": 3})
ensemble = EnsembleRetriever(retrievers=[bm25, vector], weights=[0.5, 0.5])
print([d.metadata["id"] for d in ensemble.invoke("請求書 再発行")])
['faq-01', 'faq-04', 'faq-02', 'faq-06']
統合方法は重み付きのReciprocal Rank Fusion(RRF)で、各Retrieverでの順位を使い weight / (順位 + c) を足し合わせます。c の既定は60、weights を省くと均等になります。重複の判定は既定で page_content の一致で行い、id_key を指定するとmetadataのキーで判定します。上のコードはAPIキー無しで動かすためのダミー埋め込みなので、ベクトル側の結果に意味はありません。本番は OpenAIEmbeddings などに差し替えます。BM25とベクトル検索を統合する設計全体はハイブリッド検索の仕組みと実装で扱っています。
一致ゼロの文書がRRFの上位に来る問題
素の BM25Retriever をそのまま EnsembleRetriever に入れると、BM25側の0点の文書も「2位」「3位」として順位点をもらいます。上と同じクエリで素のBM25Retriever(k=3)を使うと、BM25側は faq-01・faq-08・faq-06 を返し、faq-08 と faq-06 は0点でした。ベクトル側も3位に faq-06 を返したため、RRFでは両方に出た faq-06 が1位、BM25で本当に一致した faq-01 が2位に落ちました。
RRFは順位しか見ないので、スコアが0かどうかを区別できません。前章の ScoredBM25Retriever のように0点の文書を返さないRetrieverを渡すと、faq-01 が1位に戻ります。ハイブリッド検索でBM25Retrieverを使うなら、この足切りを入れてから weights を調整してください。型番やエラーコードの検索が多い場合は、BM25側を重くする候補として [0.6, 0.4] を試します。自然文中心の場合も含め、実際の質問と正解文書で比較して重みを決めてください。
langchain-communityの終了と移行先の選び方
sunset告知と公式ドキュメントの削除
LangChainは2026年5月22日、GitHubのIssue「Sunsetting langchain-community」で、langchain-community の終了を即日発効で告知しました。新機能は1年以上受け付けておらず、統合は提供元ごとの独立パッケージへ移す方針です。同日に0.4.2が公開され、前日には公式ドキュメントからBM25Retrieverを含むcommunity統合のページが削除されています(langchain-ai/docs PR #4106)。いま残る公式情報はBM25RetrieverのAPIリファレンスです。
BM25Retrieverの後継となる独立パッケージは、2026年10月時点ではありません。0.4.2は langchain-core 1.7.0 の上で問題なく動くので、既存のコードは版を固定して使い続けられます。ただし修正は今後出ない前提で、新規に組むなら次の規模の判断を先にしておきます。
文書数ごとの速度と移行先
rank_bm25 はクエリのたびに全文書のスコアをPythonで計算するため、検索時間が文書数に比例して伸びます。1文書30語・語彙5,000語の合成コーパスで、索引作成時間と、3語のクエリ20件の平均検索時間を測りました。乱数シードは0、取得件数は5件で、前処理とRetriever呼び出しを含む計測です(Intel Core i9-9880H・他の処理と並行しての計測なので目安です)。
| 文書数 | rank_bm25 索引 | rank_bm25 1クエリ | bm25s 0.3.15 索引 | bm25s 1クエリ |
|---|---|---|---|---|
| 1万件 | 0.47秒 | 22.2ミリ秒 | 1.18秒 | 1.5ミリ秒 |
| 10万件 | 6.56秒 | 207.8ミリ秒 | 13.26秒 | 27.3ミリ秒 |
判断の目安は次のとおりです。
- 数千件まで:BM25Retrieverで十分です。起動時に作り直すか、
pickleで保存して読み込みます。 - 数万件以上、または検索の待ち時間が気になる:bm25sで索引を作り、
BaseRetrieverを継承したRetrieverに包み、_get_relevant_documentsの中で bm25s の検索を呼びます。bm25sは索引のディスク保存にも対応しています。 - 文書が頻繁に増減する、複数プロセスで共有する:BM25Retriever単体では更新と共有の管理を別途実装する必要があるため、ElasticsearchやOpenSearchの全文検索、または全文検索を持つベクトルDBに移し、そのRetrieverを使います。小さく始めるならSQLiteのFTS5による全文検索も選択肢です。
BM25Retrieverを使ったRAG全体の組み方はLangChainでRAGを構築する手順で解説しています。社内文書の検索精度の検証から本番構成の設計までを外部に任せたい場合は、一創のRAG構築支援でご相談いただけます。
よくある質問
BM25Retrieverを使うには何をpip installすればよいですか?
pip install langchain-community rank_bm25 です。日本語を扱うなら sudachipy sudachidict_core などの形態素解析器も入れます。rank_bm25 が無いと from_texts の時点で ImportError になります。
日本語で検索結果がおかしいのはなぜですか?
既定の前処理が空白区切りで、日本語の文全体が1語になるためです。検索語が一致しない場合は全文書が0点になりますが、そのときの返却順は保証されません。形態素解析で語に分ける preprocess_func を渡してください。
get_relevant_documentsが使えないのはなぜですか?
langchain-core 1.x で削除されたためです。1.7.0 では AttributeError: 'BM25Retriever' object has no attribute 'get_relevant_documents' になります。invoke(query) に置き換えます。
BM25Retrieverの索引はディスクに保存できますか?
専用の保存メソッドはありません。Retrieverごと pickle で保存して読み込めます。前章のFAQ 8件をSudachiの前処理つきで保存すると2,609バイトでした。preprocess_func を lambda で渡していると保存に失敗するので、def で定義した関数を渡します。
BM25RetrieverとTFIDFRetrieverはどちらを使うべきですか?
文書長にばらつきがある検索では、まずBM25Retrieverを比較の基準にします。語の出現回数の飽和と文書長の補正を持つためですが、TFIDFRetrieverより検索精度が高いかは、実際の質問と正解文書で比較して判断します。どちらも langchain-community に含まれ、終了告知の対象である点は同じです。