<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hastimal Jangid</title>
    <description>The latest articles on DEV Community by Hastimal Jangid (@hjangid).</description>
    <link>https://dev.to/hjangid</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4034691%2Fddb3be5f-665a-4fd7-aba8-19b6ad3b1ca1.jpg</url>
      <title>DEV Community: Hastimal Jangid</title>
      <link>https://dev.to/hjangid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hjangid"/>
    <language>en</language>
    <item>
      <title>Building an AI Search Visibility &amp; Brand Analyzer with Gemini, BigQuery, and Google Search Grounding</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Tue, 29 Sep 2026 05:22:17 +0000</pubDate>
      <link>https://dev.to/hjangid/building-an-ai-search-visibility-brand-analyzer-with-gemini-bigquery-and-google-search-grounding-286g</link>
      <guid>https://dev.to/hjangid/building-an-ai-search-visibility-brand-analyzer-with-gemini-bigquery-and-google-search-grounding-286g</guid>
      <description>&lt;h2&gt;
  
  
  AI Search Journey Lab — Part 3 of 7
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Measuring how brands appear across AI search journeys using Gemini, Google Search grounding, deterministic visibility scans, and BigQuery history.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Open source:&lt;/strong&gt; &lt;a href="https://github.com/hastimal/ai-search-journey-lab/tree/main" rel="noopener noreferrer"&gt;ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Previous:&lt;/strong&gt; &lt;a href="https://dev.to/hjangid/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud-53lm"&gt;Building Grounded Local Search with Gemini and Google Maps on Google Cloud&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The first two articles answered a user question. This one measures the system itself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Part 1, I focused on the search journey:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How does one natural-language request become a grounded decision?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Part 2, I went deeper into the actual local-search implementation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do Gemini, Google Places, Search grounding, deterministic ranking, and Maps work together?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once that worked, I started asking a different question.&lt;/p&gt;

&lt;p&gt;Suppose I run the same kind of search repeatedly.&lt;/p&gt;

&lt;p&gt;What happens if instead of only asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which business should the user choose?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which brands keep appearing?&lt;br&gt;
Which brands are cited?&lt;br&gt;
Which competitors show up more often?&lt;br&gt;
Does a brand appear in the initial answer, the fan-out journey, or only in supporting evidence?&lt;br&gt;
How does that change over time?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the starting point for V3 — AI Visibility.&lt;/p&gt;

&lt;p&gt;This article is about turning an AI search workflow into something measurable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why AI visibility needs a different data model
&lt;/h2&gt;

&lt;p&gt;A single search result is transient.&lt;/p&gt;

&lt;p&gt;If I want to measure visibility, I need history.&lt;/p&gt;

&lt;p&gt;One run is interesting.&lt;/p&gt;

&lt;p&gt;Fifty runs are analyzable.&lt;/p&gt;

&lt;p&gt;Five hundred runs can start showing patterns.&lt;/p&gt;

&lt;p&gt;So V3 introduced a new concern into the project: &lt;strong&gt;persistence&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is where BigQuery enters the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I wanted to measure
&lt;/h2&gt;

&lt;p&gt;For each visibility run, I wanted to capture things such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;target brand&lt;/li&gt;
&lt;li&gt;competitors&lt;/li&gt;
&lt;li&gt;original query&lt;/li&gt;
&lt;li&gt;fan-out tasks&lt;/li&gt;
&lt;li&gt;mentions&lt;/li&gt;
&lt;li&gt;citations&lt;/li&gt;
&lt;li&gt;candidate coverage&lt;/li&gt;
&lt;li&gt;source coverage&lt;/li&gt;
&lt;li&gt;ranking position&lt;/li&gt;
&lt;li&gt;execution timestamp&lt;/li&gt;
&lt;li&gt;historical trend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important shift was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;V1/V2:
user query → recommendation

V3:
many queries → repeatable measurements
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That turns the project from a search demo (which we have planned) into an analytics system. :) &lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqcw4w3sv2zoy3svw5pn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqcw4w3sv2zoy3svw5pn.png" alt="Building an AI Search Visibility &amp;amp; Brand Analyzer with Gemini" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: I made visibility scans deterministic
&lt;/h2&gt;

&lt;p&gt;One important design choice was that V3 should not rely on a user manually clicking around and interpreting the results.&lt;/p&gt;

&lt;p&gt;I wanted repeatable runs.&lt;/p&gt;

&lt;p&gt;That means the runner should accept an explicit input scope and execute the same workflow consistently.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VisibilityRun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;target_brand&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Example Brand&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;competitors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Competitor A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Competitor B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;best local coffee shops in San Antonio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet coffee shop for remote work&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the runner executes the scan and records the results.&lt;/p&gt;

&lt;p&gt;The goal is reproducibility.&lt;/p&gt;

&lt;p&gt;If I run the same scenario tomorrow, I want to compare the resulting data rather than rely on screenshots or memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nfgjolhk6cunb8qyqs6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nfgjolhk6cunb8qyqs6.png" alt="AI Search Visibility" width="799" height="772"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Separate mention detection from visibility scoring
&lt;/h2&gt;

&lt;p&gt;A brand appearing in a response is not the same as a brand being strongly visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;brand:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;RankRabbit&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Response:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="s2"&gt;"Other platforms include RankRabbit, Competitor A, and Competitor B."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a mention.&lt;/p&gt;

&lt;p&gt;But now &lt;strong&gt;compare&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. RankRabbit — recommended first
2. Competitor A
3. Competitor B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two appearances should not necessarily have the same visibility weight.&lt;/p&gt;

&lt;p&gt;So I separate several concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mention presence&lt;/li&gt;
&lt;li&gt;position&lt;/li&gt;
&lt;li&gt;citation presence&lt;/li&gt;
&lt;li&gt;fan-out coverage&lt;/li&gt;
&lt;li&gt;narrative prominence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That lets the application calculate visibility metrics explicitly instead of collapsing everything into one LLM judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Capture evidence before calculating visibility
&lt;/h2&gt;

&lt;p&gt;The workflow should preserve what caused a brand to be counted.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"brand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Brand"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mentioned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citation_present"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fanout_coverage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.67&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"evidence_sources"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"source-a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"source-b"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That data is much more valuable later than a single score such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;visibility_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;74&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because it lets me explain where the score came from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Persist visibility history in BigQuery
&lt;/h2&gt;

&lt;p&gt;V3 can run in-memory for a quick demo, but historical analysis needs persistence.&lt;/p&gt;

&lt;p&gt;I used a &lt;em&gt;BigQuery&lt;/em&gt; dataset for that purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Project:
ai-search-journey-lab

