You Already Have a Vector Database
You’ve probably spent the last two years hearing that you need a specialized vector database. Chroma, Qdrant, Pinecone, Weaviate, the acronyms never stop. Everyone’s got their favorite. But if you’ve got Postgres running in your homelab or cloud account anyway, you’re throwing away the simplest solution on the table.
Postgres with pgvector is a legitimately solid vector database, not a scrappy workaround. It does everything you need for local embeddings, RAG pipelines, and semantic search. And because it’s just Postgres, you get backup strategies, transactions, access control, and monitoring you already understand. No new dashboards. No vendor lock-in. Just SQL.
Let’s talk about why you should care, how to set it up, and when to actually reach for something more specialized.
What pgvector Does (And Doesn’t)
pgvector is a Postgres extension that adds vector data types and indexes. You store embeddings as columns, query them with similarity operations (cosine, L2, inner product), and use indexes to make it not glacially slow. It’s that simple.
The math is straightforward. You’ve got a vector (a list of a few hundred to a few thousand numbers, depending on your embedding model). pgvector can measure similarity between vectors and return the K nearest neighbors fast enough to serve real queries.
What it doesn’t do: it’s not a managed service (Postgres is still your responsibility), it doesn’t have fancy multi-tenancy isolation at the database level, and if you’re running embedding queries on millions of rows with complex filters, you might eventually want to specialize. But for a homelab with 100k documents? For a side project with 50k articles? This is overkill-proof.
The real question isn’t “is pgvector good?” It’s “why do you need a separate tool?”
Install pgvector and Build a Pipeline
You need two things: the pgvector extension on your Postgres instance, and a Python client to generate embeddings.
Step 1: Install the Extension
If you’re running a supported Postgres (13+, and ideally 17 by now, since it’s 2026), installation is one command:
sudo apt-get install postgresql-17-pgvectorIf you’re running Postgres in Docker (which you probably are in a homelab), use the official image with pgvector built in:
services: postgres: image: pgvector/pgvector:pg17 environment: POSTGRES_PASSWORD: your_password volumes: - postgres_data:/var/lib/postgresql/data ports: - "5432:5432"volumes: postgres_data:Connect to your database and enable the extension:
psql -U postgres -d your_database -c "CREATE EXTENSION IF NOT EXISTS vector;"Done. Seriously.
Step 2: Design Your Schema
Here’s a typical RAG table. You’ve got your documents (the actual text), metadata, and the embedding vector:
CREATE TABLE documents ( id BIGSERIAL PRIMARY KEY, content TEXT NOT NULL, title VARCHAR(255), source VARCHAR(255), embedding vector(1536), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW());The 1536 is the dimensionality: common for OpenAI’s text-embedding-3-small. If you’re using Ollama’s nomic-embed-text, you’d use 768. If you’re rolling your own model, check the output shape.
Add an index. We’ll talk about which one in a minute:
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);Step 3: Generate and Store Embeddings
Here’s a real pipeline. You’re reading documents, generating embeddings, and storing them:
import psycopg2from psycopg2.extras import execute_valuesfrom ollama import embeddings as ollama_embedimport json
# Connect to your Postgres instanceconn = psycopg2.connect( dbname="your_database", user="postgres", password="your_password", host="localhost", port=5432)cursor = conn.cursor()
# Load your documents (from files, APIs, whatever)documents = [ {"title": "Docker Basics", "content": "Docker is a containerization tool..."}, {"title": "Compose Guide", "content": "Docker Compose allows you to..."}, # ... many more]
# Generate embeddings (using Ollama locally)records = []for doc in documents: # Generate embedding response = ollama_embed( model="nomic-embed-text", input=doc["content"] ) embedding = response["embeddings"][0]
# Prepare row for insert records.append(( doc["title"], doc["content"], embedding, doc.get("source", "unknown") ))
# Bulk insertexecute_values( cursor, "INSERT INTO documents (title, content, embedding, source) VALUES %s", records, template="(%s, %s, %s::vector, %s)")conn.commit()cursor.close()conn.close()
print(f"Inserted {len(records)} documents")Notice the %s::vector cast: Postgres needs to know you’re storing a vector, not a JSON array.
Step 4: Query by Similarity
Now the payoff. Find documents similar to a query:
import psycopg2from ollama import embeddings as ollama_embed
conn = psycopg2.connect( dbname="your_database", user="postgres", password="your_password", host="localhost", port=5432)cursor = conn.cursor()
# User queryuser_query = "How do I use Docker Compose?"
# Generate embedding for the queryresponse = ollama_embed( model="nomic-embed-text", input=user_query)query_embedding = response["embeddings"][0]
# Find top 5 most similar documentscursor.execute( """ SELECT id, title, content, embedding <=> %s::vector AS distance FROM documents ORDER BY embedding <=> %s::vector LIMIT 5; """, (query_embedding, query_embedding))
results = cursor.fetchall()for doc_id, title, content, distance in results: print(f"{title} (similarity: {1 - distance:.3f})") print(f" {content[:100]}...\n")
cursor.close()conn.close()The <=> operator used above is cosine distance. Lower distance = more similar. You can also use <-> for L2 (Euclidean) distance or <#> for inner product. Just make sure your index’s operator class matches the operator you query with (vector_cosine_ops for <=>).
Index Strategy: IVFFlat vs HNSW
This is where people get confused. pgvector supports two index types, and the choice matters for your dataset size and query speed.
IVFFlat (Inverted Flat)
IVFFlat divides your vectors into clusters (you specify the number), then does a brute-force search within the closest few clusters. Fast to build, memory-efficient, but lower recall than HNSW, it only searches the clusters nearest your query, so it can miss neighbors that landed just over a cluster boundary.
Use IVFFlat if:
- Your dataset is under 1 million vectors
- You’re okay with an approximate answer (which you are in RAG, perfect accuracy doesn’t matter)
- You want to avoid a surprise memory spike at 2 AM
Configuration:
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);The lists parameter is the number of clusters. The official pgvector recommendation is rows / 1000 for up to 1M rows, and sqrt(rows) once you’re over 1M. So 10k documents → 10 lists, 100k → 100 lists. The lists = 100 above is a fine default until you’re well past 100k rows.
HNSW (Hierarchical Navigable Small World)
HNSW is fancier: it builds a graph where each vector knows its nearest neighbors at multiple scales. Faster queries, slower builds, higher memory overhead. Think of IVFFlat as a filing cabinet and HNSW as a hyperlinked map.
Use HNSW if:
- Your dataset is over 1 million vectors and you need sub-second queries
- You have the memory (HNSW uses maybe 2-10x more RAM during build)
- You’re in production and the indexing time cost is worth the latency gain
Configuration:
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);m is the number of connections per node (higher = more accurate, slower build). ef_construction is the search width during build.
For most homelabs, stick with IVFFlat. It’s the right tradeoff.
Sizing Your Embeddings
This is a common question: “Should I use 768, 1536, or 3072 dimensions?”
The honest answer: it doesn’t matter as much as people think.
- 768 dims (Ollama’s nomic-embed-text, MiniLM): Fast, cheap, good enough for semantic search
- 1536 dims (OpenAI text-embedding-3-small): The default most production RAG systems standardize on
- 3072+ dims (OpenAI text-embedding-3-large, BAAI/bge-large): Overkill for homelab unless you’re doing fine-grained similarity on very large corpuses
The trade-off is straightforward: higher dims = slightly better accuracy, more storage, slower queries. In practice, going from 768 to 1536 is noticeable. Going from 1536 to 3072? You won’t feel it in most RAG setups.
Pick what your embedding model outputs. Don’t try to squeeze a 3072-dim embedding into 1536 dims or zero-pad a 768-dim to 1536. Use the model’s native output.
Hybrid Search: Keywords + Vectors
Here’s where pgvector shines compared to specialized vector DBs. You’ve already got Postgres. You already have full-text search. Why not use both?
A common pattern: vector search for relevance, keyword filters for precision. Find the top 50 semantically similar documents, then filter to ones published in the last month and tagged “docker”.
cursor.execute( """ SELECT id, title, content, embedding <=> %s::vector AS similarity FROM documents WHERE source = %s AND created_at > NOW() - INTERVAL '30 days' AND to_tsvector('english', content) @@ plainto_tsquery('english', %s) ORDER BY embedding <=> %s::vector LIMIT 10; """, (query_embedding, "blog", "docker compose", query_embedding))This is a RAG system’s best friend: get vectors, filter by metadata, then rank by relevance. No specialized vector DB can beat Postgres at metadata filtering because Postgres IS a real database.
When to Stop Using pgvector
There’s a point where you outgrow it. Honestly, that point is pretty far out, but here it is:
- Millions of vectors + low-latency requirements: HNSW will work, but you’re pushing Postgres hard. Qdrant or Milvus might be faster.
- Multi-tenancy at scale: Postgres row-level security works, but it’s not as elegant as a vector DB designed for isolated namespaces.
- Serverless/auto-scaling: If you need to spin up embeddings on-demand, Postgres connection pooling gets annoying. Pinecone’s managed chaos might suit you better.
- Sharding across machines: Postgres sharding is manual and painful. Qdrant handles it natively.
But you probably don’t have any of those problems. You’re running this in a homelab. You’ve got stable compute. Your dataset is maybe 100k documents. pgvector with a 2GB Postgres instance handles that in its sleep.
The Real Cost Calculation
Every “specialized vector database” pitch includes a cost comparison:
- Pinecone: $0.04 per 1M vectors/month + egress fees
- Weaviate: Open source (free) or managed (pay per compute)
- Qdrant: Similar to Weaviate
- pgvector: Free extension, you pay for Postgres compute (which you already have)
If you’re self-hosting Postgres on a home server that costs you nothing, pgvector wins by default. If you’re renting cloud compute, you pay Postgres rates anyway, why add another service?
The only exception: if you’re building an AI startup and need something shiny to show investors, go ahead and use a specialized vector DB. Honestly, the resume value alone might be worth it.
Getting Started
Set up a test project. Throw 1,000 documents at pgvector. Build a simple semantic search API. See how it feels.
You’ll probably realize: this is fine. Better than fine. It’s just Postgres. Your backups work. Your monitoring works. You can write a SQL query. There’s no API quirk to learn, no rate limits, no “vector db is down” Slack message.
Start with IVFFlat, stay with IVFFlat until you’ve got a real performance problem (which you won’t). Use OpenAI’s embedding model or Ollama locally depending on your privacy requirements.
That’s it. You’ve got a vector database now.
Full example code is on GitHub (when I write it up): I’ll add a working Python RAG pipeline with Docker Compose + Postgres + pgvector to sumguy-examples soon. For now, the snippets above are production-ready: copy them, swap your connection details, and go.