Long Context vs RAG: Accuracy, Cost and Latency on the Same 24 Questions
Hands-on lab · IDE in your browser

Long Context vs RAG: Accuracy, Cost and Latency on the Same 24 Questions

Answer the same questions from a 19,000-token handbook three ways and measure each.

Time
55 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit55 min · 5 stepsSession running
3 / 5 steps passingAmendments travel with their section · step 4 of 5
compare.py▶ Run✓ Check
# ---------- Step 4: amendments travel with their section ---------- def with_amendments(chunks, sections):    """The chunks, each followed by every amendment whose title ends with "to section <that chunk's number>" (the    number is the first word of a title, such as 2.14), with no section twice."""       
TerminalOutput

The job

Harbourline Freight wants a chat assistant for its 19,000-token carrier handbook. The model can read all of it in one prompt, so should it? You answer 24 real questions both ways and bring back accuracy, cost and latency for each question type.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    The whole handbook in the prompt

    Harbourline Freight's carrier handbook (handbook.md) is about 19,000 tokens: rules, 48 depot profiles and a list of amendments.

    You writelong_prompt()mentions()grade()run_all()
  2. 2

    What an answer costs

    Every reply carries prompt_tokens, completion_tokens and seconds.

    You writecost_usd()percentile()summary()
  3. 3

    Retrieve sections instead

    RAG sends only the parts of the handbook that look relevant.

    You writesplit_sections()retrieve()rag_prompt()
  4. 4

    Amendments travel with their section

    Part 3 changes earlier sections by number: "Amendment A2 to section 2.1: Martin Achebe takes over her role at this site." Nothing in it says Kestrel, so retrieval rarely finds it, and a model reading everything still has to connect 2.1 to Kestrel.

    You writewith_amendments()
  5. 5

    List questions, a few profiles at a time

    "Which depots are open on Sundays?" needs all 48 profiles.

    You writeis_list_question()map_prompt()map_answer()routed()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: The whole handbook in the prompt

Harbourline Freight's carrier handbook (handbook.md) is about 19,000 tokens: rules, 48 depot profiles and a list of amendments. The model reads 128,000 tokens, so the simplest assistant puts the whole handbook in every prompt. Before comparing it with RAG, you need a score: questions.json has 24 questions, each with phrases a right answer must mention (must) and must not (must_not).

Do this

1. Write long_prompt(handbook, question): the handbook, then RULES, then the question. Instructions placed right before the question are followed more reliably than ones 19,000 tokens earlier.

2. Write mentions(text, phrase): is the phrase in the text as a whole word or number, ignoring case? "£60" is not in "£600", "16.5" is not in "16.55", and "no" is not in "not".

3. Write grade(answer, q): everything in q["must"] mentioned and nothing in q["must_not"].

4. Write run_all(answer_for, questions, workers): call answer_for(question text) for each question, workers at a time, and return each reply with "type" and "correct" added, in question order.

5. Run. Each question type tests something different: lookup, scattered (lists across many depots), superseded (changed by an amendment) and absent (the handbook does not say).

compare.py, the file you edit109 lines
"""Long context or RAG for Harbourline Freight's depot handbook. You write the functions marked TODO, one step at
a time."""
import re
from concurrent.futures import ThreadPoolExecutor

import numpy as np

import harness as H

RULES = """Answer from the handbook text only, in as few words as possible.
Amendments in Part 3 replace anything earlier that they contradict.
For a list, give every item that qualifies, separated by commas, and nothing else.
If the handbook does not give the answer, reply exactly: not stated"""


# ---------- Step 1: the whole handbook in the prompt ----------

def long_prompt(handbook, question):
    """The whole handbook, then RULES, then the question."""
    # TODO (Step 1): the handbook, then RULES, then the question.
    raise NotImplementedError("Step 1: write long_prompt()")


def mentions(text, phrase):
    """True if phrase appears in text as a whole word or number, ignoring case."""
    # TODO (Step 1): re.search with (?<![\w.]) before re.escape(phrase.lower()) and (?![\w]) after, on text.lower().
    raise NotImplementedError("Step 1: write mentions()")


def grade(answer, q):
    """True if the answer mentions everything in q["must"] and nothing in q["must_not"]."""
    # TODO (Step 1): every q["must"] mentioned, and no q["must_not"] mentioned.
    raise NotImplementedError("Step 1: write grade()")


