PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 44: Search and RAG

Retrieval-augmented generation on PostgreSQL

Level 9: AI and Vector Search30 min

RAG answers questions with a language model grounded in your own documents. PostgreSQL can own every part except the model call: chunks, embeddings, metadata, access control, freshness tracking, retrieval and the audit trail of what was shown to the model.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Chunk documents with overlap and track their provenance
  • Assemble model context with citations and a size budget
  • Keep embeddings fresh when content or models change

conceptThe concept

The RAG pipeline:

  1. Ingest: split documents into chunks of a few hundred tokens with some overlap, keep the source id, position and a content hash.
  2. Embed: call the model for each chunk, store the vector with the model name and version.
  3. Retrieve: embed the question with the same model, run hybrid search with permission and freshness filters.
  4. Assemble: format the best chunks with citations, within the model's context budget.
  5. Generate and log: send question plus context to the model, and store which chunks were used so answers can be audited and evaluated.

Filtered approximate search is the classic trap: an HNSW index returns its nearest candidates first and a selective WHERE clause can discard most of them. pgvector 0.8 adds hnsw.iterative_scan (strict_order or relaxed_order) to keep scanning until enough rows qualify.

whyWhy it matters

Models answer confidently whether or not they know. Retrieval from current, permission-checked data with citations turns a generic chatbot into something you can trust and audit, and keeping it in PostgreSQL means the same transactions, backups and access rules as the rest of the product.

Use caseReal-world scenario

When an article is edited, a trigger marks its chunks stale. A worker re-chunks and re-embeds only those, in a transaction that swaps old chunks for new ones, so retrieval never sees a half-updated article.

Try itExamples

Run and edit these in the interactive lesson.

Chunking with overlap in SQL
WITH doc AS (
  SELECT 'Refunds are issued to your original payment method within 5 working days after the returned item reaches our warehouse. Your bank may take another 3 to 5 days to show the refund on your card statement. Gift card purchases are refunded as store credit that never expires.' AS body
), words AS (
  SELECT w, n FROM doc, regexp_split_to_table(body, '\s+') WITH ORDINALITY AS t(w, n)
)
SELECT g AS starts_at_word, string_agg(w, ' ' ORDER BY n) AS chunk
FROM generate_series(1, (SELECT max(n) FROM words)::int, 15) g
JOIN words ON n >= g AND n < g + 20
GROUP BY g
ORDER BY g;
Windows of 20 words every 15 words: each chunk shares 5 words with the next. Production pipelines count tokens and respect sentence boundaries, but the idea is the same.
Assemble the model context with citations
WITH hits AS (
  SELECT a.id AS article_id, a.title, c.chunk_no, c.content,
         c.embedding <=> pgm.embed('how long until my refund shows up') AS d
  FROM kb_chunks c
  JOIN kb_articles a ON a.id = c.article_id
  WHERE a.audience = 'customer'
  ORDER BY d
  LIMIT 3
)
SELECT string_agg(format('[%s.%s] %s: %s', article_id, chunk_no, title, content), E'\n' ORDER BY d) AS context
FROM hits;
The [article.chunk] markers let the model cite its sources and let you check them.
Detect chunks that need re-embedding
BEGIN;
ALTER TABLE kb_chunks
  ADD COLUMN embedding_model text NOT NULL DEFAULT 'toy-8d-v1',
  ADD COLUMN content_md5 text;
UPDATE kb_chunks SET content_md5 = md5(content);
UPDATE kb_chunks
SET content = content || ' Sale items are refunded as store credit.'
WHERE article_id = 5 AND chunk_no = 1;
SELECT id, article_id, chunk_no
FROM kb_chunks
WHERE content_md5 IS DISTINCT FROM md5(content) OR embedding_model <> 'toy-8d-v1';
ROLLBACK;
The same check finds every chunk embedded with an older model after you switch.
Filtered search with iterative index scans
BEGIN;
CREATE TABLE docs AS
SELECT g AS id, g % 10 AS tenant_id,
       ARRAY(SELECT random() FROM generate_series(1, 32) WHERE g > 0)::real[]::vector(32) AS v
FROM generate_series(1, 3000) g;
CREATE INDEX ON docs USING hnsw (v vector_cosine_ops);
ANALYZE docs;
SET LOCAL hnsw.ef_search = 20;
SET LOCAL hnsw.iterative_scan = strict_order;
SELECT count(*) AS rows_returned FROM (
  SELECT id FROM docs
  WHERE tenant_id = 3
  ORDER BY v <=> (SELECT v FROM docs WHERE id = 1)
  LIMIT 10
) t;
ROLLBACK;
Without iterative scans, ef_search 20 yields about 20 candidates and the tenant filter keeps roughly 2 of them.

internalsUnder the hood

Chunk size is a trade-off: small chunks match precisely but lose context, large ones carry context but dilute the embedding. Overlap prevents a sentence that answers the question from being split across two chunks. Store chunk boundaries so you can show the user exactly what was retrieved.

Avoid / preferDo this, not that

Avoid
-- embed whole documents as one vector
Prefer
-- chunk into a few hundred tokens with overlap, one row per chunk,
-- and keep (document_id, chunk_no) for citations

One vector for a long document averages many topics together, so it matches nothing well, and the model receives far more text than it needs.

Avoid
UPDATE kb_chunks SET embedding = $new WHERE id = $1;  -- new model, one row at a time, live
Prefer
ALTER TABLE kb_chunks ADD COLUMN embedding_v2 vector(1024);
-- backfill embedding_v2, build its index, switch queries, then drop the old column

Mixing vectors from two models in one column makes distances meaningless until the backfill finishes. Side-by-side columns allow a clean cut-over and a rollback.

PitfallsCommon mistakes

  • Not storing which model produced each embedding, so a model change silently corrupts search.
  • Retrieving without permission filters and relying on the prompt to tell the model not to reveal restricted content.
  • Not logging retrieved chunk ids with each answer, so bad answers cannot be traced or evaluated.

LimitsWhen not to use it

Do not use RAG for questions answered by structured data: "how many orders did I place last month" is a SQL query, not a retrieval problem. Text-to-SQL with a read-only role, or a normal API, is more accurate.

SecuritySecurity notes

Retrieved text is untrusted input to the model: a document can contain instructions ("ignore previous instructions and ..."), known as prompt injection. Keep the model's database role read-only and limited to the views it needs, never let model output run SQL against write-capable roles, and filter retrieval by the end user's permissions, not the service's.

ChallengeTry it yourself

Build context for "package arrived damaged, how do I send it back" from customer-facing chunks, in order of distance, but stop before the running total exceeds 60 words.

Show one solution
One solution
WITH hits AS (
  SELECT a.title, c.content,
         c.embedding <=> pgm.embed('package arrived damaged, how do I send it back') AS d,
         array_length(regexp_split_to_array(c.content, '\s+'), 1) AS words
  FROM kb_chunks c
  JOIN kb_articles a ON a.id = c.article_id
  WHERE a.audience = 'customer'
), ranked AS (
  SELECT *, sum(words) OVER (ORDER BY d ROWS UNBOUNDED PRECEDING) AS running_words
  FROM hits
)
SELECT title, words, running_words
FROM ranked
WHERE running_words <= 60
ORDER BY d;

InterviewQuestions you should be able to answer

Design a RAG system on PostgreSQL.
Tables for documents and chunks (document id, chunk number, content, content hash, embedding, model name, tenant and permission metadata, a generated tsvector). HNSW on the embedding (often a halfvec expression) and GIN on the tsvector. An ingestion worker chunks with overlap and embeds new or changed chunks, swapping them transactionally. Retrieval is hybrid search with RRF, filtered by the end user's permissions (row-level security) and freshness, with iterative scans for selective filters. Context is assembled with citations under a token budget, and every answer logs the chunk ids used for evaluation and audit.
How do you change embedding models without downtime?
Add a second vector column (or table) for the new model, backfill it in batches with a background worker, build its index concurrently, evaluate retrieval quality on a test set, switch queries to the new column behind a flag, and drop the old column after a safe period. Never mix vectors from two models in one column.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation