Part 2 · Documents → Q&A · Training a passage reader

Teaching a 1.5B model to read Pub 17: two misses, one pass

In part 1, a model fine-tuned on question → answer pairs from IRS Publication 17 scored 0.02, and the base model with the publication's passages in front of it scored 0.73. That post ended with a plan: stop trying to teach the model the facts, and teach it to read the passages retrieval hands it. This post is that plan carried out. It took three days and two attempts that didn't clear the bar. The third did: a 1.5B model that answers from retrieved passages better than the same base model reading the same passages, on 157 questions fixed before any model was scored. All three training seeds beat the bar. It is on the Hugging Face Hub.

For education only. Not tax advice. The model in this post demonstrates a training method. It is not a tax expert and makes no claim to be one. It answered 41 of 157 test questions wrong. Do not use it, or anything like it, to prepare a return; the IRS publishes Publication 17 at IRS.gov.

The result, in one table

157 held-out questions, the same retrieval for every arm (top-3 passages of 600 characters, no reranker), the same judge (gemma4:12b running locally, grading each answer against the answer key as correct / partial / wrong = 1 / 0.5 / 0):

SystemCorrect / partial / wrongJudge score
Base model alone (Qwen2.5-1.5B-Instruct)—0.073
Base model + retrieved passages (the bar)82 / 15 / 600.570
Fine-tuned reader + the same passages, seed 42 (run #160)91 / 18 / 480.637
Fine-tuned reader + the same passages, seed 43 (run #161)97 / 13 / 470.659
Fine-tuned reader + the same passages, seed 44 (run #162, published)100 / 16 / 410.688
Published reader, no passages—0.185

Paired row by row against the bar, run #162 is better on 32 questions, worse on 12 and the same on 113: +0.118, 95% interval +0.054 to +0.182. The weakest seed cleared it by much less (+0.067, interval +0.003 to +0.131), and averaged over the three seeds the gain is +0.091 (+0.028 to +0.155). So read the headline as "about +0.07 to +0.12, depending on the seed", not as exactly +0.118. The spread between seeds is about as large as the weakest seed's gain.

The Results → Verdict card for run #162: Better than the base model + your passages. Run #162, seed 44 of run #160, vs base Qwen/Qwen2.5-1.5B-Instruct, 157 test examples, trained to answer from your passages, so both are scored with the same retrieved passages. judge: answer correct 0.570 (base + your passages) → 0.688 (fine-tuned + the same passages), +0.118. Gain on these rows: fine-tuning helped 32 rows, hurt 12, no change on 113; 95% range +0.054 to +0.182, a gain beyond row-to-row noise; one training run, so re-train and re-run before relying on it. Base + passages 82 correct, 15 partial, 60 wrong, retrieval missed 31 and the model misread 29; fine-tuned + passages 100 / 16 / 41, retrieval missed 28 and the model misread 13; without passages the fine-tune scores 0.185. Retrieval served: top-3 (BM25 only), 600-char passages. Judged by ollama:gemma4:12b.
Results → Verdict for run #162. The card compares the fine-tune with the base model reading the same passages, splits the wrong answers into retrieval misses and misreads, and reminds you that one training run is one draw.

The no-passages line matters as much as the headline. On its own the reader scores 0.185: it did not memorise the publication, and it isn't supposed to. Retrieval still does the remembering, which is part 1's result. The fine-tune taught the model to read.

Why the bar is the base model with passages

A fine-tune that answers from passages is easy to make look good: compare it with the base model without passages and you get 0.07 → 0.69, a tenfold "improvement" that retrieval alone produces. The honest comparison is the base model reading the same passages for the same questions, scored by the same judge. That's the bar. The fine-tune has to beat it beyond row-to-row noise, or it hasn't earned its training run.

We also wrote the rules down before anything was scored:

What changed from part 1's plan, and why

Part 1 said the reader would be judged on the same 41 questions, against base + passages at 0.73. Two things changed that plan.

The very first reader is worth a line too. Run #152 trained on the pairs' own answers, with the right passage held out of 20% of prompts so the model would learn to say "I don't know". It learned that too well: on validation it said "the passages don't say" on 35% of questions whose answer passage was right there in the prompt. The cause was in the data. A pair's answer was written from one passage, but retrieval doesn't always return that passage, so the targets disagreed with the prompts. That led to the fix every later attempt uses.

The recipe: training equals serving

Every training question is retrieved with the project's own retrieval and rendered exactly as serving will send it: the numbered passages in the system message, then the question. A teacher model answers from those same passages, citing [1], [2], …, and its answer becomes the training target. When the passages don't hold the answer, the teacher says so, and that answer is kept as a natural "I don't know". No rows are withheld artificially. A local judge then grades every teacher answer against the pair's reference answer and drops the ones it rejects, before anything trains.

In BrewSLM that's the Documents → Q&A card on Train, with Train the model to answer from your passages and Use a teacher model's answers to the same passages as the targets ticked, and the teacher picked in the Generation model picker under it.

Attempt 1: one writer cleared it, the other didn't

The first full attempt ran the recipe twice on the same 157-question test set (bar 0.621, base + passages), changing only the model that wrote the training data, meaning the question → answer pairs and the teacher answers:

Data writerSeed 42Seed 43Seed 44
A flagship-class model0.717 · better0.752 · better0.688 · within noise
gpt-oss:120b, open weights, on our own GPU0.662 · within noise0.637 · within noise0.659 · within noise

The stronger writer beat the bar on two of three seeds. Every seed from the open-weight writer scored above the bar, but none beyond noise. A flagship model can be used for higher quality, and here it measurably was. But we publish weights only when every row of their data, test set included, came from an open-weight model we can name on the model card. So the question became: what was the open-weight pipeline getting wrong?

The data said two things:

What we changed in BrewSLM

Each failure became a general option rather than a one-off script:

The Turn your documents into a Q&A assistant card on Train, set up as for this run: Generation model, local model; Train the model to answer from your passages (ticked, implied by the teacher option); Use a teacher model's answers to the same passages as the targets (ticked) with a Teacher model picker set to the local model; Revise answers the checker rejects (one round of feedback) (ticked) with a Critic model picker, here the checker itself (its verdict's reason, no extra call); Batch (half price, slower) unticked; Calls at once 4; Keep the current test examples and grow them to resolve a gap of 0.08 (157 rows) (ticked); buttons Train again on the teacher's answers to your passages and Run again (write new pairs).
The Documents → Q&A card on Train with the options this run used. For attempt 2 the critic was gpt-oss:120b (picked by name through the API), not the checker itself, which is the default shown here.

Attempt 2: all open weights, the test set included

A new project, built from scratch from the same PDF: same cleaning, same base model, same retrieval. gpt-oss:120b wrote everything: questions, reference answers, the 157-question test set, the 157-question validation set, and the teacher answers. gemma4:12b judged. This test set is not attempt 1's, which is why the bar moves from 0.621 to 0.570, and why the two attempts' scores aren't directly comparable. Each is only meaningful against its own bar.

StageWhat came out
Generation1,064 / 1,064 passages, 3,187 training pairs, 1,054 answer-key questions (60 rewritten, 9 stripped by question repair)
Split3,168 train / 157 validation / 157 test; no question shared across them
Teacher answers3,317, of which 272 were natural "I don't know"s
Judge filter, keep = correct2,890 answers graded; 2,262 correct, 628 rejected
Critique and revise, one round628 revised; 242 fixed and kept; 386 still rejected (317 still partial or wrong, 69 turned into "I don't know")
Trains on2,779 passage-prompt rows
Training, per seedLoRA r 16 on every linear layer, 2 epochs, 348 steps, 36 to 38 minutes on an NVIDIA GB10

Then the rule, applied mechanically. Validation scores were 0.710, 0.704 and 0.723 for seeds 42, 43 and 44, so seed 44 is the pick. Its test result is better than the bar with the interval above zero (+0.054 to +0.182). All three seeds are better, and the rule needed two. Pass.

One thing we found while writing this up: the flow's own summary said the revise round "attempted 628" but the filter "dropped 317", which don't reconcile. The missing 69 were revisions that came back as "I don't know". They were correctly kept out of training, but they dropped out of every count. That's fixed: the counts now add up, and the 69 have a line of their own.

What the number does and doesn't say

Where it still fails, 57 of 157 test examples. 1: What amount is entered on line 4 if any? Expected -0-; got I don't know; judge wrong; retrieval missed it. 2: For what type of accelerated death benefits is filing Form 8853 not required? Expected: you don't have to file Form 8853 to exclude accelerated death benefits paid on the basis of actual expenses incurred. Got: Form 8853 is not needed for benefits paid on a per-diem or other periodic basis, cited as 【1†L1-L4】; judge wrong, the model named per-diem payments where the reference says actual expenses; retrieval found the passage, so the model misread it. 3: After what year are Indian tribal government bonds treated as issued by a state? Expected: after 1982, including tribal economic development bonds issued after February 17, 2009. Got: after 1982, cited 【1†L1-L3】; judge partial, the 2009 date for economic development bonds is missing; retrieval found it.
The first three of the 57 test questions it still gets wrong or partly wrong, as Results → Verdict lists them. One retrieval miss, one misread (it swapped which payments are exempt), one partial. Two show the 【1†L1-L4】 citation form.

Try it

The model is TensorGreed/brewslm-pub17-passage-reader (Apache 2.0, merged weights, about 3 GB). Its model card has the exact prompt layout it was trained on, a working transformers snippet, every number above, and how the data was made. Put it behind a retriever over Publication 17, keep the passages in the system message, and it cites the passage it used (as [1] or 【1†L1-L4】, see above) or says it doesn't know.

The same run from a script

Every option above is a field on one request. Against a running BrewSLM:

# pairs, answer key, split, teacher answers, filter, one revise round, then train
POST /api/projects/{id}/flows/documents-to-qa   {
  "backend": "ollama:gpt-oss:120b",
  "train": true,
  "answers_from_teacher": true,  "teacher_backend": "ollama:gpt-oss:120b",
  "teacher_filter": true,        "teacher_filter_keep": "correct",
  "teacher_revise_rounds": 1,    "teacher_critic_backend": "ollama:gpt-oss:120b",
  "keep_test": true,             "decision_gap": 0.08,  "concurrency": 4
}
# three seeds of the same run's exact config, as one seed group with its own lift check
POST /api/projects/{id}/evaluation/summary/check-seeds   {"experiment_id": {run}, "num_seeds": 3}
# score any finished run on validation, as a tracked Job
POST /api/projects/{id}/auto-rag/comparison/run?corpus=documents&split=val&experiment_id={run}

Publication 17 is a work of the United States government and in the public domain. The run ids in this post (#160, #161, #162) are the real ones.