BM25Retrieverは、LangChainでキーワード一致に基づく文書検索を担うRetrieverです。埋め込みベクトルを作らずに動くため、型番・エラーコード・専門用語のように「その語がそのまま含まれる文書」を探す用途に強く、RAGの検索段でベクトル検索と併用されます。この記事では、正しいimportと最小実装から、k1・bのチューニング、日本語文書のためのpreprocess_func、EnsembleRetrieverによるハイブリッド検索までを実装コード付きで解説します。旧来よく混同される「rank_bm25を直接使うコード」ではなく、LangChainのBM25Retrieverそのものの使い方に絞ります。
まとめ:BM25Retriever実装の要点
- importは
from langchain_community.retrievers import BM25Retriever。内部でrank_bm25を使うためpip install rank_bm25が必須。 - コーパスは
from_texts(文字列)かfrom_documents(Documentオブジェクト)で渡し、検索は.invoke(query)で実行する。 - スコアの挙動は
bm25_paramsのk1・bで調整する。 - 日本語は既定の空白分割では機能しないため、形態素解析した
preprocess_funcを渡す。 - 意味の言い換えに弱いので、ベクトル検索と
EnsembleRetrieverで組み合わせると精度が上がる。
以下、それぞれを実装コードとともに順に見ていきます。
BM25Retrieverとは|キーワード検索を担うLangChainのRetriever
BM25Retrieverは、TF-IDFを発展させたランキング関数BM25で、クエリと各文書の一致度をスコア化して上位を返すRetrieverです。ベクトル検索が埋め込みモデルで「意味の近さ」を測るのに対し、BM25は「語がどれだけ含まれるか」を測ります。埋め込みAPIの呼び出しもGPUも不要で、少量のコーパスならその場でインデックスを構築できる手軽さが利点です。
BM25のスコアリングとTF-IDFとの違い(k1・b)
BM25がTF-IDFと違うのは、単語頻度の「飽和」と文書長の「補正」を持つ点です。同じ語が何度出ても頻度スコアはk1で頭打ちになり、長い文書が有利になりすぎないようbで正規化します。rank_bm25のBM25Okapi既定値はk1=1.5、b=0.75で、この2値がBM25Retrieverの検索結果を左右する主要パラメータです。
ベクトル検索ではなくBM25Retrieverを選ぶ場面
BM25Retrieverが向くのは、ユーザーが正確な語で検索する場面です。製品コード「ABC-1200」、エラー文言「Connection refused」、条文番号や社内固有名詞などは、意味が近いだけの文書より完全一致の文書が正解になります。逆に「解約したい」を「退会」と言い換えるような表記ゆれ・同義語には弱く、ここだけをBM25で解こうとすると取りこぼします。この弱点を埋めるのが後述のハイブリッド検索です。
BM25Retrieverの導入と最小実装(インストール〜invoke)
BM25RetrieverはLangChainのcommunityパッケージに含まれ、実体の計算はrank_bm25が担います。まず両方をインストールします。
pip install langchain-community rank_bm25
正しいimportとfrom_textsでの最小検索
importはlangchain_community.retrieversから行います。文字列のリストをそのまま渡すならfrom_textsが最短です。返す件数はk(既定4)で指定します。
from langchain_community.retrievers import BM25Retriever
texts = [
"BM25はキーワードの一致度で文書をスコアリングする",
"ベクトル検索は意味の近さで文書を探す",
"ハイブリッド検索は両者を組み合わせる",
]
retriever = BM25Retriever.from_texts(texts, k=2)
docs = retriever.invoke("キーワード 一致")
for d in docs:
print(d.page_content)
クエリ実行は.invoke()を使います。以前の.get_relevant_documents()は非推奨で、新しいコードでは.invoke()に統一します。
from_documentsによるメタデータ付き文書の検索
出典やIDを結果に残したいときは、Documentにメタデータを持たせてfrom_documentsで渡します。RAGで「どのFAQから引いたか」を追跡する用途で使います。
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=3)
results = retriever.invoke("請求書 再発行")
print(results[0].metadata)
k1・bとbm25_paramsによる検索精度の調整
k1とbは、from_texts/from_documentsのbm25_paramsにまとめて渡します。
retriever = BM25Retriever.from_texts(
texts,
bm25_params={"k1": 1.2, "b": 0.75},
k=3,
)
調整の指針は明確です。k1を上げると同じ語が繰り返し出る文書ほど強く優遇され、下げると1回出れば十分という評価に近づきます。bは1.0に近づけるほど長い文書へのペナルティが強くなり、0に近づけると文書長を無視します。短いFAQやチャットログのように長さが揃ったコーパスではbを小さめ、記事とスニペットが混在するコーパスでは既定の0.75付近から始めて、実クエリで上位の妥当性を見ながら詰めるのが実務的です。
BM25Retrieverでの日本語文書検索(preprocess_func)
BM25Retrieverの既定の前処理は「空白で区切る」だけです。単語間に空白のない日本語では文全体が1トークンになり、ほぼ検索が効きません。日本語では形態素解析した結果を返すpreprocess_func(Callable[[str], List[str]])を渡します。ここではJanomeを使います。
from janome.tokenizer import Tokenizer
from langchain_community.retrievers import BM25Retriever
tokenizer = Tokenizer()
def preprocess_func(text):
return [token.surface for token in tokenizer.tokenize(text)]
texts = ["請求書の再発行はマイページから", "パスワードの再設定手順"]
retriever = BM25Retriever.from_texts(texts, preprocess_func=preprocess_func, k=2)
docs = retriever.invoke("請求書 再発行")
MeCabやSudachiでも同様に、トークンの表層形(surface)のリストを返せば置き換えられます。精度を上げるなら、助詞・記号を落とす、表記を正規化する(全角半角・カタカナ長音の統一)といった処理をpreprocess_func内に足します。なお、この関数は後述の永続化でpickle化されうるため、lambdaではなくモジュール直下のdefで定義しておくのが安全です。
ベクトル検索と組み合わせるハイブリッド検索(EnsembleRetriever)
キーワード一致のBM25と、意味の近さで探すベクトル検索は得意分野が逆で、両方を走らせて結果を統合すると取りこぼしが減ります。LangChainではEnsembleRetrieverが複数のRetrieverの結果をReciprocal Rank Fusion(RRF)でまとめます。RRFは各Retrieverでの順位の逆数を足し合わせて再ランキングする方式で、スコアの尺度が違うBM25とベクトル検索を公平に混ぜられます。
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
bm25 = BM25Retriever.from_texts(texts, preprocess_func=preprocess_func, k=4)
vector = FAISS.from_texts(texts, OpenAIEmbeddings()).as_retriever(search_kwargs={"k": 4})
ensemble = EnsembleRetriever(retrievers=[bm25, vector], weights=[0.5, 0.5])
docs = ensemble.invoke("解約したい")
weightsは各Retrieverの寄与度で、合計は自由です。ユーザーが型番やエラーメッセージで検索しがちなら[0.6, 0.4]とBM25側を厚く、自然文の質問が中心なら[0.4, 0.6]とベクトル側を厚くします。まずは[0.5, 0.5]から始め、実クエリで外した検索を見て寄せるのが定石です。意図ごとに検索戦略を切り替える上位設計はOmniRAGの使い分けパターンが参考になります。なお、ベクトル側の埋め込みはOpenAIEmbeddingsに限らず任意(ローカルならHuggingFaceEmbeddings等)に置き換えられます。
BM25Retrieverの運用上の落とし穴(永続化・API・import)
実装で事故になりやすいのは、性能よりも運用面です。
- インメモリで永続化されない:BM25Retrieverはコーパスとインデックスをメモリ上に保持し、ディスクへ自動保存しません。プロセスを再起動するたびに
from_textsで作り直すか、Retrieverをpickleで保存・復元します。大規模コーパスを毎回構築するとメモリと初期化時間が増えるため、コーパス更新頻度に応じて再構築か永続化かを決めます。 - get_relevant_documentsは非推奨:古い記事のコードは
.get_relevant_documents()を使いますが、現行は.invoke()です。移行時に置き換えます。 - importパスの変更に注意:BM25Retrieverは
langchain_community、EnsembleRetrieverはlangchainに分かれています。バージョン間でパッケージ構成が動いた経緯はLangChain v1.0の変更点を確認すると把握しやすいです。引数の最終仕様はBM25Retrieverの公式APIリファレンスで確認できます。 - preprocess_funcはmodule直下で定義:pickle化する場合、
lambdaやローカル関数は保存できません。
よくある質問
Q. BM25RetrieverとTFIDFRetrieverはどちらを使うべきですか?
文書長の補正と頻度飽和を持つBM25の方が実務での精度が安定しやすく、基本はBM25Retrieverで問題ありません。どちらもLangChainのcommunityに含まれ、使い方はほぼ同じです。
Q. from_textsとfrom_documentsの違いは何ですか?
渡すデータ型の違いです。文字列のリストならfrom_texts、メタデータ付きのDocumentを扱うならfrom_documentsを使います。出典やIDを結果に残したいなら後者です。
Q. 日本語で検索結果が出ないのはなぜですか?
既定の前処理が空白分割のためです。日本語は形態素解析したpreprocess_func(Janome・MeCab・Sudachi等)を渡さないと、文全体が1トークンになり一致しません。
Q. get_relevant_documentsは使えなくなりましたか?
非推奨です。動く場合もありますが、新しいコードは.invoke(query)に統一してください。
Q. BM25Retrieverはディスクに保存できますか?
自動保存はされません。プロセスごとにfrom_textsで再構築するか、Retrieverをpickleで保存・復元します。