How to build a tiny 1.5B text-to-SQL model that beats a 7B A developer built a 1.5B parameter text-to-SQL model that outperforms a 7B model on the Spider benchmark, achieving 71.5% accuracy through self-consistency voting. The project, starting with Qwen2.5-0.5B and progressing to Qwen2.5-Coder-1.5B, highlights the importance of evaluation harnesses and the trade-off between model size and compute. I wanted to build something with an LLM using my own hands. Not wire an API into a wrapper, but take a base model, train it, measure it, break it, and serve it over HTTP. Why? Because I wanted to get my hands dirty working with an LLM, making and breaking things. Text to SQL fits exactly. You ask a question in plain English, the system writes SQL, and you can run the query to find out whether it was right. Correctness is whether the rows match the reference query's rows against the real database, which is a fact rather than a judgement. It also has a mature benchmark in Spider https://huggingface.co/datasets/xlangai/spider , 10,000 human written questions over 200 real SQLite databases, split so the test databases never appear in training. So I built it. Qwen2.5-0.5B as the base, LoRA adapters, one g5.xlarge with an NVIDIA A10G, about a dollar an hour. Continued pretraining on SQL text, then supervised fine-tuning on question and query pairs, then reinforcement learning with GRPO on top. The dashboards looked great. Reward climbing to 1.0, loss dropping cleanly through every stage. It scored 6.4%. Comically bad levels of accuracy. I didn't find that out for a while, because there was no held out evaluation. Every number I had came from data the model had trained on. The untrained base model scored 17.4%, so three stages of training had made things worse than no training at all. The fix wasn't a better model. It was building the thing that could tell me I was wrong, an evaluation harness that runs both queries against the real database and compares the rows that come back. Then I rebuilt. 6.4% to 44.6% on real schemas, then 49.7% with a proper RL reward, all still at 0.5B. Only then did I switch to Qwen2.5-Coder-1.5B, which landed at 68.1%. Sampling eight answers per question and returning whichever result most of them agreed on took it to 71.5%, against 71.2% for Qwen2.5-Coder-7B-Instruct. The 7B is still better at one attempt, 71.2% to 68.1%. The 1.5B gets there by answering eight times and voting, trading compute for the gap. That trade is the point though, and the point of this entire blog, and it's the one a lot of people are making now. Take a small model, aim it at one task, and build enough system around it that it beats something far larger at that one thing. The first version was three training stages run back to back on Qwen2.5-0.5B , a half billion param open model from Alibaba, all of them using LoRA Low-Rank Adaptation https://arxiv.org/abs/2106.09685 , which freezes the model's real weights and trains a tiny fraction of new, lightweight parameters. Continued PreTraining : keep training the base model on raw SQL text so it gets used to the shape of the language. Supervised FineTuning : show it thousands of question and correct-query pairs and have it imitate them. Reinforcement Learning with GRPO : Group Relative Policy Optimization. The model writes several answers to the same question, each is scored, and it gets pushed toward whichever ones scored above that group's average. It learns from its own attempts rather than from copied answers. Here is what those runs reported: | Stage | Steps | Loss | Data actually seen | |---|---|---|---| | Continued PreTraining | 500 | 2.46 to 0.81 | ~2.7 passes over the corpus | | Supervised FineTuning | 500 | 3.26 to 0.51 | 0.10 of one pass, about 8,000 of 78,577 examples | | Reinforcement Learning | 300 | reward 0.8 to 1.0 | 5,000 rows of the same training set | You have to understand that this was a learning project and my approach was flawed, so a few things went wrong. Every curve went the right way, and none of them meant anything, for three reasons. The data taught the wrong task : sql-create-context hands the model a schema already trimmed to exactly the columns the answer needs, so it never has to work out which table matters. Working that out is the entire job. It also saw only a tenth of that data, because the run was configured by step count rather than by passes over it. The reward could not teach anything : a formatting reward worth 1.0 fired for essentially every answer, and an exact string match reward worth 2.0 fired for almost none, so a group of four answers usually scored 1.0, 1.0, 1.0, 1.0 . GRPO scores each answer against its group's average, so when they all score the same the update is zero. The metric that tracks this hit 1.00 by step 160, meaning a large share of those 300 steps did nothing at all. And exact string matching is the wrong test anyway. It scores age 56 as zero against age 56 . There was no held out evaluation : the RL stage ran on a slice of the same data the model had been finetuned on, so every number I had measured how well it remembered its own training set. That last one is the real defect and the other two follow from it. Bad data and a broken reward are ordinary mistakes. What made them expensive is that nothing in the system could report them. The dashboards were green throughout. I stayed on the half billion param model for all of this. Not because it was good, but because a full fine tune plus a full evaluation there is about two GPU hours. When you are wrong five times in a row, that matters more than the ceiling does. The old training set handed the model schemas already trimmed to the columns the answer needed. The rebuild uses Spider with the real, complete database schemas, so the model has to find the right table among all the wrong ones. Three other things changed at the same time. Loss is computed on the answer only : A training example is the schema, then the question, then the correct query. With full schemas the schema part is roughly 75% of the tokens. If you score the model on reproducing the whole example, most of the training signal is teaching it to generate database schemas, which nobody asked for. Masking the prompt means every bit of the signal lands on the query. Two full passes over the data instead of a tenth of one : An epoch is one complete pass through the training set. The old run had covered 0.10 of one. A learning rate suited to what was actually being trained : The learning rate controls how big each update is. The old value was appropriate for nudging a model that already knew the task. LoRA starts its extra weights from scratch, so it needs a larger one. That is the single biggest change in the project. 6.4% to 44.6%. One thing I did not expect. Validation loss picked the wrong checkpoint. Epoch 2 looked slightly worse than epoch 1 on both validation loss and token accuracy, and it was 3.9 points better on real execution accuracy. Loss is a proxy for a proxy. Run the metric you actually care about, on held out data, at every checkpoint. In hindsight the old setup is a bit embarrassing, but every one of these fixes came from the failures, not from knowing better. Something that's true for most things that we do, not just machine learning. Now that the harness existed, the reward could use it. Instead of a black and white, all or nothing test, the reward is a ladder with partial credit. | Outcome | Reward | What it buys | |---|---|---| | No SQL, or it will not parse | 0.0 | nothing | | It parses as SQL | 0.2 | syntax is a solved sub-problem | | The database accepted and ran it | 0.5 | the tables and columns actually exist | The rows match the reference | 2.0 | correct | The point of the middle rungs is the zero gradient problem from before. A group of four answers where none is fully correct now scores something like 0.2, 0.5, 0.5, 0.2 instead of 1.0, 1.0, 1.0, 1.0 . There is real disagreement inside the group, so there is a real update. The 0.5 rung is aimed at one specific failure. Going into this, 88% of the model's remaining mistakes were invented column and table names. That rung pays for nothing except the columns existing. 44.6% to 49.7%. The mechanism moved in exactly the way the ladder was designed to move it. unknown column 647 → 458 down 29% unknown table 66 → 7 down 89% Two models answering the same 2,147 questions share most of their answers, and the shared ones tell you nothing. Only the questions where they disagree carry information. McNemar's test looks at exactly those. If the two models were equally good, the disagreements should split roughly evenly between them. They did not. RL fixed 251 questions and broke 142. The test says a split that lopsided happens by chance with probability below 0.001. The fixed and broken counts matter as much as the test. The headline is plus 5.1 points, but that is a net . It is not a clean sweep, and reporting it as one would hide 142 regressions. None of these throw an error. They just quietly change what the reward means, and nothing tells you it happened. Reward scaling flattens the ladder : the library divides each group's scores by how spread out that group was. The order of the rungs survives, the spacing does not, so a group whose best answer was genuinely correct ends up pushing exactly as hard as one whose best answer merely parsed. The ladder becomes a ranking. Switching that off keeps the 7.5 times gap between correct and parsed intact. The reference model is not the one you think : RL usually penalizes the model for drifting too far from a reference version of itself, measured as KL divergence. Under LoRA that reference is your adapter switched off, which is the raw base model, not your finetuned checkpoint. So the penalty was measuring how far supervised finetuning had already moved, not what RL was doing. I set it to zero rather than pull the model back toward something I had deliberately trained it away from. Row caps must not be able to change a verdict : the reward stops reading rows once it has one more than the correct answer has. Anything longer could not have matched anyway, so the cap can never turn a right answer into a wrong one. It only stops a hallucinated cross join from materializing a million rows inside the training loop. Reward hacking is when the model finds a way to score well without doing the task. It is usually discovered from a reward curve that climbs while the benchmark stays flat, which is to say after the GPU hours are gone. So before spending the GPU hours, I attacked my own reward. I wrote eleven policies that never look at the question at all, and measured how much reward each one could collect. SELECT 1 always parses and never matches. SELECT FROM