CodeWorlds
Back to collections
Guide16 min readCodeWorlds Team

Exa, a semantic search engine built for language models

Exa returns prompt-ready page content instead of a link list. MIT SDKs at 2.18.1, a closed service, per-thousand pricing and the limits of semantic search.

Exa, a semantic search engine built for language models

Exa is a paid web search API that returns prompt-ready page text instead of a bare list of links. The exa-py and exa-js clients both sit at version 2.18.1, released on 14 August 2026 under the MIT licence, but the search engine itself is closed and billed per request, at 7 dollars per thousand calls to the /search endpoint.

What Exa actually returns

The difference starts with what comes back in the response. A classic search API returns a title, a URL and a two-sentence snippet, and you fetch the full text separately with your own crawler. Exa attaches page text to every result by default: in SDK 2.18.1, omitting the contents parameter means {"text": {"maxCharacters": 10000}}, and getting a plain list of URLs requires passing contents=False explicitly. That inverted default feeds straight into the bill, and I come back to it under pricing.

There are a handful of endpoints you actually call from agent code. /search runs a query and returns results together with content. /contents fetches content for URLs you already have. /answer returns a model-generated answer with citations. /monitors runs scheduled searches that report new hits. The Agent API stands apart, for longer research tasks, and is billed on a completely different basis.

One detail from popular write-ups about Exa is already out of date and should not be copied forward. The find_similar method in Python and findSimilar in TypeScript are marked deprecated in 2.18.1, and the docstring spells out the migration path: call search("pages similar to " + url) instead of find_similar(url). The code still works, but building a new integration around that method makes no sense, since its RegularSearchOptions type is already described in the typings as slated for removal.

Versions, licence, and what is genuinely open

Checking the licence from three independent places turns out cleanly here, which is not the rule in this category of tooling. The LICENSE file on the master branch of both repositories, exa-labs/exa-py and exa-labs/exa-js, carries the MIT text with an Exa Labs copyright notice from 2024. The license field on PyPI for exa-py reads MIT, backed by the License :: OSI Approved :: MIT License classifier, and in the npm registry exa-js declares MIT.

The third source, the contents of the published package, agrees as well. The exa_py-2.18.1-py3-none-any.whl wheel weighs 103 kB, contains exa_py-2.18.1.dist-info/licenses/LICENSE, and holds real code: exa_py/api.py alone runs to 3507 lines, alongside the websets, agent, research and monitors modules. The exa-js-2.18.1.tgz tarball unpacks to roughly 1.2 MB, ships package/LICENSE plus built dist/index.js and dist/index.mjs files and type declarations. No stub, no empty metapackage.

That is where the good news ends. The MIT licence covers an HTTP client, not a search engine. The index, the embedding model, the crawling infrastructure and the ranking logic are closed, and there is no self-hosted variant. The code you get for free exists purely to talk to https://api.exa.ai. This is full vendor lock-in: if Exa raises prices, changes ranking behaviour or disappears, you have no fallback beyond rewriting your search layer against another API. It differs from the arrangement described under Firecrawl, where an AGPL-3.0 server at least gives you a theoretical way to run it yourself.

Release cadence is high. PyPI lists 122 releases of exa-py, npm 115 releases of exa-js, and versions 2.16.0, 2.17.0, 2.18.0 and 2.18.1 appeared between 2 July and 14 August 2026. Version numbers across the two packages are synchronised, which helps in mixed projects. The price of that pace is frequent deprecation: within api.py alone, start_crawl_date, end_crawl_date, contents.context, highlights.numSentences and highlights.highlightsPerUrl are all marked deprecated.

It pays to look at dependencies before installing, because both packages pull in more than the word client suggests. exa-py needs Python 3.9 or newer and declares httpx>=0.28.1, httpcore>=1.0.9, openai>=1.48, pydantic>=2.10.6, requests>=2.32.3, python-dotenv>=1.0.1 and typing-extensions>=4.12.2. exa-js depends on zod at ^3.22.0, openai at ^5.0.1, plus cross-fetch, dotenv and zod-to-json-schema. Finding the OpenAI SDK as a hard dependency can be a surprise in a project that does not use OpenAI at all.

Search modes and the first query

