Skip to main content

Beyond `LIKE`: Full-Text and Fuzzy Search in ZenStack V3

· 8 min read
Yiming
Co-founder of ZenStack

Cover image

Every ORM gives you contains, startsWith, and endsWith on string fields, and for a while that feels like enough. Then a product manager asks for "search that just works" on a product catalog, and you realize how blunt those operators really are. contains: 'iphone' won't match "iPhones". It won't match "iphne" from a sloppy mobile keyboard. It won't rank a name match higher than a description match. And once your table gets large enough, ILIKE '%term%' quietly stops being free.

ZenStack v3.7 ships two new operators that solve different halves of this problem — full-text search for natural-language content and fuzzy search for short, typo-prone fields — both expressed in the same familiar ORM query API you're already using.

info

These features are supported for PostgreSQL only for now.

Two operators, two jobs

The two features are not interchangeable. They solve genuinely different problems, and picking the wrong one will produce frustrating results.

Full-text searchFuzzy search
Backed byto_tsvector / to_tsquerypg_trgm extension
Schema attribute@fullText@fuzzy
Filter operatorftsfuzzy
Relevance sorting key_ftsRelevance_fuzzyRelevance
Best fornatural-language textshort, identifier-like fields
Typo tolerance
Language-aware (stemming, stop words)

A rough mental model: reach for full-text search when you're searching paragraphs of prose where words matter — descriptions, articles, reviews. Reach for fuzzy search when you're searching short identifier-like fields where characters matter — names, brands, SKUs, tags.

Both features are PostgreSQL-only for now, since they're built on Postgres-native machinery.

To make the rest of this post concrete, we'll use a small e-commerce product catalog:

model Product {
id Int @id @default(autoincrement())
name String @fuzzy
brand String @fuzzy
description String @fullText
sku String @unique
}

name and brand are short, user-typed, and prone to typos — perfect candidates for @fuzzy. description is prose that benefits from stemming and word-level matching — that's @fullText territory.

Full-text search tokenizes text into lexemes, applies language-aware stemming and stop-word removal, and matches against a structured query. "Running shoes" matches a description that contains "run", and a search for "the best" doesn't waste time matching the word "the" in every row.

Mark a String field with @fullText and use the fts operator:

// Find products whose description mentions "wireless"
await db.product.findMany({
where: { description: { fts: { search: 'wireless' } } },
});

The search value is passed directly to Postgres to_tsquery, so the full tsquery syntax is available — boolean composition, negation, and adjacency:

// AND — both terms must appear
await db.product.findMany({
where: { description: { fts: { search: 'wireless & noise-cancelling' } } },
});

// OR — either term
await db.product.findMany({
where: { description: { fts: { search: 'leather | canvas' } } },
});

// NOT — exclude a term
await db.product.findMany({
where: { description: { fts: { search: 'phone & !refurbished' } } },
});

// Adjacent words in order — "quick brown" as a phrase
await db.product.findMany({
where: { description: { fts: { search: 'quick <-> brown' } } },
});

Stemming via config

The optional config field selects the Postgres text-search configuration used to tokenize both the document and the query. 'english' enables stemming (so 'run' matches "running"); 'simple' does literal token matching:

// 'run' matches "running", "runs", "runner" — stemmed
await db.product.findMany({
where: { description: { fts: { search: 'run', config: 'english' } } },
});

// 'run' does NOT match "running" — literal only
await db.product.findMany({
where: { description: { fts: { search: 'run', config: 'simple' } } },
});

If you leave config off, Postgres falls back to the cluster's default_text_search_config.

Ranking results

A filter tells you whether a row matches; for natural-language search you usually also want to know how well. The _ftsRelevance orderBy key compiles to ts_rank and lets you sort by match quality:

await db.product.findMany({
where: { description: { fts: { search: 'wireless & headphones' } } },
orderBy: {
_ftsRelevance: {
fields: ['description'],
search: 'wireless & headphones',
sort: 'desc',
},
},
});

fields can list multiple columns; ZenStack ranks against the concatenated document (via concat_ws), so a multi-field rank is straightforward:

orderBy: {
_ftsRelevance: {
fields: ['name', 'description'],
search: 'wireless',
sort: 'desc',
},
}

A note on indexes

By default, Postgres computes to_tsvector(...) for every row on every query. For production workloads, add a GIN index on the fields you search. ZenStack does not yet emit these from the schema — add them via a raw SQL migration (zen migrate dev --create-only then edit the generated file):

CREATE INDEX product_description_fts_idx
ON "Product"
USING GIN (to_tsvector('english', "description"));

