●CHAT — Tomorrow, August 26, Google Chat becomes the Ask Gemini hub: searching, drafting, catching up on threads, and managing tasks and events all land in one place with Workspace context intact●SEARCH — AI Mode in Google Search is now sometimes served by Gemini 3.7 Flash. Response characteristics on the search side shift with it, which is worth checking if you watch your traffic mix●STUDIO — Developer logs now cover the Interactions API. Supported calls can be traced from the AI Studio dashboard, which makes triage easier before you have logging of your own●TTS — gemini-3.1-flash-tts-preview now supports streaming speech generation through streamGenerateContent, so playback can start before generation finishes●ROBOTICS — gemini-robotics-er-1.6-preview shuts down on August 31, six days out. The ER 2 line succeeds it with spatial reasoning, multi-step tool orchestration, and multi-robot coordination●STUDENT — Gemini added a student hub, study notebooks, interactive visualizations, and Deep Research in Gemini Live, with a free year of Google AI plans for eligible students●CHAT — Tomorrow, August 26, Google Chat becomes the Ask Gemini hub: searching, drafting, catching up on threads, and managing tasks and events all land in one place with Workspace context intact●SEARCH — AI Mode in Google Search is now sometimes served by Gemini 3.7 Flash. Response characteristics on the search side shift with it, which is worth checking if you watch your traffic mix●STUDIO — Developer logs now cover the Interactions API. Supported calls can be traced from the AI Studio dashboard, which makes triage easier before you have logging of your own●TTS — gemini-3.1-flash-tts-preview now supports streaming speech generation through streamGenerateContent, so playback can start before generation finishes●ROBOTICS — gemini-robotics-er-1.6-preview shuts down on August 31, six days out. The ER 2 line succeeds it with spatial reasoning, multi-step tool orchestration, and multi-robot coordination●STUDENT — Gemini added a student hub, study notebooks, interactive visualizations, and Deep Research in Gemini Live, with a free year of Google AI plans for eligible students
Rebuilding the CTR Denominator After AI Search Fans Out Your Queries
My top five queries by impressions all had zero clicks, and four of them were not phrases a person would type. Here is how I rebuilt the denominator and where I let Gemini make the call.
I sorted last week's search data by impressions and looked at the top of the list. The first five rows all had zero clicks.
My first instinct was that the titles were weak. Decent positions, nobody clicking — that usually points at the headline.
Then I actually read the queries.
rork.com core business features target audience — I could not picture a person typing that into a search box.
The five queries sitting at the top
The site in question is one I run alongside my work as an indie developer: a technical site covering app development tooling. Total impressions for the period were 41,700.
Query
Impressions
Clicks
rork.com core business features target audience
1,371
0
rork max swiftui features and ai capabilities
1,282
0
onspace vs rork for complex app logic
1,149
0
expo audio continue playing screen locked react native 2025
1,023
0
rork max swiftui features and native app generation
624
0
That is 5,449 impressions, or 13.1% of the total, with zero clicks across the board.
Three of the first four read wrong. core business features target audience is a stack of attribute nouns. features and ai capabilities follows the same shape. onspace vs rork for complex app logic even carries its own comparison axis inside the phrase.
People do not search that way. A person types rork max pricing — short, only the words they need.
These are sub-queries that an AI search layer generated internally. One question comes in, and the system decomposes it into somewhere between eight and sixteen retrieval queries to assemble an answer. The behaviour is documented as query fan-out, and Google describes how impressions are counted on AI-powered search surfaces — but the report does not separate them from human searches for you.
So my CTR denominator contains impressions that structurally cannot produce a click.
Never put click count in the classifier
I nearly walked into the obvious trap here.
"Zero clicks plus high impressions" would make a tidy rule. All five rows above satisfy it.
But apply that rule and CTR rises the moment you exclude anything. You are removing exactly the impressions that contribute nothing to the numerator. That is not an improvement; it is arithmetic engineered to flatter itself.
I have built metrics like that before — the kind that always move in the direction you were hoping for. The danger is not the first wrong number. It is that once a metric starts agreeing with you, you stop checking it.
So I decided the classifier would ignore clicks entirely and look only at the shape of the query string. If a high-click query ever gets flagged as synthetic, that tells me the rule is wrong, and I want that failure mode to stay visible.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to tell, from your own data, whether a rise in impressions came from readers or from machine-generated sub-queries
✦You will know when a falling CTR calls for a rewrite and when it calls for a look at what is sitting in the denominator
✦You will be able to report effective CTR honestly, knowing the exclusion policy alone moves it from 0.73% to 0.81%
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This runs directly against the Queries.csv you export from Search Console. No API access required.
# fanout_score.py - score a query for "AI fan-out likeness" using the string alone# Deliberately ignores clicks and impressions to avoid circular reasoningimport csvimport reimport sysfrom dataclasses import dataclass, field# Function words that chain attributes together; rare in human queriesJOINERS = (" vs ", " versus ", " for ", " and ", " with ", " without ")# Words people use when something is broken. Strong negative signal.INTENT_MARKERS = ( "error", "fix", "not working", "failed", "how to", "why", "cannot", "can't", "エラー", "できない", "直し方", "とは", "使い方",)# A trailing year usually means a human wanting current informationYEAR = re.compile(r"\b(19|20)\d{2}\b")# Nouns that ask a system to enumerate; skewed toward generated sub-queriesATTRIBUTE_NOUNS = re.compile( r"\b(features|capabilities|use cases|target audience|overview|benefits)\b")@dataclassclass Verdict: query: str score: int band: str reasons: list = field(default_factory=list)def fanout_score(query: str) -> Verdict: q = query.lower().strip() tokens = q.split() score, reasons = 0, [] if len(tokens) >= 6: score += 1 reasons.append(f"tokens>=6 ({len(tokens)})") if len(tokens) >= 8: score += 1 reasons.append(f"tokens>=8 ({len(tokens)})") joiners = [j.strip() for j in JOINERS if j in f" {q} "] if joiners: score += 1 reasons.append("joiner:" + "/".join(joiners)) hits = [m for m in INTENT_MARKERS if m in q] if hits: score -= 2 reasons.append("intent:" + "/".join(hits)) if YEAR.search(q): score -= 1 reasons.append("year-suffix") if ATTRIBUTE_NOUNS.search(q): score += 1 reasons.append("attribute-noun") # 3 or more: fan-out. 1 or less: human. 2: undecided. band = "fanout" if score >= 3 else ("human" if score <= 1 else "ambiguous") return Verdict(query=query, score=score, band=band, reasons=reasons)def main(path: str) -> None: with open(path, newline="", encoding="utf-8-sig") as f: for row in csv.DictReader(f): v = fanout_score(row["Top queries"]) print(f"{v.band:9s} score={v.score:2d} {v.query}") print(f" {v.reasons}")if __name__ == "__main__": main(sys.argv[1] if len(sys.argv) > 1 else "Queries.csv")
Here is the actual output for those five rows.
ambiguous score= 2 rork.com core business features target audience ['tokens>=6 (6)', 'attribute-noun']fanout score= 3 rork max swiftui features and ai capabilities ['tokens>=6 (7)', 'joiner:and', 'attribute-noun']ambiguous score= 2 onspace vs rork for complex app logic ['tokens>=6 (7)', 'joiner:vs/for']human score= 1 expo audio continue playing screen locked react native 2025 ['tokens>=6 (9)', 'tokens>=8 (9)', 'year-suffix']fanout score= 4 rork max swiftui features and native app generation ['tokens>=6 (8)', 'joiner:and', 'attribute-noun', 'tokens>=8 (8)']
Two out of five came back with any confidence.
My eyes said four of these were obviously machine-written. The scorer caught fewer than half. That gap was the opposite of what I expected. What I was reading was the seating of the vocabulary; what the scorer reads is token count and function words. Those are not the same measurement.
And lowering the threshold to close the gap immediately swallows row four, which is a perfectly legitimate search. This is not a tuning problem.
Sending only the undecided rows to Gemini
The two ambiguous rows go to the model. Not all five — and the reason is reproducibility rather than cost. Whatever a deterministic rule can settle should stay with the rule; only the judgements that genuinely require reading language go to the model.
The output has to be structured. Free-form text breaks whatever aggregates it downstream.
# adjudicate.py - send only the ambiguous rows to the modelfrom google import genaifrom pydantic import BaseModelclient = genai.Client(api_key="YOUR_API_KEY")SCHEMA_PROMPT = """You are analysing search logs.Decide how likely it is that a human typed the given query into a search box.Guidance:- Human queries are short and contain only the words the person needs- Stacked attribute nouns (features / capabilities / target audience) and phrases with the comparison axis embedded are typical of AI-generated sub-queries- A long query that looks like a pasted error message is humanYou are given no click or impression data. Judge from the wording alone."""class Judgement(BaseModel): likely_human: bool confidence: float # 0.0-1.0 reason: strdef adjudicate(query: str) -> Judgement: response = client.models.generate_content( model="gemini-3.7-flash", contents=f"{SCHEMA_PROMPT}\n\nQuery: {query}", config={ "response_mime_type": "application/json", "response_schema": Judgement, }, ) return Judgement.model_validate_json(response.text)AMBIGUOUS = [ "rork.com core business features target audience", "onspace vs rork for complex app logic",]for q in AMBIGUOUS: j = adjudicate(q) # Low-confidence calls are not adopted; they carry over to next week verdict = "human" if j.likely_human else "fanout" status = verdict if j.confidence >= 0.7 else "hold" print(f"{status:7s} conf={j.confidence:.2f} {q}\n {j.reason}")
Asking for confidence and refusing anything under 0.7 matters more than it looks. Models return a confident sentence whether or not they are confident. If you do not force the hesitation into a number, the hesitation never reaches your records.
Carrying rows over as unresolved is the correct outcome, not a failure. Forcing a verdict on something you cannot judge leaves the reasoning nowhere while the aggregation marches on. The same instinct shows up in normalising a closed label vocabulary, and the schema side borrows directly from structured output validation.
Three exclusion policies, three different CTRs
This was the part that changed how I report the number at all.
Recomputing effective CTR under different exclusion ranges, starting from 0.70% over 41,700 impressions:
Exclusion policy
Impressions removed
Share of total
Effective CTR
Exclude nothing
0
0%
0.70%
Score 3 and above only
1,906
4.6%
0.73%
Include ambiguous rows
4,426
10.6%
0.78%
All five top rows
5,449
13.1%
0.81%
From 0.70% to 0.81%, on policy alone.
So I stopped reporting "effective CTR" as a single number. Give someone a bare figure and they will ask which one is real, and the answer lives entirely in the exclusion policy.
Now I write the two together in one sentence: "0.73%, excluding score-3-and-above only." Split them across two lines and by next week the number is travelling alone.
Do not delete the human query that got zero clicks
Row four, expo audio continue playing screen locked react native 2025, landed on the human side of the scorer. It is nine tokens long, but every one of them describes a real problem: keeping audio playing while the screen is locked. Anyone who has shipped background audio will recognise the word order.
1,023 impressions. Zero clicks.
That is not denominator pollution. That is a real failure to be chosen — either the position is far enough down that nobody scrolls to it, or the title does not meet the query where it is. Either way it needs its own investigation.
This is the risk in cleaning a denominator. While you sweep, things that were never meant to be swept disappear from view. Excluding synthetic queries exists to make CTR readable, not to make zero-click rows stop bothering you.
I split the script's output in two, so excluded queries always land in their own section. Set aside, not deleted. That one change is what lets me pick this row back up next week.
Folding it into the weekly routine
Five fixed steps:
Export Search Console queries sorted by impressions, descending
Run fanout_score.py and split into fanout / ambiguous / human
Run adjudicate.pyonly in weeks where ambiguous exceeds ten rows
Record effective CTR as a single sentence that names the exclusion policy
Push zero-click rows from the human band into a separate list for next week
Step three is conditional on purpose. Make the model call permanent and the judgement criteria quietly migrate into the model. Weeks where the scorer alone was enough are evidence that the criteria are holding.
The reporting side dropped straight onto the Streamlit and Gemini API dashboard I built earlier — excluded impressions plotted underneath the total impressions chart.
Closing
Sort your own impressions descending and read the top ten rows with your eyes. That alone tells you what is sitting in your denominator.
Until I did this, I had been reading a falling CTR as a headline problem. What had actually changed was the thing being measured. As AI search keeps expanding, I suspect this kind of drift accumulates quietly.
When a metric refuses to move, the denominator may deserve suspicion before the strategy does.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.