RAG on live news
Retrieval-augmented generation needs article bodies, and article bodies are exactly what news APIs put behind their top tier. Here they are free, which makes this recipe possible at all.
The shape
- Measure with
/v1/stats— is there coverage worth retrieving? - Retrieve a wide, cheap candidate set: headlines and descriptions only.
- Rank the candidates yourself, before spending tokens.
- Ground — fetch full text for the survivors only.
- Cite every claim back to a URL.
Steps 2 and 3 are what most implementations skip, and it is why they are slow and expensive.
Complete implementation
"""Ground an answer in live news coverage."""
import requests
from anthropic import Anthropic
BASE = "https://freenewsapi.ai"
client = Anthropic()
def measure(query, window="7d"):
"""Is there enough coverage to be worth the retrieval?"""
r = requests.get(f"{BASE}/v1/stats", params={"q": query, "date": window}, timeout=30)
r.raise_for_status()
return r.json()
def retrieve(query, window="7d", pool=60, **filters):
"""Wide, cheap candidate set \u2014 no bodies yet."""
r = requests.get(f"{BASE}/v1/search", params={
"q": query, "date": window, "sort": "relevance",
"size": pool, "full_text": False, **filters,
}, timeout=20)
r.raise_for_status()
return r.json()["results"]
def rank(candidates, keep=8):
"""Prefer corroborated, recent, well-described articles \u2014 one per publisher."""
seen_hosts, ranked = set(), []
for a in sorted(candidates, key=lambda x: x.get("published_at") or "", reverse=True):
if a["host"] in seen_hosts: # one voice per publisher
continue
if not a.get("description"): # no summary, probably a stub page
continue
seen_hosts.add(a["host"])
ranked.append(a)
if len(ranked) >= keep:
break
return ranked
def ground(articles):
"""Fetch bodies only for the survivors."""
out = []
for a in articles:
r = requests.get(f"{BASE}/v1/article", params={"url": a["url"]}, timeout=20)
if r.status_code == 200:
out.append(r.json())
return out
def as_context(articles, chars=3000):
blocks = []
for i, a in enumerate(articles, 1):
blocks.append(
f"[{i}] {a['title']}\n"
f"Publisher: {a.get('sitename') or a['host']} "
f"({a.get('country') or 'unknown country'}, {a.get('lang')})\n"
f"Published: {a['published_at']}\n"
f"URL: {a['url']}\n\n"
f"{(a.get('text') or a.get('description') or '')[:chars]}"
)
return "\n\n---\n\n".join(blocks)
SYSTEM = """Answer strictly from the numbered articles provided.
Cite every factual claim as [n] with the source URL.
If the articles disagree, present the disagreement rather than picking a side.
If they do not answer the question, say so plainly instead of filling the gap.
Note when coverage comes from a single publisher or a single country."""
def answer(question, query=None, window="7d", **filters):
query = query or question
stats = measure(query, window)
if stats["total"] < 3:
return f"Not enough coverage: {stats['total']} articles in the last {window}."
articles = ground(rank(retrieve(query, window, **filters)))
if not articles:
return "Found headlines but could not retrieve any article bodies."
resp = client.messages.create(
model="claude-opus-5", max_tokens=2048, system=SYSTEM,
messages=[{"role": "user", "content":
f"{as_context(articles)}\n\n---\n\nQuestion: {question}"}],
)
return "".join(b.text for b in resp.content if b.type == "text")
print(answer("What is driving the change in grain export prices?",
query="grain export prices", window="7d", lang="en"))Why each step is there
Measure first
One /v1/stats call costs about 90 ms and 200 tokens, and it tells you
whether the story exists, where it is being covered and when it started. Skipping it means
occasionally running a full RAG pipeline over three irrelevant articles.
Retrieve wide, without bodies
Sixty headlines with descriptions is roughly 9,000 tokens if you were to read them all — but you are not reading them, your ranking code is. Retrieving sixty bodies instead would be 55,000 tokens, mostly wasted.
One article per publisher
Wire copy is republished verbatim across dozens of outlets. Without deduplication by host, half your context is the same Reuters story eight times, and the model reads that repetition as strong corroboration when it is one source.
Ground with bodies only at the end
Eight bodies is about 7,000 tokens. That is the whole budget of the expensive step, and it buys grounding a headline cannot: numbers, quotes, attributions.
Failure modes to handle
| Symptom | Cause | Fix |
|---|---|---|
| Zero results for an obviously real event | Query phrased as a sentence; search is AND across all terms | Reduce to 2–3 keywords that would appear in a headline |
| The same story eight times | Wire republication | Deduplicate by host, as above |
| Answer skews to one country's framing | English-language search returns English-speaking publishers | Run the query per country or per lang and combine |
| Model cites an article that does not support the claim | Description used as grounding instead of body text | Require text; drop articles where retrieval failed |
| Context window overflow | Bodies run 2,000–4,000 characters | Truncate per article, as in as_context |
Vector search
Search here is lexical: BM25 over title, description and body. That is a good fit for
news, where the entities you are looking for appear literally in the text. Semantic search
and a /v1/similar endpoint are on the roadmap.
Until then, if you need embeddings, retrieve with a keyword query and embed the bodies
yourself — you have them, in full, for free.