Skip to content

Tuning search relevance

Relevance is not a setting you flip — it’s a loop. This guide is the technique we use to tune search with samesake, written up from a real session where “black dress with white” kept returning red dresses. Every step here came from a concrete fix.

query → explain → diagnose → fix (data or config) → re-measure → repeat

1. Diagnose before you tune — read the result’s constraintTrace

Section titled “1. Diagnose before you tune — read the result’s constraintTrace”

Never guess why a result ranked where it did. Every search() call returns parsed (the NLQ output) and constraintTrace.appliedFilters (the compiled filters) alongside the hits.

const r = await app.search("black dress with white", { limit: 20 });
console.log(r.parsed, r.constraintTrace.appliedFilters);
const explained = await app.search.searchExplain("black dress with white", { limit: 20 });
console.log(explained.docs[0]);
// { id, fts_rank, cosine_rank, recency_rank, rrf_score, aspect_ranks? }

In our case the explain showed fts = null for every hit (no title contained “black”/“white”) and the order was driven entirely by the embedding — which, to the model, was mostly “dress.” That single output told us colour wasn’t a signal at all. Measure the cause, then fix it.

We audited the colour field: 17 of 27 products had no colour at all — they’d been guessed from the title with a heuristic, and most titles (“Princess Line Dress with Belt”) have none. No amount of weight-tuning fixes absent data. Garbage in, “red dress for a black query” out.

3. Enrich attributes at the source (don’t guess from text)

Section titled “3. Enrich attributes at the source (don’t guess from text)”

The fix was to read the attributes off the product images with samesake’s enrich pipeline — a multimodal stage that calls your generate with the image + a schema and writes structured fields into enriched:

import { z } from "zod";
enrich: pipeline(
stage("vision", {
model: "your-vision-model",
images: (ctx) => (ctx.data.image_url ? [String(ctx.data.image_url)] : []),
prompt: () => "Describe this product's colours and pattern as JSON.",
schema: () => z.object({ color_text: z.string(), pattern: z.string().optional() }),
})
)

The schema callback takes a zod schema or a plain JSON Schema object — samesake converts zod to JSON Schema and hands it to your generate. (The same goes for a constrained NLQ schema.) Provider-dialect mapping — e.g. Gemini’s responseSchema vs responseJsonSchema — stays in your generate function.

Run with app.enrich.enrich(). Colours went from mostly-empty to accurate (“RED PUFF SLEEVE MAXI DRESS” → solid red). See the full pipeline.

Then measure the enrichment itself — the Enricher’s evaluate(gold) scores per-attribute precision / recall / F1 against a gold set, so you know whether the fix actually landed (and catch regressions where a prompt tweak fixes one product but breaks others). It’s the root-cause loop beneath search relevance — see Measure enrichment accuracy.

4. Compose what you embed — the indexing DSL

Section titled “4. Compose what you embed — the indexing DSL”

An embedding only knows what’s in the text it was built from. Declare surface builders on the collection — they run at enrich time, persist to the row, and the indexer reads them (no separate compose step, no string template on the embeddings block):

indexing: {
surfaces: {
embed_doc: {
kind: "dense",
embedding: "doc",
build: ({ data, enriched }) =>
`${data.title} ${data.brand} ${enriched.color_text ?? ""} ${enriched.pattern ?? ""}`.trim(),
},
rerank_doc: {
kind: "rerank",
build: ({ data, enriched }) =>
`${data.title}. Colors: ${enriched.color_text}. Pattern: ${enriched.pattern ?? "solid"}.`,
},
fts_doc: {
kind: "fts",
build: ({ data }) => `${data.title} ${data.brand}`.trim(),
},
},
gate: ({ enriched }) =>
enriched.color_text ? { index: true } : { index: false, reason: "missing-color" },
},
embeddings: { doc: { model: "...", dim: 1536 } },

Use fashion.indexing() for the fashion vertical — it wires embed, rerank, FTS surfaces and a confidence/cross-signal gate. See Pipeline lifecycle.

Now “black dress” cosine-matches products whose embedded text actually says black — the black dress jumped from buried to #1.

5. Hard filters vs soft signals — and keep NLQ in its lane

Section titled “5. Hard filters vs soft signals — and keep NLQ in its lane”

This is the subtlest lever:

  • Hard filters for strict, well-populated constraints: price ≤ 5000, available = true. These should gate the result set — that’s the “hard filters stay hard” promise.
  • Soft fields (f.text({ soft: true })) for sparse or fuzzy attributes: a missing colour tag shouldn’t empty your results. samesake relaxes soft filters when too few rows match.
  • Constrain NLQ so it can’t turn a fuzzy word into a hard filter on a sparse field. We gave NLQ a schema of just { semantic_query, max_price } — so “black dress” never compiles to color = 'black' (which had dead-ended at the 2 literally-tagged rows). Colour is left to the embedding + visual signals instead.

