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.
The two things you have to get right
Section titled “The two things you have to get right”Strip it down. Good marketplace search needs exactly two things to work:
- 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. - 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.
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)”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:
// 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.upsertfingerprints each product. It computes acontent_hashfrom 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’senriched_atto null — marking it “dirty.”enrich.enrichonly 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:
await app.enrich.retryFailed({ limit: 500 }); // a flaky seller image or model hiccup? retry with backoff- Image revalidation — the old
revalidateImagesprobe-and-clear step is a@samesake/serverworkflow, not anEnricher/SamesakeBundlemethod. Detect the seller’s photo swap yourself (conditional GET / ETag onimage_url) and re-upsertthe row with a changedimage_etag; that marks it dirty andenrich.enrich()re-reads it with fresh vision output. See server-only capabilities. retryFailedmeans a single product whose image timed out, or that hit a model rate-limit, doesn’t silently vanish from your marketplace. It’s markedfailed, 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:
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).
Step 5 — Improve on numbers, not vibes
Section titled “Step 5 — Improve on numbers, not vibes”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:
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):
await app.enrich.upsert(rows);await app.enrich.enrich();await app.resolve(); // ← cluster same-product listingsNow 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.
Multi-store isolation — scopes
Section titled “Multi-store isolation — scopes”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.
What you ended up with
Section titled “What you ended up with”- 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.
Where to go next
Section titled “Where to go next”- Connect a real store feed — Shopify, WooCommerce, MedusaJS.
- Every status a product moves through — Pipeline lifecycle.
- How the re-ranker works (and swapping providers) — Reranking.
- Picking models and providers — Providers.
- A single-store, start-from-scratch version — From a store idea to search.
Run it end to end
Section titled “Run it end to end”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).