ある朝、Cloudflare のダッシュボードを開いたら、壁紙アプリ用に運用していたベクトルストレージの使用量が想定の 3 倍になっていました。「似てる壁紙」を順に並べる機能のためだけに、ベクトルが 2GB 近くを占めていたのです。gemini-embedding-001 を 768 次元のまま素直に使い続けていたのが理由でした。
私は 2014 年から個人で iPhone・Android アプリを開発していて、累計 5,000 万ダウンロードを超える壁紙・癒し系アプリを運営しています。アクティブなメタデータは 80,000 件ほどで、それを gemini-embedding-001 で全部ベクトル化していました。出力次元を 768 から 256 に切り詰めた経緯と、精度・ストレージ・コストの実数値、さらに本番に出すまでに踏んだ手順と落とし穴を、後追いできる形で書き残しておきます。
80,000 件のベクトルが 2GB を食い始めた朝
ベクトルそのものは 80,000 件 × 768 次元 × 4 バイト(float32)= 約 240MB です。それだけなら問題はないのですが、実際の SQLite + sqlite-vec の運用では、インデックス、メタデータの JSON、HNSW のグラフ構造を含めて 2GB 弱になっていました。Cloudflare D1 のストレージ料金は数十ドル単位ですが、月額がじわじわ上がっている事実は気持ち悪いものです。AdMob から入ってくる広告収益で運用しているアプリなので、固定費は素直に削っておきたい類のコストでした。
公式ドキュメントには output_dimensionality パラメータで 128 / 256 / 512 / 768 を指定できると書かれていますが、それぞれで精度がどれくらい落ちるかは書かれていません。「実際にどこで切り詰めるのが現実的か」は、自分のデータで測ってみないと分かりませんでした。
Matryoshka 表現の「先頭だけ切り取れる」性質
Gemini の embedding モデルは、Matryoshka representation という方式で訓練されています。やや乱暴に言えば「ベクトルの先頭 N 次元だけを切り出しても、意味の方向はおおむね保たれる」ように設計されているのです。output_dimensionality=256 を指定すると、モデル内部で 768 次元を計算したあと、先頭 256 次元を返してくれます。
宮大工だった祖父のことを時々思い出します。木材を一度大きく挽き出してから、用途に合わせて段階的に削って整えるという考え方は、Matryoshka の発想とよく似ています。1 回の埋め込み計算から 256 / 512 / 768 と段階的に取り出して使えるという設計思想は、丁寧に作られた仕組みだと感じます。
運用上の意味としては、用途ごとに別の埋め込みを生成する必要がないことです。例えば「高速な一次絞り込み」では先頭 128 次元、「メインの検索」では 256 次元、「厳密な再ランキング」では 768 次元、というふうに、同じ列のベクトルから読み出す長さだけを切り替えれば済みます。
768 → 256 に切り詰めた実装
切り詰めること自体は、EmbedContentConfig に output_dimensionality を追加するだけです。
# 壁紙メタデータを 256 次元の embedding に変換するシンプルな関数です
from google import genai
from google.genai import types
import numpy as np
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
def embed_text(text: str, dim: int = 256) -> list[float]:
"""指定された次元数のセマンティック埋め込みを返します。"""
result = client.models.embed_content(
model="gemini-embedding-001",
contents=text,
config=types.EmbedContentConfig(
output_dimensionality=dim,
task_type="SEMANTIC_SIMILARITY",
),
)
return result.embeddings[0].values
# L2 正規化(cosine 類似度を使う場合は必須に近い前処理です)
def l2_normalize(v: list[float]) -> list[float]:
arr = np.array(v, dtype=np.float32)
norm = np.linalg.norm(arr)
if norm == 0:
return v
return (arr / norm).tolist()
vec = l2_normalize(embed_text("夜空に咲く青い桜のイラスト"))
print(len(vec)) # 期待される出力: 256
print(vec[:3]) # 期待される出力: 256 個の浮動小数の先頭 3 つ
最初に 768 で取って後から vec[:256] で切り詰めるのも理屈の上では同じ結果になりますが、API のレスポンス転送量と JSON のパース時間が無駄になるので、最初から output_dimensionality=256 で取得する方を私は選んでいます。
ひとつ補足しておくと、Gemini Embedding の課金は入力トークン数ベースで、出力次元数では変わりません。256 を選んでも 768 を選んでも API 料金は同じです。コストの削減効果は、ストレージ・ベクトル DB のインデックス・サービス間でベクトルを転送するときの egress、この 3 か所で出ます。
精度の落ち方は思ったよりも穏やかでした
実際に同じ 80,000 件のメタデータで、検索クエリ 200 件分の recall@10 を測ってみました。「ユーザーがよく入力した検索語」をクエリとして使い、768 次元での結果を正解集合と仮定したうえで、各次元での recall を比べた数値です。
- 768 次元: recall@10 = 0.985(基準値)
- 512 次元: recall@10 = 0.978(-0.7 ポイント)
- 256 次元: recall@10 = 0.972(-1.3 ポイント)
- 128 次元: recall@10 = 0.948(-3.7 ポイント)
数値だけ見ると 256 次元と 128 次元の差は小さく見えるかもしれませんが、実際にアプリで「似てる壁紙」の並びを目視すると、128 次元では短文タイトルの壁紙ほどジャンルが混ざる傾向がありました。たぶん、短いテキストほど先頭次元に情報を強く詰め込まなければならず、128 次元まで削るとその余裕がなくなるのでしょう。
私の場合、256 次元はユーザーが体感する違いがほぼなく、768 次元と並べてもどちらがどちらか分からない程度でした。一方で 128 次元は、自分でレビューした時点で「あれ、これは違うジャンルだな」と気づくサンプルが出始めました。個人開発のサービスで、自分が一目で気づくくらいの精度差なら採用しない、というのが私のラインです。
念のため書いておくと、上の recall は 768 次元の結果を正解と仮定した相対値です。別に 80 件だけ手作業で「ユーザー視点で本当に似ているか」をラベル付けして同じ計測をしたところ、256 次元は 768 次元との差が 1.5 ポイント以内、128 次元は約 4 ポイント下、という同じ方向の結果になりました。自動指標だけで判断せず、必ず数十件は手で見る、という運用は捨てない方が良いと感じます。
推奨次元の判断マトリクス
実プロジェクトで「結局、何次元にすればいいのか」と聞かれることが多いので、私が個人開発で使っている判断マトリクスをそのまま置いておきます。
- ベクトル数 1 万件未満 / 軽量検索のみ → 256 次元で開始、必要に応じて 512 へ
- ベクトル数 1 万〜10 万件 / 推薦・関連表示 → 256 次元(推奨) + float16
- ベクトル数 10 万〜100 万件 / 高精度な検索が必要 → 256 次元 + 厳密な再ランキングのみ 768 を使う
- ベクトル数 100 万件超 / コスト最優先 → 256 次元 + int8 量子化
- 法律・医療など正解が一意で精度が最重要 → 768 次元のまま運用し、ストレージは pgvector など効率的な DB で吸収する
短いテキスト(タイトル、タグ、検索キーワードなど)を多く埋め込む場合は、上記より一段大きい次元を選ぶ方が無難です。私の壁紙アプリでは「短文しかない」ためにこの選び方を少し保守的にしています。
ストレージ設計で効いた小さな工夫
次元を 256 に落とすだけで、ベクトル本体は 80,000 × 256 × 4 = 約 80MB まで小さくなります。インデックスやメタデータを含めても 700MB 前後で、月額のストレージ料金は約 3 分の 1 に下がりました。さらに踏み込むなら、以下の 2 つは個人開発で効いた工夫です。
float32 → float16 量子化は、numpy 側で型を変えるだけで、ベクトル本体が半分になります。検索時に必要なら float32 に戻します。
# float16 量子化(推奨:ほぼ無損失でストレージが半分になります)
import numpy as np
def to_float16_blob(vec_f32: np.ndarray) -> bytes:
"""float32 のベクトルを float16 のバイト列にして保存可能な形にします。"""
return vec_f32.astype(np.float16).tobytes()
def from_float16_blob(blob: bytes, dim: int = 256) -> np.ndarray:
"""検索時に float16 のバイト列から float32 のベクトルへ戻します。"""
return np.frombuffer(blob, dtype=np.float16, count=dim).astype(np.float32)
# 使用例
v = np.random.randn(256).astype(np.float32)
v /= np.linalg.norm(v) # L2 正規化済みであることが前提
blob = to_float16_blob(v) # 期待される出力サイズ: 512 バイト(256 × 2)
restored = from_float16_blob(blob)
print(np.dot(v, restored)) # 期待される出力: 1.0 にほぼ等しい数値
もう一段踏み込んで int8 量子化までやると、ストレージはさらに 4 分の 1 になります。ただしスケール係数の保存を忘れると ranking が壊れます。私は最初これで一度ハマりました。
# int8 量子化(要:スケール係数の保存)
import numpy as np
def quantize_int8(vec_f32: np.ndarray) -> tuple[bytes, float]:
"""L2 正規化済みのベクトル前提で int8 にスケーリングします。"""
scale = float(np.max(np.abs(vec_f32)))
if scale == 0.0:
return b"\x00" * len(vec_f32), 1.0
quantized = np.clip(np.round(vec_f32 / scale * 127), -127, 127).astype(np.int8)
return quantized.tobytes(), scale
def dequantize_int8(blob: bytes, scale: float, dim: int = 256) -> np.ndarray:
"""検索時に int8 のバイト列から float32 のベクトルへ戻します。"""
arr = np.frombuffer(blob, dtype=np.int8, count=dim).astype(np.float32)
return arr * (scale / 127.0)
# 使用例
v = np.random.randn(256).astype(np.float32)
v /= np.linalg.norm(v)
blob, scale = quantize_int8(v) # blob は 256 バイト、scale は浮動小数で別カラムに保存
restored = dequantize_int8(blob, scale)
print(np.dot(v, restored)) # 期待される出力: 0.99 を超える数値(ほぼ無損失)
この実装で recall@10 はさらに 0.005 ポイント程度しか落ちず、私のアプリでは目視レビューでも違いが分かりませんでした。スケール係数はベクトルと同じ行の vec_scale REAL 列に置いています。一見、たった 1 つの float ですが、これがないと再構成時に方向は合っても大きさが揃わず、dot 積の順位が崩れます。
移行を進めるときの 5 ステップ
「いきなり全件を 256 次元に作り直す」は失敗の典型で、私は最初の壁紙アプリで一度やって戻せなくなりました。次に出すアプリでは、以下の順序で慎重に進めるようになりました。
- 新カラムで両立期間を作る:
embedding_768(既存)と embedding_256(新規)を同じ行に並べて持つ。クエリ側はフラグで切り替えできるようにしておきます
- 新規挿入だけ 256 次元にする: 既存データはまだ触らず、これから入る新メタデータだけを
output_dimensionality=256 で書き込みます。1 〜 2 日運用して、書き込みのコードパスとフォーマットを安定させてから次へ進みます
- 読み取りの A/B 比較: 同じクエリで 768 と 256 の両方を検索し、Top10 の差分をログに残します。本番ユーザーには 768 の結果を返しつつ、私の手元で 256 の結果を毎日ざっと目視レビューします
- 既存データの再埋め込み: 一晩〜数日かけて全件を 256 で埋め直します。ここで API のレートリミットを必ず gentle に。指数バックオフを入れない夜間バッチで一度 429 の連鎖に遭いました
- 読み取り経路を切り替えてから旧カラムを落とす: クエリを 256 側に向ける → 数日様子を見る → CTR や離脱率が悪化していないことを確認してから
embedding_768 を DROP します。DROP を最後に置くのが本当に大事です
この手順で私は本番ロールアウト中の事故をゼロに抑えられました。一気にやろうとせず、必ず「戻せる状態」を経由します。
本番に出す前に走らせている 60 秒ベンチマークゲート
埋め込みの次元や量子化を変えるたびに、私は CI でこの簡単なベンチマークを走らせています。固定クエリと期待される Top3 を持っておき、ランキングが想定の閾値より崩れたら CI を落とします。
# tests/embedding_gate.py — CI で 1 分以内に終わる軽いゲート
import numpy as np
GOLDEN = [
("夜空に咲く青い桜", ["sakura_night_blue_001", "sakura_blue_pixel_002", "starry_night_sakura_003"]),
("淡い水彩のひまわり", ["sunflower_watercolor_010", "sunflower_pale_011", "summer_watercolor_012"]),
("和柄のシンプルな波模様", ["wave_japanese_seigaiha", "wave_japanese_blue", "minimal_wave_indigo"]),
]
RECALL_FLOOR = 0.85 # Top3 のうち期待 ID が何個一致したかで判定します
TOP_N = 3
def assert_embedding_quality(load_index, search) -> None:
"""インデックスと検索関数を渡すと、ゴールデンセットで閾値割れを検知します。"""
index = load_index()
hits = 0
total = 0
for query, expected_ids in GOLDEN:
result_ids = [doc.id for doc in search(index, query, top_n=TOP_N)]
for eid in expected_ids:
if eid in result_ids:
hits += 1
total += 1
recall = hits / total
assert recall >= RECALL_FLOOR, f"embedding gate failed: recall={recall:.3f}"
# 使用例(pytest 経由でも CI でも、新インデックスを load_index に差し替えて呼び出します)
# assert_embedding_quality(load_index=lambda: build_index_v256_int8(), search=ann_search)
たった 3 件のクエリですが、量子化のスケール係数を保存し忘れたバグはこれで一度引っかかりました。「自分のサービスにとって絶対に外せない結果」だけをゴールデンに置く運用が、長く続けるコツです。
個人開発で踏みやすい落とし穴
実際に運用した中で踏んだ、もしくは踏みかけた落とし穴を残しておきます。
vec[:256] で切り詰めるなら、埋め込み生成と検索の両側で同じ長さに揃えること。生成側だけ短くして検索側が 768 のままだと、サイレントに次元エラーが出ない代わりに dot 積の値が壊れます
- L2 正規化は 量子化の前後どちらでも構わないが、片側を忘れて混在すると ranking が崩れます。私はクエリ側で必ず L2 した結果を保存する、と運用ルールを 1 つに固定しました
- int8 量子化のスケール係数を 行ごとに保存しない と、ベクトルごとの大きさの違いが消え、cosine 類似度の順位が崩れます
- ANN ライブラリの多くは内部で float32 を仮定しているため、int8 の bytes をそのまま渡すとコンパイルは通るのに ranking が静かに壊れることがあります。dequantize 関数を必ず通すことを CI で担保します
- 夜間のバックフィル中にレートリミット 429 を食らうと、リトライ実装が無いと一気に数万件分が抜けます。指数バックオフは必ず入れてからバッチを回します
- Cloudflare D1 のように 行数制限がストレージ制限より先に来る バックエンドでは、ベクトル DB を分離する判断を早めにします。私は最終的に大きめのアプリでは pgvector に逃がしました
「Gemini API × sqlite-vec で作る超軽量 RAG」(sqlite-vec のセットアップ)、または規模が大きければ「pgvector で構築するセマンティック検索エンジン」(pgvector の運用)を、用途に応じて使い分けると長期的にずっと楽です。ブラウザ側で完結させたいなら「Gemini Embedding をブラウザに持たせる」(IndexedDB の活用)も選択肢になります。
本記事の Matryoshka の話は、こうした基礎の延長として読むと腑に落ちると思います。
次にやってほしいこと
まずは、現在 768 次元で運用しているベクトルのうち、ランダムに抽出した 1,000 件だけを output_dimensionality=256 で埋め直し、cosine similarity の上位 10 件が現状とどれくらい一致するかを目視で見比べてみてください。1 時間かからない検証で、月額のストレージ請求が 3 分の 1 になる可能性があります。さらにストレージを削りたい場合は、上に置いた float16 → int8 の順で段階的に試すと、戻せる地点を保ったまま進められます。
同じように個人開発でストレージ料金にじわじわ削られている方の役に立てば嬉しいです。お読みいただきありがとうございました。