kaedeasset記事 · Notes霧走町 流氷に眠る

RRFを実装して測り、負けたので消した

We implemented RRF, measured it, lost, and removed it

 D05-3-T09-rrf-implemented-and-lost・技術・日本語入力でプレイする仕組み

入力を解いて返すReciprocal Rank Fusionclaude自然言語処理

ハイブリッド検索の融合には、業界の既定があります。
うちはそれを実装して測り、流す前に決めた基準を満たさなかったので採らず、同じ日にコードも消しました。
負けた理由は、うちの照合が「順位」ではなく「答えてよいか」で終わる作りだったからです。

この回の位置と、前後の回#

区切り: T03〜T24 日本語入力を解いて返すまで(22 回のうち 7 回目)
先に読むとよい回: T08(二つの点を混ぜる)
次に読む回: T10(固有名詞の辞書)

名前 — Reciprocal Rank Fusion#

正式名
Reciprocal Rank Fusion
略称
RRF
日本語
逆順位融合(日本語の記事でも RRF のまま書かれることが多い)
初出
Gordon V. Cormack, Charles L. A. Clarke, Stefan Büttcher, "Reciprocal rank fusion outperforms Condorcet and individual rank learning methods", SIGIR 2009(2 ページの短い論文)

RRF とは#

Reciprocal Rank Fusion(RRF)とは、複数の検索系が返した順位表を、順位だけで 1 本にまとめる方法です。
文書 d の点は、各順位表 i での順位 r_i(d) を使ってこう書きます。

score(d) = Σ_i 1 / (k + r_i(d))

k は定数で、論文では 60。
予備の実験で決め、その後の検証では動かさなかった値です。
いまでも多くの実装が既定を 60 にしています。

性質を三つ挙げます。

一つ目、点の尺度を見ません。
BM25 の点は上限が無く、埋め込みのコサインは 0〜1 の狭い所に固まります。
この二つを正規化して足すと、外れ値一つで順位が動きます。
RRF は順位しか使わないので、この問題が最初から起きません。
学習も要りません。

二つ目、上位を重く見ますが、k が大きいので重みはなだらかです。
k=60 なら 1 位は 1/61、10 位は 1/70。
1 位と 10 位の差は 15% ほどしかありません。
片方の系でだけ 1 位、もう片方では圏外、という文書より、両方で 5 位前後の文書が勝ちやすい。
「両方がそこそこ推す」ものを上げる設計です。

三つ目、点の大きさに意味がありません。
最大でも、系が n 本なら n/(k+1) です。
2 本なら 2/61、約 0.033。
この値は「どれくらい確からしいか」を何も言いません。

いまは Elasticsearch・OpenSearch・Weaviate・Qdrant などがハイブリッド検索の融合として RRF を持っています。
疎(BM25 など)と密(埋め込み)を混ぜるときの、まず試す方法です。

よく言われる弱点も、この三つ目から来ます。
点の大小が消えるので、1 位が圧倒的に近いのか、僅差なのかが分かりません。
閾値で「ここより下は返さない」と切る用途には、そのままでは使えません。
もう一つは同点です。
片方の系で点が同じ文書が並ぶと、順位をどう割り振るかで結果が変わります。

うちの融合(いまのソース)#

照合は 2 系統です(各系統の中身は T04・T05、混ぜ方は T08 の回)。
文字 n-gram の tf–idf のコサイン(CosA)と、埋め込みのコサイン(Raw)。
言い換え 1 本ごとに、こう混ぜています。AdvMatcher.hpp の query から写します。

const double Scaled = std::clamp(
(Raw - Th.embedFloor) / std::max(1e-6, 1.0 - Th.embedFloor), 0.0, 1.0);
C = std::max(CosA, CosA * WA + Scaled * WB); // WB = embedWeight 0.7, WA = 0.3

