zanith

Search

One search() call composes Postgres full-text, trigram similarity, and vector distance into a single ranked query. Each method is optional; weights decide how much each one contributes to the final ordering.

A first query

TS
import { search } from 'zanith';
 
const q = search({
table: 'posts',
text: { column: 'body_tsv', query: 'rust web', weight: 0.6 },
trigram: { column: 'title', query: 'rust web', weight: 0.3 },
vector: { column: 'embedding', query: queryEmbedding, weight: 0.1 },
limit: 20,
});
 
const result = await adapter.execute(q);
// → SELECT … FROM "posts"
// WHERE (body_tsv @@ plainto_tsquery('rust web'))
// OR (title % 'rust web')
// OR (embedding <=> $N <= …)
// ORDER BY (0.6 * ts_rank(...) + 0.3 * similarity(...) + 0.1 * (1 - cosine(...))) DESC
// LIMIT 20

search() returns a CompiledQuery — hand it to adapter.execute(). Wiring it onto ModelAPI directly is on the roadmap.

The three matchers

text — full-text

OptionDefaultEffect
columnrequiredThe tsvector column (or text — set language for runtime to_tsvector).
queryrequiredThe user's query string.
languageundefinedPass null for a stored tsvector (more efficient). Pass a language for runtime conversion.
parser'plain''plain' · 'phrase' · 'websearch' · 'raw' — maps to plainto_tsquery / phraseto_tsquery / websearch_to_tsquery / to_tsquery.
weight1Contribution to the final rank.

trigram — pg_trgm similarity

OptionDefaultEffect
columnrequiredText column compared via the % operator.
queryrequiredThe query string.
threshold0.1Drop rows below this similarity score.
weight1Contribution to the final rank.

vector — pgvector distance

OptionDefaultEffect
columnrequiredThe vector(N) column.
queryrequiredQuery embedding — same dimensionality as the column.
metric'cosine''cosine' (<=>) · 'l2' (<->) · 'inner' (<#>).
weight1Contribution to the final rank (lower distance ranks higher).

match: 'and' vs 'or'

By default, rows match if any configured matcher succeeds ('or'). Pass match: 'and' to require every configured matcher.

TS
search({
table: 'posts',
text: { column: 'body_tsv', query: 'rust' },
trigram: { column: 'title', query: 'rust' },
match: 'and', // both must match
limit: 20,
});

Setting up tsvector — tsvectorSetupSql

tsvectorSetupSql() emits the DDL for a generated tsvector column, the GIN index, and a trigger that maintains it on INSERT / UPDATE. Run it once in a migration.

TS
import { tsvectorSetupSql } from 'zanith';
 
const sql = tsvectorSetupSql({
table: 'posts',
column: 'body_tsv',
sources: ['title', 'body'],
language: 'english',
});
await adapter.executeRaw(sql);
 
// Emits, in order:
// 1. ALTER TABLE … ADD COLUMN IF NOT EXISTS "body_tsv" tsvector;
// 2. CREATE INDEX IF NOT EXISTS "posts_body_tsv_idx" … USING gin(...);
// 3. CREATE OR REPLACE FUNCTION posts_body_tsv_trigger_fn() …
// 4. CREATE TRIGGER posts_body_tsv_trigger BEFORE INSERT OR UPDATE …
// 5. UPDATE "posts" SET "body_tsv" = to_tsvector(...); -- backfill
OptionDefaultEffect
sourcesrequiredColumns concatenated into the tsvector. Each is wrapped in coalesce(_, '') so NULLs don't break it.
language'english'Postgres dictionary.
triggerName__trigger
Override if you collide with an existing trigger.
indexName__idx
Override if you collide with an existing index.

Faceting — facets()

For filter sidebars: pass a base WHERE plus a list of facet columns and get back one query that returns bucket counts per facet via UNION ALL.

TS
import { facets } from 'zanith';
 
const q = facets({
table: 'products',
facets: ['brand', 'category', 'in_stock'],
where: { sql: '"price" < ?1', params: [100] },
});
 
const { rows } = await adapter.execute(q);
// rows = [
// { facet: 'brand', value: 'Acme', count: 14 },
// { facet: 'brand', value: 'Globex', count: 9 },
// { facet: 'category', value: 'Tools', count: 12 },
// …
// ]

Composing with extension helpers

For ad-hoc rank tweaks that search()can't express, drop down to the typed extension helpers — see Postgres extensions for cosineDistance, trigramSimilar, and friends.