AI Search Journey Lab — Part 2 of 7
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:
- Gemini
- Google Places API (New)
- Google Search grounding
- Google Maps links
- deterministic ranking
- Python
- Streamlit
- Cloud Run
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:
- business name
- address
- rating
- review count
- opening hours
- Place ID
- Maps URL
Google Places is a very good fit for those.
But some user requirements are much softer:
- quiet enough to work
- suitable for a group
- good vegetarian options
- comfortable for studying
- appropriate for a child who is anxious about dental visits
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 architecture
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
Step 1: Start from structured intent
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.
Step 2: Turn intent into retrieval tasks
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.
Step 3: Google Places becomes the structured candidate source
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.
I use field masks intentionally
For this workflow, I do not need every possible field.
I only want the fields that are useful for the decision.
For example:
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.
Step 4: Place ID becomes the anchor for identity
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
Step 5: Generate Google Maps links as part of the decision output
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.
Conceptually:
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.”
Step 6: Places alone did not solve the problem
Now we reach the interesting part.
Google Places can tell me:
- the business exists
- where it is
- its rating
- its opening hours
- its Maps URL
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.
Step 7: Ground the softer constraints
I do not ask Gemini a broad question like:
Is this coffee shop suitable?
I make the verification task more specific.
For example:
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.
A candidate now has two kinds of evidence
Conceptually:
{
"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.
Step 8: I keep evidence matching separate from synthesis
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:
- structured intent
- candidate identities
- Places data
- grounded Search evidence
- unsupported constraints
- scores
- rank order
This makes the final prompt much narrower.
Step 9: Deterministic ranking before final synthesis
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:
- location relevance
- category match
- opening-hour compatibility
- rating
- rating count
- constraint coverage
- grounded evidence
- unsupported constraints
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
Step 10: Gemini handles the final explanation
Gemini comes back near the end.
Conceptually:
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.
What the final answer should contain
For each recommendation, I want the output to include useful decision context.
For example:
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.”
Step 11: Validate the architecture with another query
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:
- category
- geographic anchor
- party size
- hours
- dietary preference
But the architecture stays the same.
That is important.
I want a reusable search workflow, not a prompt-specific demo.
Step 12: Test the workflow like normal software
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.
Step 13: Run the same workflow on Cloud Run
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.
What I learned from building the local-search layer
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.
Why this matters for agentic systems
This local-search workflow also became a useful foundation for the later agent work in the repository.
Once the system already has:
- structured intent
- tools
- explicit retrieval
- evidence
- state
- ranking
- bounded outputs
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.
Next: Building an AI Search Visibility & Brand Analyzer with Gemini, BigQuery, and Google Search Grounding
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:
- target brands
- competitors
- visibility runs
- mentions
- citations
- fan-out coverage
- deterministic visibility analysis
- BigQuery history
- visibility trends
Try the project
Repository: GitHub Repository
Local startup:
python scripts/run_app_locally.py










Top comments (1)
GitHub repo - github.com/hastimal/ai-search-jour...