For the full story — how a sentence splits into filters + a clean semantic_query, how price/numeric intent is handled, and the search.relevanceFloor that returns nothing instead of nearest-neighbour padding when there’s no real match — see Conversational search.

A single global float is a blunt instrument, so beyond relevanceFloor every collection gets a pluggable result cutoff deciding where the list honestly ends (bad results are worse than an honest empty page). Default is { strategy: "score-drop" }: with no keyword-matched hit, the whole list is cut when even the best cosine is below minAnchor (default 0.3 — calibrate per model, like relevanceFloor), and a steep cosine cliff (maxDrop, default 0.5) ends a semantic tail mid-list. Alternatives: { strategy: "category-coherence", field: "category" } (unanchored hits scattered across categories → nothing real matched → zero) and { strategy: "none" } to opt out. Keyword-matched hits are never cut, and any hard-filtered query bypasses the cutoff entirely — filtered recall stays total. Responses report removals via cutoff_dropped.

Multilingual catalogs — language + search.phonetic

Section titled “Multilingual catalogs — language + search.phonetic”

The lexical leg is language-configurable: collection("products", { language: "german", … }) picks the Postgres stemmer for both the indexed fts column and query parsing (default "english"; use "simple" for mixed-language catalogs). Accents/case/punctuation are folded on both sides (cafécafe) automatically. For cross-script matching — a Sinhala or Tamil query finding the Latin-transliterated product — declare search: { phonetic: true } on the collection: index time stores per-token phonetic codes, query time ORs the query’s codes into the lexical candidates. Changing language on an existing collection is a destructive migration (the generated column must be rebuilt).

With a multimodal embedding model, an image space gives you three retrieval modes over one index: text→text (doc cosine), text→image (a text query embedded into the image space), and image→image (find-similar / search-by-image). Colour/pattern intent that text barely encodes is far stronger image→image — so “find similar” is where the visual space earns its keep, while text→image mainly adds category/shape sense. Know which mode a query needs.

7. Tune query-time weights and ranking policy last

Section titled “7. Tune query-time weights and ranking policy last”

Channel weights (Channels.fts({ weight }), Channels.cosine, Channels.spaces) and defaultSpaceWeights rescale the RRF mix without reindexing. After first-stage fusion, search.rankingPolicy applies multiplicative business/availability/personalization axes on normalized relevance (relevance^α × availability × business × …) with optional minRelevanceFloor. Hard axes multiply; soft axes add. Reach for these only after the data and signals are right — re-weighting noise just reshuffles noise.

Default reranking uses llmRerank(generate) (from @samesake/server) when wired via samesake({ rerank }) or createSearch({ rerank }) — a position-aware blend with first-stage RRF, not a full replace. Pass rerank: false per query to force pure RRF. See Reranking and Relevance judge for the full contract.

Empirical tuning: FASHION_CONFIDENCE_FLOOR and relevanceExponent are placeholders until you run the offline eval gate — see Eval gate — tune floor and exponents.

8. Measure with three loops, and respect corpus size

Section titled “8. Measure with three loops, and respect corpus size”

Tune against fixed sets, not vibes. samesake has three complementary eval loops:

  • Enrichment accuracy (root cause) — the Enricher’s evaluate(gold), per-attribute P/R/F1 vs a gold set. Fix this first; search can’t beat the data it ranks. See Measure enrichment accuracy.
  • Search relevancematcher.runEval(...) over a fixed query set: relevance@k, nDCG, constraint compliance. See Eval from search snapshots and Eval gate. The LLM judge sees each candidate’s price (so it can verify “under N” constraints) and persists grades per (query, doc) — so a pre/post comparison reflects a real retrieval change, not judge re-roll noise. Report by query bucket (keyword / attribute / use-case / price / negation / style / local); an overall nDCG win can hide a tail regression.
  • Adversarial red-team — deliberately-breaking queries (out-of-distribution, numerical/malformed, injection, contradiction, degenerate, polysemy) to confirm the engine fails gracefully: no crashes, no leaked secrets, and no confident junk for off-domain queries (tune search.relevanceFloor so “gaming laptop” returns nothing, not five random dresses).

Be honest about scale: at ~30 products colour is a weak discriminator no matter what, because the embedding is dominated by category. Real relevance wins need clean attributes and a catalog big enough to disambiguate.

upsert and enrich do their work inline and resolve when it’s finished — there is no internal job queue or runner. To run them durably or off the request path, that’s the caller’s job: wrap each call in your platform’s durable step, or call them from your own concurrency-bounded loop.

// inside an Inngest step — the platform owns durability, retries, and scheduling:
await step.run("enrich", () => app.enrich.enrich());

See the pipeline guides for Inngest / Upstash / Cloudflare Workflows / Vercel Workflows recipes. To bound how many run at once in a single process, limit at the call site (a p-limit or a worker pool) — samesake imposes no runner of its own.

Explain → fix the data → compose the embedding → set hard/soft correctly → constrain NLQ → tune weights → measure. In that order.