Voyage embeddingを次元削減する方法 — voyage-4やcode-4を256次元に切り詰める
voyage-4・voyage-code-4・voyage-4-nanoなどが対応するMatryoshka学習で、embeddingを256次元に切り詰める手順です。API指定と手元スライスの違いも示します。
このTipsでできること
Voyage AIのembeddingモデルは、粗い表現から細かい表現までを1本のベクトルに持たせるMatryoshka学習を採用しています。先頭部分だけを取り出して使えるのが特徴です。voyage-4やvoyage-code-4のような対応モデルなら、既定の1024次元で生成したベクトルの先頭256次元だけを切り出して正規化すれば、ストレージとメモリの消費を1/4に減らせます。切り詰めには2通りあります。APIのoutput_dimensionで最初から短く取る方法と、保存済みのベクトルを手元でスライスする方法です。落ちる検索精度の幅は用途で変わるので、手元のデータで測ってから次元数を決めます。
Matryoshka embeddingとは、粗い情報から細かい情報までを1本のベクトルの先頭から順に詰め込んだ表現方式です。ロシアの入れ子人形(マトリョーシカ)のように、外側(先頭次元)だけでも意味のある近似表現になり、内側(後方次元)を足すほど細かく表せます。
やり方
Anthropic自身はembeddingモデルを提供しておらず、公式ガイドはVoyage AIを提供元として案内しています。Voyage AIには金融・医療などの業界特化モデルや、顧客ごとにファインチューニングしたモデルもあります。
Matryoshka学習の対象は、複数の出力次元(256 / 512 / 1024 / 2048)に対応するモデルです。たとえばvoyage-4・voyage-4-large・voyage-code-4・voyage-3.5・voyage-context-4が当てはまります。公式ガイドのFAQは、デフォルトの1024次元でベクトルを取得したあと、手元のコードで先頭次元を切り出す方法を示しています。
| モデル | 出力次元 | 複数次元(Matryoshka) |
|---|---|---|
voyage-4 / voyage-4-large / voyage-4-lite / voyage-code-4 | 出力次元256 / 512 / 1024(既定)/ 2048 | 複数次元(Matryoshka)対応 |
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)非対応 |
公式FAQがMatryoshka埋め込みを生成すると説明しているのは、複数の出力次元に対応するモデルです。出力次元が1024だけのモデルには、この切り詰めを当てはめません。同じマルチモーダル系列でもvoyage-multimodal-3(1024固定・非対応)とvoyage-multimodal-3.5(対応)でMatryoshka対応の有無が分かれます。系列名だけで判断せず、個別のモデル名で表を確認してください。
表のうちvoyage-4-nanoは、Hugging Faceで配布されるApache 2.0ライセンスのオープンウェイトモデルで、自分の環境で動かします。Voyage AIのAPI経由では使えません。
既定の次元が2048なのは、このモデルだけです。1024次元を前提にした下のコードをそのまま当てると、先頭256要素の取り出し方は同じでも、元の長さが違うので削減率は1/8になります。
voyage-context-4とvoyage-context-3は、embed()ではなくcontextualized_embed()で呼ぶモデルです。この記事のコードはembed()を前提にしているので、これらのモデルに当てはめるときは返り値の形を確かめてから切り詰めます。
切り詰めの手順は4段階です。登録とクエリの両方で同じ関数を通すのがポイントです。
1024次元から256次元へ切り詰める流れ
- 1
既定の1024次元で取得する
embed()を次元の指定なしで呼びます。検索用途では、文書側はinput_type="document"、クエリ側はinput_type="query"を付けます。 - 2
先頭256要素だけ残す
NumPy配列なら
[:, :256]のスライスです。再計算は要りません。 - 3
行ごとに再正規化する
ユークリッドノルムで割り、長さ1に戻します。省くと内積の値も順位もずれます。
- 4
登録とクエリで同じ関数を通す
次元数の定数を1箇所に置き、両方のパイプラインから呼びます。
input_typeの違いはVoyage embeddingのinput_typeでquery/documentを分ける理由で扱っています。
pip install -U voyageai次のPythonコードは、1024次元のvoyage-code-4ベクトルを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-4のベクトルを生成(デフォルトは1024次元の浮動小数点数)
embd = vo.embed(["Sample text 1", "Sample text 2"], model="voyage-code-4").embeddings
# 切り詰め先の次元数
short_dim = 256
# 先頭256次元を取り出して正規化
resized_embd = embd_normalize(np.array(embd)[:, :short_dim]).tolist()np.array(embd)[:, :short_dim]の部分がMatryoshka embeddingの本質です。全次元を再計算せず、既存ベクトルの先頭256要素をスライスするだけで済みます。ゼロベクトルの行があるとembd_normalizeはValueErrorを出します。
output_dimensionを指定しない限り、embed()は既定の1024次元でベクトルを返します。output_dimensionで256・512・1024・2048を指定して、API側で次元を決める方法もあります。output_dimensionを使うと、APIが短い次元のベクトルを返します。この記事のコードは保存済みのベクトルにも使える手元スライスの方式なので、再正規化も自分で行います。
異なる粒度のインデックスが必要なときは、1回の呼び出しで得た1024次元のベクトルを、用途ごとにshort_dimを変えて複数回切り詰めれば済みます。API呼び出しを増やす必要はありません。
このとき事故が起きやすいのが、ドキュメント登録処理とクエリ処理を別々に書いてしまい、short_dimの値がどちらか一方だけ古いまま残るケースです。たとえば運用途中で256次元から512次元に切り替えたとき、ドキュメント側のバッチ処理は更新したのにクエリ側のリアルタイム処理を直し忘れると、次元数が512と256で食い違ったまま検索が動き続けます。エラーで止まらずに不自然な類似度が返り続けるため気づきにくい事故です。対策は上の手順の4段目にあたり、次の共通関数がその実装です。
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-4", input_type="document").embeddings,
SHORT_DIM,
)
query_embd = truncate_embedding(
vo.embed(["Sample query"], model="voyage-code-4", input_type="query").embeddings,
SHORT_DIM,
)補足
再生成でなく切り詰めで済むのは、Matryoshka学習が1本のベクトルの中に粗い表現から細かい表現までを持たせるためです。公式FAQの説明は「複数の出力次元に対応するモデルが、先頭の次元だけを残せるベクトルを返す」という範囲です。
output_dtypeによる量子化は、次元削減とは別の軸の圧縮です。公式FAQは、32bit浮動小数点を8bit整数や1bitに変えると、保存・メモリ・コストが4分の1(int8)・32分の1(binary)になると説明しています。
切り詰めと量子化の違い
Matryoshka切り詰め
1024本のうち先頭256本だけを残します。1要素あたりのビット数は変わりません。手元のコードでスライスして再正規化します。
量子化(output_dtype)
次元数はそのままで、floatをint8やbinaryに替えます。APIの引数で指定し、結果は整数のリストで返ります。
両方を組み合わせると、1024次元・32bitのベクトルを256次元・1bitまで圧縮できます。1件あたり4096バイトが32バイトになり、割り算では128分の1です。binaryとubinaryは8次元を1つの整数にまとめて返すので、256次元なら返るリストの長さは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-4", input_type="document"
).embeddings
query_embd_full = vo.embed(
[query], model="voyage-code-4", 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次元に留めるといった調整の判断材料になります。
再正規化を省くと何が起きるか
コサイン類似度と内積が一致するのは、Voyageのembeddingが長さ1に正規化されているからです。公式FAQも、内積のほうが速く計算できると説明しています。先頭だけ切り出すとこの前提が崩れます。
手元のPythonだけで、乱数で作った1024次元の単位ベクトルを使って確かめられます。APIは呼ばず、標準ライブラリだけで動きます。実際のembeddingでは数値が変わりますが、長さが縮む仕組みは同じです。
import math, random
random.seed(0)
v = [random.gauss(0, 1) for _ in range(1024)]
n = math.sqrt(sum(x * x for x in v))
v = [x / n for x in v] # 長さ1の1024次元ベクトル
head = v[:256]
length = math.sqrt(sum(x * x for x in head))
print(round(length, 4)) # 切り詰め後の長さ
print(round(sum(x * x for x in head), 4)) # 自分自身との内積0.5304
0.2813先頭256次元だけを残すと長さは約0.53に縮み、同じベクトル同士の内積は理想の1.0でなく約0.28になります。縮み方はベクトルごとに違うため、再正規化を省くと内積の順位がコサイン類似度の順位からずれます。登録するベクトルの本数が多いほど、このずれは検索結果に出ます。
再正規化しておけば、類似度の関数は好みで選べます。公式FAQによると、Voyageのベクトルではコサイン類似度と内積が同じ値になり、コサイン類似度とユークリッド距離は順位が同じになります。ベクトルデータベースの距離指標がL2でも内積でもコサインでも、切り詰めて再正規化したベクトルなら検索結果の順序は揃います。
切り詰めたベクトルは、元のモデルとは別の次元のインデックスです。256次元の登録データに対して、1024次元のまま作ったクエリを当てることはできません。モデルを替えるときも、新しいモデルの出力次元とshort_dimを表で確かめてから、インデックスを作り直します。
ストレージはどれだけ減るか
ベクトル1件の大きさは次元数に比例します。32bit浮動小数点(float)で比べると、次のとおりです。
1件あたりのベクトルの大きさ
1024次元・float
4,096バイト
100万件で約4.1GB
256次元・float
1,024バイト
100万件で約1GB
RAG(検索拡張生成)でベクトルデータベースへ数百万件を入れる構成なら、この差がインフラ費用に出ます。ベクトルDB側のカラムやインデックスの次元数も、切り詰め後の値に合わせて作ります。内積の計算量も次元数に比例するので、類似度計算そのものも軽くなります。
よくある質問
次元削減後に既存のベクトルデータベースを作り直す必要はあるか
同じ次元数で統一する必要があります。1024次元と256次元のベクトルが同じインデックスに混在すると、距離計算自体が成立しません。既存インデックスを段階的に移行する場合は、新旧の次元数で別インデックスを用意し、切り替えのタイミングで一括入れ替えるのが安全です。
デプロイ環境が分かれている構成では、切り詰め関数を共通ライブラリにして両方からimportする形が向いています。登録処理がバッチジョブ、クエリ処理がAPIサーバーといった構成でも、コードのコピーによるずれを防げます。
量子化と次元削減はどちらを先に試すべきか
決まった順序はありません。手段ごとの圧縮率は上の比較のとおりです。目標の削減率に届く組み合わせを選び、それぞれの精度の落ち幅を、上のCalloutの評価セットで測ります。圧縮の手段ごとに検索精度への効き方が違うかどうかは、手元のデータで確かめるのが確実です。
256次元・512次元・1024次元のどれを選べばよいか
固定の正解はなく、処理の段階によって使い分けるのが基本です。一次検索を小さい次元で行い、候補を絞った後の再ランキングで大きい次元を使う二段階の組み方もあります。この場合もembed()の呼び出しは1回で、得た1024次元のベクトルを段階ごとに異なるshort_dimで切り詰めれば済みます。どこまで削れるかは、上のCalloutの評価で決めます。