{"slug": "building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud", "title": "Building Grounded Local Search with Gemini and Google Maps on Google Cloud", "summary": "A developer built an open-source grounded local-search workflow that combines Gemini for intent parsing, Google Places API (New) for structured local data, and Google Search grounding for softer evidence, then normalizes, deduplicates, scores, and synthesizes results into Maps-linked recommendations on Cloud Run. The implementation splits retrieval into two paths — Places for structured constraints like hours and party size, and Search grounding for qualitative preferences like quiet and work-friendly — after Gemini converts the user query into a structured intent object and a bounded retrieval plan. The code is published as ai-search-journey-lab on GitHub.", "body_md": "Building a grounded local-search workflow with Gemini, Google Places API (New), Google Search grounding, deterministic ranking, Maps links, and Cloud Run.\n\n**Open source:** [ai-search-journey-lab](https://github.com/hastimal/ai-search-journey-lab)\n\nPrevious article: [Search Journey Optimization with Gemini: From Query Fan-Out to Grounded Decisions](https://dev.to/hjangid/search-journey-optimization-with-gemini-from-query-fan-out-to-grounded-decisions-3f99)\n\n**In Part 1, I explained the journey. In Part 2, I want to show the implementation.**\n\nIn the first article, I focused on the architecture behind a search journey:\n\n**intent → query fan-out → retrieval → evidence → deduplication → ranking → grounded answer**\n\nThat was the conceptual layer.\n\nThis article is more practical.\n\nI want to show how I built the actual local-search workflow behind that architecture using:\n\nThe original demo query is still the same:\n\nFind a coffee shop near Geekdom in San Antonio for six people, quiet enough to work, open after 8 PM, and recommend the top three.\n\nThe difference now is that I want to focus on what happens after the intent has been understood.\n\n**The local-search problem is really two retrieval problems**\n\nWhen I first started implementing this workflow, I realized that one retrieval system was not enough.\n\nSome constraints are structured.\n\n**For example:**\n\nGoogle Places is a very good fit for those.\n\nBut some user requirements are much softer:\n\nThose do not always map cleanly to one structured field.\n\nThat led me to split retrieval into **two paths**:\n\nGoogle Places for structured local data\n\nand\n\nGoogle Search grounding for additional evidence\n\nThat separation is at the heart of this article.\n\nThe V1 flow for grounded local search looks like this:\n\n**User Query → Gemini Intent → Query Fan-Out → Places API + Google Search Grounding → Normalize → Evidence → Score → Gemini Synthesis → Maps-linked Recommendations**\n\nI do not call Places directly with the full user prompt.\n\nFirst, I want a structured representation of what the user is asking for.\n\nConceptually:\n\n```\n{\n  \"category\": \"coffee_shop\",\n  \"location_reference\": \"Geekdom, San Antonio\",\n  \"party_size\": 6,\n  \"open_after\": \"20:00\",\n  \"preferences\": [\n    \"quiet\",\n    \"work-friendly\"\n  ],\n  \"result_count\": 3\n}\n```\n\nThe important thing here is that the retrieval layer no longer has to interpret every phrase from scratch.\n\nGemini handles the language ambiguity.\n\nThe application gets a predictable structure.\n\nOnce I have the intent, I generate a bounded retrieval plan.\n\nA simplified example might look like:\n\n```\n{\n  \"places_tasks\": [\n    {\n      \"query\": \"coffee shops near Geekdom San Antonio\"\n    },\n    {\n      \"query\": \"work-friendly coffee shops downtown San Antonio\"\n    }\n  ],\n  \"search_tasks\": [\n    {\n      \"query\": \"coffee near Geekdom quiet work open late\"\n    }\n  ]\n}\n```\n\nI deliberately keep this small.\n\nThe *goal* is **not to generate ten variants of the same search**.\n\nThe goal is to cover enough of the user's intent without creating unnecessary duplication and latency.\n\nFor local search, Google Places API (New) gives me the first candidate set.\n\nA simplified Text Search call looks like this:\n\n``` php\nimport requests\n\ndef search_places(query: str, api_key: str) -> list[dict]:\n    endpoint = \"https://places.googleapis.com/v1/places:searchText\"\n\n    payload = {\n        \"textQuery\": query,\n        \"pageSize\": 10,\n    }\n\n    headers = {\n        \"Content-Type\": \"application/json\",\n        \"X-Goog-Api-Key\": api_key,\n        \"X-Goog-FieldMask\": \",\".join(\n            [\n                \"places.id\",\n                \"places.displayName\",\n                \"places.formattedAddress\",\n                \"places.rating\",\n                \"places.userRatingCount\",\n                \"places.currentOpeningHours\",\n                \"places.googleMapsUri\",\n            ]\n        ),\n    }\n\n    response = requests.post(\n        endpoint,\n        json=payload,\n        headers=headers,\n        timeout=20,\n    )\n\n    response.raise_for_status()\n\n    return response.json().get(\"places\", [])\n```\n\nWhat matters most to me here is not the HTTP request itself.\n\nIt is the set of fields that become available to downstream logic.\n\nFor this workflow, I do not need every possible field.\n\nI only want the fields that are useful for the decision.\n\n```\nplaces.id\nplaces.displayName\nplaces.formattedAddress\nplaces.rating\nplaces.userRatingCount\nplaces.currentOpeningHours\nplaces.googleMapsUri\n```\n\nThis keeps the retrieval boundary explicit.\n\nIt also makes it easier to understand which parts of the final recommendation came directly from Places data.\n\nOne thing I did not appreciate enough at the beginning was how important entity identity would become.\n\nSuppose **two** fan-out tasks return the **same** **business**.\n\nWithout *normalization*, I might end up with:\n\n```\nCandidate A\nCandidate A\nCandidate B\nCandidate C\n```\n\nThat creates a **ranking** **problem**.\n\nSo I use a canonical identifier such as `Place ID` to determine whether I am looking at the same entity.\n\nA simplified deduplication step looks like:\n\n``` php\ndef deduplicate_places(candidates: list[dict]) -> list[dict]:\n    unique: dict[str, dict] = {}\n\n    for candidate in candidates:\n        place_id = candidate[\"place_id\"]\n\n        if place_id not in unique:\n            unique[place_id] = candidate\n            continue\n\n        unique[place_id] = merge_candidate(\n            unique[place_id],\n            candidate,\n        )\n\n    return list(unique.values())\n```\n\nThe order matters:\n\n**retrieve → normalize identity → deduplicate → rank**\n\nNOT\n\n**retrieve → rank duplicates → fix identity later**\n\nOne thing I wanted from the beginning was that the recommendation should not end as plain text.\n\nIf the user asks for a **local business**, the result should be **actionable**.\n\nThat is why I keep the Google Maps URI returned by Places.\n\n```\ncandidate = {\n    \"name\": place[\"displayName\"][\"text\"],\n    \"address\": place[\"formattedAddress\"],\n    \"rating\": place.get(\"rating\"),\n    \"maps_url\": place.get(\"googleMapsUri\"),\n}\n```\n\nThen the final recommendation can give the user a direct route from:\n\n```\nAI recommendation\n```\n\nto\n\n```\nGoogle Maps\n```\n\nThat sounds small, but it changes the experience from “**read an AI answer**” to “** act on an AI answer.**”\n\nNow we reach the interesting part.\n\nGoogle Places can tell me:\n\nBut the original user also asked for:\n\nquiet enough to work\n\nThat is a different class of requirement.\n\nThere may not be a clean structured field for it.\n\nSo I added a second evidence path using Google Search grounding.\n\nI do not ask Gemini a broad question like:\n\nIs this coffee shop suitable?\n\nI make the verification task more specific.\n\n```\nCandidate:\nExample Coffee\n\nVerify:\n1. evidence that it is open late\n2. evidence relevant to working/studying\n3. evidence relevant to seating/group suitability\n\nFor each constraint:\n- supported\n- unsupported\n- supporting evidence\n- citation\n```\n\nConceptually, the call sits behind a workflow stage like:\n\n```\nwith trace_span(\n    \"search_grounding.verify_evidence\",\n    attributes={\"workflow.stage\": \"v1\"},\n):\n    evidence = verify_candidates(\n        candidates=candidates,\n        intent=intent,\n    )\n```\n\nThis gives me a richer candidate representation.\n\n```\n{\n  \"place_id\": \"abc123\",\n  \"name\": \"Example Coffee\",\n  \"places\": {\n    \"rating\": 4.6,\n    \"review_count\": 825,\n    \"open_after_8\": true\n  },\n  \"search_evidence\": {\n    \"work_friendly\": true,\n    \"group_suitability\": null\n  }\n}\n```\n\nThat `null` is useful.\n\nIf I cannot verify group suitability, I do not want the system to quietly convert uncertainty into confidence.\n\nThat is one of the most important lessons I took from this build:\n\nUnsupported should remain unsupported.\n\nAnother decision I made was to avoid letting the final LLM call discover and interpret everything again from scratch.\n\nBy the time Gemini reaches the final synthesis stage, the system already has:\n\nThis makes the final prompt much narrower.\n\nI deliberately keep ranking outside Gemini.\n\nA simplified workflow looks like:\n\n```\nwith trace_span(\n    \"evidence.aggregate_and_score\",\n    attributes={\"workflow.stage\": \"v1\"},\n):\n    ranked_candidates = aggregate_and_score(\n        candidates=candidates,\n        evidence=evidence,\n        intent=intent,\n    )\n```\n\nThe scoring logic can consider signals such as:\n\nThe point is not that one universal formula can rank every local-search problem.\n\nThe point is that the system can explain why one candidate scored higher than another.\n\n``` python\ndef build_static_map_url(\n    ranked_candidates: list[RankedCandidate],\n    *,\n    api_key: Optional[str] = None,\n    max_candidates: int = 3,\n    width: int = 640,\n    height: int = 360,\n    maptype: str = \"roadmap\",\n) -> str:\n    \"\"\"Build a Google Maps Static API URL for the top ranked candidates.\n\n    Args:\n        ranked_candidates: List of ranked candidates.\n        api_key: Optional Google Maps API Key override.\n        max_candidates: Maximum candidate markers to place (default: 3).\n        width: Image width in pixels (default: 640).\n        height: Image height in pixels (default: 360).\n        maptype: Map type (default: 'roadmap').\n\n    Returns:\n        Fully-formed Static Maps URL including API key.\n\n    Raises:\n        ValueError: If GOOGLE_MAPS_API_KEY is not configured or no valid coordinates exist.\n    \"\"\"\n    result = generate_static_map(\n        ranked_candidates,\n        api_key=api_key,\n        max_candidates=max_candidates,\n        width=width,\n        height=height,\n        maptype=maptype,\n    )\n    return result.url\n```\n\nView the complete scoring implementation on [GitHub](https://github.com/hastimal/ai-search-journey-lab/blob/main/src/ai_search_journey/static_map.py)\n\nGemini comes back near the end.\n\n```\nwith trace_span(\n    \"gemini.synthesize_recommendations\",\n    attributes={\"workflow.stage\": \"v1\"},\n):\n    answer = synthesize_recommendations(\n        intent=intent,\n        ranked_candidates=ranked_candidates,\n    )\n```\n\nBy this point, Gemini is not being asked to search the world.\n\nIt is being asked to explain a decision whose evidence is already available.\n\nThat is a much smaller and more controllable problem.\n\nFor each recommendation, I want the output to include useful decision context.\n\n```\n1. Example Coffee\n   Rating: 4.6\n   Open after 8 PM: Yes\n   Work-friendly evidence: Supported\n   Group suitability: Not fully verified\n   Maps: [Open in Google Maps]\n```\n\nThat is much more useful than:\n\n“I recommend Example Coffee because it looks great.”\n\nI did not want the workflow to work only for the one query it was designed around.\n\nSo I also tested:\n\nFind an Indian restaurant near Trinity University for eight students, open after 9 PM, with vegetarian options. Recommend the top three.\n\nThis changes several constraints:\n\nBut the architecture stays the same.\n\nThat is important.\n\nI want a reusable search workflow, not a prompt-specific demo.\n\nI keep normal engineering checks around the AI workflow.\n\nMy standard validation is:\n\n```\n./.venv/bin/ruff check .\n./.venv/bin/mypy src\n./.venv/bin/pytest -q\n```\n\n**The point is simple:**\n\nGemini does not eliminate the need for testable software.\n\nIf anything, combining model behavior with APIs, ranking logic, state, and deployment makes testing more important.\n\nOnce the local implementation was stable, I deployed the same application to Cloud Run.\n\nThat gave me a clean path from local development to a hosted demo.\n\nYou can inspect the service with:\n\n```\ngcloud run services describe ai-search-journey-lab \\\n  --region us-central1 \\\n  --format=\"yaml(\n      metadata.name,\n      status.url,\n      status.latestReadyRevisionName,\n      status.conditions\n  )\"\n```\n\nAnd verify health:\n\n```\ncurl -fsS \\\n\"https://ai-search-journey-lab-642110324230.us-central1.run.app/_stcore/health\"\n```\n\nLet's run locally!\n\nLet's run the same Geekdom query from the deployed Google cloud service.\n\nThe biggest lesson was that local AI search is not just:\n\n**prompt → model → answer**\n\nIt is closer to:\n\n**natural-language intent → retrieval plan → entity search → evidence search → identity normalization → constraint verification → deterministic ranking → grounded explanation → actionable Maps result**\n\nThat distinction is important.\n\nBecause once the application has multiple evidence sources and multiple ranking signals, the LLM becomes one component in the system rather than the entire system.\n\nThat is the architecture I wanted.\n\nThis local-search workflow also became a useful foundation for the later agent work in the repository.\n\nOnce the system already has:\n\nit becomes much easier to add agent orchestration later.\n\nThat is where Google ADK eventually enters the story in Part 4.\n\nBut I deliberately did not start there.\n\nI first wanted a workflow I could understand without an agent.\n\nThen I could add the agent layer intentionally.\n\nIn the next article, I will move from:\n\nWhich business should this user consider?\n\nto:\n\nHow visible is a brand across AI-generated search journeys?\n\nI will cover:\n\n**Repository**: [GitHub Repository](https://github.com/hastimal/ai-search-journey-lab/tree/main)\n\n**Local startup:**\n\n```\npython scripts/run_app_locally.py\n```\n\n", "url": "https://wpnews.pro/news/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud", "canonical_source": "https://dev.to/hjangid/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud-53lm", "published_at": "2026-09-29 04:09:07+00:00", "updated_at": "2026-09-29 04:16:47.428042+00:00", "lang": "en", "topics": ["ai-search", "ai-agents", "generative-ai", "ai-tools", "developer-tools"], "entities": ["Gemini", "Google Places API", "Google Search", "Google Cloud", "Cloud Run", "Geekdom", "ai-search-journey-lab"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud", "markdown": "https://wpnews.pro/news/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud.md", "text": "https://wpnews.pro/news/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud.txt", "jsonld": "https://wpnews.pro/news/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud.jsonld"}}