DeepQuery, plain-English access to your databases

2026 · SynergyBoat · published

Teams sit on databases their non-technical people cannot query. Built DeepQuery for one client, an EV-charging network: a plain-English question passes a confidence gate, two candidate queries go through ten validation rules and an arbiter, and the winner runs read-only under row and time caps, returning rows, a chart and a narrative. Forty-three questions ran against that client's production data in April 2026, reconciled against the reporting they already had, and the reusable half of the pipeline is published as a package.

Numbers this essay already carries, each linking the paragraph that states it
lines of TypeScript 44,110 stated in
active commit days 19 stated in

The problem

A model will write SQL for almost any question you put to it. What it will not do is tell you when the SQL is wrong. A wrong query looks exactly like a right one. It parses, it runs, it comes back with rows, and the number lands in front of somebody who is about to make a decision with it.

That is the real difficulty in putting plain-English querying in front of an operations team, and it gets harder when the team already has reporting. The client I built this for runs an EV-charging network. Their operational data sat in MongoDB, their monthly utilization report came out of a warehouse, and their operations people read that report every month. So the job was to produce their numbers, from a question typed in English, and to be able to show why the answer was that one and not another.

What shipped

An engine, a web app and a shared contract package: 44,110 lines of TypeScript in 219 commits. The first landed on 6 March 2026 and the last change to the code on 5 April. Commits land on 19 days in all, and the last of those days are documentation, in July. The engine is Express with sixteen route groups and a twelve-tool MCP server beside it, so an agent can list datasources, describe a schema and run a query without opening the app at all. The web app is Next.js on Cloudflare Workers, and everything past the landing page is behind Supabase auth.

Seven database connectors are registered: a SQLite demo warehouse, Postgres, MySQL, MongoDB, Snowflake, BigQuery and ClickHouse. Four of them run as installed. The other three reach for their drivers through a dynamic import, and those drivers are not in the manifest, which is the kind of thing I would rather say out loud than let a reader find. Each connector introspects its own catalog, information_schema for Postgres and MySQL, listCollections and document sampling for Mongo, system.columns for ClickHouse. A detector infers the join relationships afterwards, and the whole schema is cached in Redis for fifteen minutes.

An answer arrives over SSE as rows, the query that produced them, a chart spec typed to one of eleven chart types, a paragraph of narrative, up to three follow-up questions and a provenance record. In production the planning and the narrative ran on Z.ai’s GLM models behind an OpenAI-compatible client, with OpenAI’s embedding model for the vectors, on EC2 in Mumbai behind a deploy that healthchecks the container and posts the result to Slack.

Then the part that matters. On 1 and 2 April 2026 I ran 43 questions against the client’s production datasource while reconciling the output against the report they already had. “Weekly session hours by charger” came back with 1,001 rows in 14.2 seconds. Forty-one of the 43 passed result validation on the first attempt and two needed a repair. Two weeks before that, the schema discovery pass had been offering “billing day of month” as a business metric; by the end of the session it matched seven of the seven metrics their own report defines, with 23 join relationships found without help. That reconciliation is the evidence this worked. It is also all of it: nobody has run a recorded query against it since.

A wrong query looks exactly like a right one.

How it works

The argument of the system is that a generated query has to earn its way to the database (Figure 1).

Nothing calls a model until the question has passed a gate, and the gate is not a model either. It scores six signals: how well the question matches a governed metric, weighted at 0.25, how much of the schema it covers at 0.2, how clear it is at 0.2, how confident the join path is at 0.1, whether the permissions are complete at 0.1, and how much of the semantic profile it touches at 0.15. Over 0.7 the question is answered. Under 0.4 it is refused. In between it is answered with caveats, unless the clarity signal is under 0.5, in which case the run stops and asks which metric you meant.

Schema context is kept small by selection rather than by summary. An embedding search over the tables returns the best six, and those six are the prompt, every column of them with its type; a keyword fallback takes four tables and eight columns each when the vectors are not available.

