TF-IDFとBM25で検索エンジンを自作:ブログ86記事で実測

Python標準ライブラリだけで転置インデックス、TF-IDF、BM25を実装し、形態素解析なしの文字バイグラムで日本語も検索。ブログ86記事でMRR、k1・bの効き方、速度を実測し、埋め込みとの使い分けもまとめた。

RAGやベクトル検索に手を出す前に、その土台にある「キーワード検索」が実際どれくらい当たるのかを、自分のデータで確かめたい開発者向けの記事です。このブログの記事(各言語86本)を対象に、Python 3.9の標準ライブラリだけで転置インデックス、TF-IDF、BM25を実装し、形態素解析器なしで日本語も検索できるようにしました。結果を先に書くと、BM25はラベル付きの日本語20問すべてで正解記事を上位3件に入れ、1回の検索は中央値0.075ミリ秒。ただし記事と違う言葉で尋ねると、MRRは0.975から0.531まで落ちました。

この記事は連載「ゼロから作る機械学習(Python標準ライブラリだけ)」の第5回で、RAGへの橋渡しの回です。これまでに勾配降下法の線形回帰、k-means、ナイーブベイズの文書分類、パーセプトロンからニューラルネットを作りました。ナイーブベイズでは単語の数を数えて「ラベル」を選びました。今回は同じ数え方で「文書」を選びます。

環境:Python 3.9.6 標準ライブラリのみ、Apple Silicon Mac(M1 Pro)。何もインストールせず、モデルもダウンロードしていません。対象は2026年11月11日時点のブログのMarkdownファイルで、この連載の記事はまだ含まれていません。

なぜ埋め込みやRAGの前にキーワード検索なのか

RAGは「関連する文章を探す」と「言語モデルがそれを読んで答える」の2段構えです。前半は検索で、検索はニューラルネットより古い技術です。先にキーワード検索を作る理由は3つあります。

  1. 比較の基準になる。 埋め込みモデルが自分の質問でBM25に勝てないなら、そのコストに見合っていません。BM25の数字がなければ、それすら判断できません。
  2. 識別子に強い。 開発者はエラーメッセージをそのまま貼ります。CS8509、WM_COPYDATA、max_connections_per_hour。キーワード索引にとってこれらは珍しく重い語ですが、埋め込みモデルにとってはサブワードの断片で、似た別のエラーの近くに置かれることがあります。
  3. ハイブリッド検索の半分になる。 実運用のRAGの多くは、BM25とベクトル検索を並べて動かし、2つの順位リストを混ぜます。どちらにしてもキーワード側は必要です。

埋め込みは実装しません。学習済みモデルが必要で、この連載ではモデルのダウンロードをしない決まりだからです。いつ埋め込みを足すべきかは、後半の判断表にまとめました。

ステップ1:転置インデックス

素朴な検索は、質問のたびに全文書を読みます。転置インデックスはそれを逆にして、語ごとに「どの文書に何回出たか」を持ちます。検索時に読むのは、質問に含まれる語のリストだけです。

2段のフローチャート。上段の索引づくり:Markdownの記事をNFKC正規化と小文字化し、英数字は単語、日本語と韓国語は文字バイグラムに分け、語ごとに出現文書と出現回数を持つ転置インデックスと文書の長さに保存する。下段の検索:検索語も同じトークナイザーに通し、その語の転置リストをインデックスから読み、候補文書をTF-IDFのコサイン類似度かBM25で採点し、上位10件のslugを返す。
索引づくりは86記事に対して1回だけ。検索語は同じトークナイザーを通り、自分の語の転置リストだけを読む。

エンジン全体は1ファイルです。load_posts はフロントマターからタイトルと説明文だけを残し、日付やタグは捨て、本文をコードブロックごと足します。識別子はコードの中にあるからです。

# search.py - inverted index + TF-IDF (cosine) + BM25, Python standard library only
import math, re, sys, unicodedata
from collections import Counter, defaultdict
from pathlib import Path

