テキスト埋め込み¶
tensai embed は埋め込みモデルでテキストをベクトルに変えます。tensai serve は同じモデルで OpenAI の /v1/embeddings に答えます。チャットアプリが資料検索 (RAG) のために呼ぶのがこれです。ほかと同じ純 Go のカーネルで動きます。既定のモデルは日本語の ModernBERT である Ruri v3 310m の imatrix Q4_K_M 変換版 (204MB、初回に自動でダウンロード) です。
$ tensai embed -sim "川べりでサーフボードを持った人たちがいます" "サーファーたちが川べりに立っています" \
"トピック: 瑠璃色のサーファー" "検索クエリ: 瑠璃色はどんな色?" \
"検索文書: 瑠璃色(るりいろ)は、紫みを帯びた濃い青。名は、半貴石の瑠璃(ラピスラズリ、英: lapis lazuli)による。JIS慣用色名では「こい紫みの青」(略号 dp-pB)と定義している[1][2]。"
1.0000 0.9573 0.8144 0.7054 0.6891
0.9573 1.0000 0.8211 0.7012 0.6800
0.8144 0.8211 1.0000 0.8715 0.8475
0.7054 0.7012 0.8715 1.0000 0.9729
0.6891 0.6800 0.8475 0.9729 1.0000
Ruri のモデルカードにある例です。元の float32 の重みで出したカードの表と、どの値も 0.003 以内で一致します。
-sim を付けなければ、テキストごとに JSON を 1 行 ({"index":0,"tokens":11,"embedding":[...]}) 出します。テキストは引数で渡します。引数がなければ標準入力から 1 行 1 テキストで読みます。ベクトルはどれも長さ 1 なので、内積がそのままコサイン類似度です。
Ruri v3 は、テキストの用途を表す接頭辞を本文の一部として付ける前提で学習されています。検索クエリには 検索クエリ:、検索される文書には 検索文書:、分類やクラスタリングには トピック: を付け、意味だけを見るときは何も付けません。tensai はテキストをそのまま渡すので、接頭辞は呼び出し側で付けます。sentence-transformers や llama.cpp で使うときと同じです。
何が動くか¶
ModernBERT はエンコーダです。どのトークンも前後両方向のすべてのトークンを見て、次のトークンではなくトークンごとのベクトルを出します。
- トークン化。 Ruri の語彙は SentencePiece の Unigram モデルです。tensai は Unigram として (スコアが最大になる分割で) 探索し、
<s>と</s>で挟みます。GGUF には文頭に空白を入れるかどうかが書かれておらず、llama.cpp は入れる前提で扱います。変換元のtokenizer.jsonは入れないので、tensai も入れません。試したテキストではすべて transformers とトークン id が一致しました。 - エンコーダ。 25 層で、各層はバイアスなしの LayerNorm、12 ヘッドの RoPE 付き注意、GeGLU のフィードフォワードからなります。先頭の層とそこから 3 層ごとの層はテキスト全体を見て、RoPE の base は 160000 です。残りの層は前後 64 トークンだけを見て、base は 10000 です。
- プーリング。 最終層の出力をテキスト全体で平均し、長さ 1 に揃えます。
複数のテキストは、重みを 1 回読む間にまとめて処理します (合計でおよそ 2048 トークンまで)。位置はテキストごとに数え直し、注意もテキストの中だけで取るので、まとめて処理しても 1 つずつ処理したときと同じベクトルになります。
精度と量子化¶
transformers で動かした元の float32 モデルとの比較です。8 から 382 トークンまでの 7 テキストで、2 つのベクトルのコサインを取りました。
| 最低 | 最高 | |
|---|---|---|
| tensai、float32 (既定) | 0.9935 | 0.9981 |
| llama.cpp b10615、同じファイルと id | 0.9936 | 0.9980 |
tensai -q8 |
0.9912 | 0.9945 |
tensai -q4 |
0.9877 | 0.9926 |
float32 で残る差は Q4_K_M ファイル自体の量子化によるもので、llama.cpp も同じです。-q8 と -q4 では、格納されたブロックをそのまま詰め替えるので重みは変わりません (Q4_K と Q6_K は直接詰め替え)。ただし整数の行列積は、活性値も 1 行ごとに 1 つのスケールで 7 ビットに丸めます。エンコーダの活性値には外れ値があるので、こちらの誤差のほうが大きくなります。このため、run と違って既定は float32 にしています。
速度とメモリです (Ryzen 7 7735HS、16 スレッド)。読み込みはおよそ 1.7 秒です。
| 幅 | トークン/秒 | 最大メモリ |
|---|---|---|
| float32 | 240 から 255 | 2.1GB |
-q8 |
420 から 450 | 0.9GB |
float32 の時間はほぼすべて行列積そのものです。
サーバー¶
-model に埋め込みモデルを指定すると、/v1/embeddings と /v1/models だけのサーバーになります。チャットと埋め込みを 1 つのサーバーで両方扱うには、チャットモデルをいつも通り指定し、埋め込みモデルを -embed で指定します。
/v1/embeddings は OpenAI のリクエスト形式を受け付けます。input には文字列、文字列の配列、トークン id の配列、またはその配列を渡せます。encoding_format は float か base64 (リトルエンディアンの float32) です。OpenAI 公式のクライアントライブラリは既定で base64 を要求します。usage は読んだトークン数で、<s> と </s> も数えます。埋め込みのリクエストは状態を持たないので、同時に来たリクエスト同士も、チャットのリクエストとも並行して処理します。
tensai models では、言語モデルなら tools や think が出る列に embed と表示されます。埋め込みモデルを run や chat に渡すと、embed か serve を使うよう案内が出ます。
フラグ¶
フラグは -model (run と同じ指定方法で、キャッシュ上の名前、.gguf のパス、org/repo/file.gguf のいずれか)、-q8/-q4、-sim、-v です。読めるのは modern-bert アーキテクチャの GGUF ファイルだけです。