Skip to content

Search for a fashion marketplace that never holds still

A marketplace is a different animal from a single shop. You don’t own the listings — your sellers do. There are tens of thousands of products, written by hundreds of different people who all describe things their own way. New drops land every hour. Prices move. Things sell out and come back. A seller swaps a product photo without telling anyone. And your shoppers? They don’t type “midi dress” — they type “breezy summer dress for a garden party, nothing pink, under 5000.”

So you have two messes to tame at once: the products are inconsistent, and the queries are human. Keyword search — match the words in the query to the words in the listing — fails at both ends. The seller wrote “boho maxi,” the shopper typed “flowy long dress for a beach holiday,” and they share zero words. Zero results. On a marketplace that happens constantly.

Let’s fix both, and — the part that actually keeps a marketplace alive — let’s make it stay fixed as the catalog churns, without you babysitting it.

Strip it down. Good marketplace search needs exactly two things to work:

  1. Make every product mean the same thing. A seller’s messy “boho maxi, perfect for summer 🌸” and another’s “long floral dress” should both become the same structured truth: { category: dress, length: maxi, pattern: floral, occasions: vacation/casual, style: bohemian }. Once products speak a consistent language, you can actually match and filter them.
  2. Understand what the shopper meant. “Under 5000” is a hard wall. “Not pink” is a hard wall. “Breezy summer garden-party dress” is meaning. You have to pull the walls out as filters and keep the meaning as meaning.

Notice these are the same two jobs, pointed in opposite directions: understand the product, and understand the query. samesake does the first with enrichment and the second with intent search. You wire them; you don’t build them.

Step 1 — Describe the catalog (the preset is your best default)

Section titled “Step 1 — Describe the catalog (the preset is your best default)”

One file. The fashion preset fills in the parts that take real expertise — which attributes matter, how to read a product and its photo into structured fields, and a quality gate.

catalog.ts
import { collection, Channels } from "@samesake/core";
import { fashion } from "@samesake/presets";
export const products = collection("products", {
fields: fashion.fields(), // brand, price, category, gender, colors, occasions… + filterable flags
spaces: fashion.spaces({ visual: true }), // photo + price + category + freshness signals
enrich: fashion.enrich(), // reads each messy listing (+ image) into consistent attributes
indexing: fashion.indexing(), // builds the searchable text + the quality gate
embeddings: { doc: { model: "gemini-embedding-2", dim: 1536 } },
search: {
channels: [
Channels.fts({ fields: ["title"], weight: 1 }),
Channels.cosine({ embedding: "doc", weight: 1 }), // meaning — this is what makes it "intent", not keyword
Channels.spaces({ weight: 1 }),
],
combiner: "rrf",
nlq: { schema: fashion.nlq.schema(), instructions: fashion.nlq.instructions },
},
});

Every filterable attribute the preset declares (category, gender, colors, price, availability…) becomes a real database column you can filter on — and crucially, it’s filled by enrichment, not by trusting whatever the seller typed. That’s how your filters stay honest across a thousand sellers’ habits.

Step 2 — Wire the bundle (with the relevance double-check on)

Section titled “Step 2 — Wire the bundle (with the relevance double-check on)”
search.ts
import { samesake } from "@samesake/postgres";
import { llmRerank } from "@samesake/server";
import { products } from "./catalog.ts";
import { geminiEmbed, geminiGenerate } from "./gemini.ts"; // your two model fns — see Providers
export const app = samesake({
url: process.env.SAMESAKE_DATABASE_URL!,
collection: products,
models: {
embed: geminiEmbed, // gemini-embedding-2
generate: geminiGenerate, // gemini-3.1-flash-lite — reads products + parses queries + judges relevance
},
rerank: llmRerank(geminiGenerate), // re-checks the top results, on by default once wired
});
await app.migrate();

Step 3 — The pipeline that keeps it fresh (this is the heart of a marketplace)

Section titled “Step 3 — The pipeline that keeps it fresh (this is the heart of a marketplace)”

Here’s where a marketplace is different from a shop you set up once. Your catalog is a river, not a lake. So you don’t “load the data” — you run a small loop that keeps the index in sync with whatever your sellers did since last time.

