Back to projects

AI / ML · Shipped 2026

Universal Document Intelligence Platform

A RAG system over long documents, built evaluation-first: 94.3% answer accuracy against a 40.0% no-retrieval baseline on a four-document corpus, behind a CI gate that fails the build when quality drops.

Year
2026
Role
Solo build · flagship
Stack
Python, OpenAI, Chroma, BM25, FastAPI, pytest, uv
The query tool with Apple's FY2023 10-K selected, answering a question about research and development spending with the figure and the page it came from, above the latency, cost, and passage count for that query

What it does

You give it a long document and ask a question. It answers from the document and tells you which page it read, and if the document does not contain the answer it declines instead of inventing one.

The corpus it is measured on is four documents chosen to break it in different ways: Apple’s FY2023 10-K, the original RAG paper, the Bitcoin whitepaper, and GitLab’s FY2026 10-K pulled live from SEC EDGAR as inline-XBRL HTML. Financial tables, academic prose, a protocol spec, and a 2.7 MB filing with zero heading tags in it.

Why the evaluation is the project

A retrieval pipeline is not hard to build any more, and I do not think building one demonstrates much. What is hard is knowing whether yours is any good, and being able to prove it to someone who is not inclined to believe you.

So the thing I actually built is the measurement apparatus, and the pipeline is what it measures. Every number below has a bootstrap confidence interval, a recorded git SHA, and hashes of the exact dataset and corpus that produced it.

A RAG system has two places to fail and they are independent. Retrieval can hand the generator the wrong passages, or the generator can be handed the right ones and still answer badly. A single accuracy number cannot tell those apart, so the harness taps the pipeline twice and scores each stage on its own.

Diagram of the query path. A question goes to retrieval, which draws passages from the vector index, then to generation, which produces an answer with a page citation. One measurement point sits after retrieval and asks whether a page holding the answer arrived. A second sits after generation and asks whether the answer is correct, grounded in the passages, and honestly cited. A separate path sends the same question to the model with no document at all, and the gap between the two is what retrieval is worth

What it scores

Hybrid retrieval, 35 questions across the four documents, gpt-4o-mini for generation and text-embedding-3-large for embeddings:

answer accuracy            94.3%   95% CI [88.6, 98.6]
no-retrieval baseline      40.0%
lift                      +54.3 points
retrieval hit rate         87.1%
faithfulness                0.971
hallucination on unanswerable probes    0.0%
citations pointing to a page shown       100%
latency               1.44s avg, 2.09s p95
cost                  $0.00037 per question

The baseline is the same model answering the same questions with no document at all. It is the number that says whether retrieval did anything, and on the Apple filing it is the difference between 100% and a model guessing at figures it cannot know.

The bug was in my own metric

For a while this project reported that citation accuracy on the live SEC filing was about 29%, against roughly 75% on the PDFs. I wrote it up as an honest weakness of HTML ingestion and moved on.

It was not a weakness. It was a broken measurement, and I only found it because I went back to read all six failures individually instead of trusting the aggregate.

In every one of the six, the retrieved page was right, the answer was right, and the model had cited a different page that also contained the fact. Apple’s revenue appears in the Business overview on page 10 and again in the MD&A discussion on page 93. My answer key listed only the MD&A page. The metric was measuring agreement with my gold set, not whether the citation was truthful.

The tempting fix is to add the other pages to the answer key. That is metric gaming: you widen the key until the number goes up, and you have learned nothing about the system. The real fix is a metric that does not need a curated key at all, so I added citation-supported percentage, which asks whether every page the model cited was one of the pages it was actually shown. That catches fabrication, which is the failure anyone genuinely cares about, and it needs no human labelling. It measures 100% across all 35 questions.

The old gold-based number stays in the report, relabelled as a strict lower bound at 61.3%, because deleting an inconvenient measurement is its own kind of dishonesty. The CI gate now fails below 95% supported.

Diagram. GitLab's revenue figure appears on page 10 under Item 1 Business and again on page 94 under Item 7 MD and A, but the answer key lists only page 94. The model was shown five passages including both pages and cited page 10. Scored against the key that counts as a wrong citation, which measures the key rather than the model. Scored by whether the cited page was one it was actually shown, it counts as truthful, needs no key, and still catches an invented page number

The tool shows the same thing live. Asked for GitLab’s fiscal 2026 revenue it answers from page 10, while my key lists page 94, and both pages carry the figure:

The tool answering GitLab's total revenue for fiscal 2026 as 955.2 million dollars citing page 10, with the five retrieved passages listed below it, each labelled with its page number and the Item section of the filing it came from

Those section labels are there because the filing has no pages. SEC primary documents are one continuous HTML stream, so ingestion splits them into synthetic pages and anchors each one to the nearest Item heading, which is the unit a person actually cites a filing by.

Choosing retrieval by measurement

Four strategies, same corpus, same questions:

strategyretrieval hitanswer accuracycitation accuracylatency
dense77.4%87.1%64.5%1.23s
BM2564.5%87.1%45.2%0.92s
hybrid87.1%94.3%61.3%1.24s
hybrid + rerank93.5%94.3%83.9%2.34s

Hybrid fuses dense and BM25 by Reciprocal Rank Fusion, and it wins because the two fail differently. Dense embeddings blur short specific strings, so BM25 catches the needle facts, while dense catches the paraphrases BM25 cannot match.

