Not all knowledge lives in text files. Orders, customers, prices and logs sit in SQL tables and spreadsheets. Vector search is poor at questions like total paid orders per city, because that needs exact filtering and arithmetic, not similarity. In this lesson you will learn two ways to bring structured data into a RAG app: let an LLM write SQL, or convert rows into documents with metadata.
Overview: how structured retrieval works
Unstructured RAG finds passages that are similar to the question. Structured retrieval asks a database to compute the answer. The database is exact, fast and always up to date, so you should use it for counts, sums, joins, date ranges and lookups by ID.
The most common pattern is called text-to-SQL. It has these steps:
- Read the database schema (table names, columns, types, a few sample rows).
- Put the schema and the user question into a prompt.
- The LLM writes a SQL query.
- Your code validates the query, then runs it on the database.
- The rows come back, and (optionally) a second LLM call turns them into a natural-language answer.
The LLM never touches the data directly. It only produces text, and your code decides whether that text is safe to run. This is the key security idea of the whole lesson: LLM output is untrusted input.
The second pattern is rows as documents. You turn each row (or group of rows) into a Document with readable text and metadata such as city or id. You embed the text, store it in a vector store, and at query time you combine similarity search with a metadata filter. This works well when the text columns are descriptive (product descriptions, support tickets) and the user question is fuzzy.
| Approach | Best for | Weakness |
|---|---|---|
| Text-to-SQL | Aggregations, joins, exact filters, fresh data | LLM can write wrong or unsafe SQL |
| Rows as documents | Fuzzy search over descriptions, tickets, reviews | Poor at maths; index gets stale |
| Hybrid (route by question) | Apps with both kinds of questions | More moving parts |
Key concepts and building blocks
These pieces from LangChain are used in this lesson:
SQLDatabase(fromlangchain_community.utilities) wraps a SQLAlchemy engine.SQLDatabase.from_uri(url)connects.db.get_table_info()returnsCREATE TABLEstatements plus sample rows.db.run(sql)executes SQL and returns the result as a string.db.dialectnames the SQL flavour.ChatPromptTemplatebuilds the prompt with the schema and question.RunnableLambdaandRunnablePassthrough.assignlet you plug your own Python steps into an LCEL chain with the|operator.Document, an embeddings class, and a vector store (InMemoryVectorStore) for the rows-as-documents pattern.
The general shape of a text-to-SQL chain is:
question -> prompt(schema + question) -> LLM -> extract SQL -> validate -> db.run -> rows -> (LLM answer)
Install the packages first. SQLDatabase needs SQLAlchemy, which comes with langchain-community.
pip install langchain langchain-core langchain-community
Examples
Example 1: Connect to a database and read it
We create a small SQLite shop database with the standard library, then open it through LangChain. SQLite needs no server, so everyone can follow along.
import sqlite3
from langchain_community.utilities import SQLDatabase
conn = sqlite3.connect('shop.db')
conn.execute('DROP TABLE IF EXISTS orders')
conn.execute('DROP TABLE IF EXISTS customers')
conn.execute('CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, city TEXT)')
conn.execute('CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER, amount REAL, status TEXT)')
conn.executemany('INSERT INTO customers VALUES (?, ?, ?)', [
(1, 'Asha', 'Pune'), (2, 'Ravi', 'Delhi'), (3, 'Meena', 'Pune'),
])
conn.executemany('INSERT INTO orders VALUES (?, ?, ?, ?)', [
(1, 1, 250.0, 'paid'), (2, 1, 100.0, 'paid'),
(3, 2, 75.5, 'refunded'), (4, 3, 300.0, 'paid'),
])
conn.commit()
conn.close()
db = SQLDatabase.from_uri('sqlite:///shop.db')
print(db.dialect)
print(sorted(db.get_usable_table_names()))
print(db.run('SELECT name, city FROM customers ORDER BY id'))
Output:
sqlite
['customers', 'orders']
[('Asha', 'Pune'), ('Ravi', 'Delhi'), ('Meena', 'Pune')]
Notice that db.run returns a string, not a list. That is deliberate: the string can be pasted straight into a prompt for the answer step.
Example 2: A text-to-SQL chain with a safety guard
To keep the example runnable without an API key, the LLM is replaced by a fake function that returns a fixed reply. In a real app you swap it for a chat model (shown later). Everything else, including the guard, stays the same.
import re
from langchain_community.utilities import SQLDatabase
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
db = SQLDatabase.from_uri('sqlite:///shop.db')
prompt = ChatPromptTemplate.from_messages([
('system', 'You are a SQLite expert. Write ONE read-only SELECT query. Return only SQL. Schema: {schema}'),
('human', '{question}'),
])
FAKE_REPLY = '''```sql
SELECT c.city, SUM(o.amount) AS total FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status = 'paid' GROUP BY c.city ORDER BY total DESC;
```'''
def fake_llm(prompt_value):
assert 'CREATE TABLE' in prompt_value.to_string()
return FAKE_REPLY
def extract_sql(text):
m = re.search(r'```(?:sql)?\s*(.*?)```', text, re.S)
return (m.group(1) if m else text).strip()
FORBIDDEN = re.compile(r'\b(insert|update|delete|drop|alter|create|attach|pragma|replace)\b', re.I)
def check_sql(sql):
body = sql.strip().rstrip(';')
if ';' in body:
raise ValueError('multiple statements')
if not body.lower().startswith('select'):
raise ValueError('only SELECT')
if FORBIDDEN.search(body):
raise ValueError('forbidden keyword')
return body
sql_chain = (
RunnablePassthrough.assign(schema=lambda _: db.get_table_info())
| prompt
| RunnableLambda(fake_llm)
| RunnableLambda(extract_sql)
| RunnableLambda(check_sql)
)
sql = sql_chain.invoke({'question': 'Total paid order value per city?'})
print('SQL:', sql)
print('Result:', db.run(sql))
for bad in ['DROP TABLE orders', 'SELECT 1; DELETE FROM orders']:
try:
check_sql(bad)
except ValueError as e:
print('rejected:', e)
Output:
SQL: SELECT c.city, SUM(o.amount) AS total FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.status = 'paid' GROUP BY c.city ORDER BY total DESC
Result: [('Pune', 650.0)]
rejected: only SELECT
rejected: multiple statements
Pune has paid orders of 250 + 100 + 300 = 650. Delhi has only a refunded order, so it is filtered out. The guard rejects anything that is not a single SELECT. The LLM often wraps SQL in a Markdown fence, so extract_sql removes it before validation.
Example 3: Use a real LLM
To use a real model, replace RunnableLambda(fake_llm) with a chat model and read the key from an environment variable. Model names change often, so check your provider’s documentation for the current name.
import os
from langchain.chat_models import init_chat_model
assert os.environ.get('ANTHROPIC_API_KEY'), 'set your API key first'
llm = init_chat_model('anthropic:claude-sonnet-5-5', temperature=0)
sql_chain = (
RunnablePassthrough.assign(schema=lambda _: db.get_table_info())
| prompt
| llm
| StrOutputParser()
| RunnableLambda(extract_sql)
| RunnableLambda(check_sql)
)
A chat model returns a message object, so StrOutputParser (from langchain_core.output_parsers) converts it to plain text before cleaning. Use temperature=0 so the SQL is as repeatable as possible.
Example 4: Rows as documents with a metadata filter
Now the second pattern. Each customer row becomes a Document. We use DeterministicFakeEmbedding so no network is needed. With real embeddings, the same code gives meaningful similarity.
import sqlite3
from langchain_core.documents import Document
from langchain_core.embeddings import DeterministicFakeEmbedding
from langchain_core.vectorstores import InMemoryVectorStore
conn = sqlite3.connect('shop.db')
rows = conn.execute('SELECT id, name, city FROM customers').fetchall()
conn.close()
docs = [
Document(
page_content=f'Customer {name} lives in {city}.',
metadata={'id': i, 'city': city},
)
for i, name, city in rows
]
store = InMemoryVectorStore(DeterministicFakeEmbedding(size=64))
store.add_documents(docs)
hits = store.similarity_search(
'who lives in Pune?',
k=3,
filter=lambda d: d.metadata['city'] == 'Pune',
)
print(sorted(d.metadata['id'] for d in hits))
Output:
[1, 3]
The filter removes every non-Pune document before ranking, so only IDs 1 and 3 can come back. The id in the metadata lets you go back to the database for fresh details. In production vector stores (Chroma, pgvector and others) the filter is usually a dictionary or expression instead of a Python function, so check your store’s documentation.
Under the hood: step by step
What happens when you call sql_chain.invoke(...) in Example 2?
RunnablePassthrough.assignkeeps the input dictionary and adds aschemakey by callingdb.get_table_info().- The prompt template fills
{schema}and{question}and produces a chat prompt value. - The model (or our fake) returns text containing SQL.
extract_sqlstrips the Markdown fence.check_sqlraises an error for anything unsafe. In LCEL, an exception stops the chain, so unsafe SQL never reaches the database.- Your code calls
db.run(sql)and gets the rows as a string.
The schema text is what makes the LLM accurate. Real column names, types and two or three sample rows tell it that status holds values like 'paid'. Without sample rows, models often guess values such as 'PAID' or 'completed'.
Common Mistakes
Mistake 1: Running LLM SQL on a powerful connection
db = SQLDatabase.from_uri('postgresql://admin:secret@host/prod')
result = db.run(llm_output) # runs whatever the model wrote
This is wrong because the model can be tricked by a prompt injection (for example, text inside a database row saying ignore your rules and drop the table). With an admin account, that could destroy data. Fix it with defence in depth: validate the query (as in Example 2) and connect with a user that only has SELECT rights on the needed tables. For SQLite you can open the file read-only:
import sqlite3
ro = sqlite3.connect('file:shop.db?mode=ro', uri=True)
print(ro.execute('SELECT COUNT(*) FROM orders').fetchone()[0])
try:
ro.execute('DELETE FROM orders')
except sqlite3.OperationalError as e:
print('blocked:', e)
4
blocked: attempt to write a readonly database
Even if the validator has a bug, the database itself refuses the write.
Mistake 2: Sending the whole schema of a big database
db = SQLDatabase.from_uri(url) # 400 tables go into every prompt
Hundreds of tables waste tokens and confuse the model. Limit what it can see:
db = SQLDatabase.from_uri(
url,
include_tables=['customers', 'orders'],
sample_rows_in_table_info=2,
)
Mistake 3: Embedding numeric rows and expecting maths
Putting rows like Order 4: 300.0 paid in a vector store and asking for the total will not work. Similarity search returns a few similar rows, not all matching rows, and the model may add them up wrongly. Use SQL for aggregation. Use embeddings for descriptive text.
Best Practices
- Use a read-only database user with access only to the tables the app needs. Never rely on the prompt alone for safety.
- Validate generated SQL: single statement,
SELECTonly, no dangerous keywords. A SQL parser library gives stronger checks than regular expressions. - Add a
LIMIT(or wrap the query) so one question cannot return millions of rows. - Set a query timeout on the database side.
- Include schema, sample rows and short column descriptions in the prompt. Explain business rules such as revenue means status = paid.
- Use
temperature=0for SQL generation. Log the question, the generated SQL and the result for debugging. - If the query fails, feed the error message back to the LLM for one or two retries, then give up politely.
- For rows-as-documents, store the primary key in metadata and re-sync the index when the table changes.
- Route questions: send exact or numeric questions to SQL, fuzzy questions to the vector store.
- Test with a golden set of questions and known correct answers, because SQL mistakes can look confident.
Practice Exercises
- Add a LIMIT guard. Extend
check_sqlso that if the query has noLIMIT, it appendsLIMIT 50. Test with one query that has a limit and one that does not. Hint: usere.search(r'\blimit\b', body, re.I). - Add a new question. Change
FAKE_REPLYso the fake LLM answers How many customers live in each city? Check that the guard accepts it and thatdb.runreturns the right counts. - Filter by two fields. Add a
tiercolumn to the customers table, put it in the document metadata, and change the filter so only Pune customers with tiergoldare returned.
Summary
- Use SQL for exact, computed answers and vector search for fuzzy, descriptive text.
- Text-to-SQL means: schema into prompt, LLM writes SQL, you validate, then run it.
SQLDatabase.from_uri,get_table_infoandrunare the core LangChain helpers.- Treat LLM-written SQL as untrusted: validate it and use a read-only database account.
- Limit visible tables, include sample rows and cap result size.
- Rows can become
Documentobjects with metadata, and metadata filters narrow similarity search to the right records. - A hybrid app routes each question to the retriever that fits it best.