意図の点は、その意図の言い換えの中の最大です。
そこに固有名詞の加点(0.15 × 最大 2)を足し、雑談と既読の段に減点をかけます。
そして、いまの値で判定します。

採用 … 0.33 以上(acceptB)
聞き返し … 0.33 に届かないが 0.27 以上(confirmFloor)、など
誘導・受け流し … それより下

named-methods の対応表では、この行を「hybrid sparse-dense retrieval・△(アレンジあり)」に置いています。
アレンジには理由を書く、理由が無ければ名前どおりに戻す、と決めています。
だから 2026-08-18 に、名前どおりの RRF を入れて比べました。

先に名前を分けたもの#

RRF を入れる前に、照合が一人で二つの仕事をしていることに気づきました。
どの意図が 1 位か(順位)と、答えてよいか(採用・聞き返し・受け流し)です。
RRF が作るのは順位だけです。
そこで同じ日に、まず二つの量に名前を付けました。

// 順位付け(rank) と 棄権の判断(confidence) は別物
// BestRank … 候補の並べ替えに使う(penalty 込み)
// Confidence … 採用 / F1 / F4 の判断に使う(penalty を戻したもの)
const double Confidence = BestRank + BestPenalty;

数値は一つも変えていません。
今日のログで見ると、この二つは実際にずれます。
港で佐伯に「あの晩はどこに」と入力すると、アリバイの意図が 0.718 で採用されます。
続けて「アリバイ」と入力すると、同じ段はもう聞いたので、加点 0.15 を打ち消す減点が入ります。
順位に使う点は 0.757、判定に使う点は 0.907 です。
競う相手がいなければ、二度目でも黙らずに言い直しを返すためです。

実装した RRF(2026-08-18・d8e58921。いまのソースには無い)#

閾値の構造体に fusion("blend" か "rrf")と rrfK(60)を足し、adv-sim golden --fusion rrf で切り替えられるようにしました。
既定は blend のままです。
query に足したのはこの部分です。git show d8e58921 から、省略せずに写します。

if (Th.fusion == "rrf" && WB > 0.0 && CosHere.size() >= 2)
{
std::vector<const Intent*> ByA, ByB;
ByA.reserve(CosHere.size()); ByB.reserve(CosHere.size());
for (const auto& [It, C] : CosHere) { ByA.push_back(It); ByB.push_back(It); }
auto ScoreOf = [](const std::map<const Intent*, double>& M, const Intent* It)
{
const auto F = M.find(It);
return (F != M.end()) ? F->second : 0.0;
};
std::sort(ByA.begin(), ByA.end(), [&](const Intent* X, const Intent* Y)
{ return ScoreOf(CosAOnly, X) > ScoreOf(CosAOnly, Y); });
std::sort(ByB.begin(), ByB.end(), [&](const Intent* X, const Intent* Y)
{ return ScoreOf(CosBOnly, X) > ScoreOf(CosBOnly, Y); });
std::map<const Intent*, double> Rrf;
const double K = static_cast<double>(Th.rrfK);
for (std::size_t I = 0; I < ByA.size(); ++I) { Rrf[ByA[I]] += 1.0 / (K + (I + 1)); }
for (std::size_t I = 0; I < ByB.size(); ++I) { Rrf[ByB[I]] += 1.0 / (K + (I + 1)); }
const double MaxRrf = 2.0 / (K + 1.0);
for (auto& [It, C] : CosHere) { C = std::clamp(Rrf[It] / MaxRrf, 0.0, 1.0); }
}

