AI in Postgres
You can build a surprising amount of AI application inside Postgres. With pgvector (and Apache AGE if you want graph queries), embeddings, retrieval, and relationships all live in the same database as the rest of your app data. For a lot of teams that means no separate vector store, no graph database, and no sync job keeping them in line.
What You Can Build
| Capability | What It Does | Key Extension |
|---|---|---|
| Vector storage & search | Store embeddings and find similar items with approximate nearest-neighbor search | pgvector |
| Semantic search | Find content by meaning, and combine vector similarity with full-text search | pgvector + tsvector |
| RAG | Ground LLM responses in your own data by retrieving relevant context at query time | pgvector |
| GraphRAG | Traverse a knowledge graph to gather multi-hop context for LLM generation | Apache AGE + pgvector |
Why This Matters
Each of these usually gets pitched as its own specialized system: a vector database for embeddings, a graph database for relationships, a search engine for full text. Postgres can handle all of them, with one security model, one backup strategy, and transactional consistency across your data.
Keep Everything Together
When everything lives in one database, a few things get easier.
You can join vectors with relational data. Similarity search and filters on metadata, user attributes, or business rules go in a single query.
Your existing tooling keeps working. ORMs, migration frameworks, backup tools, and monitoring don't care that a column holds embeddings.
Embeddings are transactionally consistent with the rows they describe. If the insert fails, the vector doesn't exist either, so you don't end up with stale vectors from a failed sync.
One GRANT system decides who can see vectors, text, metadata, and graph data. And your application talks to one database through one connection pool instead of three or four.
Embedding models and retrieval tricks change every few months. Your database shouldn't have to. On Postgres you can swap models, try a new retrieval strategy, or add graph traversal without re-architecting anything.
Storing and Querying Vectors
Enable pgvector
pgvector adds vector data types, distance operators, and index types to Postgres. Enable it in any database:
CREATE EXTENSION vector;
The current release line is pgvector 0.8.x. If you're on something older, it's worth upgrading: 0.7.0 added halfvec, sparsevec, and bit indexing, and 0.8.0 added iterative index scans, which fix a lot of filtered-search headaches (more on that below).
Create a Table with Embeddings
CREATE TABLE documents ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, title text NOT NULL, content text NOT NULL, metadata jsonb DEFAULT '{}', embedding vector(1536) );
The vector(1536) type stores a fixed-length array of 32-bit floats. Pick the dimension to match your embedding model:
| Model | Dimensions |
|---|---|
OpenAI text-embedding-3-small | 1536 |
OpenAI text-embedding-3-large | 3072 |
Cohere embed-english-v3 | 1024 |
| Open-source (e5, BGE, etc.) | 768 to 1024 |
One gotcha: HNSW and IVFFlat indexes on the vector type top out at 2,000 dimensions. If you're using a 3,072-dimension model, store or index it as halfvec (up to 4,000 dimensions), or ask the model for fewer dimensions if it supports that.
Distance Operators
Here are the three operators you'll use most:
-- Cosine distance (most common for normalized embeddings) SELECT title, 1 - (embedding <=> $1::vector) AS similarity FROM documents ORDER BY embedding <=> $1::vector LIMIT 5; -- L2 (Euclidean) distance SELECT title, embedding <-> $1::vector AS distance FROM documents ORDER BY embedding <-> $1::vector LIMIT 5; -- Inner product (returns the negative inner product, so multiply by -1) SELECT title, (embedding <#> $1::vector) * -1 AS score FROM documents ORDER BY embedding <#> $1::vector LIMIT 5;
| Operator | Distance Metric | Best For |
|---|---|---|
<=> | Cosine | Most embedding APIs, and the safe default |
<-> | L2 / Euclidean | When magnitude matters |
<#> | Negative inner product | Normalized vectors when you want the fastest option |
<+> | L1 / taxicab | Less common, added in 0.7.0 |
<~> / <%> | Hamming / Jaccard | bit vectors |
If your vectors are normalized to length 1 (OpenAI's are), cosine and inner product give the same ranking, and inner product is a little cheaper to compute.
Whatever operator you pick, the index has to use the matching operator class (vector_cosine_ops, vector_l2_ops, vector_ip_ops). If they don't match, Postgres will quietly ignore the index and do a sequential scan.
Indexing for Performance
Without an index, pgvector does exact (brute-force) search. That's perfect recall and it's fine for a few thousand rows. Past that, create an approximate nearest-neighbor (ANN) index.
HNSW is where we'd start for most workloads:
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128); -- Tune query-time recall vs. speed (default is 40) SET hnsw.ef_search = 100;
HNSW builds are memory hungry. Bump maintenance_work_mem for the session that builds the index, and set max_parallel_maintenance_workers if you have the cores, since pgvector supports parallel HNSW builds.
IVFFlat builds faster and uses less memory, which makes it handy for big bulk loads:
-- Build after loading data (it needs a representative sample) CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Tune: higher probes = better recall, more latency SET ivfflat.probes = 10;
A common starting point for lists is rows / 1000 up to a million rows, and sqrt(rows) beyond that.
| Index Type | Build Speed | Query Speed | Recall | Incremental Inserts |
|---|---|---|---|---|
| HNSW | Slower | Faster | Higher | Yes, no rebuild needed |
| IVFFlat | Faster | Slower | Lower (tunable) | Yes, but recall can drift as data changes, so rebuild occasionally |
Filtering and Iterative Index Scans
Here's a trap that bites almost everyone. With an ANN index, Postgres asks the index for the nearest ef_search candidates first and applies your WHERE clause afterward. If your filter is selective, you can ask for 10 results and get back 3.
pgvector 0.8.0 added iterative index scans to fix this. When the filter throws away too many rows, the scan keeps going until it has enough:
-- Results come back in approximate distance order SET hnsw.iterative_scan = relaxed_order; -- Or require exact distance order (slightly more work) SET hnsw.iterative_scan = strict_order; -- Cap how far it will go (default 20000 tuples) SET hnsw.max_scan_tuples = 20000;
IVFFlat has the same option with ivfflat.iterative_scan = relaxed_order and ivfflat.max_probes. If you filter on the same column all the time (say tenant_id), a partial index or partitioning by that column is often even better.
Half-Precision, Sparse, and Binary Vectors
Since 0.7.0, pgvector also has halfvec (16-bit floats), sparsevec, and bit, and you can index all of them:
-- Half-precision vectors use about half the storage and index memory CREATE TABLE documents_half ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, content text NOT NULL, embedding halfvec(1536) ); CREATE INDEX ON documents_half USING hnsw (embedding halfvec_cosine_ops);
You don't have to change the column type to get the savings. An expression index on a cast works too, as long as your queries use the same cast:
CREATE INDEX ON documents USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops); SELECT title FROM documents ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536) LIMIT 5;
For really large tables, binary quantization shrinks the index much further. Search the binary index for candidates, then re-rank them with the full vectors:
CREATE INDEX ON documents USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops); SELECT id, title FROM ( SELECT id, title, embedding FROM documents ORDER BY binary_quantize(embedding)::bit(1536) <~> binary_quantize($1::vector) LIMIT 50 ) candidates ORDER BY embedding <=> $1::vector LIMIT 5;
Reach for
halfvecfirst when memory gets tight. For most applications the recall loss is small and you cut index memory in half. Binary quantization saves more but needs the re-rank step, so test recall on your own data.
Semantic Search
Semantic search finds content by meaning instead of exact keywords. Combine pgvector's similarity search with Postgres's built-in full-text search and you get hybrid search, which usually beats either one alone.
Why Hybrid Search?
| Approach | Strengths | Weaknesses |
|---|---|---|
| Keyword (tsvector) | Exact term matching, fast, no ML model needed | Misses synonyms and paraphrases |
| Vector (pgvector) | Understands meaning, handles synonyms | Can miss exact terms like product codes, needs embeddings |
| Hybrid (both) | Catches exact matches and semantic matches | A little more SQL |
Setting Up Hybrid Search
CREATE TABLE articles ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, title text NOT NULL, body text NOT NULL, -- Full-text search column (auto-updated) tsv tsvector GENERATED ALWAYS AS ( to_tsvector('english', title || ' ' || body) ) STORED, -- Vector embedding for semantic similarity embedding vector(1536) ); -- Index both search methods CREATE INDEX ON articles USING gin (tsv); CREATE INDEX ON articles USING hnsw (embedding vector_cosine_ops);
Note the explicit STORED on the generated column. Postgres 18 made virtual generated columns the default, and you want this one stored so the GIN index has something to index.
Hybrid Search Query
Reciprocal Rank Fusion (RRF) is a simple, effective way to merge two ranked lists. Rank each list on its own, then add up 1 / (k + rank) for every list a row appears in:
WITH keyword_results AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, query) DESC) AS rank_pos FROM articles, plainto_tsquery('english', $1) query WHERE tsv @@ query ORDER BY rank_pos LIMIT 20 ), vector_results AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $2::vector) AS rank_pos FROM articles ORDER BY embedding <=> $2::vector LIMIT 20 ), combined AS ( SELECT COALESCE(k.id, v.id) AS id, -- RRF: sum of 1/(60 + rank position) across both lists. -- A row missing from one list contributes 0 from that list. COALESCE(1.0 / (60 + k.rank_pos), 0) + COALESCE(1.0 / (60 + v.rank_pos), 0) AS rrf_score FROM keyword_results k FULL OUTER JOIN vector_results v ON k.id = v.id ) SELECT a.id, a.title, a.body, c.rrf_score FROM combined c JOIN articles a ON a.id = c.id ORDER BY c.rrf_score DESC LIMIT 10;
Compute the rank positions inside each CTE, before the join. If you number rows after the FULL OUTER JOIN, rows that only showed up in one list still get a position in the other and the scores come out wrong.
aside negative The
60constant comes from the original RRF paper. It keeps a top result in one list from drowning out everything else. You can tune it, but 60 is a fine default.
Simpler Hybrid Search with Weighted Scoring
If RRF feels like too much, a weighted linear combination also works:
SELECT id, title, body, (0.7 * (1 - (embedding <=> $2::vector))) + (0.3 * ts_rank_cd(tsv, plainto_tsquery('english', $1))) AS score FROM articles WHERE tsv @@ plainto_tsquery('english', $1) OR embedding <=> $2::vector < 0.5 ORDER BY score DESC LIMIT 10;
Keep in mind the two scores aren't on the same scale, and a distance threshold in WHERE can't use the HNSW index, so this version does more scanning on large tables. Adjust the 0.7/0.3 weights for your domain. Technical content tends to want more keyword weight; conversational queries tend to want more vector weight.
RAG (Retrieval-Augmented Generation)
Retrieval-Augmented Generation gives an LLM relevant context from your own data before it answers. Postgres works well as the retrieval layer because embeddings, source text, and metadata are all in one place.
The RAG Pattern
- Split long documents into overlapping chunks.
- Run each chunk through an embedding model.
- Insert the embeddings into Postgres alongside the source content and metadata.
- At query time, embed the user's question and find similar chunks with vector search.
- Pass the retrieved chunks to an LLM to produce a grounded answer.
Schema for RAG
-- Source documents CREATE TABLE documents ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, title text NOT NULL, source_url text, content text NOT NULL, metadata jsonb DEFAULT '{}', created_at timestamptz DEFAULT now() ); -- Chunked and embedded content CREATE TABLE chunks ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, document_id bigint REFERENCES documents(id) ON DELETE CASCADE, chunk_index int NOT NULL, content text NOT NULL, token_count int, embedding vector(1536), UNIQUE (document_id, chunk_index) ); -- Index for fast retrieval CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops); CREATE INDEX ON chunks (document_id);
Retrieval with Metadata Filtering
This is where Postgres-based RAG really pays off: similarity search and structured filters in one query.
-- Find relevant chunks, filtered by category and recency SET hnsw.iterative_scan = relaxed_order; SELECT c.content, c.chunk_index, d.title, d.source_url, 1 - (c.embedding <=> $1::vector) AS similarity FROM chunks c JOIN documents d ON d.id = c.document_id WHERE d.metadata->>'category' = 'engineering' AND d.created_at > now() - interval '90 days' ORDER BY c.embedding <=> $1::vector LIMIT 5;
The planner will usually pick one of two plans here: walk the HNSW index and filter as it goes, or use a B-tree on the filter columns and compute exact distances for the rows that match. When the filter is very selective, the second plan is often faster and gives perfect recall. Run EXPLAIN ANALYZE on real queries to see which one you're getting. With a separate vector database, that same query means coordinating metadata filters across two systems.
Chunking Strategies
How you split documents has a big effect on retrieval quality:
-- Example: inserting chunks with overlap tracking INSERT INTO chunks (document_id, chunk_index, content, token_count, embedding) VALUES ($1, 0, $2, $3, $4), -- tokens 0-500 ($1, 1, $5, $6, $7), -- tokens 450-950 (50-token overlap) ($1, 2, $8, $9, $10); -- tokens 900-1400
| Strategy | Chunk Size | Overlap | Best For |
|---|---|---|---|
| Fixed-size | 500 tokens | 50 to 100 tokens | General purpose |
| Paragraph-based | Varies | None | Well-structured documents |
| Semantic | Varies | None | Documents with clear topic shifts |
aside negative Chunk size matters. Too large and the embedding loses specificity; too small and you lose context. Start with 500 to 1000 tokens and 10 to 20% overlap, then iterate based on retrieval quality.
Context Window Assembly
After retrieval, it often helps to hand the LLM the neighboring chunks too. Compute lag/lead over the whole document first, then filter to the chunks you retrieved. If you filter first, the window functions only see the retrieved rows and the "neighbors" are wrong.
-- Get retrieved chunks with their previous and next chunks SELECT content, prev_chunk, next_chunk FROM ( SELECT c.id, c.content, c.embedding, lag(c.content) OVER w AS prev_chunk, lead(c.content) OVER w AS next_chunk FROM chunks c WHERE c.document_id IN ( SELECT document_id FROM chunks WHERE id IN (SELECT id FROM retrieved_chunk_ids) ) WINDOW w AS (PARTITION BY c.document_id ORDER BY c.chunk_index) ) with_context WHERE id IN (SELECT id FROM retrieved_chunk_ids) ORDER BY embedding <=> $1::vector;
GraphRAG
GraphRAG combines knowledge graph traversal with vector retrieval to give the LLM multi-hop context. Standard RAG retrieves isolated chunks. GraphRAG also follows relationships between entities to pull in connected information.
When to Use GraphRAG
- Questions that span multiple documents ("How does project X relate to team Y's goals?")
- Queries that need multi-hop reasoning ("Who manages the person who wrote this code?")
- Domains with lots of entity relationships (org charts, supply chains, codebases)
Setting Up a Knowledge Graph with Apache AGE
Apache AGE adds graph queries to Postgres using the openCypher query language:
-- Enable the AGE extension CREATE EXTENSION age; LOAD 'age'; SET search_path = ag_catalog, "$user", public; -- Create a knowledge graph SELECT create_graph('knowledge_base');
Check that AGE supports your Postgres major version before you plan around it. It tends to trail new Postgres releases.
Building the Graph
Store entities and relationships extracted from your documents:
-- Add entities as graph nodes SELECT * FROM cypher('knowledge_base', $$ CREATE (:Document { id: 1, title: 'Postgres Performance Guide', category: 'engineering' }) $$) AS (v agtype); SELECT * FROM cypher('knowledge_base', $$ CREATE (:Concept { name: 'Index Tuning', description: 'Optimizing database indexes for query performance' }) $$) AS (v agtype); -- Create relationships SELECT * FROM cypher('knowledge_base', $$ MATCH (d:Document {id: 1}), (c:Concept {name: 'Index Tuning'}) CREATE (d)-[:COVERS {section: 'chapter_3', depth: 'detailed'}]->(c) $$) AS (e agtype);
GraphRAG Retrieval Pattern
Combine vector similarity with graph traversal. Cypher runs inside its own string, so it can't see columns from the surrounding SQL. The simplest approach is to have Cypher return document pairs that are connected within two hops, then join those to your vector results in plain SQL:
-- Step 1: Find relevant chunks via vector search WITH seed_chunks AS ( SELECT c.id, c.document_id, c.content, 1 - (c.embedding <=> $1::vector) AS similarity FROM chunks c ORDER BY c.embedding <=> $1::vector LIMIT 5 ), -- Step 2: Documents connected to other documents within 2 hops doc_links AS ( SELECT (src::text)::bigint AS src_doc_id, (dst::text)::bigint AS related_doc_id FROM cypher('knowledge_base', $$ MATCH (d:Document)-[*1..2]-(related:Document) RETURN d.id, related.id $$) AS (src agtype, dst agtype) ), -- Step 3: Chunks from documents linked to the seed documents graph_chunks AS ( SELECT c.content, c.embedding, 'graph' AS source FROM chunks c WHERE c.document_id IN ( SELECT l.related_doc_id FROM doc_links l WHERE l.src_doc_id IN (SELECT document_id FROM seed_chunks) ) ORDER BY c.embedding <=> $1::vector LIMIT 5 ) -- Combine vector-similar and graph-connected chunks SELECT content, similarity, source FROM ( SELECT content, similarity, 'vector' AS source FROM seed_chunks UNION ALL SELECT content, 1 - (embedding <=> $1::vector), source FROM graph_chunks ) combined ORDER BY similarity DESC LIMIT 10;
This walks every document pair in the graph, which is fine for small graphs. As the graph grows, build the Cypher query in your application with the seed document IDs filled in (or pass them through AGE's parameter argument) so the traversal starts from just those nodes.
Entity Extraction and Graph Maintenance
If you'd rather keep the graph in regular tables, extract entities and relationships into their own tables and keep them updated from your ingestion code or triggers:
-- Table to track extracted entities CREATE TABLE entities ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, document_id bigint REFERENCES documents(id) ON DELETE CASCADE, entity_name text NOT NULL, entity_type text NOT NULL, -- 'person', 'concept', 'tool', etc. properties jsonb DEFAULT '{}' ); CREATE TABLE entity_relationships ( id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, source_id bigint REFERENCES entities(id) ON DELETE CASCADE, target_id bigint REFERENCES entities(id) ON DELETE CASCADE, relationship text NOT NULL, -- 'mentions', 'depends_on', 'authored_by' properties jsonb DEFAULT '{}' ); -- Indexes for fast graph traversal CREATE INDEX ON entity_relationships (source_id); CREATE INDEX ON entity_relationships (target_id); CREATE INDEX ON entities (entity_type, entity_name);
GraphRAG with Common Table Expression (CTE)
If you don't want to install AGE, recursive CTEs over those tables handle graph traversal just fine:
-- Traverse relationships up to 3 hops from seed entities WITH RECURSIVE graph_walk AS ( -- Start from entities mentioned in relevant chunks SELECT e.id, e.entity_name, e.entity_type, 0 AS depth FROM entities e JOIN chunks c ON c.document_id = e.document_id WHERE c.id IN (SELECT id FROM seed_chunk_ids) UNION ALL -- Walk relationships SELECT e2.id, e2.entity_name, e2.entity_type, gw.depth + 1 FROM graph_walk gw JOIN entity_relationships er ON er.source_id = gw.id JOIN entities e2 ON e2.id = er.target_id WHERE gw.depth < 3 ) SELECT entity_name, entity_type, min(depth) AS min_depth FROM graph_walk GROUP BY entity_name, entity_type ORDER BY min_depth, entity_name;
The depth < 3 check is what keeps cycles from running forever, so don't drop it. On graphs with lots of cycles you can also add Postgres's CYCLE clause to skip nodes you've already visited.
You don't need a dedicated graph database for GraphRAG. Recursive CTEs cover most knowledge graph traversal patterns. AGE gives you openCypher syntax for more complex graph queries, but plain relational tables work well for simpler graphs.
Snowflake Cortex with Postgres from Python
The most common way we see people combine Postgres and Snowflake Cortex is also the simplest. Their app is in Python, their data is in Postgres, and they want an LLM to explain it, summarize it, or answer questions about it. You don't need a pipeline for that. You need two connections and AI_COMPLETE.
The pattern looks like this:
- Query Postgres with psycopg like you normally would.
- Turn the results into text (a small table, a few summary rows, some retrieved chunks).
- Send that text plus the user's question to
AI_COMPLETEin Snowflake. - Show the answer, or use it to drive the next query.
Postgres stays the source of truth and does what it's good at (fast, fresh, transactional queries). Cortex gives you hosted LLMs without running GPUs or managing API keys for a separate model provider. For a complete working app built this way, with charts, a natural language query page, and a chatbot over live IoT data, see the guide Charts, Graphs, and AI for Postgres Data Using Streamlit and Cortex. For more on the application side, see Snowflake Postgres + Cortex AI.
Connect to Both Databases
pip install snowflake-connector-python "psycopg[binary]" pandas
import os import pandas as pd import psycopg import snowflake.connector # Snowflake: reads credentials from ~/.snowflake/connections.toml sf = snowflake.connector.connect(connection_name="my_connection") # Postgres: a normal libpq connection string, e.g. # postgresql://application:password@<your-instance-host>:5432/postgres?sslmode=require pg = psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) def pg_df(sql: str, params=None) -> pd.DataFrame: """Run a query in Postgres and return a DataFrame.""" with pg.cursor() as cur: cur.execute(sql, params) cols = [c.name for c in cur.description] return pd.DataFrame(cur.fetchall(), columns=cols)
The Snowflake role you connect with needs the USE AI FUNCTIONS account privilege and the SNOWFLAKE.CORTEX_USER database role. Both are granted to PUBLIC by default, but plenty of accounts revoke them, so check with your admin if the first call fails with a privilege error. You'll also want a default warehouse on the connection.
If you're on Snowflake Postgres, the connection string is in Snowsight under your instance's connection details. Make sure a network policy allows the host your app runs on.
A Small AI_COMPLETE Helper
Everything below goes through one function:
MODEL = "llama3.1-70b" # pick any model available in your region def complete(prompt: str) -> str: """Send a prompt to Cortex AI_COMPLETE and return the response text.""" with sf.cursor() as cur: cur.execute("SELECT AI_COMPLETE(%s, %s)", (MODEL, prompt)) return cur.fetchone()[0]
Pass the model and prompt as bound parameters rather than formatting them into the SQL string. Prompts are full of quotes, newlines, and user input, and binding means you never have to escape any of it. Model availability varies by region, so run SHOW CORTEX BASE MODELS if a model name doesn't work.
Ask Questions About Your Data
This is the pattern from the chatbot in the guide above, and it's the one we'd start with. Rather than asking the LLM to write SQL, you run a few summary queries yourself, paste the results into the prompt, and let the model answer in plain English with real numbers.
def data_context() -> str: """Build a text snapshot of the data for the LLM.""" parts = [] daily = pg_df(""" SELECT date_trunc('day', created_at)::date AS day, count(*) AS orders, round(sum(total)::numeric, 2) AS revenue FROM orders WHERE created_at >= now() - interval '14 days' GROUP BY 1 ORDER BY 1 """) parts.append("DAILY ORDERS (last 14 days):\n" + daily.to_string(index=False)) top = pg_df(""" SELECT p.name, sum(oi.quantity) AS units FROM order_items oi JOIN products p ON p.id = oi.product_id JOIN orders o ON o.id = oi.order_id WHERE o.created_at >= now() - interval '14 days' GROUP BY p.name ORDER BY units DESC LIMIT 10 """) parts.append("TOP PRODUCTS (last 14 days):\n" + top.to_string(index=False)) return "\n\n".join(parts) def ask(question: str) -> str: prompt = ( "You are a data analyst. Answer the question using only the data below. " "Give specific numbers. If the data doesn't answer it, say so.\n\n" f"{data_context()}\n\n" f"Question: {question}" ) return complete(prompt) print(ask("Was last week better than the week before? What drove it?"))
A few things make this work well:
- Keep the context small. A few aggregated tables of a few dozen rows each is plenty. Don't dump raw tables into the prompt. Postgres is much better at
GROUP BYthan an LLM is at counting. - Pick context queries that match the questions people actually ask. If they ask about regions, add a region summary.
- Cache the context for a few minutes (
functools.lru_cachewith a timestamp, orst.cache_data(ttl=300)in Streamlit) so a chat session doesn't rerun the same aggregates on every message.
Natural Language to SQL to Chart
The other page in the guide lets someone type "average order value by week this quarter" and get a chart back. The LLM writes the SQL, Postgres runs it, and pandas or Streamlit draws it.
SCHEMA = """ orders(id bigint, customer_id bigint, created_at timestamptz, total numeric, region text) order_items(order_id bigint, product_id bigint, quantity int, price numeric) products(id bigint, name text, category text) """ def nl_to_sql(question: str) -> str: prompt = ( "Write a single Postgres SELECT query that answers the question. " "Return only the SQL, no explanation and no markdown.\n\n" f"Schema:\n{SCHEMA}\n" f"Question: {question}" ) sql = complete(prompt).strip() # models sometimes wrap answers in code fences anyway return sql.removeprefix("```sql").removeprefix("```").removesuffix("```").strip() question = "average order value by week for the last 3 months" sql = nl_to_sql(question) print(sql) df = pg_df(sql) df.plot(x=df.columns[0], y=df.columns[1]) # or st.line_chart(df) in Streamlit
Be careful here, because you're running SQL that a model wrote. At minimum:
-
Run generated SQL on a connection that can't write. A dedicated Postgres role with only
SELECTgrants is best. You can also adddefault_transaction_read_onlyand a shortstatement_timeoutto the connection:pg_ro = psycopg.connect( os.environ["READONLY_DATABASE_URL"], options="-c default_transaction_read_only=on -c statement_timeout=5000", ) -
Point it at a replica if you have one, so a bad query can't slow down production.
-
Show the generated SQL to the user. It builds trust, and people spot wrong joins faster than you'd expect.
Retrieval Plus AI_COMPLETE (RAG)
If you've set up pgvector as described earlier on this page, RAG is the same pattern with a different context step: retrieve the most relevant chunks from Postgres, then send them to AI_COMPLETE.
def answer(question: str, query_embedding) -> str: chunks = pg_df( """ SELECT id, content FROM chunks ORDER BY embedding <=> %s::vector LIMIT 5 """, (str(list(query_embedding)),), ) context = "\n\n".join(f"[{r.id}] {r.content}" for r in chunks.itertuples()) prompt = ( "Answer the question using only the context below. " "Cite the chunk ids you used in square brackets. " "If the context doesn't contain the answer, say so.\n\n" f"Context:\n{context}\n\nQuestion: {question}" ) return complete(prompt)
Where the query embedding comes from is up to you. Most teams already use an embedding model in their app and keep using it. Cortex also has AI_EMBED (for example AI_EMBED('snowflake-arctic-embed-l-v2.0', 'your text'), which returns 1024 dimensions), so you can embed in Snowflake if you'd rather keep everything in one place. Just use the same model for documents and questions, and match the pgvector column size to it. If you ever need to embed a large table in bulk, pg_lake can move the text to Snowflake and the vectors back through a stage.
Other Cortex AI Functions
AI_COMPLETE covers most of what people want, but Cortex has more specific functions that are cheaper and more predictable when they fit. You call them the same way, by sending text you pulled from Postgres:
AI_CLASSIFY()sorts text into categories you define, like routing support tickets.AI_SENTIMENT()scores the sentiment of reviews or comments.AI_EXTRACT()pulls structured fields out of free text.AI_TRANSLATE()translates text between languages.AI_SUMMARIZE_AGG()summarizes across many rows without you worrying about context window limits.
If your data is already in Snowflake (through data mirroring or pg_lake), you can also run these functions directly over the table in Snowflake SQL. Cortex Search goes further and handles chunking, embedding, indexing, and retrieval for you over data in Snowflake.
Running It in Streamlit
Everything above drops straight into a Streamlit app, which is what the guide builds. Two tips from that project:
- In Streamlit in Snowflake, use
st.connection("snowflake")for the Cortex calls instead of the Python connector. Locally, the connector withconnection_nameworks fine. The guide has a small helper that detects which environment it's in. - Wrap both connections in
st.cache_resourceso they're created once, not on every rerun, and wrap your context queries inst.cache_data(ttl=...).
Production Notes
- Reuse connections. Opening a Snowflake connection per request is slow. Create it once at startup, and use
psycopg_poolor PgBouncer for Postgres. See Connection Management. - Use dedicated roles. Give the app a Snowflake role that can call Cortex and use a warehouse and nothing else, and a Postgres role with only the access the app needs.
- Watch prompt size. You pay for tokens in and out. A data context of a few small tables is cheap; pasting 10,000 rows is not.
- Watch credits. Check Cortex usage in the views in
SNOWFLAKE.ACCOUNT_USAGE, especially before you put a chatbot in front of lots of users.
Conclusion
Related Resources
The pgvector GitHub repository, with installation instructions, distance operators, and indexing documentation.
Articles on building AI applications with Postgres, including RAG patterns, embedding strategies, and pgvector performance tuning.
Build a Streamlit app over Snowflake Postgres data with charts, natural language to SQL, and a Cortex chatbot that answers with live numbers.
Reference for Snowflake Cortex AI functions, including AI_EMBED, AI_COMPLETE, AI_EXTRACT, and more.
Guide to pg_lake stages, Shared Iceberg, and moving data in both directions for embedding pipelines.
Open-source extension that adds graph queries and openCypher support to Postgres.
This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances