Text-to-SQL Evaluation
Evaluate LLM-generated SQL queries with string comparison, execution-based validation against a live database, and the built-in text_to_sql eval.
Evaluate LLM-generated SQL queries using the built-in text_to_sql and ground_truth_match Turing metrics, local string comparison, and execution-based validation against a live database.
| Time | Difficulty | Package |
|---|---|---|
| 15 min | Intermediate | ai-evaluation |
- Future AGI account → app.futureagi.com
- API keys:
FI_API_KEYandFI_SECRET_KEY(see Get your API keys) - Python 3.11
Install
pip install ai-evaluation
export FI_API_KEY="your-api-key"
export FI_SECRET_KEY="your-secret-key"
Tutorial
Set up the evaluator and test database
Create an in-memory SQLite database with sample data and define a test suite of natural language questions, expected SQL, and LLM-generated SQL.
import os
import sqlite3
from fi.evals import Evaluator, evaluate
evaluator = Evaluator(
fi_api_key=os.environ["FI_API_KEY"],
fi_secret_key=os.environ["FI_SECRET_KEY"],
)
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
cursor.executescript("""
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL,
city TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id),
amount REAL NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT INTO customers VALUES (1, 'Alice Johnson', 'alice@example.com', 'New York');
INSERT INTO customers VALUES (2, 'Bob Smith', 'bob@example.com', 'Austin');
INSERT INTO customers VALUES (3, 'Carol White', 'carol@example.com', 'Chicago');
INSERT INTO orders VALUES (1, 1, 120.00, 'completed', '2024-01-10');
INSERT INTO orders VALUES (2, 1, 80.50, 'completed', '2024-02-15');
INSERT INTO orders VALUES (3, 2, 200.00, 'pending', '2024-03-01');
INSERT INTO orders VALUES (4, 3, 55.25, 'completed', '2024-03-10');
INSERT INTO orders VALUES (5, 2, 175.00, 'cancelled', '2024-03-20');
""")
def run_sql(sql: str) -> list:
"""Execute SQL and return sorted rows for deterministic comparison."""
try:
cursor.execute(sql)
return sorted(cursor.fetchall())
except Exception as e:
return [("ERROR", str(e))]
test_cases = [
{
"question": "Get all customer names",
"expected_sql": "SELECT name FROM customers;",
"generated_sql": "SELECT name FROM customers;",
},
{
"question": "Find completed orders",
"expected_sql": "SELECT * FROM orders WHERE status = 'completed';",
"generated_sql": "SELECT * FROM orders WHERE status='completed';",
},
{
"question": "Total spend per customer",
"expected_sql": "SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id;",
"generated_sql": "SELECT customer_id, SUM(amount) FROM orders GROUP BY customer_id;",
},
{
"question": "Customers who placed completed orders",
"expected_sql": "SELECT name FROM customers WHERE id IN (SELECT customer_id FROM orders WHERE status = 'completed');",
"generated_sql": "SELECT DISTINCT c.name FROM customers c JOIN orders o ON c.id = o.customer_id WHERE o.status = 'completed';",
},
{
"question": "Total revenue from all orders",
"expected_sql": "SELECT SUM(amount) FROM orders;",
"generated_sql": "SELECT SUM(amount) FROM orders WHERE status = 'completed';",
},
]
print(f"{len(test_cases)} test cases loaded, database ready.")You should see 5 test cases loaded, database ready. Case 1 is a perfect match. Case 2 has a whitespace difference. Case 3 has an alias difference. Case 4 uses a JOIN instead of a subquery. Case 5 has a logic error: it filters to completed orders instead of summing all.
Validate SQL intent with text_to_sql
The built-in text_to_sql metric checks whether generated SQL is valid and correctly matches the natural language question’s intent. It does not need a reference query, just the question and the generated SQL.
print(f"{'Question':<40} text_to_sql")
print("-" * 55)
for tc in test_cases:
result = evaluator.evaluate(
eval_templates="text_to_sql",
inputs={
"input": tc["question"],
"output": tc["generated_sql"],
},
model_name="turing_small",
)
eval_result = result.eval_results[0]
print(f"{tc['question']:<40} {eval_result.output}")You should see (illustrative output, actual scores vary by model run):
Question text_to_sql
-------------------------------------------------------
Get all customer names Passed
Find completed orders Passed
Total spend per customer Passed
Customers who placed completed orders Passed
Total revenue from all orders FailedThe text_to_sql metric catches the logic error in case 5: the question asks for “all orders” but the SQL filters to completed only. Cases 2 to 4 pass because the generated SQL is valid and matches the question intent, regardless of formatting or structure differences.
Compare against a reference with ground_truth_match
ground_truth_match checks whether the generated output matches a reference (expected) output. It evaluates semantic equivalence, not just string identity.
print(f"{'Question':<40} ground_truth_match")
print("-" * 62)
for tc in test_cases:
result = evaluator.evaluate(
eval_templates="ground_truth_match",
inputs={
"generated_value": tc["generated_sql"],
"expected_value": tc["expected_sql"],
},
model_name="turing_small",
)
eval_result = result.eval_results[0]
print(f"{tc['question']:<40} {eval_result.output}")You should see (illustrative output, actual scores vary by model run):
Question ground_truth_match
--------------------------------------------------------------
Get all customer names Passed
Find completed orders Passed
Total spend per customer Passed
Customers who placed completed orders Passed
Total revenue from all orders Failed Run local string checks: equals and levenshtein_similarity
Local metrics run instantly with no API call. Use equals as a fast CI gate, and levenshtein_similarity to catch near-matches.
SIMILARITY_THRESHOLD = 0.85 # below this, treat formatting drift as a real mismatch
print(f"{'Question':<40} {'Exact':>6} {'Similarity':>11}")
print("-" * 62)
for tc in test_cases:
exact = evaluate(
"equals",
output=tc["generated_sql"].strip().rstrip(";").lower(),
expected_output=tc["expected_sql"].strip().rstrip(";").lower(),
)
sim = evaluate(
"levenshtein_similarity",
output=tc["generated_sql"],
expected_output=tc["expected_sql"],
)
exact_str = "PASS" if exact.passed else "FAIL"
sim_str = f"{sim.score:.2f}" + (" low" if sim.score < SIMILARITY_THRESHOLD else "")
print(f"{tc['question']:<40} {exact_str:>6} {sim_str:>11}")You should see (illustrative scores):
Question Exact Similarity
--------------------------------------------------------------
Get all customer names PASS 1.00
Find completed orders FAIL 0.97
Total spend per customer FAIL 0.91
Customers who placed completed orders FAIL 0.47 low
Total revenue from all orders FAIL 0.71 lowCase 2 (whitespace) and case 3 (alias) score high on similarity despite failing exact match. Case 4 and case 5 fall below SIMILARITY_THRESHOLD and print the low flag: case 4 because the JOIN structure looks very different from the subquery even though it’s correct, case 5 because the logic error also happens to reword the query. String metrics alone are not enough to judge SQL correctness.
Add execution-based validation
The most reliable check: run both the generated and reference SQL on the same database and compare result sets. If they return the same rows, the generated SQL is correct regardless of structure.
print(f"{'Question':<40} Execution Match")
print("-" * 60)
for tc in test_cases:
gen_rows = run_sql(tc["generated_sql"])
ref_rows = run_sql(tc["expected_sql"])
match = gen_rows == ref_rows
status = "PASS" if match else "FAIL"
print(f"{tc['question']:<40} {status}")
if not match:
print(f" Generated: {gen_rows}")
print(f" Reference: {ref_rows}")You should see:
Question Execution Match
------------------------------------------------------------
Get all customer names PASS
Find completed orders PASS
Total spend per customer PASS
Customers who placed completed orders PASS
Total revenue from all orders FAIL
Generated: [(255.75,)]
Reference: [(630.75,)]Cases 2 to 4 all pass execution even though they have different formatting, aliases, and structure. Case 5 fails because filtering to completed orders returns 255.75 instead of the full total of 630.75.
Combine all four methods into one sweep
Run text_to_sql, ground_truth_match, the local string checks, and execution match together to see where each approach agrees or diverges.
print(f"{'Question':<35} {'SQL':>4} {'GT':>4} {'Exact':>6} {'Sim':>5} {'Exec':>5}")
print("-" * 68)
for tc in test_cases:
sql_eval = evaluator.evaluate(
eval_templates="text_to_sql",
inputs={"input": tc["question"], "output": tc["generated_sql"]},
model_name="turing_small",
)
gt_eval = evaluator.evaluate(
eval_templates="ground_truth_match",
inputs={"generated_value": tc["generated_sql"], "expected_value": tc["expected_sql"]},
model_name="turing_small",
)
exact = evaluate(
"equals",
output=tc["generated_sql"].strip().rstrip(";").lower(),
expected_output=tc["expected_sql"].strip().rstrip(";").lower(),
)
sim = evaluate(
"levenshtein_similarity",
output=tc["generated_sql"],
expected_output=tc["expected_sql"],
)
gen_rows = run_sql(tc["generated_sql"])
ref_rows = run_sql(tc["expected_sql"])
exec_pass = gen_rows == ref_rows
sql_str = "OK" if sql_eval.eval_results[0].output == "Passed" else "FAIL"
gt_str = "OK" if gt_eval.eval_results[0].output == "Passed" else "FAIL"
q = tc["question"][:33] + ".." if len(tc["question"]) > 33 else tc["question"]
print(
f"{q:<35} "
f"{sql_str:>4} "
f"{gt_str:>4} "
f"{'OK' if exact.passed else 'FAIL':>6} "
f"{sim.score:>5.2f} "
f"{'OK' if exec_pass else 'FAIL':>5}"
)You should see (illustrative sweep):
Question SQL GT Exact Sim Exec
--------------------------------------------------------------------
Get all customer names OK OK OK 1.00 OK
Find completed orders OK OK FAIL 0.97 OK
Total spend per customer OK OK FAIL 0.91 OK
Customers who placed completed o.. OK OK FAIL 0.47 OK
Total revenue from all orders FAIL FAIL FAIL 0.71 FAILCases 2 to 4 fail exact match and score low on string similarity but pass every meaningful check (the Turing metrics and execution). Case 5 fails across all checks: a high-confidence logic error worth flagging.
Fix case 5’s generated SQL to match the question’s intent (total revenue from all orders, not just completed ones), then rerun the sweep for that case alone:
test_cases[4]["generated_sql"] = "SELECT SUM(amount) FROM orders;"
tc = test_cases[4]
gen_rows = run_sql(tc["generated_sql"])
ref_rows = run_sql(tc["expected_sql"])
exec_pass = gen_rows == ref_rows
print(f"{tc['question']:<40} Execution Match")
print(f"{tc['question']:<40} {'PASS' if exec_pass else 'FAIL'} {gen_rows}")You should see:
Total revenue from all orders Execution Match
Total revenue from all orders PASS [(630.75,)]Dropping the WHERE status = 'completed' filter flips case 5 from FAIL to OK: text_to_sql and ground_truth_match now pass, execution match returns 630.75 instead of 255.75, and the sweep in this step would show OK across every column for all five cases.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
evaluator.evaluate() raises an authentication error | FI_API_KEY or FI_SECRET_KEY is missing, blank, or copied from the wrong project | Re-export both keys from Get your API keys and confirm they belong to the project you’re evaluating against |
eval_result.output is always "Failed" for correct SQL | inputs uses the wrong keys for the template (text_to_sql needs input/output, ground_truth_match needs generated_value/expected_value) | Match the input keys to the template being called; the two Turing metrics do not share a schema |
Local evaluate() call raises KeyError or returns no .score | equals and levenshtein_similarity are called with generated_value=/expected_value=, the Turing-template keys, instead of output=/expected_output= | Use output and expected_output for local metrics; they use a different parameter naming than the Turing templates |
sqlite3.OperationalError: no such table | The cursor.executescript() block never ran, or conn/cursor were re-created without re-running the schema | Re-run step 1 top to bottom in the same session so the in-memory database exists before you query it |
Exact match (equals) fails on SQL you’d call identical | Case, trailing whitespace, or a trailing semicolon differs between generated and expected SQL | Normalize both strings first: .strip().rstrip(";").lower(), as shown in step 4 |
levenshtein_similarity scores a correct query low | The generated SQL is structurally different (JOIN vs subquery) even though it’s logically equivalent | Don’t gate on similarity alone; treat a low score as a prompt to check execution match, not as a failure by itself |
Execution match reports [("ERROR", ...)] for one side | The generated or reference SQL has a syntax error, or references a column/table that doesn’t exist in the schema | Read the error string in the printed row; it’s the raw sqlite3 exception message from run_sql() |
pip install ai-evaluation fails with a Python version error | The package requires Python 3.11+; an older interpreter is active | Use Python 3.11 as set in the prerequisites, and confirm with python --version before installing |
To run these same checks across a large SQL generation dataset instead of five test cases, see Batch Evaluation.
Questions & Discussion