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

文字が一つも重ならない問いを、文の埋め込みで拾う

Catching questions that share no characters, with sentence embeddings

 D03-3-T05-sentence-embedding・技術・日本語入力でプレイする仕組み

入力を解いて返すMultilingual E5 Text Embeddings (multilingual-e5-small)E5mean poolingL2 normalizationclaude自然言語処理

「晩飯のあとはどうしてたんだ」。
この問いは、辞書の言い換えと文字がほとんど重なりません。それでも佐伯は「……事件の夜の居場所か? それとも別の話か」と聞き返します。
文の埋め込み multilingual-e5-small が、意味の近さを拾っているからです。

『霧走町 流氷に眠る』は、入力された日本語を書き下ろした証言のどれかに当てます。遊んでいる間に文は作りません。
当てる物差しは二つ。文字の側(昨夜の T04 の文字 n-gram と tf–idf)と、意味の側(この回)です。

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

区切り: T03〜T24 日本語入力を解いて返すまで(22 回のうち 3 回目)
先に読むとよい回: T04(文字 n-gram と tf–idf)
次に読む回: T06(トークナイザ)

名前 — Multilingual E5 Text Embeddings#

正式名
Multilingual E5 Text Embeddings(モデル名 multilingual-e5-small)
略称
mE5、E5(EmbEddings from bidirEctional Encoder rEpresentations)
日本語の呼び名
文の埋め込み(sentence embedding)、多言語 E5
初出
Wang, Yang, Huang, Yang, Majumder & Wei, "Multilingual E5 Text Embeddings: A Technical Report", arXiv:2402.05672, 2024。元の E5 は Wang ほか, "Text Embeddings by Weakly-Supervised Contrastive Pre-training", arXiv:2212.03533, 2022。
あわせて使う名前
mean pooling(平均プーリング。代表的な使用例は Reimers & Gurevych, "Sentence-BERT", EMNLP 2019)、L2 normalization(L2 正規化)

一般の解説 — Multilingual E5 Text Embeddings#

文の埋め込み(sentence embedding)とは、文を固定長のベクトルにする方法です。
近い意味の文が近いベクトルになるように学習させ、コサインで近さを測ります。
問いと答えを別々に符号化するので bi-encoder と呼ばれ、辞書の側のベクトルを前もって計算しておけます。

E5 系は、BERT 型のエンコーダを二段で学習させています。
一段目は、ウェブから集めた大量の文の対で、正しい対を他の対と見分ける対照学習(contrastive learning、損失は InfoNCE)。
二段目は、ラベルつきのデータでの微調整。多言語版の技術報告は、約 10 億の多言語の文の対で一段目を行った、と書いています。
大きさは small・base・large の三つで、small は 12 層・隠れ次元 384 です。
トークナイザは XLM-RoBERTa の SentencePiece(語彙約 25 万)で、最大長は 512 トークンです。

使い方の決まりが一つあります。入力の先頭に接頭辞を付けます。
検索のように問いと文書が非対称なら、問いに "query: "、文書に "passage: "。
意味の近さや言い換えの検索のような対称な用途では、両側に "query: " を付けよ、とモデルカードにあります。付けないと性能が落ちる、とも。

文のベクトルは、最後の層の各トークンのベクトルを平均して作ります(mean pooling)。
パディングがあるときは、attention mask が 1 の位置だけを平均します。
最後に L2 正規化すると、内積がコサインになります。

よく知られた弱点です。
コサインの絶対値が高めに集まります。E5 のモデルカードの FAQ に「コサインが 0.7〜1.0 に分布するのは、InfoNCE の温度を 0.01 と低くしたことによる既知の挙動」とあります。順位には効くが、閾値は使う人が決め直す必要がある、ということです。
否定や数字の違いに鈍い(「来た」と「来なかった」が近くなる)。
長い文は最大長で切られ、話題が混ざった文は平均で薄まります。

うちの実装 — Multilingual E5 Text Embeddings#

前処理と後処理は Plugins/KaedeAdv/Source/KaedeAdv/Private/core/AdvEmbedMath.hpp の 1 か所です。
推論の呼び出し方は、UE(NNE の ORT CPU)とコマンドライン版(ONNX Runtime の C API を直接)で違いますが、計算は同じ関数を呼びます。

inline constexpr const char* kEmbedQueryPrefix = "query: ";
inline constexpr int kEmbedMaxTokens = 128;
inline constexpr int kEmbedDim = 384;