Dataset:
ai_search_journey_v3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's explore how bigQuery tables look like.....&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;bq&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="c1"&gt;--use_legacy_sql=false \&lt;/span&gt;
  &lt;span class="s1"&gt;'
  SELECT
    table_name,
    table_type,
    creation_time
  FROM
    `ai-search-journey-lab.ai_search_journey_v3.INFORMATION_SCHEMA.TABLES`
  ORDER BY
    table_name
  '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsozflotc0y5vg95vm1ko.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsozflotc0y5vg95vm1ko.png" alt="Bigquery dataset" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Query historical visibility with SQL
&lt;/h2&gt;

&lt;p&gt;Once I started persisting V3 runs in BigQuery, I no longer had to rely only on what the Streamlit dashboard displayed.&lt;/p&gt;

&lt;p&gt;I could query the visibility history directly from the terminal.&lt;br&gt;
That became one of my favorite parts of V3 because it gave me a second way to validate everything the UI was showing.&lt;/p&gt;

&lt;p&gt;For all of the examples below, I use the BigQuery CLI:&lt;/p&gt;

&lt;p&gt;Once scan results are persisted, SQL becomes one of the most useful tools in the project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frwil79mlf3uokmn8mlwi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frwil79mlf3uokmn8mlwi.png" alt="Big Query visibility scans" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemini</category>
      <category>bigquery</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Building Grounded Local Search with Gemini and Google Maps on Google Cloud</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Tue, 29 Sep 2026 04:09:07 +0000</pubDate>
      <link>https://dev.to/hjangid/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud-53lm</link>
      <guid>https://dev.to/hjangid/building-grounded-local-search-with-gemini-and-google-maps-on-google-cloud-53lm</guid>
      <description>&lt;h2&gt;
  
  
  AI Search Journey Lab — Part 2 of 7
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Building a grounded local-search workflow with Gemini, Google Places API (New), Google Search grounding, deterministic ranking, Maps links, and Cloud Run.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Open source:&lt;/strong&gt; &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Previous article: &lt;a href="https://dev.to/hjangid/search-journey-optimization-with-gemini-from-query-fan-out-to-grounded-decisions-3f99"&gt;Search Journey Optimization with Gemini: From Query Fan-Out to Grounded Decisions&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;In Part 1, I explained the journey. In Part 2, I want to show the implementation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the first article, I focused on the architecture behind a search journey:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;intent → query fan-out → retrieval → evidence → deduplication → ranking → grounded answer&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the conceptual layer.&lt;/p&gt;

&lt;p&gt;This article is more practical.&lt;/p&gt;

&lt;p&gt;I want to show how I built the actual local-search workflow behind that architecture using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemini&lt;/li&gt;
&lt;li&gt;Google Places API (New)&lt;/li&gt;
&lt;li&gt;Google Search grounding&lt;/li&gt;
&lt;li&gt;Google Maps links&lt;/li&gt;
&lt;li&gt;deterministic ranking&lt;/li&gt;
&lt;li&gt;Python&lt;/li&gt;
&lt;li&gt;Streamlit&lt;/li&gt;
&lt;li&gt;Cloud Run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original demo query is still the same:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The difference now is that I want to focus on what happens after the intent has been understood.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The local-search problem is really two retrieval problems&lt;/strong&gt;&lt;br&gt;
When I first started implementing this workflow, I realized that one retrieval system was not enough.&lt;/p&gt;

&lt;p&gt;Some constraints are structured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;business name&lt;/li&gt;
&lt;li&gt;address&lt;/li&gt;
&lt;li&gt;rating&lt;/li&gt;
&lt;li&gt;review count&lt;/li&gt;
&lt;li&gt;opening hours&lt;/li&gt;
&lt;li&gt;Place ID&lt;/li&gt;
&lt;li&gt;Maps URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google Places is a very good fit for those.&lt;/p&gt;

&lt;p&gt;But some user requirements are much softer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;quiet enough to work&lt;/li&gt;
&lt;li&gt;suitable for a group&lt;/li&gt;
&lt;li&gt;good vegetarian options&lt;/li&gt;
&lt;li&gt;comfortable for studying&lt;/li&gt;
&lt;li&gt;appropriate for a child who is anxious about dental visits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those do not always map cleanly to one structured field.&lt;/p&gt;

&lt;p&gt;That led me to split retrieval into &lt;strong&gt;two paths&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Google Places for structured local data&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Google Search grounding for additional evidence&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That separation is at the heart of this article.&lt;/p&gt;


&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The V1 flow for grounded local search looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;User Query → Gemini Intent → Query Fan-Out → Places API + Google Search Grounding → Normalize → Evidence → Score → Gemini Synthesis → Maps-linked Recommendations&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw14523uxzrxw0ujcuk5g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw14523uxzrxw0ujcuk5g.png" alt="Grounded local search architecture" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Start from structured intent
&lt;/h2&gt;

&lt;p&gt;I do not call Places directly with the full user prompt.&lt;/p&gt;

&lt;p&gt;First, I want a structured representation of what the user is asking for.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee_shop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location_reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Geekdom, San Antonio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"party_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"open_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"quiet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"work-friendly"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing here is that the retrieval layer no longer has to interpret every phrase from scratch.&lt;/p&gt;

&lt;p&gt;Gemini handles the language ambiguity.&lt;/p&gt;

&lt;p&gt;The application gets a predictable structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Turn intent into retrieval tasks
&lt;/h2&gt;

&lt;p&gt;Once I have the intent, I generate a bounded retrieval plan.&lt;/p&gt;

&lt;p&gt;A simplified example might look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"places_tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee shops near Geekdom San Antonio"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"work-friendly coffee shops downtown San Antonio"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"search_tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee near Geekdom quiet work open late"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deliberately keep this small.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;goal&lt;/em&gt; is &lt;strong&gt;not to generate ten variants of the same search&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The goal is to cover enough of the user's intent without creating unnecessary duplication and latency.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzy8gs6sx6i5h6ny8h32.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffzy8gs6sx6i5h6ny8h32.png" alt="Query Fanout Places and Search" width="800" height="1119"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Google Places becomes the structured candidate source
&lt;/h2&gt;

&lt;p&gt;For local search, Google Places API (New) gives me the first candidate set.&lt;/p&gt;

&lt;p&gt;A simplified Text Search call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://places.googleapis.com/v1/places:searchText&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;textQuery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pageSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-Api-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-FieldMask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.formattedAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.userRatingCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.currentOpeningHours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.googleMapsUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What matters most to me here is not the HTTP request itself.&lt;/p&gt;

