Mubin Attar

Production

DBWhisper

A natural-language-to-SQL agent that reads your database safely — schema-aware retrieval, a fail-closed read-only validator, and multi-provider LLM fallback.

  • Designed, built, and maintain (solo)
  • 2025 – present
Silent tour of the live product — plain English in, a fail-closed read-only query and answer out. Visit the live app →

Context

Natural-language-to-SQL is easy to demo and hard to trust. Calling an LLM to write SQL takes an afternoon; the reason most such tools never reach production is that a model will happily emit DROP TABLE, a multi-statement payload, or a query against a table that doesn't exist. DBWhisper turns a plain-English question into safe, read-only SQL across Postgres, MySQL, and SQL Server, runs it, and explains the answer — with the guarantee that it can read your data but never change it.

Constraints

Three constraints shaped the design. Safety must be structural— the read-only promise can't depend on the model behaving. Schemas are large — real databases have dozens to hundreds of tables, too many to paste into a prompt, so the agent has to retrieve the right ones. And it runs on a $0 stack (Vercel + a Hugging Face Docker Space + Neon Postgres), which rules out waste: small prompts, bounded loops, lean images.

System architecture

A split deploy in three tiers: a Next.js UI on Vercel, a FastAPI + LangGraph agent on a Docker Space, and the target database queried through a read-only user. Retrieval and conversation memory sit in Neon Postgres with pgvector.

System map

Tap to inspect

  1. 01
  2. 02
  3. 03
  4. 04

Use the arrow keys, Home, or End to move between nodes. Press Enter or Space to pin a node's decision, and Escape to release it.

DBWhisper — hover or focus a node to trace a request. Dashed edges are fallback paths.

Enrollment (once per database). A target database is introspected, each table is documented into structured sections (summary, columns, relationships, stats), and those chunks are embedded into pgvector. Retrieval later searches the documentation embeddings, not the raw schema — which is what makes it work on large databases at all.

Data flow

One question, end to end: the UI POSTs to /query; the agent runs search_tables to find candidate tables by semantic similarity, pulls only the needed section of each (columns / relationships / stats) so the prompt stays small, checks verified queries for a close human-approved example to adapt, then generates SQL. Generation passes through validate_sql before anything touches the database; the query executes under a timeout and row cap; the result is summarized back into natural language. A per-turn LangGraph checkpoint gives follow-ups context.

Key decisions

The decisions that make it trustworthy — each with the alternative it beat and the cost it accepted.

A deterministic, fail-closed SQL validator — not LLM trust

Instead of: trust the model's SQL, an LLM-as-judge safety check

It uses sqlparse (not fragile regex) to enforce SELECT/WITH-only, single-statement, no DDL/DML/EXEC, no information_schema/sys/pg_catalog — and, given a database, rejects any query touching a table outside the enrolled schema. If no schema index exists, it fails closed rather than open.

Trade-off accepted Rejects some exotic-but-valid queries and adds an enrollment step — a false 'safe' is far costlier than a false 'unsafe'.

Defense in depth, not a single gate

Instead of: rely on the validator alone

Below the validator: read-only DB users (a probe warns if a connection looks writable), a query timeout + row cap, and log sanitization that masks passwords, API keys, and SQL literals. Any one layer failing doesn't expose the database.

Trade-off accepted More moving parts to maintain and test.

Multi-provider LLM fallback over single-vendor lock-in

Instead of: one provider (e.g. OpenAI only)

A priority chain (OpenAI → OpenRouter → DeepSeek → Groq → Anthropic → Gemini) picks the first provider with credentials and always keeps Gemini's free tier as a final fallback, so no single outage or quota exhaustion takes the product down — and it stays free to run.

Trade-off accepted Six provider adapters to keep working behind one interface.

Retrieval over context-stuffing

Instead of: dump the whole schema into the prompt

Schemas are documented, embedded, and retrieved per question, keeping the prompt small and the hallucination surface (and token cost) low.

Trade-off accepted An enrollment/embedding step per database.

What failed

