27.8 pgvector: Vector Search in PostgreSQL
Right, so you’ve got your data living happily in PostgreSQL, the reliable old workhorse of relational databases. But now you want to do something… fancy. You want to find similar images, recommend relevant products, or cluster user profiles based on their behavior. For that, you need to search by meaning, not just by exact matches. This is where pgvector waltzes in, not as some disruptive new technology, but as a brilliantly simple extension that lets your existing PostgreSQL instance throw a massive vector-shaped party.
The premise is beautiful: stop overcomplicating your architecture. You might not need a separate, specialized vector database that adds operational complexity and forces you to sync data between systems. With pgvector, you can store your embeddings right next to your relational data and query it all in one go. It’s the “just enough” solution for a huge number of use cases.
Getting pgvector Up and Running
First, you need to install it. If you’re using a managed PostgreSQL service like AWS RDS, Aurora, or Google Cloud SQL, check their docs; most major providers now support enabling pgvector with a simple parameter change. For a self-hosted Postgres, you’ll need to install the extension from source. Let’s assume you’ve got it available. Enabling it is as trivial as it gets:
CREATE EXTENSION IF NOT EXISTS vector;
That’s it. No servers to configure, no new services to monitor. You’ve just taught your PostgreSQL a new superpower.
Defining Your Vector Column
Now, let’s talk about the vector data type. When you create your table, you define a column of this type and specify its dimensions. The dimension is crucial—it’s the length of the array of numbers that constitutes your embedding. Got 384-dimensional embeddings from all-MiniLM-L6-v2? Or 1536 from text-embedding-ada-002? You gotta specify it.
CREATE TABLE document_embeddings (
id SERIAL PRIMARY KEY,
document_text TEXT NOT NULL,
embedding vector(1536), -- This is the magic line
metadata JSONB
);
Why lock in the dimension? It’s a trade-off. PostgreSQL uses it to enforce integrity (preventing you from inserting a 768-dim vector into a 1536-dim column) and for performance optimizations under the hood. It’s a good thing. Trust me.
Inserting Vectors: It’s Just Arrays
Inserting data is wonderfully straightforward. You’re just inserting an array of floats. The mental leap is tiny.
INSERT INTO document_embeddings (document_text, embedding)
VALUES (
'The history of PostgreSQL is long and storied',
'[0.141, -0.045, 0.302, ..., -0.118]' -- Your full 1536-dim array here
);
In practice, you’ll almost never hand-craft these arrays. You’ll use your application code to generate an embedding from a model (using OpenAI, Hugging Face, etc.) and then pass the resulting list of floats as a parameter to your query. Here’s a more realistic snippet using Python and psycopg2:
import psycopg2
from get_embedding_function import get_embedding # Your embedding function
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
text = "The quick brown fox jumps over the lazy dog."
embedding = get_embedding(text) # Returns a list of 1536 floats
# Use the ::vector syntax to cast the Python list to the PG vector type
cur.execute(
"INSERT INTO document_embeddings (document_text, embedding) VALUES (%s, %s::vector)",
(text, embedding)
)
conn.commit()
Querying for Similarity: The <-> Operator
This is the core of it all. pgvector introduces the <-> operator, which calculates the Euclidean distance (L2 distance) between two vectors. A smaller distance means the vectors are more similar. It’s intuitive.
To find the most similar documents to a given query embedding, you use an ORDER BY with this operator and LIMIT your results.
SELECT
id,
document_text,
embedding <-> '[0.141, -0.045, 0.302, ..., -0.118]'::vector AS distance
FROM document_embeddings
ORDER BY distance
LIMIT 5;
Again, in your app, you’d parameterize this. You’d first generate an embedding for your query string and then search for it.
query_embedding = get_embedding("What are the origins of PostgreSQL?")
cur.execute(
"""
SELECT id, document_text, metadata
FROM document_embeddings
ORDER BY embedding <-> %s::vector
LIMIT 5
""",
(query_embedding,)
)
results = cur.fetchall()
Indexing: Making It Fast(er)
Here’s where the designers made a choice you need to understand. Performing a exact nearest neighbor search (ORDER BY distance LIMIT k) requires a full table scan. It calculates the distance for every… single… row. This is comically slow for large tables. This is where Approximate Nearest Neighbor (ANN) indexes come in. They trade a tiny bit of accuracy for a massive gain in speed.
pgvector provides two index types: IVFFlat and HNSW. HNSW is generally better—it’s faster to build and query and often more accurate—but it’s a storage hog. IVFFlat is the older, more compact method.
Let’s create an HNSW index because you’re probably not living in 2017.
CREATE INDEX ON document_embeddings
USING hnsw (embedding vector_l2_ops);
The vector_l2_ops part tells it to use the Euclidean distance operations for this index. Now, the crucial pitfall: the index must be built after you have a representative amount of data in your table. The IVFFlat index works by creating clusters. If you build it on an empty table, it creates bad clusters and your search accuracy will be terrible until you rebuild it. HNSW is less sensitive to this, but it’s still a best practice to index after loading a good chunk of data.
When to Just Say No
pgvector is fantastic, but let’s be direct about its limits. It’s not a silver bullet. If you need to search billions of vectors with millisecond latency, a dedicated vector database is probably your answer. pgvector is for when your vector count is in the millions or low hundreds of millions, and when the convenience of having everything in Postgres outweighs the need for raw, specialized speed. It’s the pragmatic choice. It keeps your system simple, and in software, simplicity is the ultimate sophistication. Now go build something clever.