Skip to content

Deep DivesBuilds

Build: a retrieval harness that tells you what each choice is worth, or a contract-aware chunker

Two three-hour builds from this week's retrieval theme: a harness that scores chunking, context lines and fusion on a labelled set (runnable, numpy only), or a clause-aware chunker for contracts.

11 min read · 2.8 h hands-onWeek 4 · Retrieval and RAG design
On this page
  1. The 5-minute version
  2. Do it
  3. Expected results
  4. Threat model and failure modes
  5. Alternatives and when to switch
  6. Build your own
  7. What to check before you trust any of this
  8. Evidence

Ask ten RAG teams what their chunk size is worth and you’ll get ten confident answers and almost no measurements. The advice is easy to repeat. The number on your corpus is harder to come by.

This week’s posts gave the argument. Monday laid out the stack (structure-aware chunks, hybrid search, a reranker) and Tuesday’s paper showed gains that don’t add up when stacked. Today you build the thing that settles it for your own documents: a small harness that scores each choice.

The 5-minute version

The idea. Retrieval quality is a table: chunking scheme on one axis, retriever on the other, query type across the top. Fill it in with a labelled set and most design arguments stop. The lab in labs/build/2026-10-31 generates 36 synthetic master-services agreements (300 clauses, 216 single-fact questions), builds four chunkings, runs BM25, a cheap dense retriever and reciprocal rank fusion, and prints hit@5 and nDCG@10 per query type. It uses only numpy and runs in about 3 seconds.

What Monday’s stack claims. Anthropic’s contextual retrieval post (19 September 2024, so older material) reports failed top-20 retrievals falling from 5.7% to 3.7% with contextual embeddings, 2.9% adding contextual BM25, and 1.9% with reranking from 150 candidates. That’s a vendor test on its own datasets. The harness lets you ask whether the same shape holds on yours.

What I’d watch for. RRF is simple: each list gives a document 1/(rank + k) and the sums decide the order. Azure AI Search documents k=60 and runs semantic ranking after the fusion (page updated 17 September 2026). Nothing in the formula checks that both lists are any good, which turns out to matter below.

flowchart TD
    G[Generate contracts + labelled questions] --> C[4 chunkings]
    C --> B[BM25]
    C --> D[Dense: tf-idf + SVD]
    B --> F[RRF k=60]
    D --> F
    F --> M[hit@5, nDCG@10 per query type]
    M --> T[Table: what each choice is worth]

Why a senior architect should care: the same harness, pointed at your own chunks and questions, is the offline eval set next week’s theme asks you to build.


Do it

Pick one option; the harness comes first because it also tests the chunker.

Option A: the retrieval harness

Goal

A script that takes a corpus, a set of chunkers, retrievers and a labelled question set, and prints the results table. The lab is the reference version.

Why it’s worth building

Every stage in the pipeline is a claim. A context header helps, hybrid search helps, a reranker helps. Tuesday’s study found that stacked gains overlap, and that the best pipeline changed with corpus size. You can only know your own numbers by measuring them.

Requirements

  • A labelled set where each question points at a specific passage, not a document.
  • Relevance judged by containment: a chunk counts only if it holds the whole answer sentence, so a window that cuts the sentence in half scores zero.
  • Results broken out by query type (identifier lookup, party-named, paraphrase), never one blended average.
  • A scale knob and a seed knob, so you can see results move.

Architecture

The lab’s single file holds the generator, four chunkers, a from-scratch BM25, a dense retriever (tf-idf over words and character trigrams, rank-48 SVD) standing in for an embedding model, RRF, and the scorer. The dense class is the one thing to replace with your real embedder.

Stack

Python 3.11+ and numpy 2.4.6. No GPU, no API key, no download.

Milestones

  1. Run the reference (30 minutes).
