Context Engineering for RAG Question Parsing: From a Raw Question to Typed Fields That Steer Retrieval and Generation

Editor
24 Min Read


talk about context engineering today, they usually mean retrieving the right context from a document: chunk selection, hybrid search, reranking, TOC-aware retrieval. That is one half of the story. The other half is that the question itself is context the LLM will see, and the question deserves the same treatment as the retrieved passage.

Take an insurance analyst asking: “What is the maximum coverage amount? Don’t confuse it with the deductible, they’re often listed together.” One string, four signals (a topic, a negative cue, an expected shape, a structural hint), each meant for a different downstream step. Handed verbatim to top-k cosine, none of them goes where it should: the embedding pulls in deductible-bearing lines, generation picks the first plausible number, the trace shows nothing about why. That is not a retrieval failure and not a generation failure. It is a context-engineering failure on the question side: the right typed pieces were never assembled in the first place. What follows names those pieces, one strategy at a time, and shows what each one is for on the receiving side.

Enterprise Document Intelligence builds enterprise RAG on four bricks (document parsing, question parsing, retrieval, generation). This article re-reads brick 2 through the context-engineering lens named by Tobi Lütke and Andrej Karpathy in mid-2025 and later structured by LangChain into four canonical strategies (write, select, compress, isolate). Nothing new is shipped here. Articles 6A (thesis), 6B (extraction), and 6C (dispatch) already coded the brick; this one names what each typed piece is for on the receiving side.

where this article sits in the series: brick 6 (question parsing) highlighted, read through the context-engineering lens – Image by author

📓 The five questions in Section 3 all run in the shipped notebook: parse them yourself, print the ParsedQuestion JSON, watch the retrieval brief and the generation brief peel off. Repo → doc-intel/notebooks-vol1.

The public companion-code repo at doc-intel/notebooks-vol1 – Image by author

1. The parser both reads context and writes context

Every brick in the pipeline can be read as a typed-context writer.

  • Parsing writes line_df / page_df.
  • Retrieval writes a filtered subset plus an audit.
  • Generation reads them and produces the answer.

Question parsing is the interesting case. It is the only brick that is both a consumer of context (it makes an LLM call itself) and a writer of context (its output typed row feeds three downstream calls).

Read as a consumer, the parser’s own LLM call is context-engineered by the same rules any other typed LLM call in the pipeline follows. Its context window is assembled from four typed slots:

  • A fixed PARSE_QUESTION_SYSTEM_PROMPT at module level, cached across every question in a session, never rewritten per call.
  • The DocContext compact JSON: one hundred tokens of doc-level facts (doc_type, n_pages, typical_fields) so the parser knows if it is looking at a two-page product sheet or a two-hundred-page policy.
  • A stable few-shot block of (question, ParsedQuestion) pairs (mise en place, one bowl per shape).
  • The raw user question: the only per-call payload.

Nothing else goes into that window. No verbatim document text, no retrieval output, no memory.

That constraint is deliberate. The parser sits before retrieval, so retrieval output does not exist yet. No memory belongs in a call that has to be reproducible. The context engineering here is minimal by design.

Read as a writer, the same brick writes one row of question_df plus three derived pieces the assembly stage will thread into three separate downstream calls. Each downstream receiver already knows the field names and reads them directly. No free-form re-parsing on the way in.

From a user string to a typed row with five columns, then to two briefs each downstream brick can act on. Regenerated by Article 6ter, re-used here to anchor the same shape under the context-engineering lens – Image by author

The receiver-side view is what changes when you read the brick as a context writer. Every field is written for someone:

  • keywords for the keyword-matching detector inside retrieval.
  • intent for the dispatcher, driving the model tier and chunk strategy the generation brick will consume.
  • pages_hint for the anchor detector, so it can pin its search to a page range when the user explicitly names one.

Nothing goes into the row that no one downstream will ask for.

2. Four strategies, four typed pieces

