Natural Language β SQL Analytics Pipeline
A step-by-step guide to building an enterprise-grade system that converts business questions into SQL queries using RAG, local LLMs, and guardrails β all running locally with open-source tools, no paid APIs.
What Are We Building?
Imagine a business analyst asking their database: "Show me the top 5 trades by value from our risk-taker clients in the last quarter". In a traditional system, they'd have to write or request custom SQL. With our NL2SQL system, they ask in plain English, and the system automatically generates, validates, and executes the SQL β all without a single line of hand-written code.
This is the power of combining RAG (Retrieval Augmented Generation), local LLMs (Ollama + Mistral), and strict validation guardrails. The system is:
- β Fast β No API calls, everything runs locally
- β Safe β 12-point guardrail validation before execution
- β Explainable β Audit trails of what SQL was generated and why
- β Educational β Every component is transparent and teachable
The 5 Core Components
1. RAG Retriever
Searches a knowledge base of schema definitions, business glossaries, and policy rules. Finds the most relevant context for the user's question.
2. SQL Generator
Takes the question + retrieved context and uses Mistral 7B (via Ollama) to generate a SELECT query using the CRISP prompt template.
3. SQL Validator
Runs 12 guardrail checks on the generated SQL: SELECT-only, schema whitelist, dangerous patterns, alias resolution, and more.
4. Query Executor
Executes validated SQL against PostgreSQL (or SQLite in PoC mode). Handles connection pooling, transactions, and error logging.
5. Response Formatter
Converts raw query results into formatted outputs: ASCII table, CSV, JSON, or HTML reports for dashboards.
Learning Goals: By the end of this guide, you will understand how to build an LLM-powered database interface from scratch. You'll learn what RAG is, why prompt structure matters, how guardrails work, and why you should never trust LLM output for database access.
Architecture & Pipeline Flow
The NL2SQL system is a 9-step pipeline. A user asks a question in plain English, and it flows through several stages of processing before returning formatted results. Here's what happens behind the scenes:
The Complete Pipeline
User asks a question
"Show me the top 5 trades by value"
Convert question to embedding
sentence-transformers (all-MiniLM-L6-v2) converts the text into a 384-dimensional vector for similarity search.
FAISS retrieval (4 category indexes)
Top 2 SCHEMA matches, Top 1 RELATIONSHIP match, Top 2 GLOSSARY matches (if similarity > 0.40), All POLICY documents.
Send question + context to LLM
Mistral 7B (via Ollama) receives the question and retrieved context via the CRISP prompt template.
LLM generates SQL
Mistral outputs a PostgreSQL SELECT query based on the schema and glossary context.
SQL Validator checks 12 guardrails
Regex patterns, schema whitelist, alias resolution, complexity limits, and LIMIT clause warnings.
If blocked β Retry with feedback
Validator returns an error message (e.g., "Column 'trade_value' doesn't exist"). LLM self-corrects and regenerates SQL.
Execute against PostgreSQL
Validated SQL runs against the database. Results are logged with the user's question, LLM reasoning, and execution time.
Format and return results
Results are converted to a table, CSV, JSON, or HTML report and returned to the user.
System Architecture Diagram
User Question
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Embedding (sentence-transformers) β
β β β
β "Show me top 5 trades by value" β
β β [0.12, 0.45, -0.23, ...] β
ββββββββββββββββ¬βββββββββββββββββββββββββββ
β 384-dim vector
βΌ
ββββββββββββββββββββββββββββ
β FAISS Retriever β
β 4 Category Indexes: β
β - SCHEMA β
β - GLOSSARY β
β - RELATIONSHIP β
β - POLICY β
ββββββββββββββββ¬ββββββββββββ
β context docs
βΌ
ββββββββββββββββββββββββββββ
β CRISP Prompt Template β
β + Retrieved Context β
β + User Question β
ββββββββββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββ
β Ollama (Mistral 7B) β
β SQL Generation β
ββββββββββββββββ¬ββββββββββββ
β generated SQL
βΌ
ββββββββββββββββββββββββββββ βββββββββββββββ
β SQL Validator βββββββΆβ Retry Loop?β
β 12 Guardrails ββββββββ Feedback β
ββββββββββββββββ¬ββββββββββββ βββββββββββββββ
β valid SQL
βΌ
ββββββββββββββββββββββββββββ
β PostgreSQL Executor β
β Run Query β
ββββββββββββββββ¬ββββββββββββ
β results
βΌ
ββββββββββββββββββββββββββββ
β Response Formatter β
β (Table/CSV/JSON/HTML) β
ββββββββββββββββββββββββββββ
Key Insight: The pipeline is designed as separate, testable stages. Each component has a single responsibility: retrieve context, generate SQL, validate it, execute it, or format it. This makes it easy to debug, test, and teach.
Technology Stack
Every tool in our stack was chosen for a specific reason: fast iteration, educational value, and production readiness. Here's what we use and why:
| Component | Technology | Why We Chose It |
|---|---|---|
| LLM | Ollama + Mistral 7B | Runs on 16GB RAM. Excellent instruction following and fast generation. Open-source and free. |
| Embeddings | all-MiniLM-L6-v2 (via sentence-transformers) | Only 80MB. 384-dim vectors sufficient for PoC. No API calls. Runs locally in 1-2ms per vector. |
| Vector Store (PoC) | FAISS | Zero setup, in-memory search, extremely fast similarity matching. Good for learning. |
| Vector Store (Prod) | pgvector (PostgreSQL extension) | Embeddings live in database. No separate index. Scales with data. |
| Database (PoC) | SQLite | File-based, zero setup. Perfect for initial development and testing. |
| Database (Prod) | PostgreSQL 16 | DECIMAL type for exact financial math. ACID guarantees. Docker-ready. |
| Prompt Framework | Custom CRISP Template (XML-based) | No external dependency. Full control. Easy to version and test. |
| Containerization | Docker | Clean isolation. PostgreSQL + Ollama + app all run in one docker-compose. |
| API Framework | FastAPI (optional for Phase 6) | Modern Python async framework. Auto-generated OpenAPI docs. Great for REST endpoints. |
Why Not LangChain or LlamaIndex?
For this PoC, we deliberately avoided heavy frameworks. LangChain and LlamaIndex are excellent tools, but for teaching purposes, every component in our system is a plain Python class with clear inputs and outputs. This makes the system easier to debug, test, and understand. You'll have a complete mental model of how NL2SQL works. In production, you might layer these frameworks on top of our foundation β but you'll understand what's underneath.
Phase 1-2: RAG β The Knowledge Engine
RAG stands for "Retrieval Augmented Generation." Instead of asking the LLM to generate SQL from scratch, we first retrieve relevant context (schema definitions, business glossaries, SQL patterns, safety rules) and feed that context to the LLM. This dramatically improves accuracy without retraining or fine-tuning.
Why RAG Matters
Without RAG, if we ask Mistral 7B "Show me the top 5 trades by value," it might hallucinate column names
(e.g., trade_value doesn't exist β the column is actually quantity * price).
With RAG, we retrieve the schema document that lists all valid columns, and Mistral generates correct SQL.
The 4 Knowledge Categories
We organize our knowledge base into 4 FAISS indexes, each serving a different purpose:
Table definitions with column names, data types, example values, primary keys, and foreign key relationships.
Business term definitions with SQL patterns. E.g., "Net Exposure" = CASE WHEN statement with specific logic.
JOIN conditions and example SQL queries showing how tables connect. E.g., "trades JOIN clients ON trades.client_id = clients.id"
Safety rules: SELECT-only queries, LIMIT required, single statement, no dynamic SQL. Non-negotiable for production.
How Retrieval Works
When a user asks a question, the system:
- Converts the question to a 384-dim embedding using all-MiniLM-L6-v2
- Searches the FAISS index for similar vectors
- Retrieves context with category-aware thresholds:
# Every query retrieval uses this strategy:
SCHEMA: Top 2 matches (always included)
RELATIONSHIP: Top 1 match (always included)
GLOSSARY: Top 2 matches (only if similarity > 0.40)
POLICY: All documents (always included)
Knowledge Base Structure
KNOWLEDGE_BASE = [
{
"text": "Table: trades\nColumns:\n - trade_id (INTEGER, PRIMARY KEY)\n - client_id (INTEGER, FOREIGN KEY)\n - symbol (TEXT)\n - side (TEXT: BUY or SELL)\n - quantity (INTEGER)\n - price (DECIMAL)\n - trade_date (DATE)",
"category": "SCHEMA",
"metadata": {"table": "trades"},
},
{
"text": "Business term: Net Exposure\nDefinition: Sum of absolute notional value per counterparty\nSQL pattern: SUM(CASE WHEN side='BUY' THEN quantity*price ELSE -quantity*price END)",
"category": "GLOSSARY",
"metadata": {"term": "net_exposure"},
},
# ... more documents ...
]
The Context Contamination Problem
Real Bug #1: In early testing, a glossary term "Active client" was being retrieved for every query,
even unrelated ones. The definition included "AND trade_date >= date('now', '-30 days')" to filter only recent trades.
This poisoned all queries, adding unwanted date filters that business users didn't ask for.
Solution: We implemented category-aware retrieval with a similarity threshold (0.40) for GLOSSARY documents.
Now, "Active client" only appears if the question is actually asking about active clients, not every single query.
Key Insight: RAG quality directly impacts LLM quality. A small model (Mistral 7B) with excellent context beats a large model (GPT-4) with poor context. Spend time curating your knowledge base.
Phase 3: The CRISP Prompt β Taming the LLM
LLMs are powerful but unpredictable. The difference between a hallucinating LLM and a precise SQL generator is often just the prompt. The CRISP framework β Context, Role, Instructions, Separator, Precision β is our weapon.
What CRISP Stands For
Context
Schema, relationships, glossary, and policies. Everything the LLM needs to know.
Role
Tell the LLM what job it has. "You are a precise SQL translator, NOT a helpful assistant."
Instructions
Explicit rules: Output ONLY a SELECT statement. Use ONLY tables in the schema. No WHERE unless asked.
Separator
Use XML tags or clear delimiters to separate sections. Prevents prompt injection and confusion.
Precision
Temperature = 0.1 (deterministic). num_predict = 256 (caps output). Short SQL = better SQL.
The CRISP Prompt Template
<ROLE>
You are a precise SQL translator. Your ONLY job is to convert the user's
question into a single PostgreSQL-compatible SELECT query.
You are NOT a helpful assistant. Do NOT add anything the user didn't ask for.
Do NOT explain your reasoning. Output ONLY the SQL query.
</ROLE>
<SCHEMA>
Table: trades
Columns: trade_id, client_id, symbol, side, quantity, price, trade_date
Table: clients
Columns: client_id, client_name, risk_tier
</SCHEMA>
<RELATIONSHIPS>
trades JOIN clients ON trades.client_id = clients.id
</RELATIONSHIPS>
<GLOSSARY>
"Net Exposure" = SUM(CASE WHEN side='BUY' THEN quantity*price ...)
"Active Client" = clients with trades in last 30 days
</GLOSSARY>
<RULES>
1. Output ONLY a single SELECT statement. No explanation.
2. Use ONLY tables and columns listed in SCHEMA.
3. Do NOT add WHERE clauses unless the user explicitly asks.
4. Do NOT use subqueries unless necessary for the question.
5. Always include a LIMIT clause (max 1000 rows).
6. Use table aliases (SELECT t.trade_id FROM trades t).
</RULES>
<QUESTION>
{user_question}
</QUESTION>
Before & After: The Power of Structure
Question: "How many trades per client?"
| Phase | Generated SQL | Result |
|---|---|---|
| Phase 1 (no prompt) | SELECT c.client_name, COUNT(t.trade_id) FROM trades t JOIN clients c ON t.client_id = c.id WHERE t.trade_date >= date('now', '-30 days') GROUP BY c.client_id |
β Added unwanted WHERE filter from glossary contamination |
| Phase 2 (basic prompt) | SELECT c.client_id, COUNT(t.trade_id) FROM trades t JOIN clients c WHERE t.trade_date >= date('now', '-30 days') GROUP BY c.client_id LIMIT 20 |
β οΈ Still filtering by date (glossary leak) |
| Phase 3 (CRISP) | SELECT c.client_id, COUNT(t.trade_id) FROM trades t JOIN clients c ON t.client_id = c.id GROUP BY c.client_id LIMIT 20 |
β Clean, correct, no unwanted filters |
Why Temperature and num_predict Matter
Temperature = 0.1: Makes the LLM deterministic. The model always picks the highest-probability next token,
reducing randomness. For SQL, we want predictability, not creativity.
num_predict = 256: Caps the response length. SQL queries should be short. If the model is writing 500 tokens,
something is wrong β it's probably explaining itself or hallucinating. Force it to be concise.
Phase 4: Guardrails β Trust but Verify
Golden Rule: Never trust LLM output for database access. Even a 99% accurate LLM fails 1% of the time. In finance, 1% can mean exposing PII, corrupting data, or destroying a database. Guardrails are non-negotiable.
The 7 Validation Checks
SELECT-Only Check
Regex: ^SELECT. No INSERT, UPDATE, DELETE, DROP. Financial system = read-only.
Single Statement Check
No semicolons or multiple statements. Prevents SQL injection via stacking (e.g., SELECT ...; DROP TABLE trades;)
Dangerous Pattern Detection
12 regex patterns: xp_cmdshell, sys_exec, dynamic SQL, comments (--), wildcards in LIKE, etc.
Table Whitelist Validation
Parse the query. Extract all table names. Check if they're in the approved schema. Reject unknowns.
Column Validation + Alias Resolution
Parse SELECT and WHERE clauses. Resolve aliases (e.g., t.trade_value where t = trades). Reject invalid columns.
Complexity Limits
Max query length, max JOIN count, max subqueries. Prevents runaway queries that lock tables.
LIMIT Clause Warning
If no LIMIT, warn (but allow). If LIMIT > 10000, block. Protects against accidental table scans.
The Hallucination Problem: Alias Resolution
This was our biggest pain point. The LLM constantly invents columns that don't exist. Here's the attack:
# User asks: "What's the average trade value?"
# LLM generates:
SELECT AVG(t.trade_value) FROM trades t
# Column 'trade_value' doesn't exist. The correct columns are:
# - trade_id, client_id, symbol, side, quantity, price, trade_date
# Validator detects:
# - FROM clause: "trades t"
# - alias_map = {"t": "trades"}
# - SELECT clause: "t.trade_value"
# - Resolve: t.trade_value β trades.trade_value
# - Check schema: trades.trade_value EXISTS? NO
# - Block and return error
Why Alias Resolution is Hard: The LLM learns patterns from training data. It sees "trade_value" in financial datasets and assumes it's universal. Mistral + small training data doesn't know our specific schema. That's why retrieval-augmented prompting is critical β we show the LLM exactly which columns exist.
Phase 5: The Retry Loop β Self-Healing SQL
When the validator blocks a query, instead of giving up, we feed the error back to the LLM and ask it to fix itself. This "self-correction" loop catches most bugs without human intervention.
How the Retry Loop Works
LLM generates SQL
SELECT AVG(t.trade_value) FROM trades t...
Validator blocks it
Error: "Column 'trade_value' doesn't exist. Allowed columns: trade_id, client_id, symbol, side, quantity, price, trade_date"
Retry prompt with error feedback
Send error message back to LLM with the list of valid columns.
LLM self-corrects
SELECT AVG(t.quantity * t.price) FROM trades t...
Validator approves
All columns are valid. Execute.
Retry Implementation
def retry_with_feedback(question, context, failed_sql, error_message):
"""
Retry prompt: Ask LLM to fix ONLY the specific error.
"""
retry_prompt = f"""
Your previous SQL was rejected:
{failed_sql}
Error: {error_message}
Fix ONLY this error. Use ONLY columns in the schema.
Output ONLY the corrected SQL query.
"""
# Send to LLM with temperature=0.1
corrected_sql = mistral.generate(retry_prompt, temperature=0.1)
return corrected_sql
Improvement Across Phases
| Test Query | Phase 1 | Phase 2 | Phase 3 | Phase 5 (Retry) |
|---|---|---|---|---|
| Top 5 trades | β | β | β | β |
| Trades per client | β Wrong filter | β Still filtered | β | β |
| BUY/SELL AAPL | β Multi-statement | β | β | β |
| Avg trade value | β Bad column | β Bad column | β (in prompt) | β (retry fixed) |
| Top 10 risky | β Hallucinated | β Hallucinated | β | β |
One Retry Attempt is Often Enough: We typically allow 1-2 retry attempts before giving up. If the LLM can't fix its own mistake in 2 tries, there's a deeper issue. Better to log it and let a human investigate.
Phase 5: SQLite β PostgreSQL Migration
We started with SQLite (zero setup, file-based) to get fast iteration. Once the logic was solid, we migrated to PostgreSQL for production use. The beauty of our database abstraction layer: the entire pipeline doesn't care which database is behind it.
SQLite vs. PostgreSQL
| Aspect | SQLite (PoC) | PostgreSQL (Prod) |
|---|---|---|
| Setup | Create .db file, done | Docker container or managed service |
| Numbers | REAL (float, 8 bytes) | DECIMAL(12,2) (exact, no rounding) |
| Dates | TEXT ("2024-01-15") | DATE type, built-in functions |
| Concurrency | Limited (file locks) | Full ACID, row-level locking |
| Scaling | Hundreds of MB max | Terabytes possible |
| Extensions | None | pgvector, JSON, full-text search, etc. |
Financial Math: Why DECIMAL Matters
The Danger of REAL/Float: Floating-point math has rounding errors. For a trade at $100.01, quantity 3:
SQLite REAL: 100.01 * 3 = 300.02999999999997 (not exactly 300.03)
PostgreSQL DECIMAL: 100.01 * 3 = 300.03 (exact)
In financial systems, these tiny errors compound. A portfolio with 10,000 trades could be off by thousands of dollars.
Always use DECIMAL for money.
Docker Setup
# docker-compose.yml for production
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: nl2sql
POSTGRES_PASSWORD: nl2sql_prod
POSTGRES_DB: financial_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
volumes:
postgres_data:
ollama_data:
Database Abstraction Pattern
class DatabaseConnection:
"""Works with SQLite or PostgreSQL β pipeline doesn't care which."""
def __init__(self, db_type="postgresql", config=None):
if db_type == "postgresql":
self.conn = psycopg2.connect(
host=config["host"],
user=config["user"],
password=config["password"],
database=config["database"]
)
elif db_type == "sqlite":
self.conn = sqlite3.connect(config["path"])
def execute(self, sql):
"""Same interface for both. Pipeline doesn't change."""
cursor = self.conn.cursor()
cursor.execute(sql)
return cursor.fetchall()
Abstraction Layer Best Practice: Never hardcode database connections in your business logic. Create an abstraction that works with multiple databases. This is how real production systems work β they switch databases by changing one line of config, not refactoring code.
Project Structure & Files
The NL2SQL project is organized as a clean Python package. Every component is modular and testable.
nl2sql-poc/ βββ src/ β βββ nl2sql/ β β βββ __init__.py β β βββ main.py # Entry point β β βββ config.py # Database, Ollama config β β βββ rag/ β β β βββ knowledge_store.py # KNOWLEDGE_BASE, embeddings β β β βββ faiss_index.py # FAISS indexes (4 categories) β β β βββ retriever.py # Category-aware retrieval logic β β βββ llm/ β β β βββ ollama_client.py # Ollama connection β β β βββ prompts.py # CRISP prompt templates β β β βββ generator.py # SQL generation logic β β βββ validator/ β β β βββ sql_validator.py # 7 validation checks β β β βββ schema_parser.py # Parse tables/columns β β β βββ alias_resolver.py # Resolve t.col β table.col β β βββ executor/ β β β βββ db_connection.py # Abstract DB layer β β β βββ executor.py # Execute validated SQL β β β βββ formatter.py # Format results (table/CSV/JSON) β β βββ pipeline/ β β β βββ nl2sql.py # Main pipeline orchestration β β β βββ audit_log.py # Logging who asked what β β βββ data/ β β βββ sample_trades.sql # Sample data for testing β βββ tests/ β βββ test_rag.py # Test retrieval quality β βββ test_validator.py # Test guardrails β βββ test_generator.py # Test SQL generation β βββ test_integration.py # Full pipeline tests βββ docker-compose.yml # PostgreSQL + Ollama βββ Dockerfile # Python application image βββ requirements.txt # Dependencies βββ README.md # Quick start guide βββ notebook.ipynb NEW # Interactive walkthrough
Key Dependencies
faiss-cpu==1.7.4 # Vector similarity search
sentence-transformers==2.2.2 # Embeddings (all-MiniLM)
psycopg2==2.9.9 # PostgreSQL driver
requests==2.31.0 # Ollama HTTP client
sqlparse==0.4.4 # SQL parsing
pytest==7.4.3 # Testing
Key Lessons & What's Next
We've built an end-to-end NL2SQL system from scratch. Here are the critical lessons we learned:
7 Key Lessons
RAG Quality > LLM Quality
Better context beats a larger model. Mistral 7B with excellent retrieval beats GPT-4 with poor context.
Category-Aware Retrieval
Don't dump everything into one index. Separate SCHEMA, GLOSSARY, RELATIONSHIP, POLICY. Use similarity thresholds.
Prompt Structure Matters
XML tags + precise role definition + explicit rules = huge improvement. Temperature and num_predict are critical.
Never Trust LLM Output
For database access, validation is non-negotiable. 12-point guardrails catch hallucinations before they hit the DB.
Retry Loops Are Cheap Insurance
One retry attempt catches most self-correctable errors. LLM fixes its own column name mistakes 80% of the time.
Start Simple, Migrate Later
SQLite β PostgreSQL, FAISS β pgvector. Build logic first with minimal setup, then optimize for scale.
Audit Everything
In finance, logging who asked what, what SQL was generated, and results is mandatory. Not optional.
What's Next: Production Features
The PoC is complete, but production deployments would add:
Multi-Turn Conversations
Add query memory so follow-ups like "Show me the top 3 of those" reference the previous result set.
Role-Based Access Control (RBAC)
Different users see different tables based on their role. Validator enforces table permissions automatically.
FastAPI Web Interface
REST API with async request handling, authentication, rate limiting, and WebSocket streaming for long queries.
pgvector Migration
Move embeddings into PostgreSQL with pgvector extension. Single database for both data and embeddings.
Advanced Prompting
Few-shot examples, chain-of-thought reasoning, or dynamic prompt adaptation based on question complexity.
Fine-Tuning on Financial Queries
Collect 100+ high-quality question-SQL pairs. Fine-tune Mistral on this domain-specific data.
Final Thoughts
You've now built an enterprise-grade NL2SQL system from scratch. You understand:
β
How RAG works and why context quality matters
β
How to prompt LLMs for SQL generation
β
How to validate SQL before it touches a database
β
How to handle errors with retry loops
β
How to migrate from PoC (SQLite) to production (PostgreSQL)
This knowledge applies beyond NL2SQL. The same patterns work for code generation, data analysis, and any domain where
you're converting natural language into structured output. Now go build something!