The load & analysis harness

Retrieval tuning log

What was tried to improve retrieval quality, what it was measured with, and what the measurements actually supported. This page exists because the first attempt produced a confident wrong conclusion in both directions — first that a change had helped, then that it had hurt — and neither was readable from the evidence collected. The corrections are recorded here alongside the results.

Superseded figures — read this before quoting any recall number below. This log covers 2026-08-02 → 2026-08-05 and its recall figures (up to 0.8636 recall@5 on the 44-question tuning set, 0.9000 on the 60-question set) were measured against a smaller question set and an earlier corpus. The golden set is now 80 questions and the current dated snapshot is recall@1 0.5125 · recall@5 0.75 · MRR 0.6185 (2026-09-12, docs/evidence/baseline-2026-09-12/README.md), the same figure Vectors & search quotes. The two are not comparable: different questions, different corpus, and the older numbers were tuned against. Retrieval at a corpus larger than the whole estate — dotnet/efcore, 79,633 chunks, recall@5 0.950, p95 43.8 ms — is a separate trial with its own page section: Retrieval at scale (docs/evidence/efcore-trial-2026-09-12/RESULTS.md).
Every figure on this page is a measurement, not an estimate. Where a number was later found to be wrong or unreadable, the original is kept and the correction stated beside it — a tuning log that only records the wins is not evidence of anything.

The instruments, and what each one costs

Three instruments measure retrieval, and they are not interchangeable. Reaching for the expensive noisy one first was the original mistake.

InstrumentWhat it measuresCostDeterminism
Golden-set eval
/api/admin/eval/relevance
recall@1, recall@5, MRR over the golden set (44 questions when this log was written; 80 now) with known answer files $0, seconds deterministic
Search smoke check
/api/admin/smoke-run/run-now
11 canonical queries with path assertions — does the right file come back at all $0, seconds deterministic
Two-path benchmark
/api/admin/bench/run
real token cost and LLM-judged correctness, ledger loop vs grep loop ~$8, 30–90 min noisy — see below
The rule that came out of this

Tune against the free deterministic instruments. Use the paid benchmark to confirm an effect that has already been demonstrated, never to discover one — it is the only instrument that costs money and the only one that cannot resolve a small change.

What was tried

1. The cross-encoder reranker

A bge-reranker-base ONNX cross-encoder (Retrieval:CrossEncoderRerank) re-scores the top pool on the actual (query, passage) pair, which RRF fusion cannot do. Enabled 2026-08-03 on the expectation that it would fix a known precision problem.

2. A corpus fix, shipped at the same time

Chunk versioning demoted stale points by (repo, file, chunk index), so a file that shrank left orphaned tail chunks marked is_latest=true forever — and re-ingest could never clear them, because re-ingest is the path that cannot see them. RetireTailChunksAsync fixed it and contamination went 5 → 0 on the queries being tracked.

The first error was here, before any measurement. Both changes landed between the baseline run and the next one, so those two runs differ by two variables and neither result can be credited to either change. This is what forced a third run.

What was measured

Three paid benchmark runs over the same 24 curated questions, each question answered twice — once by an agent loop given only query_ledger, once by a loop given only grep/read over the source tree — with token counts taken from the API usage blocks and both answers graded by an LLM judge.

Superseded — the correctness rows in this table are withdrawn. The "ledger correct 19 / grep correct 4" split counted grep paths that hit the turn cap as WRONG ANSWERS rather than as never having finished. With the cap raised so the control can complete, the same comparison is 9 vs 10 with grep marginally AHEAD (Measured value). The token figures are also not comparable to anything measured since: these runs recorded model: None, because the config capture read a key nobody sets while the runner used a code default, so nobody can say which model produced them. That covers everything derived from those inputs too — cost per correct answer, cost-per-correct saving and the cheaper-on counts — so every numeric cell in the table below is struck through at the cell, and the only figures still standing are the configuration rows and the question counts. Kept in place because the reasoning below about paired comparison and control drift is still correct, and because deleting a withdrawn number is how it comes back.
A — 08-02 baselineB — 08-03C — 08-03
cross-encoderoffONoff
tail-chunk fixnoyesyes
questions measured242417 — run died
token saving vs grep 54.4% superseded 28.9% superseded see pairing below
cost saving vs grep 45.4% superseded 19.8% superseded —
ledger cheaper on 22 / 24 superseded 17 / 24 superseded —
ledger correct 19 withdrawn 17 withdrawn —
grep correct 4 withdrawn 5 withdrawn —
$ per correct answer $0.147 vs $1.280 withdrawn $0.200 vs $0.849 withdrawn —
cost-per-correct saving 88.5% withdrawn 76.4% withdrawn —

