Text-to-SQL agent chat over your own database, with hybrid schema retrieval and memory
Problem
Data analysts and non-technical users must hand-write SQL against unfamiliar databases and re-derive context (schema, join paths, past queries) every session.
What it does
Users register a PostgreSQL/MySQL/Oracle/MSSQL database, then ask questions in plain English. A LangGraph agent retrieves relevant schema via hybrid dense+BM25 search in Qdrant, writes and executes validated read-only SQL, and streams an analysis, SQL, and rows back over SSE. Durable facts and user preferences are stored in Mem0; conversations persist in Postgres via a Celery worker.
Architecture
[web] Next.js web app (Clerk auth, chat workspace) (client) [api] FastAPI backend with LangGraph agent (api) [worker] Celery worker (turn persistence, memory writes, schema indexing) (worker) [pg] PostgreSQL (users, databases, conversations, turns) (store) [redis] Redis (checkpoints, caches, Celery broker) (store) [qdrant] Qdrant (dense + BM25 schema index, tenant-filtered) (store) [mem0] Mem0 long-term memory (facts, preferences) (store) [llm] DeepSeek chat model via langchain-deepseek (model) [embed] OpenRouter-hosted embedding model (Qdrant inference) (external) [userdb] User's database (PostgreSQL/MySQL/Oracle/SQL Server) (external) web ──▶ api · REST + SSE chat stream with Clerk bearer token api ──▶ llm · agent runs with tools api ──▶ qdrant · retrieve_context: RRF-fused dense+sparse hybrid search api ──▶ mem0 · search_db_facts / load user prefs api ──▶ redis · db-config cache, checkpointer state restore api ──▶ pg · conversation/turn lookups (SQLModel) api ──▶ userdb · sqlglot-validated read-only SQL, Fernet-decrypted creds api ──▶ worker · enqueue turn persistence and memory writes worker ──▶ pg · insert_chats_in_db worker ──▶ mem0 · memory_write (infer=False) worker ──▶ qdrant · ingest_schema upserts worker ──▶ redis · broker / result backend worker ──▶ embed · embed schema documents on ingest
Simplified — box-and-arrow sketch of the repo structure, not a system diagram.
Decisions
01
Single compiled agent with per-run Context + middleware instead of per-tenant prompts
Dynamic prompt middleware injects engine, dense schema and user preferences at every model call, while ToolCallLimitMiddleware caps retrieve_context at 2 and run_sql at 4 calls per turn; one agent serves all users and databases.
Evidence: apps/backend/agent/init.py, apps/backend/agent/middleware.py, apps/backend/agent/context.py
02
sqlglot AST validation as a guardrail, least-privilege DB role as the real boundary
run_sql parses in the target dialect, allows only single SELECT/WITH/UNION statements, rejects SELECT INTO and any write/DDL/grant/copy node, and returns max 51 rows; README explicitly states validation is not the security boundary and ships per-engine read-only setup guides.
Evidence: apps/backend/agent/tools/run_sql.py, README.md
03
Hybrid dense+BM25 schema retrieval with RRF fusion and tenant filtering
Every query prefetches dense (1024-d, OpenRouter-hosted embedding) and BM25 sparse vectors then fuses with FusionQuery(RRF), filtered by a user__db tenant payload key, so retrieval is scoped per user and per database.
Evidence: apps/backend/agent/tools/retriever.py, apps/task_queue/task_queue/tasks.py
04
Two-layer memory with write-behind via Celery
Short-term state lives in AsyncRedisSaver checkpoints (falling back to last 5 Postgres turns); long-term facts/preferences go to Mem0 via a Celery task with infer=False so stored text is exactly what the agent produced, keeping chat responses off the write path.
Evidence: apps/backend/agent/memory/short.py, apps/task_queue/task_queue/tasks.py, apps/backend/api/endpoints/chat.py
05
Agent quality measured with a DeepEval harness on BIRD-Interact-style data
packages/evals wraps the agent's SQL tool with stricter checks, intercepts Celery memory dispatch so evals don't mutate memory, and scores 15 metrics (SQL correctness, tool ordering, memory quality, grounding) with timestamped JSON reports.
Evidence: packages/evals/runner.py, packages/evals/metrics/, README.md
06
One image, whole monorepo, ARM CI with SSH deploy
Root DockerFile installs both Bun and uv, builds with turbo, and starts web (3000) + API (8000) via one turbo command; GitHub Actions builds on ubuntu-24.04-arm, pushes to Docker Hub, then SSH-deploys by resetting the VM checkout and re-running docker compose.
Evidence: DockerFile, .github/workflows/main.yml, docker-compose.yml
The hard parts
- Multi-dialect read-only SQL safety: sqlglot parsing and validation must be correct across postgres/mssql/mysql/oracle dialects (apps/backend/agent/tools/run_sql.py)
- Streaming a LangGraph agent over SSE with tool_call/tool_result/output events, including checkpointer restore and Postgres turn-rebuild fallback when checkpoints expire (apps/backend/api/endpoints/chat.py)
- Tenant isolation across every store: Qdrant tenant payloads, Mem0 user/app/run filters, owner-scoped Postgres lookups, Fernet-encrypted credentials in Redis cache
- Dynamic single-agent prompting that must adapt to arbitrary schemas at every model call (apps/backend/agent/middleware.py, apps/backend/agent/init.py)
- Live-service evaluation harness with mocked memory dispatch and stricter SQL interception (packages/evals/runner.py)
Known limitations
- No unit or integration tests for application code; the only pytest file is the live-service eval harness requiring LLM/Redis/Qdrant/Mem0 and a test tenant (README.md Known limitations, packages/evals/test_eval.py)
- Final answer is emitted as one SSE output event and faked word-by-word in the frontend; no token streaming from the backend (README.md, apps/backend/api/endpoints/chat.py)
- Celery runs with the solo pool, one task at a time per worker process (README.md)
- Database rename/description editing is client-side only; no backend update endpoint (README.md)
- Landing-page demo workspace is simulated and touches no real database (README.md)