Fine-Tuned, Guarded Text-to-SQL Copilot

A small model taught to write SQL on a laptop, behind a parser-based guard and a confidence score honest enough to act on.

LoRA Fine-Tuning Apple Silicon / MLX Text-to-SQL Guardrails sqlglot Calibration

TL;DR

The Decision That Triggered This Build

Natural-language SQL puts data access in the hands of people who cannot read SQL. That is the entire value, and precisely why the system must protect them: they cannot spot a query that is wrong, and certainly not one that is destructive.

Two questions. Can a model small enough to run locally be taught to write correct SQL for a specific schema and dialect? And can the surrounding system be made safe enough that a non-technical analyst using it is not a risk to the database?

Why Small and Local

The constraint is real

4-bit CUDA training tooling does not run on Apple silicon at all, and 7–8B quantized weights plus optimizer state will not fit in 16GB shared with the OS. MLX does the same job, frozen quantized base, trainable adapters, natively on Metal. Measured peak memory in training was 1.1 GB at rank 16 on 8 layers; the constraint had headroom to spare.

Small makes the result clearer, not weaker

A larger model already writes passable SQL, so fine-tuning moves it modestly. A small coder model fails in exactly the ways LoRA fixes, wrong dialect, invented columns, malformed joins, so the base-versus-tuned gap is wider. The failure dump bears this out: the base model's invalid queries are real schema errors ("no such column: T2.Country", aggregate-in-GROUP-BY), not formatting noise.

It makes the copilot genuinely offline

Local inference means no per-query cost and no schema leaving the machine, which is why the execution-accuracy numbers cost zero API quota: the fine-tuned model generates every query on the M1.

"I picked the smallest model that could learn the task, because the question I cared about was how much capability LoRA buys per parameter, not how high I could push absolute accuracy."

Fine-Tuning

Diagram of a LoRA-adapted layer. An input vector feeds two parallel paths: a large frozen pretrained weight matrix marked as not updated, and a low-rank path through a tall thin trainable matrix A followed by a wide short trainable matrix B initialised to zero, scaled by alpha over rank. The two paths sum to produce the output.

LoRA freezes the pretrained weight and trains two small matrices beside it, rank 16 on 8 layers, peak 1.1 GB, on a 16GB laptop.

No equations: the base weight matrix is frozen; two small matrices train beside it and are added to its output. Rank is how much room the adjustment gets (16 here); alpha how strongly it applies (32). B starts at zero, so the adapted model begins exactly equal to the base and improves from there.

The prompt template is a contract

One module, imported by both the training project and the serving project. If they drift, the model is asked to work against a format it never saw, and the failure looks like "the fine-tune didn't work" rather than "the prompt changed".

Schema serialisation is a real trade

Table name plus column names is the choice; types and sample rows roughly triple the token count for a modest gain, and sequence length is the binding constraint in 16GB. Examples over the cap are dropped and counted, never truncated, 73 of 7,000 (1.0%), because a truncated example teaches the model to emit truncated SQL.

The baseline is measured before training

Deliberately, 42.5% execution accuracy, 40% invalid SQL on 200 Spider dev examples, so the fine-tuned comparison cannot be quietly flattered after the fact.

The result that surprised me: LM loss and task accuracy diverge

The standard rule says watch validation loss; when it rises while training loss falls, you are overfitting, so stop. The run showed the exception. Validation (next-token) loss bottomed at iter 200 (1.41) and then climbed steadily to 2.09 at iter 2000, the textbook overfitting signal. But execution accuracy did the opposite: +0 points at 200 iters, +1 at 600, +5 at 2000. For a task metric, the language-modelling loss is the wrong early-stopping signal; you have to evaluate the task itself. Early-stopping on val loss would have shipped a model with no gain at all.

Why Keyword Blocking Does Not Work

The blocklist is built first, and it fails 4 of 6 test cases, in both directions at once.

Two false alarms

