Gemini Embedding の次元を 3072 から 768 へ切り詰める — ベクトルDBのコストとレイテンシを下げる Matryoshka 設計
ベクトル検索の機能は、作った直後よりも半年後にこそ本性を現します。私が2014年から運営しているアプリ群では、ストアレビューの意味的クラスタリングと、壁紙アプリのカテゴリ分類に埋め込み(Embedding)を使っています。最初の数千件のうちは何も問題が起きません。ところが件数が10万件、30万件と積み上がってきたある日、ベクトルDBの請求額と、検索の応答時間のグラフが、静かに、しかし確実に右肩上がりになっていることに気づきます。
そのとき私が最初に手を伸ばしたのは、より速いDBへの乗り換えでも、インデックスの種類を変えることでもありませんでした。1件あたりのベクトルそのものを小さくすることでした。gemini-embedding-001 は既定で 3072 次元のベクトルを返します。これを 768 次元へ切り詰めると、ストレージはおよそ 4 分の 1 になり、距離計算の負荷もほぼ次元数に比例して軽くなります。しかも、やみくもに切り捨てているわけではありません。Matryoshka 表現という仕組みのおかげで、前方の次元だけを残しても意味のほとんどが保たれるのです。
ここからは、その「次元を削る」という判断を、勘ではなく根拠を持って行えるように設計していきます。再正規化という見落としやすい一手と、768 で粗く絞って 3072 で精緻化する二段構えの検索まで、実際に動くコードとともに組み立てます。
3072 次元がじわじわと効いてくる場所
埋め込みのコストは、API を呼ぶ瞬間だけの話だと思われがちです。実際には、生成したあとの方がずっと長く効いてきます。
一番分かりやすいのはストレージです。float32 で 3072 次元のベクトルを 1 件保存すると 12,288 バイト、おおよそ 12 KB です。これが 30 万件あれば、ベクトルだけで約 3.5 GB になります。マネージドなベクトルDBの多くは保存量とメモリ常駐量で課金するため、この 3.5 GB がそのまま月々のコストに乗ります。次元を 768 へ落とせば 1 件 3 KB、合計でおよそ 0.9 GB。保存量で約 75% の削減になります。
二番目はレイテンシです。最近傍探索の距離計算は、内積にせよコサイン類似度にせよ、次元数に比例した演算量がかかります。HNSW のようなグラフ系インデックスでも、グラフを辿るたびに次元数ぶんの内積を繰り返すため、次元を 4 分の 1 にすると 1 ホップあたりの計算も理屈の上では 4 分の 1 近くまで軽くなります。私の環境では、レビュー検索の p95 レイテンシが目に見えて下がりました。
三番目はメモリ帯域です。これは見落とされがちなのですが、大規模な検索では CPU がベクトルを読み込む帯域そのものが律速になります。1 件あたりのバイト数が小さいほど、同じ時間でより多くの候補を評価できます。
つまり 3072 次元は「いちばん良い数字」ではあっても、「いつも最適な数字」ではありません。問題は、品質を落とさずにどこまで削れるか、です。
なぜ前方の次元だけで足りるのか — Matryoshka 表現
ふつうの埋め込みモデルでベクトルを途中で切ると、残った部分は意味を成しません。情報が全次元に均等に散らばっているため、後半を捨てると前半だけでは何も復元できないのです。
gemini-embedding-001 が違うのは、Matryoshka Representation Learning(MRL、入れ子人形のような表現学習)で訓練されている点です。学習時にモデルは、全 3072 次元だけでなく、先頭 768・1024・1536・2048 次元といった「途中で切ったベクトル」でも良い成績を出すように罰則を受けます。その結果、最も重要な意味情報がベクトルの前方に集まるように仕上がります。入れ子人形の一番外側だけを見ても全体の形が分かるのと同じ発想です。
これは公開ベンチマークにも表れています。MTEB の総合スコアは 3072 次元と 1536 次元がともに 68.17、768 次元でも 67.99 で、768 まで削っても差はごくわずかです。私自身、レビュー分類のタスクで 3072 と 768 を比較しましたが、クラスタの分かれ方に実用上の違いは感じませんでした。
ここで宮大工だった祖父のことを思い出します。祖父は「手を動かすことが一つの信心だ」と言う人でしたが、同時に、削るべきところを見極める目を何より大切にしていました。次元を削るのは、雑にするのとは正反対の作業です。何が本質で、どこからが飾りなのかを見極める。Matryoshka はその見極めを、モデルの側があらかじめ済ませてくれている仕組みだと私は理解しています。
なお、2026 年 3 月にプレビュー公開された後継の gemini-embedding-2-preview も同じ MRL の性質を持ちます。本記事は本番で長く使える安定版として gemini-embedding-001 を軸に書きますが、次元を削る設計の考え方はそのまま後継モデルにも引き継げます。
output_dimensionality で実際に切り詰める
切り詰めは、API のパラメータひとつで指定できます。output_dimensionality に欲しい次元数を渡すだけです。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
def embed_3072(text: str) -> list[float]:
"""既定の 3072 次元をそのまま受け取る。"""
res = client.models.embed_content(
model="gemini-embedding-001",
contents=text,
config=types.EmbedContentConfig(task_type="RETRIEVAL_DOCUMENT"),
)
return res.embeddings[0].values # 長さ 3072、すでに正規化済み
def embed_768(text: str) -> list[float]:
"""先頭 768 次元だけを受け取る。"""
res = client.models.embed_content(
model="gemini-embedding-001",
contents=text,
config=types.EmbedContentConfig(
task_type="RETRIEVAL_DOCUMENT",
output_dimensionality=768,
),
)
return res.embeddings[0].values # 長さ 768、ただし正規化されていない
ここまでは簡単です。output_dimensionality=768 を渡せば、長さ 768 のベクトルが返ってきます。問題はこの先です。上のコードのコメントにすでに書きましたが、768 次元のベクトルは正規化されていません。これを知らずに使うと、検索結果が静かに崩れます。
768 次元では必ず再正規化する — ここを忘れると検索が静かに壊れる
gemini-embedding-001 の仕様で、3072 次元の出力だけが正規化済み(ノルムが 1)で返り、それ以外の次元は正規化されていません。前方を切り出したベクトルは、長さ(L2 ノルム)がばらばらの状態で返ってくるのです。
なぜこれが問題になるのでしょうか。多くのベクトルDBは既定でコサイン類似度ではなく内積(ドット積)で距離を計算します。正規化済みのベクトル同士なら、内積とコサイン類似度は一致します。ところが正規化されていないベクトルで内積を取ると、ノルムの大きいベクトルが内容に関係なく上位に来てしまうのです。本来は無関係なはずの文書が、たまたまノルムが大きいというだけで検索上位に居座る。これがエラーも警告もなく起きるため、原因の特定が非常に厄介です。
対処はシンプルで、切り詰めたベクトルは必ず L2 正規化してから保存・検索します。
import numpy as np
def l2_normalize(vec: list[float]) -> list[float]:
"""L2 ノルムを 1 に揃える。次元を切り詰めたベクトルには必須。"""
arr = np.asarray(vec, dtype=np.float32)
norm = np.linalg.norm(arr)
if norm == 0.0:
return arr.tolist() # ゼロベクトルはそのまま返す
return (arr / norm).tolist()
def embed_768_normalized(text: str) -> list[float]:
res = client.models.embed_content(
model="gemini-embedding-001",
contents=text,
config=types.EmbedContentConfig(
task_type="RETRIEVAL_DOCUMENT",
output_dimensionality=768,
),
)
return l2_normalize(res.embeddings[0].values)
私はこの一手を抜かして、検索結果が「なんとなくおかしい」状態を半日ほど追いかけたことがあります。コードは正常に動き、例外も出ず、それらしい結果が返ってくる。だからこそ気づきにくいのです。次元を切り詰めたら必ず再正規化する。これを保存側とクエリ側の両方で、同じ関数を通して徹底するのが、唯一にして確実な防ぎ方です。
ひとつ運用上の注意を加えると、保存時に正規化したのにクエリ時に正規化を忘れる、あるいはその逆、という非対称が一番起きやすい事故です。後述する抽象化層で、埋め込みの入口を一本化しておくと、この非対称を構造的に防げます。
768・1536・3072 のどれを選ぶか
次元の選択は、精度・コスト・レイテンシの三角形のどこに重心を置くかの判断です。私が実際に使っている目安を共有します。
768 次元は、件数が多く、検索が「だいたい近いものをすくい上げる」性質のタスクに向きます。私の場合、ストアレビューの意味クラスタリングや、壁紙の見た目の近さによる分類はこれで十分でした。ベンチマーク上の精度差がごくわずかである一方、ストレージとレイテンシの恩恵が最も大きいからです。
1536 次元は、精度をもう一段ほしいが 3072 はやり過ぎ、という中間の選択です。ストレージは 3072 の半分で、MRL のおかげで精度はほぼ 3072 に並びます。迷ったらここから始めるのが無難だと考えています。
3072 次元は、検索結果が事業の中核を担い、わずかな精度差が体験に直結する場面に取っておきます。たとえば有料機能としてのドキュメント検索や、誤検索が信頼を損なう領域です。あるいは、後述する二段検索の「精緻化」段だけに使う、という割り切り方もあります。
コストの目安を一枚にまとめると、30 万件・float32 で次のようになります。
- 3072 次元:1 件 12 KB、合計 約 3.5 GB
- 1536 次元:1 件 6 KB、合計 約 1.8 GB
- 768 次元:1 件 3 KB、合計 約 0.9 GB
この差が、マネージドDBの月額にほぼ線形で効いてきます。件数が増えるほど、最初の次元選択の重みが増していきます。
既存インデックスを止めずに次元を移す
すでに 3072 次元で運用しているインデックスを 768 へ移す場合、モデルそのものを変えるわけではないので空間は共有されています。とはいえ、3072 のベクトルと 768 のベクトルを同じインデックスで混ぜて検索することはできません。次元数が違うからです。したがって移行は、新しい次元用のインデックスを別に立て、背景で作り直し、品質を確認してから読み取り先を切り替える、という段取りになります。
埋め込みモデルそのものを乗り換える場合の無停止リインデックスは、別記事のGemini Embedding モデルを無停止で切り替えるリインデックス設計で詳しく扱いました。次元の切り替えも考え方は共通で、二重書き込みとシャドー検証の枠組みをそのまま使えます。ここでは、次元固有のポイントだけを補足します。
再生成のジョブでは、各ベクトルにどの次元で作ったかを必ず記録しておきます。
def build_record(doc_id: str, text: str, dim: int) -> dict:
res = client.models.embed_content(
model="gemini-embedding-001",
contents=text,
config=types.EmbedContentConfig(
task_type="RETRIEVAL_DOCUMENT",
output_dimensionality=dim,
),
)
vec = res.embeddings[0].values
if dim != 3072:
vec = l2_normalize(vec) # 3072 以外は再正規化
return {
"id": doc_id,
"embedding": vec,
"embedding_dim": dim, # 次元をメタデータに刻む
"embedding_model": "gemini-embedding-001",
}
embedding_dim をレコードに持たせておくと、移行の途中で「このベクトルは新旧どちらの次元か」を一目で判定でき、二重書き込みの取りこぼしも検知できます。地味ですが、移行を安全にするのはこうした一行のメタデータです。
768 で粗く絞り、3072 で精緻化する二段検索
次元を削るとコストは下がりますが、精度を一切妥協したくない場面もあります。そこで私が好んで使うのが、粗密二段の検索(coarse-to-fine retrieval)です。Matryoshka の性質を最も生かせる設計だと考えています。
考え方はこうです。まず 768 次元のインデックスで広めに候補を集めます。ここは速くて軽い。次に、その候補だけを 3072 次元のベクトルで並べ替えます。全件を 3072 で検索するのに比べ、精緻化の対象が数十件に絞られているため、コストもレイテンシもごくわずかで済みます。
def two_stage_search(query: str, coarse_index, fine_store, top_k: int = 10):
"""768 で広く拾い、3072 で精緻に並べ替える。"""
# 第1段: 768 次元で粗く 100 件まで絞る
q768 = embed_768_normalized(query)
candidates = coarse_index.search(q768, top_k=100) # [(id, _score768), ...]
# 第2段: 候補だけを 3072 次元で並べ替える
q3072 = embed_query_3072(query) # クエリ用 3072 ベクトル(正規化済み)
rescored = []
for doc_id, _ in candidates:
v3072 = fine_store.get_vector(doc_id) # 候補の 3072 ベクトルを取得
score = float(np.dot(q3072, v3072)) # 正規化済みなので内積=コサイン
rescored.append((doc_id, score))
rescored.sort(key=lambda x: x[1], reverse=True)
return rescored[:top_k]
この構成なら、巨大な 3072 インデックス全体をメモリに常駐させる必要がありません。768 のインデックスだけを高速な層に置き、3072 のベクトルは安価なストレージから候補ぶんだけ引いてくればよいのです。「全部を高精度で持つ」から「大半は軽く、要所だけ重く」へという発想の転換が、コストと精度の両立を可能にします。
クエリ用の 3072 ベクトル embed_query_3072 は、task_type="RETRIEVAL_QUERY" を指定して作る点だけ補足しておきます。ドキュメント側とクエリ側で task_type を使い分けるのは、次元の話とは独立した、検索精度の基本です。
私が現時点で選んでいる構成
最後に、いま私の 6 サイトと複数のアプリで実際に動いている構成を、判断の根拠とともに残しておきます。
件数が多く精度の許容範囲が広いタスク(レビュー分類・壁紙分類)は、768 次元の単段検索です。再正規化を埋め込み関数の中に閉じ込め、保存とクエリで同じ関数を必ず通す。これで非対称の事故は構造的に起きません。
検索の質が体験に直結する有料寄りの機能は、768 で粗く絞り 3072 で精緻化する二段検索にしています。全件 3072 と比べてメモリ常駐量を大きく減らせる一方、ユーザーが触れる検索品質はほとんど変わりません。
次元の選択に唯一の正解はありません。けれども「とりあえず既定の 3072」で積み上げ続けると、件数が増えた将来の自分が、静かに膨らんだ請求書を前に頭を抱えることになります。Matryoshka が前方に意味を寄せてくれている以上、その恩恵を受け取らない手はない、というのが今の私の結論です。
まずは手元のタスクで 768 と 3072 を同じデータで比較し、検索結果がどれだけ変わるかを自分の目で確かめてみてください。多くの場合、その差の小ささに少し驚くはずです。お読みいただきありがとうございました。