Read naively, B is a large regression against A, and it was first reported as one. It is not safely readable as one, for two reasons.

The control arm moved

The grep path is a control: it never touches the ledger, the index, or any retrieval setting, so nothing done on the ledger side can move it. Between A and B it moved anyway — −18.2% on input tokens and 4 → 5 on correct answers. That is model non-determinism in the agentic loop plus judge variance, and it is larger than the ledger-side difference being attributed to the reranker. Whatever the control does between two runs is the smallest difference this harness can resolve.

The headline rode a threshold sitting inside the score mass

“Correct” is a count of judge scores clearing a 0.6 pass mark. Four of the ledger’s seven failures in run B scored 0.50–0.55. Sweeping the pass mark over the same run:

pass markledger correctgrep correct
0.521 / 245 / 24
0.6 (published)17 / 245 / 24
0.813 / 243 / 24

The ledger count swings by 8 on where the line is drawn; the grep count barely moves. The threshold-independent figure for the same run is the mean judge score: ledger 0.690 against grep 0.198. That is the number to compare two configurations on, and it is now published beside the counts on the Context saving screen.

Run C — isolating the reranker

Run C repeated the 24 questions with the cross-encoder off and everything else held at run B’s state, making the reranker the only variable. Pairing the two runs question-by-question over the 17 that completed:

on 17 paired questionsencoder ON (B)encoder OFF (C)change
ledger tokens355,635324,286−8.8%
ledger mean judge score0.7410.732−0.009
ledger correct13130
grep tokens (control)518,079539,903+4.2%
grep mean score (control)0.2240.147−0.076

Result: the cross-encoder cost about 9% more tokens and bought nothing measurable in quality. The token effect (8.8%) is roughly twice the control drift (4.2%), so it is real. The quality effect (0.009) is an order of magnitude below the control drift (0.076), so it is unreadable — which is the honest way of saying the reranker did not help. It is now off.

A correction. The reranker was first reported as costing +30.5% input tokens. That figure compared B against A, which also differed by the corpus fix, so it bundled two changes. Isolated properly the cost is 8.8% of total tokens — the direction was right, the magnitude was inflated about threefold.

Root cause: prose outranks the code it describes

The free instruments found in minutes what the paid one could not resolve in three runs. From the 44-question golden set, where every question expects a code file:

measurevalue
recall@1 · recall@5 · MRR0.455 · 0.614 · 0.527
top-1 result is a document, not code45% of questions
documents as a share of all top-5 slots48% (105 of 220)
questions missing entirely that have a doc at rank 165% (11 of 17)
slots taken by REBUILD_PLAN.md / CLAUDE.md / AGENTS.md alone37 of 220

The smoke check agrees: 3 of 11 canonical queries fail, and all three fail the same way. “How are secrets loaded” returns appsettings.TeamHelp.README.md above Program.cs at 0.99 confidence — maximally confident, and wrong.

The mechanism. Both dense lanes are natural-language similarity. A document about the code matches a prose question better than the code does, because the question is prose and so is the document, while a code chunk only competes seriously in the design (intent) lane. This also explains the reranker result: a cross-encoder scores prose similarity between query and passage, so it amplifies this bias rather than correcting it.

The fix, and the guard on it

Retrieval:DocPrior scales the fused score of prose documents — .md, .markdown, .rst, .txt, .pdf only. Structured config (.json, .yml, .html, .sql) is deliberately untouched: it is frequently the correct answer to “where is X configured”, and demoting it would trade one retrieval error for another. Default 1.0 is byte-identical to no prior.

The golden set cannot choose this value on its own. All 44 questions expect code, so demoting documents improves the score almost by construction — pushed far enough it would score well while making the ledger useless at “what does the rebuild plan say”. The sweep therefore runs doc-seeking control queries at every value, and a value only ships if it improves code retrieval while leaving document retrieval intact. If no such value exists, the prior is the wrong fix and does not ship.

The sweep — 2026-08-04

Every value measured against the golden set and, at the same value, against four control queries whose correct answer is a document. The control column is the position of that expected document; a dash means it fell out of the top 5 and document retrieval is broken at that setting.

