# Search
aardvark has **built-in search** — no Algolia, no crawler, no API keys. The build
writes a full-text index of your site and a Mantine search box scores it in the
browser as you type.
## Using it
Press ⌘K (or /) anywhere, or click the search box in the
header. Then:
- type to see ranked results update live, with the matching text highlighted so
you can tell *why* each result surfaced;
- after results appear, use the path filter to narrow the list to one or more
sections of the site;
- leave **Exact matches** off for small spelling slips, or turn it on
when you want only exact typed terms;
- use ↑/↓ to move through results and ↵ to open
the highlighted one;
- press Esc to close.
### Narrowing a search
Two operators help when a plain keyword search returns too much:
- **Exact phrase** — wrap words in double quotes to match them as one literal,
consecutive phrase: `"getting started"` finds only pages with that exact wording
(punctuation counts), not pages that merely mention *getting* and *started* apart.
- **Exclude a word** — prefix a term with a minus sign to drop every page that
contains it: `theme -dark` finds theme pages that don't mention *dark*. You can
exclude a phrase too — `-"under construction"`. Exclusions look at page content,
not the URL or breadcrumb, so `-dark` won't hide a page that just happens to live
under `/dark-mode/`.
Mix them freely: `"api key" -deprecated` requires the exact phrase *api key* and
drops anything mentioning *deprecated*.
The search box also corrects small typos for unquoted terms by looking at
high-signal labels: titles, headings, and keywords. That helps a
misspelling still reach the right page, but it can be too generous for short
product/component names. Check **Exact matches** in the search dialog to
require typed terms exactly. Quoted phrases and exclusions are always
literal.
### Filtering by path
After a query has results, the search dialog also shows a **path filter**. It is
built automatically from the URLs in the current result set: if the results include
`/components/inputs/textinput/`, the filter can offer `/components/` and
`/components/inputs/`. Root (`/`) is skipped so the menu stays focused on useful
site sections.
Pick one or more folders to keep only results under those paths. For example,
`/components/inputs/` keeps input component pages, while `/components/` keeps the
whole components section. Multiple selected paths are combined as an OR filter:
choosing `/components/buttons/` and `/authoring/` shows results from either area.
Path filters do **not** rerank results. The search scorer still runs once for the
query, then the selected paths narrow the visible list while preserving that
ranking order. Matching is path-boundary aware, so `/components/` matches
`/components/button/` but not `/components-extra/`.
Filters stay selected while you refine the query, as long as the new result set
still contains those paths. If a selected path disappears, aardvark drops that
selection so a stale filter cannot make a valid search look empty. Closing the
search dialog clears both the query and selected paths.
## What the build does
Every build writes `search-index.json` to your site root — one record per page:
| field | what it holds |
|---|---|
| `url`, `title`, `breadcrumb` | where the page lives and what it's called |
| `description`, `keywords` | from the page's front matter |
| `headings` | every `h1`–`h6` on the page |
| `inboundLinks` | a `{text: count}` map of the link text other pages use to link here — deduped, with how many times each is used |
| `text` | the full, user-visible body text (markup stripped) |
The client scores matches by where they hit: a hit in the **title**, **URL**, or
**breadcrumb** is worth 10; in the **headings**, **keywords**, or **description**, 5;
and anywhere in the **body**, 1. A match in the **inbound link text** is worth 5 too,
scaled up (dampened, by `log2` of the count) when many pages link here with that text —
so a widely-referenced page ranks a little higher for the words others use to point at
it, without letting nav links dominate. A multi-word query that appears **verbatim** in
the title, a heading, or the description earns an extra bonus on top, so exact phrase
matches in those summary fields rank above incidental word hits — and a `"quoted phrase"`
(see [above](#narrowing-a-search)) is *required*, matched literally anywhere on the page,
not merely boosted. Results sort by total
score, strongest first; when two pages score the same, the one with more headings wins
(a fully documented page beats a one-line stub), then the more-linked page, then title.
Matching is **accent-insensitive**: the query and the index are folded the same way
(diacritics stripped, case ignored), so searching `cafe` finds *Café* and `münchen`
finds *Munchen* — either spelling matches either. This is always on and needs no config.
Every one of these weights is configurable — see [Tuning the ranking](#tuning-the-ranking).
### Per-page control
Two front-matter keys tune how an individual page behaves in search:
- **`searchable: false`** removes the page from the search box and every AI/agent corpus the build
emits — the assistant / MCP index, `llms.txt`, and page cards — while leaving it **fully public and
crawlable**: it stays in the sitemap, keeps its normal `robots` meta, and still serves, so search
engines index it as usual. Use it to declutter results with pages readers shouldn't stumble into via
search (a legal boilerplate page, a redirect stub). This is distinct from `noindex`, which goes
further and hides the page from the *outside* world too — dropping it from the sitemap and emitting a
`noindex` robots directive so search engines skip it.
- **`boost: `** scales a page's match score (default `1`). It's *multiplicative*, so it
reranks pages the query already matches rather than forcing an unrelated page to the top —
`boost: 2` roughly doubles a page's rank strength, `boost: 0.5` halves it, and `boost: 0`
soft-hides a page (it stays indexed but can never rank). Handy to float a canonical "Getting
started" above near-duplicates, or sink a deprecated page.
```yaml
---
title: Deprecated API
boost: 0.2 # still findable, but ranks below the current docs
---
```
### Tuning the ranking
Set any of the weights under `search.ranking` in your config. Anything you leave out
keeps its default, so you only list what you want to change. These are the defaults:
```yaml
search:
ranking:
title: 10 # match in the page title
url: 10 # match in the URL path
breadcrumb: 10 # match in the breadcrumb trail
headings: 5 # match in any h1-h6 heading
keywords: 5 # match in the front-matter keywords
description: 5 # match in the front-matter description
text: 1 # match anywhere in the body text
inboundLink: 5 # match in other pages' link text (then scaled up by how many link here)
phrase: 10 # bonus for a verbatim multi-word phrase in the title, a heading, or the description
synonym: 0.5 # a synonym match scores 50% (0.5×) of the same word matched exactly; see Synonyms
typo: 0.5 # a typo-corrected match scores 50% (0.5×) of an exact match; see Typo tolerance
```
The last two — `synonym` and `typo` — are **multipliers**, not field weights. A synonym-
or typo-matched term earns its normal per-field score, then that score is **multiplied by
this factor**. So the default `0.5` means such a match is worth **half** of the same word
matched exactly: if an exact title match is worth `10`, the same word reached through a
synonym or a typo-correction is worth `5`. That's why [synonyms](#synonyms) and
[typo-corrected](#typo-tolerance) matches rank below an exact match.
Set it to `0.25` to count them at a quarter, `1` to count them the **same** as an exact
match (no penalty), or `0` to keep the expansion for recall but give it no ranking weight
at all. Keeping the factor **below `1`** preserves the rule that an exact match always
wins; a value **above `1`** would let a synonym or fuzzy match *outrank* a literal one.
Set a weight to `0` to switch a field off entirely. You only list the weights you want
to change — everything else keeps its default. A few common adjustments:
**Rank on titles and structure, not prose.** Good when bodies are long and repetitive
(API references, generated docs) and the title already says what a page is:
```yaml
search:
ranking:
text: 0 # ignore body-text matches
headings: 8 # lean harder on section headings
```
**Reward exact wording.** Boosts pages where the query appears verbatim in a title,
heading, or description — handy for a glossary or a precise term index:
```yaml
search:
ranking:
phrase: 25 # a verbatim multi-word match is a strong signal
```
**Trust your own titles over how other pages link.** Lower the inbound-link weight when
a few heavily-linked hub pages crowd out more relevant results:
```yaml
search:
ranking:
inboundLink: 1
```
**Lean on synonyms and typo-correction.** If your synonym map is curated and your terms
are easy to misspell, let those matches compete closer to exact ones (still below, so an
exact match wins):
```yaml
search:
ranking:
synonym: 0.9 # a synonym match counts almost as much as an exact one
typo: 0.7 # forgive misspellings more generously
```
**Validation.** Weights and factors must be numbers `>= 0`. An unknown key, a non-numeric
value, or a negative one is ignored with a build warning and falls back to its default — so a
typo can't silently skew ranking. The tie-breaking order used when two pages score equally
(more headings, then more inbound links, then title) isn't configurable.
### Typo tolerance
Search forgives misspellings out of the box: a query term that matches nothing is compared
against the words in your titles, headings, and keywords, and the closest ones (within one edit
for short words, two for longer) are searched too — so `instalation` still finds *Installation*.
Corrected matches rank **below** exact ones, so a real match is never buried — and you control *how
far* below with the `search.ranking.typo` factor (see [Tuning the ranking](#tuning-the-ranking)). It's
**on by default**; tune or disable it under `search.typoTolerance`:
```yaml
search:
typoTolerance: false # turn it off entirely
```
…or tune it:
```yaml
search:
typoTolerance:
maxDistance: 1 # max edits to consider (0–2; default 2)
minLength: 5 # don't fuzz terms shorter than this (default 4)
```
Short terms and API symbols are left alone (`minLength`) so they aren't fuzzed into unrelated words.
### Synonyms
Map the words readers type to the words your docs use — so a search for one finds pages that only
mention the other. Each entry is **multi-way**: every word in a group matches every other.
```yaml
search:
synonyms:
otp: [one-time password, 2fa code]
login: [sign in, signin, log in]
k8s: [kubernetes]
```
With this, `otp` also finds pages that say *one-time password* — and searching *one-time password* finds
pages that only say `otp`. Matching is by whole query word: a single-word synonym (like `k8s`/`kubernetes`)
works in **both** directions, and a multi-word value matches when the reader types its words in order, so it
too expands back to the rest of its group. Synonyms expand the
reader's query only — they rank below an exact match (tune how far below with the `search.ranking.synonym`
factor — see [Tuning the ranking](#tuning-the-ranking)), and a `"quoted phrase"` or `-exclusion` is never
expanded (those are explicit literal signals). Checking **Exact matches** also skips synonym expansion
for that search. Malformed entries are dropped with a build warning.
### Section & API-symbol results
Turn this on to let a result jump straight to the **heading** it matched, instead of the top of the
page — and to make **API operations searchable by symbol**:
```yaml
search:
sections: true # → default heading band h2–h3
```
…or choose the band:
```yaml
search:
sections:
minLevel: 2
maxLevel: 4
```
With `search.sections` on:
- a match in a section heading deep-links to that heading (`/guide/auth/#tokens`), with the heading
shown in the result's trail;
- every `{% openapi %}` operation becomes findable by its **operationId**, its **`METHOD /path`**, or
its tag — so a search for `createPet` or `POST /pets` lands right on that operation. (Operations are
rendered by the API-reference island, so they're invisible to the normal page-text indexer without
this.)
It's **off by default** because section records grow the index; a match in body prose still surfaces
the page even when it's off. This composes with the [path filter](#narrowing-a-search) — faceting still
narrows by site area, while sections locate the spot within a page.
### Multilingual sites
On a site with more than one language, each result is tagged with its page's language, and the search
box gently **prefers results in the language you're currently reading** — a translated page ranks above
its foreign-language twin, while an untranslated page stays findable. No configuration required.
Matching is also accent-insensitive (see above), which helps across languages that share a script.
### 404 suggestions
When search is on, the generated **404 page** helps a lost reader recover: it reads the URL that
missed, searches the index for the closest pages, and lists them as "were you looking for…" links
(deep-linked to a heading when [sections](#section-api-symbol-results) are on). Because it reuses the
search scorer, [typo tolerance](#typo-tolerance) quietly fixes a fat-fingered URL too — `/instalation/`
suggests *Installation*. It's automatic; just write a helpful [`404.md`](/deployment/#custom-404-page), which stays the fallback
when a visitor has JavaScript disabled. The header ⌘K search is available on the 404 page as well.
The built-in theme wires this in for you. If you ship a **fully custom theme**, add the
`{% not_found_html %}` slot to your `default.html` (the built-in places it just before
`{% content %}`) to get the suggestions island — see
[Theming → what the layout receives](/theming/#what-the-layout-receives). Without it the 404 page still renders your static
`404.md`; you just don't get the "were you looking for…" island.
## Analytics
When the Google Analytics integration is configured (`integrations.analytics`),
search activity is reported automatically — no extra setup:
- a **`search`** event (with `search_term` and the number of `results`) each time a
query settles. This feeds GA4's built-in *Site search* report, and the
`results: 0` events show what people look for but don't find — your content gaps.
- a **`search_select`** event (`search_term`, `url`, `position`) when a result is
opened, so you can see which queries lead where.
With analytics off, nothing is sent.
### Search Analytics dashboard (AI-enabled sites)
If your site uses the built-in AI assistant (a metered "aardvark cloud" key), the search box also
reports activity to your dashboard's **Search Analytics** tab — a superset of what Fern, ReadMe, and
Mintlify show:
- **top searches** and **zero-result queries** (your content gaps), with a volume trend;
- **click-through rate**, **average result position**, and **no-click sessions** (searches that went
nowhere);
- **Ask-AI escalation rate** — how often a search turned into an assistant question;
- **top clicked results**, a **language** breakdown, and **search latency**;
- **CSV export** of the top terms.
Query text is stored by default (bounded; the raw rows are swept after a retention window — **90 days
by default**, configurable via the gateway's `SEARCH_EVENT_RETENTION_DAYS`, after which only the
aggregate trends remain) so you get the term-level drill-down. Set `search.analytics.store_terms: false`
to log only the anonymous funnel (counts, rates,
positions, latency — no raw text), or `search.analytics: false` to turn the dashboard capture off
entirely. Readers with **Do-Not-Track** or **Save-Data** set are never logged by the shipped search
widget — it sends no beacon (a client-side check, like `store_terms` above). (This is independent of the
GA events above — you can run either, both, or neither.)
`store_terms: false` is a **client-side, best-effort** control: your site's search widget omits the query
text before sending, so with the shipped widget a reader's query text isn't transmitted. The gateway does
**not** re-check the flag — it stores whatever `term` a caller sends — so it isn't an absolute guarantee:
a custom or scripted client that ignored the setting could still write term text. On the server, the ingest
endpoint's **origin allowlist** is the primary safeguard: it only accepts beacons whose `Origin` is your own docs site, so the
world-readable `aardvark_live_` key in `ai-config.json` can't be used from another site's browser to write
into your analytics — that allowlist, not the per-account rate cap, is what keeps a leaked public key
low-risk. It isn't an absolute gate (a determined caller scripting requests outside a browser can forge the
`Origin` header), but even then they can only inject their *own* text (bounded and rate-capped), never a
reader's query. In short: the widget + `store_terms` protect readers' text client-side, and the origin
allowlist is the server-side backstop that keeps other sites out.
## Index size
The index is gzipped at build time — `search-index.json.gz` is written next to the
plain file, and the search box gunzips it in the browser (via `DecompressionStream`),
so it downloads small on **any** host, not only ones that apply `Content-Encoding`
themselves (typically a 3–4× saving). Browsers without `DecompressionStream` fall back
to the plain `search-index.json` automatically.
The JSON itself is minified (it's read by code, not people), so the plain file stays
as small as possible before gzip even kicks in.
To skip the gzipped copy and rely on your server's own compression instead:
```yaml
search:
compress: false
```
## Caching
The index is served from a stable path (`search-index.json` / `.gz`), so make sure
your host serves it with **revalidation** — an `ETag`/`Last-Modified` (most static
hosts and CDNs do this by default) or `Cache-Control: no-cache` — so readers pick up
fresh results right after a redeploy instead of a stale cached copy. It only needs
attention if your host sets long, non-revalidating cache lifetimes on JSON.
> **Serve from the domain root.** The search box fetches `/search-index.json` by an
> absolute, root-relative path — as the theme does for all its generated assets (the
> islands bundle, `theme-.css`, hover-card data, …). Deploying under a URL sub-path
> (e.g. `https://example.com/docs/`) isn't supported: those assets would 404. Host
> the site at the origin root (or a subdomain), which is the default for the
> supported targets (Cloudflare, Netlify, GitHub Pages project sites via a subdomain).
## Turning it off
Search is on by default. To disable both the header box and the index file, set:
```yaml
search: false
```
> The search box is a Mantine island, so it appears on builds that bundle islands
> (Node + esbuild — the default). `search-index.json` is written either way, so a
> `--no-bundle` build can still be searched once rebuilt with the bundle.