# Search in depth

`POST /v1/search` answers a query with merged, deduplicated results from
several search engines, falling back to a paid API only when it has to.

## Request

```json
{
  "query": "Nestlé sustainability report 2025",
  "language": "en",
  "country": "CH",
  "max_results": 20,
  "freshness": "month",
  "cache_ttl_s": 86400,
  "project_ref": "company:4711"
}
```

Only `query` is required. `language` is ISO 639-1 (optionally with a region,
`de-CH`), `country` ISO 3166-1 alpha-2, `max_results` 1–50 (default 20),
`freshness` one of `day`, `week`, `month`, `year`.

## Providers and fallback

1. **Shared cache** — keyed by the normalised query (whitespace collapsed,
   lower case) plus `language`, `country`, `max_results` and `freshness`;
   entries live 7 days. `cache_ttl_s` caps the age you accept; `0` bypasses
   the cache.
2. **SearXNG** — Cormorant's own metasearch instances (Bing, Yahoo, Yep,
   Yandex, Seznam, DuckDuckGo, Brave, Wikipedia, Bing News). Free.
3. **Brave Search API** — the paid fallback, used when SearXNG

   - fails or times out (`fallback_reason`: `searxng_error`,
     `searxng_timeout`, `no_searxng_instance`),
   - reports unresponsive engines and fewer than two engines answered
     (`unresponsive_engines`, or `captcha_engines` when an engine was
     CAPTCHA-blocked), or
   - returns fewer than 3 usable results (`too_few_results`).

`provider` says who answered (`searxng` or `brave`), `fallback_reason` why
the fallback was considered and `unresponsive_engines` which SearXNG engines
failed for this query. When Brave is used, its results come first, followed
by results only SearXNG found.

If Brave is unavailable or your project's budget is exhausted, whatever
SearXNG returned is served (and not cached) with `fallback_reason` set. A
search that the engines answered but that found nothing returns
`results: []` with `error: null`. Only when the providers could not answer is
`error` set — for example `budget_exhausted`, `brave_http_429` or
`searxng_timeout`.

## Results: nothing is lost

Results are deduplicated by normalised URL (scheme, `www.`, trailing slash and
tracking parameters ignored). For each URL, **every distinct snippet and every
engine** that returned it is kept, in order of first appearance:

```json
{
  "url": "https://www.nestle.com/sustainability/reporting",
  "title": "Creating Shared Value and Sustainability Report 2025",
  "snippets": ["Our 2025 report covers ...", "Download the full report ..."],
  "engines": ["bing", "duckduckgo", "brave_api"],
  "published_at": "2026-03-02T00:00:00",
  "rank": 1
}
```

More engines agreeing on a URL is a useful relevance signal; more snippets give
an LLM more context before it decides to fetch the page.

## Costs

SearXNG answers and cache hits are free. A Brave call costs a small fixed
amount per request (operator-configured, on the order of half a cent), shown
in `cost_usd` and charged to your project's budget. Brave's own rate limits are
retried by Cormorant and never billed.

## Examples

```sh
curl -sS https://api.cormorant.ch/v1/search \
  -H "Authorization: Bearer $CORMORANT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "Holcim annual report 2025", "language": "en", "freshness": "year", "max_results": 10}'
```

```python
answer = client.post(
    "/v1/search",
    json={
        "query": "Holcim annual report 2025",
        "language": "en",
        "freshness": "year",
        "max_results": 10,
    },
).json()
if answer["error"]:
    print("search failed:", answer["error"])
for hit in answer["results"]:
    print(f"{hit['rank']:>2} {hit['title']} ({', '.join(hit['engines'])})\n   {hit['url']}")
```

```ts
const answer = await (
  await fetch(`${BASE}/v1/search`, {
    method: "POST",
    headers,
    body: JSON.stringify({ query: "Holcim annual report 2025", language: "en", freshness: "year", max_results: 10 }),
  })
).json();
for (const hit of answer.results) console.log(hit.rank, hit.title, hit.url, hit.engines.join(","));
```

## Tips

- Searching is cheap; fetching is where the time goes. Use the snippets to
  decide which results deserve a fetch.
- Keep `max_results` as small as you need: it is part of the cache key, so
  consistent values also mean more cache hits.
- For many queries use `POST /v1/batch/search` — see
  [Sync vs batch](/docs/sync-vs-batch.md).
