DuckDB NL2SQL SFT is a compact end-to-end project for fine-tuning, evaluating, and inspecting a single-table natural-language-to-SQL model for DuckDB.
The current model route is:
Schema + natural-language question
-> SmolLM2-1.7B-Instruct
-> LoRA adapter
-> DuckDB SQL
-> DuckDB execution evaluation
The project focuses on a controlled single-table setting so that the full SFT workflow can be measured clearly: dataset construction, LoRA training, prompt control, SQL execution, evaluator design, and model comparison.
Final adapter:
outputs/adapter
Fixed evaluation set:
data/eval/spider_dev_single_table_execution.jsonl
Final execution metrics:
result_match: 92/100 = 0.920
ordered_result_match: 82/100 = 0.820
pred_execution_ok: 98/100 = 0.980
gold_execution_ok: 100/100 = 1.000
row_count_match: 96/100 = 0.960
LLM-as-judge summary on the same LangSmith experiment:
llm_judge.correct: 92/100 = 0.920
major semantic issues: 7
minor semantic issues: 1
The overall LLM-as-judge score matches execution result_match, but the failed examples are not identical. A compact summary is kept at:
outputs/eval/langsmith_judge_summary.json
Base-vs-adapter comparison view on the same 100-example execution benchmark:
Base SmolLM2-1.7B-Instruct result_match: 0.79
LoRA adapter result_match: 0.91
The comparison CSV is kept at:
outputs/eval/base_vs_adapter_comparison.csv
The chart below compares the fine-tuned adapter against the base model on the same 100-example single-table execution benchmark.
The current scope is intentionally constrained:
- single-table SQL generation
- DuckDB SQL dialect
- SELECT / WITH query generation
- local LoRA SFT
- execution-based evaluation
- local and hosted experiment inspection
The project does not currently target multi-table joins, production SQL safety enforcement, query optimization, or enterprise database integration.
The project has four main stages.
Training examples are stored as chat-style SFT rows:
{
"messages": [
{"role": "system", "content": "..."},
{"role": "user", "content": "Schema:\n...\n\nQuestion: ..."},
{"role": "assistant", "content": "SELECT ...;"}
],
"metadata": {
"layer": "...",
"verified_by_duckdb": true
}
}All SQL examples are intended to be executable DuckDB SQL. The external evaluation set is verified by actually executing gold SQL against DuckDB databases.
The base model is:
HuggingFaceTB/SmolLM2-1.7B-Instruct
The adapter is trained with PEFT LoRA. The base model remains frozen; only LoRA weights are trained.
At evaluation time, each example provides:
Schema:
Table ...
Question:
...
The model outputs one SQL query. The SQL is extracted and normalized before execution.
Both predicted SQL and gold SQL are executed against the same DuckDB database. The evaluator compares the returned rows, not just the SQL text.
Current training files:
data/train/duckdb_sql_sft_train.jsonl
data/train/duckdb_sql_sft_validation.jsonl
Training data summary:
total_examples: 2027
train_examples: 1816
validation_examples: 211
Layer counts:
duckdb_dialect: 200
multidomain_nl2sql: 400
spider_train_execution: 1000
targeted_aov_hard_cases: 30
targeted_grounding: 200
targeted_hard_cases: 80
targeted_result_grain: 200
targeted_single_table_hard_cases: 128
The targeted hard cases focus on recurring execution failures, including:
- aggregate filters that require
HAVING - result grain mistakes
- global aggregate vs grouped aggregate mistakes
- exact schema grounding
- full literal preservation
COUNT,SUM,AVG,MIN,MAXprojection errors- top/least/most-common ordering patterns
- sorting by hidden columns without projecting them
Current evaluation file:
data/eval/spider_dev_single_table_execution.jsonl
The evaluation set is derived from the official Spider dev split and filtered into a fixed single-table execution benchmark.
Build characteristics:
source_dataset: official_spider_dev
total_examples: 100
candidate_examples_after_filtering: 414
sample_seed: 42
db_count: 19
verified_duckdb_version: 1.5.4
verification_mode: single_table_execution_against_spider_duckdb
Filtering rules:
- one
FROMtable - no joins
- no nested SQL
- no set operations
- SELECT-compatible SQL
- transpiled to DuckDB
- gold SQL must execute against the converted DuckDB database
Each evaluation example stores its own DuckDB database path in metadata:
{
"metadata": {
"db_path": "data/eval/spider_single_table_duckdb/xxx.duckdb"
}
}This avoids evaluating all examples against a single shared database.
Main training script:
scripts/train.py
Default model:
HuggingFaceTB/SmolLM2-1.7B-Instruct
Main command:
python scripts/train.py \
--model HuggingFaceTB/SmolLM2-1.7B-Instruct \
--train-file data/train/duckdb_sql_sft_train.jsonl \
--validation-file data/train/duckdb_sql_sft_validation.jsonl \
--output-dir outputs/adapter \
--run-name smollm2-1.7b-duckdb-sql-final \
--wandb-mode online \
--num-train-epochs 1Important default hyperparameters:
assistant_only_loss: true
max_length: 2048
num_train_epochs: 1
per_device_train_batch_size: 1
gradient_accumulation_steps: 8
learning_rate: 2e-4
warmup_ratio: 0.03
lora_r: 16
lora_alpha: 32
lora_dropout: 0.05
target_modules:
- q_proj
- k_proj
- v_proj
- o_proj
- gate_proj
- up_proj
- down_proj
Final token-level validation metrics:
eval_loss: 0.1445
eval_mean_token_accuracy: 0.9576
epoch: 1.0
Token-level validation is useful for monitoring training, but final model quality is judged by execution evaluation.
Prompt config:
configs/sql_prompts.yaml
Only the current default prompt is kept:
v4: single_table_execution_prompt
Earlier prompt variants were removed after testing because prompt-only changes had limited impact once the adapter reached the low-90% execution-match range. Further improvement should come from targeted data and evaluator design rather than longer prompt rules.
Main evaluation script:
scripts/evaluate.py
Evaluate the final adapter locally:
python scripts/evaluate.py \
--dataset data/eval/spider_dev_single_table_execution.jsonl \
--prompt-version v4 \
--model HuggingFaceTB/SmolLM2-1.7B-Instruct \
--adapter outputs/adapter \
--local-only \
--local-output outputs/eval/final_matrix.jsonlAnalyze the local matrix:
python scripts/analyze.py \
--input outputs/eval/final_matrix.csv \
--report-output outputs/eval/final_report.txt \
--failure-output outputs/eval/final_failures.csvOpen the local dashboard:
python scripts/dashboard.py \
--input outputs/eval/final_matrix.csvThe evaluator computes:
result_match
ordered_result_match
pred_execution_ok
gold_execution_ok
row_count_match
pred_error_type
Metric meanings:
result_match: predicted SQL and gold SQL return equivalent rows; row order and column order are ignored.ordered_result_match: predicted SQL and gold SQL return exactly the same rows in the same order.pred_execution_ok: predicted SQL executes successfully in DuckDB.gold_execution_ok: gold SQL executes successfully in DuckDB.row_count_match: predicted result and gold result have the same number of rows.pred_error_type: normalized DuckDB error category for failed predicted SQL.
Error categories include:
not_select_or_with
syntax_error
table_not_found
column_not_found
group_by_error
unsupported_function
type_conversion_error
binder_error
duckdb_execution_error
none
The evaluator compares execution results instead of SQL strings, so semantically equivalent SQL can pass even when the query text differs from the gold query.
LangSmith can be used to inspect hosted experiment runs and compare prompts/models.
Run with LangSmith:
python scripts/evaluate.py \
--dataset data/eval/spider_dev_single_table_execution.jsonl \
--prompt-version v4 \
--model HuggingFaceTB/SmolLM2-1.7B-Instruct \
--adapter outputs/adapter \
--langsmith-dataset c2d-single-table-spider-100 \
--experiment-prefix adapter-smollm2-v4The LangSmith evaluator uses the same execution evaluator functions as local evaluation:
result_matchordered_result_matchpred_execution_okgold_execution_okrow_count_matchpred_error_type
Local evaluation remains the source of truth for final scores because it writes a complete matrix with predicted SQL, gold SQL, execution results, row counts, and error diagnostics.
If an experiment has already been run in LangSmith, the SQL model does not need to be rerun. Export the existing LangSmith experiment table as CSV, then use scripts/judge.py to call a judge model and write judge feedback back to the original LangSmith runs.
The judge compares the question, schema prompt, gold SQL, and predicted SQL, then returns a structured JSON verdict:
{
"judge_correct": true,
"judge_error_type": "correct",
"severity": "none",
"explanation": "short explanation"
}Dry-run CSV parsing without calling a judge model:
python scripts/judge.py \
--input path/to/langsmith_experiment_export.csv \
--dry-run \
--limit 3Run the judge locally and save a JSONL audit file:
python scripts/judge.py \
--input path/to/langsmith_experiment_export.csv \
--model gpt-4o-mini \
--output outputs/eval/langsmith_judge_results.jsonlRun the judge and upload feedback to the existing LangSmith runs:
python scripts/judge.py \
--input path/to/langsmith_experiment_export.csv \
--model gpt-4o-mini \
--output outputs/eval/langsmith_judge_results.jsonl \
--uploadIf the judge JSONL has already been generated, upload it without calling the judge model again:
python scripts/judge.py \
--upload-existing outputs/eval/langsmith_judge_results.jsonlThe script writes these feedback keys:
llm_judge.correct
llm_judge.error_type
llm_judge.severity
Feedback is uploaded with deterministic feedback IDs, so rerunning an upload updates the existing judge feedback instead of creating duplicate feedback rows.
The judge error labels are:
correct
wrong_projection
wrong_filter
wrong_aggregation
wrong_grouping
wrong_order_or_limit
wrong_literal_value
schema_grounding_error
unsupported_or_invalid_sql
other_semantic_error
Current judge result summary:
llm_judge.correct: 92/100
judge_error_type counts:
correct: 92
wrong_aggregation: 3
wrong_grouping: 1
wrong_projection: 1
wrong_filter: 1
wrong_literal_value: 1
wrong_order_or_limit: 1
Comparison with execution evaluation:
execution result_match: 92/100
llm_judge.correct: 92/100
both wrong: 5 examples
execution wrong, judge correct: #26, #69, #93
judge wrong, execution correct: #6, #60, #100
This means the two evaluators agree on the overall score but not on every sample. Execution evaluation remains the primary metric because it is deterministic and database-grounded. LLM-as-judge is used as a diagnostic layer for semantic risks such as missing DISTINCT, wrong aggregation grain, wrong literal values, or ordering mistakes that may not always be exposed by a fixed database instance.
The raw judge JSONL is intentionally kept local because it contains LangSmith run IDs. The committed summary file contains aggregate counts and mismatch indexes only:
outputs/eval/langsmith_judge_summary.json
Langfuse integration is available for local execution traces and scores:
python scripts/evaluate.py \
--dataset data/eval/spider_dev_single_table_execution.jsonl \
--prompt-version v4 \
--model HuggingFaceTB/SmolLM2-1.7B-Instruct \
--adapter outputs/adapter \
--local-only \
--langfuse \
--langfuse-session-id adapter-smollm2-v4Langfuse records generation inputs/outputs, execution-eval metadata, and score observations for the local matrix run.
W&B is used for training logs when enabled:
--wandb-mode onlineFor offline or disabled logging:
--wandb-mode offline
--wandb-mode disabledThe training script stores W&B local files under:
.wandb/
This directory is ignored by Git.
configs/
sql_prompts.yaml
data/
DATASET_EVOLUTION.md
train/
duckdb_sql_sft_train.jsonl
duckdb_sql_sft_validation.jsonl
manifest.json
eval/
spider_dev_single_table_execution.jsonl
spider_dev_single_table_execution.manifest.json
spider_single_table_duckdb/
outputs/
adapter/
adapter_model.safetensors
adapter_config.json
tokenizer.json
tokenizer_config.json
eval/
final_matrix.jsonl
final_matrix.csv
final_report.txt
final_failures.csv
base_vs_adapter_comparison.csv
langsmith_judge_summary.json
scripts/
train.py
evaluate.py
analyze.py
dashboard.py
judge.py
conda create -n duckdb-nl2sql-sft python=3.10
conda activate duckdb-nl2sql-sft
pip install -r requirements.txtOptional environment file:
cp .env.example .envFill .env only if you want Hugging Face, W&B, LangSmith, or Langfuse integration. Do not commit .env.
The final adapter is committed with Git LFS:
outputs/adapter/adapter_model.safetensors
The current project loads the adapter separately from the base model:
base model: HuggingFaceTB/SmolLM2-1.7B-Instruct
adapter: outputs/adapter
For deployment, the adapter can be merged into the base model with PEFT's merge_and_unload() workflow.
The remaining failures are concentrated in a small number of SQL reasoning patterns:
best rankshould map toMIN(rank_column)most recent/latestshould sort by the relevant date column- exact schema grounding such as
pet_ageinstead ofPetAge - aggregate filtering with
HAVING - full literal values such as
Hawaii,Wisconsin, andEnglish SUM(tours)vsCOUNT(*)- duplicated or missing projected columns in grouped results
These are better addressed with targeted SFT data than by adding longer prompt rules.