DEV Community

Cover image for Simulate Google's Query Fan-Out to Find Your Content Gaps (Python)
Furqan Khalid
Furqan Khalid

Posted on

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

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.


What we're building

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)
Enter fullscreen mode Exit fullscreen mode

Two parts:

  1. Fan-out: an LLM writes the sub-queries a search system would plausibly issue.
  2. Coverage: an embedding model scores how well each sub-query is answered by some passage on your page.

Pipeline from seed question to LLM fan-out to page passages to embedding scores, with example coverage bars for five sub-queries
Image: diagram by the author.

Setup

pip install anthropic pydantic sentence-transformers requests beautifulsoup4
export ANTHROPIC_API_KEY=...
Enter fullscreen mode Exit fullscreen mode

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

Step 1: Generate the fan-out

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

# fanout.py
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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode
[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
...
Enter fullscreen mode Exit fullscreen mode

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.

Step 2: Split your page into passages

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.

# passages.py
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
Enter fullscreen mode Exit fullscreen mode

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).

Step 3: Score coverage with embeddings

# coverage.py
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
Enter fullscreen mode Exit fullscreen mode

Step 4: Put it together

# main.py
import sys
from fanout import fan_out
from passages import page_passages
from coverage import coverage

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

# Merge a few runs to smooth out randomness.
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']}")
Enter fullscreen mode Exit fullscreen mode
python main.py "best way to host a Next.js app for a small team" https://your-site.com/blog/nextjs-hosting
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

Reading the results

  • The thresholds are rough. 0.55 and 0.68 work reasonably well for 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.
  • Gaps aren't automatically to-dos. "Vercel pricing per seat" may not belong on your page. But if your page is "the best way to host Next.js for a small team," pricing per seat is exactly what that reader needs. Add a short, specific passage with real numbers and a date.
  • Fix gaps with self-contained passages. Lead with the answer, name the entity ("Vercel Pro costs…", not "It costs…"), and include the number or constraint. A passage that makes sense without the paragraph above it can be lifted into an answer.
  • Don't spin up a page per sub-query. That's the doorway-page pattern, and Google's spam policies now explicitly cover AI features too. One strong page that answers the cluster beats twelve thin ones.

Caveats (please read)

  • This approximates fan-out. Google hasn't published how many sub-queries it issues or how it picks them. Use the output as a gap-finding tool, not as a model of Google.
  • Embedding similarity measures topical match, not correctness or quality. A passage can score 0.8 and still be out of date.
  • Coverage is necessary but not sufficient. Pages also need to be indexed, crawlable, and trusted enough to be picked over competitors.

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.

Where this fits

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.

Top comments (0)