Skip to content
intermediate Phase 9 · RAG Architecture End-to-End

Assembling Context for the Model

Order, dedupe, and trim retrieved passages to maximize usable signal per token.

50m
0 problems
Topic Progress 0%

The Answer Repeated Three Times

The support bot returned: "The cap is 4000 EUR. The cap is 4000 EUR. The cap is 4000 EUR." Three sentences, one fact, and the passage that named the 90-day eligibility window never made it into the prompt at all.

Nothing was wrong with retrieval. Five passages came back ranked, and two of them were the same paragraph — one from the handbook, one from the FAQ page that quoted it — plus a third chunk that overlapped the first by 80% because the chunker uses a sliding window. Three of five slots went to one fact. The prompt had a budget in tokens, and duplication spent it twice.

Deduplication runs before anything else touches the list:

import re

def norm(text: str) -> set[str]:
    return set(re.sub(r"[^a-z0-9 ]", "", text.lower()).split())

def dedupe(passages: list[dict], threshold: float = 0.7):
    kept, kept_toks, dropped = [], [], 0
    for p in passages:                       # list arrives in rank order
        toks = norm(p["text"])
        if toks and any(len(toks & k) / len(toks | k) >= threshold for k in kept_toks):
            dropped += 1
            continue
        kept.append(p)
        kept_toks.append(toks)
    return kept, dropped

Input:

1 "The relocation cap for Berlin is 4000 EUR."          score 0.94
2 "Relocation cap for Berlin is 4000 EUR. Apply within 90 days of arrival."  score 0.91
3 "The relocation cap for Berlin is 4000 EUR."          score 0.90   (FAQ page)
4 "Parental leave is 14 weeks in Germany."              score 0.62

Output: kept 3 of 4, dropped 1

Two lines decide the behavior:

  • The list is walked in rank order, and each passage is compared only against the ones already kept. The highest-ranked copy therefore wins, and a duplicate can never evict the passage that outranked it.
  • The comparison is Jaccard overlap on normalized tokens. Exact-match dedupe would have missed rows 1 and 2 — they differ by a trailing sentence — while a threshold high enough to catch it would start merging genuinely similar policy pages.

Notice what dedupe does not do: it does not delete the overlap between two legitimately different chunks of the same document. Overlap is how a sliding-window chunker keeps a sentence from being cut in half; the assembler's job is to stop the same fact from occupying three slots, not to eliminate window overlap everywhere.

Deduplication returns budget, but budget only helps if the rest of the pipeline knows how to spend it. That is the next two steps: order, then trim.

Numbering and Order Are a Contract

Context assembly produces a block of text the model reads and answers from. Two properties of that block change the answer: which passages appear first, and whether a marker like [2] means anything after generation.

Order. Long contexts show primacy effects: instructions and evidence near the top get more weight than equivalent material buried at position 3,000. Retrieval order is a similarity ranking, not a reading order, so the assembler decides explicitly:

Ordering choice Good for Failure it introduces
Raw rank order single-source corpora three chunks from one document crowd the top
Round-robin across sources corpora where several documents matter a weak passage from source two outranks a strong one from source four
Grouped by section questions that span one document cross-document questions lose their second-best evidence

For a mixed corpus, take one passage per source per round, then fall back:

def diverse_order(passages: list[dict]) -> list[dict]:
    pools: dict[str, list[dict]] = {}
    for p in sorted(passages, key=lambda p: p["score"], reverse=True):
        pools.setdefault(p["doc"], []).append(p)
    ordered = []
    while pools:
        for doc in list(pools):
            ordered.append(pools[doc].pop(0))   # one per source per round
            if not pools[doc]:
                del pools[doc]
    return ordered

Input: passages from handbook (0.94, 0.71), faq (0.91), wiki (0.62). Output order: handbook(0.94), faq(0.91), handbook(0.71), wiki(0.62) — the strong second passage from handbook waits one round instead of stacking on top.

Number. The number the model writes in its answer is only meaningful if you control the mapping:

def number(passages: list[dict]) -> str:
    lines = []
    for i, p in enumerate(passages, 1):        # 1-based, dense, no gaps
        lines.append(f"[{i}] ({p['section']}) {p['text']}")
    return "\n\n".join(lines)

The prompt then says: cite the passage number after each factual sentence, using [n]. Because numbering happens after dedupe and ordering, [2] refers to the second line the model actually saw — not to the second result the vector database returned. Evict a passage during trimming and the surviving numbers are re-issued densely, so no citation can point at a gap. Whether the model's markers are then validated is the job of citation grounding; assembly's duty is to keep the mapping honest.

A Hard Cap With a Reserve for the Answer

Retrieval will happily return more passages than fit. Left alone, the request either overflows the window — a 4000 context_length_exceeded in production — or the service silently truncates from the end, which throws away passages nobody ranked.

The budget is arithmetic, and the answer gets paid first:

