AI in Postgres
Postgres is a complete platform for AI-powered applications. With extensions like pgvector and Apache AGE, you can build sophisticated AI systems entirely within your database — no external vector stores, graph databases, or complex data pipelines required.
What You Can Build
| Capability | What It Does | Key Extension |
|---|---|---|
| Vector storage & search | Store embeddings and find similar items using approximate nearest-neighbor search | pgvector |
| Semantic search | Find content by meaning, not just keywords — 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 knowledge graphs to gather richer, multi-hop context for LLM generation | Apache AGE + pgvector |
Why This Matters
Each of these capabilities typically requires a separate specialized system — a vector database for embeddings, a graph database for relationships, a search engine for full-text. Postgres handles all of them in one place, with one security model, one backup strategy, and full transactional consistency across all your data.
Keep Everything Together
With Postgres, everything lives in one database:
- Join vectors with relational data — Combine similarity search with filters on metadata, user attributes, or business logic in a single query.
- Existing tooling — Use familiar ORMs, migration frameworks, backup tools, and monitoring. No new infrastructure to learn.
- ACID guarantees — Embeddings are transactionally consistent with the data they represent. No stale vectors from failed syncs.
- Single access control — One
GRANTsystem governs who can see what — vectors, text, metadata, and graph data alike. - One connection pool — Your application talks to one database, not three or four.
aside positive The AI landscape moves fast, but your database is the stable foundation. Building on Postgres means you can swap embedding models, try new retrieval strategies, or add graph traversal — all without re-architecting your infrastructure.
Storing and Querying Vectors
Enable pgvector
pgvector adds a vector data type and distance operators to Postgres. Enable it in any database:
CREATE EXTENSION vector;
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. Choose 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–1024 |
Distance Operators
pgvector provides three distance operators for different use cases:
-- 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 (use with non-normalized embeddings) SELECT title, (embedding <#> $1::vector) * -1 AS score FROM documents ORDER BY embedding <#> $1::vector LIMIT 5;
| Operator | Distance Metric | Best For |
|---|---|---|
<=> | Cosine | Normalized embeddings (most embedding APIs) |
<-> | L2 / Euclidean | When magnitude matters |
<#> | Negative inner product | Max inner product search |
Indexing for Performance
Without an index, pgvector performs exact (brute-force) search. For datasets beyond a few thousand rows, create an approximate nearest-neighbor (ANN) index.
HNSW (recommended for most use cases):
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128); -- Tune query-time recall vs. speed SET hnsw.ef_search = 100;
IVFFlat (faster to build, useful for bulk-load scenarios):
-- Build after loading data (needs 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;
| Index Type | Build Speed | Query Speed | Recall | Incremental Inserts |
|---|---|---|---|---|
| HNSW | Slower | Faster | Higher | Yes — no rebuild needed |
| IVFFlat | Faster | Slower | Lower (tunable) | Requires periodic rebuild |
Half-Precision and Binary Vectors
pgvector 0.7+ supports halfvec (16-bit floats) and binary quantization for reduced memory usage:
-- Half-precision vectors use ~50% less 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);
aside positive Use
halfvecwhen you need to scale to millions of vectors. The recall loss is minimal for most applications, and you cut index memory in half.
Semantic Search
Semantic search finds content by meaning rather than exact keyword matches. By combining pgvector's similarity search with Postgres's built-in full-text search, you get hybrid search — the best of both approaches.
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, needs embeddings |
| Hybrid (both) | Best recall — catches both exact matches and semantic similarity | Slightly more complex |
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);
Hybrid Search Query
Combine both signals with Reciprocal Rank Fusion (RRF) — a simple, effective way to merge ranked lists:
WITH keyword_results AS ( SELECT id, ts_rank_cd(tsv, query) AS rank FROM articles, plainto_tsquery('english', $1) query WHERE tsv @@ query ORDER BY rank DESC LIMIT 20 ), vector_results AS ( SELECT id, 1 - (embedding <=> $2::vector) AS rank FROM articles ORDER BY embedding <=> $2::vector LIMIT 20 ), combined AS ( SELECT COALESCE(k.id, v.id) AS id, -- RRF formula: sum of 1/(k + rank_position) across both lists COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY k.rank DESC NULLS LAST)), 0) + COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY v.rank DESC NULLS LAST)), 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;
aside negative The
60constant in RRF is standard (from the original paper). It prevents high-ranked items in one list from dominating. You can tune it, but 60 works well as a default.
Simpler Hybrid Search with Weighted Scoring
If RRF feels complex, a weighted linear combination works too:
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;
Adjust the 0.7/0.3 weights based on your domain. Technical content often benefits from higher keyword weight; conversational queries benefit from higher vector weight.
RAG (Retrieval-Augmented Generation)
Retrieval-Augmented Generation enhances LLM responses with relevant context from your own data. Postgres is a natural fit for the retrieval layer because your embeddings, source text, and metadata all live together.
The RAG Pattern
- Chunk documents — Split long content into overlapping segments.
- Generate embeddings — Run each chunk through an embedding model.
- Store in Postgres — Insert embeddings alongside the source content and metadata.
- Retrieve at query time — Embed the user's question and find similar chunks via vector search.
- Generate response — Pass the retrieved context 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
The real power of Postgres-based RAG is combining similarity search with structured filters:
-- Find relevant chunks, filtered by source and recency 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;
This uses both the HNSW index for vector similarity and standard B-tree indexes on metadata — something that requires complex multi-system orchestration with standalone vector databases.
Chunking Strategies
How you split documents affects 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–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–1000 tokens with 10–20% overlap and iterate based on retrieval quality.
Context Window Assembly
After retrieval, assemble the context for your LLM call:
-- Get chunks with surrounding context (previous and next chunks) SELECT c.content, lag(c.content) OVER (PARTITION BY c.document_id ORDER BY c.chunk_index) AS prev_chunk, lead(c.content) OVER (PARTITION BY c.document_id ORDER BY c.chunk_index) AS next_chunk FROM chunks c WHERE c.id IN (SELECT id FROM retrieved_chunk_ids) ORDER BY c.embedding <=> $1::vector;
This gives your LLM not just the most relevant chunk, but its surrounding context for better answer generation.
GraphRAG
GraphRAG combines knowledge graph traversal with vector retrieval to provide richer, multi-hop context to LLMs. Standard RAG retrieves isolated chunks; GraphRAG follows relationships between entities to gather connected information.
When to Use GraphRAG
- Questions that span multiple documents ("How does project X relate to team Y's goals?")
- Queries requiring multi-hop reasoning ("Who manages the person who wrote this code?")
- Domains with rich entity relationships (org charts, supply chains, codebases)
Setting Up a Knowledge Graph with Apache AGE
Apache AGE adds graph database capabilities 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');
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 for multi-hop context:
-- 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: Find related entities via graph traversal related_entities AS ( SELECT entity_id, relationship, depth FROM cypher('knowledge_base', $$ MATCH (d:Document {id: $doc_id})-[r*1..2]-(related) RETURN id(related) AS entity_id, type(r) AS relationship, length(r) AS depth $$) AS (entity_id agtype, relationship agtype, depth agtype) WHERE doc_id IN (SELECT document_id FROM seed_chunks) ), -- Step 3: Get chunks from related documents graph_chunks AS ( SELECT c.content, c.embedding, 'graph' AS source FROM chunks c JOIN documents d ON d.id = c.document_id WHERE d.id IN (SELECT entity_id::int FROM related_entities) 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;
Entity Extraction and Graph Maintenance
Keep your knowledge graph in sync with your documents using 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, you can implement graph traversal with recursive CTEs:
-- 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 DISTINCT entity_name, entity_type, min(depth) AS min_depth FROM graph_walk GROUP BY entity_name, entity_type ORDER BY min_depth, entity_name;
aside positive You don't need a dedicated graph database for GraphRAG. Postgres recursive CTEs handle most knowledge graph traversal patterns. Apache AGE adds openCypher syntax for complex graph queries, but the relational approach works for simpler graphs.
Snowflake Cortex Integration
Snowflake Cortex provides serverless AI functions that pair naturally with Postgres. Use Cortex to generate embeddings and run LLM inference at scale, then serve the results from Postgres at application speed.
Generating Embeddings with Cortex
You don't need to manage embedding model infrastructure yourself. The AI_EMBED function generates vector embeddings on demand:
-- Generate an embedding with AI_EMBED (recommended) SELECT AI_EMBED('snowflake-arctic-embed-l-v2.0', 'Your text here'); -- Specify output dimensions (model-dependent) SELECT AI_EMBED('snowflake-arctic-embed-l-v2.0', 'Your text here');
| Model | Dimensions | Best For |
|---|---|---|
snowflake-arctic-embed-l-v2.0 | 1024 | High accuracy, general purpose |
snowflake-arctic-embed-l-v2.0-8k | 1024 | Longer documents (8K token context) |
snowflake-arctic-embed-m-v1.5 | 768 | Good balance of speed and accuracy |
voyage-multilingual-2 | 1024 | Multilingual content |
Batch Embedding Generation
Generate embeddings for an entire table of content in Snowflake:
-- Embed all rows in a content table CREATE OR REPLACE TABLE my_db.my_schema.embedded_chunks AS SELECT id, document_id, chunk_index, content, AI_EMBED('snowflake-arctic-embed-l-v2.0', content) AS embedding FROM my_db.my_schema.chunks;
Scripting Between Snowflake Postgres and Snowflake
Snowflake Postgres and Snowflake can exchange data bidirectionally through pg_lake stages. This enables a powerful pattern: export text from Postgres, generate embeddings in Snowflake with Cortex, and write the vectors back to Postgres — all without leaving the Snowflake ecosystem.
Step 1: Set up the stage connection
-- In Snowflake: create a storage integration pointing to your Postgres instance CREATE STORAGE INTEGRATION my_pg_stage_integration TYPE = POSTGRES_INTERNAL_STORAGE POSTGRES_INSTANCE = 'my_postgres_instance'; -- Create a stage for file exchange CREATE STAGE my_pg_stage RELATIVE_URL = '/embeddings' STORAGE_INTEGRATION = my_pg_stage_integration;
Step 2: Export text from Postgres to the stage
-- In Postgres: export content that needs embeddings COPY ( SELECT id, content FROM documents WHERE embedding IS NULL ) TO '@STAGE/embeddings/to_embed.parquet' WITH (FORMAT 'parquet');
Step 3: Generate embeddings in Snowflake
-- In Snowflake: read the exported file and generate embeddings CREATE OR REPLACE TABLE embedded_results AS SELECT $1:id::int AS id, AI_EMBED('snowflake-arctic-embed-l-v2.0', $1:content::text) AS embedding FROM @my_pg_stage/to_embed.parquet; -- Write results back to the stage COPY INTO @my_pg_stage/embeddings_done.parquet FROM embedded_results FILE_FORMAT = (TYPE = PARQUET);
Step 4: Load embeddings back into Postgres
-- In Postgres: load the generated embeddings CREATE TEMP TABLE embedding_import ( id int, embedding vector(1024) ); COPY embedding_import FROM '@STAGE/embeddings/embeddings_done.parquet' WITH (FORMAT 'parquet'); -- Update the source table UPDATE documents d SET embedding = e.embedding FROM embedding_import e WHERE d.id = e.id;
aside positive With pg_lake stages, you can script the entire embedding pipeline without any external infrastructure. Postgres exports text, Snowflake generates embeddings with Cortex, and the vectors flow right back — no S3 buckets, no custom ETL code, no third-party orchestrators.
Syncing Embeddings with Shared Iceberg
For continuous sync rather than batch scripting, use the Shared Iceberg pattern. Postgres writes an Iceberg table that Snowflake reads through a catalog integration:
-- In Postgres: create an Iceberg table with your content CREATE TABLE documents_iceberg ( id int, content text ) USING iceberg; -- In Snowflake: read the table, generate embeddings, and write back via stage CREATE OR REPLACE ICEBERG TABLE my_iceberg_docs CATALOG = 'my_postgres_catalog' CATALOG_TABLE_NAME = 'documents_iceberg'; -- Generate embeddings from the synced data SELECT id, AI_EMBED('snowflake-arctic-embed-l-v2.0', content) AS embedding FROM my_iceberg_docs;
Embedding Queries at Runtime
For real-time RAG, you also need to embed user queries. Two approaches:
Option A: Embed in Snowflake, query in Postgres — Call Cortex from your application, then pass the vector to Postgres:
# Python example using Snowflake connector + psycopg import snowflake.connector import psycopg # Generate query embedding via Cortex sf_conn = snowflake.connector.connect(...) cursor = sf_conn.cursor() cursor.execute(""" SELECT AI_EMBED('snowflake-arctic-embed-l-v2.0', %s)::ARRAY """, (user_question,)) query_embedding = cursor.fetchone()[0] # Search Postgres with the embedding pg_conn = psycopg.connect(...) results = pg_conn.execute(""" SELECT content, 1 - (embedding <=> %s::vector) AS similarity FROM chunks ORDER BY embedding <=> %s::vector LIMIT 5 """, (query_embedding, query_embedding)).fetchall()
Option B: Use the same model via an API — If you're using an open model like Arctic Embed, you can also run it locally or via a model serving endpoint for query-time embeddings, avoiding the round-trip to Snowflake.
Cortex AI Functions
Beyond embeddings, Cortex provides additional serverless AI functions you can use to enrich data before storing it in Postgres:
AI_COMPLETE()— Run LLM inference (GPT-4, Llama, Mistral, and more) over your data.AI_EXTRACT()— Extract structured information from text or documents.AI_CLASSIFY()— Classify text or images into user-defined categories.AI_SENTIMENT()— Analyze sentiment of text content.AI_TRANSLATE()— Translate text between languages.AI_SUMMARIZE_AGG()— Summarize across multiple rows without context window limits.
Cortex Search
Build full semantic search applications without managing embedding pipelines. Cortex Search handles chunking, embedding, indexing, and retrieval automatically.
Integration with Postgres
Combine Snowflake's managed AI services with Postgres's operational database strengths:
- pg_lake stages — Bidirectional file exchange for scripting embedding pipelines between Postgres and Snowflake.
- Shared Iceberg — Postgres writes Iceberg tables that Snowflake reads for analytics and AI processing.
- Streamlit in Snowflake — Build interactive AI applications that query both Snowflake and Postgres data.
- Hybrid retrieval — Use Postgres for low-latency operational RAG and Snowflake for analytical queries over the same data.
aside positive Using Snowflake Cortex for embeddings means you don't need to provision GPU infrastructure, manage model versions, or handle scaling. Generate embeddings at Snowflake scale, then serve them from Postgres at application speed.
Conclusion
Related Resources
The pgvector extension GitHub repository with installation instructions, distance operators, and indexing documentation.
Collection of articles on building AI applications with Postgres, including RAG patterns, embedding strategies, and pgvector performance tuning.
Reference for all Snowflake Cortex AI functions including AI_EMBED, AI_COMPLETE, AI_EXTRACT, and more.
Guide to pg_lake stages, Shared Iceberg, and bidirectional data movement for embedding pipelines.
Open-source extension that adds graph database capabilities and openCypher query 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