Session 3 · Week 2 · Tue 21 Jul 2026 · 90 min

What goes in the window.

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.

Ehsan Gazar
Production-Ready Systems with LLMs and Agents · session 3 of 12
Where we're going · 90 minutes

The run sheet.

0–10Context > promptthe window is the whole game
10–26Why cramming hurtslost in the middle, four failure modes, cost
26–60RAG done properlyembeddings, hybrid + RRF, chunking, re-rank, eval
60–76Agentic retrieval + memoryfetch on demand; multi-turn rot
76–86Live: audit your contextwhat you fetch and exclude
86–90Recap + Project 1 previewThursday's workshop
You’ll leave able to treat the window as a budget, name the four ways context fails, build hybrid search, RRF and re-ranking in code, measure retrieval instead of eyeballing it, and stop multi-turn memory rotting.
The reframe

Stop tuning words. Start engineering context.

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.

Prompt engineering
rewording the instruction, hoping for a better answer
low ceiling
vs.
Context engineering
controlling what facts, docs, and history reach the window
the real lever
Foundations · what "context" actually is

Everything the model sees is one flat string.

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

Why cramming hurts · 1

Models forget the middle.

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.

recall ↑ position in the context window → used lost used
Why cramming hurts · 2 & 3

Every extra token dilutes, delays, and bills.

1
Distraction
irrelevant passages pull the answer off course; the model can't tell your junk from your gold.
2
Context rot
quality degrades as the window grows; long contexts drift and contradict themselves.
3
Cost & latency
you pay per token, in and out, and a fat prompt is slower on every single call.

The goal isn't maximum context. It's the minimum sufficient context, the smallest set of tokens that makes the answer correct.

Why cramming hurts · name the failures

Context fails in four distinct ways.

"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 modeWhat happensThe fix
Poisoninga wrong fact (or hallucination) enters context and gets cited as truth on later turnsvalidate before you store; don't feed model output back unchecked
Distractionso much context the model over-focuses on it and ignores its own reasoningtrim; retrieve fewer, better chunks
Confusionirrelevant-but-plausible content nudges the answer sidewaysre-rank; drop low-relevance passages
Clashtwo retrieved passages contradict each other and the model picks wrongdedup; prefer newest / most authoritative source

every one of these gets worse as you add tokens, not better

The mental model

The window is a budget you allocate.

Everything competes for the same finite space. Spend it deliberately, and leave headroom, don't fill it to the brim.

system + instructions
retrieved context (ranked)
recent history
room for output

If retrieved context is eating the whole bar, you're retrieving too much, or not ranking it. That's the rest of tonight.

RAG foundations · how retrieval "understands" meaning

Embeddings: text becomes a point in space.

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?"

"cancel flight" "refund?" "pizza recipe"
// 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

RAG done properly

Retrieval is a pipeline, not a vector lookup.

"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;
RAG done properly · the version everyone ships first

Here's the naive RAG almost everyone starts with.

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

Retrieval · the two lenses

Hybrid search: meaning AND keywords.

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.

Retrieval · fusing two ranked lists

Reciprocal Rank Fusion merges by rank, not score.

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.

RRF(doc) = Σ 1 / (k + rank)  over each list, k≈60
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
Retrieval · pick with eyes open

Dense, sparse, or hybrid?

ApproachGreat atBlind toCost
Dense / vectorparaphrase, synonyms, intentexact codes, rare names, negationembed + ANN index
Sparse / BM25exact tokens, jargon, IDs"same idea, other words"cheap, no model
Hybrid + RRFboth, with graceful fallbacka little more plumbingruns both, fuses

default to hybrid; drop a lens only when you've proven you don't need it

Retrieval · the unit

How you chunk decides what you can find.

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.

Too big
a whole page comes back for one sentence of signal. The window fills with filler.
Just right
semantic units (a section, a Q&A, a function) with a little overlap, plus metadata.
Too small
a fact split from the thing it describes; retrieved alone it's meaningless.
Retrieval · chunk on structure, carry metadata

Split on meaning first, then window with overlap.

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

Retrieval · the filter

Retrieve broadly, then re-rank ruthlessly.

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.

Retrieval · re-rank, then spend the budget

Score, sort, and fill to a token budget, then stop.

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

A real retrieval · same query, two pipelines

"Refund on flight BA-2490, cancelled by you?"

Naive top-k · vectors only
10 chunks that all sound like refunds: the general refund FAQ, a promo-refund blog, T&Cs boilerplate. "BA-2490" and "cancelled by carrier" blur into their neighbours. Answer: vague, maybe wrong.
dumped
Hybrid + re-rank
BM25 catches "BA-2490" and "cancelled by carrier" exactly; the re-ranker keeps the 2 chunks that state the carrier-cancellation policy. Answer: grounded, cites the clause.
engineered

Same corpus, same model. The only difference is what reached the window, and that difference is the whole answer.

RAG done properly · don't eyeball it

Measure retrieval, or you're just guessing.

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?

MetricQuestion it answersStage
Recall@kis the right chunk anywhere in what we fetched?retrieval
Precision / MRRis it near the top, or buried?re-rank
Faithfulnessdoes the answer stick to the retrieved text, no invention?generation
Answer relevancedid it actually address the question asked?generation

build a 20-question gold set now; it pays for itself by Thursday

The 2025 shift

Agentic retrieval: let the model fetch on demand.

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;
💡 In plain English

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.

Agentic retrieval · the loop, with a leash

Retrieval as a tool the model calls in a loop.

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

The conversation problem

Multi-turn context rots. Manage it.

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.

Summarize
compress old turns into a running brief; keep the last few verbatim.
Prune
drop turns that no longer matter; don't carry dead ends forever.
Externalize
store durable facts (name, goal, decisions) outside the window; re-inject on need.
Memory · rebuild the window every turn

Don't append forever. Reconstruct on each turn.

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

The repeatable move

Fetch → Rank → Trim → Name what you excluded.

fetch
Broadly
hybrid, high recall
rank
Ruthlessly
re-rank, keep few
trim
To budget
leave headroom
name
The cut
what you left out, on purpose

If you can't say what you deliberately kept out of the window, you haven't engineered the context, you've dumped it.

Your turn · ~10 min

Audit your context.

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.

1
What's in there?
list every source in the window
2
Is it ranked?
or dumped in retrieval order?
3
What should be cut?
the tokens earning nothing
Recap · then Thursday

Minimum sufficient context, ranked.

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.

Thursday · S4 workshop
Design your context pipeline end to end. Output: Project 1, Context & Retrieval Design.
due Sun Jul 26
Bring
your boundary diagram, tonight's context audit, and a 20-question gold set. That's the raw material.
take-home