ProjectsLinguaQL
LinguaQL
Personal projectAsk a database questions in plain English. LinguaQL turns them into validated, read-only SQL, checked against the real schema before anything runs.
- Role
- Solo build
- Timeline
- ~2 weeks
- Stack
- Python · FastAPI · LangGraph · Claude Opus 4.8 · sqlglot · Postgres/pgvector · TypeScript · Next.js · ECharts · Docker
Overview
A text-to-SQL engine you can point at a live database without holding your breath. The idea is to trust the model as little as possible: it decides what SQL to write, but everything that matters for safety, how tables relate, whether a query is valid, whether it's affordable, is computed from the schema and enforced before execution.
Under the hood, it reads the schema and foreign keys into a relationship graph, retrieves relevant columns by embedding, and has Claude generate SQL through a forced tool call. That SQL is parsed to an AST, checked against the real catalog, cost-gated by a static heuristic and a live EXPLAIN, and only then run, read-only. Failed checks go back to the model as a bounded self-correction loop. What survives comes back with an auto-selected chart.
Problem
Text-to-SQL demos well and fails badly. The models are good enough to write syntactically valid SQL almost every time, which is exactly the problem: a query with a hallucinated join key or a silently wrong grain returns a confident number, a clean chart, and no error. Nothing in the loop tells you it's wrong. The other half of the problem is blast radius, since the same model that invents a column will happily write an unbounded scan across five joins, and a naive pipeline runs it against your production database.
I wanted to find out how much of the trust gap could be closed with deterministic machinery around the model rather than better prompting: what the model is allowed to decide, versus what gets computed from the schema and enforced before execution.
Constraints
Read-only, always. The engine connects to databases it doesn't own, so no generated statement can be allowed to mutate anything, and “we told the model not to” doesn't count as enforcement.
Public demo on my own Anthropic key. Every query is real money and every retry another paid call, so cost is bounded at three levels: per-query retries, per-IP throttling, and a global daily budget.
Zero setup for a stranger. Clicking the link shouldn't require an API key, a model download, or a config file, just docker compose up and one button. That ruled out anything with a heavy default dependency.
Decisions worth defending
Joins are computed, not generated.
The model never gets “here are the tables, figure out how they relate.” Ingestion reads foreign keys from information_schema into a relationship graph; the retriever resolves join paths by BFS and hands the generator literal orders.customer_id = customers.id conditions to use verbatim. Where explicit FKs are missing, common in real schemas, edges are inferred from {name}_id to {name}s.id, type-validated, and tagged inferred so the provenance shows. Dumping the schema and letting the model infer the joins is about forty lines less code and works most of the time. But a wrong join is the failure that doesn't announce itself: no error, no empty result, just a number that's quietly off. That's exactly where the determinism should go.
Guardrails are an AST pass, not a prompt or a blocklist.
Generated SQL is parsed with sqlglot, walked for destructive node types, and every table and column identifier is checked against the ingested catalog, with CTE names and aliases threaded through as legitimate identifiers. A regex blocklist for DROP|DELETE|UPDATE is one line and loses to comments, casing, and nesting; a prompt instruction isn't enforcement at all. Parsing costs a dependency and some alias edge cases. In exchange, the guarantee is structural instead of probabilistic.
Cost control fails closed.
Two layers: a static complexity score (SELECT * +10, no WHERE +20, no LIMIT +15, +25 per join past the third), then a real EXPLAIN against the source database with a planner-cost ceiling. The decision I'd defend hardest is what happens when EXPLAIN itself errors or times out: the query is aborted, never run. Treating an unestimable query as safe would have been the convenient default, but “we couldn't tell how expensive this is” is the exact case where you don't want to find out by running it.
Self-correction is capped at 2 retries.
Validation and cost-rejection errors are fed back into the next attempt as explicit instructions, which recovers most hallucinated-column cases on the first retry. The cap exists because each attempt is a paid Opus call: unbounded retry buys a few points of success rate at the cost of an unbounded worst case per question, the wrong trade for a public demo on my own wallet. On exhaustion, the pipeline returns the validation errors instead of executing unvalidated SQL.
Low confidence halts instead of guessing.
The generator returns a confidence score with the SQL; below 0.7 the pipeline stops before execution and hands back its interpretation and plan for the user to confirm or rephrase. It's friction on a one-click flow, and I added it anyway: for an analytics tool, “I think you meant X, confirm?” is cheaper than a wrong chart the user believes.
Schema snapshots use stale-while-revalidate.
Every ingest produces an immutable, ID-tagged snapshot. Reconnecting to a known database serves a snapshot younger than 24h instantly; a stale one keeps answering while a fresh snapshot builds in the background and swaps in atomically, pruning the old index. Re-ingesting synchronously on connect is simpler but makes every reconnect wait on full introspection and embedding. This costs a lock and a background task, and makes reconnection feel instant.
The default embedder ships with zero dependencies.
Retrieval is meaningfully better with all-MiniLM-L6-v2 or text-embedding-3-small, and both are supported behind the same interface. Neither is the default: MiniLM pulls ~1GB of torch into the image, OpenAI needs a second key. Retrieving tables and columns over a few dozen tables is a forgiving problem, so the default trades some recall for a demo that starts cold with one key and no downloads, and a bad config falls back to hashing with a warning instead of failing to boot.