WORD = re.compile(r"[a-z0-9_]+|[぀-ヿ㐀-鿿가-힯]+")

def tok_bigram(text):
    """Latin/digit runs become words; CJK/Hangul runs become character bigrams."""
    out = []
    for w in WORD.findall(unicodedata.normalize("NFKC", text).lower()):
        if w[0] < "぀":
            out.append(w)
        elif len(w) == 1:
            out.append(w)
        else:
            out.extend(w[i:i + 2] for i in range(len(w) - 1))
    return out

def tok_space(text):
    """The naive tokenizer: lowercase and split on whitespace."""
    return unicodedata.normalize("NFKC", text).lower().split()

def load_posts(folder):
    """Return {slug: (title, text)}; front matter is dropped except title and description."""
    posts = {}
    for p in sorted(Path(folder).glob("*.md")):
        raw = p.read_text(encoding="utf-8")
        _, fm, body = raw.split("---", 2)
        meta = dict(re.findall(r"^(title|description): *['\"]?(.*?)['\"]?$", fm, re.M))
        posts[p.stem] = (meta["title"], meta["title"] + "\n" + meta.get("description", "") + "\n" + body)
    return posts

class Index:
    def __init__(self, docs, tokenize=tok_bigram):
        self.tokenize = tokenize
        self.ids = list(docs)
        self.postings = defaultdict(dict)            # term -> {doc number: term frequency}
        self.length = []                             # tokens per document
        for n, slug in enumerate(self.ids):
            tf = Counter(tokenize(docs[slug]))
            self.length.append(sum(tf.values()))
            for term, c in tf.items():
                self.postings[term][n] = c
        self.N = len(self.ids)
        self.avgdl = sum(self.length) / self.N
        # TF-IDF document vector norms, computed once at build time
        self.norm = [0.0] * self.N
        for term, plist in self.postings.items():
            idf = math.log(self.N / len(plist))
            for n, c in plist.items():
                self.norm[n] += ((1 + math.log(c)) * idf) ** 2
        self.norm = [math.sqrt(x) or 1.0 for x in self.norm]

    def tfidf(self, query, k=10):
        q = Counter(t for t in self.tokenize(query) if t in self.postings)
        scores = defaultdict(float)
        for term, qc in q.items():
            plist = self.postings[term]
            idf = math.log(self.N / len(plist))
            wq = (1 + math.log(qc)) * idf
            for n, c in plist.items():
                scores[n] += wq * (1 + math.log(c)) * idf
        qn = math.sqrt(sum(((1 + math.log(c)) * math.log(self.N / len(self.postings[t]))) ** 2
                           for t, c in q.items())) or 1.0
        return self._top({n: s / (self.norm[n] * qn) for n, s in scores.items()}, k)

    def bm25(self, query, k=10, k1=1.2, b=0.75):
        scores = defaultdict(float)
        for term in set(self.tokenize(query)):
            plist = self.postings.get(term)
            if not plist:
                continue
            df = len(plist)
            idf = math.log(1 + (self.N - df + 0.5) / (df + 0.5))
            for n, c in plist.items():
                norm = k1 * (1 - b + b * self.length[n] / self.avgdl)
                scores[n] += idf * c * (k1 + 1) / (c + norm)
        return self._top(scores, k)

    def _top(self, scores, k):
        best = sorted((x for x in scores.items() if x[1] > 0), key=lambda x: -x[1])[:k]
        return [(self.ids[n], round(s, 3)) for n, s in best]

if __name__ == "__main__":
    folder, query = sys.argv[1], " ".join(sys.argv[2:])
    posts = load_posts(folder)
    idx = Index({s: t for s, (_, t) in posts.items()})
    for name, hits in (("TF-IDF", idx.tfidf(query, 3)), ("BM25", idx.bm25(query, 3))):
        print(name)
        for slug, s in hits:
            print(f"  {s:7.3f}  {posts[slug][0]}")

インデックスの正体は dict[str, dict[int, int]] です。日本語記事では語の種類が21,457、転置リストの要素が合計114,836個でした。

