AnswerLineStart free

, Rank Tracking · Analytics

AI rank tracking: what "rank" means inside an AI answer, and how to measure it

Rank tracking inside AI answers starts with a definition problem. A search results page has one ordered list, so “rank 4” is unambiguous. An AI answer has several ordered things at once: brands named in the prose, sources in a list, inline citation chips, product cards, map listings. And from one run to the next, you may not appear at all.

Below: six rank definitions, how to aggregate when you are absent, the statistics and sample sizes behind a trustworthy number, and the collection pipeline in brief. The full operational build is in the batch pipeline tutorial.

Six definitions of rank

Pick the definition that matches the business question, and name it in every report. Mixing them silently is the most common source of contradictory dashboards.

Rank type Definition Question it answers Fields in this API
Mention rank Order of first appearance of your brand among tracked brands in the answer text “When the answer lists options, where are we?” text; entities[] order on ChatGPT
Source rank Position of the first source on your domain in the answer’s source list “Is our page among the first sources?” sources[].position
Inline citation rank Position of the first inline citation chip pointing to your domain “Is our page cited inside the prose, and how early?” citationPills[].position
Product rank Position of your product in a shopping card “Where do we appear in product recommendations?” ChatGPT shoppingCards[].products[].position (with include.shopping), Copilot shoppingCards[].products[].position, Perplexity shopping_cards[].products[].position
Local rank Position of your listing in a map or places module “Where does our location appear for local prompts?” map[].position on ChatGPT, Copilot and AI Mode
Organic rank Position of your page in Google’s results “Do we rank for the searches the engine ran?” organicResults[].position on Google Search

Notes that matter when you implement them:

The absence problem

A tracked page usually has some position in a results page. In an AI answer, a brand is either present or absent on each run. How you treat those runs decides what your metric says.

A worked example (illustrative numbers)

Twenty runs of one prompt, mention rank among tracked brands:

Metric Your brand Competitor
Presence rate 12 / 20 = 60% 19 / 20 = 95%
Mean rank when present 21 / 12 = 1.75 3.0
Top-3 rate (all runs) 11 / 20 = 55% 19 / 20 = 95%
Mean reciprocal rank (all runs) 8.58 / 20 = 0.43 6.33 / 20 = 0.32

“Mean rank when present” says you win comfortably. Top-3 rate says the competitor is in the visible top three almost every time and you are there about half the time. Mean reciprocal rank, which weights position 1 heavily, still favours you. Which metric fits depends on the question; the error is reporting conditional rank alone.

Aggregations that handle absence

Pick one headline metric per report and show presence rate beside it. For a broader metric set (mention share, citation share, recommendation share), see the AI share of voice framework.

Why repeated runs are required

The same prompt, engine and market can give a different answer on each run. Sampling during generation, changes in the pages the engine retrieves, and model updates all move the output; AI answer volatility covers the causes. So a rank for one prompt is a distribution, and you estimate it by sampling.

Intervals on presence and top-k rates

Presence and top-k rates are proportions. For small samples or rates near 0% or 100%, use the Wilson interval rather than the normal approximation:

from math import sqrt

def wilson(successes: int, n: int, z: float = 1.96) -> tuple[float, float]:
    if n == 0:
        return (0.0, 1.0)
    p = successes / n
    denom = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / denom
    half = z * sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return (centre - half, centre + half)

print(wilson(12, 20))  # (0.387, 0.781)
print(wilson(19, 20))  # (0.764, 0.991)

From the example: your 60% presence over 20 runs is compatible with anything from about 39% to 78%. The competitor’s 95% is 76% to 99%. Twenty runs separate these two, but they would not separate 60% from 70%.

Intervals on MRR and mean rank

MRR and conditional rank are not proportions. Bootstrap them: resample the runs with replacement a few thousand times, recompute the metric each time, and take the 2.5th and 97.5th percentiles. When you aggregate across prompts, resample prompts (with all their runs) rather than individual runs, for the reason in the next section.

