NativePort
← How-to

How to Build a Semantic Research Pipeline with Exa on NativePort

Chain Exa's search, contents, findSimilar and answer actions through NativePort into one research pipeline: discover, expand, and get a cited answer.

Exa runs its own semantic index rather than scraping a search engine’s results page, and its API is built around four actions that compose: search finds candidate pages by meaning rather than keyword match, contents pulls clean text (or highlights, or a summary) out of pages you already have, findSimilar expands from one good page to others like it, and answer skips straight to a cited, synthesized answer when that’s what you actually want. NativePort proxies all four under one key. This guide chains them into a small research pipeline: discover, expand, then answer.

What you’ll need

  • A NativePort API key. Sign up to get a key and $5 in credits.
  • Python 3, standard library only — urllib.request, no pip install.
  • Your key exported as an environment variable, never hardcoded:
export NATIVEPORT_API_KEY="np_..."

The four actions

All four sit behind the same key and the same Authorization: Bearer <NATIVEPORT_API_KEY> header — only the path changes, and each request/response body is exactly Exa’s own documented shape, untouched by the gateway.

POST https://api.nativeport.ai/exa/search
POST https://api.nativeport.ai/exa/contents
POST https://api.nativeport.ai/exa/findSimilar
POST https://api.nativeport.ai/exa/answer
import json
import os
import urllib.request

GATEWAY = "https://api.nativeport.ai"
API_KEY = os.environ["NATIVEPORT_API_KEY"]


def exa(action, body):
    data = json.dumps(body).encode()
    req = urllib.request.Request(f"{GATEWAY}/exa/{action}", data=data, method="POST")
    req.add_header("Authorization", f"Bearer {API_KEY}")
    req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read())

Step 1 — search, with contents bundled

query is the only required field. Exa’s type parameter picks a latency/depth tier — auto (the default, a sensible starting point), instant and fast for latency-sensitive calls, deep and deep-reasoning when a query needs Exa’s own multi-step research pass rather than a single index lookup. On /search specifically, text/highlights/summary have to be nested inside a contents object rather than passed at the top level — that’s how you get page content back in the same round trip as the search itself, instead of a second call:

results = exa("search", {
    "query": "how retrieval-augmented generation reduces hallucination",
    "numResults": 5,
    "contents": {
        "text": {"maxCharacters": 1000},
        "highlights": {"query": "hallucination reduction"},
    },
})["results"]

for r in results:
    print(r["title"], r["url"])

Each item in results carries id, url, title, publishedDate, author, and — because contents was requested inline — text and highlights too. costDollars on the top-level response reports what that call actually cost, split by component (search vs. contents).

Step 2 — pull contents standalone, or expand with findSimilar

The same text/highlights/summary fields work as a standalone call against /contents when you already have IDs or URLs and don’t want to re-run a search — the difference from step 1 is that on /contents those fields sit at the top level, not nested:

pages = exa("contents", {
    "ids": [results[0]["id"]],
    "text": True,
})["results"]

findSimilar takes a single URL and returns other pages Exa’s index considers related by meaning, not by shared keywords — useful for broadening a research pipeline past whatever the original query phrasing happened to surface:

similar = exa("findSimilar", {
    "url": results[0]["url"],
    "numResults": 5,
    "excludeSourceDomain": True,
    "contents": {"text": {"maxCharacters": 500}},
})["results"]

excludeSourceDomain keeps the expansion from just returning more pages off the same site as your seed URL — worth leaving on for genuine topic breadth.

Step 3 — answer

When the end goal is a synthesized, cited answer rather than a list of pages to read yourself, /answer skips straight there:

answer = exa("answer", {
    "query": "does RAG measurably reduce hallucination rates compared to a model with no retrieval?",
    "text": True,
})

print(answer["answer"])
for c in answer["citations"]:
    print("-", c["url"])

text: true includes the full cited passages alongside each citation, not just the URL. There’s no model or systemPrompt field on this endpoint — unlike /search, /answer’s only real inputs are the query itself and whether you want streaming (stream: true, server-sent events) or the citation text inline.

Putting it together

A minimal pipeline: search with contents bundled, expand the strongest result with findSimilar, then ask /answer the actual question once you have a sense of what’s out there:

def research(question):
    hits = exa("search", {
        "query": question,
        "numResults": 5,
        "contents": {"highlights": {"query": question}},
    })["results"]

    if hits:
        exa("findSimilar", {
            "url": hits[0]["url"],
            "numResults": 5,
            "excludeSourceDomain": True,
        })  # broaden coverage; folded into your own ranking/synthesis if you're not using /answer

    return exa("answer", {"query": question, "text": True})


result = research("does RAG measurably reduce hallucination rates?")
print(result["answer"])

Whether you stop at search+contents and synthesize with your own model, or let /answer do the synthesis, depends on whether you need control over the sources going into the final answer or just want the answer itself.

Errors you’ll actually hit

  • 402 Payment Required (from NativePort): the NativePort balance is at $0. Every request answers this deterministically, so top up and retry.
  • 401 Unauthorized: the Authorization: Bearer <NATIVEPORT_API_KEY> header is missing, malformed, or doesn’t resolve to an account.
  • 403 Forbidden: the key is valid but the account isn’t active.
  • 404 on any path outside search, contents, findSimilar, answer: not rate-limited, just not a mounted route — Exa’s async /research API in particular isn’t exposed through this proxy.
  • 400/422 (from Exa): malformed or invalid request parameters — check required fields per action (query for search/answer, url for findSimilar, ids/urls for contents).
  • 429 (from Exa): Exa’s own documented per-second rate limits (tightest on search, findSimilar and answer; looser on contents) — back off before retrying.
  • Exa’s API has no dedicated “out of credits” status distinct from the codes above — a funding problem on Exa’s own account, if it ever happens, would surface as a 401/403-shaped failure rather than a specific code.

Security

Treat NATIVEPORT_API_KEY like any other credential: environment variable or secret store, never a literal string in source, never logged. Exa’s own key never reaches your process — the gateway injects it server-side into every call.

What this costs

Exa’s response carries its own costDollars breakdown, and that’s what gets metered — no NativePort markup on top. Search, contents, findSimilar and answer are priced independently (search scales with result count and depth tier; contents and answer both scale with page count and content type requested), so current per-request rates are worth checking on the Exa provider page rather than assumed from one call. A zero balance answers every request with 402 instead of degrading. Adding credit carries a flat 5.5% fee on top-ups between $10 and $5,000 — never on the calls themselves. Full breakdown: pricing.

Where to go next