壁紙アプリのタグ検索を gemini-embedding-2 で作り直したとき、結果が「間違ってはいないけれど、惜しい」外れ方をしました。「夕焼け 海」で引いたのに、上位に来るのは夕焼けの山や、海だけの写真。欲しかった一枚は7番目にいる。精度がゼロなら実装ミスを疑いますが、惜しいときほど原因が見えにくいものです。
半日ほどインデックスの正規化やチャンク設計を見直して、最後に気づいたのが task_type でした。ドキュメントもクエリも task_type を指定せずに埋め込んでいたのです。ここを揃えた瞬間、さきほどの一枚が2番目に上がってきました。
同じ現象は、gemini-embedding-2 が GA になって「テキストも画像も同じモデルで埋め込める」ようになった今、むしろ増えていると感じます。モデルを差し替える機会が増えたぶん、task_type の指定が引き継がれずに落ちるからです。原因と直し方を、私自身が測った差とともに順に整理していきます。
task_type は「何のための埋め込みか」をモデルに伝えている
埋め込みモデルは、渡したテキストをベクトルに変換します。ただ、同じ文でも「検索される側のドキュメント」として使うのか、「検索する側のクエリ」として使うのかで、最適なベクトルの向きは微妙に異なります。
task_type は、その用途をモデルに明示するパラメータです。gemini-embedding-2 では、ドキュメントとクエリで別々に最適化された埋め込みを得られます。指定しないと汎用的な埋め込みになり、検索という非対称なタスク(短いクエリで長いドキュメントを引く)に最適化されません。
ここが直感に反するところです。クエリとドキュメントは同じ空間で比較するのだから、同じ扱いにすべきだと考えてしまいます。実際には、非対称なタスクでは「クエリ用」と「ドキュメント用」を別々に指定したほうが、コサイン類似度の順位が安定します。
よくある間違い:ドキュメントもクエリも task_type なしで埋め込む
最初に私がやっていたのが、これです。インデックス作成時も検索時も、同じ関数で task_type を渡さずに埋め込んでいました。
from google import genai
client = genai.Client() # GEMINI_API_KEY は環境変数から読み込みます
def embed_plain (text: str ) -> list[ float ]:
# task_type を指定していない = 用途不明の汎用埋め込み
resp = client.models.embed_content(
model = "gemini-embedding-2" ,
contents = text,
)
return resp.embeddings[ 0 ].values
# インデックスも検索も同じ関数を使ってしまう
doc_vec = embed_plain( "夕焼けに染まる海辺の風景写真" )
query_vec = embed_plain( "夕焼け 海" )
このコード自体はエラーになりません。だから厄介なのです。検索は動くし、それらしい結果も返ってきます。ただ、非対称タスクに最適化されていないぶん、順位がぼやけます。「精度が出ない」ではなく「惜しい」という症状は、この状態でよく現れます。
正しい実装:ドキュメントとクエリで task_type を分ける
直し方はシンプルです。インデックスに入れる文書は RETRIEVAL_DOCUMENT、検索クエリは RETRIEVAL_QUERY で埋め込みます。次元数(output_dimensionality)はドキュメントとクエリで必ず揃えてください。
from google import genai
from google.genai import types
import numpy as np
client = genai.Client()
DIM = 1536 # ドキュメントとクエリで同じ値にそろえる
def embed_document (text: str ) -> np.ndarray:
resp = client.models.embed_content(
model = "gemini-embedding-2" ,
contents = text,
config = types.EmbedContentConfig(
task_type = "RETRIEVAL_DOCUMENT" ,
output_dimensionality = DIM ,
),
)
v = np.array(resp.embeddings[ 0 ].values, dtype = np.float32)
return v / np.linalg.norm(v) # 次元を切り詰めたら再正規化する
def embed_query (text: str ) -> np.ndarray:
resp = client.models.embed_content(
model = "gemini-embedding-2" ,
contents = text,
config = types.EmbedContentConfig(
task_type = "RETRIEVAL_QUERY" ,
output_dimensionality = DIM ,
),
)
v = np.array(resp.embeddings[ 0 ].values, dtype = np.float32)
return v / np.linalg.norm(v)
# インデックス側
docs = [
"夕焼けに染まる海辺の風景写真" ,
"夕暮れの山並みと雲海" ,
"真昼の透明な南国のビーチ" ,
]
index = np.vstack([embed_document(d) for d in docs])
# 検索側
q = embed_query( "夕焼け 海" )
scores = index @ q # 正規化済みなので内積 = コサイン類似度
ranking = np.argsort( - scores)
for rank, i in enumerate (ranking, start = 1 ):
print ( f " { rank } 位: { docs[i] } score= { scores[i] :.3f } " )
ポイントは3つあります。
ドキュメントとクエリで task_type を分けること。
次元を output_dimensionality で切り詰めたら L2 正規化をやり直すこと(切り詰めるとノルムが1でなくなります)。
比較は、正規化済みベクトルの内積で行うこと。
この3点を守ると、順位が締まります。
なぜ再正規化が要るのかというと、gemini-embedding-2 は Matryoshka 表現学習に対応していて、末尾を捨てて次元を減らせる設計だからです。減らした直後のベクトルは長さが1からずれるので、コサイン類似度を内積で近似する前提が崩れます。ここは検索の順位に直接効きます。次元とストレージのトレードオフについては、gemini-embedding の出力次元を256に絞ってストレージを減らす設計 も併せて読むと判断しやすいはずです。
私の環境で測った差(読むときの注意つき)
ここで正直に書いておきたいのですが、以下の数値は私の手元の小さなセット(アプリのヘルプ記事と壁紙タグ、あわせて約1,200件)で測ったものです。公式ベンチマークではありません。task_type の効き方はコーパスの性質で変わるので、あくまで「方向」として受け取ってください。
私の環境では、task_type なしから「ドキュメント=RETRIEVAL_DOCUMENT/クエリ=RETRIEVAL_QUERY」に揃えたところ、上位5件の再現率(recall@5)がおおよそ 0.72 から 0.86 へ上がりました。相対で見ると、およそ19%の改善です。特に効いたのは、クエリが短い場合でした。「夕焼け 海」のような2語のクエリほど、用途を明示したときの改善幅が大きくなりました。
設定 recall@5(私の約1,200件で測定) 体感
task_type なし(ドキュメント・クエリとも未指定) 約 0.72 惜しい外れ方が多い
両方 RETRIEVAL_DOCUMENT で統一 約 0.74 ほぼ変わらない
ドキュメント=DOCUMENT/クエリ=QUERY 約 0.86 短いクエリが安定
面白かったのは、真ん中の「両方 RETRIEVAL_DOCUMENT に揃える」がほとんど改善しなかったことです。task_type を指定しさえすればよい、という話ではありません。ドキュメントとクエリで役割を分けて初めて効きます。
task_type の種類と、迷ったときの選び方
gemini-embedding-2 には検索以外の用途も用意されています。よく使うものを整理します。用途が変われば最適な task_type も変わるので、「とりあえず RETRIEVAL_DOCUMENT」で全部通さないほうがよい場面があります。
task_type 使う場面 片側だけ?両側?
RETRIEVAL_DOCUMENT 検索対象の文書を索引化するとき ドキュメント側のみ
RETRIEVAL_QUERY その索引を検索するクエリ クエリ側のみ
SEMANTIC_SIMILARITY 2つの文の似ている度合いを対称に測るとき 両側とも同じ
CLASSIFICATION 埋め込みを特徴量にして分類器を学習するとき 用途に応じて
CLUSTERING レビューやタグをまとめて群に分けるとき 両側とも同じ
CODE_RETRIEVAL_QUERY 自然文でコードスニペットを検索するとき クエリ側のみ
迷ったときの判断はこうしています。タスクが「非対称」(短いクエリで長い文書を引く、自然文でコードを引く)なら、DOCUMENT と QUERY を分けます。タスクが「対称」(2文の類似度、クラスタリング)なら、両側を SEMANTIC_SIMILARITY か CLUSTERING で統一します。この一本の線引きで、ほとんどの場面は迷わなくなります。
App Store のユーザーレビューを群に分ける処理は CLUSTERING で統一しています。同じ埋め込み基盤でも用途がまるで違うので、task_type も当然変わります。多言語レビューの実運用については Google Play 多言語レビュー返信を Gemini と作る運用設計 にまとめています。
移行時の落とし穴:既存インデックスは作り直しが要ることがある
一番はまりやすいのが、モデルや設定を変えたときです。task_type を後から変えると、ドキュメント側の埋め込みも作り直しが必要になります。RETRIEVAL_DOCUMENT で作った索引に、RETRIEVAL_QUERY のドキュメントベクトルが混ざると、同じ文書なのに別の場所に置かれてしまいます。
判断の基準はシンプルです。ドキュメント側の task_type や次元、モデルのいずれかを変えたら、その索引は全件再埋め込みします。クエリ側だけの変更なら索引はそのままで構いません。この非対称性を忘れると、「クエリだけ直したのに直らない」という迷路に入ります。
本番で動いている索引ほど、この作り直しの判断は重くなります。全件再埋め込みはコストがかかるので、頻繁には避けたいところです。1,200件程度なら数分で終わりますが、数十万件になると話が変わります。再埋め込みの費用感やキャッシュの考え方は Gemini API コンテキストキャッシュでコストを削減する方法 の考え方が応用できます。索引の作り直しは「いつでもできる」ものではなく「設計判断」だと捉えておくと、あとで慌てません。
小さなゴールデンセットで task_type を A/B する
task_type の効果はコーパス依存なので、私は必ず自分のデータで確かめるようにしています。といっても大げさな評価基盤は要りません。「このクエリなら、この文書が正解」というペアを20〜30個そろえるだけで十分です。
import numpy as np
# (クエリ, 正解ドキュメントのインデックス) を手で20〜30件そろえる
golden = [
( "夕焼け 海" , 0 ),
( "夜の街の明かり" , 5 ),
( "雪山の朝" , 8 ),
# ... 自分のアプリの実クエリを並べる
]
def recall_at_k (embed_q, index, golden, k = 5 ):
hits = 0
for query, gold_idx in golden:
q = embed_q(query)
topk = np.argsort( - (index @ q))[:k]
if gold_idx in topk:
hits += 1
return hits / len (golden)
# index_doc は RETRIEVAL_DOCUMENT で作った行列
# embed_query は RETRIEVAL_QUERY 版、embed_plain は task_type なし版
r_tuned = recall_at_k(embed_query, index_doc, golden)
r_plain = recall_at_k(embed_plain_normalized, index_doc, golden)
print ( f "RETRIEVAL_QUERY: { r_tuned :.3f } / task_type なし: { r_plain :.3f } " )
この20行があるだけで、「なんとなく良くなった気がする」から「recall@5 が 0.72 から 0.86 に上がった」へと、判断の土台が変わります。個人開発だと評価を後回しにしがちですが、埋め込みの設定変更こそ、この小さな計器が効きます。File Search のようなマネージド機構を使う場合でも、内部で task_type がどう扱われるかは Gemini File Search API でRAGなしにAI応答を構築する を押さえたうえで、自分のクエリで確かめるのが確実です。
まとめ
検索が「惜しい」外れ方をしたら、正規化やチャンク設計に手を入れる前に、まず task_type を確認してみてください。ドキュメントは RETRIEVAL_DOCUMENT、クエリは RETRIEVAL_QUERY。この一致だけで順位が締まる場面は、思っているより多いです。
次の一歩として、いま動いている検索のクエリを5つだけ選び、正解ドキュメントとのペアにして recall@5 を測ってみてください。数字が出た瞬間に、次に直すべき場所が見えてきます。私自身もこの計器を持つようになってから、埋め込みまわりの迷いがずいぶん減りました。お読みいただきありがとうございました。