inline std::vector<float> MeanPoolNormalize(const float* LastHidden, int TokenCount, int Dim)
{
std::vector<float> V((size_t)Dim, 0.f);
if (!LastHidden || TokenCount <= 0 || Dim <= 0) { return V; }
for (int t = 0; t < TokenCount; ++t)
for (int d = 0; d < Dim; ++d) { V[(size_t)d] += LastHidden[(size_t)t * Dim + d]; }
double Sq = 0.0;
for (int d = 0; d < Dim; ++d)
{
V[(size_t)d] /= (float)TokenCount;
Sq += (double)V[(size_t)d] * (double)V[(size_t)d];
}
const double Norm = Sq > 1e-18 ? std::sqrt(Sq) : 1e-9;
const float Inv = (float)(1.0 / Norm);
for (int d = 0; d < Dim; ++d) { V[(size_t)d] *= Inv; }
return V;
}

手順はこうです。

  1. 原文の先頭に "query: " を付ける。入力にも、辞書の言い換えにも(対称な用途なので)。
  2. SentencePiece の Unigram で id 列にする。先頭に <s>、末尾に </s>、合わせて最大 128(明日の夜の T06)。
  3. 1 文ずつ、パディングなしで推論する。入力は input_ids・attention_mask(全部 1)・token_type_ids(全部 0)の 3 本の int64。
  4. last_hidden_state [T, 384] を全トークンで平均する。<s> と </s> も平均に入る(mask が 1 なので、本家の使い方と同じ)。
  5. L2 正規化する。

モデルは Hugging Face の Xenova/multilingual-e5-small にある、int8 に量子化した ONNX(onnx/model_quantized.onnx)です。ファイルは 118,308,185 byte。
語彙 250,037 × 384 次元の単語埋め込みの表だけで約 9,600 万の重みがあり、大きさの大半はそこです。

正規化(T03 の norm/1)は、埋め込みにはかけません。原文を渡します。
今日(10月29日)、「アリバイは?」と「ありばいは」は文字の側では同じ 0.754 でしたが、混ぜた点は 0.838 と 0.754 でした。
モデルが学習で見た形のまま渡す方が、意味がよく出ます。

焼いておく分と、その場で計算する分#

辞書の言い換えのベクトルは、前もって焼いておきます(adv-sim bake → Game/adv/baked/vectors.bin)。
今日の版で 4,459 本 × 384 次元です。言い換え 2,845 本より多いのは、検査セットの入力と、その節も焼いてあるからです。
キーは、正規化する前の原文そのものです。照合では std::map<std::string, std::vector<float>> を文字列で引きます。

キャッシュに無い文、つまりプレイヤーが実際に入力する文は、その場で計算します。
今日、このPC のコマンドライン版で、新しい文を 1 つ入力する 1 手が 16〜18ms でした(照合の全体を含む)。
焼いたベクトルと実行時の計算が同じかは、adv-sim embedcheck で全部の言い換えを引き直して確かめます。今日は 2,845 文すべて cos=1.000000 でした(なぜそこまで一致させるのかは T07)。

節ごとに引いて、いちばん近いものを採る#

入力は、文全体に加えて、句読点(。.!?、,と半角の . ! ? ,)で割った節も引きます。
4 文字未満の節と、全体と同じ節は捨てます。
言い換え 1 本ごとに、引いたベクトル全部とのコサインの最大を採ります。

for (const std::vector<float>* QV : QVecs)
{
Raw = std::max(Raw, VectorCache::cosine(*QV, *D.Embed));
}

前置きの付いた文で、全体を平均すると前置きの話題に引っ張られるからです。
今日、「網の修理も大変でしょうが、昨日の夜はどこにいましたか」は、表層 0.501・混ぜた点 0.796 で居場所の問いとして採用されました。
節の割り方そのものは T12 で書きます。

0.82 を 0 点にする#

生のコサインは、そのまま使いません。embedFloor(0.82)を 0 点にして、0〜1 へ伸ばします。

const double Scaled = std::clamp(
(Raw - Th.embedFloor) / std::max(1e-6, 1.0 - Th.embedFloor), 0.0, 1.0);

生のコサインはログに出していないので、今日のログの表層と混ぜた点から逆算しました(同じ言い換えから来たと仮定した概算です)。

猫は好きですか 0.867
寒いですね 0.874
晩飯のあとはどうしてたんだ 0.886
あの晩の足取りを聞かせてくれ 0.952
昨日の夜、どこにいたんですか 0.991

佐伯に猫の話をしても、どこかの言い換えと 0.87 近くになります。
モデルカードの FAQ のとおり、役に立つ幅は 0.85〜1.0 あたりに押し込まれています。
0.82 は「採用線」ではなく「ゼロ点」です。採用するかどうかは、混ぜたあとの点で別に決めます(T08・T16)。

0 点を振ってみた#

