Voyage embeddingを次元削減する方法 — Matryoshka学習の実装
voyage-code-3等が対応するMatryoshka学習を使い、1024次元のembeddingを256次元に切り詰めて保存コストを抑える実装手順です。
このTipsでできること
Voyage AIのembeddingモデルは、生成したベクトルの先頭部分だけを取り出しても意味的な精度がある程度保たれるMatryoshka学習を採用しています。voyage-code-3のような対応モデルなら、1024次元で生成したベクトルの先頭256次元だけを切り出して正規化すれば、検索精度を大きく落とさずにストレージとメモリの消費を1/4に減らせます。
Matryoshka embeddingとは、粗い情報から細かい情報までを1本のベクトルの先頭から順に詰め込んだ表現方式です。ロシアの入れ子人形(マトリョーシカ)のように、外側(先頭次元)だけでも意味のある近似表現になり、内側(後方次元)を足すほど精度が上がります。通常の埋め込みモデルは全次元を使って初めて意味が成立する設計のため、この「途中で切っても壊れない」性質はMatryoshka学習特有のものです。
やり方
Voyage AIのモデルは、Anthropicの公式embeddingガイドがそのまま使い方を提供しています。Anthropic自身はembeddingモデルを提供しておらず、Voyage AIをパートナーとして案内する形です。Voyage AIは金融・医療などの業界特化モデルや、個々の顧客向けにファインチューニングしたモデルも手がけており、汎用モデルで精度が足りない場合の選択肢として公式ガイドが名前を挙げています。
voyage-4・voyage-4-large・voyage-3-large・voyage-3.5・voyage-code-3・voyage-context-4など、複数の出力次元(256 / 512 / 1024 / 2048)に対応するモデルがMatryoshka学習の対象です。公式ガイドが示す手順は、デフォルトの1024次元でベクトルを取得したあと、手元のコードで先頭次元を切り出す方式です。
| モデル | 出力次元 | Matryoshka切り詰め |
|---|---|---|
voyage-4 / voyage-4-large / voyage-4-lite / voyage-4-nano | 出力次元256 / 512 / 1024(既定)/ 2048 | Matryoshka切り詰め対応 |
voyage-3-large / voyage-3.5 / voyage-3.5-lite / voyage-code-3 | 出力次元256 / 512 / 1024(既定)/ 2048 | Matryoshka切り詰め対応 |
voyage-context-4 / voyage-context-3(チャンク単位の文脈化embedding) | 出力次元256 / 512 / 1024(既定)/ 2048 | Matryoshka切り詰め対応 |
voyage-multimodal-3.5 | 出力次元256 / 512 / 1024(既定)/ 2048 | Matryoshka切り詰め対応 |
voyage-finance-2 / voyage-law-2 / voyage-multimodal-3 | 出力次元1024固定 | Matryoshka切り詰め非対応 |
固定次元のモデルは、そもそも複数の出力次元を選べる設計になっていないため、この記事の切り詰め手法は使えません。同じマルチモーダル系列でもvoyage-multimodal-3(1024固定・非対応)とvoyage-multimodal-3.5(対応)でMatryoshka対応の有無が分かれるため、系列名だけで判断せず個別のモデル名で表を確認してください。
生成後に手元で切り詰める場合は、ベクトルの先頭から必要な次元数を取り出し、再正規化する必要があります。切り詰めただけでは単位ベクトルでなくなり、コサイン類似度と内積が一致しなくなるためです。検索用途か文書登録用途かで埋め込み前に指定するinput_typeも結果を左右します。詳しくはVoyage embeddingのinput_typeでquery/documentを分ける理由で扱っています。
pip install -U voyageai次のPythonコードは、1024次元のvoyage-code-3ベクトルを256次元に切り詰める実装です。
import voyageai
import numpy as np
def embd_normalize(v: np.ndarray) -> np.ndarray:
"""
2次元numpy配列の各行を、ユークリッドノルムで割って単位ベクトルに正規化する。
ノルムが0の行があるとゼロ除算になるため ValueError を送出する。
"""
row_norms = np.linalg.norm(v, axis=1, keepdims=True)
if np.any(row_norms == 0):
raise ValueError("Cannot normalize rows with a norm of zero.")
return v / row_norms
vo = voyageai.Client()
# voyage-code-3のベクトルを生成(デフォルトは1024次元の浮動小数点数)
embd = vo.embed(["Sample text 1", "Sample text 2"], model="voyage-code-3").embeddings
# 切り詰め先の次元数
short_dim = 256
# 先頭256次元を取り出して正規化
resized_embd = embd_normalize(np.array(embd)[:, :short_dim]).tolist()embd_normalizeはNumPy配列の各行をユークリッドノルムで割り、単位ベクトルに戻す関数です。np.array(embd)[:, :short_dim]の部分がMatryoshka embeddingの本質で、全次元を再計算せず、既存ベクトルの先頭256要素をスライスするだけで済みます。
複数の次元数を使い分ける場合も、embed()自体は常にデフォルトの1024次元でベクトルを返します。異なる粒度のインデックスが必要なときは、1回の呼び出しで得た1024次元のベクトルを、用途ごとにshort_dimを変えて複数回切り詰めれば済み、API呼び出し自体を増やす必要はありません。
このとき事故が起きやすいのが、ドキュメント登録処理とクエリ処理を別々に書いてしまい、short_dimの値がどちらか一方だけ古いまま残るケースです。たとえば運用途中で256次元から512次元に切り替えたとき、ドキュメント側のバッチ処理は更新したのにクエリ側のリアルタイム処理を直し忘れると、次元数が512と256で食い違ったまま検索が動き続けます。エラーで止まらずに不自然な類似度が返り続けるため気づきにくく、切り詰め関数を1箇所にまとめ、両方のパイプラインから同じ関数を呼ぶ形にしておくと安全です。
def truncate_embedding(embd: list, short_dim: int) -> list:
"""
ドキュメント登録・クエリ処理の両方から呼ぶ共通の切り詰め関数。
short_dim を1箇所で管理し、登録側とクエリ側で値がずれる事故を防ぐ。
"""
return embd_normalize(np.array(embd)[:, :short_dim]).tolist()
SHORT_DIM = 256 # 登録・クエリ共通の次元数はここだけを変更する
doc_embd = truncate_embedding(
vo.embed(["Sample text 1"], model="voyage-code-3").embeddings, SHORT_DIM
)
query_embd = truncate_embedding(
vo.embed(["Sample query"], model="voyage-code-3", input_type="query").embeddings, SHORT_DIM
)補足
なぜ再生成でなく切り詰めで済むかは、Matryoshka学習の訓練方法に理由があります。通常の埋め込みモデルは全次元を使って初めて意味のある表現になりますが、Matryoshka学習では訓練時に複数の次元数(例えば64・128・256・512・1024)それぞれで損失関数を評価します。その結果、先頭の少ない次元だけでも独立した意味的な近似として機能するよう最適化されます。
output_dtypeパラメーターによる量子化(float / int8 / uint8 / binary / ubinary)は次元削減とは別軸の最適化です。量子化は各次元の表現精度(ビット数)を落とし、Matryoshka切り詰めは次元の本数そのものを減らします。両方を組み合わせれば、1024次元・32bit浮動小数点のベクトルを、256次元・1bitのバイナリ表現まで圧縮でき、理論上の保存コストは128分の1(次元数1/4 × ビット幅1/32)まで下がります。量子化だけを単独で使う場合の手順はembeddingバイナリ量子化でVoyage AIの保存コストを1/32にするにまとめています。
切り詰め後の精度を素早く確認したいときは、公式ガイドのクイックスタート例と同じ構成が使えます。既知の正解ドキュメントに対応するクエリを用意し、1024次元と256次元それぞれでnp.dot→np.argmaxの結果が一致するかを比較する検証コードです。
docs = ["Sample document 1", "Sample document 2", "Sample document 3"]
query = "Sample query"
correct_idx = 0 # docs[correct_idx] が正解ドキュメントの想定
doc_embds_full = vo.embed(docs, model="voyage-code-3").embeddings
query_embd_full = vo.embed([query], model="voyage-code-3", input_type="query").embeddings[0]
for short_dim in [1024, 256]:
doc_embds = truncate_embedding(doc_embds_full, short_dim)
query_embd = truncate_embedding([query_embd_full], short_dim)[0]
scores = np.dot(doc_embds, query_embd)
picked = int(np.argmax(scores))
print(f"short_dim={short_dim}: picked={picked}, match={picked == correct_idx}")手元のデータで数十件試すだけでも、matchが両方の次元数で揃うかを見れば、切り詰めが実用に耐えるかどうかの当たりを付けられます。1024次元では正解できていたクエリが256次元でmatch=Falseに変わった場合は、そのクエリだけ大きい次元を使う、あるいは512次元に留めるといった調整の判断材料になります。
Matryoshka切り詰めで押さえておきたいこと
コサイン類似度と内積の一致は、Voyageのembeddingがすでに長さ1へ正規化されていることが前提です。切り詰め後に再正規化を省略すると、ベクトルの長さがまちまちになり、内積による類似度計算がコサイン類似度とずれてしまいます。バッチで大量のベクトルを切り詰める場合ほど、この再正規化のステップを見落としやすい点に注意が必要です。
ストレージ削減量は次元数の比率でそのまま計算できます。1024次元・32bit浮動小数点(float)のベクトルは1件あたり4096バイトです。256次元へ切り詰めれば1024バイトになり、100万件のドキュメントを格納する場合、単純計算で約4.1GBから約1GBへ削減できます(端数はインデックス構造のオーバーヘッドを含まない概算値)。数百万件規模のドキュメントをベクトルデータベースへ格納するRAG(検索拡張生成)構成では、この削減がインフラコストとインデックスの検索速度の両方に直接効いてきます。内積演算のコストは次元数に比例するため、次元を4分の1にすれば類似度計算そのものも軽くなります。
よくある質問
次元削減後に既存のベクトルデータベースを作り直す必要はあるか
同じ次元数で統一する必要があります。1024次元と256次元のベクトルが同じインデックスに混在すると、距離計算自体が成立しません。既存インデックスを段階的に移行する場合は、新旧の次元数で別インデックスを用意し、切り替えのタイミングで一括入れ替えるのが安全です。
同じ理由で、検索クエリのベクトルとインデックス側のベクトルは常に同じ次元数へ切り詰める必要があります。ドキュメント側だけ256次元に切り詰め、クエリ側は1024次元のまま検索してしまうミスは、次元数の不一致でエラーになるか、意図しない類似度が返る原因になります。切り詰め処理をドキュメント登録パイプラインとクエリ処理パイプラインの両方に同じ関数として実装し、short_dimの値を1箇所で管理すると、この種の不一致を仕組みで防げます(実装例は前掲のtruncate_embedding関数を参照)。デプロイ環境が分かれている構成(登録処理はバッチジョブ、クエリ処理はAPIサーバーなど)では、この関数を共通ライブラリとして両方からimportする形にしておくと、コードのコピーによるずれも防げます。
量子化と次元削減はどちらを先に試すべきか
決まった順序はありません。量子化(output_dtype)は次元数を変えずに1要素あたりのビット数だけを減らすため、検索精度への影響が比較的小さい傾向があります。次元削減は情報そのものを間引くため精度への影響が相対的に大きくなりやすい構造です。ストレージ削減の目標値が小さいうちは量子化だけで様子を見て、それでも足りない場合に次元削減を足す進め方が扱いやすいでしょう。
256次元・512次元・1024次元のどれを選べばよいか
固定の正解はなく、処理の段階によって使い分けるのが基本です。候補を数千件から数百件に絞る粗い一次フィルタリングの段階では小さい次元で十分な場合が多く、最終的な類似度判定や再ランキングの直前では大きい次元に切り替える構成が向いています。二段階構成にする場合、一次フィルタと再ランキングでモデル呼び出しを分ける必要はなく、embed()で得た1024次元のベクトルを段階ごとに異なるshort_dimで切り詰めれば済みます。どこまで削って問題ないかの判断基準は上のCalloutの手順で決めてください。