Free lesson · LLMOps Engineering

Deploy pgvector and build embedding ingestion pipeline with operational monitoring

You will deploy pgvector and build an embedding ingestion pipeline with operational instrumentation. Deploy PostgreSQL with pgvector extension in your vCluster. Create embedding tables with proper indexing (HNSW for approximate nearest neighbor). Build EmbeddingIngestionPipeline: reads documents from a source queue, chunks documents using configurable strategy, calls embedding model via LiteLLM, and stores vectors in pgvector. Instrument the pipeline: embedding_pipeline_throughput_docs_per_second, embedding_pipeline_latency_seconds{stage} (chunking, embedding, storage), embedding_pipeline_errors_total{error_type}, embedding_pipeline_queue_depth. Implement pipeline health checks: verify embedding model is accessible, verify pgvector is writable, verify queue is being drained.

Course: GenAI Operations · Chapter 38 · Embedding Pipeline Ops

Free to read — no subscription required.

Introduction

When you run embedding pipelines in production, the gap between "it works locally" and "it runs reliably at scale" appears fast: writes fail silently, duplicate chunks accumulate, and there is no signal when throughput degrades. pgvector closes that gap by bringing vector similarity search into PostgreSQL, where you can enforce deduplication constraints, index embeddings for fast retrieval, and attach Prometheus metrics to the same database your application already trusts. By the end of this lesson, you'll have deployed pgvector on Kubernetes with an HNSW index configured for cosine similarity and a Helm-driven deployment script that bootstraps the full schema on first startup.

Key Terminology

  • pgvector — A PostgreSQL extension that adds a native vector column type and vector similarity operators, enabling approximate nearest-neighbor search inside the same database that stores your application data; activated per-database with CREATE EXTENSION IF NOT EXISTS vector.
  • HNSW index — Hierarchical Navigable Small World index, a graph-based approximate nearest-neighbor structure created with USING hnsw; the m=16 parameter controls graph connectivity and ef_construction=200 controls build-time recall quality, together determining the throughput/accuracy trade-off for cosine similarity queries.
  • chunk-level deduplication — A database-enforced guarantee that each (document_id, chunk_index) pair appears at most once in the embeddings table, implemented via a UNIQUE(document_id, chunk_index) constraint that prevents silent accumulation of duplicate embedding rows across pipeline reruns.
  • init ConfigMap — A Kubernetes ConfigMap (here pgvector-init) that holds SQL bootstrapping scripts mounted into the PostgreSQL pod; the Bitnami chart's primary.initdb.scriptsConfigMap hook executes these scripts automatically on first boot, so schema creation is declarative and version-controlled rather than a manual step.
  • idempotent Helm deployment — The helm upgrade --install invocation pattern used in deploy_pgvector() that either installs a new release or updates an existing one without error, making repeated script executions safe and enabling the deployment to serve as a reliable CI step or operational runbook.
  • Prometheus ServiceMonitor — A Kubernetes custom resource enabled via metrics.serviceMonitor.enabled=true in the Helm values that registers the pgvector pod as a scrape target, routing throughput and query-latency metrics into the cluster's monitoring stack for operational visibility.

Concepts

Why PostgreSQL Instead of a Dedicated Vector Store

The core architectural decision in this lesson is running vector similarity search inside PostgreSQL via pgvector rather than deploying a standalone vector database. The motivation is operational: your application already trusts PostgreSQL for transactional data, and adding a vector column to an existing table means you can join embeddings with metadata, enforce foreign keys, and apply familiar backup and access-control policies — all without a second stateful system to operate.

The trade-off is real. A dedicated vector store can scale similarity search horizontally with less ceremony. But for embedding pipelines that are already PostgreSQL-native — where document_id lives in a relational row, where updated_at drives staleness queries, and where you want UNIQUE constraints to prevent duplicate ingestion — pgvector keeps the operational surface small. A single database handles storage, deduplication, and retrieval.

HNSW Indexes and the Recall/Throughput Trade-off

Vector similarity search is an approximate problem. An exhaustive scan over millions of 1536-dimensional vectors is prohibitively slow, so pgvector builds a Hierarchical Navigable Small World graph over the embedding space. When a query arrives, the HNSW algorithm traverses this graph, skipping most of the space and returning approximate nearest neighbors in milliseconds.