In 2.18.1 the type parameter accepts seven values: auto, fast, deep-lite, deep, deep-reasoning, neural and instant. The default auto picks an algorithm on its own, neural forces embedding-based search, instant is the low-latency variant, and the three deep variants run multi-step research that decomposes the query into sub-queries.

Code
Bash
pip install exa-py==2.18.1
npm install exa-js@2.18.1
export EXA_API_KEY="your-key"

The client reads the key from the EXA_API_KEY environment variable when you do not pass it to the constructor. With neither source available, you get a ValueError while constructing the object, not on the first request.

Code
Python
from exa_py import Exa

exa = Exa()  # reads EXA_API_KEY from the environment

response = exa.search(
    "reports on data centre energy consumption in 2026",
    type="auto",
    category="publication",
    num_results=8,
    start_published_date="2026-01-01",
    exclude_domains=["reddit.com", "quora.com"],
    contents={
        "text": {"max_characters": 4000, "verbosity": "compact"},
        "highlights": {"query": "energy consumption", "max_characters": 600},
    },
)

print(response.resolved_search_type)  # 'neural' or 'keyword'
print(response.cost_dollars.total)
for result in response.results:
    print(result.title, result.url, len(result.text))

The category field accepts six values: company, news, publication, personal site, financial report and people. It is not a domain filter but a switch between specialised indexes, so category="company" returns results carrying extra entity fields rather than the same pages in a different order.

The most interesting item in that response is resolved_search_type, or resolvedSearchType in TypeScript. With type="auto", the field tells you whether Exa ultimately used the neural or the keyword path. It is the only way to see after the fact which route a query took, and the first thing worth logging when tuning result quality.

Fetching content, freshness and subpages

The /contents endpoint helps when you already have URLs from elsewhere, say from a sitemap or a database. The get_contents method takes a single URL, a list of URLs, or a list of earlier results.

Code
Python
result = exa.get_contents(
    ["https://exa.ai/pricing", "https://docs.exa.ai/reference/error-codes"],
    livecrawl="preferred",
    livecrawl_timeout=10000,
    max_age_hours=0,
    filter_empty_results=True,
    subpages=2,
    subpage_target=["pricing", "faq"],
    extras={"links": 5, "image_links": 2},
    text={"max_characters": 8000, "exclude_sections": ["navigation", "footer"]},
    summary={"query": "how much does a thousand queries cost"},
)

The livecrawl parameter has five values: always, fallback, never, auto and preferred. They control whether Exa fetches the page live or settles for its indexed copy. Separately there is max_age_hours, where zero means always fetch fresh, minus one forbids live fetching and forces the cache, and any positive value sets the acceptable age of a cached copy in hours.

There is a trap here that is easy to miss in the field documentation. Three text options, verbosity, include_sections and exclude_sections, only take effect when you set max_age_hours=0. Sections can be tagged with header, navigation, banner, body, sidebar, footer and metadata, but without a fresh fetch the filter is silently ignored and you get the full indexed copy, paying for characters you never wanted.

The TypeScript version offers the same capabilities with camel-case names. Below is the equivalent of a query with a model-generated answer.

Code
TypeScript
import Exa from "exa-js";

const exa = new Exa(process.env.EXA_API_KEY);

const answer = await exa.answer(
  "What is the free credit allowance in Exa and what happens once it runs out?",
  {
    text: true,
    model: "exa-pro",
    systemPrompt: "Answer briefly and cite your sources.",
  }
);

console.log(answer.answer);
console.log(answer.citations.map((c) => c.url));
console.log(answer.costDollars?.total, answer.requestId);

const stream = exa.streamAnswer("What changed in exa-js 2.18?", { text: false });
for await (const chunk of stream) {
  process.stdout.write(chunk.content ?? "");
}

The model parameter accepts exactly two values, exa and exa-pro. The response carries a requestId, useful when reporting problems, and costDollars with a cost breakdown. That cost structure has a total field, and beneath it search with neural and keyword subfields, plus contents with text and summary subfields.

Pricing and the arithmetic of the bill

Pricing is entirely pay as you go, with no subscription and no minimum commitment. Below are the rates from the vendor's own pricing table, checked on 22 August 2026.

EndpointBase price per 1000 requestsEach result above 10AI summaries
/search7 USD1 USD per 1000 results1 USD per 1000 pages
/answer5 USDnot applicablenot applicable
/monitors15 USD1 USD per 1000 results1 USD per 1000 pages
/contents1 USD per 1000 pages, per content typenot applicable1 USD per 1000 pages

