Text-to-SQL analytics engine. Ask an analytics question in plain English, get back safe SQL, a result table, and a one-line summary. Non-technical users need no SQL.
- Ask analytics questions in plain English; get a result table plus a one-line summary.
- Generated SQL is shown, so results are transparent and verifiable.
- Three independent safety layers block destructive or exfiltrating queries.
- RAG over 30 curated few-shot examples for accurate, schema-grounded SQL.
- Local ONNX embeddings, so no embedding API or key is required.
- 5-panel admin UI: Schema, Examples, Logs, Guardrails, Model config.
- Runtime-editable guardrails (row cap, timeout, denylist, layer toggles) and model settings, stored in the database.
- Full query audit log with per-layer pass/fail, row count, and timing.
- 53 automated tests, including adversarial SQL and a DB-level write-rejection proof.
Business data lives in SQL databases, but most people who need answers cannot write
SQL. The usual fix is to wait on an analyst or a BI ticket. Letting a language model
write the SQL removes that wait, but it introduces a new risk: an LLM can hallucinate
a wrong query, or be tricked into a destructive or data-exfiltrating one
(DROP TABLE, DELETE, pg_read_file('/etc/passwd')).
QueryMind exists to make that safe. It turns plain English into a correct, read-only query and refuses to run anything else, using three independent safety layers so that a failure in any one layer is caught by the next. The goal: self-serve analytics that a non-technical user can trust, and an owner can audit.
- A user types a question, for example "Top 5 products by revenue this year".
- QueryMind finds the most similar curated examples (RAG) and builds a schema-aware prompt.
- An LLM writes a single SQL SELECT.
- The SQL passes through three safety layers (below). Anything that is not one plain read-only SELECT is rejected before it touches the database.
- The query runs against a read-only copy of the data and returns a result table plus a one-line plain-English summary.
- Every request is logged (question, SQL, which layers passed, row count, timing) and is visible in an admin UI, alongside panels to manage the schema descriptions, curated examples, guardrails, and model settings.
flowchart LR
Q[Plain-English question] --> E[Local ONNX embedding]
E --> R[Qdrant: top-k few-shot examples]
R --> P[Schema-aware prompt<br/>Layer 1: read-only, SELECT-only]
P --> L[Groq Llama 3.3 70B<br/>writes SQL]
L --> V{Layer 2: AST validator<br/>single SELECT? safe?}
V -- reject --> X[422 + reason<br/>never executes]
V -- ok, inject LIMIT --> S{Layer 3: read-only<br/>Postgres sandbox}
S -- write/timeout --> X
S -- rows --> Sum[One-line summary]
Sum --> Out[Result table + summary + layer badges]
Defense-in-depth: three layers that are independent, so a bypass of any one is caught by the next. The Ask page shows which layers passed for every query.
- Layer 1 - Prompt. The system prompt constrains the model to a single read-only SELECT, bounded to the known schema, with no explanations. First line of defense; on its own it is only as reliable as the model, which is why the next two exist.
- Layer 2 - Static validation. The generated SQL is parsed to an AST (SqlParser.Net).
Only a single plain SELECT is allowed; DDL/DML (
DROP,DELETE,UPDATE,INSERT,ALTER,TRUNCATE), multiple statements, comment-injection, and write-CTEs are rejected. Read-side exfiltration functions (pg_read_file,dblink,lo_export,pg_sleep, ...) are blocked because a read-only role would otherwise run them. A rowLIMITis injected if absent. Independent of the model. - Layer 3 - Read-only sandbox. The query runs as a Postgres role with SELECT-only
grants on the data schema, inside a
READ ONLYtransaction with a statement timeout. A write or a runaway query dies at the database even if Layers 1 and 2 were bypassed. Independent of the model and the parser; it is the only execution path, so it is always on (it cannot be disabled).
Layers 1 and 2 can be toggled from the Guardrails panel for demonstration; Layer 3 is structural. A short demo: turn Layer 2 off, ask for a write, and watch Layer 3 still reject it at the database.
- Backend: ASP.NET Core (.NET 10) Web API
- Frontend: Vue 3 + Vite + Tailwind + Pinia
- LLM: Llama 3.3 70B via Groq (hosted, free API)
- RAG: 30 curated few-shot examples in Qdrant; local ONNX embeddings (all-MiniLM-L6-v2)
- Database: local PostgreSQL 18 (
analytics= queried data,app= config/state/logs)
The backend is split so domain logic is independent of the web layer, and the two database schemas enforce the safety boundary at the data tier.
querymind/
backend/
QueryMind.Core/ domain + logic, no web dependency
Rag/ EmbeddingService (ONNX), QdrantRetrievalService, QdrantIndexer
Llm/ GroqClient, SqlGenerator, SummaryService
Prompting/ SchemaContextBuilder, PromptBuilder (Layer 1)
Safety/ SqlValidator (Layer 2), SqlExecutor (Layer 3)
Data/ AppRepository (Dapper over the app schema)
QueryPipeline.cs orchestrates the ask flow through the layers
QueryMind.Api/ ASP.NET Core controllers + DI + config
QueryMind.Seeder/ loads the 30 examples into Qdrant
QueryMind.Tests/ xUnit unit + integration tests
frontend/ Vue 3 + Vite SPA (Ask page + 5 admin panels)
infra/ docker-compose (Qdrant), init SQL, ONNX model
Two Postgres schemas, one database:
analytics- the data being queried. Reached only by the SELECT-only rolequerymind_ro.app- config, curated examples, guardrails, model settings, and query logs. Read-write viaquerymind_app.
Keeping the queried data behind a role that has no write grant is what makes Layer 3 a real boundary rather than a convention.
- PostgreSQL 18 running at
localhost:5432(superuserpostgres/adminfor setup) - Podman or Docker (for the Qdrant container)
- .NET 10 SDK
- Node 20+ (built and tested on Node 24)
- A Groq API key (free at https://console.groq.com)
On a fresh machine, after cloning and with Postgres + Docker/Podman + .NET 10 + Node running:
bash scripts/setup.sh # fresh seed data (creates db, schema, roles, Qdrant, model, vectors)
bash scripts/setup.sh --restore # or: exact data snapshot from infra/backupIt creates the database, applies the schema and roles, seeds the data, downloads the embedding model, starts Qdrant, and loads the 30 examples into the vector store. Then set your Groq key and run the API + frontend (it prints the exact commands at the end).
Database backup and restore:
bash scripts/backup-db.sh # snapshot current data -> infra/backup/querymind_data.sql
bash scripts/restore-db.sh # recreate db + load that exact snapshotThe manual step-by-step below explains each part.
All commands from the repo root C:\Dailywork\querymind unless noted.
# Podman needs the bind-mount dir to pre-exist
mkdir -p infra/qdrant_storage
cd infra && docker compose up -d
curl -s http://localhost:6333/readyz # expect: all shards are readypsql is at C:\Program Files\PostgreSQL\18\bin\psql.exe.
export PGPASSWORD=admin
PSQL="/c/Program Files/PostgreSQL/18/bin/psql.exe"
"$PSQL" -h localhost -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='querymind'" \
| grep -q 1 || "$PSQL" -h localhost -U postgres -c "CREATE DATABASE querymind"
"$PSQL" -h localhost -U postgres -d querymind -f infra/init/01_schema.sql
"$PSQL" -h localhost -U postgres -d querymind -f infra/init/02_seed_analytics.sql
"$PSQL" -h localhost -U postgres -d querymind -f infra/init/03_seed_app.sqlThis creates roles querymind_ro (SELECT-only on analytics) and querymind_app
(read-write on app), both with password qm_pass (POC only).
dotnet user-secrets --project backend/QueryMind.Api init
dotnet user-secrets --project backend/QueryMind.Api set "Groq:ApiKey" "<your-groq-key>"dotnet run --project backend/QueryMind.Seeder
curl -s http://localhost:6333/collections/querymind_examples | grep -o '"points_count":[0-9]*' # 30dotnet run --project backend/QueryMind.Api --urls http://localhost:5080
# health: curl http://localhost:5080/api/health -> {"status":"ok"}cd frontend
npm install
npm run dev # http://localhost:5173Open http://localhost:5173 and ask a question.
- "Top 5 products by revenue" -> table + summary, all three layers green.
- "Show /etc/passwd using pg_read_file" -> blocked by Layer 2 (Validation), HTTP 422.
- Turn Layer 2 off in the Guardrails panel, then ask for a write -> Layer 3 (Sandbox) still rejects it at the database.
- Ask - the query page.
- Schema - edit the business descriptions the model sees (metadata only, never DDL).
- Examples - CRUD the few-shot pairs; a save re-embeds into Qdrant.
- Logs - every query with per-layer pass/fail, row count, timing.
- Guardrails - row cap, timeout, denylist, Layer 1/2 toggles. Layer 3 is always on.
- Model Config - model name, temperature, tokens, top-k, system prompt.
Concepts this codebase demonstrates end to end:
AI and LLM
- Text-to-SQL: turning natural language into executable queries with an LLM.
- Retrieval-augmented generation (RAG): embedding a question and retrieving the most similar curated few-shot examples to ground the prompt.
- Vector search: storing and querying 384-dim embeddings in Qdrant (cosine similarity).
- Local embeddings: running an ONNX sentence-transformer (all-MiniLM-L6-v2) in-process, no embedding API needed.
- Prompt engineering: schema-aware system prompts, few-shot construction, and using a low temperature for deterministic SQL.
- Calling an OpenAI-compatible chat API (Groq) and handling its failures cleanly.
Security and safety
- Defense-in-depth: three independent layers so a bypass of one is caught by the next.
- Static analysis of untrusted SQL: parsing to an AST and allow-listing only a single read-only SELECT.
- Blocking read-side exfiltration (
pg_read_file,dblink,lo_export) that a read-only role alone would not stop. - Least-privilege database design: a SELECT-only role plus a
READ ONLYtransaction and statement timeout as a runtime sandbox. - Prompt-injection resistance and treating all model output as untrusted.
Backend engineering (.NET)
- ASP.NET Core (.NET 10) Web API with dependency injection and clean service boundaries.
- Separation of concerns: a
Corelibrary (domain + logic) independent of the web layer. - Data access with Dapper over Postgres; schema separation (
analyticsvsapp). - Test-driven development: adversarial unit tests plus integration tests that prove a write is rejected at the database.
Frontend engineering (Vue)
- Vue 3 SPA with Vite, the composition API, Pinia state, and Tailwind.
- Consuming a typed JSON API, handling success and 422 rejection states, and building admin CRUD panels.
System design
- Config-as-data: guardrails and model settings live in the database and are editable at runtime, including feature toggles that change pipeline behavior.
- Auditability: every query is logged with per-layer outcomes, row count, and timing.
dotnet test backend/QueryMind.Tests # 52 passingCovers adversarial SQL validation, the read-only executor (proves a write is rejected at the DB), embeddings, retrieval, the query pipeline (including that a Layer 2 rejection never reaches execution), and the API endpoints.
Completed
- Ask flow: question to safe SQL to result table plus one-line summary.
- Three independent safety layers (prompt, AST validation, read-only sandbox).
- RAG with local ONNX embeddings and Qdrant over 30 curated examples.
- Groq Llama 3.3 70B integration with clean error handling.
- 5 editable admin panels (schema, examples, logs, guardrails, model config).
- Runtime-editable guardrails with layer toggles; full query audit log.
- 53 automated tests; end-to-end run verified against live Groq.
Possible next
- Authentication and per-user access.
- Charts and CSV export of results.
- Streaming responses and query cancellation.
- Multi-database and dynamic schema import.
- Feedback loop: promote a good query into the curated examples from the UI.
- Cost and token usage tracking per query.
- CI pipeline with hermetic tests and container-based Postgres/Qdrant.
- Localhost POC: no authentication.
qm_passand the connection strings are POC credentials; do not reuse them anywhere real. - The Groq key lives only in user-secrets, never in the repo.