cd /news/generative-engine-optimization/simulate-google-s-query-fan-out-to-f… · home › topics › generative-engine-optimization › article
[ARTICLE · art-155117] src=dev.to ↗ pub= topic=generative-engine-optimization verified=true sentiment=· neutral

Simulate Google's Query Fan-Out to Find Your Content Gaps (Python)

A developer published a Python script that approximates Google's query fan-out by using Claude to generate likely sub-queries for a seed question, then splitting a target page into passages, embedding them with a small open-source model, and scoring each sub-query against its best-matching passage to produce a coverage report that surfaces content gaps first. The approach is motivated by Google's documentation that AI Overviews and AI Mode may issue multiple related searches across subtopics, and by Bing Webmaster Tools' AI Performance report exposing the grounding queries Copilot generates before citing pages. The author recommends running the generation step 3–5 times and merging results, since real fan-out is not deterministic.

by read5 min views6 publishedOct 22, 2026

When someone asks Google's AI Mode "what's the best way to host a Next.js app for a small team?", Google doesn't run one search. It splits the question into sub-questions and runs those in parallel. Google calls this query fan-out. Its docs say AI Overviews and AI Mode "may use a 'query fan-out' technique, issuing multiple related searches across subtopics and data sources, to develop a response" (Search Central).

Microsoft lets you see this happening. Bing Webmaster Tools' AI Performance report lists grounding queries, the background queries Copilot generated before citing your pages. They're shorter and more specific than what users type.

That changes what "covering a topic" means. Your page isn't competing for one query. It's competing for 5–15 machine-generated ones, and every sub-question you skip is a retrieval you hand to someone else.

You can't see Google's real fan-out. But you can approximate it closely enough to find gaps. Here's a script that does exactly that.

seed question ──► LLM generates likely sub-queries
                             │
your page ──► split into passages ──► embed
                             │
              for each sub-query: best-matching passage + score
                             │
                     coverage report (gaps first)

Two parts:

pip install anthropic pydantic sentence-transformers requests beautifulsoup4
export ANTHROPIC_API_KEY=...

I'm using Claude for generation and a small open-source embedding model for scoring. Both are swappable.

Structured output keeps this clean: we ask for a typed list and get a validated Python object back, with no regex over model text.

from typing import Literal
import anthropic
from pydantic import BaseModel

class SubQuery(BaseModel):
    query: str
    intent: Literal["definition", "comparison", "how-to", "pricing", "constraint", "alternative", "follow-up"]

class FanOut(BaseModel):
    sub_queries: list[SubQuery]

client = anthropic.Anthropic()

PROMPT = """You are simulating the query fan-out step of an AI search engine.
Given a user's question, list the 12 short web searches the engine would most
likely run in parallel to build a complete, trustworthy answer.

Cover different facets: definitions, comparisons, pricing, constraints
(size, budget, region, stack), how-to steps, alternatives, and the follow-up a
careful user would ask next. Write each one the way a search engine would
(short, keyword-like), not as a full sentence.

User question: {question}"""

def fan_out(question: str) -> list[SubQuery]:
    response = client.messages.parse(
        model="claude-opus-5-5",
        max_tokens=16000,
        output_config={"effort": "low"},  # this is a simple task; keep it cheap
        messages=[{"role": "user", "content": PROMPT.format(question=question)}],
        output_format=FanOut,
    )
    return response.parsed_output.sub_queries

Run it on a real question:

for sq in fan_out("best way to host a Next.js app for a small team"):
    print(f"[{sq.intent:11}] {sq.query}")
[comparison ] vercel vs netlify vs cloudflare pages nextjs
[pricing    ] vercel pro pricing per seat 2026
[constraint ] nextjs self hosting docker small team
[how-to     ] deploy nextjs app router to cloudflare
[constraint ] nextjs ISR support hosting providers
[alternative] nextjs hosting without vercel
[follow-up  ] nextjs hosting cold starts serverless
...

Generated output varies between runs. That's fine. Run it 3–5 times and merge the results, because real fan-out isn't deterministic either.

AI systems retrieve passages, not whole pages. We'll split by headings and paragraphs, keeping each heading attached to its text so the passage still makes sense on its own.