LangChain’s context-engineering taxonomy lists four canonical strategies: write, select, compress, isolate. Read as a writer, the question-parsing brick embodies each of them in a different piece. That is the mapping worth remembering:

The four LangChain context-engineering strategies (write / compress / select / isolate) each mapped to one typed piece the question-parsing brick writes – Image by author

The four subsections below take each strategy and walk what the parser writes for it.

2.1 Write: ParsedQuestion as a contract

Most RAG pipelines summarise the user question into a rewritten string that keeps the flavour and loses the structure. The context-engineering equivalent is the opposite: write out a typed row with named fields, so every downstream call gets the same shape and can address fields by name.

ParsedQuestion is that row. What matters here is not the field list (see Article 6B for it), but that the field names are the contract. A downstream test can assert “if pages_hint is present, the retrieval brief must include it”. A schema linter can flag a field that no downstream call reads. A domain expert can audit the row without opening any code.

Receiver: the two brief builders (build_retrieval_brief and build_generation_brief), each pulling different columns. Cache profile: deterministic per raw string; one LLM call to build, cached and never re-parsed. Audit column: the row itself.

2.2 Compress: the retrieval brief

The retrieval brick has no business knowing the answer shape, the suggested model tier, or the clarification the user was asked. Those fields exist on the parsed row; the retrieval brief drops them.

That is compression as context engineering, not compression as token-saving. The retrieval brief is smaller than ParsedQuestion, but the reason is architectural, not economic: anchor detectors that never see the shape field cannot accidentally start branching on it. Every field the brief omits is a coupling that will never form.

Receiver: the anchor detectors (keyword matcher, TOC arbiter, embedding scorer) in Article 7B. Cache profile: deterministic projection of ParsedQuestion; evicting the parsed row evicts the brief. Audit column: the retrieval brick logs the brief it received alongside the anchors it picked.

2.3 Select: the generation brief

Selection in a context-engineered pipeline means picking the right template to instantiate for this call, not letting the LLM decide the template on the fly. Article 6C develops the dispatcher that does that: intent, section_hint, layout_hint, and pages_hint drive chunk_strategy, suggested_model, and the three activation flags (pages_hint_active, section_filter_active, layout_pattern_active).

Read this way, the generation brief is not “the fields generation needs”; it is “the decisions the dispatcher already made, so the LLM has nothing to invent.” The selection happens deterministically in Python. The LLM inherits the choice and executes it.

Receiver: the generation-schema lookup and the LLM call in Article 8A. Cache profile: dispatch output keyed by (intent, section_hint, layout_hint, pages_hint); reused across users and sessions with the same shape. Audit column: the dispatcher logs one reason per activation.

2.4 Isolate: the clarification request

The isolate strategy in LangChain’s taxonomy prevents low-quality context from bleeding into a downstream call. In question parsing, the clarification loop is exactly that pattern applied to the parser’s own output.

When the parser cannot resolve intent, shape, or scope with confidence, it does not pick the best guess and forward it. That would poison the retrieval brief with a scope filter that might be wrong. Every anchor and every answer downstream would inherit the bias.

Instead, it writes a ClarificationRequest, holds the pipeline, and lets the user say what they meant.

Article 6bis develops the loop that follows, including how the answer is cached as a default so the second occurrence is silent.

Receiver: the UI, then the parser again on the enriched string. Cache profile: ClarificationDefault rows keyed on (ambiguity_reason, user); hits after the first ask. Audit column: one line per clarification with the ambiguity, the question, the answer, the resulting default.

3. Five walked examples

Abstractions read better on concrete strings. The five questions below each illustrate a different technical case: no hints, section_hint alone, section_hint plus layout_hint, a ClarificationRequest instead of a ParsedQuestion, and the section_hint-vs-pages_hint distinction. For each, parse_question(...) in docintel.question writes a ParsedQuestion row, then dispatch(parsed) in docintel.question.dispatcher produces the routing decisions the generation brick reads. The JSON blocks below use the real fields from src/docintel/question/: nothing is invented on top.

3.1 Baseline: keywords only, no scope hints

Insurance question.

“What is the maximum coverage amount? Don’t confuse it with the deductible, they’re often listed together.”

The keyword extractor pulls the two nominal phrases in the question. intent lands on factual because the answer is one bounded value. No page is pinned, no section is named, no layout target is set.

ParsedQuestion:

{
  "original_question": "What is the maximum coverage amount? Don't confuse it with the deductible, they're often listed together.",
  "keywords": ["maximum coverage amount", "deductible"],
  "intent": "factual",
  "retrieval": {
    "main_query": "maximum coverage amount",
    "rewrites": ["policy limit", "coverage limit"],
    "anchor_keywords": ["maximum coverage amount"],
    "section_hint": null,
    "layout_hint": null
  },
  "structural_hints": null
}

dispatch(parsed):

{
  "chunk_strategy": "combined",
  "suggested_model": "gpt-4.1-mini",
  "activations": [
    {"flag": "pages_hint_active", "on": false, "reason": "no pages_hint -> retrieval searches the full document."},
    {"flag": "section_filter_active", "on": false, "reason": "no section_hint -> retrieval scans every section."},
    {"flag": "layout_pattern_active", "on": false, "reason": "no layout_hint -> default text retrieval, no layout two-hop."}
  ]
}

What the current schema does not capture. The negative cue (“don’t confuse with deductible”) does not have a dedicated field. Both terms end up side by side in keywords, and the disambiguation depends on the retrieval detectors picking the right anchor plus the generation LLM reading the surrounding context. Adding a negative_keywords field on RetrievalQuery is a live schema-evolution question, not a claim about what the parser writes today. The article documents what lands; it does not invent fields that do not exist yet.

3.2 section_hint activates section_filter_active

Legal question.

“Does the indemnification clause survive termination, and if so, for how long?”

The question names a section by title. intent lands on section_retrieval because the payload is a bounded portion of the document.

ParsedQuestion:

{
  "original_question": "Does the indemnification clause survive termination, and if so, for how long?",
  "keywords": ["indemnification clause", "termination", "survival"],
  "intent": "section_retrieval",
  "retrieval": {
    "main_query": "indemnification survival after termination",
    "rewrites": ["survival clause indemnification"],
    "anchor_keywords": ["indemnification", "survival"],
    "section_hint": "Indemnification",
    "layout_hint": null
  },
  "structural_hints": null
}

dispatch(parsed):

{
  "chunk_strategy": "combined",
  "suggested_model": "gpt-4.1",
  "activations": [
    {"flag": "pages_hint_active", "on": false, "reason": "no pages_hint -> retrieval searches the full document."},
    {"flag": "section_filter_active", "on": true, "reason": "retrieval.section_hint='Indemnification' -> filter toc_df, narrow to that section."},
    {"flag": "layout_pattern_active", "on": false, "reason": "no layout_hint -> default text retrieval, no layout two-hop."}
  ]
}

section_filter_active: true is the concrete effect of writing section_hint. Retrieval drops the full-document scan and reads only the pages that fall inside the Indemnification section of toc_df. The two-part answer (does it survive + for how long) is not carried on the parsed row: it stays in the section text that the generation LLM reads.

3.3 layout_hint = "table" activates the two-hop pattern

Finance question.

“What was the total revenue for Q3 2024, broken down by region?”

intent lands on listing because the expected answer is one row per region. layout_hint: "table" flips the two-hop pattern on.

ParsedQuestion:

{
  "original_question": "What was the total revenue for Q3 2024, broken down by region?",
  "keywords": ["total revenue", "Q3 2024", "region"],
  "intent": "listing",
  "retrieval": {
    "main_query": "total revenue Q3 2024 by region",
    "rewrites": ["segment revenue", "revenue by geography"],
    "anchor_keywords": ["revenue", "Q3 2024"],
    "section_hint": "Segment revenue",
    "layout_hint": "table"
  },
  "structural_hints": null
}