DocPriorrecall@1recall@5MRRdoc share of top-5doc-control ranks
1.0 (no prior)0.5000.6140.54532%1 1 1 1
0.80.5230.6820.57217%1 2 1 1
0.70.5000.7050.57812%1 2 1 1
0.6 ← shipped0.5000.7270.5837%1 2 2 1
0.50.4770.7270.5663%3 2 2 3
0.40.4770.6590.5490%3 – 2 3
0.3 / 0.20.4770.6590.5390%– – – –

0.6 ships. It is the maximum on both recall@5 and MRR, and every control document is still retrieved. Below 0.5 the trade turns: code recall@1 falls and documents start dropping out, so the prior is buying nothing on either side. At 0.3 document retrieval is destroyed outright — which is exactly the failure the control queries exist to catch, and the reason the golden set was not allowed to pick the value alone.

Measured effect, live

recall@5 0.614 → 0.727 (+11.3 points, +18.4% relative) and MRR 0.545 → 0.583, with recall@1 unchanged at 0.500. Documents fell from 32% to 7% of all top-5 slots. Free, deterministic, and reproducible in about two minutes — against three paid benchmark runs that could not resolve the question at all.

What the prior did not fix

The smoke check still fails 3 of 11, but the failures changed class. “How are secrets loaded” no longer returns a README — it now returns Deploy-Agent.ps1. The other two return admin.html and features.html. HTML is deliberately outside the prior, so these are a different defect, not an unfixed one: a page that mentions a concept outranking the code that implements it. Recorded rather than tuned away — widening the prior until the smoke light turns green would be fitting the instrument, which is the mistake this whole page documents.

The HTML correction — 2026-08-04

The prior first keyed on file extension, which does not say what a file is. wwwroot/docs/retrieval.html is prose about the ledger; wwwroot/admin.html is the ledger. Excluding all HTML left the ledger's own handbook outranking the code it documents on 7 of the 23 questions that still missed rank 1. HTML now counts as prose when a directory segment says so — docs/, doc/, or a public-* tree — so application HTML is untouched. recall@5 0.727 → 0.750, MRR 0.583 → 0.591.

Two more free levers, swept 2026-08-04

With the paid benchmark blocked on credits, the next two candidates were both measured on the free instruments alone. One shipped, one did not, and the one that did not is the more useful result.

Per-file diversity — shipped at 1

The index holds one point per chunk, but an agent sees files. A large multi-chunk file can therefore occupy several of the five slots anyone ever reads, with near-identical content in each. On the golden set, 30 of 220 top-5 slots (14%) were a repeat of a file already shown, across 18 of the 44 questions — one query returned the same SQL migration at ranks 4, 6, 9, 10, 13 and 17, crowding out six distinct candidates.

Retrieval:MaxChunksPerFile caps chunks per file after fusion, the priors and any rerank, then backfills the deferred chunks if the cap would leave fewer results than asked for — so it can only reorder, never truncate.

caprecall@1recall@5MRRduplicate slotscontrols
0 (uncapped)0.5000.7500.59230 / 220all intact
1 ← shipped0.5000.7500.5990all intact
20.5000.7500.59421 / 220all intact
30.5000.7500.59228 / 220all intact
recall@k cannot see this change, and that is expected. It is a known-item metric over one expected file per question, so freeing a slot only scores if the expected file was sitting just outside the window. The measured win is that five slots hold five different files instead of 3.6, and the measured guarantee is that nothing else moved: recall unchanged, MRR up 0.007, every test, CI and document control at its exact baseline rank. Shipping on a metric that did not move requires saying which metric did.

A test / CI prior — swept, and NOT shipped

The same argument as the prose prior, one class over: a test names the thing it tests, repeatedly, so it matches a question about that thing better than the implementation does. After the prose prior shipped, tests still held 17 of 220 top-5 slots and 3 rank-1 positions, CI workflows 2 more — while no golden answer is a test file. The worst case was LedgerIdCarryForwardTests.cs, a file whose entire body is a comment explaining that the test is skipped, ranking 1 for "how does the ledger fuse the code and design vectors".

It was swept exactly like the doc prior, with control queries whose correct answer is a test or a workflow.

TestPriorrecall@1recall@5MRRtest slotsdoc slotstest-control ranks
1.0 (no prior)0.5000.7500.59217191 1 3
0.80.5000.7500.5988191 2 –
0.70.5000.7500.5985201 2 –
0.60.5000.7500.5981212 5 –
0.50.5000.7500.5980214 – –
0.40.5000.7500.5980215 – –