Where the client’s semantic profile defines the metric, two candidate queries are generated: one compiled from the governed formula, one from the planner, which gets the compiled query as a baseline to beat. Both go through ten validation rules. Four are structural: the tables exist, the columns exist, the plan parses, nothing writes. Six are semantic: the metric survives, the grain survives, the filters survive, the aggregation is additive where it claims to be, the metric is certified, the lookup is complete. An arbiter picks the winner, and a failure can go back for up to two repairs.

What wins still is not trusted. A safety pass rejects anything that is not a single read-only statement, throws out comments and appends a LIMIT when the query forgot one. The executor caps the run at 1,000 rows and 30 seconds. The result is validated again after it comes back, which is where those two repairs on 1 April came from. Only then does the pipeline assemble the provenance record, call a model for the narrative and stream the whole thing out. Twelve stages in the code, seven of them labelled on the wire.

What I’d do differently

The claim I made about this system for months, here and on my CV, was that it enforces an 8,000-token context budget upstream so the model never has to decide what to drop. It does not. I wrote that budget: a module with a total, four sub-limits and a token estimator that counts words and multiplies by 1.3. No file in the repository imports it. The one function in it that would prune a schema prompt measures the prompt, writes a debug line saying pruning is suggested, and returns the string unchanged. Nothing counts a token anywhere before a model call. What bounds the prompt is that six-table slice, which is a cap on tables and not on tokens, and the 8,000 that does run in production is a different constant with the same digits: the planner’s output ceiling. There is a fair chance I read that one and wrote it down as this one.

The clarify branch is the part I am sorriest about. In the engine it works. The score lands in the band, the pipeline emits a clarification event, the run stops. The browser handles four event names and that is not one of them, so the event arrives, no handler runs, and the reader is left looking at an empty answer. All 43 recorded runs took the plain answer path, so my own evidence could never have caught it. The question it would have asked is hardcoded anyway, one sentence with the profile’s metric names as the options.

The value resolver has the same shape of problem and a worse cause. It works, it matches proper nouns and quoted strings against values sampled from the real columns, and it is imported by two pipeline files that nothing imports, 1,029 lines of orphan between them. So “last month” is resolved by the model, because the prompt shows it a date-range template, and not by anything upstream of the model. The table profiler is the third case: it samples fifty rows per table for cardinalities, null rates and top values, and the live pipeline hands schema selection an empty map where those profiles belong, so none of it reaches the prompt that was built to carry it.

And there are no tests. Not few: none, and no framework installed to write them in. CI runs a typecheck. The strongest thing in this system is a bank of validators and an arbiter deciding whether a query is allowed to touch a database, and I have no automated proof that any of them fires. The nearest thing to evidence is a 43-line file that is not even committed.

One last thing a reader should know. The gate, the plan validator, the arbiter and the resolver do not live in this repository. They were extracted into @synergyboat/mcp-query-intelligence, a published package, which is the right move for reuse and the reason nobody can read the commit history of the parts I have just spent an essay praising. Nothing has been pushed to the engine since 5 April 2026.