Deep search sits beside that table, and here two places on the vendor's own site state the numbers differently. The exa.ai/pricing page splits it into two columns, Deep Search at 12 USD and Deep-Reasoning Search at 15 USD per thousand requests, while the documentation summary collapses both into a single range of 12 to 15 USD. Both figures describe the same product at different levels of detail, and when budgeting it is safer to take the upper value.

The phrase price per thousand requests can mislead, because the base price covers up to ten results. A query with num_results=30 therefore costs 7 USD per thousand requests plus twenty thousand extra results at 1 USD per thousand, which is 27 USD per thousand such queries, nearly four times the headline rate. On top of that, returning content is /contents at 1 USD per thousand pages and per content type, so asking for text and highlights together is billed twice. Since the SDK asks for text by default, a content charge appears even when you never thought about it.

The free plan is called Starter and grants 20 USD in credits at sign-up plus 10 USD in credits every month, with no card required. The documentation converts that starting 20 USD into roughly 2800 searches, which matches the arithmetic: 20 divided by 7 dollars per thousand gives 2857 requests at ten results and with no extra content types. It comes with a limit of 5 queries per second and 3 concurrent agent runs. Here the vendor publishes two different numbers: the pricing page assigns the Starter plan 5 queries per second, while a separate documentation page on limits lists 10 queries per second for /search as the default, with no split by plan. The paid plan carries 10 queries per second and 25 concurrent agent runs according to the pricing page, so the documentation page most likely describes that second case. Before planning throughput, check the limit on your own key rather than trusting either page.

One number on the pricing page does not close arithmetically and deserves noting. The headline promises over 120 USD in credits per year, yet 10 USD a month times 12 months is exactly 120 USD. You only get above that figure once the one-off 20 USD sign-up bonus is counted, which means 140 USD in the first year and exactly 120 USD in every year after.

Once credits run out, the API starts returning 402 Payment Required, signalling exhausted account credits or an exceeded per-key budget. Exceeding the per-second query limit yields 429. The full list of error codes is in the documentation, and the distinction itself matters in error-handling code, because 429 is worth retrying with backoff while 402 never fixes itself.

Code
Python
import httpx
from exa_py import Exa

exa = Exa()

try:
    response = exa.search("new embedding models", type="fast", contents=False)
except httpx.HTTPStatusError as error:
    status = error.response.status_code
    if status == 402:
        raise RuntimeError("Exa credits exhausted, top up the account")
    if status == 429:
        raise RuntimeError("Per-second query limit exceeded, retry with backoff")
    if status == 501:
        raise RuntimeError("/answer could not answer this query")
    raise

The Developer plan keeps the same per-request rates and adds card or bank billing, email support, SOC 2 Type II, a limit of 10 queries per second and 25 concurrent agents. The Enterprise plan is quoted on request and adds the things regulated industries tend to require: zero data retention, HIPAA, custom indexes, SSO for the dashboard, and up to 1000 results per single search.

When semantics loses to keyword matching

Embedding-based search wins where the query describes a concept rather than a string of characters. A sentence like "articles explaining why teams move away from microservices" contains no word that must appear in a good result, so keyword matching handles it poorly while an embedding model handles it well. The same applies to finding pages similar to a known page and to queries of the form "companies doing X in region Y".

The reverse case is just as common and should not be hidden. If you are looking for an exact proper name, a version number, an issue identifier or an error code such as TS2345 or ECONNREFUSED, an embedding model returns topically nearby pages but not necessarily the one containing that exact string. A classic inverted index does better and faster. If the search box in the top right corner of your application looks up product names, semantics improves nothing there.

Exa offers three ways out. The first is include_text and exclude_text, lists of strings that must or must not appear in the page text, applied on top of the search result. The second is the keyword mode reached indirectly through auto, whose decision you read from resolved_search_type. The third is simply admitting that a given query belongs elsewhere, since an agent is under no obligation to own a single search tool.

In practice the arrangement that works best puts a routing layer in front, deciding by the shape of the query. Queries with quotation marks, error codes or identifiers go to keyword search, descriptive queries go to Exa. Such a router is written in LangChain or LlamaIndex as an ordinary tool choice and costs a dozen or so lines.

