AACsearch
API de recherche

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.

The Relevance tuning dashboard page explains how to operate the relevance knobs from the UI. This page explains how the search engine actually decides what to return — the developer-facing reference behind the dashboard.

Read it once when you're integrating, then again whenever an analytics signal points at a relevance regression.

Query processing pipeline

A POST /api/search request is processed in this order:

1. validate input (Zod)
2. resolve tenant + scoped filter   ← Invariants 4 + 5
3. expand synonyms                  ← per-index synonym sets
4. tokenize + drop stopwords        ← per-locale stopword list
5. apply typo tolerance             ← numTypos default
6. retrieve over queryBy fields     ← weighted match
7. apply curation rules             ← pin / hide overrides
8. score with _text_match           ← BM25-flavoured ranking
9. sort with sortBy (or default)
10. paginate, highlight, return

Steps 3–5 happen at the AACSearch engine layer; step 7 happens on top of the raw engine result before ranking. Tenant isolation (step 2) is always applied — no caller can bypass it.

queryBy and field weighting

queryBy declares which fields the engine searches across. Field order matters: earlier fields get higher weight by default. You can override the per-field weights with queryByWeights:

{
  "queryBy": "title,brand,description,tags",
  "queryByWeights": "10,5,2,1"
}
  • The numbers are relative weights, not absolute scores. 10,5,2,1 is the same as 100,50,20,10.
  • Without queryByWeights, the default weighting is geometric: roughly 8, 4, 2, 1, ….
  • Fields not listed in queryBy are not searched at all — they can still be filtered or returned.

The default queryBy for the product schema is "title,brand,description,tags". Override per request from the storefront only when you have a measured reason — otherwise platform-wide consistency is more valuable than micro-tuning per query.

When to change weights

A signal-by-signal guide:

Signal in analyticsWeight change to try
Brand-name queries returning low-CTR resultsRaise brand (e.g. title:10, brand:8).
SKU queries failing entirelyAdd sku to queryBy with the highest weight.
Long-tail descriptive queries returning irrelevant titlesAdd description; consider stem: true on it.
Tag-driven faceted browse failingRaise tags weight or add it if missing.
Title-only matches dominating, missing relevant descriptionsLower the gap between title and description.

Never change weights without an evaluation set — the Relevance quality page covers the evaluation workflow.

Typo tolerance

Typo tolerance is on by default. The number of typos allowed scales with query token length:

Token lengthDefault numTypos
1–4 characters0 (exact match only)
5–7 characters1
8+ characters2

Hard cap: numTypos ≤ 3. Override per request:

{ "q": "iphn", "numTypos": 2 }

Stricter modes:

  • prioritizeExactMatch: true — boost exact matches above typo matches in scoring.
  • dropTokensThreshold: 1 — if no documents match all tokens, allow dropping the last token before retrying. Useful for very long queries.
  • typoTokensThreshold: 0 — only apply typo tolerance when zero documents match exactly.

Where typo tolerance hurts: SKU and brand-name queries where a typo is more likely to mean "different product" than "misspelled query". A common pattern is to lower numTypos for the SKU sub-search in a multi-search batch and keep the default for the main search.

Synonyms

Synonyms map equivalent terms at query-time:

"pants" ↔ ["jeans", "trousers"]
"sneakers" ↔ ["trainers"]
"mobile" ↔ ["phone"]    # locale-specific

When the user searches "trousers", the engine expands the query to also match documents containing "pants" or "jeans". Synonyms are per-index and managed under Relevance tuning in the dashboard or via the search.synonyms oRPC procedures.

Three rules to keep synonym sets healthy:

  1. Be locale-specific. Don't make "pants" synonymous with "trousers" across both en and ru indexes — the meaning differs.
  2. Avoid one-way associations. "laptop" → "macbook" is a curation, not a synonym. Use a curation rule when only one direction is intended.
  3. Audit periodically. Synonyms apply to every query containing the root term; a stale synonym from a year ago is a permanent low-grade noise source.

Synonyms run before retrieval (step 3 of the pipeline). They cannot recover documents that aren't in the index — content gaps need a content fix, not a synonym.

Curation rules

A curation rule pins or hides specific document IDs for an exact query string:

Query "summer sale":
  pin    = ["tshirt-123", "shorts-456"]
  hide   = ["winter-coat-789"]

Curations run after retrieval (step 7 of the pipeline): pinned IDs are inserted at the top of the result list in the order given; hidden IDs are removed.