The loop is three commands, and the magic is that they’re all incremental and idempotent — running them on an unchanged catalog does almost nothing:

sync.ts
// Run this on a schedule (cron) and/or from your store webhooks.
async function sync(rows) {
await app.enrich.upsert(rows); // upsert raw listings — content-hash dirty-tracked
await app.enrich.enrich(); // understand + (re)build surfaces for the new/changed ones
}

Why this is safe to run every five minutes over your whole catalog without melting your model bill:

  • enrich.upsert fingerprints each product. It computes a content_hash from the listing’s fields. On upsert, if the hash is identical to what’s stored, nothing else changes — the product is untouched. If the hash is different (a seller edited the title, dropped the price, swapped the photo), it resets that row’s enriched_at to null — marking it “dirty.”
  • enrich.enrich only processes dirty rows (enriched_at IS NULL). New products and genuinely-changed products get re-read by the model; the other 49,000 untouched listings are skipped entirely. No wasted model calls.

So a full re-sync of a 50,000-product marketplace where 80 things changed costs you ~80 enrichments, not 50,000. You can run it as often as you like.

The two background jobs that make it durable

Section titled “The two background jobs that make it durable”

Two more commands you run on a slower schedule (say, hourly) — these are what separate a toy from something that survives a real marketplace:

maintenance.ts
await app.enrich.retryFailed({ limit: 500 }); // a flaky seller image or model hiccup? retry with backoff
  • Image revalidation — the old revalidateImages probe-and-clear step is a @samesake/server workflow, not an Enricher/SamesakeBundle method. Detect the seller’s photo swap yourself (conditional GET / ETag on image_url) and re-upsert the row with a changed image_etag; that marks it dirty and enrich.enrich() re-reads it with fresh vision output. See server-only capabilities.
  • retryFailed means a single product whose image timed out, or that hit a model rate-limit, doesn’t silently vanish from your marketplace. It’s marked failed, retried later with exponential backoff.

Every product is always in one honest state — pending, ready, quarantined, failed, or dead — and only ready ones show in search. The whole state machine is in Pipeline lifecycle.

Step 4 — Search by intent, with filters that hold

Section titled “Step 4 — Search by intent, with filters that hold”

Now the shopper side. Two things are happening when someone searches “breezy summer dress for a garden party, nothing pink, under 5000”:

The meaning is matched, not the words. Because your search config carries the cosine (meaning) channel, the query and every product live in the same “meaning space.” “Breezy summer garden-party dress” lands near “flowy floral linen midi” even with no shared words. That’s intent search — and on text queries it’s the default behaviour, so you get it for free.

The hard constraints become filters. “Under 5000” and “nothing pink” are walls, not vibes. Wire fashion.nlq onto the collection’s search.nlq and search() parses the sentence into structured constraints and applies them as real filters itself — no separate parse call:

search.ts
import { app } from "./search.ts";
const { hits, parsed, constraintTrace } = await app.search(
"breezy summer dress for a garden party, nothing pink, under 5000",
{ filters: { available: true }, limit: 20 }
);
// parsed.semantic_query → "flowy summer garden-party dress"
// constraintTrace.appliedFilters → { max_price: 5000, exclude_colors: ["pink"], available: true }

A pink 6,000 dress can be the most beautiful match in your catalog and it still won’t show — because price and colour were walls, and search() pushes them into the database as real conditions. The meaning only decides the order of the dresses that were allowed through. That’s the rule from the very top, working exactly as promised.

And because llmRerank is wired, the top handful get a final “is this actually a garden-party dress?” pass from a model — blended with the retrieval order, never blindly replacing it (Reranking).

On a marketplace you’ll constantly be tempted to fiddle — change a weight, swap a model, tighten the gate. Don’t guess whether it helped. samesake ships an offline grader: a frozen set of real queries, scored on the 4-class ESCI rubric by an LLM judge from a different model family than your enrich pipeline — runEval refuses a judge that would grade its own homework. Change something, re-run, compare the numbers; if relevance drops, you don’t ship it.