Legitimate queries refused because the word "delete" appears inside a string literal, and inside a comment, places where it has no power at all. Users blocked for no reason route around the tool, which is how shadow database access starts.

A bypass

CREATE TABLE passes because nobody thought to list "create". Every new pattern is another round of whack-a-mole.

A category it cannot see

A blocklist of verbs has nothing to say about which tables you may read. An ordinary SELECT walks restricted data straight out.

Side-by-side comparison. The left panel shows a keyword blocklist refusing a legitimate query because the word delete appears inside a string literal, and allowing a CREATE TABLE statement because that keyword was never listed, failing four of six test cases. The right panel shows the AST guard parsing SQL into a syntax tree and applying three structural checks in sequence, exactly one statement, the statement is a SELECT, and every table including those in nested subqueries appears in the allowlist, allowing the query only if all three pass and otherwise refusing with a stated reason, blocking all three hundred red-team queries.

String matching inspects characters. What matters is structure, the blocklist got 4 of 6 wrong; the AST guard blocked 300 of 300.

Then the AST guard: parse with sqlglot, allow only a single SELECT touching approved tables. The shift is from denying what you can think of to allowing only what you approved, a verb invented after the code was written is refused by default. Because the parser walks the whole tree, a restricted table reached through a nested subquery is caught for free. Measured: 300 of 300 red-team queries blocked across 20 databases, 100%. It also caught the fine-tuned model's own mistakes: when the model emitted a malformed nested subquery, the guard refused it as unparseable rather than executing garbage.

A Score That Keeps Its Promise

Grouped bar chart showing confidence calibration. For each confidence bucket, an outlined bar shows the confidence the system claimed and a solid bar shows the accuracy it actually delivered. The gap between the pair in each bucket is what expected calibration error averages, here 0.21.

A confidence score is a promise. Calibration measures whether it is kept, bucket by bucket, because averages hide the failure. Measured ECE: 0.21.

The guard stops catastrophe. It does nothing about a safe SELECT that answers the wrong question. So each query carries a confidence score, and anything below a threshold goes to a human. That plan is sound only if the score means something. Calibration is whether it does; ECE is the average gap between claimed and delivered confidence, measured in bins. Measured here: ECE = 0.21, moderately miscalibrated, and honestly reported as such rather than assumed to be trustworthy.

Evaluation

Execution accuracy
0.425 → 0.475
base vs fine-tuned (+5.0), 200 Spider dev
Invalid-SQL rate
0.40 → 0.36
base vs fine-tuned
LoRA config
r16 · α32
8 layers · 2000 iters
Peak memory · throughput
1.1 GB
21 tok/s, M1 Pro 16GB
Execution accuracy (behind guard)
0.60
fine-tuned, 50 examples
Invalid-SQL rate
0.12
behind the guard
Red-team block rate
1.00
300 / 300, 20 databases
ECE (10 bins)
0.214
keyword blocklist: 4 / 6 cases wrong

The escalation tradeoff, the confidence score doing real work, cutting wrong auto-runs from 13 to 3 (50 examples):

Confidence threshold Auto-run correct Auto-run wrong Sent to human
0.0 (no gate) 30 13 0
0.5 29 6 8
0.7 29 3 11
1.0 (gate everything) 28 2 13
Main weakness: safety and correctness are different problems

Every query the guard admits is safe; a significant share (40% of them) still answer the wrong question. The guard is a hard boundary against catastrophe and offers nothing against being confidently, safely wrong. The confidence threshold addresses that, and it is strictly weaker: it depends on the score being calibrated, and at ECE 0.21 the score is only moderately trustworthy, the escalation table above is honest about how many wrong queries still slip through at each threshold.

Recommendation

Ship the parser-based guard unconditionally

A small amount of code that blocked 100% of a 300-query red team and refuses a destructive statement no matter how confident anything claims to be. Add the fine-tuned model behind it, it beats the base by 5 points and halves invalid SQL, and always return the generated SQL alongside the results.

Next Iteration Priorities