Skip to content

Faceted instant search — refine as you type

Picture a shopper on your site. They type “linen” in the search box and, before they hit Enter, results already reshuffle. On the left, checkboxes: Brand (12), Category (8), Colours (5). Each label shows how many products match right now — not a stale count from yesterday’s index, but the count after the current query and any filters you’ve already applied.

That sidebar is faceted search. The search box handles meaning and keywords; the facets handle narrowing (“only Uniqlo”, “dresses only”, “under 8,000”) with honest counts so people know what they’ll get before they click.

Let’s wire it up — index once, search with facets on every keystroke, and let clicking a facet become a hard filter.

Three field roles (search box vs filters vs facets)

Section titled “Three field roles (search box vs filters vs facets)”

On the collection, fields play three different jobs:

  • searchable — feeds the search box (full-text and meaning channels).
  • filterable — can become a hard filters: { … } condition (price cap, in-stock only).
  • facet: true or facet: "range" — drives the refinement sidebar with per-value counts (or price buckets for ranges).

The fashion preset declares all three sensibly out of the box:

catalog.ts
import { collection, Channels } from "@samesake/core";
import { fashion } from "@samesake/presets";
export const products = collection("products", {
fields: fashion.fields(), // brand, category, colors, price… with filterable + facet flags
spaces: fashion.spaces({ visual: true }),
enrich: fashion.enrich(),
indexing: fashion.indexing(),
embeddings: { doc: { model: "gemini-embedding-2", dim: 1536 } },
search: {
channels: [
Channels.fts({ fields: ["title"], weight: 1 }),
Channels.cosine({ embedding: "doc", weight: 1 }),
Channels.spaces({ weight: 1 }),
],
combiner: "rrf",
},
});

Not fashion? Same idea with raw field helpers — mark what the box searches, what filters hard, and what gets a count column:

catalog.ts — generic facets
import { collection, f, Channels, gates } from "@samesake/core";
export const products = collection("products", {
fields: {
title: f.text({ searchable: true }),
brand: f.text({ filterable: true, facet: true }),
category: f.text({ filterable: true, facet: true }),
colors: f.text({ filterable: true, facet: true }), // or f.array({ …, facet: true })
price: f.number({ filterable: true, facet: "range", budget: true }),
available: f.boolean({ filterable: true, facet: true }),
},
enrich: { stages: [] },
indexing: {
surfaces: {
embed_doc: { kind: "dense", embedding: "doc", build: ({ data }) => `${data.title} ${data.brand}`.trim() },
fts_doc: { kind: "fts", build: ({ data }) => `${data.title} ${data.brand}`.trim() },
},
gate: gates.always,
},
embeddings: { doc: { model: "gemini-embedding-2", dim: 1536 } },
search: { channels: [Channels.fts({ fields: ["title"], weight: 1 }), Channels.cosine({ embedding: "doc", weight: 1 })], combiner: "rrf" },
});

facet: "range" on a number field gives you numeric stats — count, min, max, avg — plus a handful of lo/hi histogram bands. Everything else with facet: true returns value → count pairs.

Facets read live from the same Postgres rows as search — so run the usual pipeline after you load or change catalog data:

sync.ts
await app.enrich.upsert(rows);
await app.enrich.enrich();

For a catalog that changes often, schedule that trio (or wire it to webhooks) so facet counts stay honest. The durable version — retries, image revalidation, quarantine — is in Pipeline lifecycle and the Running the pipeline guides.

Search with facets — one call, hits + counts

Section titled “Search with facets — one call, hits + counts”

Pass the facet field names on search. samesake runs the same query and filters you would for results, then aggregates counts for each requested facet on that narrowed set:

search.ts
import { samesake } from "@samesake/postgres";
import { llmRerank } from "@samesake/server";
import { geminiEmbed, geminiGenerate } from "./gemini.ts";
const app = samesake({
url: process.env.SAMESAKE_DATABASE_URL!,
collection: products,
models: { embed: geminiEmbed, generate: geminiGenerate },
rerank: llmRerank(geminiGenerate),
});
const result = await app.search("linen dress", {
facets: ["brand", "category", "colors"],
filters: { available: true },
limit: 24,
});
// result.hits — the product rows, ranked
// result.facets — per-field counts for the sidebar

