Quickstart
This is the precise, no-narration version of Build a search experience. Keyword-only search needs no LLM.
Prerequisites
Section titled “Prerequisites”- Bun 1.3+ (Node also works)
- Postgres 15+ with the required extensions
createdb samesake_devpsql samesake_dev -c "CREATE EXTENSION vector; CREATE EXTENSION pg_trgm; CREATE EXTENSION unaccent; CREATE EXTENSION fuzzystrmatch;"Install
Section titled “Install”bun add @samesake/core @samesake/postgresnpm install @samesake/core @samesake/postgrespnpm add @samesake/core @samesake/postgresSAMESAKE_DATABASE_URL=postgres://localhost:5432/samesake_devBuild it
Section titled “Build it”-
Declare the catalog.
searchablefields feed keyword search;filterablefields become hard filters.catalog.ts import { collection, f, Channels, gates } from "@samesake/core";export const products = collection("products", {fields: {title: f.text({ searchable: true }),brand: f.text({ filterable: true }),price: f.number({ filterable: true, budget: true }),color: f.text({ filterable: true }),available: f.boolean({ filterable: true }),},// Required before indexing. Empty here — this data is already clean, so the// surfaces below read `data` directly (a messier catalog adds parse stages).enrich: { stages: [] },indexing: {surfaces: {embed_doc: { kind: "dense", embedding: "doc", build: ({ data }) => `${data.title} ${data.brand} ${data.color}`.trim() },fts_doc: { kind: "fts", build: ({ data }) => `${data.title} ${data.brand} ${data.color}`.trim() },},gate: gates.always,},embeddings: { doc: { model: "gemini-embedding-2", dim: 1536 } },search: {channels: [Channels.fts({ fields: ["title", "brand", "color"], weight: 1 }),Channels.cosine({ embedding: "doc", weight: 1 }),],combiner: "rrf",nlq: { instructions: "Shopping queries for a fashion catalog. Extract price budgets and attributes." },},}); -
Create the bundle. Supply your embedding function. For a keyword-only smoke test, a stub embedding is fine.
search.ts import { samesake } from "@samesake/postgres";import { products } from "./catalog.ts";const app = samesake({url: process.env.SAMESAKE_DATABASE_URL!,collection: products,models: {embed: async ({ text, dim }) => stubEmbed(text, dim), // swap for a real model later},}); -
Migrate, push, enrich.
search.ts await app.migrate();await app.enrich.upsert([{ id: "1", data: { title: "ivory linen slip dress", brand: "atelier", price: 12900, color: "ivory", available: true } },{ id: "2", data: { title: "black sequin party dress", brand: "luxe", price: 28000, color: "black", available: true } },]);await app.enrich.enrich(); -
Search.
search.ts const { hits } = await app.search("linen dress under 15000", {filters: { available: true },limit: 10,});console.log(hits.map((h) => h.id));
Serve it over HTTP
Section titled “Serve it over HTTP”samesake is not a hosted service — there is no bundled fetch handler. Wrap app.search
in whatever web-standard handler your runtime uses:
// Bun.serve / Workers / Vercel / Denoexport default { fetch: async (req: Request) => { const { searchParams } = new URL(req.url); const result = await app.search(searchParams.get("q") ?? "", { limit: 10 }); return Response.json(result); },};Where to go next
Section titled “Where to go next”- Build a search experience — the same thing, explained from scratch.
- Connect a real store: MedusaJS · headless Shopify · headless WooCommerce.
- Eval from search snapshots — measure keyword vs hybrid on a real corpus.