We put the same 60 buyer questions to ChatGPT and Google AI Mode on the same night. Question by question, the two engines shared 19 of 507 cited URLs. That's 3.7%. Blend both engines into one "AI visibility" score and you're averaging two different source lists.
Everything behind that number is public: the questions, all 917 citation rows, the ChatGPT answers and our page-by-page notes. CC BY 4.0, on GitHub and Hugging Face, with a DOI. This post covers how it was collected, how a citation is counted, and how to recount every headline number with about 20 lines of pandas.
What the data turned into: the 44-second film.
Canlah-AI
/
ai-citations-sg-2026
What ChatGPT and Google AI Mode cite for 110 Singapore buyer questions: every cited URL in answer order, ChatGPT answers, 92 cited pages taken apart. Round 1: 526 citations on two engines; round 2: 241 on ChatGPT. CC BY 4.0, DOI.
What do ChatGPT and Google AI Mode cite when Singapore buyers ask?
110 questions, every cited URL, open to recount.
Load it in 30 seconds · Recounts and findings · The playbook built on it · 中文完整数据说明
Put the same 60 Singapore buyer questions to ChatGPT and Google AI Mode and they cite almost entirely different pages: 19 of 507 cited URLs shared (3.7%). Ask ChatGPT the same question twice, minutes apart, and only 23.9% of its cited URLs come back. Here is every citation behind those numbers, so you can check them yourself.
Five Singapore industries. 110 buyer questions. ChatGPT (OpenAI API, gpt-5.5 with web search, Singapore location) and, in round 1, Google AI Mode (through a third-party SERP API). This dataset keeps the questions, every cited URL in answer order, the ChatGPT answer texts, our page-by-page annotations of cited pages, and a…
TL;DR
- 60 buyer questions from five Singapore industries: dental, family law, tuition, B2B SaaS and e-commerce. ChatGPT returned 307 citations, Google AI Mode 219: 526 citations, 474 distinct URLs.
- Same question, two engines: 19 shared URLs out of 507 (3.7%). In 36 of the 54 questions where both engines cited sources, they shared nothing at all.
- In that round, ChatGPT cited 0 of 307 forum or social pages. AI Mode cited 30 of 219 (YouTube 13, Reddit 10).
- Ask ChatGPT the same question twice, minutes apart, and only 53 of 222 cited URLs repeat (23.9%).
What we collected
Two rounds, both run in the early hours of 23 September 2026 (Singapore time), about 55 minutes apart.
| Round 1 | Round 2 | |
|---|---|---|
| Questions | 60 (5 industries × 12, 6 in Chinese) | 50 (5 × 10, 5 in Chinese) |
| Engines | ChatGPT + Google AI Mode | ChatGPT only |
| Official citations | 307 + 219 = 526 | 241 |
Round 1 asks buying questions: how much, which one, A or B, what if it goes wrong. Round 2 uses the same nine question types in every industry: definition, process, statistics, calculation, near me, single review, full guide, rules and checklist.
The rounds ask different questions, so we never add them together. Each one stands on its own.
How one citation is captured
ChatGPT goes through the OpenAI Responses API: gpt-5.5 with the web_search tool, approximate location Singapore, no system prompt, the question passed in as-is. Citations come from the url_citation annotations on the answer text.
Google AI Mode goes through a third-party SERP API with gl=sg; citations come from its reference list.
Both are API results, not what someone sees in the consumer app, and each question was asked once in the official runs.
Here's the shape of the ChatGPT call, simplified from the method in the dataset README:
from urllib.parse import urlsplit, urlunsplit
from openai import OpenAI
client = OpenAI()
def strip_openai_utm(url: str) -> str:
"""Drop only utm_source=openai; leave every other parameter byte-for-byte."""
parts = urlsplit(url)
kept = [p for p in parts.query.split("&") if p and p != "utm_source=openai"]
return urlunsplit(parts._replace(query="&".join(kept)))
resp = client.responses.create(
model="gpt-5.5",
tools=[{"type": "web_search",
"user_location": {"type": "approximate", "country": "SG", "city": "Singapore"}}],
input="how much does a single tooth dental implant cost in Singapore and can I use Medisave",
)
citations = [] # one entry per distinct URL, in order of first mention
for item in resp.output:
if item.type != "message":
continue
for part in item.content:
for ann in getattr(part, "annotations", None) or []:
if ann.type == "url_citation":
url = strip_openai_utm(ann.url)
if url not in citations:
citations.append(url)
print(resp.model, len(citations))
Three rules turn raw answers into rows:
- One citation = one distinct URL within one answer. A URL linked five times in one answer counts once. That's why 526 citations collapse to 474 distinct URLs once you dedupe across answers.
-
Strip only
utm_source=openai. ChatGPT appends it to links. It touched 30 of the 917 rows, and removing it creates no new duplicates. -
Keep the order.
positionis the order of first mention in the ChatGPT answer, or the order of AI Mode's reference list.
The files
| File | Rows | One row is |
|---|---|---|
queries.csv |
110 | a question, with industry, round, language and question type |
citations.csv |
917 | one URL in one answer (767 official, 150 supplementary) |
chatgpt_answers.jsonl |
141 | one ChatGPT call, with the model receipt and answer text |
page_annotations.csv |
141 | our notes on one cited page |
page_types.csv |
46 | one type of cited page |
Filter on official == "yes" for the headline numbers. The supplementary rows are an independent second run of 30 round-2 questions, plus one retry, kept for the repeat test further down.
Recount the headline numbers
No clone needed:
import pandas as pd
BASE = "https://raw.githubusercontent.com/Canlah-AI/ai-citations-sg-2026/main/"
cites = pd.read_csv(BASE + "citations.csv")
# Headline numbers use the official runs only
r1 = cites[(cites["official"] == "yes") & (cites["round"] == "r1")]
print(r1["engine"].value_counts().to_dict())
print(len(r1), "citations,", r1["url"].nunique(), "distinct URLs")
# Same question, two engines: how many cited URLs overlap?
shared = union = 0
for _, g in r1.groupby("query_id"):
a = set(g.loc[g["engine"] == "chatgpt", "url"].str.rstrip("/"))
b = set(g.loc[g["engine"] == "google_ai_mode", "url"].str.rstrip("/"))
shared += len(a & b)
union += len(a | b)
print(f"{shared} shared of {union} ({shared / union:.1%})")
# Forum and social pages, per engine
print(r1[r1["is_ugc"] == "yes"]["engine"].value_counts().to_dict())
Output:
{'chatgpt': 307, 'google_ai_mode': 219}
526 citations, 474 distinct URLs
19 shared of 507 (3.7%)
{'google_ai_mode': 30}
Prefer Hugging Face? load_dataset("CanlahAI/ai-citations-sg-2026", "citations") gives you the same 917 rows.
Read the 3.7% for what it is: overlap of cited URLs, not a score for either engine. Two engines, two source lists. If you care about both, check both.
Ask twice, get a different list
Thirty round-2 questions were sent twice, minutes apart, same model, same settings. Same cites frame as above:
twice = cites[cites["run_id"].isin(["r2", "r2_rerun"])]
inter = union = 0
for q in twice.loc[twice["run_id"] == "r2_rerun", "query_id"].unique():
g = twice[twice["query_id"] == q]
a = set(g.loc[g["run_id"] == "r2", "url"])
b = set(g.loc[g["run_id"] == "r2_rerun", "url"])
inter += len(a & b)
union += len(a | b)
print(f"{inter} of {union} URLs repeat ({inter / union:.1%})") # 53 of 222 (23.9%)
By registered domain it's 64 of 146 (43.8%). Exactly 1 of the 30 questions came back with an identical list. One answer is one sample. A ranking built on one sample is noise with a decimal point.
What else is in there
-
Different engines lean on different sources. For dental questions, 76% of ChatGPT's citations (38 of 50) went to government and public-institution pages, while 75% of AI Mode's (39 of 52) went to clinics and other commercial sites. Filter
r1toquery_idvalues containingDENand runpd.crosstab(den["engine"], den["site_type"]). -
Mostly not the Google top ten. Only 7.2% of ChatGPT's cited URLs (22 of 307) sat in Google's organic top ten for the same question. For AI Mode it was 40.6% (89 of 219). Google's result lists aren't redistributed, so this one lives as aggregates in
stats.md. -
The lifted line opens a paragraph. On 15 of 18 cited family-law pages, the sentence the engine used was the first sentence of its paragraph. The position notes (written in Chinese) are in
page_annotations.csv. -
46 types of cited page, from price guides to statute explainers: 9 tested on both engines, 33 with ChatGPT evidence only, 4 untested and labelled that way in
page_types.csv.
Scope, in one paragraph
These are API results, not the consumer UI. One market (Singapore), mostly English, 10 to 12 questions per industry, one night in September. Official answers are stored as their first 600 characters, so later sentences can't be matched to citations. Site types come from domain rules, not hand checks. The README (English summary on top, detail in Chinese) spells out what the data shouldn't be used for, including scoring individual businesses.
Ideas if you want to build on it
- Re-run the 110 questions on a newer model and diff against September.
- Add a Google AI Mode leg to round 2, which is ChatGPT only today.
- Test whether the "first sentence of a paragraph" pattern holds outside Singapore.
To cite it: Pang, H. (2026). Singapore GEO Citation Probes, September 2026 (ChatGPT and Google AI Mode). Canlah AI. doi.org/10.5281/zenodo.23005020
Why it exists
We collected it to write the GEO Playbook, a free method book on getting a business named in AI answers: 9 chapters plus 4 appendices, a blueprint for each of the 46 page types, and dental and law-firm editions. All CC BY 4.0, and every number in it is sourced, with our own measurements tracing back to these files (evidence appendix).
If you'd rather hand the method to an agent, the condensed version is a Claude Code plugin:
/plugin marketplace add Canlah-AI/geo-playbook
/plugin install geo-playbook@canlah-ai
Codex, Cursor and other agents can read the same rules from AGENTS.md in the repo.
What would you test first with 917 labelled citations? Tell me in the comments.
Written with AI assistance. The headline numbers were recounted from the dataset with the code in this post; the rest come from the dataset's stats.md and README.

Top comments (0)