The index expression must match the query expression exactly — same configuration, same column list, same concat_ws shape for multi-field ranks.

Full-text search is powerful, but it has one stubborn limitation: it does not tolerate typos. A user searching for "iphne" will get nothing, even though the catalog is full of iPhones. That's where fuzzy search comes in.

Fuzzy search compares strings by trigram similarity — overlapping three-character sequences — so it matches even when the input and the stored value don't line up character-for-character. It's backed by Postgres's pg_trgm extension, which you need to enable once:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent; -- only if you use unaccent: true

Mark a field with @fuzzy and use the fuzzy operator:

// finds "iPhone 15" despite the missing letter
await db.product.findMany({
where: { name: { fuzzy: { search: 'iphne' } } },
});

// finds "Bose QuietComfort" with transposed letters
await db.product.findMany({
where: { name: { fuzzy: { search: 'Qiuet Comfort' } } },
});

Modes

The mode option controls how the search string is compared:

  • 'simple' (default) — compares against the whole field with similarity(). Best for short fields like names.
  • 'word' — approximate substring matching with word_similarity(). Finds the search term inside a longer field.
  • 'strictWord' — like 'word' but with strict_word_similarity(), which favors matches at word boundaries.
// 'word' mode: find products whose name *contains* something like "wireless"
await db.product.findMany({
where: { name: { fuzzy: { mode: 'word', search: 'wireles' } } },
});

// 'strictWord' mode: prefer word-boundary matches
await db.product.findMany({
where: { brand: { fuzzy: { mode: 'strictWord', search: 'Sony' } } },
});

Tuning with threshold

The threshold option sets the minimum similarity (strictly greater than) for a row to match — a number in [0, 1], where higher is stricter:

// strict: only near-exact matches
await db.product.findMany({
where: { name: { fuzzy: { search: 'iPhone', threshold: 0.9 } } },
});

// permissive: useful for early-typing autocomplete
await db.product.findMany({
where: { name: { fuzzy: { search: 'iP', threshold: 0.05 } } },
});

If you omit threshold, Postgres's session defaults apply.

Accent-insensitive matching

By default, fuzzy search is accent-sensitive — 'creme' won't match 'Crème brûlée'. Set unaccent: true to fold accents away before comparing:

await db.product.findMany({
where: { name: { fuzzy: { search: 'creme', unaccent: true } } },
});

This requires the unaccent extension to be installed.

Ranking by closeness

Just like full-text search, fuzzy search has a relevance sort key — _fuzzyRelevance — so you can order results by how close they are to the input string:

await db.product.findMany({
where: { name: { fuzzy: { search: 'iphne' } } },
orderBy: {
_fuzzyRelevance: {
fields: ['name'],
search: 'iphne',
sort: 'desc',
},
},
});

When multiple fields are listed, ZenStack ranks against the best-matching field via GREATEST(similarity(...), similarity(...)) — handy for autocomplete that should match either a product name or its brand.

Indexes

Without an index, Postgres computes trigram similarity for every row on every query. For production, add a GIN index with the gin_trgm_ops operator class:

CREATE INDEX product_name_trgm_idx
ON "Product"
USING GIN ("name" gin_trgm_ops);

-- If you use unaccent: true, index the unaccent() expression
-- so Postgres can still use the index
CREATE INDEX product_name_unaccent_trgm_idx
ON "Product"
USING GIN (unaccent("name") gin_trgm_ops);

Putting it together

The two operators compose with everything else in the query API, so you can build a "search bar that just works" experience pretty easily. For example, a product search that fuzzy-matches on name or brand, full-text-matches on the description, and ranks by name similarity:

const q = 'wireles headphons';

await db.product.findMany({
where: {
OR: [
{ name: { fuzzy: { mode: 'word', search: q } } },
{ brand: { fuzzy: { mode: 'word', search: q } } },
{ description: { fts: { search: q.split(/\s+/).join(' & ') } } },
],
},
orderBy: {
_fuzzyRelevance: { fields: ['name', 'brand'], search: q, sort: 'desc' },
},
take: 20,
});

A single query, fully typed, expressed in the ORM API — no raw SQL, no separate search service, no rebuilding an Elasticsearch index on every write.

Wrapping up

Both @fullText and @fuzzy are available now in ZenStack v3.7. They're Postgres-only for the moment. If you want to dig deeper, the reference docs are here:

As always, feedback and bug reports are very welcome — file an issue at zenstackhq/zenstack or drop into our Discord.

Share on
Comments
Feel free to ask questions, give feedback, or report issues.

Don't Spam


You can edit/delete your comments by going directly to the discussion, clicking on the 'comments' link below