Siddhant Deval
Siddhant Deval
backend5 min read

Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)

Spinning up Pinecone or Qdrant as a separate vector database creates catastrophic dual-write synchronisation hazards and complicates multi-tenant security. This article builds the Workspace knowledge vault on PostgreSQL 16 with pgvector HNSW indexing, Row-Level Security for tenant isolation, and shadow-column migration strategies for embedding model upgrades.

Series·Part 9 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)

Reaching for Pinecone or Qdrant as a dedicated vector database solves a real problem — fast ANN search — but creates three new ones: dual-write synchronisation between your relational database and the vector store, complex transaction semantics that span two systems, and Row-Level Security that cannot be enforced at the vector query level. For the Workspace, where every chunk belongs to an organisation and must be invisible to others, a separate vector store creates an unacceptable security surface.

This article builds the production knowledge vault on PostgreSQL 16 + pgvector 0.7+: HNSW index construction with tuned parameters, Row-Level Security policies that gate every ANN scan at the database level, and a shadow-column migration strategy for zero-downtime embedding model upgrades from 1536 to a new dimension count.

Architectural Note

Who this is for: Backend engineers evaluating whether to add a dedicated vector database or stay in PostgreSQL. After this article, you will understand the exact trade-offs and the engineering patterns that make pgvector production-capable for multi-tenant workloads.


1. HNSW vs IVF: The Indexing Decision

1.1 How HNSW Works

Hierarchical Navigable Small World (HNSW) is a graph-based ANN algorithm. At build time, it constructs a multi-layer graph where each node connects to its M nearest neighbours at each layer. At query time, it greedily traverses from an entry point at the top layer, descending through layers until reaching the base layer where the final candidates are collected.

Parameter Effect Recommended for Workspace
m (connections per node) Higher = better recall, more RAM, slower build 16 for <1M vectors; 32 for >1M
ef_construction Higher = better graph quality, slower build 128 for production (minimum: 64; best recall: 256)
ef_search (at query time) Higher = better recall per query, slower 40 default; tune per latency budget
Crucial Requirement

Set maintenance_work_mem to at least 2 GB (in the same session as CREATE INDEX) before building a large HNSW index. Without this, pgvector falls back to a slower disk-based build path that is 10–20× slower. This is the most common production deployment oversight.

1.2 HNSW vs IVF Trade-offs

Dimension HNSW IVF (Flat)
Query latency Lower (graph traversal) Higher (inverted list scan)
Recall@10 Higher (95–99%) Moderate (90–95%, depends on nprobe)
Build time Longer Shorter
RAM usage Higher (full graph in VRAM/RAM) Lower (lists can be paged)
Incremental updates ✅ Supports INSERT without full rebuild ❌ Requires periodic full rebuild
Best fit Workspace knowledge vault (production) Batch analytics (offline)
Crucial Requirement

HNSW requires the full index to fit in PostgreSQL's shared_buffers or the OS page cache for optimal performance. For a 1M-vector index at 1536 dimensions (float32), the index occupies approximately 6 GB. Plan shared_buffers accordingly.


2. Building the HNSW Index

SQL
-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;

-- Chunks table (matching Drizzle schema from Part 8)
CREATE TABLE chunks (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id  UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  org_id       UUID NOT NULL REFERENCES organisations(id),
  content      TEXT NOT NULL,
  heading      TEXT NOT NULL,
  chunk_index  INTEGER NOT NULL,
  sha256       TEXT NOT NULL,
  token_count  INTEGER NOT NULL,
  embedding    vector(1536) NOT NULL,
  created_at   TIMESTAMPTZ DEFAULT now()
);

-- CRITICAL: Set maintenance_work_mem before building HNSW.
-- Without this, pgvector falls back to disk-based building (10–20× slower).
SET maintenance_work_mem = '2GB';  -- Use ≥4 GB for >500k vectors

-- HNSW index using inner product (requires L2-normalised vectors — Part 8)
-- vector_ip_ops = inner product = cosine similarity for unit vectors (fastest)
-- ef_construction=64 is minimum; use 128–256 for production-grade recall (>98%)
CREATE INDEX CONCURRENTLY chunks_embedding_hnsw_idx
  ON chunks
  USING hnsw (embedding vector_ip_ops)
  WITH (m = 16, ef_construction = 128);  -- 128 is the production baseline