決めたことを、境界ごとに書きます。

  • 順位表は 2 本。表層のコサインの順と、埋め込みの生のコサイン(embedFloor で切る前)の順です。密の側は CosBOnly という表を新しく作り、言い換えの中の最大を控えました。
  • 並べる対象は、いまの場所・相手・段階で有効な候補(CosHere)だけ。全意図で並べると、関係ない意図が順位を食います。
  • 候補が 1 つ以下なら RRF をかけません(CosHere.size() >= 2)。埋め込みが使えない構成(WB = 0)でもかけません。
  • 同点の扱いは決めていません。std::sort は安定ではなく、同点の並びは入力の並び(意図へのポインタの順=読み込み順)に従います。毎回同じ結果にはなりますが、理由のある順ではありません。表層が 0 の候補はたくさんあるので、下の方は同点だらけです。
  • 固有名詞の加点は、RRF の後に今までどおり足しました。加点を 3 本目の順位表にする案もありましたが、0・1・2 の粗い量は順位に馴染まないので見送りました。
  • 2/(k+1) で割って [0,1] に伸ばしたのは、加点・減点・閾値の意味を保つためです。疎と密の間の正規化ではなく、融合した後の 1 本の尺度をそろえるだけ、という整理でした。

基準は、流す前に書いた#

設計の文書に、流す前に固定した基準です。

  • 凍結した dev の正解率が落ちないこと
  • 雑談の誤受理(NEG_FP)が 0 のままであること
  • 本命: 1 位と 2 位の差が 0.10 未満で採用した入力(THIN)のうち、誤りが減ること

僅差の採用が不安定になるのは、重み付き加算の弱点としてよく挙げられる挙動です。
RRF が効くなら、ここが減るはずでした。
holdout はこの判定に使わない、と決めていました。

結果(2026-08-18) — Reciprocal Rank Fusion#

本命の THIN の誤りは、6 つの検査のうち 5 つで増えました。
凍結した dev は落ちました。
人物像を変えた入力などで正解率が上がった検査もありましたが、中身は、今まで聞き返していた入力を強引に採用した分でした。
聞き返し経由も含めた到達率で見ると、勝った検査は一つもありませんでした。
そして、どの検査でも聞き返しが一度も出なくなりました。

なぜ負けたか(ここからは計算)#

RRF を [0,1] に伸ばした値の下限を、計算で出します。
候補が N 個で、ある候補が両方の表で最下位なら、点は 2/(k+N)。
伸ばすと (k+1)/(k+N) です。
港で佐伯と話しているとき、データから数えると候補は 39 あります。
最下位でも 61/99、約 0.62。
採用線 0.33 より下に、誰も来ません。
「採用線に少し届かないから聞き返す」帯が、尺度の上で消えます。

上の方も見ます。
両方の表で 1 位なら 1.0、両方で 2 位なら 61/62≒0.984。
1 位と 2 位の差は最大でも 0.016 で、THIN の幅 0.10 にいつも収まります。
両方で 10 位でも 61/70≒0.871 です。
ほぼ全員が 0.9 前後に並ぶ尺度で、0.10 の差を見る規則は意味を失います。
08-18 に THIN の件数が大きく増えたのは、劣化というより尺度の違いでした。
当時、採用線を振ってみても結果が変わらなかったのも、同じ理由です。

うちの規則は、ほとんどが絶対値の幅でできています。
聞き返しの幅 0.08(marginF1)、推理の僅差の幅 0.08(deductionMargin)、THIN の 0.10、固有名詞の加点 0.15、既読の打ち消し 0.3。
どれも blend の尺度で決めた値です。
尺度を替えれば全部振り直しになります。

正しい読みは「RRF が悪い」ではなく「校正していない状態では比べられない」です。

名前どおりでない所 — Reciprocal Rank Fusion#

実装は名前どおりの RRF です(k=60・順位の逆数の和・学習なし)。
名前から外したのは 2 点です。
一つは、出た値を 2/(k+1) で割って [0,1] に伸ばしたこと。
RRF の値はふつう順位を決めるためだけに使い、閾値とは比べません。
もう一つは、その値を閾値(採用・聞き返し)に通したことです。
負けた原因は、この二つ目にあります。
RRF は順位を返す方法で、「答えるか」を返す方法ではありません。

消した理由#

