Reranking search results with a cross-encoder LLM

By Sophia Papadopoulos · 19 July 202681 views
Reranking search results with a cross-encoder LLM

The typical vector search pipeline retrieves the top-K most similar documents using embedding similarity. It is fast — milliseconds for millions of documents — but similarity in embedding space does not always mean relevance to the specific query. A search for "Python async performance tips" might retrieve an article about Python async syntax (high embedding similarity) that does not actually contain performance tips.

Reranking fixes this. After retrieving candidates with your fast vector search, a reranker scores each candidate against the full query and reorders them by actual relevance. The result is dramatically better precision at the top positions, which is exactly where it matters for search applications.

Why reranking works

Bi-encoders (the embedding models used for vector search) encode the query and documents independently and compare them in vector space. This is fast but loses the fine-grained interaction between query and document.

Cross-encoders process the query and document together, allowing full attention between every token in the query and every token in the document. This captures nuances — the presence of specific keywords, the way terms relate to each other — that bi-encoders miss. The cost is that you cannot pre-compute cross-encoder scores; they must be computed per query per candidate.

LLMs make particularly effective rerankers because they understand language deeply and can be prompted to explain their relevance judgments, which aids debugging.

The two-stage pipeline

Query → Bi-encoder retrieval (fast, recall-oriented) → Top 50-100 candidates
     → Cross-encoder reranking (slower, precision-oriented) → Top 5-10 results

Stage one maximises recall: retrieve more than you need, accepting some irrelevant results. Stage two maximises precision: reorder the candidates to surface the most genuinely relevant ones first.

Implementation

from anthropic import Anthropic
import json
from dataclasses import dataclass
from typing import Optional

client = Anthropic()

@dataclass
class SearchResult:
    id: str
    content: str
    metadata: dict
    initial_score: float       # From bi-encoder retrieval
    rerank_score: Optional[float] = None  # From cross-encoder