Two parameters control the quality of that approximation at index-build time. m=16 sets how many bidirectional links each node maintains in the graph — higher values improve recall and query-time navigation at the cost of memory and slower inserts. ef_construction=200 controls how many candidates the builder considers when placing each new node — larger values produce a higher-quality graph but slow down ingestion. The values chosen here (m=16, ef_construction=200) are a conventional starting point for high-recall cosine similarity over 1536-dimensional OpenAI-compatible embeddings. Production tuning requires measuring recall against your actual query distribution (see Code Walkthrough for the exact CREATE INDEX statement).

The vector_cosine_ops operator class tells pgvector to optimize the graph for cosine distance specifically. Using a different distance metric at query time after building with vector_cosine_ops returns meaningless results, so the index operator class and the query operator must match.

Declarative Schema Bootstrap and Idempotent Deployment

A recurring failure mode for stateful infrastructure is schema drift: the database the application expects diverges from what is actually deployed, and the discrepancy only surfaces at runtime. This lesson closes that gap with two interlocking patterns.

First, the init ConfigMap externalizes the entire schema — extension, table, constraints, and indexes — as a SQL file under version control. The Bitnami PostgreSQL chart mounts this ConfigMap and executes it on first pod startup, meaning a fresh deployment always arrives in the correct state without a manual migration step.

Second, deploy_pgvector() uses helm upgrade --install, which is idempotent by construction: running it against a cluster that already has the release applies any changed values and exits cleanly; running it against a fresh cluster installs from scratch. The --wait --timeout 300s flags turn the call into a synchronous gate — the script only returns after the PostgreSQL pod has passed its readiness probe, so any downstream pipeline step that runs immediately afterward can assume the database is accepting connections. Together these two patterns mean the deployment script is also a safe runbook for operators: run it to install, run it again to reconfigure, and the cluster converges to the declared state either way (see Code Walkthrough for the full helm_args list).

Code Walkthrough

Now that you understand how HNSW indexes and chunk-level deduplication work together in pgvector, the following two artifacts wire those concepts into a runnable Kubernetes deployment: a ConfigMap that bootstraps the schema on first startup, and a Python helper that drives the Helm installation idempotently.

The pgvector-init ConfigMap holds the SQL script that PostgreSQL executes automatically when the pod first boots. It enables the vector extension, creates the embeddings table with a vector(1536) column sized for OpenAI-compatible models, and enforces chunk-level deduplication through a UNIQUE(document_id, chunk_index) constraint. The HNSW index is tuned with m=16 and ef_construction=200 for high-recall cosine similarity search, and two supporting B-tree indexes on document_id and updated_at accelerate the document lookups and staleness queries used by the reprocessing logic.

Code snippetyaml
1# helm-values/pgvector-init-configmap.yaml 2apiVersion: v1 3kind: ConfigMap 4metadata: 5 name: pgvector-init 6 namespace: embedding-ops 7data: 8 init-pgvector.sql: | 9 CREATE EXTENSION IF NOT EXISTS vector; 10 CREATE TABLE embeddings ( 11 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 12 document_id TEXT NOT NULL, 13 chunk_index INTEGER NOT NULL, 14 content TEXT NOT NULL, 15 embedding vector(1536), 16 metadata JSONB DEFAULT '{}', 17 created_at TIMESTAMPTZ DEFAULT NOW(), 18 updated_at TIMESTAMPTZ DEFAULT NOW(), 19 source_modified_at TIMESTAMPTZ, 20 UNIQUE(document_id, chunk_index) 21 ); 22 CREATE INDEX idx_embeddings_hnsw 23 ON embeddings 24 USING hnsw (embedding vector_cosine_ops) 25 WITH (m = 16, ef_construction = 200); 26 CREATE INDEX idx_embeddings_document_id ON embeddings (document_id); 27 CREATE INDEX idx_embeddings_updated_at ON embeddings (updated_at);

