← Back

Retrieval, Measured

kb-agent never measured its own retrieval. Three paired A/B changes later, hybrid stayed off the default.

kb-agent is the RAG agent in the system, and it answers questions about my own projects out of a small knowledge base. I planned its second milestone as a shared retrieval backbone: the classifier would call it over HTTP instead of its own vector store. That consumer shipped its own lexical retriever, so the backbone had nobody left to serve. I retired the backbone, and that left an untested assumption: kb-agent’s retrieval was good enough to be another project’s substrate. Nothing measured it, and my own kickoff note is blunt about that. Every quality claim about the search tool was a vibe.

So the milestone turned inward: measure retrieval first, then decide whether it needs to change at all.

Plate 01 The unit of the retrieval eval. A query returns ranked chunks. An objective path match judges each result against a hand-labeled gold set. The verdict is hit or miss. No LLM judge runs at this layer. QUERY question gold unit RANKS 1 2 3 GOLD path match VERDICT HIT MISS

One query, a ranked list, an objective path match. The tables below measure how often the right source file is in the top k. No LLM judge runs at this layer.

The baseline, and the two suspicions it named

27 queries, hand-labeled, weighted to the corpus rather than split evenly: 8 about projects, 5 about libraries, 10 about my plain-language notes, and 4 adversarial. The corpus is lopsided, so an even split would misrepresent what retrieval actually searches. Each label is a query with the source file or files that should come back. The harness records recall at 1, 3 and 5, plus MRR. This page prints recall@1, recall@5 and MRR. I chose k=5 to match the search tool’s own default.

There is no LLM judge at this layer, and the label is an objective path match. A judge would re-score a question a person already answered, and it would add variance and a validation burden. Retrieval quality stays separate from answer quality. A RAG system that conflates the two hides bad retrieval behind a capable model. If I measure answer quality later, that layer can have a judge. It stays off this one.

The tool takes an optional kind filter. The gap between a run with it and a run without it is the largest split on this page.

Baseline settingrecall@1recall@5MRR
Unfiltered0.6300.8890.744
With the kind filter0.8521.0000.920

The retriever is good when it knows the kind. Real usage sat on the top row, because the model mostly did not supply one.

The first run also turned two vague suspicions into named misses. I left repo boilerplate and generated report files indexed as notes on purpose, to measure whether they hurt retrieval. They sat in the top five of the misses. I also assumed the model usually passed the kind filter. It did not.

Contents

Three changes, and an A/B test for each one

Every change ran through one paired comparison layer. I vendored that layer from the classifier’s eval. I did not re-derive it. A comparison that quietly shrinks its own denominator still prints a confident number.

Plate 02 Both arms answer the same queries. The test reads only the discordant pairs. BOTH ARMS the same queries WIN LOSS TIE McNemar exact, discordant only health: dropped and unpairable rows reported
Both arms answer the same queries, and each query returns a win, a loss or a tie. McNemar’s exact test then reads the discordant pairs, which are the wins and the losses. A harness-health section reports every dropped or unpairable row. The plate shows the method and no result. It carries no query, no arm and no count. Every figure from these three comparisons sits in the cards and the tables below.

Each pair scores one outcome, and each change names its own outcome. Change 1 and change 3 score recall@5: was the right file in the top five. Change 2 scores the model’s first tool call instead, because it changes no retrieval.

Change 1PR #71

Filter the boilerplate out of the notes ingest

The notes sweep globbed whole repositories for Markdown. It indexed README files, CLAUDE.md files and generated reports alongside real content. Those files crowded the real content out of the top results. The filter excludes them from the notes sweep only. It records what it skipped, and it never drops a file in silence. The index went from 47 source files to 44.

The result: recall@5 rose from 88.9% to 92.6%, recall@1 from 0.630 to 0.741, and MRR from 0.744 to 0.807. The kind-filtered arm went from 0.852 to 0.963 at recall@1. At recall@5 the pairs are one win, zero losses and 26 ties. The claim is one recovered miss and no regressions, not the percentage.

Change 2PR #74

Steer the model to pass the kind filter

This is prompt and tool-description text, not a retrieval change. It aims at the gap in the table above, and it needed a different instrument. That harness makes one real model call per gold query, with the real system prompt and the real tool list. It then grades the first tool call. Did it reach for the search tool with a kind, and was the kind right. No tool executes and no loop runs, so the measurement is model behaviour rather than retrieval.

The result: the rate at which the model supplies a kind went from 0.370 to 0.889, and the correct-kind rate from 0.370 to 0.852. This model rejects the temperature parameter as deprecated, so I cannot pin the sampling. I ran each arm three times and the figures are medians. On the supplied kind the pairs are 15 wins, zero losses and 12 ties. On the correct kind they are 14 wins, zero losses and 13 ties. Each pair takes the majority of the three runs, so these counts do not divide out of the medians. The baseline spread was 0.333 to 0.407 and the candidate 0.889 to 0.926. Those ranges do not overlap, so the median of three is worth quoting.

This does not show that answers got better. It shows that the agent now picks the stronger setting more often. The retrieval eval already measured that setting as the stronger one.

Contents

The one I built and did not ship

A doubled rule marks a decision that was reversed.

