検索順位が合っていても、RAGで渡す文書の足切りは壊れる。内積をコサイン類似度として使うコードで、クエリの正規化を外すと、閾値0.8を通る候補が1件から3件に増えた。
自分は今回、3本のベクトルで正規化する側を変えながら出力を見ていた。文書側を直すと1位がAに戻る。でも、まだ3件通っているんだよね。先頭の文書だけ確認するテストでは、この差を拾えない。
内積検索を3通りで動かす
検索した文書はスコア0.8以上だけLLMへ渡す。0.8は実験用で、実サービスへの推奨値ではない。
クエリは(2, 0)。文書Aは(1, 0)で同じ向き、BとCは向きも長さも違う。計算を切り分ける人工データで、意味の近さは測っていない。
repro.pyに保存し、python3 repro.pyで実行する。Python 3.14.6で確認済み。標準ライブラリだけで内積を全件計算する。Faiss自体の実行は含まない。
from math import hypot
q = (2.0, 0.0)
docs = {"A": (1.0, 0.0), "B": (3.0, 4.0), "C": (2.0, 3.0)}
def unit(v):
n = hypot(*v)
if n == 0:
raise ValueError("zero vector")
return tuple(x / n for x in v)
def search(query, rows):
scores = [(name, sum(x * y for x, y in zip(query, v, strict=True)))
for name, v in rows.items()]
return sorted(scores, key=lambda row: row[1], reverse=True)
normalized = {name: unit(v) for name, v in docs.items()}
for label, query, rows in [
("raw", q, docs),
("docs_only", q, normalized),
("both", unit(q), normalized),
]:
scores = search(query, rows)
accepted = [name for name, score in scores if score >= 0.8]
print(label, [(name, round(score, 3)) for name, score in scores],
"accepted=", accepted)
assert len(accepted) == (1 if label == "both" else 3)
出力はこうなった。
raw [('B', 6.0), ('C', 4.0), ('A', 2.0)] accepted= ['B', 'C', 'A']
docs_only [('A', 2.0), ('B', 1.2), ('C', 1.109)] accepted= ['A', 'B', 'C']
both [('A', 1.0), ('B', 0.6), ('C', 0.555)] accepted= ['A']
丸めるのは表示だけ。閾値との比較には丸める前の値を使っている。
文書だけ直すと、順位のテストは通る
内積は「クエリの長さ×文書の長さ×コサイン類似度」。未正規化のBは長さが5あるので、向きが完全に一致するAを追い越す。
文書を長さ1にそろえると、残る倍率はクエリの長さだけになる。今回は2倍。この倍率は全候補に共通なので、順位はA、B、Cに戻る。それでもBの0.6は1.2になり、閾値0.8を通ってしまう。
クエリ側も長さ1にそろえると、スコアはAが1.0、Bが0.6、Cが約0.555になる。
Faiss公式の距離仕様でも、コサイン類似度を内積検索で扱うには、登録前の文書と検索前のクエリを両方正規化するとある。今回はその片方を抜いた計算を再現した。
RAGに組み込むときの確認箇所
文書の登録処理とクエリの検索処理に正規化を置く。登録コードを直しても、保存済みの未正規化ベクトルは残る。過去分の長さも確認したい。
ただし、モデルが最初から長さ1のベクトルを返す場合や、検索基盤がコサインを直接計算する場合もある。まず手元の出力と設定を確認する。内積そのものを目的に選んだ検索へ、無条件でこの変更を入れる話でもない。
ゼロベクトルは長さで割れず、ValueError: zero vectorで止める。実際に出たら埋め込み生成側を調べる。今回は有限値の小さなベクトルが対象だ。
おわりに
検索のテストには、上位IDと一緒に閾値通過件数を残しておく。今回ならAが1位という確認に加え、通過が1件というassertが効く。クエリの長さが変わる入力も混ぜれば、件数の変動を追いやすい。LLMへ渡す文書が急に増えたときは、この順でスコアの倍率を確かめたい。