With the schema ConfigMap in place, deploy_pgvector drives the Helm installation. It targets the Bitnami PostgreSQL OCI chart, overrides the default image with ankane/pgvector (which ships the extension pre-compiled against PostgreSQL 16), sets memory and CPU requests appropriate for HNSW in-memory workloads, mounts the init ConfigMap so the schema runs automatically on first boot, and enables the Prometheus ServiceMonitor so throughput and query-latency metrics flow into the cluster's monitoring stack. Using helm upgrade --install makes the call idempotent — re-running it on an already-deployed release applies value changes without creating a duplicate release. The --wait --timeout 300s flags block the script until the PostgreSQL pod passes its readiness probe, so subsequent pipeline steps can safely assume the database is accepting connections.

Code snippetpython
1# scripts/deploy_pgvector.py 2import subprocess 3import sys 4 5def deploy_pgvector(namespace: str = "embedding-ops") -> None: 6 """Deploy PostgreSQL with pgvector extension via Helm.""" 7 helm_args = [ 8 "helm", "upgrade", "--install", "pgvector", 9 "oci://registry-1.docker.io/bitnamicharts/postgresql", 10 "--namespace", namespace, 11 "--create-namespace", 12 "--set", "image.repository=ankane/pgvector", 13 "--set", "image.tag=v0.7.4-pg16", 14 "--set", "primary.persistence.size=50Gi", 15 "--set", "primary.resources.requests.memory=2Gi", 16 "--set", "primary.resources.requests.cpu=1000m", 17 "--set", "primary.initdb.scriptsConfigMap=pgvector-init", 18 "--set", "auth.postgresPassword=changeme", 19 "--set", "metrics.enabled=true", 20 "--set", "metrics.serviceMonitor.enabled=true", 21 "--wait", "--timeout", "300s", 22 ] 23 result = subprocess.run(helm_args, capture_output=True, text=True) 24 if result.returncode != 0: 25 print(f"Helm deploy failed: {result.stderr}", file=sys.stderr) 26 sys.exit(1) 27 print("pgvector deployed successfully") 28 29if __name__ == "__main__": 30 deploy_pgvector()

Confirm that the deployment succeeded by running kubectl get pods -n embedding-ops until pgvector-postgresql-0 shows Running, then connecting to the database and executing SELECT extname FROM pg_extension WHERE extname = 'vector'; — a single returned row confirms the extension is active and the schema bootstrap ran cleanly.

Do's and Don'ts

Having walked through the material above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do use helm upgrade --install with --wait --timeout 300s — the idempotent upgrade path means re-running deploy_pgvector on an already-deployed release applies value changes without creating a duplicate, and the wait flags block downstream pipeline steps until pgvector-postgresql-0 passes its readiness probe so you never race against an unready database.
  2. Do set UNIQUE(document_id, chunk_index) on the embeddings table — this constraint is what prevents silent duplicate accumulation when chunks are reprocessed; without it, repeated ingestion runs stack redundant rows that corrupt similarity search results and inflate index size invisibly.
  3. Do enable metrics.enabled=true and metrics.serviceMonitor.enabled=true in the Helm values — these flags wire the PostgreSQL exporter into the cluster's Prometheus stack at deploy time, giving you the throughput and query-latency signals you need to detect pipeline degradation before failures become user-visible.

Don'ts

  1. Don't swap out ankane/pgvector for the default Bitnami PostgreSQL image — the default image does not ship the vector extension pre-compiled, so the CREATE EXTENSION IF NOT EXISTS vector line in the init ConfigMap will fail silently and the embeddings table will never be created, leaving downstream pipeline steps with no schema to write to.
  2. Don't remove or reduce the HNSW m=16, ef_construction=200 parameters without understanding the recall trade-off — lowering these values speeds up index builds but reduces recall for cosine similarity queries; the B-tree indexes on document_id and updated_at are separate and do not compensate for a degraded HNSW configuration.
  3. Don't skip verifying the extension with SELECT extname FROM pg_extension WHERE extname = 'vector'; after deployment — the init ConfigMap only runs on first boot, so a pod that restarted against an already-initialized volume may appear healthy while the schema bootstrap never executed; the single-row query is the only reliable confirmation that the extension is active and all three indexes exist.

This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the LLMOps Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in GenAI Operations

All free lessons in LLMOps Engineering