AACsearch
Search API

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:=en

Negation

availability:!=out_of_stock
brand:!=Competitors

Range (numeric)

price:>50
price:<200
price:[50..200]     # inclusive range
price:(50..200)     # exclusive range

Array 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:<200

Tenant 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):

FieldTypeExample filter
brandstringbrand:=Sony
categoriesstring[]categories:=[Electronics, Audio]
availabilitystringavailability:=in_stock
pricefloatprice:[10..500]
sale_pricefloatsale_price:&lt;100
localestringlocale:=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

LabelsortBy value
Most relevant_text_match:desc
Price: low to highprice:asc
Price: high to lowprice:desc
Newest firstcreated_at:desc
Sale price: low to highsale_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:

  1. Use increasing page values with a fixed perPage
  2. Or implement cursor-based pagination by sorting on a unique field (id:asc) and using filterBy: "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:

CharacterMeaning in the grammarInside a value, write as
,Array element separatorWrap value in `…`
&&AND operatorWrap value in `…`
||OR operatorWrap value in `…`
( )GroupingWrap value in `…`
[ ]Range / array delimitersWrap value in `…`
:Field/operator separatorWrap value in `…`
`The quote character itselfEscape 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:&lt;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.

On this page