Skip to content

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
  1. 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.
  2. Unaccent + lowercase — Postgres unaccent('GeForce')geforce. Strips Arabic diacritics for the search box too.
  3. to_tsquery('simple', ...)simple (not English) because we don't want stemming on brand/model tokens like "RTX" or "X3D".
  4. ts_rank against Product.searchVector — generated GIN-indexed tsvector built from unaccent(brand || ' ' || model || ' ' || slug).
  5. Empty-result fallback — if FTS returns 0 matches, run similarity() from pg_trgm against (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 expansionsrtx → geforce rtx, gtx → geforce gtx, radeon → amd radeon. Catches users who only type the SKU prefix. 2. Socket aliasesam5 → socket am5, lga1700 → lga 1700. Catches the underscore/space variants. 3. Common misspellings + Arabic transliterationsmothercard → 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 when q is 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 tookMs logging; CTR-based ranking is M3+ once we have a click-through corpus to tune against.
  • Voice / image search. Not on roadmap.

See also