日本語は空白で切れない:文字バイグラム

英語は単語の間に空白がありますが、日本語にはありません。split() で切ると、文がまるごと1つの「語」になります。日本語記事の中で「後方参照」を含む空白区切りトークンを数え、同じ質問を2つのトークナイザーで検索しました。

# tokdemo.py - whitespace tokens vs character bigrams on real posts
import sys
from collections import Counter
from search import Index, load_posts, tok_bigram, tok_space

folder, word, query = sys.argv[1], sys.argv[2], sys.argv[3]
posts = load_posts(folder)
docs = {s: t for s, (_, t) in posts.items()}
forms = Counter(t for text in docs.values() for t in tok_space(text) if word in t)
print(f"whitespace tokens containing {word!r}: {len(forms)} distinct forms")
for t, c in forms.most_common(5):
    print(f"  {c:3}  {t[:40]}")
for name, tok in (("whitespace", tok_space), ("bigram", tok_bigram)):
    idx = Index(docs, tok)
    print(f"{name}: {tok(query)}")
    for slug, s in idx.bm25(query, 3) or [("(no results)", 0)]:
        print(f"  {s:6.2f}  {slug}")
$ python3 tokdemo.py content/ja 後方参照 "正規表現 後方参照"
whitespace tokens containing '後方参照': 26 distinct forms
    1  正規表現の後方参照\\1:.net・python・php・jsで検証
    1  正規表現の後方参照(\\1、\\k<name>)を.net・python・php
    1  regexp)で、同じ入力のまま実行し直しました。2つの例のマッチ結果はどのエン
    1  に移したら後方参照の書き方で詰まった人向けの記事です。先に結論を書くと、後方参照
    1  後方参照:グループが捕まえた文字列をもう一度探す
whitespace: ['正規表現', '後方参照']
    0.00  (no results)
bigram: ['正規', '規表', '表現', '後方', '方参', '参照']
   20.77  regex-what-is-regular-expression
   20.77  regex-backreferences
   18.75  regex-repeated-characters

「後方参照」は26種類の「トークン」の中にありますが、どれも文の断片で、単独の「後方参照」は1つもありません。後方参照そのものを扱った記事があるのに、結果は0件です。

文字バイグラムは辞書なしでこれを避けます。かな・漢字・ハングルの連続を、2文字の窓で1文字ずつずらして切ります。「後方参照」は「後方/方参/参照」になり、この語を含む文には必ず同じ窓が現れます。MySQLの全文検索のngramパーサーが既定で2文字単位なのと同じ考え方です。代償はノイズと索引の大きさで、「方参」は単語ではありませんし、語の種類は英語の8,412に対して日本語は21,457でした。MeCabやSudachiのような形態素解析器を使えばきれいに切れますが、辞書つきの依存が増えます。ブログ程度の規模ならバイグラムで足りました。

BM25のMRR@10の棒グラフ(1が満点)。日本語のラベル付き:空白区切り0.338、文字バイグラム0.975。日本語の言い換え:空白区切り0、バイグラム0.531。韓国語のラベル付き:空白区切り0.933、バイグラム0.967。韓国語の言い換え:空白区切り0.420、バイグラム0.594。
同じ索引コードで、トークナイザーだけを変えたBM25のMRR@10。空白区切りは日本語で崩壊する。韓国語はラベル付きセットでは持ちこたえるが、助詞が語にくっつく言い換えの検索で差が開く。

ステップ2:TF-IDFとコサイン類似度

TF-IDFは文書中の語に (1 + log tf) × log(N / df) の重みをつけます。

  • tf:その文書での出現回数。対数を取るので、30回出た語が1回の語の30倍にはなりません。
  • df:その語を含む文書の数。全記事に出る語は log(N / df) が0、珍しい語ほど大きくなります。「です」「する」は重さ0、cs8509 は重い。仕組みの核心はこれだけです。