&lt;p&gt;It is the set of fields that become available to downstream logic.&lt;/p&gt;




&lt;h2&gt;
  
  
  I use field masks intentionally
&lt;/h2&gt;

&lt;p&gt;For this workflow, I do not need every possible field.&lt;/p&gt;

&lt;p&gt;I only want the fields that are useful for the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;places.id
places.displayName
places.formattedAddress
places.rating
places.userRatingCount
places.currentOpeningHours
places.googleMapsUri
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the retrieval boundary explicit.&lt;/p&gt;

&lt;p&gt;It also makes it easier to understand which parts of the final recommendation came directly from Places data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Place ID becomes the anchor for identity
&lt;/h2&gt;

&lt;p&gt;One thing I did not appreciate enough at the beginning was how important entity identity would become.&lt;/p&gt;

&lt;p&gt;Suppose &lt;strong&gt;two&lt;/strong&gt; fan-out tasks return the &lt;strong&gt;same&lt;/strong&gt; &lt;strong&gt;business&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Without &lt;em&gt;normalization&lt;/em&gt;, I might end up with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Candidate A
Candidate A
Candidate B
Candidate C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That creates a &lt;strong&gt;ranking&lt;/strong&gt; &lt;strong&gt;problem&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I use a canonical identifier such as &lt;code&gt;Place ID&lt;/code&gt; to determine whether I am looking at the same entity.&lt;/p&gt;

&lt;p&gt;A simplified deduplication step looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deduplicate_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;place_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → normalize identity → deduplicate → rank&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;NOT&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → rank duplicates → fix identity later&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzngj63e68el6idqykn2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzngj63e68el6idqykn2.png" alt="Places results in search journey" width="800" height="1429"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Generate Google Maps links as part of the decision output
&lt;/h2&gt;

&lt;p&gt;One thing I wanted from the beginning was that the recommendation should not end as plain text.&lt;/p&gt;

&lt;p&gt;If the user asks for a &lt;strong&gt;local business&lt;/strong&gt;, the result should be &lt;strong&gt;actionable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is why I keep the Google Maps URI returned by Places.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formattedAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maps_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;place&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;googleMapsUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the final recommendation can give the user a direct route from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI recommendation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google Maps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That sounds small, but it changes the experience from “&lt;strong&gt;read an AI answer&lt;/strong&gt;” to “&lt;strong&gt;act on an AI answer.&lt;/strong&gt;”&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Places alone did not solve the problem
&lt;/h2&gt;

&lt;p&gt;Now we reach the interesting part.&lt;/p&gt;

&lt;p&gt;Google Places can tell me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the business exists&lt;/li&gt;
&lt;li&gt;where it is&lt;/li&gt;
&lt;li&gt;its rating&lt;/li&gt;
&lt;li&gt;its opening hours&lt;/li&gt;
&lt;li&gt;its Maps URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the original user also asked for:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;quiet enough to work&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a different class of requirement.&lt;/p&gt;

&lt;p&gt;There may not be a clean structured field for it.&lt;/p&gt;

&lt;p&gt;So I added a second evidence path using Google Search grounding.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Ground the softer constraints
&lt;/h2&gt;

&lt;p&gt;I do not ask Gemini a broad question like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this coffee shop suitable?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I make the verification task more specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually, the call sits behind a workflow stage like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_grounding.verify_evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives me a richer candidate representation.&lt;/p&gt;




&lt;h2&gt;
  
  
  A candidate now has two kinds of evidence
&lt;/h2&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"place_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Example Coffee"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"places"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"review_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;825&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"open_after_8"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"search_evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"work_friendly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"group_suitability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;null&lt;/code&gt; is useful.&lt;/p&gt;

&lt;p&gt;If I cannot verify group suitability, I do not want the system to quietly convert uncertainty into confidence.&lt;/p&gt;

&lt;p&gt;That is one of the most important lessons I took from this build:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unsupported should remain unsupported.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpkm2n9z53psoy3g5iew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frpkm2n9z53psoy3g5iew.png" alt="Structured &amp;amp; Grounded Evidence by Candidate" width="800" height="848"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: I keep evidence matching separate from synthesis
&lt;/h2&gt;

&lt;p&gt;Another decision I made was to avoid letting the final LLM call discover and interpret everything again from scratch.&lt;/p&gt;

&lt;p&gt;By the time Gemini reaches the final synthesis stage, the system already has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structured intent&lt;/li&gt;
&lt;li&gt;candidate identities&lt;/li&gt;
&lt;li&gt;Places data&lt;/li&gt;
&lt;li&gt;grounded Search evidence&lt;/li&gt;
&lt;li&gt;unsupported constraints&lt;/li&gt;
&lt;li&gt;scores&lt;/li&gt;
&lt;li&gt;rank order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes the final prompt much narrower.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Deterministic ranking before final synthesis
&lt;/h2&gt;

&lt;p&gt;I deliberately keep ranking outside Gemini.&lt;/p&gt;

&lt;p&gt;A simplified workflow looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evidence.aggregate_and_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ranked_candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;aggregate_and_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scoring logic can consider signals such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;location relevance&lt;/li&gt;
&lt;li&gt;category match&lt;/li&gt;
&lt;li&gt;opening-hour compatibility&lt;/li&gt;
&lt;li&gt;rating&lt;/li&gt;
&lt;li&gt;rating count&lt;/li&gt;
&lt;li&gt;constraint coverage&lt;/li&gt;
&lt;li&gt;grounded evidence&lt;/li&gt;
&lt;li&gt;unsupported constraints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not that one universal formula can rank every local-search problem.&lt;/p&gt;

&lt;p&gt;The point is that the system can explain why one candidate scored higher than another.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_static_map_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;RankedCandidate&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;maptype&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roadmap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;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: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;roadmap&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;).

    Returns:
        Fully-formed Static Maps URL including API key.

    Raises:
        ValueError: If GOOGLE_MAPS_API_KEY is not configured or no valid coordinates exist.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_static_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;max_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;maptype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;maptype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;View the complete scoring implementation on &lt;a href="https://github.com/hastimal/ai-search-journey-lab/blob/main/src/ai_search_journey/static_map.py" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuv03879m8oupg6ndmbrm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuv03879m8oupg6ndmbrm.png" alt="Score and Ranking" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Gemini handles the final explanation
&lt;/h2&gt;

&lt;p&gt;Gemini comes back near the end.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.synthesize_recommendations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;synthesize_recommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By this point, Gemini is not being asked to search the world.&lt;/p&gt;

&lt;p&gt;It is being asked to explain a decision whose evidence is already available.&lt;/p&gt;

