導入
ベクトル検索(埋め込み)は「意味」に強く、言い換えや同義語にも対応できます。しかし固有名詞・型番・エラーコードのような完全一致が重要な単語には、意外と弱いことがあります。逆にキーワード検索は完全一致に強く、意味の言い換えには弱いという逆の弱点を持っています。両方を組み合わせれば、それぞれの穴を補い合えます。
説明
キーワード検索の中でも定番なのが BM25 というスコア計算方法です。ざっくり言うと、TF-IDF(第3章で使ったもの)を検索用にさらに洗練したもので、「その単語が文書に何回出てくるか(頻度)」「その単語がどれだけ珍しいか(希少さ)」「文書の長さ」を考慮してスコアを付けます。長い文書だからといって単語がたくさん出てくるのを有利にしすぎない、という補正が入っているのが特徴です。
import numpy as np
from collections import Counter
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
docs = [
"The cat sat on the mat in the sunny room.",
"A dog ran fast across the green park.",
"Cats and dogs are common household pets.",
"The quick brown fox jumps over the lazy dog.",
]
query = "cat pet"
# --- ベクトル検索(TF-IDF + コサイン類似度)---
vec = TfidfVectorizer().fit(docs)
dense = cosine_similarity(vec.transform([query]), vec.transform(docs))[0]
# --- キーワード検索(BM25を自前実装)---
tokenized = [d.lower().replace(".", "").split() for d in docs]
N = len(tokenized)
avgdl = sum(len(d) for d in tokenized) / N
df = Counter()
for d in tokenized:
for w in set(d):
df[w] += 1
def bm25(query, k1=1.5, b=0.75):
q = query.lower().split()
out = []
for d in tokenized:
dl = len(d); tf = Counter(d); s = 0.0
for w in q:
if w in tf:
idf = np.log(1 + (N - df[w] + 0.5) / (df[w] + 0.5))
s += idf * tf[w] * (k1 + 1) / (tf[w] + k1 * (1 - b + b * dl / avgdl))
out.append(s)
return np.array(out)
sparse = bm25(query)
# --- ハイブリッド: 各スコアを0〜1に正規化して重み付き合算 ---
def norm(x):
return (x - x.min()) / (x.max() - x.min() + 1e-9)
hybrid = 0.5 * norm(dense) + 0.5 * norm(sparse)
for i in np.argsort(hybrid)[::-1]:
print(f"hybrid={hybrid[i]:.2f} vec={norm(dense)[i]:.2f} bm25={norm(sparse)[i]:.2f} | {docs[i]}")
最初の実行は少し待ちます:scikit-learn を使う回は、初回だけライブラリの読み込みに数十秒かかることがあります(2回目以降は速くなります)。「読み込み中…」と出たら、そのまま待ってください。
ベクトルのコサイン類似度とBM25のスコアは、そもそも値の範囲が違います(片方は0〜1、もう片方は理論上いくらでも大きくなる)。そのままでは足し算しても意味がないので、norm 関数で両方を0〜1の範囲にそろえてから、重み付き(ここでは0.5対0.5)で合算しています。重みは検索対象の性質に合わせて調整するものです。
実務ではスコアそのものではなく順位を合成する RRF(Reciprocal Rank Fusion) という手法もよく使われます。「1位に1点、2位に0.5点…」のように順位だけを見て統合するので、スコアのスケールが違う検索方式を組み合わせるときに扱いやすいという特徴があります。
やってみよう
hybrid の重みを 0.8 * norm(dense) + 0.2 * norm(sparse) のようにベクトル寄りに変えてみましょう。また query を "quick dog" に変えると、上位に来る文書がどう変わるか確かめてみてください。
演習
上のコードに続けて、ベクトル検索だけ(norm(dense))で1位になる文書と、ハイブリッド(hybrid)で1位になる文書をそれぞれ print してください。同じ文書か、違う文書か比べてみましょう。
ヒント1を見る
np.argsort(norm(dense))[::-1][0] で「ベクトルだけ」の1位のインデックスが分かります。docs[インデックス] で中身を表示しましょう。
ヒント2を見る
hybrid についても同じように np.argsort(hybrid)[::-1][0] で1位のインデックスを求められます。
まとめ
- ベクトル検索は意味の言い換えに強く完全一致に弱い、キーワード検索(BM25)はその逆。組み合わせると穴を補い合える。
- BM25はTF-IDFを検索用に洗練したスコアで、単語の頻度・希少さ・文書長を考慮する。
- スコアを合成するときは正規化してから重み付き合算するのが基本。順位を合成するRRFという方法もある。