Filters, Sorting & Pagination
Complete reference for filter expressions, sort options, faceting, and pagination in AACsearch queries.
AACsearch uses AACSearch's filter and sort syntax with a few conventions specific to the product schema. This page is a complete reference for building filter expressions, configuring sort orders, and paginating results.
Filter expression syntax
Filters are specified as a string using boolean logic.
Equality
availability:=in_stock
brand:=Sony
categories:=Electronics
locale:=enNegation
availability:!=out_of_stock
brand:!=CompetitorsRange (numeric)
price:>50
price:<200
price:[50..200] # inclusive range
price:(50..200) # exclusive rangeArray membership
categories:=[Electronics, Audio]
brand:=[Sony, Bose, Sennheiser]String contains / prefix
AACSearch supports exact string match only for faceted fields. For prefix search, use q with queryBy.
Boolean logic
# AND (both conditions must match)
availability:=in_stock && price:<200
# OR (either condition matches)
brand:=Sony || brand:=Bose
# Grouping
(brand:=Sony || brand:=Bose) && availability:=in_stock && price:<200Tenant filter (automatic)
The public handler automatically prepends:
organization_id:={organizationId}You do not need to add this filter manually — it is always applied.
Available filter fields
The default product schema includes these filterable fields (marked facet: true):
| Field | Type | Example filter |
|---|---|---|
brand | string | brand:=Sony |
categories | string[] | categories:=[Electronics, Audio] |
availability | string | availability:=in_stock |
price | float | price:[10..500] |
sale_price | float | sale_price:<100 |
locale | string | locale:=en |
Faceting
Facets compute the value distribution for a field across matching documents. Use them to render filter sidebars.
{
"q": "headphones",
"facetBy": "brand,categories,availability"
}Response includes facetCounts:
{
"facetCounts": [
{
"fieldName": "brand",
"counts": [
{ "value": "Sony", "count": 45 },
{ "value": "Bose", "count": 28 },
{ "value": "Sennheiser", "count": 19 }
]
},
{
"fieldName": "availability",
"counts": [
{ "value": "in_stock", "count": 89 },
{ "value": "out_of_stock", "count": 23 }
]
}
]
}Facets with filters applied
When a filter is active, facet counts reflect the filtered result set, not the whole index:
{
"q": "*",
"filterBy": "brand:=Sony",
"facetBy": "categories,availability"
}This returns categories and availability counts for Sony products only.
Sorting
Single-field sort
{ "sortBy": "price:asc" }
{ "sortBy": "price:desc" }
{ "sortBy": "created_at:desc" }
{ "sortBy": "sale_price:asc" }Relevance-first sort (default)
{ "sortBy": "_text_match:desc" }This is the default when sortBy is omitted.
Multi-field sort (tiebreaker)
{ "sortBy": "_text_match:desc,price:asc" }Sorts by relevance score first; within the same score, sorts by price ascending.
Common sort presets
| Label | sortBy value |
|---|---|
| Most relevant | _text_match:desc |
| Price: low to high | price:asc |
| Price: high to low | price:desc |
| Newest first | created_at:desc |
| Sale price: low to high | sale_price:asc |
Pagination
{
"page": 1, // 1-indexed, default: 1
"perPage": 20 // default: 10, max: 100
}Response fields for pagination:
{
"found": 142, // total matching documents
"page": 1, // current page
"outOf": 5000 // total documents in the index (unfilterd)
}Computing total pages:
const totalPages = Math.ceil(results.found / perPage);
const hasMore = results.page * perPage < results.found;Deep pagination warning
AACSearch performance degrades with large page values for indexes over 100K documents.
For deep pagination or exports:
- Use increasing
pagevalues with a fixedperPage - Or implement cursor-based pagination by sorting on a unique field (
id:asc) and usingfilterBy: "id:>lastSeenId"
Escaping values
Filter values are parsed by AACSearch's filter expression engine. Most strings work as-is, but a few characters have special meaning and must be backtick-quoted (or backslash-escaped) when they appear inside a value:
| Character | Meaning in the grammar | Inside a value, write as |
|---|---|---|
, | Array element separator | Wrap value in `…` |
&& | AND operator | Wrap value in `…` |
|| | OR operator | Wrap value in `…` |
( ) | Grouping | Wrap value in `…` |
[ ] | Range / array delimiters | Wrap value in `…` |
: | Field/operator separator | Wrap value in `…` |
` | The quote character itself | Escape with backslash: \` |
Examples:
# Brand name contains a comma — must be quoted
brand:=`Ben & Jerry's`
# Category contains the AND operator literally
categories:=`Home && Garden`
# Title contains a backtick
title:=`The \`Quoted\` Title`Numeric and boolean values never need escaping. Numbers can be negative (price:>-100) and floats use a dot decimal (price:<99.99).
User-controlled values must always be escaped before concatenation. The standard way is the safe filter builder below — never interpolate user input into the filter string directly.
Safe filter builder
Hand-concatenating a filter string from user input is a category-1 injection bug: a customer typing ") || price:>0 || (1:=1 could bypass your business rules. Always build filters from a structured representation.
A minimal, copy-paste-able builder in TypeScript:
type AtomicFilter =
| { field: string; op: "="; value: string | number | boolean }
| { field: string; op: "!="; value: string | number | boolean }
| { field: string; op: ">" | "<" | ">=" | "<="; value: number }
| { field: string; op: "in"; values: Array<string | number> }
| { field: string; op: "range"; min: number; max: number; inclusive?: boolean };
type FilterTree =
| AtomicFilter
| { and: FilterTree[] }
| { or: FilterTree[] };
const FIELD_RE = /^[a-z_][a-z0-9_]*$/;
function escapeValue(v: string | number | boolean): string {
if (typeof v === "number" || typeof v === "boolean") return String(v);
// backtick-quote and escape inner backticks
return "`" + String(v).replace(/`/g, "\\`") + "`";
}
function fieldOrThrow(field: string): string {
if (!FIELD_RE.test(field)) throw new Error(`invalid field name: ${field}`);
return field;
}
export function buildFilter(node: FilterTree): string {
if ("and" in node) return node.and.map(buildFilter).join(" && ");
if ("or" in node) return "(" + node.or.map(buildFilter).join(" || ") + ")";
const f = fieldOrThrow(node.field);
if (node.op === "in") return `${f}:=[${node.values.map(escapeValue).join(", ")}]`;
if (node.op === "range") {
const [lo, hi] = node.inclusive === false ? ["(", ")"] : ["[", "]"];
return `${f}:${lo}${node.min}..${node.max}${hi}`;
}
return `${f}:${node.op}${escapeValue(node.value)}`;
}Usage from a storefront:
const filter = buildFilter({
and: [
{ field: "brand", op: "in", values: form.brands },
{ field: "price", op: "range", min: form.minPrice, max: form.maxPrice },
{ field: "availability", op: "=", value: "in_stock" },
...(form.sale ? [{ field: "sale_price", op: "<", value: form.maxPrice } as const] : []),
],
});The builder enforces two invariants: field names match a strict identifier regex (no injection vector via the field), and every string value is backtick-quoted with inner backticks escaped (no injection vector via the value). Replicate the same shape in your server SDK of choice.
E-commerce filter recipes
The canonical filter expressions for a product catalog. Drop into your storefront and replace the values from your facet UI.
Category page (single category, in-stock only)
{
"indexSlug": "products",
"q": "*",
"filterBy": "categories:=`Audio` && availability:=in_stock",
"facetBy": "brand,price",
"sortBy": "_text_match:desc,popularity_score:desc"
}Multi-brand multi-category drill-down
filterBy: "categories:=[`Electronics`, `Audio`] && brand:=[`Sony`, `Bose`] && availability:=in_stock"string[] filters use :=[a, b] syntax — OR within the same field, AND across fields.
Price range (handles "on sale" branch)
filterBy: "categories:=`Audio` && availability:=in_stock && (sale_price:[10..100] || (sale_price:<0 && price:[10..100]))"Translates to: "Sale items in 10–100, plus non-sale items in the same range when sale_price is absent." Without the OR group, sale-flagged items with sale_price=null would be filtered out — a common storefront bug.
Locale-aware listing
filterBy: "locale:=`en` && categories:=`Audio` && availability:=in_stock"Always include the locale filter for multi-locale storefronts. Without it, a French shopper can see English-only products in the result list.
Search-as-you-type
{
"q": "wireless",
"queryBy": "title,brand",
"perPage": 5,
"includeFields": "id,title,brand,price,image_url",
"filterBy": "availability:=in_stock"
}Suggestion paths almost always include the in-stock filter; an out-of-stock suggestion is a user-experience trap.
"More like this" (vector hybrid)
{
"q": "*",
"vectorQuery": "embedding:([…current product vector…], k:50)",
"filterBy": "id:!=`product-123` && availability:=in_stock && price:[1000..15000]",
"perPage": 12
}id:!= excludes the current product; the price range is roughly the current product's tier so the recommendations don't drift to a different segment.
Field selection
Reduce response size by selecting only the fields you need:
{
"includeFields": "id,title,price,brand,availability",
"excludeFields": "description,tags"
}Use excludeFields to strip large text fields from responses when you don't render them
(e.g., description in a search-as-you-type autocomplete that only shows titles).
Combining filters with scoped tokens
When a scoped token is used, its scopedFilter is AND-combined with the caller's filterBy:
Token scopedFilter: "price:<100"
Caller filterBy: "brand:=Sony && availability:=in_stock"
Effective filterBy: "brand:=Sony && availability:=in_stock && price:<100"The token's filter cannot be overridden by the caller's filterBy.
Multi-Search & Querying
Execute multiple search queries in a single request for autocomplete, federated search, and multi-index scenarios.
Search Core Relevance
Query processing, queryBy weights, typo tolerance, synonyms, curations, ranking — the developer-side reference for how the search engine decides which documents to return.