← learning.n0tls.com
§ A field guide for the rag-research repo

Teaching a small model
to find the right document

Your corpus is static, sub-2,000 documents, and queries arrive as a few plain sentences. That's a small, well-posed retrieval problem — not a language-modeling one. Here's what's actually running under the hood of "better retrieval," and what fits on one GPU.

corpus < 2,000 docs query ≈ 1–3 sentences budget = 10 GB VRAM index rebuilt: rarely
01 the object everything else is built on

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.

02 two towers, shared weights

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.

03 one tower, both inputs at once

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.

04 use both, in order

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.

05 teaching the towers

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):

loss = −log ( esim(q, d⁺) ⁄ Σᵢ esim(q, dᵢ) )

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.

06 what actually fits in 10 GB

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:

modelparamsdimrolefine-tune VRAM*
all-MiniLM-L6-v222M384bi-encoder< 1 GB
bge-small-en-v1.533M384bi-encoder< 1 GB
e5-small-v233M384bi-encoder< 1 GB
gte-base / bge-base-en-v1.5109M768bi-encoder~2 GB
ModernBERT-base149M768bi-encoder~2.5 GB
ms-marco-MiniLM-L-6-v222Mcross-encoder< 1 GB
bge-reranker-base278Mcross-encoder~4 GB
bge-large-en-v1.5 / e5-large-v2335M1024bi-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.

07 serving 2,000 vectors

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.

08 decision cheatsheet
baseline

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.

most likely win

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.

accuracy ceiling

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.

skip for now

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?

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?

3. What does contrastive fine-tuning actually train a bi-encoder to do?

4. Where do this project's (query, doc) training pairs come from, and why does that matter?

5. Why can this project's corpus skip a real vector database like FAISS or HNSW?