Iris is a conversational natural language to SQL system with memory, preferences, and result-based evaluation.
"Show me customer orders from last month, sorted by total"
→ SQL generated → executed → results displayed
"Actually, always sort results by date descending"
→ Preference saved → applies to all future queries
Key capabilities:
- Conversational chat with persistent memory and context
- User preferences learned from natural language
- Intent routing (SQL query vs conversation vs preference)
- Schema ranking (embeddings) for large databases (56+ tables)
- Local-first storage (SQLite for user data, no cloud dependency)
- Auto database onboarding (single command to connect any PostgreSQL)
Evaluation is based on executed results, not SQL text matching.
- Python 3.10+
- PostgreSQL (Iris queries your databases — see Database Setup below)
- Either:
- Apple Silicon Mac (for local MLX inference — fastest on Mac)
- NVIDIA GPU + CUDA (for HF Transformers backend via Docker)
git clone https://github.com/maxpar1/iris-project.git
cd iris-project
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtcp .env.example .env
# Edit .env — set IRIS_MODEL_BACKEND, model paths, and database passwordsIris ships configured for four PostgreSQL sample databases. The easiest to try is Pagila (DVD rental schema):
# Install Pagila locally — https://github.com/devrimgunduz/pagila
createdb pagila
psql pagila -f pagila-schema.sql
psql pagila -f pagila-data.sql
# Create a read-only user
psql pagila -c "CREATE USER pagila_reader WITH PASSWORD 'your_password';"
psql pagila -c "GRANT CONNECT ON DATABASE pagila TO pagila_reader;"
psql pagila -c "GRANT USAGE ON SCHEMA public TO pagila_reader;"
psql pagila -c "GRANT SELECT ON ALL TABLES IN SCHEMA public TO pagila_reader;"Set PAGILA_READER_PASSWORD=your_password and DB_HOST=localhost in .env.
# Start the engine (loads models — ~30s first run with cache, longer without)
python -m iris_core serve
# In another terminal, check health
python -m iris_core health
# Ask a question
python -m iris_core ask "How many customers are there?" --database pagila
# Or launch the Streamlit chat UI
streamlit run scripts/gui.pydocker compose up --build
curl http://localhost:8002/healthUses the HF Transformers backend. Requires NVIDIA Container Toolkit. Configure IRIS_MODEL_ID in .env to point to any HF chat model (e.g. Qwen/Qwen2-7B-Instruct). Databases still need to be reachable from the container.
/ask— question → SQL → execute. Fields:question,database, optionaltenant_id,top_k,max_tokens. Returnssql,rows,row_count,error_code./health— service status + metrics snapshot/metrics— JSON metrics snapshot/generate— low-level text generation (internal)
- Logs: JSON to stdout (set
IRIS_LOG_LEVEL, default INFO). - Stage logs emitted for
rank,schema_pack,generation,executionwith latency and basic stats. - Metrics:
/healthreturns per-stage counts, success rate, p50/p95/pmax, and average schema size. /metrics: JSON metrics snapshot (same data as/health.metrics).
- Schema caching + hard cap: formatted schemas are cached by signature and trimmed to
IRIS_SCHEMA_MAX_CHARS(default 15k). - Query protection: statement timeout (
IRIS_STATEMENT_TIMEOUT_SECONDS, default 60s) and max row limit (IRIS_MAX_ROW_LIMIT, default 100).
The engine loads 3 specialized models at startup:
| Model | Purpose | Backend |
|---|---|---|
SQL Generator (configurable; default mlx-community/gpt-oss-20b-MXFP4-Q8) |
NL → SQL generation | MLX (local) or HF Transformers (Docker/GPU) |
Embedder (configurable; default Qwen/Qwen3-Embedding-0.6B) |
Schema ranking, table/column scoring | sentence-transformers (PyTorch, uses MPS on Mac) |
Arch-Router (katanemo/Arch-Router-1.5B.gguf) |
Intent classification (SQL/conversation/preference) | llama-cpp-python (GGUF) |
All models are downloaded from Hugging Face on first run and cached locally. No API keys or cloud calls required.
Override any of these via env vars: IRIS_MODEL_ID, IRIS_EMBEDDER_MODEL, IRIS_SCHEMA_RANKER_MODEL, IRIS_ROUTER_MODEL.
The chat system maintains conversation history with semantic retrieval:
from iris_core.chat import ChatSession, ChatStore
store = ChatStore() # SQLite at ~/.iris/chat.db
session = ChatSession(store, user_id="user1", database="pagila")
response = session.process_input("Show me top 5 customers by spend")
# → Routes to SQL generation, executes, returns results
response = session.process_input("What about the bottom 5?")
# → Uses conversation context to understand "bottom 5"Context management:
- Recent 10 messages always included
- Older messages retrieved via semantic similarity (RAG)
- Token budget management for prompt limits
Users state preferences in natural language:
User: "Always show amounts in USD"
→ Preference extracted: {category: "formatting", rule: "Show amounts in USD"}
User: "Sort results by date descending by default"
→ Preference extracted: {category: "sorting", rule: "Sort by date descending"}
User: "Forget that last preference"
→ Most recent preference deactivated
Preferences are:
- Extracted using LLM (JSON output, no regex)
- Stored per-user, optionally per-database
- Injected into SQL generation prompts
The Arch-Router model classifies user input:
| Intent | Example | Action |
|---|---|---|
SQL_QUERY |
"How many orders this month?" | Generate SQL, execute |
CONVERSATIONAL |
"Thanks!" / "Can you explain?" | LLM response |
PREFERENCE |
"Always sort by date" | Extract and save preference |
Low-confidence inputs default to SQL_QUERY (safer for data product).
For large schemas (StackOverflow: 56 tables), the embedding ranker reduces what the LLM sees:
- Converts tables to searchable documents
- Scores relevance to the question
- Selects top-k tables (default: 10)
- FK-aware expansion: Adds linking tables via BFS
Reduces prompt size from 50k+ chars to ~10k while preserving join paths.
Used by the Streamlit UI (
scripts/gui.py) and directly via the/generate+/routeAPI endpoints. The CLIaskcommand sends the full schema to the model, which works fine for small databases like Pagila (~22 tables).
At database onboarding, every table and column gets an LLM-generated description:
Table: film_actor
Description: "Links films to actors in a many-to-many relationship"
Column: rental.return_date
Description: "When the rental was returned, NULL if still out"
Enrichment uses sample data for context and runs once (cached).
All user data stored locally:
| Data | Storage | Location |
|---|---|---|
| Chats & messages | SQLite | ~/.iris/chat.db |
| User preferences | SQLite | ~/.iris/chat.db |
| Message embeddings | SQLite BLOB | ~/.iris/chat.db |
| Schema cache | JSON files | .schema_cache/ |
No cloud accounts or API keys required for user data.
Single command to onboard any PostgreSQL database:
python scripts/add_database.py mydb \
"postgresql://user:pass@host:5432/dbname" \
--schema publicThis automatically:
- Tests connection
- Discovers non-public schemas
- Introspects tables, columns, PKs, FKs (via
pg_catalog) - Enriches with LLM-generated descriptions
- Updates
databases.yamland.env
| Database | Tables | Description |
|---|---|---|
| pagila | 21 | DVD rental business (customers, films, payments) |
| chinook | 11 | Music store (artists, albums, invoices) |
| support_tickets | 3 | IT support tickets (priorities, queues, types) |
| stackoverflow | 56 | StackOverflow dump (100GB+, 1M users, 5M posts) |
All databases are read-only PostgreSQL accessed via Tailscale.
User Input
│
▼
┌──────────────────┐
│ Intent Router │ ← Arch-Router model
│ (classify) │
└────────┬─────────┘
│
┌────┴────┬────────────┐
▼ ▼ ▼
SQL_QUERY PREFERENCE CONVERSATIONAL
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│Ranker │ │Extract │ │ LLM │
│ (embed)│ │ & Save │ │Response│
│ Schema │ └────────┘ └────────┘
│ Build │
│Generate│ ← SQL model
│Validate│
│Execute │
└────────┘
│
▼
Results
iris_core/
├── chat/ # Conversational system
│ ├── session.py # Main orchestrator
│ ├── router.py # Intent classification (Arch-Router)
│ ├── context.py # Conversation context + RAG
│ ├── preferences.py # Preference extraction & management
│ ├── store.py # SQLite persistence
│ ├── embeddings.py # BGE-micro-v2 client
│ └── models.py # Data models (Chat, Message, Preference)
│
├── schema/ # Schema processing
│ ├── model.py # Schema, Table, Column dataclasses
│ ├── enricher.py # LLM-based descriptions
│ ├── ranker.py # Table ranking (embeddings) + FK expansion
│ └── formatter.py # Schema → prompt text
│
├── db/ # Database connectivity
│ ├── connection.py # DbTarget, DbRegistry
│ ├── introspection.py # pg_catalog queries
│ ├── cache.py # Hash-based schema cache
│ └── sampler.py # Sample data for enrichment
│
├── sql/ # SQL generation & execution
│ ├── engine.py # FastAPI server (4 models)
│ ├── generator.py # LLM → SQL extraction
│ ├── validator.py # Safety checks (no DROP/DELETE)
│ └── executor.py # Execute on live DB
│
├── prompt/ # Prompt construction
│ ├── templates.py # System prompts
│ └── builder.py # Assemble schema + context + question
│
└── eval/ # Evaluation framework
├── result_compare.py # Result-based comparison
└── cases/ # Test cases by level (1-5)
# All databases, level 1
python scripts/run_eval.py --rerank # --rerank enables embedding-based ranking
# Single database
python scripts/run_eval.py pagila --rerank
# Specific level (1-5)
python scripts/run_eval.py --level 3 --rerank
# Detailed logging
python scripts/run_eval_with_log.py stackoverflow| Level | Difficulty | Skills |
|---|---|---|
| 1 | Basic | SELECT, COUNT, WHERE |
| 2 | Intermediate | JOINs, GROUP BY, ORDER BY |
| 3 | Advanced | Multi-hop JOINs, NOT EXISTS, aggregations |
| 4 | Master | Window functions, date arithmetic, CTEs |
| 5 | Edge | Recursive CTEs, self-joins, complex ratios |
| Database | Level 1-2 | Level 3 | Level 4 | Level 5 | Total |
|---|---|---|---|---|---|
| pagila | 100% | 100% | 100% | 100% | 100% |
| chinook | 100% | 100% | 100% | 100% | 100% |
| support_tickets | 100% | 100% | 100% | 100% | 100% |
| stackoverflow | 100% | 60% | 40% | 60% | 70% |
Supports two formats:
targets:
# Decomposed credentials (original)
pagila:
host: "100.x.x.x"
port: 5432
dbname: "pagila"
user_env: "PAGILA_READER_USER"
password_env: "PAGILA_READER_PASSWORD"
# Connection string (simpler)
stackoverflow:
connection_string_env: "STACKOVERFLOW_DATABASE_URL"
schemas: ["se"] # Non-public schema support# .env file
PAGILA_READER_USER="pagila_reader"
PAGILA_READER_PASSWORD="..."
STACKOVERFLOW_DATABASE_URL="postgresql://user:pass@host:5432/db"iris-project/
├── iris_core/
│ ├── chat/ # Conversational system
│ ├── schema/ # Schema processing
│ ├── db/ # Database connectivity
│ ├── sql/ # SQL generation & execution
│ ├── prompt/ # Prompt construction
│ ├── eval/ # Evaluation framework
│ └── config/ # databases.yaml
│
├── scripts/
│ ├── add_database.py # Auto database onboarding
│ ├── run_eval.py # Evaluation runner
│ ├── run_eval_with_log.py # Detailed logging
│ ├── update_cache_facts.py # Refresh schema enrichment
│ └── gui.py # Streamlit interface
│
├── tests/ # Test suite
└── .env # Secrets (not committed)
- Result-based correctness — Evaluated on executed results, not SQL text
- Conversational first — Memory, context, and preferences matter
- Local-first — User data stays local, no cloud dependency
- Runtime introspection — No hardcoded schema assumptions
- Deterministic pipeline — LLM is one step in a reproducible flow
# Start the engine (loads all models — required before `ask` or GUI)
python -m iris_core serve
# Health check
python -m iris_core health
# Ask a question (requires engine running)
python -m iris_core ask "How many customers are there?" -d pagila
python -m iris_core ask "Top 5 films by rental count?" -d pagila --sql-only
# Launch the Streamlit chat UI
streamlit run scripts/gui.py
# Add a new database target (updates databases.yaml + runs schema enrichment)
python scripts/add_database.py NAME "postgresql://..." --schema SCHEMA
# Run evals against benchmark questions
python scripts/run_eval.py # All DBs, level 1
python scripts/run_eval.py pagila # Single DB
python scripts/run_eval.py --level 4 --rerank # Level 4 with reranking
python scripts/run_eval_with_log.py pagila # Detailed log
# Streamlit GUI
streamlit run scripts/gui.py