<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pradhyumna Holla</title>
    <description>The latest articles on DEV Community by Pradhyumna Holla (@pradholla).</description>
    <link>https://dev.to/pradholla</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1040135%2Fc7bb2e12-bae3-422d-91d0-6212e8f7320e.jpg</url>
      <title>DEV Community: Pradhyumna Holla</title>
      <link>https://dev.to/pradholla</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pradholla"/>
    <language>en</language>
    <item>
      <title>How to build a tiny 1.5B text-to-SQL model that beats a 7B</title>
      <dc:creator>Pradhyumna Holla</dc:creator>
      <pubDate>Fri, 04 Sep 2026 07:10:20 +0000</pubDate>
      <link>https://dev.to/aws-builders/how-to-build-a-tiny-15b-text-to-sql-model-that-beats-a-7b-298</link>
      <guid>https://dev.to/aws-builders/how-to-build-a-tiny-15b-text-to-sql-model-that-beats-a-7b-298</guid>
      <description>&lt;p&gt;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. &lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://huggingface.co/datasets/xlangai/spider" rel="noopener noreferrer"&gt;Spider&lt;/a&gt;, 10,000 human written questions over 200 real SQLite databases, split so the test databases never appear in training.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It scored 6.4%. Comically bad levels of accuracy.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The first version was three training stages run back to back on &lt;code&gt;Qwen2.5-0.5B&lt;/code&gt;, a half billion param open model from Alibaba, all of them using LoRA(&lt;a href="https://arxiv.org/abs/2106.09685" rel="noopener noreferrer"&gt;Low-Rank Adaptation&lt;/a&gt;), which freezes the model's real weights and trains a tiny fraction of new, lightweight parameters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continued PreTraining&lt;/strong&gt;: keep training the base model on raw SQL text so it gets used to the shape of the language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supervised FineTuning&lt;/strong&gt;: show it thousands of question and correct-query pairs and have it imitate them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reinforcement Learning with GRPO&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;Here is what those runs reported:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Steps&lt;/th&gt;