質問にも同じ重みをつけ、2つのベクトルのなす角のコサインを点数にします。ベクトルの長さで割るので(文書側は構築時に self.norm に計算済み)、長い記事が語の多さだけで勝つことはありません。

ステップ3:BM25と、k1・bの意味

BM25は考え方を引き継ぎつつ2点を変えます。idfは log(1 + (N − df + 0.5) / (df + 0.5)) で、負になりません。大きいのは回数と長さの扱いです。

score += idf × tf × (k1 + 1) / (tf + k1 × (1 − b + b × 文書長 / 平均文書長))

k1は「飽和」の速さ、つまり同じ語が何度出ても点数が頭打ちになる速さです。bは文書の長さによる補正の強さで、0なら長さを無視し、1なら平均との比で完全に割り引きます。knobs.py で両方を見ました。

# knobs.py - what k1 and b actually do
import math, sys
from search import Index, load_posts

# 1) k1: contribution of one term as its count grows, in a document of average length (idf left out)
print("tf   " + "  ".join(f"k1={k1:<4}" for k1 in (0.3, 1.2, 3.0)) + "  TF-IDF 1+log(tf)")
for tf in (1, 2, 5, 10, 30):
    row = [tf * (k1 + 1) / (tf + k1) for k1 in (0.3, 1.2, 3.0)]
    print(f"{tf:<4} " + "  ".join(f"{v:7.2f}" for v in row) + f"  {1 + math.log(tf):7.2f}")

# 2) b: the same query with length normalisation off, default and full
folder, query = sys.argv[1], " ".join(sys.argv[2:])
posts = load_posts(folder)
idx = Index({s: t for s, (_, t) in posts.items()})
length = dict(zip(idx.ids, idx.length))
print(f"\nquery: {query}   (average length {idx.avgdl:.0f} tokens)")
for b in (0.0, 0.75, 1.0):
    print(f"b={b}")
    for slug, s in idx.bm25(query, 3, 1.2, b):
        print(f"  {s:6.2f}  {length[slug]:5} tokens  {slug}")
$ python3 knobs.py content/ja "正規表現 後読み"
tf   k1=0.3   k1=1.2   k1=3.0   TF-IDF 1+log(tf)
1       1.00     1.00     1.00     1.00
2       1.13     1.38     1.60     1.69
5       1.23     1.77     2.50     2.61
10      1.26     1.96     3.08     3.30
30      1.29     2.12     3.64     4.40

query: 正規表現 後読み   (average length 3078 tokens)
b=0.0
   14.66   2584 tokens  regex-what-is-regular-expression
   14.22   1662 tokens  regex-lookaround
   13.30   3459 tokens  regex-subexpression
b=0.75
   15.20   1662 tokens  regex-lookaround
   14.93   2584 tokens  regex-what-is-regular-expression
   13.02   3459 tokens  regex-subexpression
…
$ python3 knobs.py content/ja "アプリが二重に立ち上がる"
…
b=0.0
   14.63   3547 tokens  winforms-single-instance-form
   13.11   2451 tokens  fcm-scheduled-push-php-cron
   12.50   2952 tokens  flutter-migration-16-apps-what-broke
…
b=1.0
   14.11   2451 tokens  fcm-scheduled-push-php-cron
   13.67   3547 tokens  winforms-single-instance-form
   12.69   2952 tokens  flutter-migration-16-apps-what-broke

k1 = 0.3 では、30回目の出現も1回目とほとんど同じ(1.29対1.00)。k1 = 3.0 ではTF-IDFにかなり近くなります。

bの効き方は2つの質問で逆向きに出ました。「正規表現 後読み」では、正解の先読み・後読みの記事は1,662トークンと日本語記事で2番目に短く(平均3,078)、b = 0では2位、既定の0.75で1位に上がります。一方「アプリが二重に立ち上がる」では、正解のWinFormsの多重起動防止の記事が3,547トークンと長めで、b = 0では1位なのに、b = 1.0では長さで割り引かれて2位に落ちます。bは「入れれば良くなるスイッチ」ではなく、短い記事と長い記事のどちらを優遇するかのつまみです。