def rerank_with_llm(
    query: str,
    candidates: list[SearchResult],
    top_k: int = 5,
) -> list[SearchResult]:
    """
    Rerank search results using an LLM as a cross-encoder.
    Returns the top_k most relevant results, sorted by relevance.
    """
    if not candidates:
        return []

    # Format candidates for the prompt
    candidates_text = "\n\n".join(
        f"[{i+1}] ID: {c.id}\n{c.content[:500]}"  # Truncate for token budget
        for i, c in enumerate(candidates)
    )

    response = client.messages.create(
        model="claude-haiku-4-5",  # Haiku for speed and cost
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": f"""Score each document for relevance to the query.

QUERY: {query}

DOCUMENTS:
{candidates_text}

For each document, provide a relevance score from 0.0 (completely irrelevant) 
to 1.0 (perfectly answers the query). Consider:
- Does the document directly answer the query?
- Does it contain the specific information asked for?
- Is the content accurate and detailed for this topic?

Return JSON array with objects: {{"id": str, "score": float, "reason": str}}
Order by score descending. Include all {len(candidates)} documents."""
        }]
    )

    text = response.content[0].text
    if "```" in text:
        text = text.split("```")[1].replace("json", "").strip()

    scores = json.loads(text)

    # Map scores back to candidates
    score_map = {item["id"]: item["score"] for item in scores}

    for candidate in candidates:
        candidate.rerank_score = score_map.get(candidate.id, 0.0)

    # Sort by rerank score and return top_k
    ranked = sorted(candidates, key=lambda c: c.rerank_score or 0, reverse=True)
    return ranked[:top_k]

Batching for cost efficiency

For large candidate sets, processing all candidates in a single prompt can exceed context limits and cost more. Batch candidates and aggregate scores:

def rerank_batched(
    query: str,
    candidates: list[SearchResult],
    batch_size: int = 10,
    top_k: int = 5,
) -> list[SearchResult]:
    """
    Rerank large candidate sets by processing in batches.
    For each batch, get relevance scores, then combine and sort.
    """
    all_scored: list[SearchResult] = []

    for i in range(0, len(candidates), batch_size):
        batch = candidates[i:i + batch_size]
        scored_batch = rerank_with_llm(query, batch, top_k=len(batch))
        all_scored.extend(scored_batch)

    # Sort all results by rerank score
    all_scored.sort(key=lambda c: c.rerank_score or 0, reverse=True)
    return all_scored[:top_k]

Note that batch scores are not comparable across batches if the model uses relative scoring (e.g. "best in this group gets 0.9"). Fix this by using absolute scoring instructions ("how well does this document answer the query, on its own merits") rather than comparative ranking.

A complete pipeline connecting vector retrieval to LLM reranking:

import numpy as np

class TwoStageSearch:
    def __init__(self, embedding_fn, vector_index, documents: dict[str, dict]):
        """
        embedding_fn: callable that takes text, returns embedding vector
        vector_index: FAISS or similar index for fast ANN search
        documents: dict mapping id → {content, metadata}
        """
        self.embed = embedding_fn
        self.index = vector_index
        self.documents = documents

    def search(
        self,
        query: str,
        initial_k: int = 30,    # How many to retrieve in stage 1
        final_k: int = 5,       # How many to return after reranking
        rerank: bool = True,
    ) -> list[SearchResult]:
        # Stage 1: Fast bi-encoder retrieval
        query_vec = np.array(self.embed(query), dtype="float32").reshape(1, -1)
        distances, indices = self.index.search(query_vec, initial_k)

        candidates = []
        for score, idx in zip(distances[0], indices[0]):
            if idx == -1:
                continue
            doc_id = str(idx)
            doc = self.documents.get(doc_id, {})
            candidates.append(SearchResult(
                id=doc_id,
                content=doc.get("content", ""),
                metadata=doc.get("metadata", {}),
                initial_score=float(score),
            ))

        if not rerank:
            return candidates[:final_k]

        # Stage 2: LLM reranking
        return rerank_batched(query, candidates, top_k=final_k)

Caching reranked results

Reranking is the expensive step. Cache aggressively:

import hashlib
from functools import lru_cache

def cache_key(query: str, candidate_ids: list[str]) -> str:
    """Deterministic cache key from query + candidate set."""
    content = query + "|" + ",".join(sorted(candidate_ids))
    return hashlib.sha256(content.encode()).hexdigest()

class CachedReranker:
    def __init__(self, ttl_seconds: int = 3600):
        self._cache: dict[str, tuple[list[SearchResult], float]] = {}
        self.ttl = ttl_seconds

    def rerank(
        self,
        query: str,
        candidates: list[SearchResult],
        top_k: int = 5,
    ) -> list[SearchResult]:
        import time

        key = cache_key(query, [c.id for c in candidates])
        cached, cached_at = self._cache.get(key, (None, 0))

        if cached and (time.time() - cached_at) < self.ttl:
            return cached[:top_k]

        results = rerank_with_llm(query, candidates, top_k=top_k)
        self._cache[key] = (results, time.time())
        return results

Measuring reranking improvement

To justify the latency and cost overhead, measure the precision improvement. The standard metric for this is nDCG@k (normalised discounted cumulative gain at k positions):

def ndcg_at_k(relevant_ids: set[str], results: list[SearchResult], k: int) -> float:
    """
    Measure ranking quality. relevant_ids is the ground truth set of
    relevant document IDs for this query.
    """
    dcg = 0.0
    for i, result in enumerate(results[:k]):
        if result.id in relevant_ids:
            dcg += 1.0 / (i + 1)  # log2(i+2) for standard DCG; simplified here

    ideal_dcg = sum(1.0 / (i + 1) for i in range(min(len(relevant_ids), k)))
    return dcg / ideal_dcg if ideal_dcg > 0 else 0.0

In practice, reranking typically improves nDCG@5 by 15–30% over bi-encoder retrieval alone, with the largest gains on queries that require understanding relationships between query terms rather than simple keyword matching. The latency cost is 200–800ms for a batch of 20–30 candidates — acceptable for most search applications where the user is waiting for results anyway.

Prompt engineering for better relevance judgments

The quality of LLM reranking depends heavily on how you frame the scoring task. A generic "rate this document" prompt underperforms a prompt that is explicit about what the query is asking for. Three techniques make a material difference:

Decompose the query before scoring. Before sending candidates to the reranker, extract the key requirements from the query in a separate call. Pass these requirements explicitly into each scoring prompt so the model evaluates against the right criteria:

def decompose_query(query: str) -> list[str]:
    """Extract the core information needs from a query."""
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=256,
        messages=[{
            "role": "user",
            "content": f"""Break this search query into its core information requirements.
List each distinct requirement on its own line.

QUERY: {query}

Requirements (one per line):"""
        }]
    )
    text = response.content[0].text.strip()
    return [line.strip("- ").strip() for line in text.split("\n") if line.strip()]


def rerank_with_requirements(
    query: str,
    candidates: list[SearchResult],
    top_k: int = 5,
) -> list[SearchResult]:
    requirements = decompose_query(query)
    requirements_text = "\n".join(f"- {r}" for r in requirements)

    candidates_text = "\n\n".join(
        f"[{i+1}] ID: {c.id}\n{c.content[:400]}"
        for i, c in enumerate(candidates)
    )

    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=1024,
        messages=[{
            "role": "user",
            "content": f"""Score each document for relevance to this query.

ORIGINAL QUERY: {query}

WHAT THE QUERY IS LOOKING FOR:
{requirements_text}

DOCUMENTS:
{candidates_text}

Score each document 0.0–1.0 based on how many of the requirements it fulfills.
A score of 1.0 means it addresses every requirement fully.
Return JSON: [{{"id": str, "score": float, "fulfilled_requirements": list[str]}}]"""
        }]
    )

    text = response.content[0].text
    if "```" in text:
        text = text.split("```")[1].replace("json", "").strip()

    scores = json.loads(text)
    score_map = {item["id"]: item["score"] for item in scores}
    for c in candidates:
        c.rerank_score = score_map.get(c.id, 0.0)

    return sorted(candidates, key=lambda c: c.rerank_score or 0, reverse=True)[:top_k]

Use the reason field for debugging. The reason string in each scored result is not just documentation — it is a debugging tool. When precision is lower than expected on a test query, read the reasons the model assigned to low-ranked documents. They frequently reveal prompt ambiguities: the model is scoring against a different interpretation of the query than you intended.

Score against document sections, not full documents. Long documents dilute relevance signals. If your documents exceed 1,000 words, split them into sections and score sections independently. The highest-scoring section determines the document's overall rerank score, and you can surface the winning section as a snippet:

def score_document_sections(
    query: str,
    document: SearchResult,
    section_chars: int = 600,
) -> tuple[float, str]:
    """
    Score a long document by its most relevant section.
    Returns (best_score, best_section_text).
    """
    sections = [
        document.content[i:i + section_chars]
        for i in range(0, len(document.content), section_chars - 100)
    ]

    best_score = 0.0
    best_section = sections[0] if sections else ""

    # Score sections in a single call to save round trips
    sections_text = "\n\n".join(
        f"[section-{i+1}]\n{s}" for i, s in enumerate(sections[:6])  # Cap at 6 sections
    )
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": f"""QUERY: {query}

{sections_text}

Score each section 0.0–1.0 for relevance to the query.
Return JSON: [{{"section": "section-N", "score": float}}]"""
        }]
    )
    text = response.content[0].text
    if "```" in text:
        text = text.split("```")[1].replace("json", "").strip()

    section_scores = json.loads(text)
    for item in section_scores:
        if item["score"] > best_score:
            best_score = item["score"]
            idx = int(item["section"].split("-")[1]) - 1
            best_section = sections[idx] if idx < len(sections) else best_section

    return best_score, best_section

This section-level scoring approach also enables passage-level result snippets, which give users better context for why a result was surfaced — a significant UX improvement over showing the document's beginning regardless of where the relevant content sits.

Latency budgeting and when to skip reranking

Reranking adds latency that may not always be acceptable. Design your system with a latency budget and a fallback:

  • Under 100ms required: skip reranking, return bi-encoder results directly
  • 100–500ms budget: rerank the top 10 candidates in a single batch
  • 500ms+ budget: rerank 20–30 candidates in batches of 10

You can also skip reranking when the top bi-encoder result has a very high similarity score (above 0.95 cosine similarity, for example), since that usually indicates a near-exact match that reranking is unlikely to displace. Build the logic as a configurable bypass so you can tune the threshold on real traffic data.

The two-stage pattern with LLM reranking is now the standard approach for applications where search precision directly affects user outcomes — customer support, legal document retrieval, code search, and knowledge base Q&A all benefit substantially from the additional precision, and the latency cost is typically invisible behind the time the user spends reading results.

Hybrid retrieval before reranking

Vector search retrieves semantically similar documents well but struggles with exact keyword matches — searches for product codes, names, or specific technical terms. BM25 keyword search is the opposite: excellent for exact terms, poor at semantic understanding. Combining both before reranking gets the best of both worlds:

from rank_bm25 import BM25Okapi
import re

class HybridRetriever:
    """
    Combine BM25 keyword search and vector similarity for candidate retrieval.
    Merge the two ranked lists using Reciprocal Rank Fusion (RRF) before reranking.
    """

    def __init__(self, documents: dict[str, dict], embedding_fn, vector_index):
        self.documents = documents
        self.embed = embedding_fn
        self.index = vector_index

        # Build BM25 index over tokenised document text
        doc_ids = sorted(documents.keys())
        self._doc_ids = doc_ids
        tokenised = [
            re.findall(r"\w+", documents[d].get("content", "").lower())
            for d in doc_ids
        ]
        self.bm25 = BM25Okapi(tokenised)

    def _rrf_score(self, rank: int, k: int = 60) -> float:
        """Reciprocal Rank Fusion score. k=60 is a standard default."""
        return 1.0 / (k + rank + 1)

    def retrieve(
        self,
        query: str,
        initial_k: int = 30,
        bm25_weight: float = 0.4,
        vector_weight: float = 0.6,
    ) -> list[SearchResult]:
        import numpy as np

        # BM25 retrieval
        query_tokens = re.findall(r"\w+", query.lower())
        bm25_scores = self.bm25.get_scores(query_tokens)
        bm25_ranked = sorted(
            range(len(self._doc_ids)), key=lambda i: bm25_scores[i], reverse=True
        )[:initial_k]

        # Vector retrieval
        query_vec = np.array(self.embed(query), dtype="float32").reshape(1, -1)
        _, vector_indices = self.index.search(query_vec, initial_k)
        vector_ranked = [i for i in vector_indices[0] if i != -1]

        # RRF fusion
        rrf: dict[int, float] = {}
        for rank, idx in enumerate(bm25_ranked):
            rrf[idx] = rrf.get(idx, 0) + bm25_weight * self._rrf_score(rank)
        for rank, idx in enumerate(vector_ranked):
            rrf[idx] = rrf.get(idx, 0) + vector_weight * self._rrf_score(rank)

        top_indices = sorted(rrf, key=lambda i: rrf[i], reverse=True)[:initial_k]

        candidates = []
        for idx in top_indices:
            doc_id = self._doc_ids[idx]
            doc = self.documents[doc_id]
            candidates.append(SearchResult(
                id=doc_id,
                content=doc.get("content", ""),
                metadata=doc.get("metadata", {}),
                initial_score=rrf[idx],
            ))

        return candidates


class HybridTwoStageSearch:
    def __init__(self, documents: dict, embedding_fn, vector_index):
        self.retriever = HybridRetriever(documents, embedding_fn, vector_index)
        self.reranker = CachedReranker(ttl_seconds=1800)

    def search(self, query: str, initial_k: int = 40, final_k: int = 5) -> list[SearchResult]:
        candidates = self.retriever.retrieve(query, initial_k=initial_k)
        return self.reranker.rerank(query, candidates, top_k=final_k)

Reciprocal Rank Fusion is preferred over a simple score average because it is robust to score scale differences between BM25 and cosine similarity — they live on incomparable scales, but their rank positions are directly comparable.

A/B testing reranking in production

Before committing to reranking for all traffic, validate the improvement on your users' actual queries using a controlled experiment. Route a fraction of traffic to the reranked path and measure click-through rate on the top result, which is the most reliable proxy for perceived relevance:

import random
from dataclasses import dataclass
from datetime import datetime

@dataclass
class SearchEvent:
    query: str
    variant: str           # "control" (no reranking) or "treatment" (reranking)
    result_ids: list[str]
    clicked_id: str | None
    timestamp: datetime

def search_with_experiment(
    searcher: TwoStageSearch,
    query: str,
    experiment_fraction: float = 0.2,
    event_logger=None,
) -> list[SearchResult]:
    """
    Run search with optional reranking based on experiment assignment.
    Log events for click-through rate analysis.
    """
    in_treatment = random.random() < experiment_fraction
    results = searcher.search(query, rerank=in_treatment)

    if event_logger:
        event_logger.log(SearchEvent(
            query=query,
            variant="treatment" if in_treatment else "control",
            result_ids=[r.id for r in results],
            clicked_id=None,  # Filled in later when the click is recorded
            timestamp=datetime.utcnow(),
        ))

    return results


def analyse_experiment(events: list[SearchEvent]) -> dict:
    """
    Compute CTR@1 (click-through rate on the top result) per variant.
    """
    by_variant: dict[str, list[SearchEvent]] = {}
    for e in events:
        by_variant.setdefault(e.variant, []).append(e)

    summary = {}
    for variant, variant_events in by_variant.items():
        clicked_top = sum(
            1 for e in variant_events
            if e.clicked_id and e.result_ids and e.clicked_id == e.result_ids[0]
        )
        summary[variant] = {
            "total_queries": len(variant_events),
            "ctr_at_1": clicked_top / len(variant_events) if variant_events else 0,
        }

    return summary

Run the experiment for at least one week to capture variation across query types and user segments. A consistent CTR@1 improvement of 5% or more in the treatment group is a strong signal that the reranking overhead is justified for that traffic slice. Scale up the treatment fraction once you have statistical confidence.

Comments

No comments yet. Be the first!

Sign in to leave a comment.