dispatch(parsed):

{
  "chunk_strategy": "combined",
  "suggested_model": "gpt-4.1-mini",
  "activations": [
    {"flag": "pages_hint_active", "on": false, "reason": "no pages_hint -> retrieval searches the full document."},
    {"flag": "section_filter_active", "on": true, "reason": "retrieval.section_hint='Segment revenue' -> filter toc_df, narrow to that section."},
    {"flag": "layout_pattern_active", "on": true, "reason": "retrieval.layout_hint='table' -> two-hop pattern: find the element, then read the surrounding caption / row."}
  ]
}

layout_pattern_active: true is the concrete effect of writing layout_hint. Downstream, the anchor detector switches to a two-hop pass: find the table region, then read the row and the header that belong to it.

3.4 ClarificationRequest instead of ParsedQuestion

Medical question.

“Is warfarin contraindicated with the current medication list?”

The question references a medication list that is not in the current project scope. The best ParsedQuestion the parser can build would have no anchor a downstream call can trust. Instead of forwarding a low-confidence guess, the clarification loop (Article 6bis) writes a ClarificationRequest and holds the pipeline until the user picks a source.

ClarificationRequest:

{
  "target_field": "medication_list_source",
  "question_to_user": "Which document lists the patient's current medications?",
  "candidate_values": [
    "current_prescription.pdf (in this project)",
    "post_op_orders.pdf (in this project)",
    "paste the list here"
  ],
  "proposed_default": "current_prescription.pdf (in this project)",
  "proposed_default_reason": "The only document flagged as 'active prescription' in the project index.",
  "audit": {
    "request_id": "clar_20260710_007",
    "model": "gpt-4.1-mini",
    "prompt_version": "v3",
    "doctype": "medical",
    "sub_conditions": ["missing_scope_reference"]
  }
}

dispatch() is never called here: without a resolved scope, no chunk strategy, no model tier, no activation flags exist yet. Isolate as context engineering, live in code. Once the user picks a document, parse_question re-runs on the enriched string and the pipeline gets both a ParsedQuestion and its dispatch() output.

3.5 section_hint set, pages_hint deliberately null

Academic paper question.

“What is the dimension of each attention head in the Transformer?”

section_hint lands on "3.2" because the question is about a specific section of Attention Is All You Need. pages_hint stays null because the user did not pin a page. The section-to-page lookup happens downstream via section_filter_active, deterministically, through toc_df.

ParsedQuestion:

{
  "original_question": "What is the dimension of each attention head in the Transformer?",
  "keywords": ["d_k", "attention head", "Transformer"],
  "intent": "factual",
  "retrieval": {
    "main_query": "attention head dimension d_k",
    "rewrites": ["d_k attention", "head dimension"],
    "anchor_keywords": ["d_k", "attention head"],
    "section_hint": "3.2",
    "layout_hint": null
  },
  "structural_hints": null
}

dispatch(parsed):

{
  "chunk_strategy": "combined",
  "suggested_model": "gpt-4.1-mini",
  "activations": [
    {"flag": "pages_hint_active", "on": false, "reason": "no pages_hint -> retrieval searches the full document."},
    {"flag": "section_filter_active", "on": true, "reason": "retrieval.section_hint='3.2' -> filter toc_df, narrow to that section."},
    {"flag": "layout_pattern_active", "on": false, "reason": "no layout_hint -> default text retrieval, no layout two-hop."}
  ]
}

pages_hint vs section_hint are two distinct signals with different provenance. pages_hint is only written when the user explicitly says “on page 3”; the retrieval brick then filters line_df with .isin(pages_hint). section_hint is written when the user names a section (by title or number); the section-to-page lookup then happens deterministically through toc_df, driven by section_filter_active. Never conflate the two: the parser does not derive a pages_hint from a TOC lookup.

