no key · no human
HomeNews API for AI agentsRAG on live news

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

  1. Measure with /v1/stats — is there coverage worth retrieving?
  2. Retrieve a wide, cheap candidate set: headlines and descriptions only.
  3. Rank the candidates yourself, before spending tokens.
  4. Ground — fetch full text for the survivors only.
  5. 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

python
"""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

SymptomCauseFix
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 timesWire 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 overflowBodies 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.