import random

def bootstrap_mrr(runs_by_prompt: dict[str, list[int | None]], iters: int = 2000) -> tuple[float, float]:
    prompts = list(runs_by_prompt)
    stats = []
    for _ in range(iters):
        sample = [random.choice(prompts) for _ in prompts]
        rr = [0.0 if r is None else 1.0 / r for p in sample for r in runs_by_prompt[p]]
        stats.append(sum(rr) / len(rr))
    stats.sort()
    return (stats[int(0.025 * iters)], stats[int(0.975 * iters)])

Detecting a change between two periods

To detect a presence rate moving from p1 to p2 with a two-sided test at 5% significance and 80% power, the observations needed per period are approximately:

n ≈ (1.96 + 0.84)² × (p1(1 − p1) + p2(1 − p2)) / (p2 − p1)²

For 30% to 40%: 7.84 × (0.21 + 0.24) / 0.01 ≈ 353 observations per period. For 30% to 50%, the same formula gives about 90. Small changes need a lot of data, which is why rank reports should be aggregated over prompt clusters and weeks rather than read per prompt per day.

Runs of one prompt are not independent

Twenty runs of one prompt tell you a lot about that prompt and little about the next one. Statistically, observations cluster by prompt, and the effective sample size shrinks by the design effect:

effective n = total observations / (1 + (runs per prompt − 1) × ρ)

where ρ is the intra-prompt correlation of the outcome. With 50 prompts × 8 runs = 400 observations and an illustrative ρ of 0.5, the effective n is 400 / 4.5 ≈ 89. Estimate ρ from your own baseline. When it is high, which is common for stable prompts, adding prompts buys far more precision than adding runs. Sampling vs census covers designs that balance the two.

Comparing ranks across engines

Position numbers are not portable between engines. Engines return different numbers of sources for similar prompts and display them differently, so “source rank 3” is a stronger placement in a short list than in a long one.

Rules that keep comparisons honest:

  1. Compare trends within an engine, not levels across engines.
  2. Prefer rate metrics across engines. Presence rate and top-3 rate survive list-length differences better than mean position.
  3. Record list length per run. Store the count of sources[] with each observation so you can normalize later (for example, rank divided by list length) if needed.
  4. Keep engine as a dimension in every table. A blended AI rank hides that engines cite different sources for the same prompt.

Choosing the prompt and run design

Decision Recommendation Why
Unit of reporting Prompt cluster × engine × market × week Enough observations for an interval; stable enough to trend
Runs per prompt Measure ρ in a baseline, then choose High ρ favours more prompts over more runs
Brand set Fixed and versioned per cluster Mention rank depends on it
Markets country, plus US state where local Answers differ by market
Add-ons include.searchQueries on ChatGPT when you analyse organic inputs Fan-out queries explain source rank
Failed tasks Keep as rows with no rank Separates collection gaps from absence

Prompt set design covers sourcing and clustering prompts; monitoring cadence covers how often to run them.

The collection pipeline, briefly

  1. Plan tasks. Expand prompt × engine × market × run slot into tasks with an idempotencyKey such as p042-chatgpt-US-2026-09-17-r3, so a resubmission can never double-count.
  2. Submit in batches. POST /v1/async/task/batch accepts 1 to 500 tasks; each item is admitted or refused on its own.
  3. Receive by webhook. Each finished task is posted to your webhook.url with task, credits and response. Deduplicate by task.id.
  4. Store raw JSON. Keep the full result; you will add rank definitions later and want to recompute history.
  5. Extract observations. One row per task per brand per rank type: (prompt_id, engine, market, run_at, brand, rank_type, rank or null, list_length).
  6. Aggregate with intervals. Wilson for rates, bootstrap by prompt for MRR and conditional rank.

A submission of two tasks for one prompt on two engines:

curl -X POST https://api.answerline.dev/v1/async/task/batch \
  -H "Authorization: Bearer $ANSWERLINE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "taskType": "CHATGPT",
      "payload": { "prompt": "best accounting software for a small construction company", "country": "US", "include": { "searchQueries": true } },
      "idempotencyKey": "p042-chatgpt-US-2026-09-17-r1",
      "webhook": { "url": "https://hooks.example.com/answers" }
    },
    {
      "taskType": "COPILOT",
      "payload": { "prompt": "best accounting software for a small construction company", "country": "US" },
      "idempotencyKey": "p042-copilot-US-2026-09-17-r1",
      "webhook": { "url": "https://hooks.example.com/answers" }
    }
  ]'

Extracting mention rank from a stored result:

import re

def mention_ranks(result: dict, engine: str, brands: dict[str, list[str]]) -> dict[str, int | None]:
    """brands maps a brand key to its aliases. Returns rank among tracked brands, or None if absent."""
    if engine == "CHATGPT" and result.get("entities"):
        names = [e["name"] for e in result["entities"]]
        first = {}
        for i, name in enumerate(names):
            for key, aliases in brands.items():
                if key not in first and any(a.lower() == name.lower() for a in aliases):
                    first[key] = i
    else:
        text = result.get("text", "")
        first = {}
        for key, aliases in brands.items():
            hits = [m.start() for a in aliases for m in re.finditer(rf"\b{re.escape(a)}\b", text, re.IGNORECASE)]
            if hits:
                first[key] = min(hits)
    order = sorted(first, key=first.get)
    return {key: (order.index(key) + 1 if key in first else None) for key in brands}

Note that ChatGPT’s entities[] can include entities that are not in your brand set; the function ranks only tracked brands, by design.

What it costs

Credits per async task: ChatGPT 5 (7 with include.searchQueries, include.shopping, include.ads or include.rawResponse, charged once), Copilot 5, Grok 5, Gemini 4, Perplexity 4, AI Mode 4, Google Search 3 for the first page. Synchronous calls add 2.

A design of 60 prompts × 4 runs per week on ChatGPT with search queries and on Copilot is 60 × 4 × (7 + 5) = 2,880 credits per week. Adding a weekly organic check on 150 deduplicated fan-out queries adds 150 × 3 = 450. See cost planning and pricing to size a plan.

Pitfalls

To start collecting observations, see the rank tracking use case or the quickstart.

Questions

Is there a rank inside an AI answer?

Not one rank but several: the order in which brands are named in the text, the position of your page in the source list, the position of an inline citation, and positions in product cards or map listings. Each has to be defined explicitly before it can be tracked.

How do you average rank when a brand is often absent?

Don't average only the runs where it appears, because a brand named once at position 1 looks better than one named every time at position 2. Report presence rate alongside conditional rank, or use a single score that counts absence, such as mean reciprocal rank or top-3 rate.

How many runs per prompt do I need?

It depends on the change you want to detect. A presence rate measured from 20 runs has a 95% interval roughly 40 points wide around 60%. Detecting a move from 30% to 40% at 80% power takes about 353 observations per period, which is usually better spread across many prompts than repeated on one.

Can I compare AI answer rank across engines?

Only with care. Engines return different numbers of sources and lay out citations differently, so position 3 on one engine is not equivalent to position 3 on another. Compare trends within an engine, or use rate-based metrics such as top-3 rate that are less sensitive to list length.

Which fields hold positions in this API?

sources[].position and citationPills[].position on the chat engines and AI Mode, entities[] order on ChatGPT, shoppingCards products position on ChatGPT (with include.shopping) and Copilot, shopping_cards on Perplexity, map[].position on ChatGPT, Copilot and AI Mode, and organicResults[].position on Google Search.

Try it on your own prompts

500 free credits a month, no card. One POST returns the answer, sources and citations as JSON.

Keep reading