What the five have in common. Every field on each parsed row is a real field on ParsedQuestion (src/docintel/question/parse_question.py). Every activation flag is a real entry from build_execution_plan(...) (src/docintel/question/dispatcher.py). The two functions are all the context engineering the question side does today. Adding a strategy means adding a field to ParsedQuestion or an activation to the dispatcher, then documenting which receiver reads it. Nothing in the article is a fictional schema.

4. Why not merge the pieces

An engineer new to the codebase reasonably asks: why four pieces? why not one payload with every field, and let each receiver pick what it needs?

The context-engineering answer is not aesthetic. It is operational.

A merged payload breaks the strategy separation. Compress, select, and isolate are only meaningful between receivers.

If retrieval and generation share the same payload, retrieval can start reading the answer shape “just in case”, and the coupling forms. Six months later, a schema linter cannot tell whether a field is used by design or by accident.

The four typed pieces make the accident impossible: the retrieval brief does not include the shape, so the code that could branch on it does not compile.

A merged payload collapses the cache boundaries. Each piece has its own cache key: parsed row per raw string, dispatch output per (intent, section_hint, layout_hint, pages_hint), retrieval brief and generation brief are deterministic projections of both. Merging them collapses every key to “per raw string”: a change to the intent enum then evicts every stored parse, even though no field of ParsedQuestion moved. The typed separation is what keeps the blast radius of a schema change small.

A merged payload leaks the ambiguity. With four pieces, the parser can write only a ClarificationRequest and hold the rest. A monolith forces the parser to send something to every receiver every time, so a partial parse gets forwarded as a real one, and the retrieval brick runs on a scope guess the parser did not want to commit to. Isolate stops working the moment the payload is single.

None of this argues against convenience helpers that assemble the pieces when a caller wants them together. It argues that the storage-level and receiver-level shape is four typed pieces, not one blob.

5. Out of scope

Three things this article deliberately does not cover.

  • Corpus-level context. When retrieval spans a corpus, the question parser also needs to know which document the user is asking about, or which subset. That is a CorpusContext typed slot that Volume 2 develops; here we stay single-document.
  • Conversation history. A multi-turn session where “and the next one?” refers back to a prior parsed question needs a memory slot in the assembly stage. Volume 3 covers that; here every question is fresh.
  • Tool-call payloads. When the parser can offload a piece of work to an external tool (say, a currency converter for “in dollars”), the tool contract becomes another typed slot. Volume 4 covers agentic bricks with tools; here the parser stays deterministic.

Each of these adds one more typed piece to the assembly stage. The vocabulary is the same. The pieces just get more numerous.

6. Sources and further reading

The context-engineering framing arrived from two sources. Tobi Lütke’s tweet proposed the term in June 2025 as a replacement for “prompt engineering.” Andrej Karpathy endorsed it a week later. LangChain later structured the practice into four canonical strategies (write, select, compress, isolate) that map cleanly onto the question-parsing brick as this article shows.

The series articles that build the pieces this one names:

  • Parse the question before you search (Article 6A). The published thesis of question parsing, and the split into two briefs.
  • Five fields RAG should extract from any question (Article 6B). The column-by-column extraction in code.
  • Dispatching the parsed RAG question (Article 6C). The dispatcher pattern that turns the parsed columns into routing decisions.
  • When RAG users ask vague questions (Article 6bis). The clarification loop that writes the fourth typed piece.
  • The untaught lessons of RAG question parsing (Article 6ter). The six positions the brick takes against the mainstream RAG playbook.

The industry sources that named the practice:

  • Tobi Lütke, “context engineering over prompt engineering”: tweet, June 2025.
  • Andrej Karpathy, “context engineering as the delicate art of filling the window”: tweet, June 2025.
  • LangChain, “Context Engineering for Agents”: the four canonical strategies (write, select, compress, isolate).

Share this Article
Please enter CoinGecko Free Api Key to get this plugin works.