cd labs/build/2026-10-31
pip install -r requirements.txt
python3 run.py --results results.json
python3 run.py --docs 144 --results r.json   # 4x corpus, about 40 seconds
  1. Read the table, then predict (30 minutes). Before looking at the second run, write down what you expect hybrid to do at 4x. Then run it.
  2. Swap in your embedder (45 minutes). Replace Dense with a real model’s vectors. Keep the interface (scores(query) returns one score per chunk).
  3. Point it at your documents (45 minutes). Write 30 to 50 questions by hand, each with the passage that answers it. Thirty is enough to see a 0.4 gap; it isn’t enough to rank two close configurations.
  4. Add a reranker stage (30 minutes). Retrieve 50, rerank to 5, and add a row. Check the latency per query next to the hit rate.

Stretch goals

  • Add a context header written by a model instead of the template and price it with the calculator below.
  • Add a second query type that cites a clause by number but not the party, and see which chunking survives.
  • Bootstrap the question set to put a confidence interval on each cell.

What contextualising your corpus once costs

100 M tokens
1.02 $
102 $One-time cost

Rate from Anthropic's Contextual Retrieval post (19 September 2024) under its stated assumptions: 800-token chunks, 8k-token documents, 100 tokens of context per chunk. Re-check current prices.


Option B: a contract-aware chunker

Goal

A chunker that splits agreements on clause boundaries, keeps the heading path, and prepends a context line (parties, document ID, date). Option B is a spec: the lab’s make_chunks shows the clause and context logic.

Requirements and milestones

  • Split on numbered headings; fall back to paragraphs when none exist.
  • Keep tables as one chunk each, with the header row repeated.
  • Emit {doc, heading_path, text, char_span} so a citation can point back to the source.
  • Test: no chunk should cut an answer sentence in half. Count how many your chunker cuts.

Do the harness first if you can only do one: it is also how you’d test this chunker.


Expected results

Our run (Python 3.11, numpy 2.4.6, seed 7, 36 documents, 3.0 seconds) from run.json:

CheckGateOur run
Clause chunks + context, hybrid: hit@5 over exact, party, paraphrase>= 0.750.870
hit@5 gained by adding the context line to clause chunks>= 0.50.754
hit@5 gained by clause chunks over fixed windows (both with context)>= 0.20.379
Multi-hop questions answered by one query<= 0.50.333
Multi-hop questions answered by two chained queries>= 0.91.000
Auto-renewing agreements visible in the top 10 chunks<= 0.50.357

hit@5, by chunking and query type (hybrid):

ChunkingExact IDParty namedParaphrase
Fixed 45-word windows0.110.140.03
Fixed windows + context0.580.600.29
Clause chunks0.150.170.03
Clause chunks + context1.001.000.61

Paraphrase hit@5 for clause chunks + context, by retriever and corpus size

00.30.50.81BM25, 36 documents: 0.7Dense (stand-in), 36 documents: 0.5Hybrid (RRF), 36 documents: 0.6BM25, 144 documents: 0.7Dense (stand-in), 144 documents: 0.1Hybrid (RRF), 144 documents: 0.136 documents144 documentsCorpus sizehit@5

BM25Dense (stand-in)Hybrid (RRF)

Corpus sizeBM25Dense (stand-in)Hybrid (RRF)
36 documents0.720.510.61
144 documents0.690.10.14
Our run (labs/build/2026-10-31, seed 7). Synthetic corpus; the dense retriever is tf-idf plus SVD, not a neural embedder.

Three things the numbers say, with the usual caveat that the corpus is synthetic and clean:

  • The context line did most of the work. Without it, 36 near-identical agreements are indistinguishable at clause level. Fixed windows with context reached only 0.49 because they cut sentences; the chunker mattered once the header was there.
  • Fusion isn’t free. At 36 documents hybrid sat between its two inputs. At 144 documents the dense stand-in fell to 0.10 on paraphrases and dragged hybrid to 0.14, below BM25’s 0.69. A real embedder should behave better; the harness is how you’d find out. Another seed (2) gave 0.44 for hybrid against 0.56 for BM25.
  • Some questions aren’t retrieval questions. One query found the right clause for 4 of 12 chained questions (“the agreement that replaced MSA-xxxx”); reading the bridge ID from the first hit and querying again got all 12 (at 4x scale, 48 questions: 0.96 two-step against 0.08 one-shot). The auto-renewal count is a query over a table: the top 10 chunks held 10 of 28.

