Bank Text-to-SQL Bot
Ask a bank's customer database questions in plain English and get exact answers. A local LLM writes the SQL; the database does the math, so numbers are never hallucinated. Private fields are masked before they reach the model.
- Python
- SQL
- SQLite
- Ollama
- Qwen 2.5 Coder
The bot answers questions about a bank’s customer database in plain English, with no hallucinated numbers. It runs on a local model, qwen2.5-coder:7b, served by Ollama on my own PC: no API key, no cloud, no cost, and customer data never leaves the machine.
Question: How many customers have a personal loan?
SQL : SELECT COUNT(*) FROM customers WHERE personal_loan = 'yes'
Answer : 6248 customers have a personal loan.
The main design decision: the model never counts
The LLM is used twice per question, and only to translate:
- English to SQL. It reads the question plus a map of the table and writes one SQL query.
- Result to English. It turns the database’s result into one short sentence.
Every count, average and total comes from the database. That split is what keeps the numbers honest.
| Piece | Role | Good at | Bad at |
|---|---|---|---|
bank.db (SQLite) |
Stores 41,188 customers | Exact counting | Understanding English |
LLM (qwen2.5-coder:7b) |
Translator | Understanding English, writing SQL | Counting |
| Python | Connects the two | Glue, masking, testing |
One question, start to finish
- The user types “How many customers have a personal loan?”
- Python sends the LLM the instructions, a map of the table and the question.
- The LLM replies with
SELECT COUNT(*) FROM customers WHERE personal_loan = 'yes'. - Python runs that SQL on
bank.dbover a read-only connection, and the database counts 6248. - Python sends 6248 back to the LLM to explain in one sentence.
- The user sees “6248 customers have a personal loan.”
The table map is what makes the SQL accurate. Python sends every column name, its type and its allowed values, for example personal_loan (TEXT) allowed values: ['no', 'yes', 'unknown']. Without it, the model might write 'Yes' with a capital Y and match nothing.
Choosing the model
I compared the models I had installed:
| Model | Decision |
|---|---|
gemma3:270m |
Too small to write SQL reliably |
phi3 |
General-purpose, weaker at code |
gpt-oss:120b-cloud |
Strong, but runs on Ollama’s servers, so customer data would leave the machine |
qwen2.5-coder:7b |
Chosen: code-specialized, fully local |
It runs at temperature 0 for consistent SQL, through Ollama’s OpenAI-compatible API. Moving to a cloud model such as Azure OpenAI later is a three-line change.
Safety and accuracy
- No AI arithmetic. Totals come from
SELECT COUNT(*)on the database, never from the model counting rows. - Private data masked before it is shown or sent to the LLM: last names become
J., emailsr***@example.com, account numbers******9190. Masking checks both column names and value patterns, so a renamed column (account_number AS acct) is still caught. - Read-only database connection. If the model ever wrote
DELETE, SQLite would refuse. - Local model. Customer data is never sent to an outside service.
A bug the tests caught
The first version passed 8 of 8 counting questions. Then a list question exposed a hallucination:
| Before | After | |
|---|---|---|
| “Show me the customers with a personal loan” | “There are 22 customers” | “There are 6248 customers” |
| Who counted | The LLM, from a 20-row sample | The database, all rows |
| Account number shown | 3410529190 |
******9190 |
Cause: the LLM saw only 20 sample rows and counted them itself.
Fix: the database counts the full result with SELECT COUNT(*) FROM (<query>), the LLM is told to use that number and never count on its own, and private fields are masked before the LLM sees them.
Results
Tested on Windows with Python 3.13.1 and qwen2.5-coder:7b on Ollama.
| Test | What it checks | Score |
|---|---|---|
| Counting test | 8 counting questions against a ground-truth answer key | 8/8 |
| Privacy test | 3 list questions: correct total and no leaked private data | 3/3 |
The data
Customer attributes come from the Bank Marketing dataset (Moro et al., 2014, UCI Machine Learning Repository). Names, emails, account numbers and the routing number are synthetic: names from the Faker library, emails on the reserved example.com domain, and a routing number that deliberately fails the ABA checksum.
What’s next
- Refuse off-topic or unanswerable questions instead of guessing
- Add policy documents and route each question to SQL or to document search (RAG)
- A simple web chat interface