Expedition. Free, on-demand.

Technical tracks for practitioners, outcomes for leaders.

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

CapabilityWhat It DoesKey Extension
Vector storage & searchStore embeddings and find similar items with approximate nearest-neighbor searchpgvector
Semantic searchFind content by meaning, and combine vector similarity with full-text searchpgvector + tsvector
RAGGround LLM responses in your own data by retrieving relevant context at query timepgvector
GraphRAGTraverse a knowledge graph to gather multi-hop context for LLM generationApache 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:

ModelDimensions
OpenAI text-embedding-3-small1536
OpenAI text-embedding-3-large3072
Cohere embed-english-v31024
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;
OperatorDistance MetricBest For
<=>CosineMost embedding APIs, and the safe default
<->L2 / EuclideanWhen magnitude matters
<#>Negative inner productNormalized vectors when you want the fastest option
<+>L1 / taxicabLess common, added in 0.7.0
<~> / <%>Hamming / Jaccardbit 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 TypeBuild SpeedQuery SpeedRecallIncremental Inserts
HNSWSlowerFasterHigherYes, no rebuild needed
IVFFlatFasterSlowerLower (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 halfvec first 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 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?

ApproachStrengthsWeaknesses
Keyword (tsvector)Exact term matching, fast, no ML model neededMisses synonyms and paraphrases
Vector (pgvector)Understands meaning, handles synonymsCan miss exact terms like product codes, needs embeddings
Hybrid (both)Catches exact matches and semantic matchesA 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 60 constant 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

  1. Split long documents into overlapping chunks.
  2. Run each chunk through an embedding model.
  3. Insert the embeddings into Postgres alongside the source content and metadata.
  4. At query time, embed the user's question and find similar chunks with vector search.
  5. 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
StrategyChunk SizeOverlapBest For
Fixed-size500 tokens50 to 100 tokensGeneral purpose
Paragraph-basedVariesNoneWell-structured documents
SemanticVariesNoneDocuments 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:

  1. Query Postgres with psycopg like you normally would.
  2. Turn the results into text (a small table, a few summary rows, some retrieved chunks).
  3. Send that text plus the user's question to AI_COMPLETE in Snowflake.
  4. 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 BY than 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_cache with a timestamp, or st.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 SELECT grants is best. You can also add default_transaction_read_only and a short statement_timeout to 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 with connection_name works fine. The guide has a small helper that detects which environment it's in.
  • Wrap both connections in st.cache_resource so they're created once, not on every rerun, and wrap your context queries in st.cache_data(ttl=...).

Production Notes

  • Reuse connections. Opening a Snowflake connection per request is slow. Create it once at startup, and use psycopg_pool or 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.

Updated Oct 27, 2026

This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances