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

文字の点と意味の点を、0.3 と 0.7 で混ぜて max をとる

Blending a character score and a meaning score at 0.3 and 0.7, then taking the max

 D04-4-T08-score-fusion・技術・日本語入力でプレイする仕組み

入力を解いて返すhybrid search (sparse-dense retrieval)score fusionconvex combination (linear combination)CombSUMclaude自然言語処理

文字の物差しは「きのうのよるはどこにいた」を 0.282 と見ます。意味の物差しは、それより低く見ます。
混ぜた点だけを使うと、文字の側が正しく当てていた入力まで落ちます。
だから、混ぜた点と文字の点の、良い方をとっています。

『霧走町 流氷に眠る』は、入力された日本語を書き下ろした証言のどれかに当てるゲームです。遊んでいる間に文は作りません。
物差しは二つ。文字 n-gram の tf–idf(T04)と、文の埋め込み(T05)。
この回は、二つの点をどう一つにするかです。

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

区切り: T03〜T24 日本語入力を解いて返すまで(22 回のうち 6 回目)
先に読むとよい回: T04(文字 n-gram と tf–idf)・T05(文の埋め込み)
次に読む回: T09(RRF を実装して測り負けた話)

正式名
hybrid search(sparse-dense retrieval)/score fusion の convex combination(linear combination)
日本語の呼び名
ハイブリッド検索、点の融合、凸結合(線形結合)

初出:
hybrid search は一般の呼び名で、初出は確かめていません。
複数の検索の点を足し合わせて融合する方法の早い例は、Fox & Shaw, "Combination of Multiple Searches"(TREC-2 の論文集、NIST Special Publication 500-215)の CombSUM です。
疎と密の融合関数を比べた論文に Bruch, Gai & Ingber, "An Analysis of Fusion Functions for Hybrid Retrieval"(arXiv:2210.11934, 2022)があります。

ハイブリッド検索(hybrid search)とは、語の一致で引く疎な検索(BM25 や tf–idf)と、埋め込みで引く密な検索を組み合わせる方法です。
疎は固有名詞・数字・まれな語に強く、言い換えに弱い。密はその逆。弱点が重ならないので、混ぜると両方より良くなりやすい、というのが動機です。

混ぜ方は大きく二つです。
一つは点で混ぜる方法。二つの点を同じ尺度にそろえ(正規化)、重み α で足します。

s = α · s_dense + (1 − α) · s_sparse

α と 1 − α の和が 1 なので convex combination(凸結合)と呼ばれます。
もう一つは順位で混ぜる方法で、代表が Reciprocal Rank Fusion(RRF)です(明日の T09)。

点で混ぜるときの肝は正規化です。
BM25 の点には上限が無く、コサインは 0〜1 の狭い所に集まります。そのまま足すと、尺度の大きい方がすべてを決めます。
よく使われるのは、問いごとに候補の最小と最大で 0〜1 に伸ばす min-max、平均と標準偏差でそろえる z-score です。
Bruch らは、凸結合が RRF より良く、新しい分野でも少ない例で 1 つの係数を合わせられる、RRF は自分の係数に敏感だ、と報告しています。

よく知られた弱点です。
α は分野と検査セットに依存します。合わせたセットに過適合します。
外れ値の 1 件が min-max の幅を決め、順位を歪めます。
点の分布の形が違う二つを足すと、どちらの側の「自信」が効いているのか読みにくくなります。

場所は Plugins/KaedeAdv/Source/KaedeAdv/Private/core/AdvMatcher.hpp の query です。
混ぜるのは、意図ではなく言い換え 1 本ごとです。ソースから写しました。

const double WB = QVecs.empty() ? 0.0 : Th.embedWeight; // キャッシュに無ければ従来動作
const double WA = 1.0 - WB;

for (const ParaDoc& D : Paras)
{
const double CosA = Dot(Q, D.Vec);
double C = CosA;
if (!QVecs.empty() && D.Embed)
{
double Raw = 0.0;
for (const std::vector<float>* QV : QVecs)
{
Raw = std::max(Raw, VectorCache::cosine(*QV, *D.Embed));
}
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);
}
double& Any = CosAny[D.IntentPtr];
Any = std::max(Any, C);
double& OnlyA = CosAOnly[D.IntentPtr];
OnlyA = std::max(OnlyA, CosA);
}

手順にするとこうです。

  1. 言い換え 1 本について、文字の点 CosA(0〜1)を出す。
  2. 意味の点は、入力の全体と節のベクトルのうち、その言い換えとのコサインが最大のもの(Raw)。
  3. Raw を、0.82 を 0・1.0 を 1 として伸ばし、0〜1 に切る(Scaled)。
  4. 0.3 × CosA + 0.7 × Scaled と、CosA の、大きい方をその言い換えの点にする。
  5. 意図の点は、その意図の言い換えの点の最大。
  6. このあとに、固有名詞の加点(1 つ 0.15、最大 2 つ・T10)と、順位づけ用の減点が乗る。
  7. 採用線は、埋め込みを混ぜたときは acceptB(0.33)、混ぜられなかったときは accept(0.27)。

最後の点は、ログに surface(意図の CosA の最大)と blend(意図の合成の最大)として出ます。
CosHere(いま居る場所と相手で有効な意図)と CosAny(どこでも)を同じ 1 回の走査で作るのは、他の場所でなら通る話かを見るためです(他所への誘導。ここでは扱いません)。

今日(10月29日)、港で佐伯に入力した文の、表層 → 合成です。2026-10-29 に測った値です。

▶ 晩飯のあとはどうしてたんだ 0.156 → 0.305(意味の側が効いた)
▶ あの晩の足取りを聞かせてくれ 0.278 → 0.597(意味の側が効いた)
▶ 昨日の夜、どこにいたんですか 0.625 → 0.852(両方)
▶ 網の修理はいつまでかかる 0.574 → 0.659(両方。雑談)
▶ 事件の夜の行動を教えてください 0.526 → 0.570(ほぼ文字の側)
▶ きのうのよるはどこにいた 0.282 → 0.282(max で文字の側が残った)

最後の行が、max の働きです。
ひらがなだけの文で、埋め込みのコサインが 0.82 から十分に離れず、0.3 × 0.282 + 0.7 × Scaled が 0.282 を下回りました。
max が無ければ点は下がり、固有名詞の加点 0.15 を足しても採用線 0.33 に届かなかったかもしれません。
実際は 0.282 + 0.15 = 0.432 で採用されています。

max を入れたのは、混ぜた点だけにしたとき、前置きの付いた長い文で埋め込みが話題を取り違え、文字の側が正しく当てていた凍結した検査が落ちたからです。
どちらかが解ければ通る、という形にしました。

重みを振った#

adv-sim golden は、--embed で重みを上書きして検査セットを流せます(Game/adv/thresholds.json は書き換えません)。今日の値です。数字は ACC(正しい意図に 1 回で着地した率)。

重み 0/0.3/0.5/0.6/0.7(いま)/0.8/0.9/1.0
凍結 176 件 97.7/100/100/100/100/100/99.4/99.4
進行に要る 244 件 73.0/76.6/82.4/84.0/88.9/90.6/92.2/92.2
自然な文 67 件 86.6/92.5/98.5/100/100/100/100/100
人物像 120 件 69.2/75.0/79.2/80.0/83.3/85.8/86.7/87.5
短い文 74 件 91.9/90.5/95.9/97.3/95.9/95.9/94.6/93.2
雑談 67 件 46.3/50.7/55.2/62.7/71.6/73.1/68.7/65.7

進行を誤って動かした数(NEG_FP)は、どの重みでも 0 でした。

ACC だけでは見えないものも数えました。
僅差の採用(1 位と 2 位の差が 0.10 未満で採用した数。括弧は、そのうち外れ)です。

進行に要る 244 件 10(3)/17(7)/21(7)/23(7)/29(7)/34(8)/37(9)/41(12)
雑談 67 件 4(0)/3(1)/7(3)/14(6)/19(7)/24(11)/26(15)/26(17)

重みを上げるほど、当たる率は上がり、僅差で決まる採用も増えます。
意味の点は 0.82 から 1.0 の狭い幅を伸ばしたものなので、候補どうしの差が詰まります。僅差の採用は、外れたときにプレイヤーから見えない事故(違う話がもっともらしく返る)になります。
凍結 176 件のうち「進めてはいけない 34 件」が雑談として受理された数も、11 → 18(0.7)→ 32(1.0)と増えます。重みを上げるほど、町の人は何にでも世間話で答えるようになります。

