ある朝、個人開発で運用している Dolice Labs のヘルプ記事を横断検索できるようにしたくて、テキストの記事と、アプリのスクリーンショット、それに壁紙アセットの画像を、私自身の手でひとつの File Search ストアにまとめて入れてみました。gemini-embedding-2 がテキストと画像を同じ空間に埋め込めるようになったので、これで「言葉でも画像でも引ける一つの索引」ができるはずでした。
ところが、いざ「課金まわりの設定はどこ」と尋ねると、上位に並んだのはスクリーンショットと壁紙ばかり。肝心のテキスト記事は、8件のうち2件しか残っていませんでした。
埋め込みは正しく効いています。画像も意味で引けています。それでも、欲しかった答えが画像の山に押し下げられてしまう。これは精度の問題というより、取得結果のモダリティ構成が偏る という、混在ストア特有のクセでした。今日はこの偏りを「測って、取得後に均す」までを、実装と一緒に書き残します。
なぜ混在ストアでは結果が片方に寄るのか
原因は、テキストと画像で類似度スコアの分布が違う ことにあります。
同じクエリベクトルに対して、画像チャンクのコサイン類似度は、テキストチャンクよりも系統的に高め・かつ密に出ることがあります。これはモデルの優劣ではなく、埋め込み空間の中でモダリティごとに「固まり方」が違うために起きる、スケールのずれです。
素朴に「全件を1本のスコアで並べて上位8件」とすると、平均が少し高いモダリティが上位枠を占有します。私のストアでは画像側のスコアがわずかに高く、結果として画像が枠を食い尽くしていました。逆のストア構成なら、テキストが画像を押しのける形でも同じことが起きます。
つまり、ひとつの数直線に二つの分布を混ぜて切っているのが問題です。比較の土俵を揃えてから配分する ——これが設計の芯になります。
まず偏りを「測る」— モダリティ構成比を出す
直す前に、偏りを数字にします。検索結果の各ヒットがテキストか画像かを判定し、上位 N 件の構成比を見るだけの小さな計測です。
File Search のグラウンディング・メタデータには、画像引用のための media_id やページ番号が含まれます。私は「media_id(または画像参照)を持つチャンク=画像モダリティ、テキスト本文を持つチャンク=テキストモダリティ」と単純に判定しています。フィールド名はお使いの SDK バージョンに合わせて読み替えてください。
from dataclasses import dataclass
@dataclass
class Hit :
id : str
score: float # ストアが返した類似度スコア
modality: str # "text" or "image"
text: str = "" # テキストチャンクの本文(あれば)
def detect_modality (chunk) -> str :
"""グラウンディング・チャンクからモダリティを推定する。
SDK のフィールド名は環境に合わせて調整すること。"""
media_id = getattr (chunk, "media_id" , None )
if media_id:
return "image"
# 画像参照を別フィールドで持つ場合のフォールバック
if getattr (chunk, "image_uri" , None ) or getattr (chunk, "inline_image" , None ):
return "image"
return "text"
def modality_mix (hits, top_n = 8 ):
top = hits[:top_n]
counts = { "text" : 0 , "image" : 0 }
for h in top:
counts[h.modality] = counts.get(h.modality, 0 ) + 1
total = max ( 1 , len (top))
return {k: (v, round (v / total, 2 )) for k, v in counts.items()}
# 例: 取得直後の生の上位8件を測る
# print(modality_mix(raw_hits, top_n=8))
# => {'text': (2, 0.25), 'image': (6, 0.75)} ← テキストを期待した問いなのに画像が3/4
この「期待した構成」と「実際の構成」の差が、改善対象です。テキストのヘルプを探す問いで画像が 75% を占めるなら、土俵を揃える価値があります。
設計の選択肢を3つに絞る
偏りの均し方はいくつもありますが、個人開発で運用し続けられる範囲に絞ると、現実的なのは次の三つでした。
方式 やること 向いている場面
モダリティ内正規化 テキスト・画像それぞれで z-score を取り直してから統合ランキング 両モダリティをなるべく公平に混ぜたいとき
クォータ方式 上位枠にモダリティ別の最低数・最大数を割り当てて埋める 「テキストは最低4件欲しい」など構成の下限を保証したいとき
クエリ意図で重み付け 問いがテキスト寄りか画像寄りかを判定し、配分を動的に変える 問いの種類がばらつくとき
実運用では、これらは排他ではなく重ねて使います。私は「モダリティ内正規化で土俵を揃え → クォータで下限を守り → 意図でクォータを微調整」の三段で落ち着きました。順に実装を示します。
実装1: モダリティ別にスコアを正規化する
まず、テキストと画像を別々に z-score 化して、同じ尺度に乗せ替えます。これで「画像のスコアが系統的に高い」分のゲタを外せます。
import statistics
def zscore_by_modality (hits):
"""モダリティごとに平均0・標準偏差1へ正規化したスコアを付け直す。"""
by_mod = {}
for h in hits:
by_mod.setdefault(h.modality, []).append(h)
rescored = []
for mod, group in by_mod.items():
scores = [h.score for h in group]
mu = statistics.fmean(scores)
# サンプルが少ないと標準偏差が不安定なので下駄を履かせる
sigma = statistics.pstdev(scores) if len (scores) > 1 else 1.0
sigma = sigma or 1.0
for h in group:
z = (h.score - mu) / sigma
rescored.append((z, h))
rescored.sort( key =lambda t: t[ 0 ], reverse = True )
return [h for _, h in rescored]
注意したいのは、正規化はモダリティ内のサンプル数に弱い ことです。あるクエリで画像が2件しか取れていないと、その2件の z-score は当てになりません。後段のクォータが、この不安定さを吸収してくれます。
実装2: クォータ方式で上位枠を配分する
正規化済みの並びを使い、上位 N 枠にモダリティの下限を保証しながら詰めます。「上位8件のうちテキストは最低4件、画像は最低2件」のような構成を、設定値として外に出しておくのが運用しやすいです。
def quota_merge (ranked_hits, top_n = 8 , min_quota = None ):
"""min_quota で各モダリティの最低件数を保証しつつ上位枠を埋める。"""
if min_quota is None :
min_quota = { "text" : 4 , "image" : 2 }
buckets = { "text" : [], "image" : []}
for h in ranked_hits:
buckets.setdefault(h.modality, []).append(h)
result, used = [], { "text" : 0 , "image" : 0 }
# 1) まず各モダリティの最低数を確保する
for mod, q in min_quota.items():
take = buckets.get(mod, [])[:q]
result.extend(take)
used[mod] += len (take)
# 2) 残り枠は正規化スコア順で埋める(既に採用したものは除く)
chosen = { id (h) for h in result}
for h in ranked_hits:
if len (result) >= top_n:
break
if id (h) not in chosen:
result.append(h)
chosen.add( id (h))
# 3) 最終的に正規化スコア順へ並べ直して返す
order = { id (h): i for i, h in enumerate (ranked_hits)}
result.sort( key =lambda h: order.get( id (h), 1_000_000 ))
return result[:top_n]
下限を「件数」で持つのがコツです。割合(例: テキスト50%)で持つと、総件数が変わるたびに端数の扱いで揺れます。個人開発の小さな索引では、件数の方が読みやすく、事故も少なかったです。
実装3: クエリの意図でクォータを動かす
最後に、問いがテキスト寄りか画像寄りかで、クォータ自体を差し替えます。「〜の画面」「どんな見た目」のような問いは画像を厚く、「設定方法」「料金」のような問いはテキストを厚く。判定は重い必要がありません。私は軽量モデルか、まずは簡単なルールから始めました。
IMAGE_HINTS = ( "画面" , "見た目" , "どんな" , "スクショ" , "スクリーンショット" , "デザイン" , "壁紙" )
TEXT_HINTS = ( "設定" , "方法" , "手順" , "料金" , "エラー" , "仕様" , "理由" )
def quota_for_query (query: str , top_n = 8 ):
q = query.lower()
image_lean = any (k in query for k in IMAGE_HINTS )
text_lean = any (k in query for k in TEXT_HINTS )
if image_lean and not text_lean:
return { "image" : 5 , "text" : 2 }
if text_lean and not image_lean:
return { "text" : 5 , "image" : 2 }
# 中立。どちらも一定数は見せる
return { "text" : 4 , "image" : 2 }
def search_rebalanced (query, raw_hits, top_n = 8 ):
ranked = zscore_by_modality(raw_hits)
quota = quota_for_query(query, top_n = top_n)
return quota_merge(ranked, top_n = top_n, min_quota = quota)
ルールで8割、迷う問いだけ gemini-flash-lite などの軽い判定に回す、という割り切りが、個人開発では費用と速度のバランスが取りやすいと感じています。判定そのものに重いモデルを使うと、検索のたびに余計な1コールが乗ってしまいますので。
公式ドキュメントに書かれていない運用知見
実際に本番運用で数週間動かして見えた、ドキュメントには載っていない勘どころをいくつか残します。
第一に、閾値は固定値でなく分位点で持つ こと。再インデックスやストアの追加でスコア分布はじわじわ動きます。「スコア0.7以上を採用」のような絶対値は、ある日から急に効きすぎたり効かなくなったりします。モダリティ内の分位点(上位X%)で持つと、分布が動いても挙動が安定しました。
第二に、クォータはコスト制御も兼ねる こと。画像チャンクを回答コンテキストに多く混ぜると、その分だけ入力トークンが膨らみます。画像の上限を決めておくことは、検索の質だけでなく、1回答あたりの費用の上限を決めることでもありました。
第三に、画像しか答えになり得ない問いに、無理にテキストを混ぜない こと。クォータの下限は「最低これだけ見せたい」という願望ですが、関連の薄いテキストを定数で押し込むと、かえって回答が濁ります。下限はあくまで「在庫があれば」の条件にして、関連スコアが床を割るものは入れない、というガードを併用しています。
どのくらい効いたか(自分の corpus での観測)
私の索引は、ヘルプ記事のテキストチャンクが約1,200、スクリーンショットと壁紙の画像が約600という構成です。手元で用意した40問の検証セットで、取得後の再配分を入れる前後を比べました。
指標 再配分なし 再配分あり
テキスト寄りの問いで上位8件に入るテキスト件数(平均) 1.9 4.3
モダリティ充足率(上位8件に関連テキストが3件以上ある問いの割合) 54% 89%
NDCG@8(手付けの正解ラベルに対して) 0.71 0.78
1検索あたりの追加コスト 0 ほぼ0(ルール判定が大半)
NDCG の伸びは穏やかですが、体感は数字以上でした。「探した答えが画像に埋もれない」という安心感は、検索を日常的に使う気にさせてくれます。あくまで自分の索引での観測値なので、構成が違えば数字は変わります。まずは前掲の modality_mix で、ご自身のストアの偏りを測るところからをおすすめします。
つまずいた点
z-score は、モダリティ内のヒットが1〜2件のときに暴れます。最初はこれで上位が不自然に入れ替わり、原因究明に半日溶かしました。これは混在ストアならではの落とし穴で、回避策はサンプル数に応じて正規化を出し分けることでした。サンプルが少ないモダリティは正規化をスキップし、クォータだけで扱うようにして安定しました。
もう一つは、意図判定の取りこぼしです。ルールに無い言い回し(「どう見える?」など)を画像寄りと拾えず、中立クォータで処理してしまう。ルールは完璧を目指さず、外したときに中立へ落ちる設計にしておくと、事故が小さく収まります。
次の一歩
まずは今のストアに対して modality_mix を回し、テキスト寄り・画像寄りの代表的な問いを5つずつ投げて、上位8件の構成比を眺めてみてください。偏りが目に見えたら、zscore_by_modality → quota_merge の二つを足すだけで、体感は大きく変わります。意図判定は、その後で必要を感じてからで十分です。
具体的には、次の順で進めるのを個人的には推奨します。
modality_mix で現状の偏りを数値にする
zscore_by_modality でモダリティ内の土俵を揃える
quota_merge で上位枠の下限を保証する
お読みいただきありがとうございました。同じように混在ストアの偏りに戸惑っている方の、最初の一歩になれば嬉しいです。