-- Composite index for tenant-scoped lookups
CREATE INDEX chunks_org_doc_idx ON chunks (org_id, document_id);

-- Deduplication index
CREATE UNIQUE INDEX chunks_sha256_doc_idx ON chunks (sha256, document_id);
TYPESCRIPT
// ✅ Tune ef_search per query — higher recall at cost of latency
// file: packages/db/src/retrieval/VectorSearch.ts

import { db } from '../drizzle'
import { sql } from 'drizzle-orm'

export async function vectorSearch(
  orgId: string,
  queryEmbedding: number[],
  limit = 20,
  efSearch = 40,  // Default; increase to 80 for higher recall at cost of latency
): Promise<SearchResult[]> {
  // Set ef_search for this session/transaction
  await db.execute(sql`SET LOCAL hnsw.ef_search = ${efSearch}`)

  const vectorLiteral = `[${queryEmbedding.join(',')}]`

  // Inner product query — works correctly only with L2-normalised vectors
  // <#> = negative inner product (pgvector uses this for similarity ordering)
  return db.execute(sql`
    SELECT
      id,
      content,
      heading,
      document_id,
      token_count,
      1 - (embedding <#> ${vectorLiteral}::vector) AS similarity
    FROM chunks
    WHERE org_id = ${orgId}::uuid    -- RLS also enforces this, but explicit is faster
    ORDER BY embedding <#> ${vectorLiteral}::vector
    LIMIT ${limit}
  `)
}

3. Row-Level Security for Multi-Tenant Isolation

3.1 Why RLS Is Non-Negotiable

Without RLS, a single misconfigured query — a missing WHERE org_id = ? clause — can return chunks from every tenant in the database. In a knowledge vault containing proprietary engineering documents, financial projections, and internal roadmaps, this is a critical data breach.

RLS enforces the tenant boundary at the database engine level. Even if application code omits the WHERE org_id filter, PostgreSQL rejects the query.

SQL
-- Enable RLS on chunks table
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks FORCE ROW LEVEL SECURITY;  -- Applies to table owners too

-- Create a PostgreSQL role for the application
CREATE ROLE workspace_app LOGIN PASSWORD 'strong_password_here';

-- Grant DML — but NOT bypass RLS
GRANT SELECT, INSERT, UPDATE, DELETE ON chunks TO workspace_app;

-- Policy: app can only see/modify chunks in its current org context
-- The app sets current_setting('app.current_org_id') at session start
CREATE POLICY chunks_org_isolation ON chunks
  USING (org_id = current_setting('app.current_org_id')::uuid)
  WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);
TYPESCRIPT
// ✅ Session-level org context — set before every query in the Workspace
// file: packages/db/src/middleware/orgContext.ts

import { db } from '../drizzle'
import { sql } from 'drizzle-orm'

/**
 * Sets the PostgreSQL session variable that RLS policies read.
 * MUST be called at the start of every request handler.
 */
export async function setOrgContext(orgId: string): Promise<void> {
  // Validate orgId is a valid UUID before passing to database
  if (!/^[0-9a-f-]{36}$/.test(orgId)) {
    throw new Error(`Invalid org ID format: ${orgId}`)
  }
  await db.execute(sql`SET LOCAL app.current_org_id = ${orgId}`)
}

// Fastify middleware usage:
// fastify.addHook('preHandler', async (req) => {
//   const orgId = req.user.orgId  // from JWT
//   await setOrgContext(orgId)
// })

4. Zero-Downtime Embedding Model Migration

4.1 The Migration Problem

When a superior embedding model is released (e.g., upgrading from text-embedding-3-small (1536d) to a hypothetical future model with 3072d), all existing vectors become incompatible. A naive migration requires:

  1. Stop all ingestion writes
  2. Re-embed every chunk in the database (hours for large vaults)
  3. Rebuild the HNSW index
  4. Resume traffic

This creates hours of downtime.

4.2 Shadow Column Strategy

SQL
-- Add shadow column for new embedding model — old column untouched
ALTER TABLE chunks ADD COLUMN embedding_v2 vector(3072);

