Compose the pipeline from primitives
The samesake() bundle is the batteries-included path: one call gives you a Postgres-backed enrich → resolve → search over a table it manages. But every stage is a standalone package, and the store is a port you implement. When you want a different backend — Redis, SQLite, LanceDB, Cloudflare KV, or just an in-memory array — you compose the primitives yourself.
This guide wires the whole pipeline by hand, with no bundle and no database. The runnable version is examples/compose-from-primitives — it runs fully in memory, no API key.
The flow
Section titled “The flow”INGEST INDEX (your store) SERVEraw rows a plain array createSearch → enrich() (pure) id + fields → parse NLQ → embed the dense surface + fts text → plan (fts + vector legs) + vector → your Retriever fuses (RRF)Three packages, each used directly: @samesake/enrich (the pure enrich() transform), @samesake/embed (your embedder), and @samesake/query (createSearch over a Retriever you supply).
Stage 1 — enrich (pure, no store)
Section titled “Stage 1 — enrich (pure, no store)”enrich() is a pure transform: rows in, enriched attributes + indexing surfaces out. It never touches a store — that’s why it composes anywhere.
import { enrich } from "@samesake/enrich";
const results = await enrich( catalog, // raw rows { pipeline: products.enrich, indexing: products.indexing }, { generate }, // your LLM (or a stub));// each result: { id, enriched, surfaces: { denseByEmbedding, fts_src, ... }, status, ok }Stage 2 — embed + index (your store)
Section titled “Stage 2 — embed + index (your store)”Embed each dense surface with your own model, then put the row into whatever index you like. Here it’s a plain array; the shape is { id, fields, fts, vector }.
import { projectFields } from "@samesake/core";
for (const r of results) { if (!r.ok || r.status !== "ready") continue; const vector = await embed({ text: r.surfaces.denseByEmbedding.doc, dim, model }); index.add({ id: r.id, fields: projectFields(products.fields, dataById.get(r.id), r.enriched), // filterable/returnable values fts: r.surfaces.fts_src, vector, });}Stage 3 — search (BYO Retriever)
Section titled “Stage 3 — search (BYO Retriever)”createSearch owns NLQ parsing, planning, and ranking. The one thing it delegates is retrieval: you supply a Retriever — (plan) => RankedRow[] — that runs the plan’s legs against your store and fuses them with reciprocal-rank fusion.
import type { RankedRow, RetrievalPlan, Retriever } from "@samesake/query";
const retriever = ((plan: RetrievalPlan) => { const eligible = rows.filter((row) => plan.filters.every((p) => matches(row, p))); // hard filters const ftsRank = rankByTokenOverlap(eligible, plan.query); // fts leg const cosRank = rankByCosine(eligible, plan.vectors[0]?.vec); // vector leg const fused = fuseRRF(ftsRank, cosRank, plan.weights); // reciprocal-rank fusion return fused.slice(0, plan.limit); // RankedRow[]}) satisfies Retriever;import { createSearch } from "@samesake/query";
const search = createSearch({ collection: products, retriever, // yours vocab, // yours — distinct field values for grounded query understanding generate, embed,});
const { hits } = await search("linen dress under 15000", { limit: 10 });// "under 15000" is parsed into a hard budget filter — the 28,000 dress never appears.What you did and didn’t implement
Section titled “What you did and didn’t implement”You implemented exactly two ports — a Retriever and a VocabProvider — over a store of your choosing. Everything else (NLQ parsing, aspect planning, RRF weighting, cutoff, ranking) is the query package. The enrich transform and the embedder are used as-is.