Why ChatGPT Can Find Your Lost Document but Ctrl+F Can’t
A friendly introduction to embedding models. Series: Fine-Tuning Embedding Models for Domain-Specific Retrieval Part 1 of 4
Why ChatGPT Can Find Your Lost Document but Ctrl+F Can’t
A friendly introduction to embedding models. *Series: Fine-Tuning Embedding Models for Domain-Specific Retrieval Part 1 of 4*
Photo by Growtika on Unsplash
Imagine you’ve got 50,000 research papers on your laptop and you need the one that explains how Adam handles sparse gradients. You hit Ctrl+F and type “sparse gradients.” Nothing. You try “AdaGrad.” Three irrelevant papers pop up. But the real answer is on page 7 of a paper that talks about “infrequent feature updates” — words you’d never have thought to search for.
This is the fundamental problem with keyword search: it requires you to guess the exact words the author used.
Embedding models solve this by translating both your query and every document into points in a high-dimensional space. Documents that mean the same thing end up close together — even if they share zero words. Search becomes geometry.
In this 4-part series, we’ll go from the very basics (what is an embedding, really?) all the way to fine-tuning an 8-billion-parameter model on your own documents. By the end, you’ll understand every moving part of a modern retrieval system.
Let’s start at the beginning.
1. The Problem with Keywords
Traditional search algorithms like BM25 and TF-IDF treat text as a bag of words. They count term frequencies, weight rare words more heavily, and rank documents by how many of your query’s words appear in them. They work remarkably well — but they have one fatal flaw: they don’t understand language.
Consider these two sentences:
- “What optimizer converges faster for sparse gradients?”
- “AdaGrad and Adam handle infrequent features efficiently.”
To a keyword search, these have zero overlap. To you and me, the second sentence is a perfect answer to the first.
The fix is to map text into a numerical space where meaning, not vocabulary, determines distance. That’s what embedding models do.
2. The First Big Idea: Word2Vec
The modern story of embeddings starts in 2013 with Word2Vec. Its core insight was the distributional hypothesis:
Words that appear in similar contexts tend to have similar meanings.
Word2Vec trained a shallow neural network on a simple task: given the surrounding words, predict the middle word (or vice versa). After training, the network’s weight matrix had a magical property — each row was an embedding for a vocabulary word, and these embeddings had remarkable algebraic structure:
vec("king") - vec("man") + vec("woman") ≈ vec("queen")
vec("Paris") - vec("France") + vec("Italy") ≈ vec("Rome")
You could literally do math on words.
But Word2Vec had three killer limitations:
- One vector per word, ever. “Bank” (financial) and “bank” (river) shared the same vector.
- Closed vocabulary. New words had no representation.
- Word-level only. No way to embed a sentence or a paragraph.
To embed a sentence, people simply averaged the word vectors. It… kind of worked. But you lost word order, negation, and nuance.
3. The Transformer Revolution
In 2017, transformers changed everything. The big idea was self-attention: instead of processing words one at a time, look at the entire sequence at once and let each word “pay attention” to every other word.
Attention(Q, K, V) = softmax(QK^T / √d_k) · V
Don’t panic about the math — here’s the intuition. Each word gets to ask every other word “are you relevant to me?” and weight its understanding accordingly. The word “bank” sitting next to “river” gets a different representation than “bank” sitting next to “account.”
BERT (2018) applied this to language understanding. Suddenly, “bank” had contextual embeddings — its vector changed based on what surrounded it.
But here’s the catch: vanilla BERT was bad at giving you one vector per sentence. People tried using the [CLS] token's representation, or averaging all token vectors. The result was an anisotropic embedding space — all sentences clustered together, making them indistinguishable for retrieval.
You couldn’t use raw BERT for search. You had to fine-tune it.
4. Sentence-BERT: The Breakthrough
In 2019, Reimers and Gurevych introduced Sentence-BERT (SBERT) — a way to fine-tune BERT specifically for producing sentence embeddings.
Their approach was beautifully simple: take two BERT models that share weights (“siamese network”), feed them two sentences, pool the outputs, and train them so that similar sentences produce similar vectors.
┌─────────────┐ ┌─────────────┐
│ Sentence A │ │ Sentence B │
└──────┬──────┘ └──────┬──────┘
│ │
┌────▼────┐ ┌────▼────┐
│ BERT │ │ BERT │ (shared weights)
└────┬────┘ └────┬────┘
│ │
[mean pool] [mean pool]
│ │
u v
└────────┬─────────┘
cosine similarity
After training, you could embed any sentence into a meaningful vector. Sentence-level semantic search was finally a solved problem.
This unlocked a critical efficiency: instead of comparing all pairs of sentences through BERT (O(n²) compute), you could embed each sentence once and then do cheap vector math.
5. Bi-Encoders vs Cross-Encoders: The Speed/Accuracy Tradeoff
Here’s a design choice that underpins every modern retrieval system. There are two ways to use a transformer for semantic similarity, and the tradeoff between them shapes everything that follows.
The Bi-Encoder (a.k.a. Dual Encoder, Dense Retriever)
query ──► Encoder ──► q_vec ──┐
├── dot(q_vec, d_vec) = score
doc ──► Encoder ──► d_vec ──┘
The query and document are encoded independently into separate vectors. Then you compute a simple dot product or cosine similarity.
- Lightning fast. Document embeddings can be precomputed once and stored in a vector database. At query time you only embed the query and run nearest-neighbor search.
- Compression bottleneck. Each document’s entire meaning is squeezed into a single fixed-size vector (typically 768–4096 floats). Subtle nuances can be lost.
The Cross-Encoder
[CLS] query [SEP] document [SEP] ──► Encoder ──► score
The query and document are concatenated and processed together. Every query token attends to every document token.
- Far more accurate. Can model exact phrase matches, negation, multi-hop reasoning.
- Glacially slow. You have to re-run the model for every (query, document) pair. Useless for searching millions of documents.
The Production Pattern
Modern systems use both. A fast bi-encoder retrieves the top-100 candidates from millions of documents in milliseconds. Then a slow-but-accurate cross-encoder reranks those 100. Best of both worlds.
This series focuses on bi-encoders because they’re what you fine-tune for domain-specific retrieval. (Cross-encoders are usually used off-the-shelf as rerankers.)
6. How Bi-Encoders Are Trained: Contrastive Learning
Now we get to the heart of it. How do you actually teach a model to put similar texts near each other in vector space?
You give it triples: a query, a positive document (the right answer), and negative documents (wrong answers). You train the model to make similarity(query, positive) higher than similarity(query, negative).
The genius trick is in-batch negatives. Take a batch of (query, positive) pairs. For any given query, every other query’s positive becomes a free negative for you.
B = 32 # batch size
q = encoder(queries) # shape: [B, embedding_dim]
d = encoder(docs) # shape: [B, embedding_dim]
scores = q @ d.T # shape: [B, B]
# scores[i, j] = similarity of query i with document j
# diagonal: (query i, its own positive) → should be HIGH
# off-diagonal: (query i, other docs) → should be LOW
targets = torch.arange(B) # [0, 1, 2, ..., B-1]
loss = F.cross_entropy(scores / temperature, targets)
This loss has a name — InfoNCE (also known as NT-Xent in the vision world). A batch of 32 gives you 32 positives and 31 × 32 = 992 negatives, essentially free. Bigger batches → more negatives → harder problem → better model.
💡 What’s the temperature? τ is a number you divide the scores by before the softmax. A small τ (like 0.05) makes the softmax very sharp — the model is heavily penalized for any rank other than #1. A large τ (like 0.5) is more forgiving. Small τ gives sharper embeddings but a harder optimization landscape. Most papers settle on τ ∈ [0.05, 0.1].
7. The Real Magic: Hard Negatives
Random in-batch negatives are easy. The model quickly learns that a query about “neural networks” doesn’t match a document about “cooking pasta.” That’s not where real learning happens.
What actually moves the needle: hard negatives — documents that are topically related but wrong. Things like:
- A query about AdaGrad getting the Adam paper as a candidate
- A query about the French Revolution getting a document about the American Revolution
- A query about vector databases getting a document about vector graphics
These are the cases where the model has to really understand the difference between similar concepts. They’re where the dragons live.
The training objective with mined hard negatives looks like this. For a query q_i with its positive d_i⁺ and K mined hard negatives:
exp(sim(q_i, d_i⁺) / τ)
loss_i = -log ──────────────────────────────────
Σⱼ exp(sim(q_i, d_j) / τ)
…where j ranges over everything: the positive, all the hard negatives, and every in-batch negative from the other queries in the batch. The model gets one fat denominator full of distractors, and it has to learn to make the positive stand out from all of them.
We’ll go deep on how to actually mine good hard negatives in Part 4 — it’s the single most important step in the whole pipeline.
8. Why Modern Embedders Use Decoder-Only Models
You might have noticed something interesting: the latest embedding models — Qwen3-Embedding, E5-Mistral, GTE-Qwen2 — are built on decoder-only LLMs (Llama, Qwen, Mistral), not encoders like BERT.
This is a real shift, and it’s worth understanding why.
Encoders (BERT, RoBERTa) have bidirectional attention — every token sees every other token. Great for understanding, but they max out around a few hundred million parameters and were pretrained on masked language modeling, which isn’t perfectly aligned with retrieval.
Decoder-only models (GPT, Llama, Qwen) have causal attention — each token only sees previous tokens. Sounds worse for understanding, right? But there’s a clever trick: you take the embedding of the last token after the model has processed the entire sequence. That last token has “seen” everything before it.
def last_token_pool(last_hidden_states, attention_mask):
# For left-padded batches (pad on the left, real content on the right):
left_padded = attention_mask[:, -1].sum() == attention_mask.shape[0]
if left_padded:
return last_hidden_states[:, -1] # last position is always real
# For right-padded batches: find the actual last real token per sequence
seq_lens = attention_mask.sum(dim=1) - 1
return last_hidden_states[
torch.arange(last_hidden_states.size(0)), seq_lens
]
Why decoder-only models work so well for embeddings:
- They’re pretrained on next-token prediction over trillions of tokens — a richer signal than masked language modeling.
- They’re much bigger (7B+ vs BERT’s 340M), giving more capacity to represent fine-grained meaning.
- They naturally handle instructions, so you can tell them what kind of retrieval you want.
That last point is huge. Modern embedders use instruction prefixes on queries:
query_text = (
"Instruct: Given a domain question, retrieve the passage that best answers it.\n"
f"Query: {your_query}"
)
document_text = your_document # no prefix
The query gets a task-specific instruction; the document doesn’t. This asymmetry tells the model what kind of search you’re doing — and it dramatically affects retrieval quality.
⚠️ Forgetting this prefix at inference time is one of the most common silent bugs in production. Your fine-tuned model will mysteriously underperform the base model, and you’ll spend hours debugging before realizing you forgot four lines of formatting.
9. L2 Normalization: The Last Detail
One more thing. After pooling, embeddings are typically L2-normalized — divided by their length so they all sit on the surface of a unit hypersphere.
embedding = F.normalize(pooled, p=2, dim=1)
Why? Without normalization, a longer document might produce a larger-magnitude vector simply because more tokens contributed to it. Then any query would score higher against long documents than short ones, just because of size, not relevance. Normalization makes dot product equivalent to cosine similarity — comparing only direction, not magnitude.
This is one of those tiny details that’s catastrophic to skip.
For most domain-specific applications — legal documents, internal wikis, research papers, customer support tickets — a fine-tuned dense bi-encoder is the sweet spot. That’s what this series is about.
What’s Next
You now have the foundation: embedding models map text to space, bi-encoders enable fast search, contrastive learning is how they’re trained, and hard negatives are where the magic happens.
But before we can train a model on your documents, we need to get the documents into the right shape. That’s where chunking comes in — and it’s much trickier than it sounds.
In Part 2: how to split PDFs into passages that are small enough to embed faithfully but large enough to actually answer questions. Spoiler — character splits don’t cut it, and PDFs are out to ruin your day.
🔑 Key Takeaways
- Embedding models map text to vectors where geometric proximity = semantic similarity.
- Bi-encoders are fast (encode independently); cross-encoders are accurate but slow.
- Contrastive learning with in-batch negatives is the foundation of modern training.
- Hard negatives — topically similar but wrong documents — are the most important training signal.
- Modern embedders are built on decoder-only LLMs with last-token pooling, L2 normalization, and task-specific instruction prefixes on queries.
Next up — Part 2: From PDFs to Passages — The Art and Science of Chunking.
메타데이터
- post_id
- d0cf035558f6
- slug
- why-chatgpt-can-find-your-lost-document-but-ctrl-f-cant-d0cf035558f6
- url
- https://medium.com/@user.ishan/why-chatgpt-can-find-your-lost-document-but-ctrl-f-cant-d0cf035558f6
- canonical_url
- https://medium.com/@user.ishan/why-chatgpt-can-find-your-lost-document-but-ctrl-f-cant-d0cf035558f6
- author_url
- https://medium.com/@user.ishan
- status
- ok
- fetched_at
- 2026-06-09 15:37:30