Count facets (text, enum, boolean, array fields with facet: true):

result.facets.brand
// { values: [{ value: "Uniqlo", count: 14 }, { value: "Muji", count: 9 }, …] }
result.facets.category
// { values: [{ value: "dress", count: 22 }, { value: "shirt", count: 11 }, …] }

Range facets (f.number({ facet: "range" })):

result.facets.price
// {
// count: 80,
// min: 1200,
// max: 48000,
// avg: 14250,
// buckets: [
// { lo: 1200, hi: 9000, count: 31 },
// { lo: 9000, hi: 16800, count: 18 },
// …
// ],
// }

Your UI renders values as checkboxes (or chips) with the count beside each label. When someone clicks Uniqlo, you don’t need a separate facet API — add a filter and search again:

await app.search("linen dress", {
facets: ["brand", "category", "colors"],
filters: { available: true, brand: "Uniqlo" },
limit: 24,
});

Counts and hits both respect the same q + filters. That’s what makes “14” next to Uniqlo trustworthy — it’s 14 among in-stock linen dresses, not 14 in the whole catalog.

Facets above ride on search(), which needs a query (or image). But some questions are pure aggregation — “how many products does each brand list?”, “what’s the average price for Loom & Aura?” — with no search at all. Reaching for raw SQL against samesake’s compiled table couples you to its internal schema. Use the backend’s facets() instead: the same facet/stats engine, no query required.

Pure aggregation — no query
import { normalizeFiltersToConstraintPredicates } from "@samesake/query";
import { products } from "./catalog.ts";
// Count per brand across the catalog (optionally scoped by a filter)
const brands = await app.facets({
fields: ["brand"],
filters: normalizeFiltersToConstraintPredicates({ category: "Footwear" }, products), // optional
});
brands.brand;
// { values: [{ value: "Nike", count: 32 }, { value: "Adidas", count: 21 }, … ] }
// Average + count for one brand — range facets carry numeric stats
const price = await app.facets({
filters: normalizeFiltersToConstraintPredicates({ brand: "Loom & Aura" }, products),
fields: ["price"],
});
price.price;
// { count: 18, min: 4200, max: 28000, avg: 11910, buckets: [ … ] }

facets() takes fields + normalized filters — no q. It returns the same FacetResult shapes as result.facets: text/enum/boolean fields give { values: [{ value, count }] }; range fields give { count, min, max, avg, buckets }. This is the primitive for “count per X” and “average Y” — the Mastra e-commerce assistant uses it for its count_products_by_brand and average_price tools.

Facets narrow and count; ranking decides order inside the narrowed set. Business rules — bury unavailable, boost your house brand, personalization — live on search.rankingPolicy and the multiplicative ranking pass. Start with the fashion defaults; when you want to tune relevance vs availability vs margin, read Reranking and the ranking section in Build a search experience.

Instant — fire search on every keystroke

Section titled “Instant — fire search on every keystroke”

Hybrid search against Postgres + pgvector is fast enough to run debounced on each keystroke (200–300 ms is a common sweet spot). You get the classic instant-search feel: type → hits update → facet counts update → click a facet → filter tightens — all without a separate search service.

Two latency knobs worth knowing:

  • rerank: false on hot paths skips the second-stage LLM judge when you want the snappiest first-stage RRF order.
  • weights per query lean harder on keywords vs meaning vs visual without reindexing.

For production quality on fashion, keep llmRerank(geminiGenerate) wired globally and only opt out on typeahead if profiling says you need to.

PieceDefault
Field shapes + facet flagsfashion.fields()
Enrich + gatefashion.enrich() + fashion.indexing()
Embeddingsgemini-embedding-2, dim 1536
Generate / judge / rerankgemini-3.1-flash-lite via llmRerank(geminiGenerate)
Visual signalfashion.spaces({ visual: true })

Copy the provider adapters from Providers, migrate once, then the enrich.upsertenrich.enrich loop above.