Why Vector Search Alone Isn’t Enough — And How the Retrieve → Re-rank Pipeline Fixes It

Posted on by

Introduction — The Search Engine That Gets Its Priorities Wrong

Imagine you’re looking for a freelance contract.

You type:

React Senior Paris

Your search engine—powered by embeddings, modern, supposedly intelligent—returns this:

1. React Junior Lyon          ← ??
2. React Senior Brussels      ← close, but not Paris
3. React Fullstack Paris      ← Paris, but not Senior
4. React Senior Paris         ← exactly what you wanted — in 4th place

You look at the results and wonder what’s going on.

The engine is using vector embeddings. It knows that « React » is related to frontend development, that « Paris » is a city, and that « Senior » implies experience. Conceptually, it understands the domain.

So why is the best result only fourth?

This isn’t a bug. It’s a fundamental architectural limitation of Dense Retrieval.

To understand—and fix—it, we first need to understand how embeddings actually work, where their limitations lie, and why modern RAG systems almost always rely on a two-stage pipeline:

Retrieve → Re-rank


1. Dense Retrieval — The Bi-Encoder

The Principle Behind Embeddings

An embedding is simply the transformation of a piece of text into a vector of numbers.

"React Senior Paris" → [0.23, -0.87, 0.41, 0.66, ...]
                        (384 or 768 dimensions)

This vector captures the overall meaning of the text. Two semantically similar texts produce vectors that are close to each other in vector space.

That’s the promise of embeddings: capturing meaning, not just words.

The Bi-Encoder

A Bi-Encoder is the model that produces these embeddings.

It encodes the query and each document independently:

          QUERY                       DOCUMENT
            |                             |
    ┌────────────────┐          ┌────────────────┐
    │  Transformer   │          │  Transformer   │
    │  (Bi-Encoder)  │          │  (Bi-Encoder)  │
    └────────────────┘          └────────────────┘
            |                             |
         Vector A                      Vector B
            |                             |
            └──────────┬──────────────────┘
                       |
               Cosine Similarity
                       |
                     Score

Both texts go through the same model, but completely independently.

Why It’s Fast

This is the key to everything.

Document embeddings are computed only once, during indexing. They’re then stored in a Vector Database such as Qdrant, Pinecone, or Weaviate.

At query time, only the query embedding is computed in real time. That vector is then compared against all the stored vectors.

INDEXING (one-time operation)
─────────────────────────────────────────────
Document 1 → Embedding → stored
Document 2 → Embedding → stored
Document 3 → Embedding → stored
...
10 million documents → 10M stored vectors


QUERY (real time)
─────────────────────────────────────────────
"React Senior Paris"
         |
    Bi-Encoder
         |
      Vector Q
         |
    Vector Search
 (ANN - Approximate Nearest Neighbors)
         |
    Top 50 results
   in a few milliseconds

Even with 10 million documents, vector search can retrieve the top 50 candidates in just a few milliseconds. That’s what makes Vector Databases practical at scale.

Cosine Similarity — The Intuition

Cosine Similarity measures the angle between two vectors, not their distance.

The intuition is simple: texts discussing the same topic tend to point in roughly the same direction in vector space. Their length doesn’t matter—the direction does.




2. The Limitations of Dense Retrieval — The Real Problem

This is where things become more complicated.

Semantics Are Global, Not Precise

A Bi-Encoder captures the general meaning of a text. It understands topics, domains, and overall context.

But it does not compare texts word by word.

As a result, semantically similar texts can end up with similar embeddings even when they differ on the details that actually matter.

A Concrete Example — Job Search

Query:

React Senior Paris

Documents:

A: "React Junior Lyon"
B: "React Senior Brussels"
C: "React Fullstack Paris"
D: "React Senior Paris"

From the embedding model’s perspective:

  • « React » appears in all four documents.
  • « Senior » versus « Junior » is only a subtle semantic distinction.
  • « Paris », « Lyon », and « Brussels » are all city names and therefore semantically related.

The model sees four frontend job offers in similar domains. It does not ask:

« Does this document exactly match the query? »

Instead, it asks:

« Do these two vectors point in roughly the same direction? »

That’s a fundamentally different question.

Other Domains Where This Problem Appears

Real Estate

Query: "3-bedroom apartment Paris 11th district €500,000"

Results:
- "3-bedroom apartment Paris 12th district €520,000"
- "3-bedroom apartment Vincennes €480,000"
- "4-bedroom apartment Paris 11th district €500,000"
- "3-bedroom apartment Paris 11th district €500,000" ← exact match, possibly ranked fourth

E-commerce

Query: "Women's red running shoes size 40"

Results:
- Men's running shoes size 40
- Women's red trail shoes size 40
- Women's red running shoes size 41
- Women's red running shoes size 40 ← exact match, possibly ranked fourth

Document Search

Query: "Full-time permanent Python developer contract"

