300件目を過ぎたあたりで、INVALID_ARGUMENT が返り始めました。壁紙の説明文をベクトル化して、カテゴリを自動で振り分ける処理です。数十件で試していた間は何も起きなかったので、まずはデータ側に変な行が混ざったのだろうと疑いました。
原因はもっと手前にありました。呼び出し方の誤りではなく、インデックスを作ったときの設定と、あとから変えた設定の食い違いでした。個人開発で自分のアプリのバックエンドを触っていると、こういう「数週間前の自分の決定」に足を取られる場面が繰り返し訪れます。
分類機能が安定して回るまでに詰まったのは3か所でした。次元の扱い、レートリミット、そして精度。どれもエラーメッセージが素直に原因を教えてくれない種類のもので、手を動かした順にそのまま記録しておきます。
INVALID_ARGUMENT — 次元は後から回せるダイヤルではない
埋め込みの次元数は、モデルによって「固定」か「選べる」かが違います。text-embedding-004 は 768 次元固定で、gemini-embedding-001 のように output_dimensionality で 3072 / 1536 / 768 から選べる系統もあります。この差を意識せずに書き換えたのが最初のつまずきでした。
私が実際にやったのは、gemini-embedding-001 の 3072 次元でインデックスを作ったあと、コストを下げたくてモデルを text-embedding-004 に差し替えるという変更です。ここで2種類の失敗が同時に起きました。モデルが受け付けない次元を渡した呼び出しは API 側で弾かれ、既存インデックスと次元が合わないベクトルはベクトルストア側で弾かれます。どちらも「引数が違う」に近い文言で返ってくるため、最初はひとつの問題だと思い込んで30分ほど公式ドキュメントを往復しました。
切り分けは、エラーがどこから来たかを先に確定させるのが早道です。
| 症状 | 出どころ | 最初に確認すること |
|---|---|---|
INVALID_ARGUMENT がベクトル取得の時点で出る | Gemini API | そのモデルが指定した output_dimensionality を許容しているか |
| ベクトルは取れるが追加・検索で落ちる | ベクトルストア(ChromaDB 等) | コレクション作成時の次元と、いま渡しているベクトルの長さ |
| エラーは出ないのに検索結果が無関係 | 設定の不一致 | クエリ側とドキュメント側で task_type が揃っているか |
対処自体は単純で、モデルと次元をインデックスと一緒に固定してしまうことです。私はコード上部に定数として置き、埋め込みを作る箇所からはそれ以外の値を使えないようにしました。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
# インデックスと一緒に固定する2つ。ここを触るときは再生成を覚悟する
EMBED_MODEL = "gemini-embedding-001"
EMBED_DIM = 768
# ❌ 固定次元のモデルに別の次元を渡すと INVALID_ARGUMENT
resp = client.models.embed_content(
model="text-embedding-004", # 768 次元固定
contents="美しい山の景色",
config=types.EmbedContentConfig(output_dimensionality=3072),
)
# ✅ 次元を選べるモデルで、インデックス作成時と同じ値を使い回す
resp = client.models.embed_content(
model=EMBED_MODEL,
contents="美しい山の景色",
config=types.EmbedContentConfig(
output_dimensionality=EMBED_DIM,
task_type="RETRIEVAL_DOCUMENT",
),
)
vector = resp.embeddings[0].values次元の選び方について補足しますと、768 次元と 3072 次元の検索精度の差は、個人開発規模の件数では体感しにくいほど小さいことがあります。壁紙アプリでは 768 次元で再構築し、週末の深夜にバッチを流して入れ替えました。ストレージと転送量が素直に減るので、迷うなら小さい側から始めるほうが後戻りしやすいと感じております。
次元を切り詰めたら、ノルムを揃え直す
output_dimensionality で小さい次元を指定できるモデルは、内部的に長いベクトルの前方を使う設計になっています。ここで見落としがちなのが、切り詰めたベクトルは長さ(L2 ノルム)が 1 から外れるという点です。
影響が出るかどうかは、ベクトルストアの距離関数で分かれます。コサイン距離で検索している限り、計算の中で正規化されるため順位は変わりません。内積(inner product)や L2 距離を選んでいる場合は、ノルムの差がそのまま類似度の差として効いてしまいます。私自身、ChromaDB の距離設定を意識せずに次元を落としたため、「精度が落ちた気がする」という曖昧な状態を数日引きずりました。
再生成のバッチに、正規化を1関数はさむだけで済みます。
import numpy as np
def l2_normalize(vec: list[float]) -> list[float]:
"""切り詰めた埋め込みを単位ベクトルに揃える"""
arr = np.asarray(vec, dtype=np.float32)
norm = float(np.linalg.norm(arr))
if norm == 0.0:
return arr.tolist()
return (arr / norm).tolist()
# 手元で確認したいとき: 3072 のままなら 1.0 前後、切り詰めると 1.0 から外れる
raw = resp.embeddings[0].values
print(round(float(np.linalg.norm(raw)), 4))
print(round(float(np.linalg.norm(l2_normalize(raw))), 4)) # 1.0正規化を入れるか入れないかを検索側と格納側で揃えることも忘れないでください。片側だけ正規化した状態は、距離関数が内積のときに最も分かりにくい形で順位を崩します。
RESOURCE_EXHAUSTED(429)— 400件目で落ちて、最初からやり直した
説明文 500 件をまとめて処理しようとして、RESOURCE_EXHAUSTED が続きました。最初は 50ms のインターバルで足りると思っていたのですが、無料枠では1分あたりの上限にすぐ届きます。
指数バックオフを入れると通るようになりました。ただ、当時の実装には別の弱点がありました。400 件目で例外が上まで抜けて、それまでのベクトルがメモリごと消えたのです。もう一度 400 件を投げ直す羽目になり、そこで初めて「途中結果を落とさない」ことを設計に入れました。
いま使っているのは、フルジッター付きのバックオフ・同時実行数の上限・1行ずつ追記するチェックポイントを組み合わせた形です。
import asyncio
import json
import random
from pathlib import Path
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
CHECKPOINT = Path("embed_checkpoint.jsonl")
MAX_CONCURRENCY = 4
def load_done_ids() -> set[str]:
if not CHECKPOINT.exists():
return set()
return {
json.loads(line)["id"]
for line in CHECKPOINT.read_text(encoding="utf-8").splitlines()
if line.strip()
}
async def embed_one(item: dict, sem: asyncio.Semaphore, max_retries: int = 5) -> dict | None:
async with sem:
for attempt in range(max_retries):
try:
resp = await client.aio.models.embed_content(
model=EMBED_MODEL,
contents=item["text"],
config=types.EmbedContentConfig(
output_dimensionality=EMBED_DIM,
task_type="RETRIEVAL_DOCUMENT",
),
)
vec = l2_normalize(resp.embeddings[0].values)
return {"id": item["id"], "vector": vec}
except Exception as e:
msg = str(e)
if "429" in msg or "RESOURCE_EXHAUSTED" in msg:
# フルジッター: 0〜2^attempt 秒の一様乱数。同時再開の波を崩す
await asyncio.sleep(random.uniform(0, 2 ** attempt))
continue
raise
return None
async def embed_all(items: list[dict]) -> int:
done = load_done_ids()
todo = [i for i in items if i["id"] not in done]
sem = asyncio.Semaphore(MAX_CONCURRENCY)
written = 0
with CHECKPOINT.open("a", encoding="utf-8") as f:
tasks = [embed_one(i, sem) for i in todo]
for coro in asyncio.as_completed(tasks):
rec = await coro
if rec is None:
continue
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
f.flush() # 落ちた瞬間までの分を残す
written += 1
return writtenf.flush() を毎行入れているのは効率が悪く見えますが、バッチが落ちる瞬間はバッファに残った分がそのまま消えます。数百件規模なら書き込みコストは無視できるので、ここは安全側に倒しています。
所要時間の見積もりも、この形にしてから素直になりました。直列で1秒間隔なら、512 件はスリープだけで 512 秒。API の応答時間を足せば10分近くかかります。同時実行を4にすれば理論上はその4分の1前後で、実際には 429 のバックオフ待ちが入るため3〜4分程度に収まる、という感覚です。上限を上げすぎると 429 の頻度が増えて逆に遅くなるため、私は4から始めて必要なら6まで、という運用にしています。
失敗したまま残った ID は、チェックポイントに書かれていない ID として次回の実行で自動的に拾われます。再実行が冪等になるだけで、深夜バッチを仕掛ける心理的なハードルがかなり下がりました。
task_type の指定漏れで、検索結果が噛み合わない
エラーが消えたあとも、「自然な山の風景」で検索して無関係な壁紙が上位に来る状態が残りました。原因は task_type の指定漏れでした。
Gemini の埋め込みモデルは、用途に応じて異なるベクトル空間を使います。検索クエリと格納するドキュメントで別の task_type を渡さないと、汎用の空間に落ちて精度が出ません。
# ❌ task_type 未指定 — 汎用のベクトル空間になる
resp = client.models.embed_content(
model=EMBED_MODEL,
contents="ナチュラルな壁紙を探しています",
config=types.EmbedContentConfig(output_dimensionality=EMBED_DIM),
)
# ✅ 検索クエリ側は RETRIEVAL_QUERY
query = client.models.embed_content(
model=EMBED_MODEL,
contents="ナチュラルな壁紙を探しています",
config=types.EmbedContentConfig(
output_dimensionality=EMBED_DIM,
task_type="RETRIEVAL_QUERY",
),
)
# ✅ 格納するドキュメント側は RETRIEVAL_DOCUMENT
doc = client.models.embed_content(
model=EMBED_MODEL,
contents="緑の葉と木漏れ日が差し込む自然の景色",
config=types.EmbedContentConfig(
output_dimensionality=EMBED_DIM,
task_type="RETRIEVAL_DOCUMENT",
),
)この修正のありがたい性質は、まずクエリ側だけ変えて効果を確認できる点です。ドキュメント側の再生成はインデックス全体の作り直しになるので、クエリ側で改善の兆しを掴んでから踏み込む順番が現実的でした。
精度が上がったかどうかを、体感でなく数字で見る
task_type を直したときに私が最初にやったのは、検索窓に思いつく言葉を打ち込んで「良くなった気がする」と判断することでした。数日後に別の変更を入れたとき、どちらが効いたのか説明できなくなり、20問の小さな正解セットを作りました。
やっていることは単純で、クエリと「これは上位に来てほしい」壁紙 ID を手で並べ、recall@5 を測るだけです。
import numpy as np
# 手で作る正解セット。20問あれば変更の向きは十分に見える
GOLD = [
{"query": "自然の風景", "relevant": ["w-102", "w-118", "w-233"]},
{"query": "夜の街の明かり", "relevant": ["w-045", "w-291"]},
# ... 20問程度まで
]
def search(query_vec: list[float], index: dict[str, list[float]], k: int = 5) -> list[str]:
q = np.asarray(query_vec, dtype=np.float32)
scored = [
(doc_id, float(np.dot(q, np.asarray(vec, dtype=np.float32))))
for doc_id, vec in index.items()
]
scored.sort(key=lambda x: x[1], reverse=True)
return [doc_id for doc_id, _ in scored[:k]]
def recall_at_k(index: dict[str, list[float]], embed_query, k: int = 5) -> float:
scores = []
for case in GOLD:
ranked = search(embed_query(case["query"]), index, k)
hit = len(set(ranked) & set(case["relevant"]))
scores.append(hit / len(case["relevant"]))
return sum(scores) / len(scores)正規化済みのベクトルを入れているので、内積がそのままコサイン類似度になります。この計測台の上で task_type の有無を比べると、手元の20問では recall@5 が 0.6 台から 0.7 台後半へ動きました。母数が小さいので値そのものは参考程度ですが、「変更を戻すと下がる」ことを確認できるだけで判断が速くなります。
15〜20% 改善したと書けるようになったのは、この20問を用意してからでした。それ以前は改善を語る根拠が自分の記憶しかなく、次の一手を決められませんでした。計測台を作る手間は1時間ほどで、そのあとの意思決定にかかる時間をずっと節約してくれています。
モデル選定は「インデックスと一緒に固定する」判断
実験的な埋め込みモデルは、ベンチマーク上の数字が魅力的に見えます。私は本番では安定側のモデルを使い続けています。理由は精度の差ではなく、切り替えのコストが呼び出しコードの1行では終わらないからです。
モデルを変えるとベクトル空間が変わるため、既存のドキュメントを全件再生成してインデックスを作り直すことになります。次元の変更と同じ構造の判断です。500 件なら深夜の数分で済みますが、数万件になると再生成のコストと、その間の検索品質をどう保つかまで考える必要が出てきます。
埋め込みモデルの系統は動きが速く、File Search のような機能では新しい埋め込み系統が使われるようになっています。モデル ID と対応する次元、料金は変わりやすいので、実装前に一次情報で確認する前提を置いておくと安全です。私はこの判断を、モデル ID と次元を書いた定数ブロック1か所に閉じ込める形で運用しています。そこを触るときだけ再生成の段取りを考える、という切り分けです。
# 壁紙アプリのバックエンドで固定している設定(参考)
EMBEDDING_CONFIG = {
"model": EMBED_MODEL, # 変えるならインデックス再生成
"output_dimensionality": EMBED_DIM, # 同上
"normalize": True, # 切り詰めたベクトルは正規化して格納
"query_task_type": "RETRIEVAL_QUERY",
"doc_task_type": "RETRIEVAL_DOCUMENT",
"max_concurrency": 4, # 429 が増えたら下げる
"max_retries": 5, # フルジッター付き
}精度をもう一段上げるために効いた2つ
task_type を直したあと、計測台の上で試して残ったものが2つあります。
ひとつは、カテゴリ名だけでなく説明文を一緒に埋め込むことです。「自然」という単語より、「山・森・湖・空など自然の景色を収録した壁紙」という文のほうが、実際の検索語との類似度が安定しました。
もうひとつは、検索クエリの前処理です。ユーザーが打つ語は短く揺れが大きいため、そのまま埋め込むより、Gemini API で短い説明文に展開してから埋め込むほうが順位が落ち着きました。呼び出しが1回増えるので、体感速度と精度のどちらを取るかは検索画面の性格によります。壁紙の検索は数百ミリ秒の遅れが許される画面だったので、精度側を選びました。
まず動かすなら task_type から
いま同じところで止まっているなら、クエリ側に RETRIEVAL_QUERY を渡す変更が最も安く済みます。インデックスの作り直しが要らず、数分で戻せます。効果が見えたら、20問の正解セットを作って次の変更に進む。この順番だと、判断のたびに手戻りが減ります。
次元とモデルは最初に固定し、切り詰めたら正規化を揃え、バッチにはチェックポイントを持たせる。この3つを置いてから、Embedding まわりで夜中に手を止めることはなくなりました。同じところで止まっている方の時間が少し戻れば、この記録を残した意味があります。