Reranking
Reranking is the second stage of search. After first-stage retrieval and RRF fusion over a deep candidate pool, an optional reranker re-scores and reorders hits before rankingPolicy and the final top-K slice.
retrieve → RRF fuse (deep pool) → rerank (blend) → rankingPolicy → top-KWhen it runs
Section titled “When it runs”Reranking is on only when a rerank function is wired and the query does not opt out:
const wantRerank = !!config.rerank && opts.rerank !== false;Wire rerank on samesake({ rerank: myRerank }) (the Tier-2 bundle) or directly on
createSearch({ rerank: myRerank }) (Tier-1). Pass rerank: false on a single query to force
pure RRF. When rerank is absent, search stays on first-stage RRF only.
When reranking is enabled (or variant diversification is on), search pulls a deeper pool (RERANK_POOL = 50) before the second stage so the final top-limit is chosen from real candidates rather than a pre-truncated set.
Blend, not replace
Section titled “Blend, not replace”The key design choice: rerank scores blend with retrieval position — they never fully replace it.
final = w(rank) · positionScore + (1 − w(rank)) · clamp01(rerankScore)positionScore = 1 / rrfRankBlend weights by retrieval rank (DEFAULT_RERANK_BLEND_WEIGHTS, tunable via RerankBlendWeights):
| Rank range | w (retrieval weight) |
|---|---|
| rank ≤ 3 | 0.75 |
| rank ≤ 10 | 0.60 |
| beyond 10 | 0.40 |
Rationale: rerankers degrade recall below retrieval-alone in 44–53% of strong-first-stage cases (“phantom hits”, arXiv:2411.11767). The position weight is the guardrail: trust retrieval at the head, trust the reranker more in the tail.
DEFAULT_RERANK_BLEND_WEIGHTS is exported from @samesake/query for reference; the search layer applies the blend internally via blendRerankScore and mergeBlendedRerank (also @samesake/query).
Unscored candidates keep their RRF slot
Section titled “Unscored candidates keep their RRF slot”mergeBlendedRerank never demotes a hit the reranker omitted. A candidate without a rerank score stays at its original retrieval index — it is not blended against a zero. Scored hits reorder only among the slots they occupied.
Candidate text
Section titled “Candidate text”The reranker sees the best available text per hit via rerankCandidateText:
- Persisted column
h.rerank_doc enriched.rerank_docfrom the indexing surface- Fallback:
title/name/data.description
Declare a rerank_doc surface at index time for reranker-specific text — see Pipeline lifecycle.
RerankFn contract
Section titled “RerankFn contract”type RerankFn = (req: RerankRequest) => Promise<Array<{ id: string; score: number }>>;
interface RerankRequest { query: string; image?: { url?: string; bytes?: Uint8Array; mimeType?: string }; candidates: Array<{ id: string; text: string; data: Record<string, unknown>; score: number; // first-stage RRF score }>; topK: number;}Returned scores must be in [0, 1]. The search layer clamps at the boundary via clamp01 — this is a floor, not a normalizer. If your cross-encoder returns raw logits, squash them yourself before returning.
llmRerank — LLM-judge rerank, opt-in
Section titled “llmRerank — LLM-judge rerank, opt-in”llmRerank wraps makeLlmJudge and maps the ESCI gain {0..3} → score/3.
It returns a plain RerankFn, so it composes with the canonical search/samesake primitives even
though the helper itself still ships from @samesake/server (it has not been ported into
@samesake/query yet). It is not bundled or auto-on — wire it explicitly:
import { samesake } from "@samesake/postgres";import { llmRerank } from "@samesake/server";
const app = samesake({ url: process.env.SAMESAKE_DATABASE_URL!, collection, models: { embed: myEmbed, generate: myGenerate }, rerank: llmRerank(myGenerate, { model: "gemini-3.1-flash-lite", }),});Once wired, reranking runs per query unless rerank: false. Not wired → pure RRF.
Remote reranker adapters
Section titled “Remote reranker adapters”No remote reranker is bundled. Any provider plugs in via RerankFn:
const cohereRerank: RerankFn = async ({ query, candidates, topK }) => { const res = await fetch("https://api.cohere.com/v2/rerank", { method: "POST", headers: { Authorization: `Bearer ${process.env.COHERE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "rerank-v3.5", query, documents: candidates.map((c) => c.text), top_n: topK, }), }); const { results } = (await res.json()) as { results: Array<{ index: number; relevance_score: number }>; }; return results.map((r) => ({ id: candidates[r.index]!.id, score: r.relevance_score, // already [0, 1] }));};const voyageRerank: RerankFn = async ({ query, candidates, topK }) => { const res = await fetch("https://api.voyageai.com/v1/rerank", { method: "POST", headers: { Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "rerank-2", query, documents: candidates.map((c) => c.text), top_k: topK, }), }); const { data } = (await res.json()) as { data: Array<{ index: number; relevance_score: number }>; }; return data.map((r) => ({ id: candidates[r.index]!.id, score: r.relevance_score, }));};const jinaRerank: RerankFn = async ({ query, candidates, topK }) => { const res = await fetch("https://api.jina.ai/v1/rerank", { method: "POST", headers: { Authorization: `Bearer ${process.env.JINA_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "jina-reranker-v2", query, documents: candidates.map((c) => c.text), top_n: topK, }), }); const { results } = (await res.json()) as { results: Array<{ index: number; relevance_score: number }>; }; return results.map((r) => ({ id: candidates[r.index]!.id, score: r.relevance_score, }));};function sigmoid(x: number): number { return 1 / (1 + Math.exp(-x));}
const localRerank: RerankFn = async ({ query, candidates }) => { const scores = await myCrossEncoder.scorePairs( candidates.map((c) => [query, c.text] as [string, string]) ); return candidates.map((c, i) => ({ id: c.id, score: sigmoid(scores[i]!), // map raw logit → [0, 1] }));};A ready-made recipe ships in examples/fashion-search/rerank.ts: onnxReranker() runs mxbai-rerank-xsmall locally via transformers.js (Node / Bun / container), and workersAiReranker(env.AI) uses Cloudflare Workers AI (@cf/baai/bge-reranker-base). A native ONNX runtime (onnxruntime-node) does not run on Cloudflare Workers — use the Workers AI binding there.
See Providers for wiring embed and generate alongside rerank.
Relevance floor
Section titled “Relevance floor”Reranking reorders; the relevance floor removes. search.relevanceFloor is an absolute query–document cosine similarity (0–1) a semantic-only hit must clear to survive — FTS keyword matches are exempt. It suppresses no-match padding: a query with no real match returns few/no results instead of the nearest neighbours.
collection("products", { // … enrich: { stages: [] }, search: { channels: [/* … */], relevanceFloor: 0.5, // calibrated for gemini-embedding-2 },});- Structured-intent bypass — when NLQ derives hard filters (price/colour/category/…), those filters define relevance, so the floor is skipped; a filter-dominated query like
"anything under 2000"is never emptied. - Calibration —
0.5is calibrated forgemini-embedding-2on a labelled positive/negative probe (positives mean cosine ≈0.60, negatives ≈0.46). Recalibrate per embedding model; absolute thresholds drift with the model and corpus. - Cosine vs reranker — on the same probe a cross-encoder reranker rejected 100% of no-match queries vs the cosine floor’s 92%. The cosine floor is the model-free default that works on every runtime; wire a reranker (above) when you want the stronger signal and your runtime supports it.
Graceful degradation
Section titled “Graceful degradation”| Condition | Behaviour |
|---|---|
rerank not wired | Pure RRF |
rerank: false on query | Pure RRF |
| Reranker throws | First-stage order preserved; warning logged; search never throws |
Related
Section titled “Related”- Relevance judge — the LLM judge behind
llmRerankandrunEval - Eval gate — measure rerank impact on the golden set
- Tuning search relevance — when to reach for rerank vs weights