調整すべきかを見るため、k1とbを総当たりしました(質問は後述の28問)。

# sweep.py - how k1 and b change MRR@10 (labelled + paraphrase queries)
import sys
from search import Index, load_posts
from queries import QUERIES, PARAPHRASE
from evaluate import metrics

root = sys.argv[1]
K1 = (0.3, 0.6, 0.9, 1.2, 1.5, 2.0, 3.0)
B = (0.0, 0.25, 0.5, 0.75, 1.0)
for lang in ("en", "ja", "ko"):
    posts = load_posts(f"{root}/{lang}")
    idx = Index({s: t for s, (_, t) in posts.items()})
    qs = QUERIES[lang] + PARAPHRASE[lang]
    print(f"[{lang}] MRR@10, n={len(qs)}   b=" + "  ".join(f"{b:5}" for b in B))
    for k1 in K1:
        row = [metrics(lambda q: idx.bm25(q, 10, k1, b), qs)[0] for b in B]
        print(f"  k1={k1:<4}" + " " * 17 + "  ".join(f"{m:.3f}" for m in row))
$ python3 sweep.py content
…
[ja] MRR@10, n=28   b=  0.0   0.25    0.5   0.75    1.0
  k1=0.3                  0.777  0.777  0.795  0.789  0.789
  k1=0.6                  0.812  0.812  0.830  0.830  0.830
  k1=0.9                  0.812  0.812  0.830  0.830  0.834
  k1=1.2                  0.830  0.830  0.848  0.848  0.834
  k1=1.5                  0.830  0.830  0.830  0.834  0.834
  k1=2.0                  0.830  0.812  0.812  0.834  0.834
  k1=3.0                  0.812  0.812  0.807  0.824  0.828
…

日本語では既定値(k1 = 1.2、b = 0.75)がそのまま最高の0.848でした。表全体の幅は0.777〜0.848。28問では、1問が1位から2位に落ちるだけでMRRが0.018動くので、差の大半は1〜2問ぶんです。英語の最高は0.932(既定値は0.905)、韓国語は0.869(既定値0.860)。この規模の質問セットでk1とbを調整すると、問題ではなく質問に合わせることになります。 数百問の判定データができるまでは既定値で十分です。

エラーコードと識別子にはキーワード検索が強い

トークナイザーは [a-z0-9_]+ をひとかたまりで残すので、識別子は珍しい語として残ります。

$ python3 search.py content/ja CS8509
TF-IDF
    0.129  C#のif・switch:パターンマッチング、CS8509、速度比較(.NET 10)
BM25
    7.874  C#のif・switch:パターンマッチング、CS8509、速度比較(.NET 10)

$ python3 search.py content/ja 二分探索
TF-IDF
    0.147  C言語の二分探索:2021年のコードを動かし直してわかったこと
    0.125  線形探索の実測:番兵法・SIMD・二分探索・HashSetを比較
    0.096  二分探索木(BST):2021年のCコードのバグと整列済み入力の罠
BM25
   12.869  線形探索の実測:番兵法・SIMD・二分探索・HashSetを比較
   12.566  C言語の二分探索:2021年のコードを動かし直してわかったこと
   12.057  二分探索木(BST):2021年のCコードのバグと整列済み入力の罠

CS8509 は1記事にしか出ないので結果は1件。「二分探索」ではBM25が線形探索の記事を二分探索の記事より上に置きました。線形探索の記事も二分探索と比較しているので間違いとは言えませんが、「二分探索そのもの」を探す人にはTF-IDFの順位のほうが自然です。BM25が常に勝つわけではない、という実例です。

評価:ラベル付き質問セットと、その偏り

測っていない検索エンジンはデモにすぎません。言語ごとに20問を作り、それぞれ正解記事を1本だけ決めました。さらに、タイトルの言葉を避けた「言い換え」の質問を8問足しました。

