AACsearch
Search API

Multi-Search & Querying

Execute multiple search queries in a single request for autocomplete, federated search, and multi-index scenarios.

Multi-search executes multiple search queries in a single HTTP round-trip. This is essential for autocomplete (run a results query + a suggestions query simultaneously) and federated search (search across multiple indexes at once).

Multi-search endpoint

Endpoint: POST /api/search/multi

Auth: Authorization: Bearer ss_search_your_key

{
	searches: Array<{
		indexSlug: string;
		q: string;
		queryBy?: string;
		filterBy?: string;
		facetBy?: string;
		sortBy?: string;
		page?: number;
		perPage?: number;
		highlightFields?: string;
	}>;
}

Response:

{
	results: Array<SearchResponse>; // one result per search in the same order
}

Autocomplete pattern

The most common multi-search use case: run a full search plus a quick-hit suggestions query simultaneously.

const [mainResults, suggestions] = await client.multiSearch([
	{
		indexSlug: "products",
		q: "wireless head",
		queryBy: "title,brand,description",
		perPage: 20,
		facetBy: "brand,categories",
	},
	{
		indexSlug: "products",
		q: "wireless head",
		queryBy: "title",
		perPage: 5,
		includeFields: "id,title",
		highlightFields: "title",
	},
]);

This sends a single HTTP request and gets both result sets back simultaneously.

Federated search across indexes

Search across multiple indexes and merge results client-side:

const [productResults, articleResults] = await client.multiSearch([
	{
		indexSlug: "products",
		q: "sustainability",
		queryBy: "title,description,tags",
		perPage: 10,
	},
	{
		indexSlug: "knowledge",
		q: "sustainability",
		queryBy: "content,title",
		perPage: 5,
	},
]);

Note: For cross-index results, merge and re-rank client-side.

Query parameters in depth

q — search query

The search string. Use "*" for a wildcard browse (no full-text matching, useful for filtered browsing).

{ "q": "wireless headphones" }    // full-text search
{ "q": "*" }                      // browse all (combine with filterBy)

Comma-separated list of fields. Order matters — earlier fields get higher weight by default.

{ "queryBy": "title,sku,brand,description" }

For each field you can specify a per-field weight override:

{ "queryBy": "title,brand,description", "queryByWeights": "10,5,2" }

filterBy — filter expression

Boolean expression using AACSearch filter syntax:

Equality:        field:=value
                 field:=[value1, value2]
Range:           field:>100
                 field:[50..200]
String exact:    field:=value
Negation:        field:!=value
Logical AND:     cond1 && cond2
Logical OR:      cond1 || cond2
Grouping:        (cond1 || cond2) && cond3

Examples:

"filterBy": "availability:=in_stock"
"filterBy": "price:[50..200] && brand:=[Sony, Bose]"
"filterBy": "categories:=Electronics && availability:!=out_of_stock"

facetBy — compute facet counts

Returns value distributions for the specified fields. Facet fields must be indexed as facet: true in the collection schema.

{ "facetBy": "brand,categories,availability" }

Response will include facetCounts with value → count pairs for each facet field.

sortBy — sort order

Single or multi-field sort:

{ "sortBy": "price:asc" }
{ "sortBy": "_text_match:desc,price:asc" }
{ "sortBy": "created_at:desc" }

Available sort fields: price, sale_price, created_at, _text_match (relevance score).

Typo tolerance

AACSearch's typo tolerance is applied automatically. By default:

  • 1 typo allowed for queries 5+ characters long
  • 2 typos allowed for queries 8+ characters long

This handles common misspellings like "headphons" → matches "headphones".

Highlighting

Matching terms are highlighted with configurable tags:

{
	"highlightFields": "title,description",
	"highlightStartTag": "<em class='highlight'>",
	"highlightEndTag": "</em>"
}

Highlights in the response:

{
	"highlights": [
		{
			"field": "title",
			"snippet": "Sony <em class='highlight'>Wireless</em> <em class='highlight'>Headphones</em>"
		}
	]
}

Pagination

Results are paginated using page (1-indexed) and perPage:

{ "page": 2, "perPage": 20 }

The response includes found (total matching count) for computing page totals:

const totalPages = Math.ceil(results.found / perPage);

Maximum perPage: 100. For exports requiring more results, implement cursor-based iteration using increasing page values.

Limits

LimitValue
Max searches per multi-search request20 — enforced by searches.array().min(1).max(20) in packages/api/modules/search/public-handler.ts.
Max perPage100
Max page1000
Max numTypos3
Max rangeFacets per search20

Exceeding the multi-search batch limit returns 400 invalid_input with the Zod path searches. Use two consecutive requests rather than trying to batch past 20 — the per-request rate-limit cost is per HTTP call, so two batches of 20 cost the same as one batch of 40 would have.

Partial failures

