Miguel Pineda Back to work
Case study

IRS Pub 17 Tax Q&A

Retrieval-augmented question answering over 333 pages of IRS tax year 2025 publications, built to measure what each retrieval technique is actually worth rather than to assemble a stack.

Personal project•Sep 2026•Python · pgvector · FastAPI · Claude
90.0%
recall@1
from 51.7% baseline
98.3%
answer accuracy
from 86.7% baseline
60
verified eval questions
188 gold pages
333
pages of IRS publications
338,000 words
The problem

Measure first, then change one thing at a time.

Most RAG projects stack every technique at once and report one number. This one starts from a deliberately naive pipeline, builds an evaluation set that can prove it wrong, and adds a single change per step. A technique stays only if it moves the metric by more than noise.

The corpus is Pub 17, Pub 501, Pub 502, the Form 1040 instructions and Schedules 1, 1-A, 2 and 3. Tax year 2025 postdates model training: it adds the Schedule 1-A deductions, a $2,200 child tax credit and a $40,000 SALT cap. A model answering from memory gets these confidently wrong, so the eval measures retrieval, not recall of pretraining.

Architecture

Two paths: ingest once, answer on request.

INGEST · OFFLINE IRS PDFsSHA-256 pinned Section-aware chunkerPDF outline · 1,508 chunks bge-base-en-v1.5embeddings Postgres 18 + pgvectorHNSW index REQUEST · POST /ask Question + JWT Role scopeSQL filter Spend capdaily, $1 Dense top 20role's files only Rerankbge-reranker · 5 Score ≥ 0.1?gate Claudecite excerpts or decline Declineno model call yes no query_log every outcome: answered, refused, failed
Hybrid full-text search and contextual enrichment are built but off by default, because neither beat this path.
Results

Reranking did most of the work.

Section-aware chunking cut the PDFs along their own outline instead of fixed 350-word windows, fixing 8 of the baseline’s 10 misses. Over-fetching 20 dense candidates and rescoring them with a cross-encoder then took recall@1 from 63.3% to 90.0%. That is the ceiling: dense recall@20 is also 98.3%, so the reranker ordered the pool as well as the pool allowed.

Recall@1 by configuration
Share of 60 questions where the top result contains the answer. n=60, so one question is 1.7 points.
AdoptedBaseline or rejected
0% 25% 50% 75% 100% Baseline: naive windows 51.7% Section-aware chunking 63.3% + cross-encoder reranking 90% Hybrid full-text, no rerank 46.7% Hybrid full-text + rerank 90% Section context, no rerank 60% Section context + rerank 90%
Show every configuration as a table
Configurationrecall@1recall@3recall@5MRR@5loose r@5answer acc.
Naive 350-word windows, dense only (baseline)51.7%78.3%83.3%0.64286.7%86.7%
Section-aware chunking63.3%86.7%91.7%0.75395.0%—
+ cross-encoder reranking, 20 → 590.0%98.3%98.3%0.93998.3%98.3%
Heading path in the embedded text (rejected)55.0%85.0%93.3%0.69696.7%—
Fold sections under 40 words (rejected)61.7%83.3%90.0%0.73493.3%—
Ablation: naive chunks + reranking90.0%93.3%96.7%0.92498.3%—
Hybrid full-text (RRF), no rerank (rejected)46.7%73.3%81.7%0.60590.0%—
Hybrid full-text (RRF) + rerank (rejected)90.0%98.3%98.3%0.93998.3%—
Generated section context, no rerank (rejected)60.0%90.0%95.0%0.74596.7%—
Generated section context + rerank (rejected)90.0%95.0%95.0%0.92296.7%—

The ablation matters more than the headline. Naive chunks with reranking reach 90.0% at recall@1 too, so once the reranker is in place, section chunking is worth about one question. It stays because it costs nothing at query time and never measured worse, but the claim it supports is small.

The one remaining miss is out of reach, not misranked. The question spells out “additional child tax credit”; its answer chunk says “ACTC” and sits at dense rank 49, outside the 20-candidate pool. Widening the pool to 50 would fix it, and it wasn’t done: that would be tuning the pipeline to a question it is scored on.

What was tried

Two adopted, two rejected.

ADOPTED

Section-aware chunking

Chunks follow the PDF outline, so tables stay whole and worked examples keep their heading.

recall@1 51.7% → 63.3%
ADOPTED

Cross-encoder reranking

bge-reranker-base rescores 20 dense candidates and keeps 5.

recall@1 63.3% → 90.0%
REJECTED

Hybrid full-text search

Postgres tsvector fused by Reciprocal Rank Fusion. Tax prose repeats “credit” and “tax” on nearly every page, so term frequency pulled noise into the top five. With reranking it changed nothing and added 15 ms at p50.

recall@1 63.3% → 46.7% without rerank
REJECTED

Contextual enrichment

Claude wrote a short context for each of 675 sections, prepended before embedding. Every difference was two questions, within noise. The contexts cost $2.44 and are committed, so the rows reproduce for free.

recall@5 98.3% → 95.0% with rerank
Why the numbers are trustworthy

An eval built to be falsifiable, not plausible.

The service

Three gates before any model call.

POST /ask wraps the pipeline in FastAPI. Every outcome, answered, refused or failed, is written to query_log with the caller, role, tokens, cost, per-stage latency and retrieved chunk ids.

1 · JWT ROLE viewer / preparer / admin scoped in SQL 2 · SPEND CAP checked before any call 429 past the cap 3 · SCORE GATE top rerank score ≥ 0.1 declines for free
The score gate was calibrated on 60 answerable and 14 verified unanswerable questions. It stops only the clearly off-topic ones; for near-topic questions the model’s own “the excerpts don’t say” is the safeguard. All 14 were declined, none answered with an invented figure.
Where the time goes: the first live request
“How much of my 2025 tips can I deduct?” as a preparer, in the container on CPU. Correct answer, cited, $0.0079.
0 ms 1,000 ms 2,000 ms 3,000 ms Retrieve (pgvector) 210 ms Rerank 20 candidates 3,017 ms Generate cited answer 2,158 ms
The unoptimized fp32 reranker is almost all of the retrieval cost. On the development GPU, dense retrieval takes 9 ms and dense plus reranking 166 ms at p50.
Shipping it

CI that spends nothing, CD to a public image.

What I’d do next

Where the remaining points are.

Read the code, run the eval.

Retrieval scoring needs no services and no API key.