Skip to content

Re-Ranking

Vector search and keyword search return a ranked list of documents. But the ranking is approximate: cosine similarity tells you documents are semantically close, not that they answer the question. A document about "JWT security best practices" is semantically close to a query about "JWT expiration handling," but the document that actually explains how to set the exp claim is more relevant. The vector score can't distinguish them.

Re-ranking fixes this. After initial retrieval returns the top 20-50 candidates, a cross-encoder model reads each (query, document) pair and scores actual relevance. The top results get reordered. It costs more per query but the relevance gain is substantial.

Gnome mascot

What you'll learn

  • Bi-encoders (the models that produce embeddings) compare query and document in separate passes; cross-encoders read both together and capture word-level interaction that embeddings miss
  • Re-ranking the top 20-50 results with a cross-encoder costs cents per query and reliably pushes the most relevant document into position 1-3
  • Re-ranking can't fix bad retrieval. If the top-50 results are all irrelevant, no cross-encoder can find a good answer -- fix retrieval first, then re-rank

The problem: approximate ranking isn't enough

Bi-encoder similarity (the cosine distance between two embedding vectors) captures broad topical overlap. It's fast because you can pre-compute document embeddings and compare them with a single vector operation at query time. But the model never sees the query and document side by side. It cannot notice that the query asks "how to revoke a JWT" while the document explains "how to issue a JWT" -- both live in the same semantic neighborhood.

A cross-encoder takes the full (query, document) pair as input and outputs a single relevance score. It reads the interaction: "the query asks about revocation, but this document is about issuance, so relevance is low." This is more expensive -- you run the model on every candidate pair -- but the ranking is substantially better.

Options & when to use each

OptionBest forCosts youWhen to pick
Cohere Rerank APIQuick integration, strong out-of-box performancePaid API, ~$1 per 1,000 searches (top-20); latency of ~200msYou want a managed service and don't want to host a model
mixedbread-ai/mxbai-rerankOpen-source cross-encoder, runs locallyGPU or sufficient CPU RAM; ~200ms per 20 pairs on CPUYou need offline or self-hosted re-ranking
BAAI/bge-reranker-v2-m3Multilingual re-rankingLarger model (~568M params); needs GPU for low latencyYour corpus has documents in multiple languages
No re-rankingLatency-sensitive applications, simple factoid lookupLower relevance for nuanced or comparative queriesVector similarity alone already meets your relevance bar

For this lesson we use the Cohere Rerank API because it requires no model hosting and has a clean Python client. The same pattern works with any cross-encoder.

Build it: re-ranking with Cohere Rerank

Step 1: install and configure

Here is a concrete example demonstrating the implementation details.

bash
pip install cohere

Here is a concrete example demonstrating the implementation details.

python
import os
import cohere

co = cohere.ClientV2(os.environ["COHERE_API_KEY"])

Step 2: retrieve, then re-rank

Here is a concrete example demonstrating the implementation details.

python
def retrieve_and_rerank(
    query: str,
    top_k_retrieval: int = 20,
    top_k_final: int = 5,
) -> list[dict]:
    """
    Retrieve top_k_retrieval documents, then re-rank
    to return only the top_k_final most relevant.
    """
    # Step 1: initial retrieval (hybrid search from previous lesson)
    candidates = hybrid_search(query, top_k=top_k_retrieval)

    if not candidates:
        return []

    # Step 2: re-rank with cross-encoder
    response = co.rerank(
        model="rerank-v3.5",
        query=query,
        documents=[doc["content"] for doc in candidates],
        top_n=top_k_final,
    )

    # Step 3: map scores back to the original documents
    reranked = []
    for result in response.results:
        doc = candidates[result.index]
        reranked.append({
            "id": doc["id"],
            "content": doc["content"],
            "relevance_score": result.relevance_score,
            "original_rrf_score": doc["rrf_score"],
        })

    return reranked

The relevance_score from Cohere is a float between 0 and 1. Scores above 0.5 generally indicate the document addresses the query. Scores below 0.3 are usually off-topic. You can use these thresholds later for failure handling.

Step 3: compare before and after

Here is a concrete example demonstrating the implementation details.

python
query = "How do I set the expiration time on a JWT token?"

# Before re-ranking
candidates = hybrid_search(query, top_k=5)
print("Before re-ranking:")
for i, doc in enumerate(candidates):
    print(f"  {i+1}. (RRF={doc['rrf_score']:.3f}) {doc['content'][:100]}")

# After re-ranking
reranked = retrieve_and_rerank(query, top_k_retrieval=20, top_k_final=5)
print("\nAfter re-ranking:")
for i, doc in enumerate(reranked):
    print(f"  {i+1}. (score={doc['relevance_score']:.3f}) {doc['content'][:100]}")

You'll typically see the most relevant document move from position 2-5 up to position 1, and at least one document that is semantically close but factually wrong drop out of the top 5 entirely.

The cost-quality tradeoff

Each re-ranking call processes top_k_retrieval document pairs. The Cohere Rerank API charges per search unit (roughly per 100 documents processed). For a typical setup with top_k_retrieval=20:

Searches per dayAPI cost (approx)Latency added
100$0.10/day~200ms
10,000$10/day~200ms per query
100,000$100/day~200ms per query (parallelize)

If cost is a concern, apply re-ranking selectively. For a query like "What is JWT?" the top vector result is probably already correct. For "How do I handle JWT expiration when the user's clock is skewed?" the nuance warrants re-ranking.

python
def should_rerank(query: str) -> bool:
    """Simple heuristic: re-rank longer, more specific queries."""
    return len(query.split()) > 5

results = hybrid_search(query, top_k=5)
if should_rerank(query):
    results = retrieve_and_rerank(query)

What goes wrong

MistakeHow you notice itThe fix
Re-ranking top-50 when retrieval already missedReranked scores are all below 0.3, even the "best" document is irrelevantFix retrieval first. Check that your chunks actually contain the answer. Use evaluation metrics (next lesson) to measure retrieval quality before adding re-ranking
Re-ranking adds unacceptable latencyUsers complain about slow responses; p95 latency jumps by 200-500msRe-rank fewer candidates (top 10 instead of top 50). Use async re-ranking so the first few results stream while the rest re-rank. Or skip re-ranking for simple queries
Cross-encoder favors longer documentsConsistently higher relevance scores for long chunksCross-encoders can exhibit length bias. Normalize by splitting long chunks or using a model known to be length-calibrated like mxbai-rerank
API key or quota exhaustedCohereError: invalid api token or 429 rate limitCache re-ranking results for repeated queries. Batch re-ranking calls to reduce API overhead. Set up usage alerts
Re-ranking the same query repeatedlyYou see the same re-ranking API calls in logsCache re-ranked results by query hash. A simple functools.lru_cache on retrieve_and_rerank eliminates duplicate API calls

Confirm it worked

Here is a concrete example demonstrating the implementation details.

python
# Test with a query that requires fine-grained relevance discrimination
query = "How do I revoke a JWT before it expires?"

# Get re-ranked results
results = retrieve_and_rerank(query, top_k_retrieval=20, top_k_final=5)

# Verify the top result is actually about revocation, not issuance or validation
assert len(results) > 0, "No results returned"
top_content = results[0]["content"].lower()
assert any(term in top_content for term in ["revok", "revoc", "blacklist", "invalid"]), \
    f"Top result does not appear to be about revocation: {top_content[:200]}"

print("Top result relevance score:", results[0]["relevance_score"])
print("Top result snippet:", top_content[:200])
# Expected: relevance_score > 0.5, content discusses revocation or blacklisting

Next: Evaluating Retrieval