&lt;p&gt;That is a much smaller and more controllable problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the final answer should contain
&lt;/h2&gt;

&lt;p&gt;For each recommendation, I want the output to include useful decision context.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is much more useful than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“I recommend Example Coffee because it looks great.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jxusu366h22bux2eh39.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jxusu366h22bux2eh39.png" alt="Final Results" width="800" height="432"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Validate the architecture with another query
&lt;/h2&gt;

&lt;p&gt;I did not want the workflow to work only for the one query it was designed around.&lt;/p&gt;

&lt;p&gt;So I also tested:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find an Indian restaurant near Trinity University for eight students, open after 9 PM, with vegetarian options. Recommend the top three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This changes several constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;category&lt;/li&gt;
&lt;li&gt;geographic anchor&lt;/li&gt;
&lt;li&gt;party size&lt;/li&gt;
&lt;li&gt;hours&lt;/li&gt;
&lt;li&gt;dietary preference&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the architecture stays the same.&lt;/p&gt;

&lt;p&gt;That is important.&lt;/p&gt;

&lt;p&gt;I want a reusable search workflow, not a prompt-specific demo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2t8bk5rk9nn1q7ec0xme.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2t8bk5rk9nn1q7ec0xme.png" alt="Indian restaurant near Trinity University" width="800" height="1352"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 12: Test the workflow like normal software
&lt;/h2&gt;

&lt;p&gt;I keep normal engineering checks around the AI workflow.&lt;/p&gt;

&lt;p&gt;My standard validation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./.venv/bin/ruff check &lt;span class="nb"&gt;.&lt;/span&gt;
./.venv/bin/mypy src
./.venv/bin/pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The point is simple:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Gemini does not eliminate the need for testable software.&lt;/p&gt;

&lt;p&gt;If anything, combining model behavior with APIs, ranking logic, state, and deployment makes testing more important.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaof809cqk6hqn35ivrw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhaof809cqk6hqn35ivrw.png" alt="tests CLI loclaly description" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 13: Run the same workflow on Cloud Run
&lt;/h2&gt;

&lt;p&gt;Once the local implementation was stable, I deployed the same application to Cloud Run.&lt;/p&gt;

&lt;p&gt;That gave me a clean path from local development to a hosted demo.&lt;/p&gt;

&lt;p&gt;You can inspect the service with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services describe ai-search-journey-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"yaml(
      metadata.name,
      status.url,
      status.latestReadyRevisionName,
      status.conditions
  )"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And verify health:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s2"&gt;"https://ai-search-journey-lab-642110324230.us-central1.run.app/_stcore/health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's run locally!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fach3bt76wgillkffevic.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fach3bt76wgillkffevic.png" alt="Cloud Run CLI" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's run the same Geekdom query from the deployed Google cloud service.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqhzpswi3zi28enbapgbs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqhzpswi3zi28enbapgbs.png" alt="Geekdom query in Cloud" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned from building the local-search layer
&lt;/h2&gt;

&lt;p&gt;The biggest lesson was that local AI search is not just:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;prompt → model → answer&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is closer to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;natural-language intent → retrieval plan → entity search → evidence search → identity normalization → constraint verification → deterministic ranking → grounded explanation → actionable Maps result&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distinction is important.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That is the architecture I wanted.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this matters for agentic systems
&lt;/h2&gt;

&lt;p&gt;This local-search workflow also became a useful foundation for the later agent work in the repository.&lt;/p&gt;

&lt;p&gt;Once the system already has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structured intent&lt;/li&gt;
&lt;li&gt;tools&lt;/li&gt;
&lt;li&gt;explicit retrieval&lt;/li&gt;
&lt;li&gt;evidence&lt;/li&gt;
&lt;li&gt;state&lt;/li&gt;
&lt;li&gt;ranking&lt;/li&gt;
&lt;li&gt;bounded outputs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;it becomes much easier to add agent orchestration later.&lt;/p&gt;

&lt;p&gt;That is where Google ADK eventually enters the story in Part 4.&lt;/p&gt;

&lt;p&gt;But I deliberately did not start there.&lt;/p&gt;

&lt;p&gt;I first wanted a workflow I could understand without an agent.&lt;br&gt;
Then I could add the agent layer intentionally.&lt;/p&gt;


&lt;h2&gt;
  
  
  Next: Building an AI Search Visibility &amp;amp; Brand Analyzer with Gemini, BigQuery, and Google Search Grounding
&lt;/h2&gt;

&lt;p&gt;In the next article, I will move from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which business should this user consider?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How visible is a brand across AI-generated search journeys?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I will cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;target brands&lt;/li&gt;
&lt;li&gt;competitors&lt;/li&gt;
&lt;li&gt;visibility runs&lt;/li&gt;
&lt;li&gt;mentions&lt;/li&gt;
&lt;li&gt;citations&lt;/li&gt;
&lt;li&gt;fan-out coverage&lt;/li&gt;
&lt;li&gt;deterministic visibility analysis&lt;/li&gt;
&lt;li&gt;BigQuery history&lt;/li&gt;
&lt;li&gt;visibility trends&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Try the project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repository&lt;/strong&gt;: &lt;a href="https://github.com/hastimal/ai-search-journey-lab/tree/main" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local startup:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;run_app_locally&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>gemini</category>
      <category>gemma</category>
      <category>googlemaps</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Search Journey Optimization with Gemini: From Query Fan-Out to Grounded Decisions</title>
      <dc:creator>Hastimal Jangid</dc:creator>
      <pubDate>Mon, 28 Sep 2026 21:29:53 +0000</pubDate>
      <link>https://dev.to/hjangid/search-journey-optimization-with-gemini-from-query-fan-out-to-grounded-decisions-3f99</link>
      <guid>https://dev.to/hjangid/search-journey-optimization-with-gemini-from-query-fan-out-to-grounded-decisions-3f99</guid>
      <description>&lt;h2&gt;
  
  
  AI Search Journey Lab — Part 1 of 7
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Building an explainable search-to-decision workflow with Gemini, Google Places, Google Search grounding, deterministic ranking, and Google Cloud.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Open source&lt;/strong&gt;: &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;ai-search-journey-lab&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It started with one search query&lt;br&gt;
I started &lt;code&gt;ai-search-journey-lab&lt;/code&gt; with a question that looked simple enough:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At first, I thought of it as a local-search problem.&lt;/p&gt;

&lt;p&gt;Then I started implementing it.&lt;/p&gt;

&lt;p&gt;Almost immediately, that single sentence turned into several separate engineering problems.&lt;/p&gt;