Threat model and failure modes

  • Poisoned or hostile chunks. Retrieved text is untrusted input. A clause that says “ignore previous instructions” gets the same weight as any other once it’s in the prompt. Keep retrieved text in a data channel, strip or flag instruction-like text, and don’t let retrieval output trigger tools without a separate check.
  • Cross-tenant leakage through context lines and caches. The header names parties. If chunks from different clients share an index, a missing filter returns another client’s contract. Filter on tenant and document ACL at query time, not after generation, and never share a semantic cache across tenants.
  • Silent wrong answers from a clean-looking score. A hit@5 of 1.00 on identifier questions says nothing about paraphrase, and a stale eval set misses drift. Break results out by query type and rerun the set on every index rebuild.
  • Fails safe or fails open. If retrieval returns nothing relevant, the system should abstain, not answer from memory. A harness can’t enforce that, but it can measure the rate at which the top result is wrong and the model answers anyway.
  • Runaway cost. Model-written context lines multiply with corpus size and rebuild frequency. Cache by chunk hash and budget the re-index.

Alternatives and when to switch

OptionStrengthWhen to switch
This harness (numpy, local)Zero dependencies, readable, freeYou need real embeddings, multilingual text or 100K+ documents
A managed retrieval service with built-in hybrid search (for example Azure AI Search)RRF and semantic ranking operated for youYour team can’t run an index, or you need ACL trimming out of the box
An open-source eval framework such as the Apache-2.0 lasagna release from Tuesday’s paperTyped, swappable stages and ablation designsYou want factorial ablations across many components rather than a table

Build your own

Copy this and fill it in. If you can’t fill in the last line, the project is too vague.

Goal (one sentence):
Which of this week's ideas it uses (chunking / context header / hybrid fusion / reranking / multi-hop):
Inputs and outputs:
Must-have (3 bullets max):
Nice-to-have:
Milestone 1 (done by hour 1):
Milestone 2 (done by hour 2):
Milestone 3 (done by hour 3):
How I'll know it works (a number or a test, not a feeling):

What to check before you trust any of this

  • The corpus is generated from ten templates, so exact-ID lookups are unrealistically easy once context is present. Treat the paraphrase column as the more honest signal, and the whole table as a shape, not a benchmark.
  • The dense retriever is tf-idf plus SVD. Conclusions about “dense” apply to it, not to a neural embedder.
  • The multi-hop and aggregation sets are small (12 and 1 questions at default scale). Use the 144-document run for the multi-hop rate, and treat both as demonstrations.
  • Anthropic’s numbers are from September 2024 on its own data. The Tuesday paper (arXiv 2610.29722, 21 October 2026) is the reason to expect your stack to compose worse than the table suggests.
  • The full program and our recorded run are in the lab folder.

Evidence

SourceTypeDateWhat we checked
Anthropic, Contextual Retrievalprimary19 Sep 2024Read the page: 5.7%, 3.7%, 2.9%, 1.9% failure rates, 150 candidates reranked to 20, $1.02 per million tokens and its assumptions
Azure AI Search, hybrid scoring (RRF)primaryupdated 17 Sep 2026Read the page: 1/(rank + k), k=60, semantic ranking after RRF
arXiv 2610.29722, RAG Compositionalityprimary21 Oct 2026Read the abstract page: +47% predicted against +17% delivered, best combination changes from 5K to 200K documents; not re-run here
labs/build/2026-10-31our run31 Oct 2026Ran run.py through scripts/run_lab.py: six gates passed; 4x-corpus and seed-2 runs recorded in run.json

A first table with real numbers beats a perfect harness you meant to write. If you only get as far as the first run, that’s a good Saturday, and the rest will keep for next weekend.

Researched and drafted with AI, checked against the linked sources. How we use AI →