Results:
- Fixed-term full-time Python developer contract
- Permanent part-time Python developer contract
- Full-time permanent Java developer contract
- Full-time permanent Python developer contract ← exact match, possibly ranked fourth

The Key Takeaway

Vector similarity is an approximation of general meaning.

It’s excellent at quickly finding documents that talk about the right topic.

It’s much worse at distinguishing documents that discuss the same topic but differ on important details.

It’s an approximation of relevance—not relevance itself.


3. The Cross-Encoder — Reading Both Texts Together

The Fundamental Difference

A Bi-Encoder encodes the query and the document separately.

A Cross-Encoder reads them together.

BI-ENCODER
──────────────────────────────────────────────
Query      →  Transformer  →  Vector A
                                         →  Cosine Similarity  →  Score
Document   →  Transformer  →  Vector B


CROSS-ENCODER
──────────────────────────────────────────────
[Query + Document]
          |
     Transformer
          |
   Relevance Score (0 → 1)

This isn’t an implementation detail. It’s a completely different paradigm.

Why Seeing Both Texts Together Changes Everything

With a Bi-Encoder:

"React Senior Paris"   →   vector [0.23, -0.87, 0.41, ...]
"React Junior Lyon"    →   vector [0.21, -0.84, 0.39, ...]

These vectors are close to each other.

The model never had the opportunity to compare the two texts directly. It simply encoded each one independently.

With a Cross-Encoder:

Input: "[CLS] React Senior Paris [SEP] React Junior Lyon [SEP]"
         |
  Transformer with Attention
         |
   Score: 0.31 (low relevance)
Input: "[CLS] React Senior Paris [SEP] React Senior Paris [SEP]"
         |
  Transformer with Attention
         |
   Score: 0.97 (high relevance)

The model reads the query in direct relation to the document. Every word in the query can interact with every word in the document.

The Role of Attention — The Intuition

The Attention mechanism (Transformer Self-Attention) allows every word in a text to « look at » every other word in order to understand its context.

In a Cross-Encoder, this mechanism operates on both texts combined.

Query:      React  Senior  Paris
               ↕      ↕      ↕
Document:   React  Junior  Lyon

The model can directly compare:

  • « Senior » in the query with « Junior » in the document → they are different.
  • « Paris » in the query with « Lyon » in the document → they are different.

It doesn’t compare global vectors anymore. It analyzes the relationships between words across both texts.

That’s why Cross-Encoders are dramatically more accurate.


4. Why Cross-Encoders Are Slow

The Problem with On-the-Fly Computation

With a Bi-Encoder, document embeddings are precomputed.

With a Cross-Encoder, nothing can be precomputed.

The score depends on the pair (query, document). Until the query is known, no computation can be performed.

For every new query, you need to run a full Transformer inference for every document.

The Cost Explodes

100 documents       →      100 Transformer inferences      → ~1 second
1,000 documents     →    1,000 Transformer inferences      → ~10 seconds
100,000 documents   →  100,000 Transformer inferences      → ~17 minutes
10,000,000 docs     →   10M Transformer inferences         → IMPOSSIBLE

Transformer inference isn’t cheap. A standard Cross-Encoder such as BERT-base takes roughly 5–10 ms per query-document pair on a GPU.

For 10 million documents, that’s 14 to 28 hours per query.

Completely impractical.


5. The Modern Solution — The Retrieve → Re-rank Pipeline

The Best of Both Worlds

The solution is elegant: use both models in sequence.

10,000,000 documents
         |
    ─────────────────
     Dense Retrieval
       (Bi-Encoder)
    ─────────────────
         |
     Top 50 candidates
      (a few ms)
         |
    ─────────────────
        Re-ranking
     (Cross-Encoder)
    ─────────────────
         |
      Top 5 results
     (high precision)
         |
          LLM
         |
     Final answer

Why It Works

Dense Retrieval solves the scalability problem: it quickly finds the 50 most likely relevant documents out of millions.

The Cross-Encoder solves the precision problem: it re-ranks those 50 documents using a much finer understanding of actual relevance.

STEP 1 — Dense Retrieval
────────────────────────────────────────────────────
Goal        : "Find plausible candidates"
Speed       : Milliseconds
Precision   : Approximate
Scale        : 10 million documents → Top 50
Model        : Bi-Encoder (precomputed vectors)


STEP 2 — Re-ranking
────────────────────────────────────────────────────
Goal        : "Rank candidates precisely"
Speed       : ~250 ms for 50 documents
Precision   : High
Scale        : 50 documents → Top 5
Model        : Cross-Encoder (on-the-fly inference)

Running 50 Cross-Encoder inferences takes around 250 to 500 ms, which is perfectly acceptable.

The Logic Behind the Pipeline

Dense Retrieval doesn’t need to find the right answers.

It needs to avoid missing them.

Its job is to ensure that the top 50 candidates contain the truly relevant documents, even if their internal ranking isn’t perfect.

The Cross-Encoder then takes care of putting those candidates in the right order.


6. A Real-World Example — mission-radar-ai

