Faceted Search, Explained (with a Working Example)
Faceted search is the sidebar on every shopping site: a list of attribute values with a count next to each one — Brand: Arc'teryx (42), Patagonia (17) — computed over the results of the query the user just ran. A facet is a filter that tells you how many things it would leave you with before you click it. On altengine's Search API you get them by attaching facets to your documents and asking for them in the search request.
Facets vs. filters: the distinction that matters
A filter narrows. A facet counts. They usually operate on the same attribute, which is why people conflate them, but they run in opposite directions:
- A filter is something the user has already chosen. It's part of the query:
brand = "patagonia"removes everything else from the result set. - A facet is something the user could choose next. It's computed over the matches and returned alongside them: here are the brands present in these 312 results, and how many documents each one holds.
That count is the whole point. Without it, a filter sidebar is a guess — the user picks "Patagonia" and lands on "0 results", backs out, tries again. With counts, dead ends are visible before they're clicked, and the sidebar doubles as a summary of the result set: most of these are jackets, and half of them are under $150.
The rule of thumb for whether you need facets: if your result sets are routinely large enough that a user has to narrow them, and the narrowing dimensions are known ahead of time (category, brand, color, price band, year), facets earn their keep. If a query typically returns a dozen rows, or every result differs on every attribute, a plain filter UI is simpler and you should stop here.
Step 1: write the facets onto your documents
Here is the part that trips up almost everyone the first time. On altengine, facets are not fields. A document carries a list of fields and, separately, an optional list of facets. fields make a value searchable; facets make it countable. Faceting on a value you only wrote as a field returns nothing — not an error, just an empty facet list, which is easy to misread as "there is no such value in my data".
So for anything a user will both search on and narrow by, write it in both places:
POST /v1/search/catalog/ns/_default/idx/products/documents
Authorization: Bearer ae_yourkeyid.your-api-key-secret
{
"documents": [
{
"id": "sku-1024",
"fields": [
{ "name": "title", "type": "text", "value": "Alpha SV Jacket" },
{ "name": "brand", "type": "atom", "value": "arcteryx" },
{ "name": "color", "type": "atom", "value": "black" },
{ "name": "price", "type": "number", "value": 749 }
],
"facets": [
{ "name": "brand", "type": "atom", "value": "arcteryx" },
{ "name": "color", "type": "atom", "value": "black" },
{ "name": "price", "type": "number", "value": 749 }
]
}
]
}
# → { "ids": ["sku-1024"] }Facets come in two types. Atom facets return a count per distinct value — that's your brand and color lists. Number facets return half-open [min, max) ranges with counts, which is how you get a price histogram without deciding the buckets yourself. Pick facet values that are already normalized: arcteryx, not Arc'teryx — an atom is matched exactly and is not tokenized, so casing and punctuation differences become separate rows in the sidebar.
Documents go up to 200 per request, and putting a document with an existing id replaces it, so backfilling facets onto an existing index means re-putting the documents with the extra block. There's no separate schema step; a facet exists because a document carries it.
Step 2: ask for facet counts in the query
A search request takes two facet-related knobs. facets is an explicit array of atom facet names you always want counts for. facet_discover asks altengine to auto-discover up to N of the most common facets over the matches — useful when you don't know the dimensions up front, or want to surface whatever is interesting in this particular result set.
POST /v1/search/catalog/ns/_default/idx/products/search
{
"query": "jacket price < 800",
"limit": 20,
"facets": ["brand", "color"],
"facet_discover": 5
}The response carries the page of results and the facet block together — one round trip, not two:
{
"total_hits": 42,
"total_hits_exact": true,
"returned": 20,
"results": [ { "id": "sku-1024", "rank": 12345, "document": { … } } ],
"cursor": "eyJvIjoyMH0",
"facets": [
{
"name": "brand",
"type": "atom",
"values": [
{ "value": "arcteryx", "count": 12 },
{ "value": "patagonia", "count": 8 }
]
}
]
}Render that facets array straight into your sidebar: name is the group heading, each value is a checkbox, each count is the number in parentheses.
Step 3: apply what the user clicked
When the user ticks a box, send the same query back with a facet_refinements entry naming that facet and value. Refinements constrain the result set to documents carrying that facet value:
{
"query": "jacket price < 800",
"limit": 20,
"facets": ["brand", "color"],
"facet_refinements": [
{ "name": "brand", "value": "arcteryx" },
{ "name": "color", "value": "black" }
]
}Note that the user's text query stays exactly as it was. Refinement is a separate axis from the query string, which is what makes the back button and shareable URLs easy: serialize the query plus the list of refinements into your URL state and you can reconstruct any point in the drill-down. And because refinements are structured rather than string-concatenated into the query, you never have to escape user-chosen values into the query language.
What facets cost, and how to keep it cheap
Search bills on four axes: a flat per-query fee, reads metered by rows examined, writes metered by rows written, and stored data. Facets touch three of them:
- Writes and storage. Every facet on a document is another indexed value, so it adds index rows at write time and bytes at rest. Facet the three or four dimensions your UI actually offers, not every attribute you happen to have.
- Reads. Counting happens over the matching set, so facet cost tracks query selectivity. One read covers up to 100 rows examined; a selective query is a read or two, while an unselective one (an empty query over a big index, say) pays for the rows it touches — with or without facets.
- Queries. Flat, per search request. This is the one worth designing around: every request you make counts, including the identical one you just made.
Two practical consequences. First, a repeated identical query is served from cache and bills zero reads — just its flat query fee — and a write to the index invalidates that cache, so you're never reading stale counts. Drill-down UIs are full of near-repeats (back button, toggling a refinement off), and those come back cheap. Second, if you want counts without a page of documents — a "refine your search" panel loaded separately from the grid — set limit: 0 and the response returns counts and facets only.
One thing facets are not: they're not total_hits. That number is counted exactly only up to total_hits_accuracy (default 20, max 10000), and when total_hits_exact comes back false you should render it as "N+". Facet counts are their own numbers in the facets block. If you're also using query rules, note that a hidden document is excluded from facet counts as well as results; field collapsing, on the other hand, leaves facet counts uncollapsed, because they count matching documents rather than groups.
The full request and response reference — every field, the query language, snippets, sorting, and the exact billing table — is on the Search API docs page, and the rates are on the pricing page. There's no cluster to size before you try this: an index you aren't querying costs only its storage, and the first $3 of usage each month is free for every organization, with no card to start.
← Back to the blog