The DeepQuery gate and validators A question in plain English, Weekly session hours by charger, enters at the top and drops into the confidence gate. The gate is a scoring function with no model call behind it: it weighs six signals, metricMatch at 0.25, schemaCoverage at 0.2, clarity at 0.2, joinConfidence at 0.1, permissionCompleteness at 0.1, profileCoverage at 0.15, and picks one of four actions. The actions and their thresholds: answer when overall >= 0.7, answer-with-caveats when overall >= 0.4, clarify when 0.4 to 0.7 and clarity < 0.5, refuse when overall < 0.4. Refuse ends the run by design. Clarify reaches the wire and ends it by accident: the engine streams a clarification event and the browser handles four event names that do not include it. Schema context for the prompt is chosen rather than summarised: at most six tables through pgvector similarity search, with a keyword fallback at four. The two answering paths continue to candidate generation, where a governed metric formula gives two candidates: profile, compiled from the governed metric formula; and llm, planned by the model, with the profile query as its baseline. Both candidates cross validatePlan's ten rules. Four are structural: table-existence, write-op-detection, json-parse, column-existence. Six are semantic: metric-preservation, grain-preservation, filter-preservation, additivity-check, certification-check, lookup-completeness. An arbiter picks one, and a violation sends a candidate back for up to two repairs. The winner runs read-only as a single statement, with a LIMIT injected when it lacks one, capped at 1,000 rows and 30 seconds. validateResult runs on the rows a second time after execution, and a failure re-enters the repair loop. What comes back over SSE is rows, a chart spec, a narrative and a provenance record. question, in plain english Weekly session hours by charger one of the 43 questions recorded against the client's data confidence gate a scoring function, no model call metricMatch 0.25 schemaCoverage 0.2 clarity 0.2 joinConfidence 0.1 permissionCompleteness 0.1 profileCoverage 0.15 six weighted signals decide the action, before any model is called answer overall >= 0.7 the plain path, and the one all 43 recorded runs took answer-with- caveats overall >= 0.4 the same path, with what the gate could not confirm attached clarify 0.4 to 0.7 and clarity < 0.5 stops at the wire one hardcoded question; the browser drops the event and shows nothing refuse overall < 0.4 stops here nothing is generated and nothing is run where the semantic profile defines the metric, two candidates profile compiled from the governed metric formula compiled from the governed metric formula llm planned by the model, with the profile query as its baseline planned by the model, with the profile query as its baseline validatePlan ten rules, and both candidates take all of them structural semantic table-existence write-op-detection json-parse column-existence metric-preservation grain-preservation filter-preservation additivity-check certification-check lookup-completeness four structural rules and six semantic ones, before anything runs arbitrate picks the candidate that runs, or sends it back the arbiter decides which candidate runs, and it can decide none does up to 2 repairs execution read-only, one statement 1,000 rows, 30 s a LIMIT is injected when the query forgot one, and comments are rejected validateResult the rows are checked after the query ran two of the 43 recorded runs failed here and took a repair over sse: rows, a chart spec, a narrative, a provenance record
part what it does
question Weekly session hours by charger, typed in plain English.
confidence gate A scoring function with no model call. It weighs metricMatch at 0.25, schemaCoverage at 0.2, clarity at 0.2, joinConfidence at 0.1, permissionCompleteness at 0.1, profileCoverage at 0.15.
answer overall >= 0.7. The plain path, and the one all 43 recorded runs took.
answer-with-caveats overall >= 0.4. The same path, with what the gate could not confirm attached.
clarify 0.4 to 0.7 and clarity < 0.5. One hardcoded question; the browser drops the event and shows nothing.
refuse overall < 0.4. Nothing is generated and nothing is run.
schema context Chosen rather than summarised: at most six tables through pgvector similarity search, and four through the keyword fallback.
profile A candidate query, compiled from the governed metric formula.
llm A candidate query, planned by the model, with the profile query as its baseline.
validatePlan Ten rules, and both candidates take all of them. Structural: table-existence, write-op-detection, json-parse, column-existence. Semantic: metric-preservation, grain-preservation, filter-preservation, additivity-check, certification-check, lookup-completeness.
arbitrate Picks the candidate that runs. A violation goes back for up to two repairs.
execution Read-only, a single statement, comments rejected, a LIMIT injected when the query lacks one, capped at 1,000 rows and 30 seconds.
validateResult The rows are validated a second time after execution, and a failure re-enters the repair loop.
over sse Rows, a chart spec, a narrative and a provenance record, streamed back as the run ends.
Fig. 1. The gate and the validators. A question is scored on six weighted signals before any model is called, and the score picks one of four actions; two of them end the run there. What survives is generated twice, put through ten rules, picked by an arbiter, run read-only under a row and a time cap, and validated again once the rows are back. Drawn for DeepQuery only.