Early on, the model would emit SELECT TOP n against Postgres — valid T-SQL, invalid everywhere else — so cross-dialect queries failed. The fix was a deterministic dialect directiveinjected before generation, stating the target dialect's rule explicitly (LIMIT for Postgres/MySQL, TOP for SQL Server) rather than hoping the model inferred it.

v1brokencross-dialect generation

Causethe model defaulted to SELECT TOP n regardless of the target database

v2correctcross-dialect generation

Fixinject a per-dialect directive before generation, and cap tool loops so a confused agent can't spin

Performance & cost

Safety is a property of the architecture, not a single number — but the pipeline is still measured end to end. The structural guarantees first, then the results.

MetricValueMethod
write access to your datanoneFail-closed validator + read-only DB user; a query is rejected if it references a table outside the enrolled schema.
LLM providers6One fallback interface; Gemini free tier is the final fallback so it runs with zero paid keys.
SQL dialects3Postgres, MySQL, SQL Server via SQLAlchemy 2.0 + ODBC.
per-run guardrailsk=4 · ≤8 calls · 30sRetrieval returns the top 4 tables; each tool is capped at 8 calls per run; execution runs under a 30s timeout, a 1000-row cap, and a 5000-char SQL limit.
infra cost$0Vercel + Hugging Face Docker Space + Neon Postgres, all free-tier.

Results (measured)

The pipeline is scored end to end — two real runs, each linked to its method:

73% (101/139, dev)Spider · Execution accuracy
Scoped Spider dev-split run of DBWhisper’s generation model (qwen/qwen3-32b at temp 0.1) with each database’s schema in context: 139 questions across 18 databases. Generated SQL is executed against the real Spider SQLite databases and compared by result set — ordered when the gold query has ORDER BY, multiset otherwise (standard execution match). 101/139 correct; malformed generations count as incorrect. 9 of the 148 sampled questions could not be scored after repeated provider throttling and are excluded, not counted either way. Measures the NL→SQL generation core; the deployed agent adds schema retrieval and a read-only validator on top.
82% exact · 100% fail-closedCustom golden-query set · Execution accuracy · fail-closed refusals
22 natural-language golden queries + 4 unsafe/out-of-scope prompts over a read-only Postgres store, run end-to-end through DBWhisper’s live pipeline (schema retrieval → generation → read-only validator → execute). 82% exact result-set match (18/22); 95% (21/22) when crediting correct answers that returned an extra column. All 4 destructive or out-of-scope prompts were refused fail-closed (4/4).
See the full eval registry

Operations

It runs as a split deploy with the agent in a Docker Space. Per-run guardrails keep it bounded: each tool result is cached per run, and a tool called more than eight times returns an abort hint that tells the model to stop looping and produce output. Logs are sanitized before write, and the validator's rules are covered by unit tests that gate CI (pytest + ruff, alongside a gitleaks secret scan).

app/core/sql_validator.pypy
def validate_sql(sql: str, db_flag: str | None) -> None:
    stmt = _single_statement(sql)              # exactly one statement
    _require_read_only(stmt)                    # starts with SELECT/WITH; no DDL/DML/EXEC
    _reject_system_tables(stmt)                 # no information_schema / sys. / pg_catalog.
    if db_flag:
        schema = _load_schema_index(db_flag)    # fail closed if missing
        _require_enrolled_tables(stmt, schema)  # only tables we can vouch for

What I'd do differently

Two things. I'd add a small golden-query eval per enrolled database so retrieval and generation quality are measured, not assumed — right now the verified-query flywheel is the feedback loop, but it isn't scored. And I'd move the six provider adapters behind a single typed capability interface earlier; retrofitting consistent retry/timeout behavior across them was more work than building it in from the start.

Evidence

The app is live and the source is public — the validator, the agent tools, and the multi-provider chain are all in the repository linked at the top of this page.

Changelog

  1. Schema browser, a verified NL→SQL training flywheel, and the full marketing site.

  2. Flagship web console — auto-generated charts, CSV export, sortable results, staged progress.

  3. Production hardening: Argon2id auth, per-query tenancy, API-key + per-IP rate limits, a SQL allowlist, and optional Sentry.

  4. Productionized from a prototype — Next.js frontend, dialect-agnostic schema extraction, and a live demo on Neon.