Reversed. Plate 03 MRR identical. I built hybrid to fix one miss, and that miss stands. Declined. DENSE ONLY MRR same kept DEFAULT HYBRID MRR same miss still a miss DECLINED built it, measured it, did not ship it

MRR identical to four decimals. I built it to rescue one miss, and that miss stands.

Reversed. Change 3PR #73 · ADR-010

Hybrid lexical and dense retrieval

The whole milestone pointed at this change. The hypothesis was specific: four of the 27 queries exist for this decision. They are two pairs, and each pair sets an exact-jargon phrase against a paraphrase of the same need. I placed them where lexical and dense retrieval ought to disagree. One of them was still a miss after change 1, and it was the paraphrase half. That is the textbook dense failure a lexical leg should rescue.

I built the lexical leg against the same chunk corpus already in the vector store. I fused it with the dense ranking by Reciprocal Rank Fusion at k=60. I left k at the canonical value and did not tune it. A tune of k on the same 27 queries that judge the outcome fits the test set. Ranks fuse and scores do not: a cosine distance and a BM25 score share no scale. Any weighted blend needs a constant fitted on that same data.

Unfilteredrecall@1recall@5MRR
Dense only0.7410.9260.8068
Hybrid0.7040.9630.8068

Recall@5 went from 92.6% to 96.3%. That is the number I would have led with, and it is one query. The MRR column carries four decimals, because the identity below is the claim that column has to support.

I built hybrid to fix one thing, and it does not fix it. That paraphrase query is still a miss under hybrid. A run of the lexical leg on its own explains why. The paraphrase’s surface terms point at the notes file just as hard as the embedding does.

Plate 04 Both retrievers return the same notes file first. The project stub sits below both lists. LEXICAL top of the list one notes file DENSE top of the list one notes file SAME WRONG FILE PROJECT STUB, BELOW BOTH LISTS
Both retrievers return the same notes file at the top of the list, so the two rankings agree. BM25 on its own returns that one file as its whole top three, and the project stub it should return arrives only fourth. Dense retrieval misses the stub as well. The plate does not show a rank position or either cut-off, because neither list is placed at its measured rank. It shows one query. The prose below carries the second surviving miss, which reads the same way.

Both retrievers prefer the same wrong file. So the query was never a lexical-versus-dense failure. It is corpus crowding: a topically adjacent notes file out-competes a project stub. No fusion of two retrievers that agree can repair it. The other surviving miss reads the same way. That falsifies the premise I built the experiment on.

The result: the default stayed dense only. MRR is identical to four decimal places across both arms, and recall@1 drops. Under the kind filter, hybrid is strictly worse on every metric that moved, and that filter is the setting change 2 steers the model toward. Against that, the lexical leg is a second retriever, a second dependency, an in-memory index and a cache-invalidation surface on every search. A default should not change on a wash.

Both paths stay in the tree behind one constant. One flag re-runs the losing arm from the same checkout.

Contents

What the numbers actually support

n = 27, so a single query is 3.7 percentage points and the significance line carries more weight than the recall deltas. Two of the three changes rest on one discordant pair, at McNemar p = 1.0000. That p-value does not mean hybrid is worse. It means the experiment did not clear the bar to change a default. It also does not prove change 1. I therefore write the claim as “regressed nothing” rather than as a percentage.

One query moved at n=27 that is 3.7 points.

Plot 05 Discordant pairs: 1 one way and 0 the other, out of 27. The comparison did not clear the bar. HYBRID RIGHT, DENSE WRONG DENSE RIGHT, HYBRID WRONG VERDICT DID NOT CLEAR
One mark is one query, out of the 27 pairs the gold set scores. Each pair scores recall@5: the right file was in the top five, or it was not. One mark is therefore 3.7 percentage points at this denominator. Hybrid is right where dense is wrong in 1 of them, and dense is right where hybrid is wrong in 0. McNemar’s exact test on those pairs gives p = 1.0000. Geometry from decisions/ADR-010-hybrid-bm25-retrieval-measured-and-not-defaulted.md at c90dd9c. The plate shows only the pairs where the two arms disagree. It leaves out every tied pair, because the test does not read those. It shows the hybrid comparison alone, and it does not show the recall deltas or the MRR, which the tables above carry.

The kind-steering result is the largest effect on the page and the least comparable to the other two. It is a model-behaviour measurement over three unpinned runs against a corpus-independent instrument. It is not a retrieval measurement. I report the two separately.

Contents

What this does not establish

Contents

Stack

Python, and ChromaDB with the local all-MiniLM-L6-v2 embedding model, so retrieval needs no API key and no network. rank-bm25 runs the lexical leg. A hand-written recall@k and MRR harness measures both arms. I vendored the paired-comparison layer from the classifier’s eval. I did not rewrite it. The repo is public: github.com/sanlee-ys/kb-agent. Everything behind every figure above is checkable. That is the gold set, both eval harnesses, the decision record for the negative result, and the per-query rank tables. Read the gold set and ADR-010. The three pull requests (#71, #74, #73) each carry their own numbers.

Contents

What it demonstrates

This page shows the same discipline as the classifier, applied to a different problem. Build the measurement before the change, then let it decide. I designed the third change around a specific hypothesis, and I built it to completion. I then reported it as a result that falsifies its own premise. The miss I aimed the milestone at was corpus crowding, not a lexical-versus-dense gap.