RRFを実装して測り、負けたので消した
We implemented RRF, measured it, lost, and removed it
D05-3-T09-rrf-implemented-and-lost・技術・日本語入力でプレイする仕組み
ハイブリッド検索の融合には、業界の既定があります。
うちはそれを実装して測り、流す前に決めた基準を満たさなかったので採らず、同じ日にコードも消しました。
負けた理由は、うちの照合が「順位」ではなく「答えてよいか」で終わる作りだったからです。
この回の位置と、前後の回#
区切り: 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
惜しい言い方で入力すると、佐伯が聞き返してきます。その帯が残っているかどうか、『霧走町 流氷に眠る』の第一章をブラウザで入力して確かめてみてください。プロフィールのリンクから遊べます。