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
Previous article: Search Journey Optimization with Gemini: From Query Fan-Out to Grounded Decisions
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:
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:
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.
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
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
Local startup:
python scripts/run_app_locally.py