Search
Understand how storefront search finds and scores products so you can tune its results.
Storefront search turns what a shopper types into a list of matching products. Read this page when results look wrong, or when you want to change how matches are found or weighted.
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. You can change how candidates are found without touching how they are ranked, and the other way round.
Trigram matching without pg_trgm
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.
/**
* "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. It is written 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. One is pg_trgm, for speed on large catalogs. The other is embedding cosine similarity, for matching by meaning. The JavaScript version is a trade-off the authors chose knowingly.
The pipeline
The search module is split into small files, and each one does a single job:
candidates.tscontainsidentifyCandidates(), the first pass that gathers products that might match.contains.tsdoes substring matching.fuzzy.tscomputes trigram similarity, which catches typos and alternate spellings.synonyms.tscontains a fixed synonym map written in code.match-strength.tsturns a match into a weighted score.policy.tssets the storefront suggestion policy, which decides 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.
policy.ts contains the suggestion policy that the storefront header and the mobile search overlay both use:
debounceMs: 250
minQueryLength: 2admin-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.
The five phases
identifyCandidates() always runs these phases in the same order:
- Synonym expansion adds related terms to the query.
- Primary identification runs
ILIKEacross name, description, tags and categories, using the original query and every synonym. - Metadata computation works out which sources matched each candidate.
- Fuzzy identification runs trigram similarity over near-misses the first pass did not return.
- Merge combines everything into the final candidate set.
Phases 1 and 4 are wrapped in try/catch, and a failure is logged. 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 sit 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 products a merchant wants to show.
The fuzzy pass
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 you can tune it for your catalog.
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 decides 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.5The 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
Synonyms are written in code instead of stored 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 expect. These are synonyms stored in the database, expansions generated by machine learning, or matching by meaning with embeddings.
Where a change goes
Change SOURCE_WEIGHTS when you want one kind of match to count for more or less. You don't need to change anything else, because relevance is defined only in those weights.
Change the fuzzy threshold, the pool size or the prefix fraction when your catalog is the problem. You might be getting too many false positives, or near-misses that never show up.
Add a MatchSource when you add a new way of matching, such as embeddings or phonetic matching. You then extend the type, the weight table and the metadata from that one place.
Edit admin-search-scope.ts only with permissions in mind. A mistake in that file does more than produce a bad search result. It can show records to someone who shouldn't see them.
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?
Related
SEO
Learn how the rule-based SEO audit scores a page, which models keep slug-redirect history, and what the AIO report checks for machine readers.
Caching
Learn how Litestore tags cached reads from CACHE_TAGS, how the CRUD layer invalidates them, and which paths are never cached.
Schema
Learn the core Prisma models in schema.prisma, the conventions every model follows, and how to change the schema safely.