&lt;th&gt;Loss&lt;/th&gt;
&lt;th&gt;Data actually seen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Continued PreTraining&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;2.46 to 0.81&lt;/td&gt;
&lt;td&gt;~2.7 passes over the corpus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supervised FineTuning&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;3.26 to 0.51&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.10 of one pass&lt;/strong&gt;, about 8,000 of 78,577 examples&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reinforcement Learning&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;reward 0.8 to 1.0&lt;/td&gt;
&lt;td&gt;5,000 rows of the same training set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data taught the wrong task&lt;/strong&gt;: &lt;code&gt;sql-create-context&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reward could not teach anything&lt;/strong&gt;: 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 &lt;code&gt;[1.0, 1.0, 1.0, 1.0]&lt;/code&gt;. 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 &lt;code&gt;age&amp;gt;56&lt;/code&gt; as zero against &lt;code&gt;age &amp;gt; 56&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There was no held out evaluation&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuilding, still at 0.5B
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix the data first
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loss is computed on the answer only&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two full passes over the data instead of a tenth of one&lt;/strong&gt;: An epoch is one complete pass through the training set. The old run had covered 0.10 of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A learning rate suited to what was actually being trained&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;That is the single biggest change in the project. &lt;strong&gt;6.4% to 44.6%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;3.9 points better&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then fix the reward
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Reward&lt;/th&gt;
&lt;th&gt;What it buys&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No SQL, or it will not parse&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It parses as SQL&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;syntax is a solved sub-problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The database accepted and ran it&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;the tables and columns actually exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;The rows match the reference&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;correct&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;[0.2, 0.5, 0.5, 0.2]&lt;/code&gt; instead of &lt;code&gt;[1.0, 1.0, 1.0, 1.0]&lt;/code&gt;. There is real disagreement inside the group, so there is a real update.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;44.6% to 49.7%.&lt;/strong&gt; The mechanism moved in exactly the way the ladder was designed to move it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown_column   647 → 458   (down 29%)
unknown_table     66 →   7   (down 89%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How I know that gain is real
&lt;/h3&gt;

&lt;p&gt;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. &lt;strong&gt;McNemar's test&lt;/strong&gt; looks at exactly those. If the two models were equally good, the disagreements should split roughly evenly between them.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fixed and broken counts matter as much as the test. The headline is plus 5.1 points, but that is a &lt;strong&gt;net&lt;/strong&gt;. It is not a clean sweep, and reporting it as one would hide 142 regressions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three library defaults that broke my reward
&lt;/h3&gt;

&lt;p&gt;None of these throw an error. They just quietly change what the reward means, and nothing tells you it happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reward scaling flattens the ladder&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reference model is not the one you think&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Row caps must not be able to change a verdict&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reward Hacking
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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. &lt;code&gt;SELECT 1&lt;/code&gt; always parses and never matches. &lt;code&gt;SELECT * FROM &amp;lt;first table&amp;gt;&lt;/code&gt; always runs. &lt;code&gt;WHERE 1 = 0&lt;/code&gt; goes after the empty result loophole. Degenerate cross joins test the row cap and the parser guards.&lt;/p&gt;

&lt;p&gt;The best of them collected &lt;strong&gt;26.8%&lt;/strong&gt; of what a real answer gets, and got &lt;strong&gt;1.8%&lt;/strong&gt; of questions outright, which is the empty answer slice and exactly where I expected it to score. So the reward is not easy to cheat, and that is a number now rather than an assumption.&lt;/p&gt;

&lt;p&gt;This probe is also what caught the reward scaling default above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teaching it to reason, and why that made it worse
&lt;/h2&gt;

&lt;p&gt;The original training data told the model to think before answering. The problem was that the "thinking" was &lt;strong&gt;one hardcoded sentence repeated across all 5,378 examples&lt;/strong&gt;. As scratch paper it is blank. It carries no information about the specific question, so all the model learns is to recite a preamble before answering exactly as it would have anyway.&lt;/p&gt;

&lt;p&gt;The proper version of this is &lt;strong&gt;rejection sampling&lt;/strong&gt;, sometimes called STaR(Self-Taught Reasoner). Take a larger model that can actually reason, have it solve each of your training questions eight times while showing its work, then &lt;strong&gt;run every attempt against the database and keep only the ones whose SQL was correct&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That filter is the entire technique. A large model is wrong confidently and fluently, and training a small model on fluent wrong reasoning is worse than not training it at all. The harness from earlier is what makes the filter possible, which is the third job that one component ended up doing.&lt;/p&gt;

&lt;p&gt;It worked as a data pipeline. 4,823 verified traces, covering 90.8% of the training questions, with a median of 114 words of reasoning that named real tables and real columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it made the model worse. 44.6% down to 41.8%.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The reasoning crowded out the SQL
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Training data&lt;/th&gt;
&lt;th&gt;Tokens the model writes&lt;/th&gt;
&lt;th&gt;Of which are SQL&lt;/th&gt;
&lt;th&gt;Share of the signal landing on the SQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Canned sentence&lt;/td&gt;
&lt;td&gt;62.6&lt;/td&gt;
&lt;td&gt;33.6&lt;/td&gt;
&lt;td&gt;53.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real traces&lt;/td&gt;
&lt;td&gt;195.1&lt;/td&gt;
&lt;td&gt;31.1&lt;/td&gt;
&lt;td&gt;15.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Training only teaches the model on the tokens it writes. With the canned sentence, it wrote about 63 tokens per answer and 34 of them were the SQL, so more than half its practice was on the query itself. With real traces it wrote about 195 tokens and the SQL was still only 31 of them. The query had not got any longer. It was just buried under 114 words of reasoning.&lt;/p&gt;

&lt;p&gt;So the model went from spending half its practice on the query to spending a sixth of it. Same number of passes over the data, same learning rate, 3.4 times less practice on the only part that gets marked. Running it for twice as many passes gave that practice back, and it came out at &lt;strong&gt;47.1%&lt;/strong&gt;, above where it started.&lt;/p&gt;

&lt;h3&gt;
  
  
  The gains didn't really stack
&lt;/h3&gt;

&lt;p&gt;Running reinforcement learning on top of the trace trained model was a wash. Plus 0.1 points, 179 questions fixed and 177 broken. Statistically that is nothing.&lt;/p&gt;

&lt;p&gt;The reason is that both interventions attack the same failure. Traces help the model name real tables and columns because the reasoning it copied named real tables and columns. The execution reward helps with exactly the same thing, and it is better at it. So the traces had already done a partial version of RL's job and left it nothing to work on.&lt;/p&gt;

&lt;p&gt;The best 0.5B model in the project stayed the straightforward one, supervised finetuning plus RL, at &lt;strong&gt;49.7%&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is in the post
&lt;/h3&gt;

&lt;p&gt;It cost real time and it did not produce the headline number, so the temptation is to leave it out. Two reasons not to.&lt;/p&gt;

&lt;p&gt;First, a negative result with a measured cause is more useful than a positive one without. "Reasoning traces did not help" is folklore. "Reasoning traces diluted the training signal on the answer by 3.4 times, and doubling the passes over the data fixed it" is something you can act on.&lt;/p&gt;

&lt;p&gt;Second, it is a clean example of the thing this whole project is about. The pipeline ran, the data was verified, the traces were genuinely good, and the model got worse. Nothing about that was visible from the training curves. The only reason I know any of it is that there was a held out number to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Did I just need a bigger model?
&lt;/h2&gt;

&lt;p&gt;At 49.7% the 0.5B model had run out of road with this training setup. The failure breakdown said the remaining errors were not the kind a better reward fixes, so I changed the base model to &lt;code&gt;Qwen2.5-Coder-1.5B&lt;/code&gt;. Three times the parameters, and pretrained on code rather than general text, which should help with a task whose output is code.&lt;/p&gt;

&lt;p&gt;Same pipeline, nothing else changed. &lt;strong&gt;67.9%&lt;/strong&gt;, and 68.1% after RL.&lt;/p&gt;

&lt;p&gt;That is a large jump and it invites the obvious objection. Maybe none of the previous work mattered and I just needed a bigger model. That is an empirical question, so I answered it with an experiment instead of an opinion. Run the &lt;strong&gt;old&lt;/strong&gt; pipeline and the &lt;strong&gt;new&lt;/strong&gt; one on &lt;strong&gt;both&lt;/strong&gt; base models. Four runs, every combination.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pipeline&lt;/th&gt;
&lt;th&gt;0.5B&lt;/th&gt;
&lt;th&gt;1.5B&lt;/th&gt;
&lt;th&gt;What scale bought&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Old&lt;/td&gt;
&lt;td&gt;4.6%&lt;/td&gt;
&lt;td&gt;46.9%&lt;/td&gt;
&lt;td&gt;+42.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;New&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44.6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;67.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+23.2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;What technique bought&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;+40.1&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;+20.9&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both effects are large and both hold up statistically. Technique at 1.5B fixed 559 questions and broke 110. Scale under the new pipeline fixed 577 and broke 78.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both matter, and they eat into each other&lt;/strong&gt;: Technique is worth 20.9 points even on a bigger, code pretrained base, so the work is not just compensating for a weak model. But that is half what it was worth at 0.5B, because the bigger model had already solved some of what the technique was fixing.&lt;/p&gt;

&lt;p&gt;The corners of the table show the same thing. Measured on its own, technique is worth 40.1 points and the bigger model is worth 42.4, so you would expect 82 from doing both. Going from the worst combination to the best actually gets you 63.3, from 4.6% to 67.9%. The missing 19 points are questions both changes would have fixed on their own, and you only get paid for those once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two things that qualify that table
&lt;/h3&gt;

&lt;p&gt;The old pipeline at 0.5B also carried a tokenizer bug that stopped generation from terminating properly, while the 1.5B version of it used a working stop token. So the +42.4 in the top row is partly "a bug was also fixed". The two numbers I actually rely on are the ones underneath it, where everything else is held fixed.&lt;/p&gt;

&lt;p&gt;And the scale column is not purely scale. The new base is both larger &lt;strong&gt;and&lt;/strong&gt; code pretrained, so those two things are tangled together in the +23.2 and I cannot separate them without a fourth base model I did not train. The technique column is clean, since that is the same base with two pipelines. The scale column should be read as "bigger and code pretrained", not "bigger".&lt;/p&gt;

&lt;p&gt;Worth saying that the old pipeline was set up to lose as gracefully as possible. Its destructive pretraining stage was left out, the library that corrupted the vocabulary was left out, the working stop token was used. It is the strongest fair version of the thing it loses to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reinforcement learning stopped helping
&lt;/h3&gt;

&lt;p&gt;Same reward, same configuration, run on the new base.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RL on the 0.5B base:  +5.1 points   251 fixed, 142 broken   p &amp;lt; 0.001
RL on the 1.5B base:  +0.2 points   122 fixed, 117 broken   not significant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Not significant" here means the fixed and broken counts are close enough that the difference is indistinguishable from chance. 122 against 117 is a coin flip.&lt;/p&gt;

&lt;p&gt;The strange part is that the mechanism still worked perfectly. Hallucinated columns fell 23%. The share of queries the database would actually run rose from 88.2% to 90.6%. Syntax errors dropped from 5 to 2. &lt;strong&gt;Everything improved except the score.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason is that the reward pays for executability, and executability had stopped being the bottleneck. At 0.5B, invented columns dominated the failures, so fixing them moved the number. At 1.5B the model already runs 90% of its queries, and what remains are queries that execute perfectly and answer the wrong question. This reward cannot see that. A query that runs and returns wrong rows scores 0.5, exactly the same as one that runs and returns wrong rows for a completely different reason.&lt;/p&gt;

&lt;p&gt;Two diagnostics from that run are worth carrying to any RL project:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;56% of training groups produced no gradient at all&lt;/strong&gt;: That is the original v0 failure arriving for the opposite reason. Back then the rollouts were uniformly wrong, so nothing separated them. Now they are uniformly right. &lt;strong&gt;Reinforcement learning gets harder as your policy gets better&lt;/strong&gt;, because it runs on disagreement inside a group, and a good model disagrees with itself less.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Training reward rose 24% while held out accuracy moved 0.2 points&lt;/strong&gt;: With entropy falling steadily the whole time. Entropy here is how spread out the model's choices are, and it falling means the model is getting more confident and less varied. That is precisely the shape of the dashboards that started this whole project. The only difference is that this time there was a held out number sitting next to it saying the gain was not real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting more out of the weights we already have
&lt;/h2&gt;

&lt;p&gt;Everything so far changes the model's weights. At 68.1% it is worth asking what the remaining 32% actually looks like, because the answer decides what you can do about it without training anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,462  correct                                        68.1%
  483  runs perfectly, answers the wrong question     22.5%   no error exists
  202  the database rejects it outright                9.4%   an error message exists
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two completely different populations. The second one has a signal attached, because when SQLite refuses a query it tells you why, in words, like &lt;code&gt;no such column: customer_name&lt;/code&gt;. The first one has nothing. The query runs, rows come back, and everything looks fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Letting it retry, which barely works
&lt;/h3&gt;

&lt;p&gt;The obvious move for the 202 is to show the model its own error and let it try again, up to three rounds.&lt;/p&gt;

&lt;p&gt;Two things make that a measurement rather than a demo. The loop &lt;strong&gt;never sees the correct answer&lt;/strong&gt;. Its only stopping signal is whether the database accepted the query, which is all that exists at inference time in the real world. And the rounds are &lt;strong&gt;batched rather than per question&lt;/strong&gt;. Round one generates all 2,147, and only the roughly 200 rejections go into round two. Looping question by question is about 50 times slower for an identical answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It bought 1.1 points.&lt;/strong&gt; I had predicted 4 and written that down beforehand.&lt;/p&gt;

&lt;p&gt;The reason is the useful part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;202  rejected on the first attempt
 63  became something the database would run    31% of rejections
 23  were actually correct                      37% of those repairs
 40  moved from "database refused it" to "runs fine, wrong rows"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two multiplications there, not one. &lt;strong&gt;An error message tells you that you are wrong. It never tells you what is right.&lt;/strong&gt; So the model fixes the complaint rather than the answer, and two thirds of its repairs migrate from the visible failure bucket into the invisible one.&lt;/p&gt;

&lt;p&gt;At 0.5B it does essentially nothing. 500 rejections, 12 repaired. A weak model told "no such column: X" still has no idea which column does exist.&lt;/p&gt;

&lt;p&gt;I do not report a significance test for retry, on purpose. The test works by weighing how many questions got fixed against how many got broken, and retry cannot break any. A query the database rejected was already wrong, so replacing it either helps or changes nothing. The broken count was zero in all four runs, which I checked rather than assumed. With nothing on the losing side, the test would call any gain significant at all, even a gain of one question. What actually means something here is the size of the gain against what it costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Voting, which works, and works where retry cannot
&lt;/h3&gt;

&lt;p&gt;The 483 queries that run cleanly and return wrong rows produce no error, so retry is structurally blind to them. But they do &lt;strong&gt;disagree with each other&lt;/strong&gt;, and disagreement is an indicator/signal.&lt;/p&gt;

&lt;p&gt;So instead of taking the model's single most likely answer, sample eight different ones, run all eight, group them &lt;strong&gt;by the rows they return rather than by the text of the query&lt;/strong&gt;, and answer with the largest group. Two queries written completely differently that return identical rows are probably both right. A hallucinated one usually returns something nobody else got.&lt;/p&gt;

&lt;p&gt;The grouping has to go through the same comparison function the benchmark uses, not a hash of the rows. That comparison ignores column order, so &lt;code&gt;SELECT age, name&lt;/code&gt; and &lt;code&gt;SELECT name, age&lt;/code&gt; count as one answer. Hashing would be faster and would split exactly the groups voting exists to merge.&lt;/p&gt;

&lt;p&gt;Two things are built in as checks rather than assumed. The first of the eight candidates is always the model's ordinary single answer, the one it would have given without voting, so setting &lt;strong&gt;k to 1&lt;/strong&gt; has to reproduce the plain score exactly. If it ever doesn't, something is wired wrong and I find out straight away. And voting at any k only looks at the first k candidates, so generating 16 once lets me score every budget from 1 to 16 without generating anything again.&lt;/p&gt;

&lt;h4&gt;
  
  
  The trap
&lt;/h4&gt;

&lt;p&gt;Every query that returns nothing looks identical to every other query that returns nothing. A broken filter, a made up condition matching no rows, and a genuinely empty answer all land in the same group and vote together. In every other group, agreement means several differently written queries arrived at the same rows, which is real evidence. In the empty group it only means several queries failed to return anything, which is not. Three people shrugging is not a consensus.&lt;/p&gt;

&lt;p&gt;So an empty group loses to any group that came back with actual rows. Both halves of that trade are measured. It is worth &lt;strong&gt;1.3 points&lt;/strong&gt;, and it costs 6 questions where the correct answer really was empty.&lt;/p&gt;

&lt;h4&gt;
  
  
  What the curve says
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;k&lt;/th&gt;
&lt;th&gt;vote@k&lt;/th&gt;
&lt;th&gt;pass@k, the ceiling&lt;/th&gt;
&lt;th&gt;Gap&lt;/th&gt;
&lt;th&gt;Gain from doubling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;68.1%&lt;/td&gt;
&lt;td&gt;68.1%&lt;/td&gt;
&lt;td&gt;0.0&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;69.2%&lt;/td&gt;
&lt;td&gt;70.1%&lt;/td&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;+1.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;70.6%&lt;/td&gt;
&lt;td&gt;72.8%&lt;/td&gt;
&lt;td&gt;2.2&lt;/td&gt;
&lt;td&gt;+1.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;71.7%&lt;/td&gt;
&lt;td&gt;75.4%&lt;/td&gt;
&lt;td&gt;3.7&lt;/td&gt;
&lt;td&gt;+1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;16&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;72.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;76.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+0.4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;vote@k&lt;/code&gt; is what the system actually answers with. &lt;code&gt;pass@k&lt;/code&gt; is whether &lt;strong&gt;any&lt;/strong&gt; of the k candidates was correct, which you can only know by checking the answer key afterwards. It is the ceiling a perfect chooser could reach.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozf60jz2yqvcazcsgffe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozf60jz2yqvcazcsgffe.png" alt="vote@k against pass@k" width="800" height="566"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Voting saturates and the ceiling does not. Each doubling buys 1.2, then 1.4, then 1.1, then 0.4. The last doubling returns a third of the one before it. But &lt;code&gt;pass@k&lt;/code&gt; keeps climbing, so &lt;strong&gt;the gap between them widens from 0.0 to 4.8 points&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That gap is right answers the model generated and threw away. At 16 samples the bottleneck is no longer producing a correct query. It is recognising the one already in hand. Measured at k = 8 alone this looks like a technique with room left in it, and it is not.&lt;/p&gt;

&lt;h4&gt;
  
  
  Where the gain comes from
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;greedy result  →  vote result
    correct    →  correct      1452
  ran, wrong   →  ran, wrong    453
   rejected    →  rejected      106
   rejected    →  correct        45
   rejected    →  ran, wrong     45
  ran, wrong   →  correct        38   ← invisible to retry
    correct    →  ran, wrong      8

fixed 83, broke 8    p &amp;lt; 0.001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Significance testing is legitimate here, because voting can and did break things. Eight regressions makes the comparison two directional again.&lt;/p&gt;

&lt;p&gt;There are two separate results in that table. Voting reaches the bucket retry cannot see at all, 38 questions. And it &lt;strong&gt;beat retry on retry's own ground&lt;/strong&gt;, repairing 45 rejected queries against retry's 23. Seven more samples turns out to be a better repair mechanism than one error message, which is worth knowing before building anything cleverer than sampling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why voting survives on a weak model and retry doesn't
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;              retry    voting
1.5B          +1.1      +3.5
0.5B          +0.2      +3.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Retry needs a model good enough to act on feedback. Voting only needs one that is right sometimes&lt;/strong&gt;, and then fishes that answer out. The second is a far weaker requirement, which is why voting survives at a model size where retry collapses entirely.&lt;/p&gt;

&lt;p&gt;That asymmetry is only visible because both techniques were measured at both sizes. Told as a 1.5B story, it would have been thrown away.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reliability check that costs nothing
&lt;/h2&gt;

&lt;p&gt;Voting requires running eight or sixteen candidates anyway. Once you have done that, you already have something else for free, and I think it is the most useful output of the whole project. &lt;strong&gt;How much did the candidates agree with each other?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agreement across 16 samples&lt;/th&gt;
&lt;th&gt;Questions&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;th&gt;How often correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;All 16 agree&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,405&lt;/td&gt;
&lt;td&gt;65.4%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;85.9%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 to 15 agree&lt;/td&gt;
&lt;td&gt;274&lt;/td&gt;
&lt;td&gt;12.8%&lt;/td&gt;
&lt;td&gt;60.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 to 11 agree&lt;/td&gt;
&lt;td&gt;377&lt;/td&gt;
&lt;td&gt;17.6%&lt;/td&gt;
&lt;td&gt;46.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing ran at all&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;4.2%&lt;/td&gt;
&lt;td&gt;0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffeyiofr5thu3k8fb4yxe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffeyiofr5thu3k8fb4yxe.png" alt="Accuracy at every level of self agreement" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When the model agrees with itself it is right 86% of the time, and that covers two thirds of all questions. When it does not, it is close to a coin flip(46.2%).&lt;/p&gt;

&lt;p&gt;In a product that distinction is worth more than the 3.5 points voting adds to the score. It is the difference between a system that silently returns a wrong number and one that can say "I am not confident about this one, check it". A wrong SQL answer does not look wrong. It looks like a number in a cell.&lt;/p&gt;

&lt;h3&gt;
  
  
  The group that only appears at sixteen samples
&lt;/h3&gt;

&lt;p&gt;91 questions where not one of sixteen attempts produced a query the database would run. Not a wrong answer among them, because there was never an answer at all. Accuracy there is 0.0%, and it is 0.0% by definition rather than by bad luck.&lt;/p&gt;

&lt;p&gt;At eight samples these hide inside the low agreement band, indistinguishable from questions the model merely found hard. At sixteen they separate cleanly, and that separation is the whole value of them. The service can recognize these before it answers rather than after. Saying nothing on 4.2% of questions costs a great deal less than being confidently wrong on them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three things that keep it honest
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Each band is scored on its own members&lt;/strong&gt;: An earlier version of this table lumped 7-of-8 agreement in with 6-of-8 and reported the pair as 62.8%. Scored separately, 7-of-8 was 62.8% and 6-of-8 was 47.9%. Lumping them made the weaker half look 15 points better than it was, and anyone trusting that number would have trusted answers they should not have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bands are deliberately wide&lt;/strong&gt;: The middle of the chart is a mess. 5-of-16 scores 33% while 1-of-16 scores 55%, but those are only 27 and 33 questions each, so the gap between them is chance rather than anything real. The order is wrong too, with less agreement sometimes scoring better than more. Splitting that middle into finer bands would be reporting a precision that is not there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One answer gets no confidence at all&lt;/strong&gt;: If the model only answered once, there is nothing to compare it against, so the service returns "unmeasured" instead of a number. A confidence figure nobody has checked is worse than no figure.&lt;/p&gt;

&lt;p&gt;The same pattern shows up on the 0.5B model, just lower, with unanimous answers correct 80.7% of the time instead of 85.9%. That is what makes this a property of the approach rather than a fluke of one particular model.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is left is a choosing problem
&lt;/h3&gt;

&lt;p&gt;The vote vs ceiling gap from the last section has a specific shape. &lt;strong&gt;Voting picks a wrong answer while holding a correct one 3.6% of the time.&lt;/strong&gt; That looked like the cheapest remaining win in the project. No training, no GPU, the candidates are already sitting on disk.&lt;/p&gt;

&lt;p&gt;So I looked at the failures before building anything. &lt;strong&gt;87% of them are cases where the wrong answer won six votes to two.&lt;/strong&gt; The model is confidently and consistently wrong, and no rule based on counting votes can override a 6-2 majority.&lt;/p&gt;

&lt;p&gt;I built two smarter selectors anyway and measured them at +0.1 and −0.1 points. Dead end, and worth the hour it took to prove rather than the week it would have taken to build. Closing that gap needs a model trained to score candidates against the question, not a tiebreak rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is the part I would keep
&lt;/h3&gt;

&lt;p&gt;If I had to throw away everything in this project except one idea, it would be this one. Accuracy tells you how often a system is right on average. It tells a user nothing about the answer currently on their screen.&lt;/p&gt;

&lt;p&gt;Self agreement tells you about &lt;em&gt;this&lt;/em&gt; answer, not the average one, it costs nothing once you are sampling, it needs no extra model, and it works well enough to act on. The more valuable output of a system like this is not being right more often. It is knowing when to shut up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that would matter in production
&lt;/h2&gt;

&lt;p&gt;Every score so far has handed the model the complete, correct schema for the exact database the question is about. Ask about students, get the student database. Nothing else on screen.&lt;/p&gt;

&lt;p&gt;No real deployment works like that. A company's warehouse has hundreds or thousands of tables, nobody knows in advance which database a question belongs to, and the whole thing does not remotely fit in a model's context window. Something has to search the schema first, pick out the handful of tables that look relevant, and pass only those to the model. If that search picks wrong, the model never had a chance.&lt;/p&gt;

&lt;p&gt;This section takes the gift away and measures what it was worth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building a test that can measure anything
&lt;/h3&gt;

&lt;p&gt;My first design was wrong, and the reason is worth a paragraph.&lt;/p&gt;

&lt;p&gt;Spider's databases are tiny. The median one has &lt;strong&gt;4 tables and 19 columns&lt;/strong&gt;, and writing it into a prompt as &lt;code&gt;CREATE TABLE&lt;/code&gt; statements costs about 102 tokens. Searching for the right table among four is not a search problem. Any method scores near perfect and you learn nothing.&lt;/p&gt;

&lt;p&gt;The obvious fix is to throw all 206 databases into one big pile and search that. This fails badly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;125 table names appear in more than one database
436 of 1,053 tables affected  (41%)

customers  → 22 databases      student → 12
addresses  → 15                staff   → 11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 22 different tables called &lt;code&gt;customers&lt;/code&gt; in that pile, belonging to 22 unrelated databases. A question asking about customers genuinely does not say which one it means. &lt;strong&gt;No search method can resolve that&lt;/strong&gt;, however good, because the information needed to resolve it is not in the question. A test built this way would measure an impossible task and then blame the retriever for failing it.&lt;/p&gt;

&lt;p&gt;So I built a &lt;strong&gt;collision free&lt;/strong&gt; pool instead. Take databases one at a time, in order of how many benchmark questions they carry, and skip any database whose table names clash with one already taken. That leaves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;81 databases, &lt;strong&gt;300 tables&lt;/strong&gt;, 1,427 columns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1,457&lt;/strong&gt; of the 2,147 test questions&lt;/li&gt;
&lt;li&gt;No two tables anywhere in the pool sharing a name&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of those 300 tables, only 107 belong to databases that carry any questions. The other &lt;strong&gt;193 are pure noise&lt;/strong&gt;, sitting there to be wrong answers.&lt;/p&gt;

&lt;p&gt;And the pool genuinely does not fit. Written out as &lt;code&gt;CREATE TABLE&lt;/code&gt; statements it comes to &lt;strong&gt;8,262 tokens, against the 3,072 token prompt limit&lt;/strong&gt; these models were evaluated under. Nearly three times over. Search is not an optimization here, it is the only way the prompt fits at all.&lt;/p&gt;

&lt;p&gt;One thing to carry through the rest of this section: every number below is over &lt;strong&gt;1,457 questions, not 2,147&lt;/strong&gt;. Putting a retrieval number next to a number from earlier in this post is comparing two different tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two ways to search
&lt;/h3&gt;

&lt;p&gt;Each table becomes one short searchable document, just its name and its column names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;student: id, name, age, dept_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search query is &lt;strong&gt;the question text and nothing else&lt;/strong&gt;. Never the database name. That is deliberate and important: if the retriever knew which database a question came from, it could just look up that database's tables directly, and every number here would be measuring an answer key rather than a search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25&lt;/strong&gt; is keyword matching, the classic search engine approach. A table scores higher when more of the question's words appear in it, and rare words count for more than common ones. Matching the word "hangar" tells you a lot, matching "the" tells you nothing.&lt;/p&gt;

&lt;p&gt;One detail carries a surprising amount of weight. Schema names are written like &lt;code&gt;city_code&lt;/code&gt; or &lt;code&gt;singerID&lt;/code&gt;, and people ask questions using words like "city" and "singer". A normal tokenizer treats &lt;code&gt;city_code&lt;/code&gt; as one indivisible thing, so a question about cities can never match it. Mine splits on both &lt;code&gt;snake_case&lt;/code&gt; and &lt;code&gt;camelCase&lt;/code&gt; and keeps the joined form as well, so &lt;code&gt;city_code&lt;/code&gt; becomes &lt;code&gt;city_code&lt;/code&gt;, &lt;code&gt;city&lt;/code&gt; and &lt;code&gt;code&lt;/code&gt;. Bridging that gap between English and identifiers is most of what this job actually is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dense retrieval&lt;/strong&gt; matches on meaning instead of words. A small embedding model turns each table description into a list of numbers, positioned so that texts meaning similar things sit near each other. Do the same to the question, and the nearest tables win. It can match "how many people are enrolled" to a &lt;code&gt;student&lt;/code&gt; table without either phrase sharing a single word with the other.&lt;/p&gt;

&lt;p&gt;Both methods return the top &lt;strong&gt;k&lt;/strong&gt; tables, where k is just how many you decide to hand the model. Here is how often each finds what the question needs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;k&lt;/th&gt;
&lt;th&gt;BM25 recall&lt;/th&gt;
&lt;th&gt;BM25 coverage&lt;/th&gt;
&lt;th&gt;Dense recall&lt;/th&gt;
&lt;th&gt;Dense coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;33.9%&lt;/td&gt;
&lt;td&gt;25.1%&lt;/td&gt;
&lt;td&gt;58.3%&lt;/td&gt;
&lt;td&gt;44.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;63.9%&lt;/td&gt;
&lt;td&gt;53.8%&lt;/td&gt;
&lt;td&gt;88.1%&lt;/td&gt;
&lt;td&gt;79.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;73.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92.6%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;78.6%&lt;/td&gt;
&lt;td&gt;70.7%&lt;/td&gt;
&lt;td&gt;95.1%&lt;/td&gt;
&lt;td&gt;90.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The column that matters is &lt;strong&gt;coverage&lt;/strong&gt;, not recall, and the difference between them is not a technicality.&lt;/p&gt;

&lt;p&gt;Recall is the share of needed tables that were found. Coverage is the share of questions where &lt;strong&gt;every&lt;/strong&gt; needed table was found. Say a question needs to join two tables and the search finds one of them. Recall calls that 50%, a decent partial score. But the question is exactly as unanswerable as if the search had found nothing at all, because you cannot write half a join. Coverage scores it zero, which is the truth.&lt;/p&gt;

&lt;p&gt;Dense wins clearly, which is not surprising given that questions are English and schemas are identifiers. BM25 is in here as the honest baseline, since it is free and needs no GPU, and if it had come close that would have been worth knowing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs end to end
&lt;/h3&gt;

&lt;p&gt;Now run the actual model on what each search method hands it. Four conditions, identical in every respect except which schema text goes into the prompt, all over the same 1,457 questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Oracle&lt;/strong&gt; is a cheat. It shows the model exactly the tables the correct answer uses and nothing else. Not achievable in reality, since knowing which tables the answer needs is most of the problem. It is here as a ceiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gold&lt;/strong&gt; is the whole correct database, which is what every number earlier in this post used. Re-measured here on the smaller question set so it compares fairly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dense&lt;/strong&gt; and &lt;strong&gt;BM25&lt;/strong&gt; are the real conditions, picking 10 tables out of 300.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Schema shown&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;Queries that run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Oracle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;exactly the tables the answer uses&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;68.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;92.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gold&lt;/td&gt;
&lt;td&gt;the whole correct database&lt;/td&gt;
&lt;td&gt;63.5%&lt;/td&gt;
&lt;td&gt;89.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dense, top 10&lt;/td&gt;
&lt;td&gt;10 tables out of 300&lt;/td&gt;
&lt;td&gt;45.2%&lt;/td&gt;
&lt;td&gt;66.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25, top 10&lt;/td&gt;
&lt;td&gt;10 tables out of 300&lt;/td&gt;
&lt;td&gt;37.1%&lt;/td&gt;
&lt;td&gt;57.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzz88bz5y92kbahz1uik7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzz88bz5y92kbahz1uik7.png" alt="All eight retrieval conditions" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two findings, and I predicted neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Showing less schema helps, if it is the right schema&lt;/strong&gt;: Oracle beats gold by 4.9 points, and gold is only showing tables from the correct database. Every irrelevant table costs something, even a related one.&lt;/p&gt;

&lt;p&gt;That is the exact opposite of what was true in training, where trimmed schemas were actively harmful because the model never learned to pick a table out of a crowd. Training on clutter is necessary. Having no clutter at question time is pure upside.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Most of the loss is distraction, not absence&lt;/strong&gt;: This is the one that surprised me. Split the dense results by whether the search actually found everything the question needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;all needed tables present   1,257 questions (86.3%)   accuracy 51.9%
at least one missing          200 questions (13.7%)   accuracy  3.0%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The missing group behaves exactly as you would expect. 3.0% is effectively zero, and those questions were unanswerable the moment the search missed. Fine.&lt;/p&gt;

&lt;p&gt;But look at the other group. &lt;strong&gt;Every table the question needed was sitting right there in the prompt, and accuracy was still only 51.9% against oracle's 68.4%.&lt;/strong&gt; Sixteen and a half points destroyed purely by the nine irrelevant tables sitting next to the right ones. The share of queries the database will even run drops from 92.1% to 66.8%, because the model keeps reaching for a plausible looking table that belongs to some completely unrelated database.&lt;/p&gt;

&lt;p&gt;Stacking those two costs together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;oracle                             68.4%
  minus distraction  16.5 points →  51.9%   right tables present, plus nine more
  minus absence       6.7 points →  45.2%   13.7% of questions miss a table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Distraction costs two and a half times what absence does.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had predicted 58 to 61% before running this, by reasoning that accuracy would be roughly coverage multiplied by the baseline. In other words, I assumed that finding the tables was the whole problem, and that once found, the model would perform as it always had. The gap between that prediction and the real 45.2% is exactly the distraction cost, which my mental model of the system had no room for at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrieving fewer tables does not help
&lt;/h3&gt;

&lt;p&gt;If distraction is the dominant cost, then handing the model fewer tables should reduce it. I wrote that prediction down before testing it. Six more runs, both search methods at three different values of k, say it is wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;k&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Accuracy when covered&lt;/th&gt;
&lt;th&gt;Accuracy when missing&lt;/th&gt;
&lt;th&gt;Accuracy overall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dense&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;79.2%&lt;/td&gt;
&lt;td&gt;55.6%&lt;/td&gt;
&lt;td&gt;1.0%&lt;/td&gt;
&lt;td&gt;44.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dense&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.3%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;51.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;45.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dense&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;90.3%&lt;/td&gt;
&lt;td&gt;49.4%&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;td&gt;44.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;53.8%&lt;/td&gt;
&lt;td&gt;63.6%&lt;/td&gt;
&lt;td&gt;2.2%&lt;/td&gt;
&lt;td&gt;35.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;64.1%&lt;/td&gt;
&lt;td&gt;57.1%&lt;/td&gt;
&lt;td&gt;1.3%&lt;/td&gt;
&lt;td&gt;37.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;70.7%&lt;/td&gt;
&lt;td&gt;54.1%&lt;/td&gt;
&lt;td&gt;2.6%&lt;/td&gt;
&lt;td&gt;39.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgagaaho5j0wn17uhxbcv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgagaaho5j0wn17uhxbcv.png" alt="Coverage rising while covered accuracy falls" width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Look at the dense rows first. Overall accuracy reads 44.3, 45.2, 44.8 across a fourfold change in how many tables get retrieved. Flat enough that you would reasonably conclude k does not matter much.&lt;/p&gt;

&lt;p&gt;That conclusion would be wrong, and the last two columns show why. &lt;strong&gt;Two opposing forces are cancelling out.&lt;/strong&gt; Coverage climbs from 79% to 90%, because searching wider finds the right tables more often. Accuracy on questions that were already covered falls from 56% to 49%, because every extra table is another distraction. One goes up, the other goes down, and the total sits still.&lt;/p&gt;

&lt;p&gt;BM25 starts so starved of recall, only 54% coverage at k=5, that the coverage gain outweighs everything else and its overall number climbs from 35.3 to 39.0. Its covered accuracy falls on exactly the same schedule though, 64% to 57% to 54%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether raising k appears to help depends entirely on whether your search method still had recall left to gain.&lt;/strong&gt; It never depends on the distraction going away, which got worse in every single condition tested.&lt;/p&gt;

&lt;p&gt;So the honest reading is narrower than "retrieve less". The number of tables you retrieve is not a dial worth tuning. It trades coverage against distraction at roughly one for one, and the 16.5 point distraction penalty survives every setting I tried. What would actually move this is making the model itself robust to irrelevant tables, by training it on cluttered schemas instead of clean ones. That is a training change, not a search change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to run
&lt;/h2&gt;

&lt;p&gt;A project can report 68.1% accuracy and say nothing at all about whether an answer takes 200 milliseconds or 30 seconds. So the last piece is an HTTP service, and a benchmark of it.&lt;/p&gt;

&lt;p&gt;The service &lt;strong&gt;reuses the voting and retry code directly&lt;/strong&gt; rather than reimplementing it. That is the point. A second copy of the clustering or selection logic could drift away from the numbers those techniques were measured at, and nothing would tell you. A request returns the SQL, the rows, a confidence band from the agreement table, and a timing breakdown.&lt;/p&gt;

&lt;p&gt;Measured on one A10G handling &lt;strong&gt;one request at a time&lt;/strong&gt;, which is what a service does, unlike the evaluation harness that batches sixteen together. Over the full test split, so the accuracy column is real evidence rather than a spot check.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Accuracy&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;Mean&lt;/th&gt;
&lt;th&gt;Queries/sec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greedy&lt;/td&gt;
&lt;td&gt;67.9%&lt;/td&gt;
&lt;td&gt;2,652 ms&lt;/td&gt;
&lt;td&gt;5,100 ms&lt;/td&gt;
&lt;td&gt;7,162 ms&lt;/td&gt;
&lt;td&gt;3,088 ms&lt;/td&gt;
&lt;td&gt;0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vote, 8 samples&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;71.3%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5,650 ms&lt;/td&gt;
&lt;td&gt;10,789 ms&lt;/td&gt;
&lt;td&gt;15,272 ms&lt;/td&gt;
&lt;td&gt;6,522 ms&lt;/td&gt;
&lt;td&gt;0.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry, 3 rounds&lt;/td&gt;
&lt;td&gt;68.9%&lt;/td&gt;
&lt;td&gt;2,714 ms&lt;/td&gt;
&lt;td&gt;9,739 ms&lt;/td&gt;
&lt;td&gt;16,042 ms&lt;/td&gt;
&lt;td&gt;3,777 ms&lt;/td&gt;
&lt;td&gt;0.26&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;p50 is the median request. p95 is the slowest one in twenty. p99 is the slowest one in a hundred.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzf7zyt76h0radvmarv36.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzf7zyt76h0radvmarv36.png" alt="Latency by percentile for all three modes" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That accuracy column is doing quiet work. It reproduces the harness from a completely different code path with different batching, giving +3.4 for voting and +1.0 for retry against the harness's +3.5 and +1.1.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tail is the finding
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Retry's median is indistinguishable from plain greedy&lt;/strong&gt;, 2,714 ms against 2,652, because most requests never enter the loop at all. Every bit of its cost sits in the tail.&lt;/p&gt;

&lt;p&gt;Its mean of 3,777 ms hides that from both directions simultaneously. It is 40% above a median that describes most requests, and 76% below a p99 that describes the requests a user actually complains about. It describes neither.&lt;/p&gt;

&lt;p&gt;Voting is the opposite shape. It costs 2.1 times more at the median, but its p99 is only 2.7 times its own median, so what you measure in staging is what you get in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mean says retry is cheaper. The tail says voting is more predictable.&lt;/strong&gt; Users experience the tail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5dmjvgomlqvp1oplr8vo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5dmjvgomlqvp1oplr8vo.gif" alt="Three questions answered live against a database the model has never seen" width="759" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That last one is the whole system running. Three questions against a test database, answered by the service. The first two are correct and unanimous. The third asks for distinct types of planes owned by pilots, and returns four plausible plane names from the wrong table. Nothing in the SQL or the output reveals that. The only sign is one sample out of eight disagreeing, which drops the confidence badge from high to medium.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it ran on
&lt;/h2&gt;

&lt;p&gt;All of this is one &lt;code&gt;g5.xlarge&lt;/code&gt; in us-east-1. A single NVIDIA A10G with 24GB, four vCPUs, $1.006 an hour on demand. The three serving benchmarks above took ~1.8, ~4 and ~2.5 hours respectively, which is most of a working day of GPU time spent purely on measuring something I had already built.&lt;/p&gt;

&lt;p&gt;That ratio is the honest summary of the project. Far more compute went into finding out whether things worked than into making them work.&lt;/p&gt;

&lt;p&gt;Four pieces of infrastructure earned their place:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic shutdown when idle&lt;/strong&gt;: A cron job checks GPU utilization every minute and shuts the box down after 30 minutes below 5%. A forgotten &lt;code&gt;g5.xlarge&lt;/code&gt; is about $170 a week, and this is the single highest value cost control in the project.&lt;/p&gt;

&lt;p&gt;But the first version measured the wrong thing. &lt;strong&gt;GPU utilization alone is not "is this machine in use".&lt;/strong&gt; An evaluation run spends real minutes loading weights, building prompts, executing SQL against SQLite and scoring results, all at 0% GPU. That script shut down a live run twice before I fixed it. Idleness is now graded. If a process is still holding GPU memory, or a human is still logged in, the limit becomes 180 minutes instead of 30. Not immunity, because "forgot to log out" must not cost $170 a week either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rescuing work from spot instances&lt;/strong&gt;: Spot instances are 50 to 60% cheaper, and AWS can reclaim them with a two minute warning delivered through the instance metadata service. Reclaiming means terminate, not stop, so the disk goes with it. A service polls for that warning and syncs checkpoints to S3 when it arrives. Two minutes is enough to do one thing quickly and not enough to do anything clever.&lt;/p&gt;

&lt;p&gt;It is not the primary mechanism though. A missed notice or a kernel panic gives no warning at all, so checkpoints also go to S3 on every save. Resuming is not restarting the same machine, it is a fresh machine pulling the last checkpoint from S3 and carrying on from that step, which I tested by killing a run and deleting its disk.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One thing to note is that I never actually ran on spot. AWS took forever to process my quota request and by the time it came through, most of the project was already done. So this is built and tested but not battle worn. It is a genuinely good feature and half the price is half the price, so if you use it, let me know how it holds up.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never mirror a checkpoint directory to S3.&lt;/strong&gt; The tidy way to stop S3 filling up with old checkpoints is &lt;code&gt;aws s3 sync --delete&lt;/code&gt;, which mirrors the local directory. It is also one fresh instance away from deleting the only copy of a run, because a new machine's checkpoint directory is empty and mirroring an empty directory deletes everything. Old checkpoints are about 110MB each. Letting them pile up is far cheaper than the failure they prevent.&lt;/p&gt;

&lt;p&gt;Also, I originally set everything up in us-east-2, because GPU instances ran about 30% cheaper there. Then I found out I could not launch an instance at all.&lt;/p&gt;

&lt;p&gt;AWS caps how many of each instance type you can run, per region, and those caps start at zero for GPU instances until you ask for an increase. Mine in us-east-2 was zero and stayed zero, so the cheaper price was for something I was not allowed to buy. Everything moved to us-east-1, where I already had approval for a few. Check your limit in a region before you plan around its pricing.&lt;/p&gt;

&lt;p&gt;Also worth knowing: a stopped instance still charges you for its disk. Mine was about $16 a month for a 200GB volume doing nothing at all. If you are finished with a machine, terminate it rather than leaving it stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would build next
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Something trained to pick the answer&lt;/strong&gt;: At sixteen tries the model writes a correct query for 77% of questions and picks it only 72% of the time. Those 4.8 points are right answers it already produced and threw away. I tried two hand written rules for choosing better and they scored +0.1 and −0.1, so this needs a model trained to judge candidates against the question, not another rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Training on messy schemas&lt;/strong&gt;: The model learned on tidy, correct database descriptions, then gets handed cluttered ones full of irrelevant tables and loses 16.5 points to the clutter. That is fixable in training, by showing it messy schemas while it learns, rather than in the search step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faster serving&lt;/strong&gt;: 2.7 seconds for a typical question is slow, and the service currently handles one request at a time. Processing several at once on the GPU is the standard fix and the single biggest weakness in these numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not multi turn reinforcement learning&lt;/strong&gt;, which was my original plan. The idea was to train the model on the whole back and forth of the retry loop rather than on single answers. Two things killed it. Only 37% of the queries it repairs end up correct, so there is not much there to learn from. And at 1.5B, over half of training groups already teach the model nothing, because all its attempts agree with each other and it needs disagreement to learn. Training on longer conversations makes that worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The headline is that a 1.5B model matched a 7B one. That is true, and it is also the least interesting thing here.&lt;/p&gt;

&lt;p&gt;The model did not get good because it got bigger. It went from 6.4% to 49.7% at half a billion parameters, before I touched the model size at all, and every one of those gains came from something outside the weights. A way to check answers by running them. A reward that had a gradient in it. Training data that did not hide the hard part. Then, once the model was as good as that size allowed, the same measuring tools were pointed at a bigger base and the gains transferred intact.&lt;/p&gt;

&lt;p&gt;And the last 3.4 points, the ones that actually drew level with the 7B, came from no training whatsoever. Sample eight answers, run all eight, keep whichever result most of them agreed on. That is a system decision, not a model decision.&lt;/p&gt;

&lt;p&gt;Look at what the pieces actually are, stripped of SQL.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A mechanical check for whether an answer is right&lt;/li&gt;
&lt;li&gt;A reward built out of that same check, so evaluation and training agree by construction&lt;/li&gt;
&lt;li&gt;Sampling several answers and using agreement between them to choose&lt;/li&gt;
&lt;li&gt;Using that same agreement to know when to keep quiet&lt;/li&gt;
&lt;li&gt;Retrieval, when the context needed does not fit&lt;/li&gt;
&lt;li&gt;Held out measurement on every claim, including the ones that came back negative&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the first is SQL specific, and only barely. Anywhere you can mechanically decide whether an output is correct, all six apply. Code that has to compile and pass tests. Structured extraction validated against a schema. Maths with a checker. An API call that either succeeds or does not. In each case you can build the same loop, and in each case a small model wrapped in it will go further than its parameter count suggests.&lt;/p&gt;

&lt;p&gt;This is why the small model + good system approach works, and why so many people are converging on it right now. You are not trying to beat a frontier model at everything. You are picking one task, building the infrastructure that knows what correct looks like, and letting a cheap model take as many attempts as it needs.&lt;/p&gt;

&lt;p&gt;The expensive part was never the model. It was building the thing that could tell me when I was wrong.&lt;/p&gt;

&lt;p&gt;Thank you for reading my blog, this was fun to build, and I learnt a lot of things too. Happy to receive your thoughts in the comments! Connect with me on &lt;a href="https://www.linkedin.com/in/pradhyumna-n-holla/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; and &lt;a href="https://x.com/PradHolla" rel="noopener noreferrer"&gt;X&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/PradHolla/SQL-Reasoning-LLM" rel="noopener noreferrer"&gt;SQL-Reasoning-LLM on GitHub&lt;/a&gt; — Code, with evaluation harness, analysis scripts that produced every chart, and the results files each number came from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The benchmark&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://yale-lily.github.io/spider" rel="noopener noreferrer"&gt;Spider&lt;/a&gt; — Benchmark, with the leaderboard and the databases.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/1809.08887" rel="noopener noreferrer"&gt;Spider paper&lt;/a&gt; — Yu et al., 2018, if you want the construction details and the difficulty splits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The techniques&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2402.03300" rel="noopener noreferrer"&gt;GRPO&lt;/a&gt; — introduced in the DeepSeekMath paper. Section 4 is where the group relative advantage comes from, which is the formula that dictates the whole reward design.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2203.11171" rel="noopener noreferrer"&gt;Self-consistency&lt;/a&gt; — Wang et al., the origin of "sample several answers and take the majority". Execution voting is this with the results as the thing being voted on rather than the text.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2203.14465" rel="noopener noreferrer"&gt;STaR&lt;/a&gt; — Zelikman et al., the rejection sampling approach behind the reasoning traces.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2106.09685" rel="noopener noreferrer"&gt;LoRA&lt;/a&gt; — Freezing the model's real weights, training a small set of extra ones. Why any of this fits on one GPU.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tools&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B" rel="noopener noreferrer"&gt;Qwen2.5-Coder-1.5B&lt;/a&gt; — the base model.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/huggingface/trl" rel="noopener noreferrer"&gt;TRL&lt;/a&gt; — the library the GRPO trainer is built on. Worth reading the &lt;code&gt;GRPOConfig&lt;/code&gt; defaults carefully, since two of them silently changed the meaning of my reward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AWS&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://aws.amazon.com/ec2/instance-types/g5/" rel="noopener noreferrer"&gt;EC2 G5 Instances&lt;/a&gt; — High performance GPU based instances for graphics intensive applications and ML inference&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-interruptions.html" rel="noopener noreferrer"&gt;Spot instance interruption notices&lt;/a&gt; — the two minute warning, and how to poll for it from the instance metadata service. This is what the checkpoint rescue in this post is built on, and it is worth reading before you put a training run on spot rather than after.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>aws</category>
      <category>python</category>
    </item>
  </channel>
</rss>