# queries.py (excerpt) - (query, the one slug that counts as the right answer)
"ja": [
    ("MySQL 1226 エラー 接続回数", "mysql-max-connections-per-hour-shared-hosting"),
    ("クイックソート スタックオーバーフロー", "algorithm-sorting"),
    ("後方参照", "regex-backreferences"),
    ("WinForms 多重起動 防止", "winforms-single-instance-form"),
    ("ヨセフス問題", "data-structure-circular-linked-list"),
    …
],
# PARAPHRASE
"ja": [
    ("1時間 DBに繋がらない", "mysql-max-connections-per-hour-shared-hosting"),
    ("https移行後 送信データが消える", "dart-http-post-redirect-not-followed"),
    ("アプリに古いデータが返る", "cdn-caches-api-responses-and-redirects"),
    ("アプリが二重に立ち上がる", "winforms-single-instance-form"),
    …

作り方と偏りを正直に書きます。

  • ラベル付きの20問は、記事タイトルを見ながら書きました。 だから正解記事と語彙が重なりやすく、キーワード方式に有利です。スコアが0.9を超えるのはそのためです。既知のエラーメッセージで検索する場面には近いのですが、一般的な検索の精度ではありません。
  • 言い換えの8問は、タイトルの語を意図的に避けて症状を書きました(CDNのキャッシュの記事に「古いデータが返る」)。用語を知らない人の検索に近い反面、8問しかないので1問で再現率が0.125動きます。
  • 正解は1問1記事。 「二分探索」のように正解が2本ありうる質問でも1本しか数えないので、どの方式も少し低めに出ます。
  • 日本語の質問は英語版の翻訳ではなく、日本語記事に向けて書いたので、言語間の数字は直接比べられません。

指標はMRR@10(正解の順位の逆数の平均。上位10件になければ0)とrecall@3(正解が上位3件に入った割合)です。評価スクリプトは次のとおりです。

# evaluate.py - MRR@10 and recall@3 for TF-IDF vs BM25, plus build/query cost
import pickle, statistics, sys, time
from search import Index, load_posts, tok_space
from queries import QUERIES, PARAPHRASE

def metrics(rank_fn, qs):
    rr, hit3, ranks = 0.0, 0, []
    for q, want in qs:
        ids = [s for s, _ in rank_fn(q)]          # top 10
        r = ids.index(want) + 1 if want in ids else None
        ranks.append(r)
        rr += 1 / r if r else 0
        hit3 += bool(r and r <= 3)
    return rr / len(qs), hit3 / len(qs), ranks

def main(root):
    for lang in ("en", "ja", "ko"):
        posts = load_posts(f"{root}/{lang}")
        docs = {s: t for s, (_, t) in posts.items()}
        builds = []
        for _ in range(5):
            t0 = time.perf_counter(); idx = Index(docs); builds.append(time.perf_counter() - t0)
        size = len(pickle.dumps((dict(idx.postings), idx.length, idx.norm)))
        npost = sum(len(p) for p in idx.postings.values())
        print(f"[{lang}] docs={idx.N} chars={sum(map(len, docs.values())):,} terms={len(idx.postings):,} "
              f"postings={npost:,} pickle={size/1024:.0f}KiB build={statistics.median(builds)*1000:.0f}ms")
        ws = Index(docs, tok_space)
        for setname, qs in (("labelled", QUERIES[lang]), ("paraphrase", PARAPHRASE[lang])):
            for name, fn in (("TF-IDF", idx.tfidf), ("BM25", idx.bm25), ("BM25/space", ws.bm25)):
                mrr, r3, ranks = metrics(fn, qs)
                print(f"  {setname:10} {name:10} MRR@10={mrr:.3f} recall@3={r3:.2f} ranks={ranks}")
        for name, fn in (("TF-IDF", idx.tfidf), ("BM25", idx.bm25)):
            lat = []
            for _ in range(20):
                for q, _ in QUERIES[lang] + PARAPHRASE[lang]:
                    t0 = time.perf_counter(); fn(q); lat.append(time.perf_counter() - t0)
            lat.sort()
            print(f"  latency {name:6} median={statistics.median(lat)*1000:.3f}ms "
                  f"p95={lat[int(len(lat) * .95)]*1000:.3f}ms (n={len(lat)})")

if __name__ == "__main__":
    main(sys.argv[1])
$ python3 evaluate.py content
…
[ja] docs=86 chars=676,624 terms=21,457 postings=114,836 pickle=717KiB build=166ms
  labelled   TF-IDF     MRR@10=0.942 recall@3=1.00 ranks=[1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1]
  labelled   BM25       MRR@10=0.975 recall@3=1.00 ranks=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1]
  labelled   BM25/space MRR@10=0.338 recall@3=0.35 ranks=[1, 1, 1, 1, None, None, None, None, None, None, 1, None, 4, None, None, 2, None, 1, None, None]
  paraphrase TF-IDF     MRR@10=0.521 recall@3=0.50 ranks=[1, 6, None, None, 1, 1, 1, None]
  paraphrase BM25       MRR@10=0.531 recall@3=0.50 ranks=[1, 4, None, None, 1, 1, 1, None]
  paraphrase BM25/space MRR@10=0.000 recall@3=0.00 ranks=[None, None, None, None, None, None, None, None]
  latency TF-IDF median=0.067ms p95=0.139ms (n=560)
  latency BM25   median=0.075ms p95=0.186ms (n=560)
