個人開発でアプリを運営していると、サポート用の知識は自然と二つに分かれていきます。手順を文章で書いたヘルプ記事と、設定画面を撮ったスクリーンショットです。私自身、壁紙アプリの問い合わせ対応で「課金の復元はどこから?」と聞かれるたびに、文章の手順を返したあとに別フォルダからスクショを探して貼る、という二度手間を続けていました。
困るのは、テキストとスクショを別々の検索基盤に置いていると、ユーザーの一つの質問に対して「文章だけ」か「画像だけ」しか引けないことです。本当は「この手順です(文章)+この画面です(画像)」をひとまとめで返したい。これまでは画像側を OCR してテキスト化し、無理やり同じ土俵に乗せていましたが、ボタンのアイコンや位置といった視覚情報は OCR では拾えませんでした。
gemini-embedding-2 が File Search でマルチモーダル埋め込みに対応したことで、この前提が変わりました。テキストの文書と画像の文書を同じストアに入れ、同じベクトル空間で検索できます。今回は、ヘルプ記事とスクショを一つの File Search ストアに混在させ、回答に「引用元の画像」まで添えて返すところまでを、実際に手を動かした順に整理します。
なぜ「テキストと画像が別ストア」だと回答が片手落ちになるのか
技術的な理由は単純で、別々の埋め込みモデルで作ったベクトルは比較できないからです。テキストを文章用の埋め込み、画像を画像用の埋め込みで別々にインデックスすると、二つのベクトル空間ができてしまい、「質問ベクトルに近いものを上位から取る」という検索が空間をまたげません。結果として、テキスト検索と画像検索を別々に叩き、後段で順位を無理にマージすることになります。
このマージが曲者でした。テキスト側のスコアと画像側のスコアはスケールが違うので、しきい値をどう揃えても「文章は的確なのに添える画像がずれる」「画像は合っているのに説明文が古い」という非対称が残ります。私の場合、サポート文面の体感精度は悪くないのに、添付スクショの的中率だけが低い、という状態が長く続きました。
マルチモーダル埋め込みは、テキストと画像を同じ空間に写像します。「課金を復元する」という質問の埋め込みと、復元ボタンが写ったスクショの埋め込みが近くに来るので、一回の検索で両方が上位に並びます。スコアのスケールも揃うため、後段のしきい値設計が一本化できるのが実務上いちばん効きました。
gemini-embedding-2 でテキストと画像の混在ストアを作る
まずストアを作り、埋め込みモデルにマルチモーダル対応の gemini-embedding-2 を指定します。ポイントは、ストア作成時に埋め込みモデルを固定しておくことです。あとからモデルだけ差し替えると既存ベクトルと新規ベクトルが別空間になり、検索結果が静かに劣化します。
# pip install google-genai
from google import genai
from google.genai import types
client = genai.Client() # GEMINI_API_KEY を環境変数から読む
# (1) マルチモーダル埋め込みでストアを作成
store = client.file_search_stores.create(
config = {
"display_name" : "app-support-kb" ,
# テキストと画像を同じ空間に埋め込むモデルを固定する
"embedding_model" : "gemini-embedding-2" ,
}
)
print (store.name) # → fileSearchStores/app-support-kb-xxxxxxxx
続いて文書を投入します。テキストのヘルプ記事も、スクリーンショットの画像も、同じストアに upload するだけです。ここで効いてくるのが custom_metadata で、あとで「どのモダリティか」「どの画面か」を回答側で使うため、登録時に必ず付けておきます。
# (2) テキストのヘルプ記事を投入
client.file_search_stores.upload_to_file_search_store(
file_search_store_name = store.name,
file = "docs/restore-purchase.md" ,
config = {
"custom_metadata" : [
{ "key" : "modality" , "string_value" : "text" },
{ "key" : "screen" , "string_value" : "settings" },
{ "key" : "locale" , "string_value" : "ja" },
]
},
)
# (3) スクリーンショットを同じストアに投入
client.file_search_stores.upload_to_file_search_store(
file_search_store_name = store.name,
file = "shots/settings-restore.png" ,
config = {
"custom_metadata" : [
{ "key" : "modality" , "string_value" : "image" },
{ "key" : "screen" , "string_value" : "settings" },
{ "key" : "locale" , "string_value" : "ja" },
]
},
)
screen のような業務的なキーを揃えておくと、「文章とスクショが同じ画面を指しているか」を後段で照合できます。私は最初これを省いて投入し、復元手順の文章にホーム画面のスクショが付く、という珍事に遭遇しました。メタデータは検索のためというより、回答の整合性チェックのために入れておく、という感覚です。
画像も引用させる ― grounding メタデータからモダリティを判定する
検索して回答を生成する側は、file_search ツールをモデルに渡すだけです。重要なのは戻り値の grounding_metadata で、ここに「どの文書を根拠にしたか」が入っています。テキストと画像が混ざっているので、引用元ごとに先ほどの modality を見て振り分けます。
QUESTION = "課金を復元したいのですが、どこから操作しますか?"
resp = client.models.generate_content(
model = "gemini-3.5-flash" ,
contents = QUESTION ,
config = types.GenerateContentConfig(
tools = [types.Tool(
file_search = types.FileSearch(
file_search_store_names = [store.name],
)
)],
),
)
print (resp.text) # 文章での回答
# 引用元をモダリティ別に仕分ける
text_refs, image_refs = [], []
gm = resp.candidates[ 0 ].grounding_metadata
for chunk in (gm.grounding_chunks or []):
ctx = chunk.retrieved_context
meta = {m.key: m.string_value for m in (ctx.custom_metadata or [])}
if meta.get( "modality" ) == "image" :
image_refs.append(ctx.title) # 例: settings-restore.png
else :
text_refs.append(ctx.title) # 例: restore-purchase.md
print ( "文章の根拠:" , text_refs)
print ( "画像の根拠:" , image_refs)
これで「文章の回答」と「根拠になったスクショのファイル名」が同時に手に入ります。回答 UI 側では image_refs に対応する画像 URL を出すだけで、説明と画面が一致した返信になります。私のサポート対応では、ここが分岐点でした。文章だけ返していた頃と比べ、「画面のどこか分からない」という追加質問が目に見えて減りました。
実際に詰まった点 ― 画像の前処理とトークン
きれいに動くまでに、いくつか落とし穴がありました。本番に入れる前に潰しておきたい点を挙げます。
大きすぎるスクショは投入前に縮める
巨大なスクショをそのまま投入しないことです。最近の端末は解像度が高く、App Store 提出用に撮ったスクショは 1290×2796 のように縦長で重いものになります。私は当初フル解像度のまま入れていましたが、検索のヒット品質はサムネイル相当に落としてもほとんど変わらず、むしろアップロードとインデックスの時間だけが伸びました。手元の80枚で測ったところ、長辺を 1024px へ縮めても検索の的中はほぼ変わらないのに、インデックスにかかる時間は約40%短くなりました。投入前に長辺を 1024px 程度へリサイズするのが、精度とコストのバランスがよいと感じています。
from PIL import Image
def shrink (path: str , max_side: int = 1024 ) -> str :
img = Image.open(path).convert( "RGB" )
w, h = img.size
scale = min ( 1.0 , max_side / max (w, h))
if scale < 1.0 :
img = img.resize(( round (w * scale), round (h * scale)))
out = path.replace( ".png" , "-small.jpg" )
img.save(out, "JPEG" , quality = 85 ) # PNG より軽く、UI 表示にも十分
return out
端末依存の形式は入口で正規化する
対応形式にも注意点があります。PNG・JPEG・WebP は素直に通りますが、iOS のスクショで増えてきた HEIC はそのままだと弾かれることがありました。この種のエラーは投入時には気づきにくく、検索しても画像だけ出てこない形で表面化します。撮影端末に依存する形式は、投入パイプラインの入口で JPEG へ正規化しておくのが確実な回避策です。上の shrink を「正規化も兼ねる入口」にしておくのが実務的だと考えています。
クエリ時の画像トークンを絞る
クエリ時の画像トークンも見落としがちです。マルチモーダル検索では、取得した画像がモデルのコンテキストに乗るため、引きすぎると無駄にトークンを消費します。本番運用では、私は検索結果の上位を絞り、画像の根拠は回答に添える最大2枚までに制限しました。「全部引いてから捨てる」より「最初から絞る」ほうが、レイテンシも素直に下がります。
回答に「該当スクショ」を添えて返す実装
ここまでを一つの関数にまとめます。サポート bot や問い合わせフォームの裏側で呼ぶ想定です。screen メタデータで文章と画像の指す画面が一致しているものだけを採用し、ズレた画像は黙って落とすのが安定運用のコツです。
def answer_with_screenshot (question: str , max_images: int = 2 ) -> dict :
resp = client.models.generate_content(
model = "gemini-3.5-flash" ,
contents = question,
config = types.GenerateContentConfig(
tools = [types.Tool(
file_search = types.FileSearch(
file_search_store_names = [store.name],
)
)],
),
)
gm = resp.candidates[ 0 ].grounding_metadata
text_screens, images = set (), []
for chunk in (gm.grounding_chunks or []):
ctx = chunk.retrieved_context
meta = {m.key: m.string_value for m in (ctx.custom_metadata or [])}
if meta.get( "modality" ) == "text" :
text_screens.add(meta.get( "screen" ))
for chunk in (gm.grounding_chunks or []):
ctx = chunk.retrieved_context
meta = {m.key: m.string_value for m in (ctx.custom_metadata or [])}
# 文章が指す画面と一致するスクショだけ採用する
if meta.get( "modality" ) == "image" and meta.get( "screen" ) in text_screens:
images.append(ctx.title)
return { "answer" : resp.text, "screenshots" : images[:max_images]}
この「文章が触れた画面のスクショだけ添える」という一致条件を入れてから、的外れな画像が混ざる事故がほぼなくなりました。検索の精度をモデル任せにせず、業務メタデータで最後に一段締める、という設計です。
どのくらい効いたか、そしてどんな時に使うべきか
私のアプリサポートでは、ヘルプ記事およそ40本とスクショ80枚ほどを一つのストアにまとめました。体感でいちばん変わったのは一次回答の自己完結率です。以前は文章を返したあとにスクショを追って送る往復が発生していましたが、混在ストアにしてからは初回返信に画面が添わるため、追加のやり取りが要らない問い合わせが増えました。多言語対応でも locale メタデータで言語ごとに切り分けられるので、英語の問い合わせには英語のヘルプと同じ画面のスクショが返ります。
一方で、何でもマルチモーダルにすればよいわけではありません。画像がほとんど関与しない純テキストの FAQ だけなら、テキスト専用のほうがインデックスは軽く速いです。私の判断基準は「回答に画面を添えたい場面が一定割合あるか」です。サポートや操作ガイドのように視覚情報が効く領域では混在ストアを推奨しますが、規約文や料金表のような文章主体のコーパスでは、無理に画像を混ぜず分けたままにしています。
もう一点、運用で気をつけているのは、アプリの UI を更新したらスクショも入れ替えることです。古い画面のスクショが残っていると、文章は最新なのに添付画像だけ前バージョン、という分かりにくいズレが起きます。App Store や Google Play へ新バージョンを出すタイミングで、ストア内のスクショも更新するのを定例作業に組み込みました。
次の一歩
まずは手元のヘルプ記事を3本と、対応するスクショを3枚だけ同じストアに入れて、answer_with_screenshot に一つ質問を投げてみてください。文章と画面が一致して返るかどうかは、screen メタデータの付け方次第で大きく変わります。少ない件数で一致条件の感触をつかんでから、コーパス全体へ広げるのが遠回りに見えて確実だと感じています。