When to reach for curations:

  • Branded query, branded landing. "summer sale" should always pin the seasonal landing products.
  • Compliance, regulatory. Hide products that should not appear for certain queries even if they match.
  • Merchandising, in-stock priorities. Pin in-stock variants of a popular SKU above out-of-stock options.

When not to reach for them:

  • "Make all running-shoe queries return Nike first" — that's a weight or synonym fix, not a curation. Curations are exact-query matches.
  • "Boost new products" — that's a sortBy: created_at:desc tiebreak, not curations.

Ranking and sort interaction

The default ordering is relevance-first:

sortBy: "_text_match:desc"

_text_match is the BM25-flavoured score the engine produces from queryBy + weights + typo tolerance + match positions. Multi-field sort lets you add tiebreakers:

sortBy: "_text_match:desc,popularity_score:desc,price:asc"

Three behaviours to keep in mind:

  • A pure-field sort overrides relevance. sortBy: "price:asc" returns the cheapest matching documents — relevance is ignored. Use this for facet-driven browse only.
  • Curated pins ignore sort. Pinned documents stay at the top regardless of sortBy. Hide rules also run regardless.
  • Wildcard queries (q: "*") have no relevance signal. With q: "*", every matching document has the same _text_match. Always combine q: "*" with a meaningful sortBy and aggressive filterBy.

For e-commerce, a useful default in the storefront is:

sortBy: "_text_match:desc,popularity_score:desc"

where popularity_score is a field you maintain at ingest time (typically click count over 30 days, see the popularity-ranking job in packages/search/lib/popularity-ranking.ts).

No-results handling

A search returns found: 0 either because no documents match or because filters narrowed the result set to nothing. The engine returns the empty result with the actual query parameters echoed back so the client can detect which case it is.

Three patterns for handling no-results:

1. Retry with relaxed filters

If filterBy was active, retry once without it. The widget does this automatically when found === 0 and filterBy is non-empty.

2. Retry with dropTokensThreshold

For long queries, drop the last token and retry:

{ "q": "wireless noise cancelling over ear headphones", "dropTokensThreshold": 2 }

This allows up to 2 tokens to drop before retrying. Useful for cases where the query is over-specific.

3. AI / semantic fallback

For storefronts with AI Search enabled, fall back to semantic search (hybrid mode) on no-results. The user gets something instead of nothing.

Combine the three: filter-relaxation first (free), token-drop second (free), semantic last (paid). Always instrument the fallback path so you can measure whether the recovery is paying off — see No-results loop.

Relevance testing workflow

Build relevance changes against an evaluation set — 30–100 queries with known-good top results. The workflow:

  1. Snapshot. Run every eval query and store the current top 5 result IDs.
  2. Tune. Change a synonym / weight / curation in a staging index.
  3. Re-run. Run every eval query against the staging index.
  4. Diff. For each query, compare staging top-5 vs production top-5. Score:
    • +1 if a previously-missing target document is now in top-5.
    • −1 if a previously-present target document drops out of top-5.
  5. Roll out if net score is positive and no regression queries dropped to zero results.

The eval set is the highest-leverage artifact in any relevance project. Reusable across deploys, A/B tests, and engine upgrades.

For an evaluation framework, Knowledge evaluation describes the same workflow applied to RAG; the structure is identical.

E-commerce example

Product index with title, brand, description, sku, categories, tags, popularity_score. A reasonable default per storefront search:

{
  "indexSlug": "products",
  "q": "running shoes",
  "queryBy": "title,brand,description,sku,tags",
  "queryByWeights": "10,5,3,8,2",
  "sortBy": "_text_match:desc,popularity_score:desc",
  "facetBy": "brand,categories,price",
  "filterBy": "availability:=in_stock"
}
  • SKU is heavily weighted so a SKU-shaped query ("WH-2024") returns the exact match first.
  • Description is weighted lower than title/brand — descriptive queries still match, but a title hit always outranks a description hit on the same product.
  • popularity_score is the relevance tiebreaker.

Content / help-center example

Article index with title, excerpt, body, tags, published_at. A reasonable default for help-center search:

{
  "indexSlug": "help-center",
  "q": "reset my password",
  "queryBy": "title,excerpt,body,tags",
  "queryByWeights": "8,4,2,3",
  "sortBy": "_text_match:desc,published_at:desc",
  "filterBy": "locale:=`en`"
}
  • title and tags are weighted relatively higher because help-center titles tend to be short and intentional.
  • body is searched with stem: true (declared on the schema) so "reset" matches "resetting".
  • The published_at tiebreaker favours fresher articles when relevance is tied.

On this page