…

3言語をまとめるとこうなります。

MRR@10 TF-IDF BM25 BM25+空白区切り
英語 ラベル付き20問 0.933 0.942 0.942
日本語 ラベル付き20問 0.942 0.975 0.338
韓国語 ラベル付き20問 0.925 0.967 0.933
英語 言い換え8問 0.806 0.812 0.781
日本語 言い換え8問 0.521 0.531 0.000
韓国語 言い換え8問 0.557 0.594 0.420
MRR@10の棒グラフ(1が満点)。ラベル付きの検索語:英語 TF-IDF 0.933、BM25 0.942。日本語 TF-IDF 0.942、BM25 0.975。韓国語 TF-IDF 0.925、BM25 0.967。言い換えの検索語:英語 TF-IDF 0.806、BM25 0.812。日本語 TF-IDF 0.521、BM25 0.531。韓国語 TF-IDF 0.557、BM25 0.594。どこでもBM25がわずかに上で、言い換えでは両方とも大きく下がり、特に日本語と韓国語で落ち幅が大きい。
自分のブログ記事でのMRR@10。TF-IDFとBM25(k1=1.2、b=0.75)の比較。ラベル付きセットは言語ごとに20問、言い換えセットはタイトルの語を避けた8問。Python 3.9、各言語86記事で実測。

読み取れること:

  • BM25はどの行でもTF-IDFをわずかに上回る。 ラベル付きセットのrecall@3は両方とも1.00で、差は2位・3位だった数問が1位になった分です。
  • 本当の弱点は言い換え。 日本語は0.975から0.531に落ち、8問中3問は上位10件にも入りませんでした。「アプリに古いデータが返る」は「キャッシュ」と言っていないので、CDNの記事とほとんど語が重なりません。これが埋め込みの出番です。
  • 日本語の空白区切りは使い物にならない(ラベル付き0.338、言い換え0.000)。

速度と索引サイズ

英語 日本語 韓国語
記事数/文字数 86 / 967,286 86 / 676,624 86 / 699,588
語の種類 8,412 21,457 13,818
転置リストの要素数 47,875 114,836 78,244
pickle にした索引 301 KiB 717 KiB 480 KiB
構築時間(5回の中央値) 76 ms 166 ms 118 ms
BM25 検索 中央値/p95 0.086 / 0.195 ms 0.075 / 0.186 ms 0.057 / 0.132 ms

構築時間はトークン化を含み、ファイル読み込みは含みません。検索時間は28問×20回=560回の計測です。日本語の索引が英語の2.4倍なのは、バイグラムで語の種類が増えるためです。それでも1MB未満なので、静的サイトならJSONに書き出してブラウザで検索することもできます。

