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.
| Configuration | R@1 | R@3 | R@5 | R@10 | MRR@10 | nDCG@10 | Median |
|---|---|---|---|---|---|---|---|
| BM25 | 7.8% | 15.6% | 21.9% | 28.1% | 0.172 | 0.178 | 0 ms |
| Dense | 38.5% | 56.8% | 72.4% | 84.4% | 0.651 | 0.666 | 1 ms |
| Dense (int8) | 38.5% | 56.8% | 70.8% | 84.4% | 0.657 | 0.665 | 1 ms |
| Hybrid RRF (equal) | 17.2% | 30.7% | 43.2% | 55.7% | 0.377 | 0.383 | 1 ms |
| Hybrid RRF (weighted) | 38.5% | 56.8% | 72.4% | 84.4% | 0.651 | 0.666 | 1 ms |
| Hybrid + rerankbest | 43.2% | 79.7% | 87.5% | 90.6% | 0.741 | 0.776 | 11562 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.
| Configuration | R@1 | R@3 | R@5 | R@10 | MRR@10 | nDCG@10 | Median |
|---|---|---|---|---|---|---|---|
| BM25 | 70.0% | 80.0% | 80.0% | 90.0% | 0.767 | 0.799 | 0 ms |
| Dense | 80.0% | 80.0% | 90.0% | 100.0% | 0.837 | 0.874 | 1 ms |
| Dense (int8) | 80.0% | 80.0% | 90.0% | 100.0% | 0.834 | 0.872 | 1 ms |
| Hybrid RRF (equal) | 90.0% | 100.0% | 100.0% | 100.0% | 0.933 | 0.950 | 1 ms |
| Hybrid RRF (weighted) | 80.0% | 80.0% | 90.0% | 100.0% | 0.837 | 0.874 | 1 ms |
| Hybrid + rerankbest | 100.0% | 100.0% | 100.0% | 100.0% | 1.000 | 1.000 | 8929 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.
| Configuration | R@1 | R@3 | R@5 | R@10 | MRR@10 | nDCG@10 | Median |
|---|---|---|---|---|---|---|---|
| BM25 | 42.1% | 58.1% | 64.7% | 78.9% | 0.558 | 0.605 | 0 ms |
| Dense | 84.6% | 96.1% | 97.8% | 99.3% | 0.948 | 0.952 | 1 ms |
| Dense (int8) | 84.6% | 94.7% | 97.8% | 99.3% | 0.948 | 0.951 | 1 ms |
| Hybrid RRF (equal) | 65.1% | 87.7% | 95.6% | 98.9% | 0.812 | 0.852 | 1 ms |
| Hybrid RRF (weighted) | 84.6% | 96.1% | 97.8% | 99.3% | 0.948 | 0.952 | 1 ms |
| Hybrid + rerankbest | 87.7% | 98.0% | 98.9% | 100.0% | 0.974 | 0.976 | 10575 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.
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 / overlap | Chunks | R@1 | R@10 | MRR@10 | nDCG@10 |
|---|---|---|---|---|---|
| 160 / 32 | 2117 | 37.0% | 84.4% | 0.644 | 0.662 |
| 320 / 64shipped | 1147 | 38.5% | 84.4% | 0.651 | 0.666 |
| 320 / 0 | 1079 | 41.7% | 82.8% | 0.685 | 0.685 |
| 640 / 128 | 692 | 43.2% | 81.3% | 0.690 | 0.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.