Prompt wording is a rounding error. The real lever is context engineering: getting exactly the right information into the model's window, ranked, and nothing else. Tonight, how to do that well, and why cramming backfires.
Teams burn weeks wording the perfect prompt. The model was never confused by your phrasing, it was missing the information or drowning in the wrong information. Context engineering is the discipline of deciding what the model sees.
The model has no memory and no database. On every call it sees exactly one thing: the tokens you assembled. Six sources compete for that one window, and you decide the mix.
flowchart LR SYS["System + instructions"] --> WIN TOOL["Tool definitions"] --> WIN RAG["Retrieved docs
ranked"] --> WIN HIST["Conversation history"] --> WIN USER["User's new message"] --> WIN WIN["🪟 Context window
one flat token string"] --> M{"Model"} M --> OUT["Output
needs room too"] classDef in fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef win fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726; class SYS,TOOL,RAG,HIST,USER in; class WIN win; class M m;
context engineering = deciding the contents of that one box
Stuff the window full and recall follows a U-curve: the model reliably uses what's at the start and the end, and quietly loses what's buried in the middle. The Lost in the Middle study measured it, accuracy sagging from the high tens to the fifties when the answer sat mid-context. A fact isn't "in context" just because it's in the tokens.
The goal isn't maximum context. It's the minimum sufficient context, the smallest set of tokens that makes the answer correct.
"It hallucinated" is lazy. Bad context has a taxonomy, and each mode has a different fix. Learn to name which one you're looking at.
| Failure mode | What happens | The fix |
|---|---|---|
| Poisoning | a wrong fact (or hallucination) enters context and gets cited as truth on later turns | validate before you store; don't feed model output back unchecked |
| Distraction | so much context the model over-focuses on it and ignores its own reasoning | trim; retrieve fewer, better chunks |
| Confusion | irrelevant-but-plausible content nudges the answer sideways | re-rank; drop low-relevance passages |
| Clash | two retrieved passages contradict each other and the model picks wrong | dedup; prefer newest / most authoritative source |
every one of these gets worse as you add tokens, not better
Everything competes for the same finite space. Spend it deliberately, and leave headroom, don't fill it to the brim.
If retrieved context is eating the whole bar, you're retrieving too much, or not ranking it. That's the rest of tonight.
An embedding model maps text to a vector. Similar meanings land close together, so you compare by angle (cosine), not by shared words. This is what lets "cancel my flight" match "how do I get a refund?"
// text → vector → compare by angle, not keywords
const a = await embed("cancel my flight");
const b = await embed("how do I get a refund?");
cosine(a, b); // 0.82 — close in meaning, 0 words shared
function cosine(u, v) {
const dot = u.reduce((s, x, i) => s + x * v[i], 0);
return dot / (norm(u) * norm(v)); // 1=same, 0=unrelated
}
a vector DB just stores these and finds the nearest points fast
"Embed the query, grab the top-k, stuff it in" is the naive version, and it's why so many RAG demos feel dumb. The real pipeline has stages.
flowchart LR Q["Query"] --> RW["Rewrite / expand
optional"] RW --> H(("Hybrid
retrieve")) H --> RR["Re-rank
keep best few"] RR --> AS["Assemble window
dedup, order, trim"] AS --> M{"Model"} M --> A["Grounded answer"] classDef s fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726; class RW,RR,AS s; class M m;
Nothing here is wrong, exactly. It's just missing every stage that makes retrieval good. Keep this on screen, we'll fix one line at a time for the rest of the section.
// ❌ Naive RAG — embed, top-k, stuff, hope
async function answer(query) {
const qvec = await embed(query);
const hits = await vectorStore.search(qvec, { topK: 8 });
const ctx = hits.map(h => h.text).join("\n\n");
return llm(`Context:\n${ctx}\n\nQuestion: ${query}`);
}
// No keywords → misses exact terms (codes, names).
// No re-rank → dumped in raw similarity order.
// No trim → whatever 8 chunks weigh, that's your bill.
every comment is a stage we're about to add
Dense (vector) search finds things that mean the same. Sparse (keyword / BM25) search finds the exact term, product codes, names, error strings, that embeddings blur. You want both, then merge.
flowchart TB Q["Query"] --> D["Dense / vector
semantic match"] Q --> S["Sparse / BM25
exact keyword"] D --> F(("Fuse
+ dedup")) S --> F F --> R["Ranked candidates"] classDef a fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef b fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class D a; class S b;
Pure vector search silently fails on "error code E-4021" or "the Q3 Whitfield contract." Keywords save you there.
Dense and sparse scores aren't comparable, one's a cosine, the other's a BM25 magnitude. RRF sidesteps that: it only looks at position in each list. A doc ranked high in either list floats up.
function rrf(lists, k = 60) {
const score = new Map();
for (const list of lists)
list.forEach((doc, i) =>
score.set(doc.id, (score.get(doc.id) ?? 0) + 1 / (k + i + 1)));
return [...score].sort((a, b) => b[1] - a[1]);
}
const dense = await vectorStore.search(await embed(query), { topK: 50 });
const sparse = await bm25.search(query, { topK: 50 });
const fused = rrf([dense, sparse]); // one ranked, de-duped list
| Approach | Great at | Blind to | Cost |
|---|---|---|---|
| Dense / vector | paraphrase, synonyms, intent | exact codes, rare names, negation | embed + ANN index |
| Sparse / BM25 | exact tokens, jargon, IDs | "same idea, other words" | cheap, no model |
| Hybrid + RRF | both, with graceful fallback | a little more plumbing | runs both, fuses |
default to hybrid; drop a lens only when you've proven you don't need it
You retrieve chunks, so chunking is a design decision, not a preprocessing detail. Too big and you retrieve noise; too small and you sever the context a passage needs to make sense.
Don't blindly cut every N characters mid-sentence. Split on the document's own structure (headings, Q&A, functions), then window inside each unit. Attach metadata, it's how you cite, filter, and rank later.
function chunk(doc, { size = 800, overlap = 120 } = {}) {
const sections = splitOnHeadings(doc.markdown); // semantic units first
return sections.flatMap(sec =>
slidingWindow(sec.text, size, overlap).map(text => ({
text,
meta: { // travels with the chunk everywhere
title: doc.title,
section: sec.heading,
url: doc.url,
updated: doc.updatedAt, // lets you break "clash" ties by recency
},
})));
}
overlap keeps a fact and its context from landing in different chunks
First-pass retrieval trades precision for recall, grab 50 candidates so you don't miss the answer. Then a re-ranker scores each against the query and you keep the top few. Cheap step, huge quality gain.
flowchart LR Q["Query"] --> RET["Retrieve top 50
high recall"] RET --> RANK["Re-rank by relevance
cross-encoder / LLM"] RANK --> TOP["Keep top 5
high precision"] TOP --> W["Into the window"] classDef s fill:#DCEBFE,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class RANK s; class TOP,W ok;
Recall in the first stage, precision in the second. Skipping the re-rank is the single most common RAG mistake.
The re-ranker (a cross-encoder that reads query + chunk together) gives you real relevance scores. Then you pack the window greedily, in rank order, until the budget runs out. This is where "minimum sufficient context" becomes literal code.
const cands = fused.slice(0, 50); // recall from hybrid
const scored = await reranker.rank(query, cands); // precision: cross-encoder
scored.sort((a, b) => b.score - a.score);
let budget = 2000, picked = []; // token budget for context
for (const c of scored) {
if (c.score < 0.2) break; // relevance floor — drop the tail
const t = countTokens(c.text);
if (budget - t < 0) break; // stop at budget, never overflow
picked.push(c); budget -= t;
}
// picked = the minimum sufficient context, in ranked order
a relevance floor AND a token cap — whichever hits first wins
Same corpus, same model. The only difference is what reached the window, and that difference is the whole answer.
Every tweak tonight, chunk size, k, re-ranker, needs a number to move. Split the metrics: did we fetch the right chunks, and did we use them faithfully?
| Metric | Question it answers | Stage |
|---|---|---|
| Recall@k | is the right chunk anywhere in what we fetched? | retrieval |
| Precision / MRR | is it near the top, or buried? | re-rank |
| Faithfulness | does the answer stick to the retrieved text, no invention? | generation |
| Answer relevance | did it actually address the question asked? | generation |
build a 20-question gold set now; it pays for itself by Thursday
Classic RAG retrieves once, up front. Agentic retrieval gives the model retrieval as a tool: it decides whether it needs to look something up, searches, reads, and searches again for multi-hop questions. More power, more cost, cap the loop.
flowchart LR
Q["Question"] --> M{"Model
need to look it up?"}
M -- yes --> SR["Search + read"]
SR --> M
M -- "enough" --> A["Grounded answer"]
classDef m fill:#EEE6FF,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
class M m;
class A ok;
Naive RAG is handing someone a photocopy before they ask the question. Agentic retrieval is giving them a library card and letting them look things up as they reason, including following one answer to the next question.
You expose search as a tool, then loop: model calls it, you run the search, feed results back, repeat until it says it has enough. The one non-negotiable, a hard cap, because the loop's cost is unbounded.
const tools = [{
name: "search_docs",
description: "Search the knowledge base. Call again to follow up.",
input_schema: { type: "object", properties: { query: { type: "string" } } },
}];
let messages = [{ role: "user", content: question }];
for (let hop = 0; hop < 4; hop++) { // cap the loop — cost is unbounded
const res = await llm({ messages, tools });
if (!res.tool_calls) return res.text; // model says it has enough
for (const call of res.tool_calls) {
const docs = await search(call.args.query); // your hybrid+rerank pipeline
messages.push(toolResult(call.id, docs));
}
}
the search tool is everything you just built — reused, not thrown away
In a long chat the history grows every turn, and with it the cost, the latency, and the U-curve middle. You can't just append forever. You curate the running context the same way you curate retrieval.
The strongest mental shift: you don't grow a conversation, you rebuild its context every turn from three parts, durable facts, a rolling summary, and the last few verbatim messages.
function buildContext(history, facts) {
const recent = history.slice(-6); // last 6 turns, verbatim
const older = history.slice(0, -6);
const brief = older.length ? summarize(older) : ""; // compress the rest
return [
systemPrompt,
facts.length && `Known facts:\n${facts.join("\n")}`, // externalized memory
brief && `Earlier, in brief:\n${brief}`, // summarized memory
...recent, // working memory
].filter(Boolean);
}
summarize runs in the background; a turn never waits on it
If you can't say what you deliberately kept out of the window, you haven't engineered the context, you've dumped it.
Take the model call from your boundary diagram. What actually lands in its window today? Run these three and drop the biggest surprise in the chat.
The window is a budget. More tokens dilute, delay, and bill. Retrieve broadly with hybrid search, fuse with RRF, chunk on meaning, re-rank to a few, assemble to a token budget, measure it, and curate multi-turn memory before it rots.