Search architecture¶
961tech ships a single search surface — /products?q= — backed by Postgres full-text search with trigram-similarity fallback and a small curated synonym map. Per ADR-0008, the choice is "use the database we already have" rather than introducing Algolia / Meili / Typesense.
Why Postgres, not a dedicated engine¶
- Single source of truth — the matcher already has Product rows + indexes; search reads the same shape.
- Zero additional ops surface (no separate index to keep in sync, no schema-on-read drift).
- Free. ADR-0006 puts us on Neon Postgres; the FTS extension is built-in.
- Works offline locally — no mocks, no fixtures, no test container setup.
The cost: a cap on relevance quality. PostgreSQL FTS is good (better than most people think) but Algolia tunes its rankings on click-through data we don't yet have. Decision-flip: revisit at M3 if relevance is the limiting factor on conversion. ADR-0008 names this trigger explicitly.
How the query flows¶
graph LR
Q[user q]
S[search-synonyms.json]
U[unaccent + lowercase]
T[to_tsquery 'simple']
R[ts_rank against searchVector]
P[pg_trgm similarity fallback]
Out[ranked products]
Q --> S --> U --> T --> R --> Out
R -.empty result.-> P --> Out
- Synonym substitution — input runs through a 49-entry map at the top of
src/lib/search.ts. Substitutes happen left-to-right on whole-token boundaries. Examples:gtx → geforce gtx,am5 → socket am5,mothercard → motherboard, common Arabic transliterations of brand names. - Unaccent + lowercase — Postgres
unaccent('GeForce')→geforce. Strips Arabic diacritics for the search box too. to_tsquery('simple', ...)—simple(not English) because we don't want stemming on brand/model tokens like "RTX" or "X3D".ts_rankagainstProduct.searchVector— generated GIN-indexed tsvector built fromunaccent(brand || ' ' || model || ' ' || slug).- Empty-result fallback — if FTS returns 0 matches, run
similarity()frompg_trgmagainst(brand || ' ' || model). Catches typos like "corsiar 1000x" → "corsair rm1000x".
Schema¶
-- 20260428182510_search_fts/migration.sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
ALTER TABLE "Product"
ADD COLUMN "searchVector" tsvector
GENERATED ALWAYS AS (
to_tsvector('simple',
f_unaccent(lower(coalesce(brand, '') || ' ' || coalesce(model, '') || ' ' || coalesce(slug, '')))
)
) STORED;
CREATE INDEX "Product_searchVector_idx" ON "Product" USING gin ("searchVector");
CREATE INDEX "Product_brand_model_trgm_idx"
ON "Product" USING gin ((coalesce(brand, '') || ' ' || coalesce(model, '')) gin_trgm_ops);
Generated tsvector means we never have to remember to update it from app code — Postgres maintains it on every INSERT/UPDATE. Cost: ~10ms extra per write at our volumes; negligible.
Synonym map maintenance¶
src/lib/search-synonyms.json is the curated list. Three categories:
1. Brand-prefix expansions — rtx → geforce rtx, gtx → geforce gtx, radeon → amd radeon. Catches users who only type the SKU prefix.
2. Socket aliases — am5 → socket am5, lga1700 → lga 1700. Catches the underscore/space variants.
3. Common misspellings + Arabic transliterations — mothercard → motherboard, nvidia → إنفيديا. Adds ~12 entries, captures most non-English-typing users.
Maintenance: when a search returns 0 for a query that should match (telemetry from tookMs slow-query logging per #102), file an issue with the failed query. Add the synonym entry. No tests needed — the synonym map is data, not code.
Latency¶
searchProducts logs slow queries (>200ms) via console.warn. At M1 scale on a populated DB:
- p50 ~5ms (FTS hit on indexed column)
- p95 ~20ms (FTS + sort)
- p99 ~80ms (pg_trgm fallback path)
Targets per reference/performance-budget.md: p95 < 200ms server-side.
Test coverage¶
tests/lib/search.test.ts covers:
- Golden-path: 4070, rtx 4070 super, am5 motherboard, corsair rm1000x — all return expected products
- Typo: corsiar 1000x → returns Corsair RM1000x via pg_trgm
- Synonym: gtx 4070 (wrong but common) → RTX 4070 via map substitution
- Empty query → returns empty result (not all products)
- Category-scoped: searchProducts({ q: '4070', category: 'GPU' }) excludes non-GPU matches
- No-category: searchProducts({ q: '4070' }) matches across categories
Where search surfaces¶
- TopNav — global search bar; submits to
/products?q=. /products?q=— list page rerenders with FTS results whenqis present./build/choose/[category]?q=— slot picker scoped to the slot's category.
What's NOT in scope¶
- Faceted search (filter by brand × socket × price-range simultaneously). M2 candidate; today the sidebar filter is one-axis at a time.
- Saved searches / search history. M2 candidate, gated on Auth.js.
- Search-result CTR analytics. Per ADR-0015 we ship
tookMslogging; CTR-based ranking is M3+ once we have a click-through corpus to tune against. - Voice / image search. Not on roadmap.