Every retrieval method is a way of drawing that picture
Grep draws it with exact substrings — a query and a document either share a token or they don't. Everything upgrade from there is really just a better way of placing the query point and the document points so that relevant pairs end up close together and irrelevant ones end up far apart, under some distance you can compute fast.
The "small model" you train is the function that does the placing: text in, a fixed-length vector out. Train it well on your corpus's vocabulary and query style, and the geometry above does the retrieving — no LLM reasoning required at query time.
The bi-encoder (a.k.a. dense retriever, two-tower model)
One encoder, run twice: once over every document — offline, once, ahead of time —
and once over the incoming query, online, per request. Each pass produces a
vector; relevance is just the dot product (or cosine) between the two vectors. This is the
architecture behind sentence-transformers, OpenAI/Cohere embedding endpoints,
and models like bge-small, e5-small, gte-small.
The entire point of the two-tower shape is that the expensive half — encoding 2,000 documents — happens exactly once, whenever the corpus changes. At query time you run the encoder a single time and compare against a matrix you already have sitting in memory. This is why bi-encoders scale to query time, and it's the architecture you want if you're serving anything interactively.
The cross-encoder
Instead of embedding query and document separately, concatenate them into one input —
[CLS] query [SEP] document [SEP] — and let the model attend across both at
once, outputting a single relevance score directly. Because query tokens and document tokens
interact inside every attention layer, cross-encoders are noticeably more accurate than
bi-encoders at judging "does this document actually answer this query." The catch is in the
name: there's no vector to precompute. You pay the full forward pass for every candidate,
every time.
ms-marco-MiniLM-L-6-v2 reranker, 22M params
Retrieve with the bi-encoder, rerank with the cross-encoder
You don't have to choose. Nearly every production retrieval system is this exact pipeline: the cheap bi-encoder narrows 2,000 documents down to a shortlist — 20, 50 candidates — by cosine distance, and the expensive-but-accurate cross-encoder only has to score that shortlist. You get the cross-encoder's judgment quality at a fraction of its cost, because it never sees the 1,980 documents that were obviously irrelevant.
Contrastive fine-tuning
A pretrained embedding model already places text in a reasonable general-purpose space. Fine-tuning
on your corpus nudges that space so that your queries land near your matching
documents specifically — pulling the true match closer, pushing everything else away, one training
step at a time. The loss almost every bi-encoder trainer uses is InfoNCE (in
sentence-transformers, MultipleNegativesRankingLoss):
Read it as: given a query, its true document should score higher than every other document in the batch under the similarity function. Batches double as negative sampling — with a batch of 32 (query, doc) pairs, each query gets 31 free negatives for the price of one forward pass.
Where the (query, doc) pairs come from
With ~2,000 documents you almost certainly don't have enough real user queries to
train on directly — which is exactly the gap synthetic data closes: prompt an LLM once per
document ("write 2–3 realistic questions this document answers"), and you have a training
set the size of your corpus × 2–3, generated in one batch job, for the cost of a few hundred
completions. It's the same idea as the eval set already in this repo (eval/queries.json
+ eval/ground_truth.json) — except here the questions become training signal
instead of scoring signal, so keep the two sets disjoint or your eval will just measure
memorization.
Short answer: all of these, comfortably
At a few sentences per query and a few paragraphs per document, sequence lengths stay under 256 tokens — the memory-hungry part of a transformer (attention, which grows with sequence length squared) never gets a chance to matter. What dominates VRAM at this scale is just parameter count × Adam's bookkeeping. Rough order-of-magnitude, not benchmarked:
| model | params | dim | role | fine-tune VRAM* |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 22M | 384 | bi-encoder | < 1 GB |
| bge-small-en-v1.5 | 33M | 384 | bi-encoder | < 1 GB |
| e5-small-v2 | 33M | 384 | bi-encoder | < 1 GB |
| gte-base / bge-base-en-v1.5 | 109M | 768 | bi-encoder | ~2 GB |
| ModernBERT-base | 149M | 768 | bi-encoder | ~2.5 GB |
| ms-marco-MiniLM-L-6-v2 | 22M | — | cross-encoder | < 1 GB |
| bge-reranker-base | 278M | — | cross-encoder | ~4 GB |
| bge-large-en-v1.5 / e5-large-v2 | 335M | 1024 | bi-encoder | ~5–6 GB |
*mixed-precision fine-tuning, batch size 16–32, sequence length ≤ 256 — weights + gradients + Adam moments + activations. Leaves headroom on a 10 GB card even at the largest row.
The realistic constraint at your corpus size isn't compute — it's that a 335M-parameter model
has far more capacity than 2,000 documents' worth of signal can steer. A small model
(MiniLM/bge-small class) fine-tuned on good synthetic pairs will very
likely outperform a large model fine-tuned on the same data, just because it overfits less.
Start small; scale up only if the eval set says you're underfitting.
Skip the vector database
Approximate nearest-neighbor indexes (FAISS, HNSW, usearch) exist to make search over millions of vectors sub-linear. At 2,000 documents, brute-force cosine similarity — a single 2,000×384 matrix multiplied against one query vector — is a few hundred microseconds in NumPy, exact, and requires no index-building step at all. Reach for ANN only if the corpus grows past roughly 100k–1M documents; below that it's solving a problem you don't have yet.
Pretrained bi-encoder, zero fine-tuning. Run today against your existing eval set. Costs nothing to try and tells you how far off-the-shelf embeddings already are on your corpus's vocabulary.
Fine-tuned small bi-encoder (MiniLM/bge-small class) on synthetic (query, doc) pairs. Cheap to train, cheap to serve, and matched to a corpus this size.
Bi-encoder retrieve → cross-encoder rerank. Add this once the bi-encoder alone plateaus — the reranker only has to be right about the top ~20 candidates.
ANN indexing, distributed serving, >300M-param models. All solve problems that show up at a scale well past 2,000 static documents.
Check your understanding— optional, 5 questions
1. What's the core structural difference between a bi-encoder and a cross-encoder?
A bi-encoder's two independent vectors are exactly what makes precomputing and caching document embeddings possible; a cross-encoder's joint pass means it must be re-run for every query/doc pair.
2. Why does retrieve-then-rerank use a bi-encoder first and a cross-encoder second, instead of just running the cross-encoder on everything?
The reranker only has to be right about the bi-encoder's top ~20 candidates, not the whole corpus — that's what keeps the expensive joint-scoring step affordable.
3. What does contrastive fine-tuning actually train a bi-encoder to do?
This is the InfoNCE-style contrastive objective: every training step is a multiple-choice problem, and the gradient nudges the true pair closer while nudging negatives apart.
4. Where do this project's (query, doc) training pairs come from, and why does that matter?
Without that leakage check, a high eval score could just mean the model memorized the 45 test questions, not that it learned the corpus.
5. Why can this project's corpus skip a real vector database like FAISS or HNSW?
A (45 × 384) @ (384 × N) matrix multiply is microseconds for a corpus this size — reaching for FAISS/HNSW only pays off once brute force stops being instant.