import requests
from bs4 import BeautifulSoup

def page_passages(url: str, min_words: int = 25) -> list[str]:
    html = requests.get(url, timeout=20, headers={"User-Agent": "fanout-audit/1.0"}).text
    soup = BeautifulSoup(html, "html.parser")
    for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
        tag.decompose()

    root = soup.find("main") or soup.find("article") or soup.body
    passages, heading = [], ""
    for el in root.find_all(["h1", "h2", "h3", "p", "li", "td"]):
        text = " ".join(el.get_text(" ", strip=True).split())
        if el.name in ("h1", "h2", "h3"):
            heading = text
            continue
        if len(text.split()) >= min_words:
            passages.append(f"{heading}: {text}" if heading else text)
    return passages

Note that this reads the raw HTML, the way many AI fetchers do. If your content only appears after JavaScript runs, this returns almost nothing, and that's a finding in its own right (see Part 9).

from sentence_transformers import SentenceTransformer, util

model = SentenceTransformer("BAAI/bge-small-en-v1.5")

def coverage(sub_queries, passages):
    q_emb = model.encode([sq.query for sq in sub_queries], normalize_embeddings=True)
    p_emb = model.encode(passages, normalize_embeddings=True)
    sims = util.cos_sim(q_emb, p_emb)  # [n_queries, n_passages]

    rows = []
    for i, sq in enumerate(sub_queries):
        best = int(sims[i].argmax())
        rows.append({
            "query": sq.query,
            "intent": sq.intent,
            "score": float(sims[i][best]),
            "best_passage": passages[best][:110] + "…",
        })
    return sorted(rows, key=lambda r: r["score"])  # gaps first
python
import sys
from fanout import fan_out
from passages import page_passages
from coverage import coverage

question, url = sys.argv[1], sys.argv[2]

seen, subs = set(), []
for _ in range(3):
    for sq in fan_out(question):
        key = sq.query.lower()
        if key not in seen:
            seen.add(key)
            subs.append(sq)

passages = page_passages(url)
print(f"{len(subs)} sub-queries · {len(passages)} passages\n")

for r in coverage(subs, passages):
    flag = "GAP " if r["score"] < 0.55 else "weak" if r["score"] < 0.68 else "ok  "
    print(f"{flag} {r['score']:.2f}  [{r['intent']}] {r['query']}")
    if flag != "ok  ":
        print(f"           closest: {r['best_passage']}")
python main.py "best way to host a Next.js app for a small team" https://your-site.com/blog/nextjs-hosting

(Illustrative output. Your numbers will differ.)

33 sub-queries · 41 passages

GAP  0.41  [pricing] vercel pro pricing per seat 2026
           closest: Why we moved: Our bill grew faster than our traffic…
GAP  0.47  [constraint] nextjs ISR support hosting providers
           closest: Deploying to Cloudflare: The adapter handles most…
weak 0.61  [follow-up] nextjs hosting cold starts serverless
...
ok   0.79  [comparison] vercel vs netlify vs cloudflare pages nextjs

bge-small on English technical content. Calibrate them by checking a few passages yourself: find the score below which the "closest passage" clearly doesn't answer the question. If you want a reality check, compare your generated sub-queries with Bing's real grounding queries (Bing Webmaster Tools → AI Performance, CSV export). Matching phrasing there is the closest thing to ground truth you'll get today.

Coverage tells you whether your page can answer the sub-questions. It doesn't tell you whether AI engines actually do pick you. For that you need to run the real prompts across ChatGPT, Perplexity, Gemini and others over time and track who gets mentioned. We built Vista AI's AI rank tracker for that part. The script above is the free, local half of the loop: find gaps, fix them, then measure.

The full script is ~120 lines. If you adapt it (different embedding model, multilingual, or batch mode over a sitemap), share it in the comments.

Next in the series: Track ChatGPT, Perplexity and Claude referrals properly.

── more in #generative-engine-optimization 4 stories · sorted by recency
── more on @google 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/simulate-google-s-qu…] indexed:0 read:5min 2026-10-22 · —