Reading Answer Boxes and Knowledge Panels Without Trusting Them Blindly
Google's answer box is a fast path to a fact and an occasional confident lie. Use it as a hint, verify it as a claim.


- What’s in the response
- Extracting defensively
- People Also Ask
- The verification step
- Putting it in a tool result
- A note on what these blocks aren’t
For a certain class of question — a conversion, a date of birth, a capital city — the answer box gets you the fact in one call, no page fetching required. That’s genuinely useful and worth wiring up.
It’s also wrong sometimes, and confidently so. Featured snippets pull from whichever page the algorithm liked, which is not always a page that’s right. Treating that text as ground truth is how agents end up asserting things with a citation to a content farm.
What’s in the response
Alongside results, a search response can carry answers, knowledge_graph, related_questions, and related_searches. Each is present only when the page actually had that block:
import os
import requests
from urllib.parse import quote_plus
API_KEY = os.environ["SERPLY_API_KEY"]
def serp(query: str, num: int = 10, location: str = "US") -> dict:
resp = requests.get(
f"https://api.serply.io/v1/search/q={quote_plus(query)}&num={num}",
headers={"X-Api-Key": API_KEY, "X-Proxy-Location": location},
timeout=30,
)
resp.raise_for_status()
return resp.json()
Extracting defensively
These blocks vary in shape by query type — a currency conversion, a sports score, and a definition are all “answers” and none of them look alike. Write the extractor to tolerate that:
def as_text(value) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
for key in ("answer", "text", "snippet", "description", "value", "title"):
if value.get(key):
return str(value[key]).strip()
return ""
if isinstance(value, list):
return " ".join(filter(None, (as_text(v) for v in value)))
return str(value)
def extract_answer(data: dict) -> dict | None:
answers = data.get("answers")
if not answers:
return None
first = answers[0] if isinstance(answers, list) else answers
text = as_text(first)
if not text:
return None
source = None
if isinstance(first, dict):
source = first.get("link") or first.get("url") or first.get("source")
return {"text": text, "source": source, "block": "answer_box"}
def extract_knowledge(data: dict) -> dict | None:
kg = data.get("knowledge_graph")
if not kg:
return None
if isinstance(kg, list):
kg = kg[0] if kg else None
if not isinstance(kg, dict):
return {"summary": as_text(kg), "block": "knowledge_graph"}
return {
"title": kg.get("title") or kg.get("name"),
"type": kg.get("type") or kg.get("subtitle"),
"summary": as_text(kg.get("description") or kg.get("snippet")),
"website": kg.get("website") or kg.get("url"),
"attributes": {
k: v for k, v in kg.items()
if isinstance(v, (str, int, float))
and k not in ("title", "name", "type", "subtitle", "description",
"snippet", "website", "url")
},
"block": "knowledge_graph",
}
The as_text helper doing shape-sniffing rather than assuming a key is the entire trick. A strict parser here breaks on the first query whose answer block is a table instead of a sentence, and the breakage is a KeyError in production rather than a graceful miss.
People Also Ask
related_questions is the most consistently useful of these blocks, because it’s not an answer — it’s a list of what else people ask, which is a query-expansion source you don’t have to generate:
def extract_questions(data: dict) -> list[str]:
out = []
for q in data.get("related_questions") or []:
if isinstance(q, str):
out.append(q)
elif isinstance(q, dict):
text = q.get("question") or q.get("title") or q.get("text")
if text:
out.append(str(text).strip())
return out
Feeding these back as follow-up searches is a cheap way to broaden coverage on a research task, and the questions come from real query data rather than a model’s guess at what’s adjacent.
The verification step
Here’s the part that separates a useful shortcut from a liability. Never surface an answer box as fact on its own — check whether the organic results agree:
def corroborate(answer: str, results: list[dict], min_agree: int = 2) -> dict:
"""Check how many organic results contain the answer's key terms."""
import re
terms = [
t for t in re.findall(r"[\w.]+", answer.lower())
if len(t) > 3 and not t.isalpha() or len(t) > 5
]
# Numbers and long words carry the meaning; drop filler
key = [t for t in terms if any(c.isdigit() for c in t) or len(t) > 5][:6]
if not key:
return {"corroborated": False, "matches": 0, "reason": "no checkable terms"}
matches = 0
supporting = []
for r in results:
blob = f"{r.get('title', '')} {r.get('description', '')}".lower()
hits = sum(1 for t in key if t in blob)
if hits >= max(2, len(key) // 2):
matches += 1
supporting.append(r.get("link"))
return {
"corroborated": matches >= min_agree,
"matches": matches,
"supporting": supporting[:3],
}
This is a heuristic, not a proof. What it reliably catches is the case that matters: an answer box asserting something no organic result on the page repeats. That’s the signature of a snippet pulled from a single outlier page, and it’s exactly when you don’t want to state it as fact.
Putting it in a tool result
def search_with_answer(query: str, num: int = 10) -> str:
data = serp(query, num)
results = data.get("results", [])
parts = []
answer = extract_answer(data)
if answer:
check = corroborate(answer["text"], results)
if check["corroborated"]:
parts.append(
f"ANSWER BOX (corroborated by {check['matches']} organic results):\n"
f"{answer['text']}\n"
f"Source: {answer['source'] or 'not attributed'}"
)
else:
parts.append(
f"ANSWER BOX (UNCORROBORATED — no organic results repeat this; "
f"treat as a lead, not a fact, and verify by reading a source):\n"
f"{answer['text']}\n"
f"Source: {answer['source'] or 'not attributed'}"
)
kg = extract_knowledge(data)
if kg:
parts.append(f"KNOWLEDGE PANEL: {kg.get('title')} — {kg.get('summary')}")
questions = extract_questions(data)
if questions:
parts.append("PEOPLE ALSO ASK:\n" + "\n".join(f"- {q}" for q in questions))
parts.append("ORGANIC RESULTS:\n" + "\n\n".join(
f"{r.get('title')}\n{r.get('link')}\n{r.get('description', '')}"
for r in results
))
return "\n\n".join(parts)
Putting the corroboration verdict in the tool output — rather than in a system prompt telling the model to “be careful with answer boxes” — is what makes it stick. The caution arrives attached to the specific claim, at the moment the model reads it.
A note on what these blocks aren’t
Answer boxes and knowledge panels are scraped page furniture, not a structured facts API. Fields appear and disappear depending on the query, the country, and the device. Build for their absence as the normal case — if answer: rather than answer["text"] — and treat their presence as a bonus, and this stays useful instead of becoming a source of intermittent crashes.