0.9 で凍結 176 件が 1 件落ちます。
落ちた 1 件は、第一章のある物を女将に見せる一文で、1 位 0.602・2 位 0.601、差 0.001 の順位の入れ替わりでした。
採用線 acceptB を 0.30〜0.40 に振っても、この 1 件は直りません(順位の問題なので)。採用線を上げると、進行に要る入力の ACC が 92.6 → 86.5 と下がり、無関係な入力への聞き返しが 2 → 15 に増えます。
重みは順位を、採用線は採るかどうかを動かします。別の量です。

なぜ 0.7 のままか#

凍結 176 件の 100% は「絶対」の条件です。0.9 と 1.0 はそれを割ります。
今日の値だけを見ると、0.8 は 0.7 より、進行に要る入力・人物像・雑談のどれでも上です。
それでも変えていないのは二つの理由です。
一つ目。これらの検査セットは、何度も見ながら辞書と閾値を直してきたものです。見た回数だけ、検査は「合わせ直した dev セット」に近づき、0.1 の差は汎化の証拠になりません。
二つ目。0.8 では僅差の採用も、その外れも、何にでも雑談で答える数も増えます。
動かすなら、まだ見ていない入力の組で確かめてからにします。そのための holdout は、使える回数を決めて記録しているので、この記事のために回すことはしていません。

入力のベクトルが 1 本も取れない(埋め込みが計算できない環境・モデルが無い)ときは、WB=0 になり、全部の言い換えで C=CosA、採用線は accept 0.27 に戻ります。
辞書の言い換えに焼いたベクトルが無いときは、その 1 本だけ C=CosA ですが、WB は 0.7 のままなので採用線は 0.33 です(文字の側だけの点を、混ぜたときの線で判定することになります)。
Raw が 0.82 以下なら Scaled=0 で、合成は 0.3 × CosA。必ず CosA 以下なので、max で CosA が残ります。
max の両辺が等しいとき(CosA = 合成)は、どちらでも同じ値です。
採用は「点 ≥ 採用線」で、ちょうどは採用です。

凸結合の形(0.3 と 0.7、和が 1)は名前どおりです。違うのは四つです。

一つ目。凸結合の点と文字の点の max をとっています。
凸結合だけなら、文字の側が高く意味の側が低い入力は、必ず点が下がります。max は「どちらかが解ければ通る」を保証しますが、点の分布はもう凸結合のものではありません。

二つ目。正規化が、問いごとの min-max ではなく、固定の下限(0.82)と上限(1.0)です。
問いごとに伸ばすと、候補がどれも無関係な入力でも、最大の候補が 1 になってしまいます。答えない判断(T16)に使える点にしておくために、固定の幅にしました。

三つ目。融合するのは言い換え 1 本ごとで、そのあとに意図ごとの最大をとります。検索の文献の多くは、文書ごとの点を融合したあと順位を作ります。うちの「文書」は言い換えで、返すのは意図なので、融合 → 最大、の順にしています。

四つ目。融合のあとに、固有名詞の加点(最大 0.30)を足します。最終の点は 0〜1 に収まらず、凸結合でもなくなります。採用線 0.33 は、この加点込みの点に対する線です。

文字の側 → 意味の側、と順に試す案(文字で当たらなければ埋め込みへ)は採っていません。先に当たった方で止まるので、文字の側が誤って高い点を出した入力を、意味の側が救えないからです。max の並列の方が正しい、と判断しました。

意味の側だけ(重み 1.0)は、今日の値で凍結 176 件を 1 件落とし、雑談と短い文で下がり、僅差の採用がいちばん多くなりました。固有名詞も効きません。

重みを意図ごとに変える案、二つの点からロジスティック回帰で学習する案は、試していません。ラベル付きの入力が、いまの検査セットしかないからです。
順位で混ぜる RRF は、実装して測り、負けたので採っていません。その話は明日の夕方の T09 で書きます。

検索語#

hybrid search sparse dense score fusion
convex combination hybrid retrieval Bruch
CombSUM Fox Shaw
min-max normalization score fusion

文字で当たる問いと、意味で当たる問い。どちらでも届くかを試せます。第一章はブラウザ版で、プロフィールのリンクから遊べます。

#claude #自然言語処理