Reranking is the interesting row. It does not improve answer accuracy at all, it ties. What it buys is retrieval hit rate and a large jump in citation accuracy, for double the latency. So it is not the default, it is a documented choice between a fast answer and a well-sourced one.

And the honest caveat, which is in the README and the report as well as here: at 35 questions, hybrid’s 7.2 point gain over dense is not statistically significant, p is about 0.19, and the confidence intervals overlap. The retrieval-level gain is real and the downstream answer gain is not yet proven. The question set has to grow before I will claim otherwise, and I would rather publish the caveat than the headline on its own.

Chunking, where there was no winner

I swept chunk size expecting a best setting to fall out. It did not:

chunkinganswer accuracycitation precisionrecall@k
256 / 3294.4%66.7%0.875
512 / 6492.6%79.2%0.833
1024 / 12896.3%66.7%0.833

Retrieval hit rate was flat at 91.7% across all three. Bigger chunks answer better because the model gets more context, smaller chunks locate better because there is less irrelevant text attached to each hit, and the two are in genuine tension. There is no dominant setting, so the repository ships the evidence and says the choice depends on whether you are optimising for answers or for provenance.

Being able to see inside a query

The pipeline is also a small FastAPI service, and every query comes back with more than an answer: the pages it cited, the passages it was given, what it cost, and a span trace of the run. The interface is built on my own element collection, so it inherits the same tokens as this site rather than carrying a second look.

The query tool with the trace panel open, showing the top level query span at 2925 milliseconds containing a retrieve span of 2034 milliseconds that lists the five pages it fetched, and a generate span of 891 milliseconds with its prompt and completion token counts

The trace splits the same way the evaluation does, into retrieval and generation, which means a slow or wrong answer can be attributed to a stage rather than guessed at. Here retrieval is most of the two and a half seconds, because hybrid search runs a dense query and a keyword query and fuses them.

Making the numbers trustworthy

Measurement is only worth something if it is hard to fool, including by me.

  • Determinism. Temperature 0 on generation, baseline, judge, and reranker. Verified by running the same question three times and getting one answer.
  • Provenance. Every run records the git SHA plus content hashes of the dataset and corpus, so a number can always be traced to the code and data that produced it.
  • Confidence intervals and significance tests, bootstrapped, on every headline metric, with the paired test run on the difference between configurations rather than read off overlapping intervals.
  • A CI regression gate with thresholds on accuracy, faithfulness, hallucination, citation support, latency, and cost. I verified it genuinely fails, exit code 1, by breaching a threshold on purpose. A gate that cannot fail is decoration.
  • Prompt-injection probes. Three attempts to make the system follow instructions embedded in a retrieved document, three resisted.
  • A grounding probe set. Questions the corpus provably cannot answer. The system declines all of them. The no-retrieval baseline confidently invented the 21 million Bitcoin cap.

61 tests, Ruff clean, and I verified the whole pipeline by cloning the repository fresh and running it from scratch, which is how I caught that .gitignore was excluding the results file the CI gate reads.

Swapping the model out from under it

The generator is a setting now, not a hard-wired client, so I can point the whole harness at a different model and change nothing else. I ran IBM’s Granite 3.3 8B locally against gpt-4o-mini: same corpus, same retrieval, same prompt, same temperature, and the same judge grading both sides.

                                  granite3.3:8b     gpt-4o-mini
answer accuracy                   92.9%             94.3%
no-retrieval baseline             30.0%             38.6%
retrieval lift                    +62.9 pts         +55.7 pts
citations to a page actually shown 95.0%            100%

The accuracy gap is not a result. 1.4 points sits inside this harness’s own run-to-run movement: the identical gpt-4o-mini configuration has returned 94.3% and 95.7% on different days, with baselines of 38.6%, 40.0% and 42.9% on byte-identical data. So the honest sentence is that an 8B model running on my laptop answered this corpus about as well as the hosted one, not that it lost.

The difference that is real is citation. Granite pointed at a page it had never been shown 5% of the time. gpt-4o-mini never did. That is the metric I built precisely because it needs no answer key and catches fabrication rather than disagreement, and it is the number that would actually decide which model you deploy.

Retrieval mattered more to the smaller model, which is the part I did not expect. Granite knew 8.6 points less from memory, so the same retrieval bought it more: +62.9 against +55.7. A model with less in its head gains more from being handed the document.

Finding this also taught me something about my own instrument. I had verified determinism by asking one question three times and getting one answer. Across all 35 questions the aggregate still drifts a few points between runs, and that drift is now the bar any future comparison has to clear before it means anything.

What is still open

The question set is too small. 35 questions is a seed set, not a benchmark, and it is the reason the hybrid-over-dense result cannot be claimed. Growing it is the single highest-value thing left, and I write the questions myself rather than generating them, because a model-written eval set tends to ask exactly what a model finds easy.

The judge is not yet validated. Answers are graded by an LLM, and I have the harness to measure its agreement with human labels as Cohen’s kappa plus a confusion matrix, but I have not sat down and labelled the subset. Until I do, every accuracy figure here rests on an ungraded grader.

The chunking decision is unmade, deliberately. The evidence is in the repository and the tradeoff is real, and I would rather leave it open than pick one and pretend the sweep pointed at it.