A multi-search request is atomic at the HTTP level — the request itself either returns 200 with all sub-results or a 4xx/5xx for the batch as a whole. Sub-searches can still fail individually inside a 200: each result envelope carries its own status.

The response shape:

{
  results: Array<{
    // Success:
    hits?: SearchHit[];
    found?: number;
    page?: number;
    facetCounts?: FacetCount[];
    searchTimeMs?: number;

    // Failure (per sub-search):
    error?: string;        // e.g. "collection_not_found", "invalid_filter"
    code?: number;         // HTTP-style status, 4xx for client error
  }>;
}

Iterate the results array in order; treat any entry with error as a sub-search failure. Common reasons:

errorCause
collection_not_foundThe indexSlug doesn't exist or has been renamed.
invalid_filterfilterBy failed to parse — typically an injection of an unescaped value.
not_authorizedA scoped token pins an index and this sub-search targets a different one.
rate_limit_exceededThe org bucket emptied between sub-searches in the same batch.
internal_errorUnexpected upstream failure. The original engine error is normalised here (Invariant 6 — never echoed raw).

A single sub-search failure does not roll back the others. Render the successful sub-results; surface the failed one in your UI rather than killing the whole page.

Shared authentication and quota

Authentication is per request, not per sub-search. The same Authorization: Bearer ss_search_* (or ss_scoped_*) header validates the entire batch:

  • The bearer key is checked once at the request boundary.
  • If the key is a scoped token, its scopedFilter is AND-combined into every sub-search (Invariant 4). A token that pins price:&lt;100 applies to all sub-searches even if you forgot to repeat it.
  • Origin and referer checks run once at the request boundary.

Quota and rate limiting treat the batch as a single unit but charge per sub-search: one multi-search request consumes searches.length units against SearchRateLimitBucket (the public handler sets units: parsed.data.searches.length). A 20-batch is 20× the unit cost of a single search. Stay under the bucket's per-window cap or expect 429.

Tenant isolation (Invariant 5) holds at the sub-search level — every sub-search has tenantId AND-combined automatically from the verified bearer token. A multi-search can never read another org's data.

Analytics grouping

Each multi-search response carries:

  • A queryId at the request level — the parent identifier for the whole batch.
  • A queryId on each sub-result — the per-search identifier; useful when one sub-search produced a suggestion list and another the primary results.

Click events posted via POST /events/track should carry the child queryId so per-search CTR computes correctly:

const [main, suggestions] = await client.multiSearch([…, …]);

trackEvent({
  type: "result_click",
  queryId: main.queryId,            // ← the sub-search the user actually clicked
  productId: clickedHit.id,
  position: clickedHit.position,
});

Conversion events follow the same rule. For session-level dashboards that want to attribute a conversion to the whole batch rather than one sub-search, the parent queryId is also recorded and can be queried out of SearchUsageEvent.metadata.parentQueryId (see Analytics overview).

A common bug is to record clicks against the first sub-search's queryId regardless of which list the click came from — that inflates the suggestions-list CTR and deflates the main-results CTR. Always pass through the specific child queryId.

Performance recommendations

  • Keep perPage ≤ 20 for search-as-you-type (autocomplete).
  • Use includeFields to return only the fields you render — reduces response size.
  • Avoid running facets on every keystroke — debounce at 150–300 ms.
  • For large result sets with many facets, consider splitting the facet query from the main query using multi-search.

Caching and debouncing

The widget bundles a client-side LRU for autocomplete suggestions; align custom integrations to the same numbers:

KnobDefault in the widgetReasonable range
Keystroke debounce200 ms150–300 ms
Min characters before search22–3
Client-side LRU size30 entries20–50, scoped to indexSlug
Popular-queries cache TTL10 min5–15 min

Server-side caching is configured by the default Hono response cache; do not extend it without a rate-limit-aware invalidation path — stale suggestions look fine until a curation change wipes out the underlying products and they keep linking to deleted pages.

For the empty-state "popular queries" suggestion list, cache the result of search.topQueries on the server with the same TTL as your homepage caches; refreshing on every focus is wasteful.

Federated suggestions example

Combining a primary product search with a categories suggestion and an articles suggestion, all in one round-trip:

const [products, categories, articles] = await client.multiSearch([
  {
    indexSlug: "products",
    q: "wireless head",
    queryBy: "title,brand,description",
    perPage: 20,
    facetBy: "brand,categories",
  },
  {
    indexSlug: "categories",
    q: "wireless head",
    queryBy: "name,aliases",
    perPage: 3,
    includeFields: "id,name,slug",
  },
  {
    indexSlug: "articles",
    q: "wireless head",
    queryBy: "title,excerpt",
    perPage: 3,
    includeFields: "id,title,url,published_at",
  },
]);

Render product hits in the main grid, categories as quick links above the grid, and articles below as related content. Three sub-searches still count as three units against the rate-limit bucket — budget accordingly.

On this page