Exa next to Tavily and Firecrawl

The three tools often get lumped together, although they solve different problems. Below are the differences that actually drive the choice.

FeatureExaTavilyFirecrawl
Primary jobsemantic search over its own indexrelevance-scored search for an agentfetching and converting known pages
Client licenceMITMITMIT
Service codeclosed, no self-hostingclosed, no self-hostingAGPL-3.0, self-hosting possible
Billingdollar amounts per request and per pagecreditscredits
Page content in the responseyes by default, up to 10000 charactersyes, scored snippetsyes, markdown of the whole page

Tavily is the closest neighbour, since it also targets agents and also returns prompt-ready snippets. It differs in its billing model and in the fact that Exa builds on its own index searched by embeddings, whereas Tavily arranges results around a relevance score for the question asked. If descriptive queries and similarity to a known page matter to you, Exa comes out ahead. If you count cost in simple credits and want one call per question, Tavily is easier to estimate.

Firecrawl is not a search engine, and comparing it with Exa on result quality misses the point. Firecrawl takes a URL and returns markdown, handles pages that require script execution, and can walk an entire site. Exa fetches content as a by-product of searching, but it will not replace a full pass over a documentation site. The natural pairing is Exa to find the URLs and Firecrawl to fetch them thoroughly.

When a task requires logging in, clicking and filling forms, none of the three suffices and you need browser control such as browser-use. A search API sees only what is publicly available to a crawler.

Common mistakes

The first and most expensive is forgetting contents=False. If an agent runs a search purely to pick URLs for later processing, the default ten thousand characters of text per result is pure cost and needless tokens in the context window.

The second is setting a high num_results without doing the arithmetic on results above the tenth. Thirty results cost nearly four times what ten cost, and models rarely read past the first few entries anyway.

The third is asking for text, highlights and summary at once. Each content type is billed separately at 1 USD per thousand pages, so the full set triples that part of the bill. One is usually enough, and highlights with a sensible query gives shorter, better-targeted context than raw text.

The fourth is relying on verbosity or exclude_sections without setting max_age_hours=0. The section filter is skipped without warning, while you go on believing that navigation menus and footers never reach the prompt.

The fifth is building new code on find_similar. The method is marked deprecated in 2.18.1 along with the entire options type it uses.

The sixth is treating the MIT licence as a guarantee of independence. You can do whatever you like with the client, but without an account and a key for api.exa.ai that client does nothing. When adopting it, assume from the start that the search layer in your application needs an interface you can swap out.

FAQ

Does the MIT licence mean Exa is an open project?

Only the client libraries are open. The exa-labs/exa-py and exa-labs/exa-js repositories carry a LICENSE file with the MIT text, the registries declare MIT, and the published packages contain both the licence file and the full source. The search engine, the index and the embedding model stay closed and paid, and there is no variant you can run on your own server.

What does a thousand searches actually cost?

At ten results and with no extra content types, it is 7 USD per thousand /search requests. Every thousand results above the tenth adds 1 USD, each content type adds 1 USD per thousand pages, and deep search starts at 12 USD and reaches 15 USD per thousand requests depending on the variant.

What happens once the free credits run out?

The API starts returning 402 Payment Required, meaning exhausted account credits or an exceeded budget assigned to the key. Requests are neither queued nor served on credit. Separately there is 429, signalling that the per-second query limit has been passed, which on the Starter plan is 5.

When is Exa the wrong choice?

When you are searching for exact strings such as an error code, a version number or an issue identifier, because keyword matching is faster and more precise there. When you need to walk an entire documentation site and convert it to markdown, since that is a job for a fetching tool. When you cannot accept lock-in to a single closed vendor.

Can you use Exa without the SDK?

Yes, the whole thing works as a plain REST API at https://api.exa.ai with the key in an x-api-key header. The SDKs add types, streaming support and conversion of field names from snake case to camel case, but they do nothing you cannot invoke with an HTTP request.

How do you check whether a query took the semantic path?

The response carries resolved_search_type in Python and resolvedSearchType in TypeScript, populated under type="auto" with either neural or keyword. Logging that field alongside cost_dollars and request_id gives you a basis for judging which queries use semantic search at all.

Read next

We use cookies to enhance your experience on the site