It does not ship. recall@1 and recall@5 are identical at every value — the tests being demoted were never standing between a question and its answer. The only movement is MRR +0.006, which is what shuffling already-correct results looks like. Against that: the mildest value tested already pushed a legitimate test file out of the top 5, and by 0.5 two of the three test controls were gone. There is a second cost, visible in the doc column — demoting tests promotes documents back into the freed slots, 19 → 21, partly undoing the prior that did work.

Why this is worth recording

The prose prior and the test prior are the same idea with the same justification and near-identical supporting statistics. One was worth +11.3 points of recall@5 and the other is worth nothing. The reasoning could not tell them apart; only the sweep could. A control set that can veto is what makes a free instrument trustworthy, and it earns its keep on the attempt it rejects.

Instrument defects found along the way

Every one of these reported success while measuring nothing. They are listed because the pattern — not any individual bug — is the finding.

DefectSymptomFix
Re-runs overwrote results in place The baseline was destroyed by the run meant to be compared against it BenchRunHistory snapshots every run with the config it ran under
No noise floor A change smaller than the control’s own drift was published as a result BenchRunComparison returns inconclusive unless the effect exceeds it
Credit exhaustion looked like a skip Account ran dry on question 18 of 24; the run recorded “ok” A 400 naming a credit balance now aborts the run, as a 401 already did
Partial runs recorded ok 7 questions never ran, so the aggregate blended two configurations Any run with skips records Failed and says why
/api/search never passed queryText Site search ran dense-only while the agent path ran hybrid — two systems, one name Passes it; both paths are now the same search
/api/search embedded the query symmetrically The same defect one layer down: the design lane is an asymmetric sub-space needing the DesignQueryPrefix cue, so this endpoint still searched it with a differently-shaped vector — and the control queries were measured through it Prefixes the design vector, as the MCP path and the eval already did
Smoke check with no queries configured Reported “0 failed”, indistinguishable from healthy A run that tested nothing reports unknown, never healthy

The ceiling this tuning is now hitting

Four ranking interventions have been measured. recall@1 has been 0.500 for every one of them. Only recall@5 and MRR ever move. That is the signature of a problem ranking cannot reach, and reading the index directly says what it is: the C# chunker emits one chunk per top-level type, truncated at 6,000 characters, so a large file is represented by its usings, fields and constructor and nothing else.

filesourceindexedcoverage
Program.cs247 kB5 kB2.1%
McpService.cs279 kB6 kB2.4%
QdrantService.cs153 kB9 kB6.2%
IngestionEngine.cs97 kB5 kB6.0%
all code files ≥ 2 kB20.1 MB12.6 MB62.7%
the 71 files over 40 kB——41.6%

Six of the eleven remaining golden-set misses expect one of these files. The text that would answer them — HybridSearchAsync, the RRF fusion, the doc prior itself, the guarded rebuild endpoint — is not in the index at all, so no prior, cap, reranker or embedder can retrieve it. Program.cs is worse than truncated: its 4,700 lines of top-level statements are dropped entirely, because the fallback that indexes top-level statements only fires when a file has no type declarations, and it has two helper classes at the bottom.

Where this stands

  • Cross-encoder: off. Measured cost ~9% of tokens, measured benefit none.
  • Doc prior: live at 0.6, with HTML decided by location. recall@5 0.614 → 0.727 → 0.750, MRR 0.545 → 0.591.
  • Per-file diversity cap: live at 1. Duplicate top-5 slots 30 → 0, MRR 0.592 → 0.599, controls intact.
  • Test / CI prior: swept and rejected — then REVERSED on 2026-08-05. No recall gain at any value on the class-level corpus, so it stayed at 1.0. After method-level chunking it is live at 0.6 and worth +4.5 points recall@1. See the correction below: the answer changed because the corpus did.
  • recall@1 did not move from any ranking change — correctly diagnosed as a chunk-granularity problem, not a ranking one. The re-ingest that followed is recorded below.
  • Three smoke failures remain, now a different defect — HTML pages outranking code, which the prior deliberately does not touch.
  • Run C: incomplete at 17 of 24 — the Anthropic account ran out of credits mid-run. The 24-question figure for the encoder-off configuration does not exist yet.
  • Resolved 2026-08-05: two full 21-of-21 runs have since completed with zero skips, and the aggregate is safe to quote again — see Measured value. A run with any skipped question is now recorded as FAILED rather than reported as a result.

The re-ingest, and the tuning result that reversed — 2026-08-05

The granularity ceiling named above was tested. The corpus was rebuilt with method-level chunking over two runs costing $14.03, taking it from 12,961 to 23,594 chunks, enrichment coverage to 100%, and zero analysis failures in 23,594.

Golden set, 44 questionsBeforeSplit onlyFinal (live)
recall@10.63640.56820.6136
recall@50.86360.84090.8636
MRR0.73890.69170.7299

The first run made retrieval worse, and the per-question diff explained it in the direction opposite to the obvious guess. Splitting helps types that are genuinely huge — McpService.cs (4,840 lines) went rank 2 to rank 1 on two questions, because its old single chunk was truncated at 6,000 characters and most of the file was never indexed. It hurts types just over the threshold: AgentsMdGenerator.cs (177 lines) fell out of the top 10 entirely. Every regression sat in the 143-880 line band, where a type used to be one coherent chunk matching many query terms and was now shattered into fragments matching weakly.

Two fixes recovered it, and the second was the larger. Keeping a whole-type chunk alongside the header and members restored recall@5 exactly — but left recall@1 untouched. Five of the six lost top-1 hits sat at rank 2, beaten by near-miss siblings: ChunkerTests.cs over IngestionEngine.cs, QueryStatsService.cs over AgentQueryLogger.cs.

The correction that matters most on this page. The test prior was swept and rejected earlier — correctly, on the old corpus, where test files were single large chunks and uncompetitive. Method-level chunking turned them into sharp little member chunks that match query terms precisely, and the same knob is now worth +4.5 points recall@1 at 0.6. A tuning result is a property of the corpus it was measured on. Nothing about the original sweep was wrong; the ground moved under it.

What the re-ingest did not buy

  • Parity, not improvement. Still one question below the pre-split recall@1. The deliverable is coverage — every file now reachable at method granularity — not measured recall.
  • The test prior was tuned ON the 44-question golden set, which makes the eval set partly a training set. The 0.6-0.1 plateau is flat and the mechanism is principled, but a two-question gain fitted on the eval set is not independently validated.
  • The golden set structurally cannot see the benefit. It measures file-level known-item retrieval, so it cannot reward returning the right 50 lines instead of a 6,000-character truncated blob. Only the paid two-path bench can — see Measured value.

The train/test split, and two priors withdrawn — 2026-08-05

The tuning set had become a training set: TestPrior was swept on the same 44 questions used to report the result. Worse, none of those 44 had a test file as its right answer, while the prior demotes 2,210 chunks across 319 files — 9.3% of the corpus. A sweep against a set that cannot detect over-demotion can only ever look good. That is why it read flat from 0.6 down to 0.1, and that flatness was blindness, not robustness.

16 held-out questions were added — written from the source tree, never from ledger output — leading with the controls the tuning set lacked: eight whose right answer IS a test file, five whose right answer IS a document. The two halves then move in opposite directions:

TestPriorTuning (44) r@1Held-out (16) r@1Held-out r@5Held-out MRR
1.0 — off, now live0.54550.81251.00000.8854
0.80.56820.68751.00000.8177
0.6 — was live0.59090.62500.87500.7298
0.40.59090.43750.75000.5409
Both priors are now OFF. As TestPrior falls, tuning recall RISES and held-out recall COLLAPSES — 0.8125 to 0.4375. The "+4.5 points recall@1" reported earlier today was arithmetically correct (0.5455 to 0.5909 on the tuning half) and its interpretation was wrong: the harm on unseen questions is four times the fitted gain. DocPrior was swept the same way and 1.0 is at least as good as 0.6 on every measure, so it is off too. Its original win (recall@5 0.614 to 0.727) was measured on the pre-re-chunk corpus and against a set with only 4 document questions in 44.

Live with both priors off, over the full 60-question set:

recall@1recall@5MRR
All 600.63330.90000.7344
Tuning 440.56820.86360.6795
Held-out 160.81251.00000.8854

The held-out half scores markedly higher than the tuning half. That is expected and not a contradiction: the tuning set was assembled from questions retrieval was known to struggle with, so it is adversarial by construction, while the held-out set was written to be answerable and to exercise the priors. The two halves are not comparable to each other — only each half to itself across configurations.

Open Brain — the MOTMaster Architectural Ledger · retrieval tuning log, measurements 2026-08-02 → 2026-08-05