Grounding with Google Maps をデモで動かすと、たいてい気持ちよく動きます。問題が出るのは、リリースして数日が経ち、ログを眺め始めてからです。回答は返ってくるのに地図情報が混ざっていない応答がぽつぽつ現れ、請求額は想定とずれ、ユーザーから「店の営業時間が違った」という報告が届く。どれもエラーにはならず、HTTP 200 のまま静かに起きます。
ここでは、レストラン検索や周辺情報のアシスタントを本番に載せたあとで実際に効いてくる四つの論点を、運用側のコードとともに整理します。セットアップの手順そのものより、「動いているように見えて外れている」状態をどう捕まえるかに重心を置きます。対象は、Vertex AI 経由で Maps Grounding を一度は動かしたことのある中級〜上級のエンジニアです。
なお、Maps Grounding は Vertex AI 経由でのみ動作し、通常の API キー認証の Gemini API では呼び出せません。対応モデルや料金の細目は更新が早いので、実装に入る前に Vertex AI 生成 AI の料金 と公式のツールドキュメントで最新の対応表を確認してください。本稿のコードは対応モデルを設定値として外に出し、差し替えやすくしてあります。
グラウンディングが「静かに不発する」のを検知する
最初に作り込むべきは、地図情報が使われたかどうかの判定です。Gemini は、クエリを位置情報の問い合わせと判断したときだけ Maps を参照します。判断が外れると、モデルは自分の内部知識だけで答えを作り、それらしい店名を返してきます。これが一番こわい失敗で、応答は流暢なのに裏取りがされていません。
判定の根拠は応答テキストではなく grounding_metadata に置きます。チャンクが一件も無ければ、その回答は地図に裏打ちされていないと見なします。
# grounding_guard.py
from dataclasses import dataclass
@dataclass
class GroundedResult:
text: str
sources: list[dict]
grounded: bool # 地図ソースが1件以上付いたか
used_maps: bool # 課金対象となる Maps 応答だったか
def inspect(response) -> GroundedResult:
"""応答から地図グラウンディングの有無を判定する。"""
sources: list[dict] = []
candidate = (response.candidates or [None])[0]
metadata = getattr(candidate, "grounding_metadata", None) if candidate else None
for chunk in getattr(metadata, "grounding_chunks", []) or []:
web = getattr(chunk, "web", None)
if web and getattr(web, "uri", None):
sources.append({
"title": getattr(web, "title", "") or "(無題)",
"uri": web.uri,
"place_id": getattr(web, "place_id", None),
})
grounded = len(sources) > 0
return GroundedResult(
text=response.text or "",
sources=sources,
grounded=grounded,
used_maps=grounded,
)不発を検知したら、黙ってそのまま返さないことが肝心です。位置情報に裏打ちされていない旨を一言添えて返すか、用途によっては Places API の素朴な近接検索へ切り替えます。個人開発で位置情報アプリを運用している私自身も、ユーザーへ返す前にこの一言を必ず差し込む方針にしてから、「実在しない店を案内された」という種類の苦情がほぼ消えました。これは本番運用で最初に踏みがちな落とし穴で、流暢な誤答をそのまま信じてもらうより、断りを入れるほうがアプリへの信頼はむしろ上がります。
def to_user_payload(result: GroundedResult) -> dict:
if result.grounded:
return {"answer": result.text, "sources": result.sources, "verified": True}
# 不発時: 地図裏付けが無いことを明示して返す
note = "(注:今回の回答は地図のリアルタイム情報で確認できていません。営業状況は各店舗で再確認してください。)"
return {"answer": f"{result.text}\n\n{note}", "sources": [], "verified": False}不発はクエリの書き方にも左右されます。「カフェを教えて」のような抽象的な問いより、地名や施設名・「近くの」といった近接表現を含む問いのほうが地図参照が発火しやすい傾向があります。システムプロンプトで「場所を尋ねる問いには必ず地名か現在地を補ってから検索する」と促しておくと、不発率はある程度下げられます。ただしゼロにはならない前提で、上の検知層は残しておきます。
アトリビューションは「描画して初めて要件を満たす」
Maps Grounding を使う以上、参照したソースのアトリビューション表示は任意ではなく要件です。grounding_metadata を取得しただけでは足りず、ユーザーが見る画面に実際に描画されて初めて満たされます。抽出はできているのに UI 側で捨てている、という取りこぼしが本番で一番起きやすい箇所です。
# attribution.py
import html
def render_attribution(sources: list[dict]) -> str:
"""Maps ソースを安全にエスケープして表示用 HTML に変換する。"""
if not sources:
return ""
items = "\n".join(
f' <li><a href="{html.escape(s["uri"])}" target="_blank" '
f'rel="noopener noreferrer">{html.escape(s["title"])}</a></li>'
for s in sources
)
return (
'<div class="maps-attribution" '
'style="font-size:13px;color:#5f6368;margin-top:12px;">\n'
" <span>情報提供元(Google Maps):</span>\n"
f" <ul style=\"margin:4px 0;padding-left:16px;\">\n{items}\n </ul>\n"
"</div>"
)title と uri は外部由来の文字列なので、必ずエスケープしてから埋め込みます。ここを生で渡すと、店名に紛れ込んだ記号でレイアウトが崩れたり、最悪の場合スクリプト混入の経路になります。
実装上もう一つ意識したいのは、対話型 UI で地図ウィジェットを出すかテキストのリンクで済ませるかの選択です。チャット形式でユーザーに地図を触ってもらいたい場合はウィジェット連携を有効にし、サーバー側で要約だけを返すバッチ処理ではテキストのアトリビューションで足ります。要件の正確な範囲は更新されることがあるため、ウィジェットの扱いとアトリビューションのスタイル規定は公式ドキュメントで都度確認するのが安全です。要点は、メタデータを取得したら必ずレンダリングまで一本の経路でつなぐことです。