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.
On this page
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
- 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
- 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.
- Swap in your embedder (45 minutes). Replace
Densewith a real model’s vectors. Keep the interface (scores(query)returns one score per chunk). - 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.
- 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
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:
| Check | Gate | Our run |
|---|---|---|
| Clause chunks + context, hybrid: hit@5 over exact, party, paraphrase | >= 0.75 | 0.870 |
| hit@5 gained by adding the context line to clause chunks | >= 0.5 | 0.754 |
| hit@5 gained by clause chunks over fixed windows (both with context) | >= 0.2 | 0.379 |
| Multi-hop questions answered by one query | <= 0.5 | 0.333 |
| Multi-hop questions answered by two chained queries | >= 0.9 | 1.000 |
| Auto-renewing agreements visible in the top 10 chunks | <= 0.5 | 0.357 |
hit@5, by chunking and query type (hybrid):
| Chunking | Exact ID | Party named | Paraphrase |
|---|---|---|---|
| Fixed 45-word windows | 0.11 | 0.14 | 0.03 |
| Fixed windows + context | 0.58 | 0.60 | 0.29 |
| Clause chunks | 0.15 | 0.17 | 0.03 |
| Clause chunks + context | 1.00 | 1.00 | 0.61 |
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
| Option | Strength | When to switch |
|---|---|---|
| This harness (numpy, local) | Zero dependencies, readable, free | You 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 you | Your 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 paper | Typed, swappable stages and ablation designs | You 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
| Source | Type | Date | What we checked |
|---|---|---|---|
| Anthropic, Contextual Retrieval | primary | 19 Sep 2024 | Read 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) | primary | updated 17 Sep 2026 | Read the page: 1/(rank + k), k=60, semantic ranking after RRF |
| arXiv 2610.29722, RAG Compositionality | primary | 21 Oct 2026 | Read the abstract page: +47% predicted against +17% delivered, best combination changes from 5K to 200K documents; not re-run here |
| labs/build/2026-10-31 | our run | 31 Oct 2026 | Ran 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 →