AI keyword research is unreliable because the model invents plausible-sounding terms no one searches - the r/SEO example of Claude suggesting "slow made ceramic plates" and "mismatched ceramic dinnerware set" for a pottery shop is the whole problem in one line. The fix is grounding: feed the LLM real People Also Ask questions, related searches, and competitor titles from a live SERP, then constrain it to expand only from those. This tutorial pulls that signal from the Scavio Google endpoint and hands it to any model as a grounded brief. The model stops guessing and starts working from terms the SERP proves exist.
Prerequisites
- Python 3.8+ or Node 18+ installed
- A Scavio API key from scavio.dev (50 free credits on signup)
- requests installed (pip install requests) for Python
- Any LLM API (the grounding step is model-agnostic)
Walkthrough
Step 1: Pull the full SERP for your seed term
Call https://api.scavio.dev/api/v1/google with light_request:false (2 credits) so the response includes the questions (People Also Ask) and related_searches blocks alongside organic results. These are real queries Google itself surfaces - the ground truth the model lacks.
import os, requests
H = {"Authorization": f"Bearer {os.environ['SCAVIO_API_KEY']}", "Content-Type": "application/json"}
def serp(seed):
r = requests.post("https://api.scavio.dev/api/v1/google",
headers=H, json={"query": seed, "light_request": False})
r.raise_for_status()
return r.json().get("data", {})Step 2: Extract the real query signal
Pull the questions block, related_searches, and the top organic titles. Together these are the vocabulary your buyers and competitors actually use - not the model's guess at it. For the pottery shop, this returns terms like 'handmade ceramic dinner plates' that real people search, not invented phrases.
def signal(data):
return {
"paa": [q.get("question") for q in data.get("questions", [])],
"related": data.get("related_searches", []),
"titles": [o.get("title") for o in data.get("organic", [])[:10]],
}Step 3: Build a constrained grounding prompt
Hand the model the real signal and forbid invention explicitly. The instruction matters: expand only from the provided questions, related searches, and titles; do not generate keywords that are not grounded in this data. This single constraint is what kills the 'slow made ceramic plates' failure mode.
def grounding_prompt(seed, sig):
return f'''You are doing keyword research for: {seed}.
Use ONLY the real search data below. Do not invent keywords.
People Also Ask: {sig['paa']}
Related searches: {sig['related']}
Competitor titles: {sig['titles']}
Return 20 keyword clusters grounded strictly in the terms above.'''Step 4: Generate and verify against the source
Run the prompt through your LLM, then verify: every keyword the model returns should trace back to a term in the PAA, related searches, or titles. Drop anything that does not. This verification step is cheap insurance - it catches the model when it slips back into inventing.
def verify(keywords, sig):
pool = " ".join(sig["paa"] + sig["related"] + sig["titles"]).lower()
return [k for k in keywords if any(w in pool for w in k.lower().split())]Step 5: Localize the grounding for non-US markets
The r/SEO thread also flagged US-skewed AI output ('change your life' copy for a Swiss audience). Grounding fixes this too: pass a country or language hint to the SERP call so the real signal comes from the right market, then constrain the model to it. The data, not the model's training bias, sets the regional vocabulary.
def localized_serp(seed, country="ch"):
r = requests.post("https://api.scavio.dev/api/v1/google",
headers=H, json={"query": seed, "light_request": False, "country": country})
return r.json().get("data", {})Python Example
import os, requests
H = {"Authorization": f"Bearer {os.environ['SCAVIO_API_KEY']}", "Content-Type": "application/json"}
def grounded_keywords(seed):
data = requests.post("https://api.scavio.dev/api/v1/google", headers=H,
json={"query": seed, "light_request": False}).json().get("data", {})
sig = {
"paa": [q.get("question") for q in data.get("questions", [])],
"related": data.get("related_searches", []),
"titles": [o.get("title") for o in data.get("organic", [])[:10]],
}
# feed sig to your LLM with: 'expand only from these real terms, do not invent'
return sig
print(grounded_keywords("handmade ceramic dinner plates"))JavaScript Example
const H = { Authorization: `Bearer ${process.env.SCAVIO_API_KEY}`, 'Content-Type': 'application/json' };
async function groundedKeywords(seed) {
const res = await fetch('https://api.scavio.dev/api/v1/google', {
method: 'POST', headers: H,
body: JSON.stringify({ query: seed, light_request: false }),
}).then(r => r.json());
const data = res.data || {};
return {
paa: (data.questions || []).map(q => q.question),
related: data.related_searches || [],
titles: (data.organic || []).slice(0, 10).map(o => o.title),
};
}
groundedKeywords('handmade ceramic dinner plates').then(console.log);Expected Output
{'paa': ['Are handmade plates food safe?', 'How much do ceramic dinner plates cost?', ...],
'related': ['handmade ceramic dinnerware set', 'stoneware dinner plates handmade', ...],
'titles': ['Handmade Ceramic Dinner Plates - ...', ...]}
# Real search terms the model can expand from - no invented 'slow made ceramic plates'.