adv-sim golden は --floor で 0 点を上書きして流せます(thresholds.json は書き換えません)。今日の値で、数字は ACC です。

0 点 0.70/0.75/0.78/0.80/0.82(いま)/0.84/0.86/0.90
進行に要る入力 244 件 93.9/92.6/91.4/89.3/88.9/82.8/80.7/69.7
人物像を変えた入力 120 件 90.0/90.0/87.5/85.0/83.3/80.8/77.5/75.0
雑談 67 件 73.1/74.6/73.1/73.1/71.6/64.2/55.2/43.3
凍結した 176 件 0.90 だけ 97.7、ほかは 100

これだけ見ると、0 点を下げた方が良く見えます。
下げると失うものは、この表に出ません。
凍結した 176 件には、事件を進めてはいけない入力が 34 件あります(世間話や、筋と関係のない頼みごと)。
その 34 件の行き先を数えました(雑談として答える/聞き返す/残りは受け流す)。

0.70 34/0
0.78 29/5
0.82 18/8
0.86 15/2
0.90 10/5

0.75 以下では、34 件すべてが何かの雑談として受理されます。
どんな入力にも、町の人が何か世間話を返す。「知らんな」も「何の話だ」も出なくなります。
進行を誤って動かした数(NEG_FP)は、どの設定でも 0 でした。壊れるのは進行ではなく、「答えない判断」の方です。
いまの 0.82 は、進行に要る入力の ACC をいくらか譲って、答えない余地を残した位置です。
ただし、これは何度も見てきた検査セットの上の値で、校正したものではありません(T16 で書きます)。

境界の扱い — Multilingual E5 Text Embeddings#

空の文字列は埋め込みを引きません(embedText が空を返す)。
入力の側のベクトルが 1 本も取れなかったとき(計算できない環境・モデルが無い)は、埋め込みの重みが 0 になり、採用線も表層用の 0.27 に戻ります。エンジンは文字の側だけで動き続けます。
辞書の言い換えのうち、焼いたベクトルが無いものは、その 1 本だけ文字の側の点で比べます。
最大 128 トークンを超える入力は、<s> と </s> を除いた 126 トークン(うち 3〜4 個は接頭辞 "query: " の分)で切ります。辞書の言い換えは最長 38 文字なので、切られるのはプレイヤーの長い入力だけです。
コサインが 0.82 ちょうどなら Scaled は 0、1.0 なら 1 です。
零ベクトル(あり得ませんが)は、割る数を 1e-9 にして落ちないようにしてあります。

名前どおりでない所 — Multilingual E5 Text Embeddings#

一つ目。コサインをそのまま使わず、0.82 から 1.0 を 0 から 1 に伸ばし、0.82 未満を 0 に切っています。
理由は上のとおり、e5 のコサインが高めに集まり、そのまま文字の側の点(0〜1 に広く散る)と混ぜると、無関係な入力まで採用線を越えるからです。

二つ目。1 つの入力に、全体と節の複数のベクトルを持たせ、最大を採っています。mean pooling で 1 文 1 ベクトル、というふつうの形ではありません。

三つ目。最大長を 128 にしています(モデルは 512 まで読めます)。
1 手ごとの計算を軽くするためで、いまの辞書と入力の長さでは効いていません。

接頭辞 "query: " を両側に付けるのは、モデルカードの推奨どおりです。mean pooling と L2 正規化も名前どおりです。

弱点と、採らなかった案 — Multilingual E5 Text Embeddings#

最初は、開発用の Python でバッチにまとめ、バッチの最長に合わせてパディングして焼いていました。
int8 のモデルは系列長に敏感で、同じ文でも同じバッチに何が入っていたかでベクトルが変わり、実行時の 1 文ずつの計算と常にずれていました(2026-08-04 に分かりました)。
いまは、実行時と同じ経路(1 文ずつ・パディングなし)で焼きます。

辞書の側に "passage: " を付ける案は採っていません。辞書も入力も「刑事の問い」で、対称だからです。

base や large の方が意味はよく取れますが、配布物と 1 手の計算が重くなります。small の int8 で、配布するモデルは 117MB です。

生成 AI に入力を分類させる案は採っていません。分類だけなら決定論にもできますが、ネットへの依存が残ります。手元の埋め込みだけで足りる、と測って決めました。

否定に鈍いのは、そのまま残っています。否定は、照合とは別の解析で扱います(T13)。

検索語#

multilingual-e5-small
Multilingual E5 Text Embeddings technical report
E5 query passage prefix
mean pooling sentence embeddings

文字は重ならないけれど、言いたいことは同じ。そういう問いを佐伯に入力してみてください。第一章はブラウザ版で遊べます(プロフィールのリンクから)。

#claude #自然言語処理