Litestore · Developer docs
Storefront

Search

Storefront search matches and scores products for each query, and you tune the results by changing how Litestore weighs matches.

Storefront search turns a shopper's query into a list of matching products. Read this page when results look wrong, or when you need to change how Litestore finds or weighs matches.

Request

GET/api/products?q=linen&limit=12
AccessNo key needed
NotesDescribed by /api/openapi.json
A search for a product changes as you add query parameters such as q, category and limit.

Search works in two steps. First it finds candidate products, then it scores them. Neither step can break the page. If the fuzzy layer fails, the shopper gets fewer results instead of an error page.

Litestore keeps the two steps apart on purpose. identifyCandidates() passes a SearchCandidateSet to the ranking pipeline and stops there. Changing how Litestore finds candidates doesn't touch how it ranks them, and the other way round.

Trigram matching

Litestore does approximate matching in JavaScript instead of using the Postgres pg_trgm extension. If you self-host, you don't need to install any database extensions.

modules/storefront/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 function pads the string with spaces at both ends. The start and end of a word then form trigrams of their own instead of blending into the middle.

trigramSimilarity returns a number from 0 to 1. We wrote it to return what Postgres's similarity() would return, so you could later swap in pg_trgm without changing callers. It divides the overlap of the two trigram sets by their union. If the two strings match exactly, ignoring case, it returns 1.0 straight away.

The source comments name two possible replacements. You could use pg_trgm for speed on large catalogs, or embedding cosine similarity for matching by meaning. The authors chose the JavaScript version with that trade-off in mind.

Pipeline

The search module is split into small files, and each one does a single job.

candidates.ts
contains.ts
fuzzy.ts
synonyms.ts
match-strength.ts
use-search-suggestions.ts
admin-search-scope.ts
FileWhat it does
candidates.tsHas identifyCandidates(), the first pass that gathers products that might match.
contains.tsDoes substring matching.
fuzzy.tsComputes trigram similarity, which catches typos and alternate spellings.
synonyms.tsHas a fixed synonym map written in code.
match-strength.tsTurns a match into a weighted score.
use-search-suggestions.tsHolds the storefront suggestion policy, which determines when a query is long enough to search at all.
admin-search-scope.tsHandles the separate admin search.

buildContainsSearch in contains.ts builds a Prisma OR filter that matches when any of the given fields contains the query, ignoring case. For an empty query it returns undefined, so callers can spread it in only when there is something to search. It can follow one level of relation with a dotted path such as customer.email.

SEARCH_SUGGESTION_POLICY in use-search-suggestions.ts is the suggestion policy that the storefront header and the mobile search overlay both use:

SettingValue
debounceMs250
minQueryLength2

Two callers use the full pipeline: the header's live suggestions (searchItems in modules/storefront/search/actions.ts) and the product queries in modules/products/storefront/queries.ts, which the shopper MCP tools also use (the assistant's searchProducts tool uses the admin picker search instead). The /feed?q=… results page doesn't: its filter translator turns q into a plain case-insensitive contains on name or description, so a typo can show a suggestion that the results page then doesn't match.

admin-search-scope.ts serves the admin, where the main concern is permissions. allowedSearchModules removes every group of records from modules the user cannot view. It does this before the search touches the database, so Litestore never even queries a module the operator cannot read. Nobody can use the command palette to see records their role hides from them.

Phases

identifyCandidates() always runs these phases in the same order.

Synonym expansion

Litestore adds related terms to the query.

Primary identification

It runs ILIKE across name, description, tags and categories, using the original query and every synonym.

Metadata computation

It works out which sources matched each candidate.

Fuzzy identification

It runs trigram similarity over near-misses the first pass did not return.

Merge

It combines everything into the final candidate set.

Litestore wraps phases 1 and 4 in try/catch and logs a failure. If synonym expansion breaks, the query simply gets no synonyms. If the fuzzy pass breaks, there are no fuzzy matches. Either way, the primary ILIKE results still come back.

The primary query returns at most maxCandidates products, 500 by default. It sorts featured products first and then the newest.

This sort order matters. take cuts off the results at the limit, and without an orderBy Postgres returns rows in whatever order they have on disk. The same query could then rank different products on different runs. Sorting by featured and then newest makes the cut-off predictable, and it keeps the merchant's featured products.

The fuzzy pass only runs when the primary pass found fewer candidates than 80% of maxCandidates. If the primary pass already found enough, Litestore skips it. It also skips queries shorter than three characters, because trigrams can't compare such short strings meaningfully.

To stay cheap, the fuzzy pass does not score the whole catalog. It takes roughly the first 40% of the query as a prefix, with a minimum of two characters. It then loads at most 200 products whose name or tag slug starts with that prefix, leaving out products the primary pass already returned.

Litestore scores each product in that pool on how similar its name is and how similar its closest tag is. It keeps the product if either score reaches the default threshold of 0.3. That threshold catches common typos without many false positives, and it's tunable for your catalog.

SettingDefault
maxCandidates500
Fuzzy pass runs below80% of maxCandidates
Shortest query for fuzzy3 characters
Prefix lengthAbout 40% of the query, at least 2 characters
Fuzzy pool size200 products
Similarity threshold0.3

Fuzzy matching is a second pass, not the first

identifyCandidates() uses trigrams to catch near-misses that a plain ILIKE would miss. This adds candidates, and the scoring pass then determines how they rank.

Match strength

Each candidate gets a queryMatchStrength between 0 and 1. Litestore works it out from the ways the product matched, which the code calls sources. SOURCE_WEIGHTS gives each source a weight:

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 weights reflect what the shopper probably meant. If the name matches, the shopper searched for exactly what the product is. If only the description matches, the product mentions the term but isn't about it. Fuzzy and synonym matches are guesses, so they score lower.

A tag match scores 0.9 as tag_exact. A tag is a strong signal, but slightly weaker than a match on the product name.

computeMatchStrength uses the highest weight among the matched sources, not their sum. A product that matches on both name and tag is not necessarily more relevant than one that matches on name alone. If Litestore added the weights, several weak matches could outrank one strong match.

Synonyms

We wrote synonyms in code instead of storing them in the database. They are part of the store's product vocabulary, not data an operator edits.

The map is a list of groups instead of pairs. One entry links laptop ↔ notebook ↔ portable computer, where pairs would need a row for every combination. Litestore builds an index from each word to its group ahead of time. Expansion works in both directions and word by word, so red laptop expands to red notebook.

Litestore returns the expansions as separate terms instead of rewriting the query. A synonym match can then score as synonym at 0.5, instead of counting as a name match.

As with fuzzy matching, the source comments list the replacements the authors suggest. You could store synonyms in the database, generate expansions with machine learning, or match by meaning with embeddings.

Search changes

When you tune search, don't stop at checking whether your query returns a product. Ask this instead: which source did that match come from, and is that source worth what it currently scores?

On this page