Skip to content

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.

INGEST INDEX (your store) SERVE
raw 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).

enrich() is a pure transform: rows in, enriched attributes + indexing surfaces out. It never touches a store — that’s why it composes anywhere.

run.ts
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 }

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 }.

run.ts
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,
});
}

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.

index-store.ts (the Retriever)
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;
run.ts
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.

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.