&lt;p&gt;I needed to understand what the user actually meant by “&lt;strong&gt;near Geekdom&lt;/strong&gt;.” I needed to capture the group size. I needed opening hours. I needed to find places that might actually work for six people sitting together.&lt;/p&gt;

&lt;p&gt;Then there was the phrase:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;quiet enough to work&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is where things became more interesting.&lt;/p&gt;

&lt;p&gt;A structured Places response can give me things like an address, rating, hours, and a Place ID.&lt;/p&gt;

&lt;p&gt;But “quiet enough to work” is not simply another field I can request.&lt;/p&gt;

&lt;p&gt;So now I needed both structured place data and additional evidence.&lt;/p&gt;

&lt;p&gt;Then, once I started generating more than one retrieval query, another problem appeared: &lt;em&gt;&lt;strong&gt;the same place could be returned by multiple searches&lt;/strong&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That meant I also needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;candidate normalization,&lt;/li&gt;
&lt;li&gt;deduplication,&lt;/li&gt;
&lt;li&gt;evidence matching,&lt;/li&gt;
&lt;li&gt;scoring,&lt;/li&gt;
&lt;li&gt;ranking,&lt;/li&gt;
&lt;li&gt;and finally, a way to explain why one candidate beat another. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Oh gosh..It's getting a lot!!&lt;/strong&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At that point, I wasn't building a “&lt;strong&gt;&lt;em&gt;send a prompt to Gemini and print the answer&lt;/em&gt;&lt;/strong&gt;” application anymore.&lt;/p&gt;

