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)queryBy — which fields to search
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) && cond3Examples:
"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
| Limit | Value |
|---|---|
| Max searches per multi-search request | 20 — enforced by searches.array().min(1).max(20) in packages/api/modules/search/public-handler.ts. |
Max perPage | 100 |
Max page | 1000 |
Max numTypos | 3 |
Max rangeFacets per search | 20 |
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:
error | Cause |
|---|---|
collection_not_found | The indexSlug doesn't exist or has been renamed. |
invalid_filter | filterBy failed to parse — typically an injection of an unescaped value. |
not_authorized | A scoped token pins an index and this sub-search targets a different one. |
rate_limit_exceeded | The org bucket emptied between sub-searches in the same batch. |
internal_error | Unexpected 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
scopedFilteris AND-combined into every sub-search (Invariant 4). A token that pinsprice:<100applies 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
queryIdat the request level — the parent identifier for the whole batch. - A
queryIdon 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 ≤ 20for search-as-you-type (autocomplete). - Use
includeFieldsto 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:
| Knob | Default in the widget | Reasonable range |
|---|---|---|
| Keystroke debounce | 200 ms | 150–300 ms |
| Min characters before search | 2 | 2–3 |
| Client-side LRU size | 30 entries | 20–50, scoped to indexSlug |
| Popular-queries cache TTL | 10 min | 5–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.