Litestore · Developer guide

Search

Learn how storefront search finds candidates, why trigram matching runs in JavaScript instead of pg_trgm, and how match strength is scored.

Search runs in two phases: find candidates, then score them.

Both phases are fail-safe — a failure in the fuzzy layer degrades results rather than returning an error page.

The split is deliberate. Search produces candidates; ranking produces rankings. identifyCandidates() hands the ranking pipeline a SearchCandidateSet and stops there, so the two concerns can be changed independently.

Trigram matching without pg_trgm

Approximate matching is implemented in JavaScript rather than delegated to the Postgres pg_trgm extension, so a self-hoster needs no database extensions:

lib/search/fuzzy.ts
/**
 * "cat" → ["  c", " ca", "cat", "at ", "t  "]
 */
function trigrams(s: string): string[] {
  const padded = `  ${s.toLowerCase()} `
  const result: string[] = []
  for (let i = 0; i <= padded.length - 3; i++) {
    result.push(padded.slice(i, i + 3))
  }
  return result
}

The string is padded with spaces at both ends, which makes the start and end of a word count as their own trigrams rather than blurring into the middle.

trigramSimilarity returns 0 to 1 and deliberately mirrors what Postgres's similarity() would return, which is what makes the future swap a drop-in. It compares the two trigram sets as intersection over union, and short-circuits to 1.0 on a case-insensitive exact match.

The source names both candidate replacements — pg_trgm for performance at scale, or embedding cosine similarity for semantic matching — so the current choice is a recorded trade-off rather than an oversight.

The pipeline

The module is a set of small files, each owning one job:

  • candidates.tsidentifyCandidates(), the first pass that gathers what might match
  • contains.ts — substring matching
  • fuzzy.ts — trigram similarity for typos and alternate spellings
  • synonyms.ts — a static in-code synonym map
  • match-strength.ts — turns a match into a weighted score
  • policy.ts — the storefront suggestion policy: what a query has to be before it is searched at all
  • admin-search-scope.ts — the separate admin search surface

buildContainsSearch in contains.ts builds a case-insensitive "any of these fields contains the query" Prisma OR filter. It returns undefined for an empty query so callers can spread it conditionally, and it supports one level of relation nesting through a dotted path such as customer.email.

policy.ts holds the storefront suggestion policy that the header and the mobile overlay share:

debounceMs: 250
minQueryLength: 2

admin-search-scope.ts is a different surface with a different concern. allowedSearchModules filters the entity groups a user may see down to the modules they can view, and it runs before the search action touches the database — so a module the operator cannot read is never even queried. The command palette cannot become a permission side-channel.

The five phases

identifyCandidates() runs in a fixed order:

  1. Synonym expansion — expand the query with related terms.
  2. Primary identification — ILIKE across name, description, tags and categories, using the original query and every synonym.
  3. Metadata computation — work out which sources matched each candidate.
  4. Fuzzy identification — trigram similarity over near-misses the first pass did not return.
  5. Merge — combine everything into the final candidate set.

Phases 1 and 4 are wrapped in try/catch and logged on failure. A broken synonym expansion produces no expansions, and a broken fuzzy pass produces no fuzzy matches. Neither can prevent the primary ILIKE results from returning.

The primary query takes at most maxCandidates, which defaults to 500, and orders by featured first, then newest.

That ordering is load-bearing rather than cosmetic. take truncates the match set, and without an orderBy Postgres returns an arbitrary page in heap order — so identical queries could rank different subsets. Featured first, then newest, makes the truncation deterministic and biased toward what a merchant wants surfaced.

The fuzzy pass

Fuzzy matching is skipped entirely when the primary pass already found enough: it runs only when the candidate count is below 80% of maxCandidates.

It is also skipped for queries shorter than three characters, where trigram similarity has nothing meaningful to compare.

To keep the comparison cheap, it does not score the whole catalog. It takes the first ~40% of the query as a prefix — at least two characters — and pulls a pool of at most 200 products whose name or tag slug starts with it, excluding the ids the primary pass already returned.

Each product in that pool is scored on name similarity and best tag similarity, and kept when either clears the default threshold of 0.3. That threshold catches common typos while avoiding false positives, and is tunable against a particular catalog.

Fuzzy matching is a second pass, not the first

identifyCandidates() uses trigrams to catch near-misses that a straight ILIKE would drop. It widens the candidate set; the scoring pass decides what actually ranks.

Match strength

Every candidate carries a queryMatchStrength between 0 and 1, derived from the sources that matched it.

SOURCE_WEIGHTS assigns one weight per source:

name_ilike         1.0
tag_exact          0.9
supplier_exact     0.85
category_exact     0.8
description_ilike  0.7
name_fuzzy         0.6
tag_fuzzy          0.5
supplier_fuzzy     0.5
synonym            0.5

The ordering encodes an argument about intent. A name match means the shopper searched exactly what the product is. A description match means the product mentions the term but is not about it. A fuzzy or synonym match is an approximation, and is penalised for that uncertainty.

Tags contribute at tag_exact strength 0.9 — a tag is a strong signal but not quite a title match.

computeMatchStrength takes the maximum weight across the matched sources, not the sum. A product that matches on both name and tag is not necessarily more relevant than one that matches on name alone, and summing would let a pile of weak signals outrank a strong one.

Synonyms

Synonyms live in code rather than in the database because they are part of the merchandising vocabulary, not operator data.

The map is a list of groups rather than pairs, so laptopnotebookportable computer are linked by one entry instead of N² pair rows. Lookup is a pre-built index from word to group, expansion is bidirectional, and it applies per word, so red laptop expands to red notebook.

Expansions are returned as separate terms rather than folded into the query, which is what lets a synonym match be scored as synonym at 0.5 instead of passing itself off as a name match.

As with fuzzy matching, the source records the replacements it anticipates: database-backed synonyms, ML-generated expansions, or embedding-based semantic similarity.

Where a change goes

Change SOURCE_WEIGHTS when the argument is about how much a kind of match should count. Nothing else needs to move, because the weights are the only place relevance is expressed.

Change the fuzzy threshold, pool size or prefix fraction when the catalog itself is the problem — too many false positives, or near-misses that never surface.

Add a MatchSource when a genuinely new way of matching arrives, such as embeddings or phonetic matching. The type, the weight table and the metadata all extend from that one point.

Touch admin-search-scope.ts only with permissions in mind. It is the file where a mistake stops being a bad search result and starts being a disclosure.

The useful question when tuning search is not "does my query return this product?" It is:

Which source did that match come from, and is that source worth what it currently scores?

On this page