def count_tokens(text: str) -> int:
    return max(1, len(text) // 4)              # ~4 chars/token estimate; swap in the real tokenizer

def assemble(passages: list[dict], question: str, window=8192, max_output=1024, margin=128):
    budget = window - max_output - margin       # the answer is reserved before the prompt is built
    left = budget - count_tokens(question) - 60 # 60: instruction boilerplate + [n] markers

    kept = []
    for p in passages:                         # already deduped and ordered best-first
        cost = count_tokens(p["text"]) + 4
        if cost > left:
            break                              # evict this one and everything below it
        kept.append(p)
        left -= cost

    context = "\n\n".join(f"[{i}] {p['text']}" for i, p in enumerate(kept, 1))
    return context, budget - left

Input: 5 passages (820, 610, 700, 480, 390 tokens), question of 40 tokens, defaults above. Output:

kept 3 of 5 passages, 2130 / 7040 prompt tokens used

Three decisions:

  • max_output and margin come out first. Reserving the answer after filling the prompt means the generation gets truncated mid-sentence, which users read as a broken product.
  • The loop breaks instead of skipping. Continuing past an ill-fitting passage to admit a smaller, lower-ranked one packs in more text but makes the order lie: the model sees evidence it was told ranks fifth sitting above evidence that ranks third. Rank fidelity beats density here.
  • Reaching inside kept and renumbering from 1 keeps the citation contract intact after eviction.

Everything above is passage-level budgeting. The rest of the request — system prompt, conversation history, the reserved output itself — is the larger arithmetic covered in context window budgeting, and the assembler sits inside that calculation as one line item.

Quiz

1. Five passages return and two are the same paragraph from different pages. What is the concrete cost if they are both kept?

Question 1 options

2. Why does dedupe compare each passage only against the ones already kept, walking in rank order?

Question 2 options

3. After trimming, the assembler renumbers passages starting at [1] with no gaps. Why is that mandatory?

Question 3 options

4. Why are max_output and margin subtracted from the window before any passage is added?

Question 4 options

Flashcards

Question

What is the order of operations in context assembly?

Answer

Retrieve → dedupe (rank order, Jaccard threshold) → order (best-first, round-robin across sources) → number densely → trim to the token budget. Each step assumes the previous one already ran.

Question

Why dedupe with token overlap instead of exact text match?

Answer

Sliding-window chunkers and cross-posted pages produce near-duplicates that differ by a sentence. Jaccard on normalized tokens catches those; exact match only catches byte-identical rows.

Question

What does dense renumbering after eviction protect?

Answer

The citation contract. Every [n] the model emits must resolve to a line present in the prompt, so numbers are re-issued 1..k over the passages that survived the budget cut.

Question

Why break rather than skip when a passage exceeds the remaining budget?

Answer

Skipping admits lower-ranked evidence above higher-ranked evidence, so the printed order stops reflecting rank. The model then weights a fifth-ranked passage above a third-ranked one for no reason you chose.

Question

Where does the answer's token reserve come from?

Answer

It is subtracted from the window before the prompt is built: budget = window − max_output − margin, then minus the question and instruction overhead. The prompt fits into what remains, never the reverse.

Revision Notes

Key Takeaways

  • 1. Duplicated and near-duplicate passages spend prompt budget on one fact and evict other evidence — dedupe in rank order with a Jaccard threshold before anything else
  • 2. Numbering happens after dedupe, ordering, and trimming so every [n] the model emits resolves to a line actually in the prompt
  • 3. Order is a decision, not a byproduct: best-first for primacy, round-robin across sources for diversity
  • 4. Subtract max_output and margin from the window first, then break — not skip — when the budget runs out
  • 5. Assembly is one line item inside the larger request budget, not the whole budget

Interview Tips

  • • Draw the four steps in order on the whiteboard — dedupe, order, number, trim — and say what each step assumes about the previous one; the sequencing answer is what interviewers probe
  • • Explain why renumbering after eviction is non-negotiable by tracing a marker back to a line that no longer exists
  • • Be ready for "same passage twice from two sources — do you keep it or drop it?" The answer weighs dedupe against source diversity and says the threshold out loud
  • • Have the arithmetic ready: window minus answer reserve minus margin, and what breaks when the reserve is subtracted last

Cheat Sheet

Assembling Context for the Model — Cheat Sheet

Order of operations

retrieve top-k
  -> dedupe      Jaccard >= 0.7, compare only against kept, rank order wins
  -> order       sorted by score desc, then round-robin one passage per source
  -> number      enumerate 1..k after all removals, no gaps
  -> trim        budget = window - max_output - margin; break on first overflow
  -> render      "[1] (section) text\n\n[2] ..."

Core functions

def norm(t): return set(re.sub(r"[^a-z0-9 ]", "", t.lower()).split())

def dedupe(ps, th=0.7):                     # keeps the best-ranked copy
    kept, toks = [], []
    for p in ps:
        t = norm(p["text"])
        if any(len(t & k) / len(t | k) >= th for k in toks): continue
        kept.append(p); toks.append(t)
    return kept

def assemble(ps, q, window=8192, max_output=1024, margin=128):
    left = window - max_output - margin - len(q) // 4 - 60
    kept = []
    for p in ps:
        cost = len(p["text"]) // 4 + 4
        if cost > left: break               # evict lowest-ranked first
        kept.append(p); left -= cost
    return "\n\n".join(f"[{i}] {p['text']}" for i, p in enumerate(kept, 1))
Symptom in the answer Assembly defect
same sentence repeated dedupe never ran, or threshold too high
correct fact missing duplicate crowded it out of the budget
marker [4] points at nothing numbering happened before trimming
plausible but unsupported claim filler passage outranked real evidence — check ordering
context_length_exceeded question/overhead not subtracted, or estimate too low

Rule of thumb: dedupe before ordering, order before numbering, number after every removal, trim last. Budget is answered first, context second.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading