Building Grounded Local Search with Gemini and Google Maps on Google Cloud 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. Building a grounded local-search workflow with Gemini, Google Places API New , Google Search grounding, deterministic ranking, Maps links, and Cloud Run. Open source: ai-search-journey-lab https://github.com/hastimal/ai-search-journey-lab Previous 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 In Part 1, I explained the journey. In Part 2, I want to show the implementation. In the first article, I focused on the architecture behind a search journey: intent → query fan-out → retrieval → evidence → deduplication → ranking → grounded answer That was the conceptual layer. This article is more practical. I want to show how I built the actual local-search workflow behind that architecture using: The original demo query is still the same: Find a coffee shop near Geekdom in San Antonio for six people, quiet enough to work, open after 8 PM, and recommend the top three. The difference now is that I want to focus on what happens after the intent has been understood. The local-search problem is really two retrieval problems When I first started implementing this workflow, I realized that one retrieval system was not enough. Some constraints are structured. For example: Google Places is a very good fit for those. But some user requirements are much softer: Those do not always map cleanly to one structured field. That led me to split retrieval into two paths : Google Places for structured local data and Google Search grounding for additional evidence That separation is at the heart of this article. The V1 flow for grounded local search looks like this: User Query → Gemini Intent → Query Fan-Out → Places API + Google Search Grounding → Normalize → Evidence → Score → Gemini Synthesis → Maps-linked Recommendations I do not call Places directly with the full user prompt. First, I want a structured representation of what the user is asking for. Conceptually: { "category": "coffee shop", "location reference": "Geekdom, San Antonio", "party size": 6, "open after": "20:00", "preferences": "quiet", "work-friendly" , "result count": 3 } The important thing here is that the retrieval layer no longer has to interpret every phrase from scratch. Gemini handles the language ambiguity. The application gets a predictable structure. Once I have the intent, I generate a bounded retrieval plan. A simplified example might look like: { "places tasks": { "query": "coffee shops near Geekdom San Antonio" }, { "query": "work-friendly coffee shops downtown San Antonio" } , "search tasks": { "query": "coffee near Geekdom quiet work open late" } } I deliberately keep this small. The goal is not to generate ten variants of the same search . The goal is to cover enough of the user's intent without creating unnecessary duplication and latency. For local search, Google Places API New gives me the first candidate set. A simplified Text Search call looks like this: php import requests def search places query: str, api key: str - list dict : endpoint = "https://places.googleapis.com/v1/places:searchText" payload = { "textQuery": query, "pageSize": 10, } headers = { "Content-Type": "application/json", "X-Goog-Api-Key": api key, "X-Goog-FieldMask": ",".join "places.id", "places.displayName", "places.formattedAddress", "places.rating", "places.userRatingCount", "places.currentOpeningHours", "places.googleMapsUri", , } response = requests.post endpoint, json=payload, headers=headers, timeout=20, response.raise for status return response.json .get "places", What matters most to me here is not the HTTP request itself. It is the set of fields that become available to downstream logic. For this workflow, I do not need every possible field. I only want the fields that are useful for the decision. places.id places.displayName places.formattedAddress places.rating places.userRatingCount places.currentOpeningHours places.googleMapsUri This keeps the retrieval boundary explicit. It also makes it easier to understand which parts of the final recommendation came directly from Places data. One thing I did not appreciate enough at the beginning was how important entity identity would become. Suppose two fan-out tasks return the same business . Without normalization , I might end up with: Candidate A Candidate A Candidate B Candidate C That creates a ranking problem . So I use a canonical identifier such as Place ID to determine whether I am looking at the same entity. A simplified deduplication step looks like: php def deduplicate places candidates: list dict - list dict : unique: dict str, dict = {} for candidate in candidates: place id = candidate "place id" if place id not in unique: unique place id = candidate continue unique place id = merge candidate unique place id , candidate, return list unique.values The order matters: retrieve → normalize identity → deduplicate → rank NOT retrieve → rank duplicates → fix identity later One thing I wanted from the beginning was that the recommendation should not end as plain text. If the user asks for a local business , the result should be actionable . That is why I keep the Google Maps URI returned by Places. candidate = { "name": place "displayName" "text" , "address": place "formattedAddress" , "rating": place.get "rating" , "maps url": place.get "googleMapsUri" , } Then the final recommendation can give the user a direct route from: AI recommendation to Google Maps That sounds small, but it changes the experience from “ read an AI answer ” to “ act on an AI answer. ” Now we reach the interesting part. Google Places can tell me: But the original user also asked for: quiet enough to work That is a different class of requirement. There may not be a clean structured field for it. So I added a second evidence path using Google Search grounding. I do not ask Gemini a broad question like: Is this coffee shop suitable? I make the verification task more specific. Candidate: Example Coffee Verify: 1. evidence that it is open late 2. evidence relevant to working/studying 3. evidence relevant to seating/group suitability For each constraint: - supported - unsupported - supporting evidence - citation Conceptually, the call sits behind a workflow stage like: with trace span "search grounding.verify evidence", attributes={"workflow.stage": "v1"}, : evidence = verify candidates candidates=candidates, intent=intent, This gives me a richer candidate representation. { "place id": "abc123", "name": "Example Coffee", "places": { "rating": 4.6, "review count": 825, "open after 8": true }, "search evidence": { "work friendly": true, "group suitability": null } } That null is useful. If I cannot verify group suitability, I do not want the system to quietly convert uncertainty into confidence. That is one of the most important lessons I took from this build: Unsupported should remain unsupported. Another decision I made was to avoid letting the final LLM call discover and interpret everything again from scratch. By the time Gemini reaches the final synthesis stage, the system already has: This makes the final prompt much narrower. I deliberately keep ranking outside Gemini. A simplified workflow looks like: with trace span "evidence.aggregate and score", attributes={"workflow.stage": "v1"}, : ranked candidates = aggregate and score candidates=candidates, evidence=evidence, intent=intent, The scoring logic can consider signals such as: The point is not that one universal formula can rank every local-search problem. The point is that the system can explain why one candidate scored higher than another. python def build static map url ranked candidates: list RankedCandidate , , api key: Optional str = None, max candidates: int = 3, width: int = 640, height: int = 360, maptype: str = "roadmap", - str: """Build a Google Maps Static API URL for the top ranked candidates. Args: ranked candidates: List of ranked candidates. api key: Optional Google Maps API Key override. max candidates: Maximum candidate markers to place default: 3 . width: Image width in pixels default: 640 . height: Image height in pixels default: 360 . maptype: Map type default: 'roadmap' . Returns: Fully-formed Static Maps URL including API key. Raises: ValueError: If GOOGLE MAPS API KEY is not configured or no valid coordinates exist. """ result = generate static map ranked candidates, api key=api key, max candidates=max candidates, width=width, height=height, maptype=maptype, return result.url View the complete scoring implementation on GitHub https://github.com/hastimal/ai-search-journey-lab/blob/main/src/ai search journey/static map.py Gemini comes back near the end. with trace span "gemini.synthesize recommendations", attributes={"workflow.stage": "v1"}, : answer = synthesize recommendations intent=intent, ranked candidates=ranked candidates, By this point, Gemini is not being asked to search the world. It is being asked to explain a decision whose evidence is already available. That is a much smaller and more controllable problem. For each recommendation, I want the output to include useful decision context. 1. Example Coffee Rating: 4.6 Open after 8 PM: Yes Work-friendly evidence: Supported Group suitability: Not fully verified Maps: Open in Google Maps That is much more useful than: “I recommend Example Coffee because it looks great.” I did not want the workflow to work only for the one query it was designed around. So I also tested: Find an Indian restaurant near Trinity University for eight students, open after 9 PM, with vegetarian options. Recommend the top three. This changes several constraints: But the architecture stays the same. That is important. I want a reusable search workflow, not a prompt-specific demo. I keep normal engineering checks around the AI workflow. My standard validation is: ./.venv/bin/ruff check . ./.venv/bin/mypy src ./.venv/bin/pytest -q The point is simple: Gemini does not eliminate the need for testable software. If anything, combining model behavior with APIs, ranking logic, state, and deployment makes testing more important. Once the local implementation was stable, I deployed the same application to Cloud Run. That gave me a clean path from local development to a hosted demo. You can inspect the service with: gcloud run services describe ai-search-journey-lab \ --region us-central1 \ --format="yaml metadata.name, status.url, status.latestReadyRevisionName, status.conditions " And verify health: curl -fsS \ "https://ai-search-journey-lab-642110324230.us-central1.run.app/ stcore/health" Let's run locally Let's run the same Geekdom query from the deployed Google cloud service. The biggest lesson was that local AI search is not just: prompt → model → answer It is closer to: natural-language intent → retrieval plan → entity search → evidence search → identity normalization → constraint verification → deterministic ranking → grounded explanation → actionable Maps result That distinction is important. Because once the application has multiple evidence sources and multiple ranking signals, the LLM becomes one component in the system rather than the entire system. That is the architecture I wanted. This local-search workflow also became a useful foundation for the later agent work in the repository. Once the system already has: it becomes much easier to add agent orchestration later. That is where Google ADK eventually enters the story in Part 4. But I deliberately did not start there. I first wanted a workflow I could understand without an agent. Then I could add the agent layer intentionally. In the next article, I will move from: Which business should this user consider? to: How visible is a brand across AI-generated search journeys? I will cover: Repository : GitHub Repository https://github.com/hastimal/ai-search-journey-lab/tree/main Local startup: python scripts/run app locally.py