BM25・埋め込み・ハイブリッドの使い分け

状況 BM25(キーワード) 埋め込み ハイブリッド
エラーコード、API名、ファイル名、バージョン 最適:珍しい語が高得点 「似た別のエラー」に寄ることがある 良い
利用者が症状を自分の言葉で書く 弱い(言い換えMRR 0.53〜0.81) 最適 最適
GPUもモデルも追加サービスもない 標準ライブラリで動く モデルとベクトルDBが必要 両方必要
形態素解析なしの日本語 バイグラムで動く モデルの日本語対応しだい 動く
なぜ1位なのか説明したい 簡単:一致した語を見せる 難しい 一部可能
100記事程度で、探す記事が決まっている 十分 たいてい不要 過剰
数千件の雑多な文書でRAG 基準線として 強い 定番

目安:まずBM25を作り、ラベル付きの質問で測る。外れ方が今回の言い換えのような「言葉の不一致」なら、そこで初めて埋め込みを足します。

正直な限界

  • 質問セットが小さく、記事の書き手自身が作った。 28問は空白区切りとバイグラムのような大きな差を示すには足りますが、TF-IDFとBM25、k1・bの設定のような小さな差の順位づけには足りません。ラベル付きの半分はタイトルの語に寄っています。
  • 正解1問1記事なので、正解が2本ある質問では全方式が少し低く出ます。
  • バイグラムは雑。 語の境目をまたいで一致し、「東京都」の中の「京都」を区別できません。形態素解析で直せますが、依存が増えます。
  • 語形変化・同義語・表記ゆれに弱い。 「キャッシュ」と「古いデータ」はつながりません。全角・半角の違いはNFKC正規化で吸収していますが、「サーバー」と「サーバ」は別の語のままです(バイグラムの重なりで一部は拾えます)。
  • 記事まるごとが単位。 RAGでは数百トークンの段落ごとに索引を作るので、文書長の統計も最適なbも変わります。
  • 計測は1台のマシン、メモリ上の索引、CPython 3.9 での値です。数百万文書なら本物の検索エンジンを使います。

次へ:ここからRAGへ

連載はここで、RAGが始まる地点に着きました。このような検索器が上位の段落を返し、言語モデルがそれを読んで答えます。ハイブリッドなら埋め込み検索を足して2つの順位を混ぜます。何を足すにしても evaluate.py は残してください。新しい部品が自分の文書で本当に効いているかを教えてくれるのは、ラベル付きの質問だけです。

連載のほかの回:線形回帰、k-means、ナイーブベイズ、パーセプトロンからニューラルネットへ。日本語テキストを扱う別の例としては、OG画像の日本語改行と禁則処理もどうぞ。

よくある質問

形態素解析ライブラリなしで日本語を検索できる?

できます。かな・漢字の連続を2文字ずつずらして切る「文字バイグラム」で索引を作ります。空白区切りでは正解記事が上位3件に入ったのは20問中7問(MRR 0.338)でしたが、バイグラムでは20問すべて(MRR 0.975)でした。

BM25はTF-IDFよりどれくらい良い?

今回のブログ記事では、わずかに良い程度でした。日本語の20問でMRR@10はTF-IDF 0.942、BM25 0.975。2位・3位だった数問が1位に上がった差で、別次元の差ではありません。英語と韓国語でも同じ傾向でした。

埋め込み(ベクトル検索)を使うならキーワード検索はいらない?

たいてい必要です。CS8509やWM_COPYDATAのようなエラーコードや識別子にはキーワード検索のほうが正確で、ハイブリッド検索のキーワード側にもなります。弱いのは言い換えで、記事の言葉を避けた質問ではMRRが0.531まで落ちました。

  • #検索エンジン
  • #BM25
  • #TF-IDF
  • #全文検索
  • #RAG
  • #Python
  • #機械学習

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