Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Every retrieval method since Chapter 2 has worked the same way: embed the question, embed each candidate independently, compare the two fixed vectors. That’s a bi-encoder, “bi” because the question and the candidate never interact, they’re each squeezed into a vector on their own, then compared afterward. A cross-encoder does something more expensive: it looks at the question and a specific candidate together, as one input, and can notice things a bi-encoder’s after-the-fact comparison misses.

This chapter builds a small, honest version of that idea from scratch, and the real architectural pattern that makes it practical: re-rank a short list instead of scoring everything the expensive way.

Chapter 8 of 10 · Better retrieval

Where we left off: Every retrieval method since Chapter 2 has worked the same way: embed the question, embed each candidate separately, compare the two fixed vectors afterward.

What we fix now: Build a small, honest approximation of interaction-based re-ranking, and the real architectural pattern that makes it practical: re-rank a short list instead of scoring everything the expensive way.

By the end, you can:

  • explain the difference between a bi-encoder and a cross-encoder, and why one is precomputable and the other isn’t

  • build a late-interaction score from scratch and see it fix a dilution failure a bi-encoder can’t

  • combine cheap and expensive retrieval into a two-stage pipeline

Source

Same wrong answer as Chapter 5: Vacation Policy first, Sick Leave Policy second. But notice the correct answer isn’t missing, it’s sitting right there at rank 2. Averaging every word in the question and every word in each candidate into one vector each washes out exactly the one word that mattered most: “sick.” It’s still in there somewhere, contributing to the average, just outweighed by everything else.

Looking at words, not just averages

Worth being precise before building anything: what follows is not a cross-encoder. A real cross-encoder performs joint neural encoding of the question and a candidate together, one forward pass through a trained network, and that’s what the Colab bonus at the end of this chapter actually runs. The lightweight method built below demonstrates the broader motivation for interaction-based re-ranking using a late interaction style score instead (the idea behind models like ColBERT), built from nothing more than the word vectors already on hand: keep each word’s vector separate instead of collapsing the whole question into one, and for every question word, find its single best-matching word anywhere in the candidate, then average those best-matches.

Sick Leave Policy wins now. The word “sick” in the question finds its best match against the word “sick” inside the Sick Leave Policy section, a near-perfect single-word match, and that strong signal survives instead of getting averaged away with everything else. Chapters 5 and 7 already fixed this same mismatch two different ways (citations that reveal it, BM25’s exact keyword match), this is a third, genuinely different mechanism: not exact words, not manual metadata, just refusing to blur word-level detail together too early.

Before · bi-encoder (averaging)

Same question: “how many sick days can be carried over to next year?”

Top result: Vacation Policy at 0.930, the wrong section. The word “sick” is in there somewhere, just outweighed once averaged with everything else in the question.

After · late interaction (word-by-word)

Same question: “how many sick days can be carried over to next year?”

Top result: Sick Leave Policy at 0.933, correct. “Sick” finds its own near-perfect match inside the Sick Leave section instead of being blurred away.

Why this isn’t the new default

If this works better, why not always compare word-by-word instead of averaging? Because it’s a lot more expensive. Averaging lets you precompute one vector per candidate once, ever, and every future query just compares against that cached vector. Word-by-word comparison has to recompute something for every single question against every single candidate, every time.

Already slower by a wide margin, at just seven candidates. Scale that up to thousands or millions of chunks (Chapter 6’s problem, back again) and running the expensive comparison against everything stops being realistic. The practical answer: use the cheap method to narrow the field first, and only spend the expensive comparison on a short list.

Two-stage retrieval in miniature: cheap and broad, then expensive and narrow. The final answer is correct, and the expensive step only ever ran three times instead of seven, a small saving here, a decisive one once “seven” becomes “seven million.”

Bonus (Colab only): a real cross-encoder

The hand-built late_interaction_score above is a real, honest technique, but a real cross-encoder is a neural network trained specifically to score a (question, candidate) pair, and it’s noticeably better in practice. This cell runs an actual one on the same shortlist:

Open In Colab

Quick check

Before moving on, answer for yourself (no peeking ahead):

  1. In your own words, what’s the actual difference between a bi-encoder and a cross-encoder, and why does that difference make one precomputable and the other not?

Check your reasoning ▸

A bi-encoder embeds the question and each candidate completely separately, so every candidate’s vector can be computed once, in advance, and just compared at query time. A cross-encoder looks at the question and a specific candidate together as one joint input, so the comparison itself depends on the question, meaning nothing about it can be precomputed ahead of time.

  1. Re-ranking only helps if the right answer is already somewhere in the shortlist. What would you expect to happen if K=1 and the bi-encoder’s single top pick is wrong?

Check your reasoning ▸

If the bi-encoder’s single top pick is wrong, re-ranking can’t help at all, there’s nothing else in the shortlist to promote instead. This shows re-ranking only fixes ordering within a shortlist that already contains the right answer; it can’t recover an answer that never made the list in the first place.

  1. This chapter’s example, Chapter 7’s example, and Chapter 5’s example all involve retrieval getting confused between Vacation Policy and Sick Leave Policy or similar near-neighbors. Why do you think that specific confusion keeps coming back across so many different fixes, rather than being solved once and for all?

Check your reasoning ▸

A strong answer recognizes each fix addresses a different mechanism, not the same root cause: citations make the mistake visible without preventing it, hybrid search helps only when there’s a distinctive exact token to latch onto, and re-ranking helps only when the shortlist already contains the right answer. None of them changes the underlying embedding model’s tendency to blend similar-vocabulary sections together, so the confusion resurfaces wherever that root cause is still in play.

  1. If you had to combine Chapter 7’s hybrid search and this chapter’s re-ranking into one pipeline, where would each one go, and in what order? Why?

Check your reasoning ▸

The natural order is hybrid search first, to build a broad, high-recall shortlist cheaply by combining keyword and embedding signal, then re-ranking second, to carefully reorder just that short list with the more expensive word-level comparison. Running it the other way, expensive re-ranking over the full candidate set before narrowing, would throw away exactly the speed benefit the two-stage pattern is designed to provide.

  1. Chapter 7’s “4477” question failed because the word never existed in the embedding vocabulary at all. Would this chapter’s late-interaction re-ranking fix that specific failure? Why or why not?

Check your reasoning ▸

No, and the chapter says so directly: late interaction still relies on word vectors that exist in the vocabulary, comparing word-by-word instead of averaging doesn’t help if one side has no vector to compare in the first place. This is a good example of a fix with a specific, bounded scope: re-ranking fixes dilution, not vocabulary gaps, and confusing the two would mean expecting a fix to work somewhere it fundamentally can’t.


You can now: explain the difference between a bi-encoder and a cross-encoder, and build a two-stage pipeline that cheaply narrows the field before spending an expensive comparison on just the shortlist.

But one problem remains: eight chapters, four retrieval techniques, and every single case for “this one is better” has been one or two hand-picked examples. That’s anecdote, not measurement.

Next → Chapter 9: Evaluation