Skip to content

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-K

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.

The key design choice: rerank scores blend with retrieval position — they never fully replace it.

final = w(rank) · positionScore + (1w(rank)) · clamp01(rerankScore)
positionScore = 1 / rrfRank

Blend weights by retrieval rank (DEFAULT_RERANK_BLEND_WEIGHTS, tunable via RerankBlendWeights):

Rank rangew (retrieval weight)
rank ≤ 30.75
rank ≤ 100.60
beyond 100.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).

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.

The reranker sees the best available text per hit via rerankCandidateText:

  1. Persisted column h.rerank_doc
  2. enriched.rerank_doc from the indexing surface
  3. Fallback: title / name / data.description

Declare a rerank_doc surface at index time for reranker-specific text — see Pipeline lifecycle.

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

No remote reranker is bundled. Any provider plugs in via RerankFn:

Cohere (rerank-v3.5)
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]
}));
};
Voyage (rerank-2)
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,
}));
};
Jina (jina-reranker-v2)
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,
}));
};
Local cross-encoder (normalize the logit)
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.

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.
  • Calibration0.5 is calibrated for gemini-embedding-2 on 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.
ConditionBehaviour
rerank not wiredPure RRF
rerank: false on queryPure RRF
Reranker throwsFirst-stage order preserved; warning logged; search never throws