const result = await matcher.runEval("market", "products", { queries: golden, judge, k: 10 });
console.log(result.aggregate, "pass:", result.pass);

That’s how you tune a marketplace without playing whack-a-mole. See Eval gate (runEval today still runs through the legacy @samesake/server Matcher — see that guide’s note).

Collapse duplicate listings — one product, many offers

Section titled “Collapse duplicate listings — one product, many offers”

On a marketplace that works, this problem grows: as more sellers join, more of them list the same physical product with different titles, photos, and prices. Search treats them as unrelated documents, so the results page shows the same silicone case five times — perceived selection collapses, the price competition is invisible, and genuinely different products get crowded out.

Declare dedup on the collection and search returns one hit per product, each carrying an offers array — the member listings’ vendor, price, and availability:

catalog.ts
export const products = collection("products", {
// ...fields, embeddings, search as before
dedup: {
channels: [
{ kind: "exactKey", field: "gtin" }, // equal non-empty GTIN → decisive, auto-links
{ kind: "trigram", field: "title", weight: 1 }, // fuzzy title similarity
{ kind: "cosine", weight: 1 }, // doc-embedding similarity
],
autoLink: 0.82, // best-candidate score at/above which two listings merge automatically
suggest: 0.62, // scores in [0.62, 0.82) queue for a human instead of merging
offerFields: ["vendor", "price", "available"], // copied onto each offer entry
},
});

Clustering is an explicit stage — it runs after enrich, in the same incremental spirit as the rest of the pipeline (it only touches rows that are enriched and not yet clustered). resolve is exposed directly on the Tier-2 bundle (app.resolve === app.enrich.resolve):

sync.ts
await app.enrich.upsert(rows);
await app.enrich.enrich();
await app.resolve(); // ← cluster same-product listings

Now search collapses on the cluster and attaches the offers:

const { hits } = await app.search("silicone case iphone 15 black");
// hits[0] = { id: "...", title: "...", offers: [
// { id: "a", vendor: "AStore", price: 12, available: true },
// { id: "b", vendor: "BShop", price: 10, available: true },
// { id: "c", vendor: "CMart", price: 15, available: true },
// ] }

offers only ever contains the fields you declared in offerFields (plus id) — never the raw listing — so you control exactly what cross-vendor data a search response reveals. A member that goes out of stock or gets quarantined drops out of offers automatically. Pass offers: false to skip the attachment, or diversify: false to see every raw listing again.

Clusters never span tenancy scopes (below) — candidates are pinned to the row’s own scope, so a listing in one store can never merge with an identical one in another.

Everything above is one marketplace: many sellers inside one shared catalog, where “vendor” is a facet shoppers can filter by. If instead you host separate stores — a SaaS where each merchant gets their own catalog, or one deployment serving several country storefronts — declare tenancy on the collection:

const products = collection("products", {
scopes: ["store_id"], // "whose catalog is this row?"
// ...fields, embeddings, search as before
});
// …and every read runs inside exactly one scope. There is no cross-scope search.
const hits = await app.search("red running shoes", {
scope: { store_id: "acme" },
});

The scope compiles to an indexed column plus a mandatory filter on every surface — search, facets, explain, document reads, deletes. Forgetting it is an error, not an unscoped query; an upsert that would overwrite another store’s document id is rejected; connectors can pin a scope ({ kind: "shopify", scope: { store_id: "acme" }, … }) so one feed maps to one store. Use a facet field when shoppers should see everything; use a scope when a tenant must never see another’s rows.

  • Every messy seller listing enriched into one consistent, filterable shape.
  • Intent search that matches meaning, not keywords — with the hard constraints pulled out as filters that never bend.
  • A continuous pipeline that keeps the index in sync with a churning catalog, cheaply, because it only touches what changed — plus image revalidation and retries so nothing rots or silently disappears.
  • A way to measure every change before it ships.

All of it on good defaults you can override the day you have an opinion.

The runnable, tested version of this guide lives in examples/marketplace-search — deterministic stubs, no API key. Run bun test (Postgres-backed examples need SAMESAKE_DATABASE_URL).