閾値を新しい尺度に合わせ直す方法にも名前があります。
conformal prediction で、有限の標本から、取りこぼし率の保証つきで閾値を決められます。
ただ、その保証は「実際のプレイヤーの入力の分布から取った、正解つきの組」が前提です。
うちにはそれがありません。
同じ日に「校正はやらない」と決めました(named-methods §5 の C-2)。

すると、RRF を測り直す前提が来なくなります。
既定で誰も通らない切り替えは、置いておくと腐ります。
5416f478 で、RRF の経路 29 行と、閾値の fusion・rrfK、adv-sim の --fusion を消しました。
消した後に dev と probe を流し、RRF を入れる前と同じ値に戻ったことを確かめています。
測った結果と判断は、設計の文書とコミットの本文に残っています。
今日、照合のソースと閾値のファイルを検索して、rrf も fusion も 0 件でした。

今日の版で、重み付き加算の側を測った#

adv-sim golden の出力です(10/29)。

  • 凍結した dev 176 件 … 全部正解。僅差の採用 12 件、誤り 0
  • 進行に要る入力 244 件 … 採用で 88.9%、聞き返し経由も入れて 92.2%(聞き返しで 8 件)。僅差 29 件のうち誤り 7
  • 人物像を変えた入力 120 件 … 83.3%、聞き返し経由で 87.5%(5 件)。僅差 12 件のうち誤り 4
  • 雑談 67 件 … 71.6%、聞き返し経由で 73.1%(1 件)。僅差 19 件のうち誤り 7

聞き返しで届いた入力は、6 つの検査で合わせて 14 件。
08-18 の RRF では、この種類の到達が全部消えました。

一例です。
港で佐伯に「晩飯のあとはどうしてたんだ」と入力すると、今日の値は 0.305。
0.27 と 0.33 の間なので、佐伯が聞き返します。

佐伯「……事件の夜の居場所か? それとも別の話か」

「はい」と返せば、居場所の証言に進みます。
この一往復が、RRF の尺度では作れませんでした。

弱点と、採らなかった案 — Reciprocal Rank Fusion#

僅差の採用の誤りは、今も残っています。
進行に要る入力で 7 件、雑談で 7 件です。
RRF はその解ではありませんでしたが、問題が消えたわけではありません。

採らなかった案を三つ書きます。

  • RRF で順位だけ決め、判定は blend の点で行う。

いちばん筋のよい組み合わせに見えます。
ただ、順位の 1 位と、判定に使う点の 1 位が違う候補になることがあります。
「この候補を返すが、答えてよいかは別の候補の点で決める」は説明がつきません。
試す前に止めました。

  • k を小さくする。

k=1 なら上位の差は大きく開きます。
ただ、それは RRF の利点(なだらかさ)を捨てて、点の尺度を作り直すのと同じです。
閾値の振り直しが要る点は変わりません。

  • 校正してから測り直す。

上に書いたとおり、校正の前提が無いのでやりません。
もし実際の入力を正解つきで集められるようになったら、そのときに RRF も再実装から測り直します。

もう一つ、ソースのコメントに残してある非対称があります。
誘導へ飛ばすかどうかだけ、判定の点ではなく順位の点(減点込み)で「現地は弱いか」を見ています。
直すなら、閾値の見直しと同時に両方をそろえて測り直す、と書いてあります。

業界の既定は、その業界の前提の上で既定です。
順位しか要らない検索なら、RRF が正しいのだと思います。
うちは「答えない」と「聞き返す」を持つ照合なので、絶対値の尺度を手放せませんでした。

検索語#

Reciprocal Rank Fusion
reciprocal rank fusion k=60
hybrid search score fusion
Cormack Clarke Büttcher SIGIR 2009

惜しい言い方で入力すると、佐伯が聞き返してきます。その帯が残っているかどうか、『霧走町 流氷に眠る』の第一章をブラウザで入力して確かめてみてください。プロフィールのリンクから遊べます。

#claude #自然言語処理