&lt;p&gt;I was building a &lt;strong&gt;search journey&lt;/strong&gt;. (and later think about &lt;em&gt;search journey optimization!&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;And that became the idea behind &lt;strong&gt;AI Search Journey Lab&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  AI Search Journey Lab: one project, seven engineering stages
&lt;/h2&gt;

&lt;p&gt;This is the first article in a seven-part series built from the same open-source repository.&lt;/p&gt;

&lt;p&gt;I wanted the series to follow the way the project itself evolved instead of creating seven unrelated AI demos.&lt;/p&gt;

&lt;p&gt;The topics are also centered around the problems I am most interested in as a Google Cloud and AI developer: grounding, search behavior, data, agents, observability, and production deployment.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search Journey Optimization&lt;/strong&gt; with &lt;strong&gt;Gemini&lt;/strong&gt;: From &lt;strong&gt;Query Fan-Out&lt;/strong&gt; to Grounded Decisions  (&lt;em&gt;this article&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;Building &lt;strong&gt;Grounded Local Search&lt;/strong&gt; with &lt;strong&gt;Gemini&lt;/strong&gt; and &lt;strong&gt;Google Maps&lt;/strong&gt; on &lt;strong&gt;Google Cloud&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Building an &lt;strong&gt;AI Search Visibility&lt;/strong&gt; &amp;amp; Brand Analyzer with Gemini, BigQuery, and &lt;strong&gt;Google Search Grounding&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Building an &lt;strong&gt;AI Visibility Agent&lt;/strong&gt; with Gemini, &lt;strong&gt;Google ADK&lt;/strong&gt;, &lt;strong&gt;MCP&lt;/strong&gt;, and &lt;strong&gt;BigQuery&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;From Query Fan-Out to Trace: Observing &lt;strong&gt;AI Search Journeys&lt;/strong&gt; with &lt;strong&gt;OpenTelemetry&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Building &lt;strong&gt;AgentOps&lt;/strong&gt; for &lt;strong&gt;Gemini Agents&lt;/strong&gt; with &lt;strong&gt;Grafana&lt;/strong&gt;, &lt;strong&gt;Tempo&lt;/strong&gt;, and &lt;strong&gt;Prometheus&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;From Search to Care: Applying &lt;strong&gt;Gemini Search Journeys&lt;/strong&gt; to &lt;strong&gt;Healthcare Discovery&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The progression looks roughly like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;search journey → grounded retrieval → visibility measurement → agentic analytics → tracing → AgentOps → healthcare application&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My goal with the series is not just to show what Gemini can generate.&lt;/p&gt;

&lt;p&gt;I want to show how the pieces around the model matter just as much: APIs, deterministic logic, persistence, tools, traces, tests, and deployment.&lt;/p&gt;


&lt;h2&gt;
  
  
  Starting from the actual problem
&lt;/h2&gt;

&lt;p&gt;Let's go back to the original request:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From an application perspective, that contains several different constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;category: coffee shop&lt;/li&gt;
&lt;li&gt;geographic anchor: Geekdom&lt;/li&gt;
&lt;li&gt;location: San Antonio&lt;/li&gt;
&lt;li&gt;party size: six&lt;/li&gt;
&lt;li&gt;operating-hours requirement: open after 8 PM&lt;/li&gt;
&lt;li&gt;qualitative preference: quiet/work-friendly&lt;/li&gt;
&lt;li&gt;result count: three&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And not all of those constraints should be handled the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Opening hours&lt;/strong&gt; can often come from structured place data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quiet enough to work&lt;/strong&gt; may require additional evidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That distinction ended up shaping most of the architecture.&lt;/p&gt;


&lt;h2&gt;
  
  
  The architecture decision that mattered most
&lt;/h2&gt;

&lt;p&gt;One decision influenced almost everything that came afterward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I did not want Gemini to own the entire workflow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It would have been very easy to send the whole prompt to a model and ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find the best three places and explain why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That would certainly produce an answer.&lt;/p&gt;

&lt;p&gt;But it would make several things harder for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;understanding which constraints were actually verified,&lt;/li&gt;
&lt;li&gt;seeing where candidates came from,&lt;/li&gt;
&lt;li&gt;knowing why something ranked highly,&lt;/li&gt;
&lt;li&gt;reproducing a decision,&lt;/li&gt;
&lt;li&gt;debugging wrong answers,&lt;/li&gt;
&lt;li&gt;and eventually tracing the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I divided the workflow into three responsibilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gemini reasoning&lt;/strong&gt;&lt;br&gt;
I use Gemini where natural-language interpretation is valuable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;intent extraction,&lt;/li&gt;
&lt;li&gt;planning,&lt;/li&gt;
&lt;li&gt;query fan-out,&lt;/li&gt;
&lt;li&gt;final response synthesis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Retrieval&lt;/strong&gt;&lt;br&gt;
I use external data sources for evidence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Google Places API (New),&lt;/li&gt;
&lt;li&gt;Google Search grounding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Deterministic application logic&lt;/strong&gt;&lt;br&gt;
I keep things such as these in normal application code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normalization,&lt;/li&gt;
&lt;li&gt;entity matching,&lt;/li&gt;
&lt;li&gt;deduplication,&lt;/li&gt;
&lt;li&gt;evidence aggregation,&lt;/li&gt;
&lt;li&gt;scoring,&lt;/li&gt;
&lt;li&gt;ranking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gave me a much more inspectable pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F101mn0owijqurm66f69q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F101mn0owijqurm66f69q.png" alt="Flow design - Search Journey Optimization" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Running the project
&lt;/h2&gt;

&lt;p&gt;I normally start the local environment with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;run_app_locally&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As the project evolved, I added observability components as well, so the local startup script eventually became responsible for more than just Streamlit.&lt;/p&gt;

&lt;p&gt;For this article, though, I am concentrating on two parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;V1 — Search to Decision&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;V2 — Journey Analysis&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The later articles will move into visibility, agents, and observability.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqu9b17so4j3t7eha7924.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqu9b17so4j3t7eha7924.png" alt="Starting AI Search Journey Lab locally" width="799" height="381"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: I first turn the prompt into structured intent
&lt;/h2&gt;

&lt;p&gt;Before I search for anything, I need to understand what the request actually contains.&lt;/p&gt;

&lt;p&gt;Conceptually, the Geekdom query becomes something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"coffee_shop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location_reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Geekdom, San Antonio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"party_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"open_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"preferences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"quiet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"work-friendly"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is not the &lt;strong&gt;JSON&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It is the boundary between &lt;strong&gt;language&lt;/strong&gt; and &lt;strong&gt;application state&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;People do not normally talk in schemas.&lt;/p&gt;

&lt;p&gt;A user might say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Somewhere close to Geekdom. We have six people, don't want somewhere too loud, and we'll probably stay late.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The application still needs to understand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"party_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"noise_preference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"quiet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minimum_close_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"20:00"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a good job for &lt;strong&gt;Gemini&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The model interprets the language.&lt;/p&gt;

&lt;p&gt;My application controls the structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  A typed intent model
&lt;/h2&gt;

&lt;p&gt;Since I am more into development and technical architecture role, I prefer moving the result into a typed object rather than passing free-form model text deeper into the workflow.&lt;/p&gt;

&lt;p&gt;A simplified version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;location_reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;party_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;open_after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;preferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives me a cleaner boundary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Natural-language request → Gemini interpretation → Structured application state&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;From this point forward, the rest of the pipeline has something predictable to work with.&lt;/p&gt;




&lt;h2&gt;
  
  
  I also made this a real workflow boundary
&lt;/h2&gt;

&lt;p&gt;I didn't want intent extraction buried somewhere inside one large function.&lt;/p&gt;

&lt;p&gt;I gave the stage its own observable boundary.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.extract_intent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_intent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That may look like a small detail here.&lt;/p&gt;

&lt;p&gt;It became much more useful later when I added OpenTelemetry.&lt;/p&gt;

&lt;p&gt;A lesson I learned while building this project was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Good architecture boundaries often become good observability boundaries later.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As you run locally, it should open in &lt;code&gt;http://localhost:8502/&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65vxrayf7pi1dbk1436u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F65vxrayf7pi1dbk1436u.png" alt="AI Search Journey Lab GUI" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's see the journey now and wait to trace the results!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frihrwh2nfr9wu3t7343c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frihrwh2nfr9wu3t7343c.png" alt="Gemini intent extraction" width="800" height="1361"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: One prompt becomes several retrieval tasks
&lt;/h2&gt;

&lt;p&gt;Once I knew what the user wanted, I ran into the next question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What exactly should I search for?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Using the complete user sentence as one search query would throw many different requirements into the same retrieval call.&lt;/p&gt;

&lt;p&gt;Instead, I started decomposing the request into a small retrieval plan.&lt;/p&gt;

&lt;p&gt;A simplified fan-out might look like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Places task:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;coffee shops near Geekdom San Antonio&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Places task:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;work-friendly coffee shops near downtown San Antonio&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Search evidence task:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;coffee near Geekdom quiet work open late&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the project, I treat that planning step separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.plan_fan_out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fanout_plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;plan_fan_out&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the part I refer to as query fan-out.&lt;/p&gt;

&lt;p&gt;One user's decision question becomes multiple narrower retrieval tasks.&lt;/p&gt;




&lt;h2&gt;
  
  
  I learned quickly that more queries are not automatically better
&lt;/h2&gt;

&lt;p&gt;Initially, fan-out sounds like a simple idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If one query is useful, several queries should be better.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But that creates its own problems.&lt;/p&gt;

&lt;p&gt;Imagine generating all of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coffee near Geekdom
quiet coffee near Geekdom
coffee for six near Geekdom
coffee with large tables near Geekdom
coffee open after 8 near Geekdom
coffee good for working near Geekdom
late-night coffee downtown San Antonio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more requests,&lt;/li&gt;
&lt;li&gt;more latency,&lt;/li&gt;
&lt;li&gt;more overlapping results,&lt;/li&gt;
&lt;li&gt;more duplicates,&lt;/li&gt;
&lt;li&gt;more evidence to reconcile,&lt;/li&gt;
&lt;li&gt;and more opportunities for noisy candidates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The opposite extreme is also bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coffee near Geekdom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;because most of the intent has disappeared.&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;query fan-out&lt;/strong&gt; became an optimization problem of its own:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Generate enough retrieval tasks to cover the user's important constraints without turning the workflow into uncontrolled query expansion.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a much more interesting problem than simply asking a model to generate ten related searches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqjcfg4jffghwle88zr7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqjcfg4jffghwle88zr7.png" alt="Query fan-out generated from the original prompt" width="800" height="1112"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Google Places gave me the structured candidate layer
&lt;/h2&gt;

&lt;p&gt;Once I had the fan-out plan, I needed actual entities.&lt;/p&gt;

&lt;p&gt;For the local-search portion of the workflow, that meant Google Places API (New).&lt;/p&gt;

&lt;p&gt;This gave me structured information such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Place ID,&lt;/li&gt;
&lt;li&gt;business name,&lt;/li&gt;
&lt;li&gt;address,&lt;/li&gt;
&lt;li&gt;rating,&lt;/li&gt;
&lt;li&gt;number of ratings,&lt;/li&gt;
&lt;li&gt;opening information,&lt;/li&gt;
&lt;li&gt;and Maps URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified retrieval call looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://places.googleapis.com/v1/places:searchText&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;textQuery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pageSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-Api-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Goog-FieldMask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.formattedAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.userRatingCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.currentOpeningHours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places.googleMapsUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentionally simplified for the article.&lt;/p&gt;

&lt;p&gt;In the repository, there is additional application logic around the retrieval and candidate handling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Place ID turned out to be more important than I expected
&lt;/h2&gt;

&lt;p&gt;At first I was mostly interested in ratings, addresses, and opening hours.&lt;/p&gt;

&lt;p&gt;Once fan-out was involved, identity became equally important.&lt;/p&gt;

&lt;p&gt;Suppose two searches produce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merit Coffee
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merit Coffee — Southtown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;String matching alone is not a great basis for deciding whether I have one business or two.&lt;/p&gt;

&lt;p&gt;Using a canonical identifier such as &lt;code&gt;Place ID&lt;/code&gt; gives me a stronger entity boundary.&lt;/p&gt;

&lt;p&gt;That became important almost immediately when I started combining results from multiple retrieval tasks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7av09z3hwh1hjcawwl7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa7av09z3hwh1hjcawwl7.png" alt="Structured candidate retrieval using Google Places" width="800" height="1038"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Places could not answer everything
&lt;/h2&gt;

&lt;p&gt;This is where the system became more than a Places demo.&lt;/p&gt;

&lt;p&gt;The API can tell me a lot about a place.&lt;/p&gt;

&lt;p&gt;But look again at part of the original requirement:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;quiet enough to work&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not the same kind of data as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;rating&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open until 10 PM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same problem appears in other searches:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;good for a large student group&lt;/p&gt;

&lt;p&gt;strong vegan options&lt;/p&gt;

&lt;p&gt;suitable for someone with a specific preference&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;These are softer constraints.&lt;/p&gt;

&lt;p&gt;I needed another evidence path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: I added Google Search grounding for the missing evidence
&lt;/h2&gt;

&lt;p&gt;Rather than asking Gemini:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is this place good for working?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted the system to investigate specific claims.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Candidate: Example Coffee

Verify:
- late-hour availability
- evidence relevant to working/studying
- evidence relevant to group seating

For each constraint:
- supported
- unsupported
- evidence
- citation

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conceptually, that stage looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_grounding.verify_evidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gave me two different types of evidence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Google Places + Search-grounded evidence&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wanted to preserve that distinction instead of blending everything together.&lt;/p&gt;

&lt;p&gt;If operating hours come from Places, I should know that.&lt;/p&gt;

&lt;p&gt;If “&lt;strong&gt;work-friendly&lt;/strong&gt;” comes from &lt;strong&gt;grounded Search evidence&lt;/strong&gt;, I should know that too.&lt;/p&gt;

&lt;p&gt;And if I cannot verify something, I want the system to keep that state visible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffi3r3somr8e5tifao71s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffi3r3somr8e5tifao71s.png" alt="Grounded evidence for qualitative constraints" width="800" height="728"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Fan-out created a duplicate problem
&lt;/h2&gt;

&lt;p&gt;Once I started running multiple Places tasks, something predictable happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the same business started appearing more than once.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;For example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Places task A ───────┐
                     ├── Candidate X
Places task B ───────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I ranked those raw results directly, Candidate X could appear stronger simply because more than one retrieval task discovered it.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;not&lt;/strong&gt; &lt;strong&gt;relevance&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;duplication&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I introduced normalization and deduplication before scoring.&lt;/p&gt;

&lt;p&gt;A simplified form is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deduplicate_places&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;place_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;place_id&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;merge_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;place_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sequence matters: &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → normalize → deduplicate → score&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I specifically did &lt;strong&gt;NOT&lt;/strong&gt; want:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;retrieve → score duplicates → fix identity afterward&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  V2 made this problem visible
&lt;/h2&gt;

&lt;p&gt;For Journey Analysis, I used another demo query:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Find an Indian restaurant near Trinity University for eight students, open after 9 PM, with vegetarian options. Recommend the top three.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One of my representative executions produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Places tasks                     2
Raw Places results              20
Unique Places candidates        13

Search tasks                     2

Candidates with Search evidence  3
Places-only candidates          10
Unmatched evidence               8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was a useful moment in the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instead of asking only:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What are the top three restaurants?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;I could now ask:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What actually happened during retrieval?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That became the purpose of &lt;strong&gt;V2 — Journey Analysis&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdllco63bjsma6uva9uu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdllco63bjsma6uva9uu.png" alt="Journey Analysis for the Trinity University query" width="800" height="419"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Step 7: Retrieval and ranking are not the same thing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This sounds obvious, but it became an important rule in my implementation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval asks:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which candidates might be relevant?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Ranking asks:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which of those candidates best satisfies the original constraints?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A candidate should not rank higher simply because &lt;em&gt;two fan-out queries&lt;/em&gt; happened to retrieve it.&lt;/p&gt;

&lt;p&gt;And the &lt;strong&gt;highest-rated business should not automatically win&lt;/strong&gt; if it fails an important constraint such as &lt;strong&gt;opening hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So after deduplication, I aggregate the evidence and score candidates explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: I keep ranking outside the LLM
&lt;/h2&gt;

&lt;p&gt;This was another decision I made deliberately.&lt;/p&gt;

&lt;p&gt;I did not want this architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Retrieve 10 places
       ↓
Send all 10 to Gemini
       ↓
"Pick the best three"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That would give me an answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;But it would make one of the most important decisions in the system opaque.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead, I keep the ranking logic inspectable in application code.&lt;/p&gt;

&lt;p&gt;Depending on the workflow, signals can include things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;location relevance,&lt;/li&gt;
&lt;li&gt;category match,&lt;/li&gt;
&lt;li&gt;operating-hour compatibility,&lt;/li&gt;
&lt;li&gt;rating,&lt;/li&gt;
&lt;li&gt;review volume,&lt;/li&gt;
&lt;li&gt;constraint coverage,&lt;/li&gt;
&lt;li&gt;grounded evidence,&lt;/li&gt;
&lt;li&gt;unsupported constraints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;evidence.aggregate_and_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ranked_candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;aggregate_and_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important property here is not one universal scoring formula.&lt;/p&gt;

&lt;p&gt;It is that I can inspect &lt;em&gt;why a candidate received its score&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_haversine_distance_miles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lon2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculate the great circle distance in miles between two latitude/longitude points.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r_earth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3958.8&lt;/span&gt;  &lt;span class="c1"&gt;# Earth radius in miles
&lt;/span&gt;    &lt;span class="n"&gt;d_lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;d_lon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lon1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_lat&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;radians&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lat2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_lon&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atan2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r_earth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calculate_proximity_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distance_miles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Calculate smooth bounded proximity bonus from distance in miles (max 12.0 pts).

    Formula: bonus = MAX_PROXIMITY_BONUS / (1.0 + distance_miles)
    - 0.0 mi  -&amp;gt; 12.00 pts
    - 0.5 mi  -&amp;gt; 8.00 pts
    - 1.0 mi  -&amp;gt; 6.00 pts
    - 2.0 mi  -&amp;gt; 4.00 pts
    - 5.0 mi  -&amp;gt; 2.00 pts
    - 190 mi  -&amp;gt; 0.06 pts (Houston vs San Antonio)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;distance_miles&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
    &lt;span class="n"&gt;bonus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX_PROXIMITY_BONUS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;distance_miles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bonus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/hastimal/ai-search-journey-lab/blob/main/src/ai_search_journey/ranking.py/" rel="noopener noreferrer"&gt;View the complete scoring implementation on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Candidates are ranked after normalization and evidence aggregation rather than being silently reordered by the model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpoc5436dep1v63jp8swc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpoc5436dep1v63jp8swc.png" alt="Deterministic candidate scoring in Journey Analysis" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 9: Gemini comes back at the end
&lt;/h2&gt;

&lt;p&gt;Gemini still has an important job after the deterministic ranking stage.&lt;/p&gt;

&lt;p&gt;I use it to turn the structured result into an answer that is useful to a person.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;trace_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini.synthesize_recommendations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workflow.stage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;synthesize_recommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ranked_candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But notice what has changed.&lt;/p&gt;

&lt;p&gt;At the start of the workflow, Gemini had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;one natural-language request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the end, it can work with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;structured intent
       +
normalized candidates
       +
Places evidence
       +
Search evidence
       +
constraint states
       +
candidate scores
       +
final ranking
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So instead of asking Gemini to discover, verify, rank, and explain everything at once, I give it a much narrower final job:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Explain a decision whose evidence has already been assembled.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the architecture I wanted.&lt;/p&gt;




&lt;h2&gt;
  
  
  The complete journey
&lt;/h2&gt;

&lt;p&gt;At a high level, the workflow became:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx16fejr2yhu7gs9y7cxx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx16fejr2yhu7gs9y7cxx.png" alt="AI Search Journey Workflow and Design" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And internally, I started giving the major stages explicit names such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gemini.extract_intent
gemini.plan_fan_out
places.text_search
search_grounding.verify_evidence
candidate.normalize_and_dedup
evidence.aggregate_and_score
gemini.synthesize_recommendations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those names become important again later in this series when I start tracing the workflows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: I test it like normal software
&lt;/h2&gt;

&lt;p&gt;Another goal for this project was to avoid treating AI code as somehow exempt from normal software-engineering practices.&lt;/p&gt;

&lt;p&gt;After making changes, I run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./.venv/bin/ruff check &lt;span class="nb"&gt;.&lt;/span&gt;
./.venv/bin/mypy src
./.venv/bin/pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqk31ytksvjs4y3yj8lhu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqk31ytksvjs4y3yj8lhu.png" alt="Ruff, Mypy and Pytest passing for AI Search Journey Lab" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Then I moved the same application to Cloud Run
&lt;/h2&gt;

&lt;p&gt;I also wanted the project to go beyond my laptop.&lt;/p&gt;

&lt;p&gt;The application runs on Google Cloud Run.&lt;/p&gt;

&lt;p&gt;I can inspect the deployed service from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run services describe ai-search-journey-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"yaml(
      metadata.name,
      status.url,
      status.latestReadyRevisionName,
      status.conditions
  )"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And verify the Streamlit health endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="s2"&gt;"https://ai-search-journey-lab-642110324230.us-central1.run.app/_stcore/health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For me, this matters because it closes the development loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;execution&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;tests&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;container&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;Cloud&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;
   &lt;span class="err"&gt;↓&lt;/span&gt;
&lt;span class="n"&gt;live&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshqlqpi4t7d0ijn180oz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fshqlqpi4t7d0ijn180oz.png" alt="AI Search Journey Lab running to deploy in Cloud Run" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same Search-to-Decision workflow running from the Cloud Run deployment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr58hsux5w7726ztxvvea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr58hsux5w7726ztxvvea.png" alt="Search to Decision workflow running on Google Cloud Run - GUI" width="799" height="331"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I am not trying to reproduce
&lt;/h2&gt;

&lt;p&gt;There is an important distinction I want to make.&lt;/p&gt;

&lt;p&gt;This project is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a reproduction of Google's internal ranking systems,&lt;/li&gt;
&lt;li&gt;a reverse-engineered implementation of AI Mode,&lt;/li&gt;
&lt;li&gt;an implementation of Google AI Overviews,&lt;/li&gt;
&lt;li&gt;or a claim that my query fan-out represents Google's proprietary query fan-out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am using query fan-out as an application architecture pattern.&lt;/p&gt;

&lt;p&gt;The project asks a developer-focused question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If one natural-language request turns into multiple retrieval tasks, what engineering problems do I have to solve before I can return a grounded decision?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is what I am trying to explore.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happens when I run many search journeys?
&lt;/h2&gt;

&lt;p&gt;V1 and V2 focus on one user's decision.&lt;/p&gt;

&lt;p&gt;But imagine running many related prompts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Now I can start asking:&lt;br&gt;
How often does a brand appear?&lt;br&gt;
Which competitors are being mentioned?&lt;br&gt;
Which sources are being cited?&lt;br&gt;
Does the brand appear in the initial query, the fan-out queries, or only the final answer?&lt;br&gt;
How does visibility change across different prompts?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is where this project moved from Search Journey Optimization into AI Search Visibility.&lt;/p&gt;

&lt;p&gt;And that is the next article.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next: Building Grounded Local Search with Gemini and Google Maps on Google Cloud
&lt;/h2&gt;

&lt;p&gt;Before I move fully into visibility analytics, Part 2 will go deeper into the actual local-search implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemini,&lt;/li&gt;
&lt;li&gt;Google Places API (New),&lt;/li&gt;
&lt;li&gt;Google Maps,&lt;/li&gt;
&lt;li&gt;Google Search grounding,&lt;/li&gt;
&lt;li&gt;grounded recommendation generation,&lt;/li&gt;
&lt;li&gt;and Cloud Run deployment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then &lt;strong&gt;Part 3 will move into the AI Search Visibility &amp;amp; Brand Analyzer with Gemini and BigQuery&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try the project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/hastimal/ai-search-journey-lab" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local startup:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt; &lt;span class="n"&gt;scripts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;run_app_locally&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>gemini</category>
      <category>googlecloud</category>
      <category>ai</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