def run_all(answer_for, questions, workers=4):
    """For each question, the reply answer_for(question text) plus "type" and "correct" (grade()), in question order,
    `workers` questions at a time."""
    # TODO (Step 1): ThreadPoolExecutor(workers).map(answer_for, question texts), then add type and correct.
    raise NotImplementedError("Step 1: write run_all()")


# ---------- Step 2: what each answer costs ----------

def cost_usd(result):
    """Dollars for one call at H.PRICE (per million tokens, input and output priced separately)."""
    raise NotImplementedError("cost_usd() arrives in Step 2")


def percentile(values, p):
    """Nearest-rank percentile: the smallest value with at least p% of the values at or below it."""
    raise NotImplementedError("percentile() arrives in Step 2")


def summary(results):
    """{"accuracy", "prompt_tokens" (mean), "usd_per_1000" (questions), "p50_s", "p95_s", "by_type": {type: accuracy}}."""
    raise NotImplementedError("summary() arrives in Step 2")


# ---------- Step 3: retrieve sections instead ----------

def split_sections(handbook):
    """[{"title", "text"}] for every "## " section: the title without "## ", the text from its heading line up to
    the next heading of any level ("# " or "## "), stripped."""
    raise NotImplementedError("split_sections() arrives in Step 3")


def retrieve(question, sections, k):
    """The k sections whose text embeddings are closest to the question's, closest first."""
    raise NotImplementedError("retrieve() arrives in Step 3")


def rag_prompt(question, chunks):
    """The chunks' texts (separated by blank lines), then RULES, then the question."""
    raise NotImplementedError("rag_prompt() arrives in Step 3")


# ---------- Step 4: amendments travel with their section ----------

def with_amendments(chunks, sections):
    """The chunks, each followed by every amendment whose title ends with "to section <that chunk's number>" (the
    number is the first word of a title, such as 2.14), with no section twice."""
    raise NotImplementedError("with_amendments() arrives in Step 4")


# ---------- Step 5: list questions, a few profiles at a time ----------

def is_list_question(question):
    """True for questions that ask which depots (plural) qualify: they start with "Which depots", any case."""
    raise NotImplementedError("is_list_question() arrives in Step 5")


def map_prompt(question, group, sections):
    """The profiles in group, each with its amendments, then: for each depot, one line "<name>: <deciding fact> =>
    yes" or "... => no", saying whether that depot alone qualifies for the question."""
    raise NotImplementedError("map_prompt() arrives in Step 5")


def map_answer(question, sections, batch=6, workers=4):
    """Ask map_prompt() about the depot profiles `batch` at a time and join the depots whose line ends "=> yes". Returns a
    reply like H.ask(): "text" is the names joined with ", " ("none" if there are none), tokens are summed over the
    calls, and "seconds" is the latency with `workers` calls at a time: the sum, over each round of `workers` calls,
    of the slowest call in it."""
    raise NotImplementedError("map_answer() arrives in Step 5")


def routed(question, sections, k=4):
    """map_answer() for list questions; otherwise RAG with retrieve(k) and with_amendments()."""
    raise NotImplementedError("routed() arrives in Step 5")
Provided for you:handbook.mdharness.pyquestions.jsontry_it.py

Frequently asked questions

Is long context better than RAG?

Not by default. In this lab both answer single-fact lookups correctly, RAG at under a tenth of the tokens, and both miss list questions that span many sections. Long context also misses amendments that refer to other sections by number.

Why do LLMs miss items in list questions over long documents?

Finding every item that qualifies among dozens of similar sections is exhaustive search. Models skip or invent items. Asking about a few sections at a time and combining the answers in code is much more reliable.

How do you measure LLM latency fairly?

Report percentiles such as p50 and p95 over the same question set. A mean hides the slow tail, which is what users notice.

Long context or retrieval?

Long-context models can read a whole document per question, and RAG sends only the parts that look relevant. Which is better depends on the questions, and the difference can be measured. You grade answers automatically, compute cost per 1,000 questions and p50/p95 latency, split a document into sections for retrieval, attach amendments to the sections they change, and answer list questions with a map step over small groups of sections.