TutorialRAG, search & data
Build a RAG pipeline with Postgres and pgvector, step by step
Set up RAG with pgvector in your existing Postgres: a chunks table, an HNSW or IVFFlat index, the right distance operator and a filtered retrieval query.
RAG with pgvector keeps your embeddings in the Postgres database you already run. You add the vector extension, store each text chunk next to its embedding, index the column with HNSW or IVFFlat, and fetch the nearest chunks with an ORDER BY ... LIMIT query. Those chunks go into the model's prompt as context.
The examples below use pgvector 0.8.7, the version in the project's install instructions at the time of writing, and PostgreSQL 18 documentation.
How retrieval-augmented generation works in four steps
Retrieval-augmented generation (RAG) answers a question with your own documents as context. The pipeline has four steps:
- Chunk and embed. Split documents into passages and turn each passage into a vector with an embedding model.
- Store. Write each chunk's text, its vector and some metadata to a table.
- Retrieve. Embed the user's question with the same model and find the chunks whose vectors sit closest to it.
- Generate. Put the retrieved chunks into the prompt and ask the LLM to answer from them.
Postgres with pgvector handles steps 2 and 3. Steps 1 and 4 happen in your application code.
How do you install the pgvector extension?
pgvector supports Postgres 13 and later. The pgvector README (opens in a new tab) lists builds from source plus packages for Docker, Homebrew, APT, Yum, conda-forge and others. Many hosted Postgres providers ship it preinstalled.
On Debian or Ubuntu with the PostgreSQL APT repository, install the package that matches your server version (18 here):
sudo apt install postgresql-18-pgvector
To build from source on Linux or macOS:
cd /tmp
git clone --branch v0.8.7 https://github.com/pgvector/pgvector.git
cd pgvector
make
make install # may need sudo
For a container, the project publishes images such as pgvector/pgvector:pg18-trixie. Then enable the extension once in each database that needs it:
CREATE EXTENSION IF NOT EXISTS vector;
A table for chunks and embeddings
A RAG schema needs the chunk text, the vector and enough metadata to filter and to rebuild later. Splitting documents and chunks into two tables keeps re-chunking simple.
CREATE TABLE documents (
id bigserial PRIMARY KEY,
source_url text NOT NULL,
title text,
tenant_id int NOT NULL
);
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tenant_id int NOT NULL,
chunk_index int NOT NULL,
content text NOT NULL,
embedding_model text NOT NULL,
embedding vector(1536) NOT NULL,
UNIQUE (document_id, chunk_index)
);
CREATE INDEX ON chunks (tenant_id);
The dimension in vector(1536) must match your embedding model. OpenAI's embeddings guide (opens in a new tab) lists 1,536 dimensions by default for text-embedding-3-small and 3,072 for text-embedding-3-large. Both models accept a dimensions parameter that returns shorter vectors.
That number matters for indexing. pgvector's HNSW and IVFFlat indexes accept vector columns of up to 2,000 dimensions. A 3,072-dimension column needs one of three workarounds: request fewer dimensions, store halfvec (indexable up to 4,000 dimensions), or index a half-precision expression.
CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
Each vector value takes 4 * dimensions + 8 bytes, so a 1,536-dimension embedding is a little over 6 KB per row.
A minimal Python loader with psycopg 3 and the pgvector-python (opens in a new tab) package (pip install pgvector) looks like this:
from openai import OpenAI
from pgvector import Vector
from pgvector.psycopg import register_vector
import psycopg
client = OpenAI()
conn = psycopg.connect("dbname=app")
register_vector(conn)
def store_chunk(document_id, tenant_id, idx, text):
emb = client.embeddings.create(
model="text-embedding-3-small", input=text
).data[0].embedding
conn.execute(
"""INSERT INTO chunks (document_id, tenant_id, chunk_index,
content, embedding_model, embedding)
VALUES (%s, %s, %s, %s, %s, %s)""",
(document_id, tenant_id, idx, text, "text-embedding-3-small", Vector(emb)),
)
For large initial loads, the pgvector README recommends COPY and adding indexes after the data is in.
HNSW vs IVFFlat: which pgvector index should you use?
Without an index, pgvector runs an exact nearest-neighbor scan with perfect recall. Approximate indexes trade some recall for speed, and pgvector offers two.
HNSW builds a multilayer graph. The pgvector docs say it has a better speed-recall trade-off than IVFFlat, builds more slowly and uses more memory. It has no training step, so you can create it on an empty table.
IVFFlat splits vectors into lists and searches only the lists closest to the query. It builds faster and uses less memory, with lower query performance. It needs data before you build it, because the lists come from k-means over existing rows.
| Question | HNSW | IVFFlat |
|---|---|---|
| Can I create it on an empty table? | Yes | No, build after loading data |
| Query speed for a given recall | Better | Lower |
| Build time and memory | Slower, more memory | Faster, less memory |
| Build-time settings | m (default 16), ef_construction (default 64) | lists (start at rows / 1000 up to 1M rows, sqrt(rows) above) |
| Query-time setting | hnsw.ef_search (default 40) | ivfflat.probes (default 1, start at sqrt(lists)) |
| Distance operator classes | L2, inner product, cosine, L1, Hamming, Jaccard | L2, inner product, cosine, Hamming |
| A sensible pick when | the table grows steadily and query latency matters | memory is tight or you rebuild in batches |
Create the index with the operator class that matches the operator you will query with. In production, build it concurrently so writes keep flowing:
SET maintenance_work_mem = '2GB';
CREATE INDEX CONCURRENTLY chunks_embedding_hnsw
ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
The PostgreSQL CREATE INDEX reference (opens in a new tab) notes that CREATE INDEX CONCURRENTLY cannot run inside a transaction block and takes significantly longer than a normal build. pgvector builds HNSW much faster when the graph fits in maintenance_work_mem, and prints a notice when it stops fitting. Raising max_parallel_maintenance_workers can also shorten the build.
Which pgvector distance operator should you use?
pgvector defines one operator per distance function:
| Operator | Distance | HNSW/IVFFlat operator class |
|---|---|---|
<-> | L2 (Euclidean) | vector_l2_ops |
<#> | negative inner product | vector_ip_ops |
<=> | cosine distance | vector_cosine_ops |
<+> | L1 (taxicab) | vector_l1_ops, HNSW only |
<~> | Hamming, for bit | bit_hamming_ops |
<%> | Jaccard, for bit | bit_jaccard_ops, HNSW only |
Use whatever distance your embedding model was trained for. OpenAI recommends cosine similarity and states its embeddings are normalized to length 1, so cosine and Euclidean distance produce identical rankings. For normalized vectors, the pgvector README says inner product gives the best performance.
<#> returns the negative inner product because Postgres index scans only run in ascending order. Multiply by -1 if you want the actual value. For cosine similarity, compute 1 - (embedding <=> query) in the select list.
The retrieval query for RAG with pgvector
The query embeds the question, then orders chunks by distance. pgvector uses the index only when the ORDER BY is a distance operator in ascending order with a LIMIT. Writing ORDER BY 1 - (embedding <=> $1) DESC skips the index.
SELECT c.id,
c.content,
d.source_url,
1 - (c.embedding <=> $1::vector) AS cosine_similarity
FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE c.tenant_id = $2
ORDER BY c.embedding <=> $1::vector
LIMIT 8;
The WHERE clause is where many RAG setups lose results. With an approximate index, pgvector applies filters after scanning the index. The README's example: if a filter matches 10% of rows and hnsw.ef_search is at its default of 40, about 4 rows come back on average.
Since version 0.8.0, iterative index scans fix this by reading more of the index until enough rows match:
BEGIN;
SET LOCAL hnsw.iterative_scan = relaxed_order;
SET LOCAL hnsw.ef_search = 100;
-- run the retrieval query here
COMMIT;
strict_order keeps exact distance order; relaxed_order allows slight reordering and gives better recall. Scans stop at hnsw.max_scan_tuples (20,000 by default) or ivfflat.max_probes. For a filter with few distinct values, a partial index works. For strict tenant isolation, pgvector suggests list partitioning or separate tables, because a shared index lets one tenant's vectors affect another's recall and speed.
Pure vector search can miss exact terms such as product codes. pgvector's docs suggest combining it with Postgres full-text search and merging the two result lists with Reciprocal Rank Fusion or a cross-encoder.
Chunking and re-indexing choices
pgvector stores whatever you give it, so chunk boundaries are an application decision. A few constraints are fixed. OpenAI's v3 embedding models accept at most 8,192 input tokens, which caps chunk size for those models. Every chunk you return also uses up prompt space, so LIMIT times chunk length must fit the context you plan to send.
Store the chunk position (chunk_index) and the source so the application can pull neighboring chunks or cite the page. Store embedding_model because vectors from different models are not comparable. Changing models means re-embedding every chunk.
Plan re-indexing around the index type:
- HNSW updates as rows arrive. Vacuuming an HNSW index can be slow; the README suggests
REINDEX INDEX CONCURRENTLYbeforeVACUUM. - IVFFlat fixes its lists at build time. If the table grows far past the row count you sized
listsfor, drop and recreate the index with a newlistsvalue. - Model change: write new embeddings to a new column or table, build its index concurrently, switch queries over, then drop the old column.
Check build progress with pg_stat_progress_create_index, and use EXPLAIN (ANALYZE, BUFFERS) to confirm the retrieval query actually uses the index.
Frequently asked questions
How many chunks can Postgres hold before pgvector stops being practical?
pgvector's README notes that a non-partitioned Postgres table is limited to 32 TB by default, and a partitioned table can have thousands of partitions of that size. The practical limit is usually memory: indexes perform better when they fit in RAM. The README suggests `halfvec` storage and binary quantization to shrink the working set, and replicas or sharding tools such as Citus for horizontal scale.
Does pgvector work with replicas and point-in-time recovery?
Yes. pgvector writes to the write-ahead log, so streaming replication and point-in-time recovery cover vector columns like any other data. Your existing backup process keeps working without changes.
Can one column hold embeddings from different models?
Declare the column as `vector` without a dimension and it accepts vectors of any length. You can only index rows that share a dimension, so pgvector's docs use an expression index cast to `vector(n)` with a partial `WHERE model_id = ...` clause, and queries must use the same cast and filter.
Why does my query return fewer rows than the LIMIT after adding an HNSW index?
The HNSW search returns at most `hnsw.ef_search` candidates (40 by default), and dead tuples or filter conditions can remove some of them. Raise `hnsw.ef_search` or turn on iterative scans. NULL vectors are never indexed, and neither are zero vectors under cosine distance.
Do I need an index at all for a small knowledge base?
Not always. Without an index, pgvector runs an exact search with perfect recall, and the README notes that a table scan can be faster than an index on small tables. Setting `max_parallel_workers_per_gather` higher speeds up exact search on larger tables before you commit to an approximate index.
Sources
- 1.GitHub - pgvector/pgvector: Open-source vector similarity search for Postgres · GitHubRepositoryPrimarygithub.com
- 2.PostgreSQL: Documentation: 18: CREATE INDEXOfficial docsPrimarypostgresql.org
- 3.Vector embeddings | OpenAI APIOfficial docsPrimarydevelopers.openai.com
- 4.github.com/pgvector/pgvector-pythonRepositoryPrimary
Spotted an error? See our corrections policy.
Enjoyed this? Get the weekly brief.
New guides, Project & Tool of the Week and the AI developments that matter. One email, no spam.
Read next
- GuideRAG, search & data
Best embedding model for RAG: how to choose by size, dimensions, languages and cost
Pick the best embedding model for RAG by language, context length, vector size, hosting and price, then test it on your own documents first.
6 min read
- ComparisonRAG, search & data
pgvector, Qdrant, Chroma or Milvus: how to choose a vector database
Choose a vector database for RAG or search by comparing pgvector, Qdrant, Chroma and Milvus on deployment, filtering, index types and licence.
7 min read
- Weekly digest
The week on HuiaHub: sandboxed agents, RAG tooling and local models
41 new HuiaHub pages this week: sandboxing coding agents, RAG retrieval, local models on Macs and phones, Claude Haiku 5.5 and six how-to guides.
4 min read