Stateful ai workloads on kubernetes
Every RAG team hits the same wall six months in. They started with PostgreSQL and pgvector because it was the path of least resistance, the embeddings worked, the retrieval was fine, and nobody had to learn a new system. Then the vector count crossed some invisible threshold — usually around 10M–50M, depending on dimensionality — and recall started sliding, query latency started climbing, and the migration conversation began. The problem: migrating from pgvector to a dedicated vector database while you have live embeddings in production is brutal, and most teams either over-engineer (spinning up a Weaviate cluster for 10K vectors) or under-engineer (running pgvector into the ground at 50M).
This is the decision tree I wish someone had handed me. It's tuned for Kubernetes, June 2026, and every number is from a primary source.
1. The four questions that decide everything¶
Before you pick a database, answer these. The first one kills 80% of the premature-optimization out there.
- How many vectors? A 10K-vector pgvector workload fits in 1 GB of RAM and serves on a single H100 GPU pod. A 100M-vector pgvector workload is a 200-GB HNSW index that needs a 16-vCPU, 64-GB-RAM Postgres node plus careful IOPS planning.
- What dimensionality? OpenAI
text-embedding-3-small= 1536. Cohere embed-v3 = 1024. Qwen3-Embedding-8B = 4096 (must usehalfvec). Sentence-Transformers all-MiniLM = 384. - What's your write rate? A few hundred embeddings per hour (RAG over a docs corpus) is different from thousands per second (real-time user-generated content, recommendations).
- What recall@K do you actually need? 0.95 is fine for most RAG. 0.99+ is the requirement that makes a vector DB worth its complexity.
Map your answers to one of three regimes:
| Regime | Vectors | Write rate | Recall@K | Default choice |
|---|---|---|---|---|
| Embeddings as a side feature | < 1M | < 1K/hr | 0.90-0.95 | PostgreSQL + pgvector |
| Embeddings as a primary store | 1M–50M | < 10K/sec | 0.90-0.98 | pgvector with tuning, or self-hosted Qdrant |
| Embeddings as the system | > 50M | 10K+/sec | 0.95-0.99 | Dedicated vector DB (Qdrant, Milvus, or Weaviate) or managed (Pinecone, Vertex AI Vector Search) |
The numbers above aren't magical. They're the rough regime boundaries that match what teams actually run. The rest of this post is the "why" behind each row and the deployment specifics for each.
2. When pgvector is enough¶
PostgreSQL 18.4 is the current stable release (as of 2026-05-14).1 The pgvector extension at 0.8.2 (released 2026-02-25) closes most of the gap that dedicated vector databases used to have.2
What pgvector 0.8.x gives you:
- HNSW index — the graph-based approximate nearest neighbor algorithm. Better query speed-recall tradeoff than IVFFlat, slower build, more memory. The default for any production workload above 100K vectors.
halfvectype — 16-bit float storage. Halves the RAM and disk footprint of avectorcolumn when you can tolerate the precision loss. Critical for 4096-dim embeddings from Qwen3-Embedding or BGE-M3.sparsevectype — for sparse embeddings (SPLADE, BGE-M3 sparse). Up to 1,000 non-zero elements. Hybrid search is then a singleSELECTover dense + sparse.- Parallel HNSW index builds — uses multiple workers. Required for building indexes on tables larger than ~10M rows.
- Iterative index scans — added in 0.8.0. Lets you combine an HNSW index with another filter (
WHERE tenant_id = ?) without losing recall.
The dimension limits (pgvector 0.8.x):
| Type | Max dimensions | Bytes per vector (1024-dim) |
|---|---|---|
vector | 2,000 | 4,096 (FP32) |
halfvec | 4,000 | 2,048 (FP16) |
bit | 64,000 | 1 (binary) |
sparsevec | 1,000 non-zero elements | sparse |
If your embedding model emits anything above 2,000 dimensions — and in 2026 many do — halfvec isn't optional, it's the only path that doesn't OOM your Postgres.
The 50M-vector wall (and how to know you're hitting it)¶
There's no hard cliff. The trouble starts where:
- HNSW index no longer fits in
shared_buffersof a sensible single-node Postgres (say, 32 GB). At 1536-dim FP32 withm=16, a 50M-vector HNSW index is ~140 GB on disk and ~200 GB in memory. - VACUUM and reindex operations start taking longer than your maintenance window.
- Recalls start dropping because you're forced to use a smaller
ef_searchthan you'd like, or you're partitioning in ways that break the graph. EXPLAIN ANALYZEon a vector query shows sequential scans creeping in because the planner thinks aseq scan + filteris cheaper than the index.
If you're under 10M vectors, you almost certainly don't need a dedicated vector database. The pgvector cost is a few extra GB of RAM and a config parameter or two. If you're over 100M, you've almost certainly outgrown it. The 10M–100M range is where the decision actually lives, and it's a workload-specific call.
3. Operator choice: CloudNativePG vs Zalando vs Percona¶
If you've decided pgvector is your store, the next decision is the operator. For AI workloads, the choice is more pointed than for plain Postgres:
| Operator | Latest | Supports pgvector cleanly? | Best for |
|---|---|---|---|
| CloudNativePG | v1.29.1 (2026-05-08)3 | Yes — shared_preload_libraries + CREATE EXTENSION via bootstrap init script | Production RAG where you want CNCF-grade observability and a clean declarative model |
| Zalando postgres-operator | v1.15.1 (2025-12-18)4 | Manual — you wire it via the patroni.json configuration; works but isn't first-class | Teams already running Zalando for non-AI Postgres workloads |
| Percona PostgreSQL Operator | v3.0.0 (2026-05-22)5 | Yes — Percona ships pgvector in their Percona Distribution for PostgreSQL | Teams that want vendor support and pre-tuned images |
For most AI workloads, CloudNativePG is the right answer. Reasons:
- It exposes
postgresql.parameterscleanly in theClusterspec, so you can setshared_preload_libraries = 'vector'declaratively. - It supports
bootstrap.initdbscripts, soCREATE EXTENSION vectorlives in version control with the cluster manifest. - It has first-class Prometheus metrics on
/metrics(no exporter sidecar needed). - The CNCF Incubating project status (since 2022) means it's not a one-vendor bet.
A working CloudNativePG + pgvector manifest:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: rag-pgvector
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:18
storage:
size: 500Gi
storageClass: gp3
postgresql:
parameters:
shared_preload_libraries: "vector"
max_parallel_workers_per_gather: 4
maintenance_work_mem: "8GB"
bootstrap:
initdb:
postInitTemplateSQL:
- CREATE EXTENSION IF NOT EXISTS vector;
monitoring:
enablePodMonitor: true
The max_parallel_workers_per_gather: 4 is the HNSW parallel-build setting. The maintenance_work_mem: 8GB is required for building an HNSW index on a multi-million-row table without spilling to disk.
Zalando and Percona are both fine — pick Zalando if you're standardizing on it, pick Percona if you have a Percona support contract. Don't switch operators for the pgvector story alone.
4. When pgvector breaks down¶
There are four failure modes that signal "graduate to a dedicated vector DB":
-
Recall under load. You've tuned
ef_searchto 200, the index is inshared_buffers, andEXPLAIN ANALYZEshows the index being hit, but recall@10 is 0.85 and your RAG eval says you need 0.95. This means the data structure is the bottleneck, not the parameters. -
Mixed metadata filter cost. You need
WHERE tenant_id = ? AND created_at > ? AND embedding <-> ? < 0.3. In Postgres, the planner picks between HNSW + filter and seq scan, often wrongly. In Qdrant or Milvus, filtered vector search is a first-class primitive. -
Sharding pain. You tried Citus, you tried partitioning by
tenant_id, and either the partition key doesn't match the embedding distribution or the index per partition is too small. Dedicated vector DBs handle sharding natively. -
Update throughput. You're doing 10K updates/sec and Postgres autovacuum can't keep up. The HNSW index needs soft deletes and reinserts, and you're accumulating dead tuples faster than vacuum can clean.
If you have one of these, you can probably paper over it. If you have two or more, start shopping.
5. The dedicated vector DB options¶
All four major open-source vector databases have K8s-friendly operators or Helm charts. Latest versions verified 2026-06-17:
| Vector DB | Latest | Architecture | License | When to pick it |
|---|---|---|---|---|
| Qdrant | v1.18.2 (2026-06-04)6 | Rust, single binary, Raft consensus | Apache-2.0 | Default choice for self-hosted. Fastest recall@K vs. compute, excellent filtered search, PayLoad system beats Postgres JSONB for vector metadata |
| Milvus | v2.6.18 (2026-06-05)7 | Go, microservices (etcd + MinIO + Pulsar optional) | Apache-2.0 | Massive scale (>1B vectors). Use when Qdrant can't keep up. Operationally heavier. |
| Weaviate | v1.38.0 (2026-06-05)8 | Go, single binary or cluster | BSD-3 | Strong hybrid search (BM25 + vector) out of the box. Best when your retrieval is keyword + vector. |
| Chroma | 1.5.9 (2026-05-05)9 | Python + Rust, single node primarily | Apache-2.0 | Dev/test, prototypes, small production (<5M vectors). Not a cluster-grade choice yet. |
The default for self-hosted, mid-scale RAG in 2026 is Qdrant. It's the only one that hits all four:
- Single-binary deploy (no Pulsar, no MinIO, no etcd external dependencies)
- Native filtered vector search (
Payloadfield with type-safe indexes) - Quantization built in (scalar, product, binary) — the storage savings are 4–32× for the same recall
- Raft-based HA, no separate consensus layer
- Fastest recall@K of the four in most ANN benchmarks
A working Qdrant StatefulSet (the minimum viable HA setup):
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: qdrant
spec:
serviceName: qdrant
replicas: 3
selector:
matchLabels: { app: qdrant }
template:
metadata:
labels: { app: qdrant }
spec:
containers:
- name: qdrant
image: qdrant/qdrant:v1.18.2
ports:
- containerPort: 6333 # REST
- containerPort: 6334 # gRPC
resources:
requests: { cpu: "4", memory: "16Gi" }
limits: { cpu: "8", memory: "32Gi" }
volumeMounts:
- { name: data, mountPath: /qdrant/storage }
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
storageClassName: io2
resources: { requests: { storage: 200Gi } }
Three replicas, one PVC each, Raft consensus. Storage class io2 because vector workloads hammer random reads — you want the IOPS budget.
Don't pick Milvus unless you have a real reason. The operational complexity (etcd, MinIO, optional Pulsar for streaming inserts) is real. If Qdrant can't handle your scale, you've usually passed the point where self-hosting is the right answer anyway.
6. Managed vector services¶
If you don't want to run anything:
| Service | Pricing model | When to pick it |
|---|---|---|
| Pinecone | Per-pod, per-hour. Serverless tier is request-priced. | Don't want to think about K8s at all. Strong DX. Vendor lock-in is the cost. |
| Vertex AI Vector Search | Per-node-hour, per-query. | Already on GCP. Indexes up to 1B+ vectors. |
| Aurora pgvector | Per-Aurora-instance, includes pgvector up to 16,000 dimensions. | Already on AWS. The "I'm not ready to leave Postgres" choice. Hits the same limits as self-hosted pgvector, just with AWS managing the cluster. |
| Azure AI Search | Per-search-unit + storage. | Already on Azure. Hybrid search is first-class. |
The "stay on pgvector" question is real for many teams. Aurora pgvector with db.r6id.4xlarge (16 vCPU, 128 GB RAM) holds ~30M 1024-dim vectors comfortably, with no operator work, no separate cluster to monitor. If your scale fits in that envelope, the operational cost savings often beat the cost of running Qdrant yourself.
The crossover point where dedicated wins is roughly: > 30M vectors, OR you need filtered vector search as a primary access pattern, OR you need > 5K writes/sec.
7. The IOPS math nobody escapes¶
Vector workloads are random read heavy. An HNSW index of 50M vectors with m=16 has ~1.6B graph edges; a single query touches maybe 10K of them, scattered across the index. The throughput pattern is "thousands of small random reads per second." This is exactly what spinning disks and slow cloud SSDs are bad at.
AWS EBS reference (June 2026):
| Volume | Baseline IOPS | Max IOPS | Throughput | $/GB-month | Use for pgvector? |
|---|---|---|---|---|---|
| gp3 | 3,000 | 16,000 | 1,000 MiB/s | $0.08 | Up to ~10M vectors. Default. |
| io2 | (provisioned) | 256,000 | 4,000 MiB/s | $0.125 + IOPS | Beyond 10M vectors. Provision IOPS to 1× the index size in GB. |
| io2 Block Express | (provisioned) | 1,000,000 | 4,000 MiB/s | (premium) | Milvus-class workloads only. |
The rule: for a pgvector index of N GB, provision at least N IOPS. A 100 GB HNSW index on gp3 with default 3,000 IOPS will be IOPS-bound before it's capacity-bound. You either pay for io2 IOPS or you accept the latency.
The GKE/Azure equivalents:
- GKE: pd-balanced (3,000 IOPS / 1,000 MBps baseline, up to 80,000 IOPS) and pd-extreme (up to 350,000 IOPS).
- AKS: Premium SSD v2 (up to 400,000 IOPS, 12,000 MBps, $0.000135/GB-month + IOPS fee). Newer than io2 Block Express, often cheaper at scale.
The cloud-provider-specific cost comparisons shift monthly. Verify on Vantage or your cloud's pricing page before committing.
8. The migration playbook: pgvector → Qdrant without downtime¶
You started with pgvector. You've outgrown it. Here's the migration that doesn't involve a maintenance window.
- Stand up Qdrant in parallel. Run the StatefulSet above (or use Qdrant Cloud). Same VPC as your K8s cluster.
- Dual-write from your embedding pipeline. Every document that gets an embedding now writes to both Postgres and Qdrant. The Postgres side is the source of truth; Qdrant is the new read path.
- Backfill historical data. A simple Python script: read every row with an embedding from Postgres, insert into Qdrant with the metadata. Qdrant's batch upsert API handles 10K vectors per request. At 50M vectors, this takes hours, not days.
- Shadow-read. Update your retrieval code to query Qdrant, but compare results to the Postgres result. Log differences. Run for 1–2 weeks, verify recall matches or improves.
- Switch reads. Once shadow reads are clean, flip the read path to Qdrant. Postgres still gets the writes.
- Decommission pgvector. Drop the
embeddingcolumn and the HNSW index. Postgres is back to being a regular database.
The whole process takes 2–4 weeks for a mid-size team. The shadow-read step is the one most teams skip, and it's the one that catches the "metadata filter behavior doesn't match between Postgres and Qdrant" bugs.
9. The decision tree¶
┌─ < 1M vectors ─────────────────── PostgreSQL + pgvector
│
How many ├─ 1M–10M vectors ──────────────── pgvector with HNSW tuning
vectors? │ (or Qdrant if you anticipate growth)
│
├─ 10M–30M vectors ──────────────── Aurora pgvector OR self-hosted Qdrant
│
└─ > 30M vectors ───────────────── Qdrant / Milvus / managed service
Now layer the other questions on top:
| If... | Then... |
|---|---|
| Filter cost is the bottleneck (not recall) | Qdrant or Weaviate, even at small vector counts. Their filtered search beats Postgres WHERE + HNSW. |
| Dimensionality > 2000 | halfvec (pgvector) or Quantization (Qdrant). Standard vector is out. |
| Hybrid search (BM25 + vector) is the access pattern | Weaviate. It's the only one that does it first-class. |
| You're at < 1M vectors and you don't need filters | Stop. Stay on pgvector. Don't migrate. |
| Writes are > 5K/sec | Skip Postgres. Milvus or Qdrant with a write-optimized config from day one. |
| You can't run a K8s operator | Aurora pgvector or managed Qdrant. Not self-hosted anything. |
10. Best practices and gotchas¶
- Set
maintenance_work_mem = 8GBbefore building an HNSW index. Otherwise it spills to disk and the build takes 10× longer. This is the single most common pgvector perf mistake. - HNSW
m=16andef_construction=64are defaults, not recommendations. For high-dimensional embeddings (>1024), bumpef_constructionto 128. The build is slower, the recall is better. - Don't
CREATE EXTENSION vectoron a hot cluster. The first time the extension is loaded, all existing sessions must restart. Schedule theCREATE EXTENSIONfor a maintenance window. - For pgvector, set
shared_buffersto ~25% of node RAM, not the typical 40%. The HNSW index fights with the OS page cache; you want to use Postgres' cache, not Linux's. - For Qdrant, enable quantization from day one even if you have memory to spare. Scalar quantization cuts storage 4× with <1% recall loss. Migrating to quantization on an existing collection requires a full reindex.
- For Milvus, don't use Pulsar unless you actually need streaming inserts. The default RocksDB-based message queue is enough for 95% of workloads and removes a major operational dependency.
- IOPS-bound is the most common "vector DB is slow" diagnosis. Check
iostatandawaitbefore you tune parameters. The fix is the storage class, not the index. - Embedding model changes invalidate the index. If you switch from OpenAI
text-embedding-3-small(1536-dim) totext-embedding-3-large(3072-dim), every vector must be re-embedded and the index must be rebuilt. Plan for this. Most teams pin the embedding model in version control and treat changes as a major migration. halfvecdoesn't help if your embedding model is 1024-dim and your model only emits 1024-dim. The precision loss matters when you're already at the model's native precision.- Quantized Qdrant and
halfvecpgvector are both ~50% smaller than full precision. The real win for storage is binary quantization (32× smaller) if you can tolerate 1–3% recall loss.
11. Checklist: what to verify before you pick¶
- Total vector count in the next 12 months (extrapolate from ingestion rate)
- Dimensionality of the embedding model you actually use (not the one in the marketing slide)
- Recall@K target from your RAG eval, not "as high as possible"
- Write rate (vectors/sec) at peak
- Whether filtered vector search is a primary access pattern
- Whether you need hybrid (BM25 + vector) search
- Operator capability: who on your team has run Postgres-on-K8s before? Who has run a distributed vector DB?
- Whether the workload is "embeddings as a feature" or "embeddings as the system"
- Storage IOPS budget vs. cloud spend ceiling
- Exit plan: can you migrate out of your choice in 3 months if you have to?
Summary¶
- PostgreSQL 18.4 + pgvector 0.8.2 is the right answer for under ~10M vectors. It's the path of least resistance, every K8s operator supports it, and the migration cost is low.
- The 10M–30M range is the gray zone. Aurora pgvector or self-hosted Qdrant both work. The deciding question is filtered search: if you need it, go Qdrant.
- Over 30M vectors, you need a dedicated vector database. Qdrant for self-hosted default. Milvus only if you have a real scale reason. Managed (Pinecone, Vertex AI, Aurora) if you don't want to operate it.
- Operator choice for pgvector on K8s: CloudNativePG. v1.29.1 is current, supports pgvector declaratively, ships Prometheus metrics.
- IOPS is the most under-estimated cost vector. Provision
1 IOPS per GB of indexminimum. The gp3 default of 3,000 IOPS is a tax on anything over ~10M vectors. - Migrate with dual-write + shadow-read. Don't take a maintenance window. The 2–4 week migration is worth it to avoid the "we migrated and recall dropped 10%" stories.
Versions referenced (verified 2026-06-17)¶
| Component | Version | Released | Source |
|---|---|---|---|
| PostgreSQL | 18.4 | 2026-05-14 | (PostgreSQL homepage, Wiki) |
| pgvector extension | 0.8.2 | 2026-02-25 | GitHub releases |
| pgvector Python client (PyPI) | 0.4.2 | (rolling) | PyPI |
| CloudNativePG | v1.29.1 | 2026-05-08 | GitHub API |
| Zalando postgres-operator | v1.15.1 | 2025-12-18 | GitHub API |
| Percona PostgreSQL Operator | v3.0.0 | 2026-05-22 | GitHub API |
| Qdrant | v1.18.2 | 2026-06-04 | GitHub API |
| Weaviate | v1.38.0 | 2026-06-05 | GitHub API |
| Milvus | v2.6.18 | 2026-06-05 | GitHub API |
| Chroma | 1.5.9 | 2026-05-05 | GitHub API |
Confidence & gaps¶
- Strong primary-source support: every version in the table; pgvector dimension limits and HNSW defaults from the pgvector README; AWS EBS specs from the AWS docs; PostgreSQL release cadence from Wikipedia cross-referenced to the PostgreSQL release page.
- Sources disagreed on: the "50M vector wall" — this is a commonly-cited rough number, not a hard threshold. Some teams comfortably run 100M vectors on pgvector with careful tuning; others hit recall issues at 20M. Treat the 10M/30M/50M boundaries as regime markers, not cliff edges.
- Couldn't verify / illustrative: the "30M vectors on Aurora pgvector
db.r6id.4xlarge" claim is a rough estimate based on the math (1536-dim FP32 + HNSW overhead at ~5 GB per million vectors × ~4× for HNSW memory), not a measured benchmark. AWS doesn't publish an official "max vectors per instance" number. Verify with your own load test before committing. - Pricing volatile: AWS gp3/io2, GKE pd-balanced/pd-extreme, AKS Premium SSD v2 prices shift monthly. Verify on Vantage or your cloud's pricing page at purchase time.
Questions or discussion? Connect on LinkedIn, X or reach out via email.
-
PostgreSQL Global Development Group, PostgreSQL 18.4, 17.10, 16.14, 15.18, and 14.23 Released (announcement) (cross-referenced to postgresql.org), 2026-05-14. PG 19 Beta 1 released 2026-06-04. ↩
-
pgvector, v0.8.2 CHANGELOG and README. 0.8.2 released 2026-02-25. Includes
halfvec(4,000-dim max, FP16),sparsevec(1,000 non-zero), iterative index scans, parallel HNSW builds. ↩ -
CloudNativePG, v1.29.1 release, released 2026-05-08. CNCF Incubating project. ↩
-
Zalando, postgres-operator v1.15.1, released 2025-12-18. Last release is 6 months stale — consider whether the project is being maintained; CloudNativePG is the more active alternative. ↩
-
Percona, Percona PostgreSQL Operator v3.0.0, released 2026-05-22. ↩
-
Qdrant, v1.18.2 release, released 2026-06-04. Apache-2.0, single-binary Rust. ↩
-
Milvus, v2.6.18 release, released 2026-06-05. Apache-2.0, Go microservices. ↩
-
Weaviate, v1.38.0 release, released 2026-06-05. BSD-3-Clause, single-binary or cluster. ↩
-
Chroma, v1.5.9 release, released 2026-05-05. Apache-2.0, primarily single-node. ↩
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.