Retrieval evaluation

118 labelled queries over 413 sections of regulation text, split into 76 development queries phrased close to the regulation's own language and 32 held-out queries written the way a practitioner would describe the situation, plus 10 bare citation lookups. Relevance is judged at section granularity.

Everything here is generated by the harness in eval/run-eval.mts against the committed index and the committed gold set. The prose version, with the full method and limitations, is in docs/retrieval-eval.md.

Held-out slice

The 32 paraphrased queries, which deliberately avoid the target section's vocabulary. These are the numbers that mean something.

ConfigurationR@1R@3R@5R@10MRR@10nDCG@10Median
BM257.8%15.6%21.9%28.1%0.1720.1780 ms
Dense38.5%56.8%72.4%84.4%0.6510.6661 ms
Dense (int8)38.5%56.8%70.8%84.4%0.6570.6651 ms
Hybrid RRF (equal)17.2%30.7%43.2%55.7%0.3770.3831 ms
Hybrid RRF (weighted)38.5%56.8%72.4%84.4%0.6510.6661 ms
Hybrid + rerankbest43.2%79.7%87.5%90.6%0.7410.77611562 ms

The same numbers, as shapes

Two things the table cannot show. On the left, how the configurations converge as k grows — BM25 never catches up, and the reranker's advantage is largest at the top of the list, where it matters. On the right, what that advantage costs: latency is on a log scale because a flat vector scan answers in a millisecond and a model call takes eleven seconds, and on a linear axis every row but one would sit on zero.

Recall at k · held out

nDCG@10 against latency · held out

Citation lookups

Queries that name a section outright. This slice is here because the headline result is that fusion does not help, and that conclusion is only honest if the query class where the lexical arm is indispensable is on the page too.

ConfigurationR@1R@3R@5R@10MRR@10nDCG@10Median
BM2570.0%80.0%80.0%90.0%0.7670.7990 ms
Dense80.0%80.0%90.0%100.0%0.8370.8741 ms
Dense (int8)80.0%80.0%90.0%100.0%0.8340.8721 ms
Hybrid RRF (equal)90.0%100.0%100.0%100.0%0.9330.9501 ms
Hybrid RRF (weighted)80.0%80.0%90.0%100.0%0.8370.8741 ms
Hybrid + rerankbest100.0%100.0%100.0%100.0%1.0001.0008929 ms

Development slice

The 76 direct queries. The fusion weight was chosen here, so read these as a description of the slice rather than an independent result.

ConfigurationR@1R@3R@5R@10MRR@10nDCG@10Median
BM2542.1%58.1%64.7%78.9%0.5580.6050 ms
Dense84.6%96.1%97.8%99.3%0.9480.9521 ms
Dense (int8)84.6%94.7%97.8%99.3%0.9480.9511 ms
Hybrid RRF (equal)65.1%87.7%95.6%98.9%0.8120.8521 ms
Hybrid RRF (weighted)84.6%96.1%97.8%99.3%0.9480.9521 ms
Hybrid + rerankbest87.7%98.0%98.9%100.0%0.9740.97610575 ms

Choosing the fusion weight

Equal-weight reciprocal rank fusion is the textbook default, and it is the wrong default here. BM25 is much the weaker arm on this corpus, and giving it an equal vote drags the fused ranking below dense retrieval alone. Swept on the development slice, the best lexical weight is 0 — which is to say the honest answer is that fusion earns nothing here, and the report says so rather than reporting a hybrid number that a tuned weight quietly rescued.

0.00
0.9523
0.10
0.9462
0.20
0.9283
0.30
0.9197
0.50
0.8882
0.75
0.8647
1.00
0.8516

Lexical weight against development nDCG@10. Bars are scaled to the best value, not to zero.

Chunk size

Four full rebuilds of the index across a fourfold range of chunk sizes, each evaluated by the same harness on the same held-out (paraphrased) slice. Dense retrieval, reranker off, because the reranker sits downstream of what the chunking makes retrievable.

Target / overlapChunksR@1R@10MRR@10nDCG@10
160 / 32211737.0%84.4%0.6440.662
320 / 64shipped114738.5%84.4%0.6510.666
320 / 0107941.7%82.8%0.6850.685
640 / 12869243.2%81.3%0.6900.673

The result is a null one, and the shipped configuration is deliberately left alone. nDCG@10 moves across a range smaller than 32 queries can resolve. What does show a shape is a tradeoff rather than a winner: larger chunks put the answer at rank 1 more often, smaller ones cover more by rank 10. The nominally best row is not adopted, because choosing it on held-out numbers would fit the system to its own test set. Chunk size is a knob that gets turned by reflex; on this corpus it is not where the quality is — reranking is.

Configurations

BM25
Okapi BM25 alone (k1 = 1.2, b = 0.75), no fitted parameters
Dense
Cosine over float32 gemini-embedding-001 vectors at 768d
Dense (int8)
The same vectors quantized to int8 with one scale per vector, 4x smaller
Hybrid RRF (equal)
Dense and BM25 fused by Reciprocal Rank Fusion, k = 60, both arms weighted 1
Hybrid RRF (weighted)
The same fusion with the lexical arm weighted 0, chosen on the dev slice
Hybrid + rerank
Top 30 weighted-fusion candidates reranked listwise by gemini-3.6-flash

What this does not prove

  • The labels are one person's judgement. Every query was written after reading the corpus and every gold section was read to confirm it answers the query, but a relevant section nobody thought of counts as a miss. That penalises every configuration equally, so the ordering of the rows survives even where the absolute numbers are conservative.
  • The held-out slice is 32 queries. One query moves recall by about 3.1 points, so differences smaller than that are noise. Read the table for its ordering, not its decimals.
  • Two of the eight frameworks Verity classifies have no corpus at all. SOC 2 and ISO/IEC 27001 are copyrighted and cannot be redistributed here.
  • The reranker is a language model. Candidate order is shuffled with a fixed seed so it cannot simply echo the fusion order, and temperature is zero, but reruns still move.

Disagree with it in the playground — the same configurations, on whatever query you like.