-- New HNSW index on shadow column — built CONCURRENTLY (non-blocking)
CREATE INDEX CONCURRENTLY chunks_embedding_v2_hnsw_idx
  ON chunks
  USING hnsw (embedding_v2 vector_ip_ops)
  WITH (m = 16, ef_construction = 64);
TYPESCRIPT
// ✅ Dual-write during migration window
// file: packages/db/src/ingestion/DualWriteIngester.ts

export async function ingestWithDualWrite(chunk: DocumentChunk): Promise<void> {
  // Embed in both old and new model simultaneously
  const [embeddingV1, embeddingV2] = await Promise.all([
    embedWithModel('text-embedding-3-large', chunk.content),   // 1536d
    embedWithModel('text-embedding-future', chunk.content),    // 3072d
  ])

  await db.update(chunks)
    .set({
      embedding: embeddingV1,      // Existing column
      embeddingV2: embeddingV2,    // Shadow column
    })
    .where(eq(chunks.id, chunk.id))
}

// After all chunks are backfilled to shadow column:
// 1. Switch query routing to embedding_v2 column
// 2. DROP COLUMN embedding (old) — non-blocking
// 3. ALTER TABLE chunks RENAME COLUMN embedding_v2 TO embedding

Summary

Decision Rule
Index type HNSW for production read-heavy workloads; m=16, ef_construction=64 is a safe starting point
Distance operator vector_ip_ops (inner product) for L2-normalised vectors — fastest and most accurate
Multi-tenancy Enable RLS + FORCE ROW LEVEL SECURITY; set app.current_org_id at session start
Model migration Shadow column + CREATE INDEX CONCURRENTLY enables zero-downtime embedding upgrades
RAM planning 1M × 1536d float32 HNSW index ≈ 6 GB; size shared_buffers accordingly

What's Next

Part 10 reveals the blind spot of pure vector search: exact identifier lookups. We implement hybrid search — fusing dense pgvector similarity with sparse BM25 full-text search via Reciprocal Rank Fusion (RRF) SQL CTEs — entirely inside PostgreSQL with no additional infrastructure.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#pgvector#PostgreSQL#HNSW#Vector Database#Node.js#Multi-tenant#Performance
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.

Series·Part 9 of 24

Full-Stack AI Architecture: From Streaming Foundations to Resilient Agentic Systems in Node.js & React

Curriculum Roadmap
01The Full-Stack AI Paradigm Shift: Beyond Toy Wrappers & Prompt Concatenation
02The Anatomy of an LLM Call: Tokens, Context Windows & Probabilistic Mechanics
03Structured Outputs & Formal Invariants: Taming Non-Determinism with Zod & TypeBox
04Full-Stack Streaming Protocols: SSE, Fetch Streams & WebSockets
05V8 Streams, Server-Side Backpressure & Cancellations in Node.js
06React 18/19 Streaming UI & Generative Components
07Generative UI & Interactive Canvas Synchronization
08High-Dimensional Vector Embeddings & Similarity Math
09Vector Storage & Indexing at Scale: HNSW vs IVF in PostgreSQL (pgvector)
Reading
10Hybrid Search: Dense Vectors Meet Sparse Lexical BM25
11Multi-Stage Retrieval & Cross-Encoder Reranking
12Advanced RAG Architectures: Contextual Retrieval & Parent-Document Splitting
13Query Transformation, Decomposition & Multi-Query Expansion
14Dynamic Context Window Management & Long-Context Packing
15Agent Loops & Deterministic State Machines
16Model Context Protocol (MCP) in Full-Stack Architecture
17Human-in-the-Loop & Durable Agent Workflows
18Multi-Agent Orchestration & Collaborative Swarms
19Semantic Caching & Provider Cost Optimization
20Multi-Provider AI Gateways: Fallbacks, Rate Limiting & Hedged Requests
21Client-Side AI & Edge Hybrid Execution: WebGPU & Transformers.js
22Production Evals & Guardrails: Measuring Retrieval and Generation
23Full-Stack AI Observability & Tracing with OpenTelemetry
24Production Capstone: Assembling The Enterprise Collaborative AI Workspace