Imagine a platform that analyzes LinkedIn job opportunities and ranks them according to a freelancer’s profile.

The Profile

Freelance Developer
- Skills       : Python, FastAPI, LangChain, React
- Experience   : Senior (10 years)
- Preference   : Fully remote
- Contract type: Engagements longer than 6 months

The Available Opportunities

Opportunity A: "Senior Python Developer - Remote - 6 months - Paris"
Opportunity B: "Junior Python Developer - On-site - 3 months - Lyon"
Opportunity C: "Python Architect - Remote - 12 months - Toulouse"
Opportunity D: "Senior React Developer - Remote - 6 months - Bordeaux"
Opportunity E: "Senior Python Developer - Remote - 6 months - Paris"

Step 1 — Dense Retrieval

The profile is transformed into an embedding.

Vector search returns the five opportunities, probably in this order:

1. Opportunity E : score 0.94 (almost identical)
2. Opportunity A : score 0.93 (very close)
3. Opportunity C : score 0.91 (Python, Remote, but Architect)
4. Opportunity D : score 0.88 (Senior, Remote, but React)
5. Opportunity B : score 0.81 (Python, but Junior + On-site)

The ranking is already reasonable, but:

  • Opportunity C (Architect, 12 months) is probably a better fit than Opportunity D.
  • Opportunity B (Junior) shouldn’t really be there.

Step 2 — Cross-Encoder

The Cross-Encoder re-evaluates every (profile, opportunity) pair:

CrossEncoder("[full profile] [SEP] Opportunity E") → 0.97
CrossEncoder("[full profile] [SEP] Opportunity A") → 0.94
CrossEncoder("[full profile] [SEP] Opportunity C") → 0.89
CrossEncoder("[full profile] [SEP] Opportunity D") → 0.72
CrossEncoder("[full profile] [SEP] Opportunity B") → 0.18

Final ranking:

1. Opportunity E (0.97) — Senior Python, Remote, 6 months, Paris ✓
2. Opportunity A (0.94) — Senior Python, Remote, 6 months, Paris ✓
3. Opportunity C (0.89) — Python Architect, Remote, 12 months ✓
4. Opportunity D (0.72) — Senior React, Remote ~
5. Opportunity B (0.18) — Junior Python, On-site ✗

The Cross-Encoder correctly identified that Opportunity B is a poor match because it’s both junior-level and on-site, giving it a score of only 0.18.

These top results can then be sent to an LLM to generate a structured response: analysis, strengths, weaknesses, and recommendations.


7. When This Pipeline Really Makes a Difference

This pipeline is particularly valuable when:

Selection Criteria Are Precise and Conjunctive

Job search, real estate, and e-commerce often involve multiple filters (location + seniority + contract type).

If the result needs to satisfy several criteria simultaneously, Dense Retrieval alone is usually not enough.

The Corpus Is Large and Heterogeneous

Beyond 100,000 documents, the quality of pure Dense Retrieval tends to degrade.

Re-ranking compensates for this loss of precision.

The Query Is Nuanced

« An article about the risks of Deep Learning, not its benefits. »

A Bi-Encoder may struggle with the negation.

A Cross-Encoder analyzes the relationship between the query and the document and is much better at capturing this kind of nuance.

The Cost of a Bad Answer Is High

Medical, legal, or financial recommendation systems often require a higher level of precision.

In these cases, the additional latency introduced by a Cross-Encoder is usually worth it.


8. Recommended Resources

Sentence Transformers — Retrieve & Re-rank

https://sbert.net/examples/applications/retrieve_rerank

The reference implementation.

The Sentence Transformers repository provides a complete Retrieve → Re-rank pipeline with pre-trained models. The code is easy to follow and the examples are straightforward.

Read this if: you want to implement the pipeline quickly in Python.

The documentation also recommends models for each stage, including the popular ms-marco-MiniLM rerankers.


Sentence Transformers — Cross Encoders

https://sbert.net/docs/package_reference/cross_encoder

The technical documentation for Cross-Encoders in the Sentence Transformers library.

It shows how to load a model, score query-document pairs, and integrate a Cross-Encoder into an existing pipeline.

Read this if: you’ve understood the theory and want to move on to implementation.


Hugging Face — Training Rerankers

https://huggingface.co/learn/cookbook/en/training_rerankers

A practical guide to fine-tuning your own Cross-Encoder for a specific domain.

It explains the required training data (query-document pairs with relevance labels) and walks through the entire process.

Read this if: the generic models are no longer sufficient and you need a domain-specific reranker for job listings, technical documentation, legal documents, and so on.


Weaviate — Cross Encoders as Rerankers

https://weaviate.io/blog/cross-encoders-as-rerankers

A great article explaining how re-ranking can be integrated directly into a Vector Database.

Read this if: you’re working with Weaviate or simply want a better understanding of how modern vector databases increasingly support reranking natively.


Comments

Leave a comment

Your email address will not be published.
Required fields are marked *