Database Operations

PostgreSQL Vector Search: Configuring pgvector Indexes

By DexNox Dev Team Published May 20, 2026

Semantic Vector Search in PostgreSQL

Integrating machine learning workflows into relational databases requires a storage mechanism for high-dimensional vector embeddings. The PostgreSQL pgvector extension supplies this capability, permitting vector storage, distance metrics, and specialized indexing structures directly within standard PostgreSQL tables. This architecture eliminates the operational overhead of running a separate vector database alongside a relational store, ensuring ACID compliance and transaction safety across tabular and embedding data.

In semantic search, texts are projected into a multi-dimensional coordinate space by an embedding model. Queries are performed by evaluating the spatial distance between the query embedding and the stored document embeddings. The three primary distance metrics used in pgvector are:

  • Cosine Distance (<=>): Measures the angular distance between vectors, ignoring magnitude.
  • L2/Euclidean Distance (<->): Measures the straight-line distance between vector coordinates.
  • Inner Product (<#>): Calculates the negative dot product, representing similarity when vectors are normalized.

To scale search queries over hundreds of thousands or millions of vectors, exact scans (sequential scans) must be avoided in favor of Approximate Nearest Neighbor (ANN) indexes. pgvector provides two primary ANN index types: Inverted File Flat (IVFFlat) and Hierarchical Navigable Small World (HNSW).


High-Performance SQL/PLpgSQL Schema Configuration

Below is the SQL DDL to initialize the pgvector extension, establish a table with a 1536-dimensional vector column (compatible with OpenAI text-embedding-3-small or text-embedding-ada-002 models), and configure both IVFFlat and HNSW indexes with optimal storage parameters.

-- Step 1: Install the pgvector extension in the database
CREATE EXTENSION IF NOT EXISTS vector;

-- Step 2: Establish the document table with metadata and vector storage
CREATE TABLE document_embeddings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    content TEXT NOT NULL,
    metadata JSONB NOT NULL,
    embedding VECTOR(1536) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);

-- Step 3: Configure an HNSW (Hierarchical Navigable Small World) index
-- HNSW builds a multi-layer graph structure. We specify the cosine distance operator (<=>).
-- m: Max number of connections per node in the graph (default is 16, higher increases recall but raises index size).
-- ef_construction: Size of the dynamic candidate list when building the graph (default is 64, higher improves index quality but slows build speed).
CREATE INDEX idx_document_embeddings_hnsw_cosine 
    ON document_embeddings USING hnsw (embedding vector_cosine_ops)
    WITH (m = 24, ef_construction = 100);

-- Step 4: Configure an IVFFlat (Inverted File Flat) index as an alternative
-- IVFFlat divides vectors into lists (clusters) using k-means.
-- lists: The number of centroids to create. A rule of thumb is rows / 1000 for tables up to 1M rows.
-- Note: IVFFlat requires the table to be populated before index creation for optimal cluster centroid calculation.
CREATE INDEX idx_document_embeddings_ivfflat_cosine 
    ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 500);

-- Step 5: Implement a high-performance PL/pgSQL retrieval function
CREATE OR REPLACE FUNCTION query_similar_documents(
    query_vector VECTOR(1536),
    match_threshold DOUBLE PRECISION,
    match_limit INT
)
RETURNS TABLE (
    id UUID,
    content TEXT,
    similarity DOUBLE PRECISION,
    metadata JSONB
) AS $$
BEGIN
    -- Return matching entries where cosine similarity is above the threshold.
    -- The cosine distance operator <=> is used. Similarity = 1 - Cosine Distance.
    RETURN QUERY
    SELECT 
        doc.id, 
        doc.content, 
        (1.0 - (doc.embedding <=> query_vector))::DOUBLE PRECISION AS similarity,
        doc.metadata
    FROM document_embeddings doc
    WHERE (doc.embedding <=> query_vector) &lt; (1.0 - match_threshold)
    ORDER BY doc.embedding <=> query_vector ASC
    LIMIT match_limit;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

To verify that the query planner leverages the index, configure the HNSW probe search parameters (hnsw.ef_search) or IVFFlat search probes (ivfflat.probes), then run an EXPLAIN ANALYZE check:

SET hnsw.ef_search = 64;

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id, content, (1.0 - (embedding <=> '[0.015, -0.02, ... 1536 values]')) AS similarity
FROM document_embeddings
ORDER BY embedding <=> '[0.015, -0.02, ... 1536 values]' ASC
LIMIT 10;

High-Performance Go Client and Embedding Driver

When querying vector fields in Go, we must write a custom driver wrapper that formats the slice of float32 vectors into the text representation expected by pgvector (e.g., [0.015,-0.02,0.98...]) or uses direct binary encoding.

The Go program below establishes a connection pool, inserts document records, and issues concurrent similarity queries.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"strings"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib"
)

// DocumentRecord represents the model for storing database records.
type DocumentRecord struct {
	ID        string
	Content   string
	Metadata  string
	Embedding []float32
}

// VectorStore wraps the database connection pool.
type VectorStore struct {
	db *sql.DB
}

// NewVectorStore initializes the database pool with robust tuning.
func NewVectorStore(dsn string) (*VectorStore, error) {
	db, err := sql.Open("pgx", dsn)
	if err != nil {
		return nil, fmt.Errorf("failed to open db connection: %w", err)
	}

	db.SetMaxOpenConns(50)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(15 * time.Minute)
	db.SetConnMaxIdleTime(5 * time.Minute)

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := db.PingContext(ctx); err != nil {
		db.Close()
		return nil, fmt.Errorf("unreachable cluster: %w", err)
	}

	return &VectorStore{db: db}, nil
}

// FormatVector converts a float32 slice into a postgres vector representation: "[val1,val2,...]"
func FormatVector(v []float32) string {
	var sb strings.Builder
	sb.WriteRune('[')
	for i, val := range v {
		if i > 0 {
			sb.WriteRune(',')
		}
		sb.WriteString(fmt.Sprintf("%f", val))
	}
	sb.WriteRune(']')
	return sb.String()
}

// InsertDocument persists a document record along with its high-dimensional vector.
func (vs *VectorStore) InsertDocument(ctx context.Context, doc DocumentRecord) error {
	query := `
		INSERT INTO document_embeddings (content, metadata, embedding)
		VALUES ($1, $2, $3::vector)
		RETURNING id;
	`
	formattedVector := FormatVector(doc.Embedding)

	var insertedID string
	err := vs.db.QueryRowContext(ctx, query, doc.Content, doc.Metadata, formattedVector).Scan(&insertedID)
	if err != nil {
		return fmt.Errorf("failed to insert document: %w", err)
	}

	return nil
}

// QuerySimilarDocuments executes a similarity search using pgvector cosine distance.
func (vs *VectorStore) QuerySimilarDocuments(ctx context.Context, targetEmbedding []float32, threshold float64, limit int) ([]DocumentRecord, error) {
	// Dynamically configure search performance
	tx, err := vs.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
	if err != nil {
		return nil, fmt.Errorf("failed to begin read transaction: %w", err)
	}
	defer tx.Rollback()

	// Tune dynamic search parameter for HNSW index paths
	_, err = tx.ExecContext(ctx, "SET LOCAL hnsw.ef_search = 64;")
	if err != nil {
		return nil, fmt.Errorf("failed to set hnsw.ef_search: %w", err)
	}

	formattedVector := FormatVector(targetEmbedding)
	query := `
		SELECT id, content, metadata
		FROM document_embeddings
		WHERE (embedding <=> $1::vector) &lt; (1.0 - $2)
		ORDER BY embedding <=> $1::vector ASC
		LIMIT $3;
	`

	rows, err := tx.QueryContext(ctx, query, formattedVector, threshold, limit)
	if err != nil {
		return nil, fmt.Errorf("similarity search failed: %w", err)
	}
	defer rows.Close()

	var matchedDocs []DocumentRecord
	for rows.Next() {
		var doc DocumentRecord
		err := rows.Scan(&doc.ID, &doc.Content, &doc.Metadata)
		if err != nil {
			return nil, fmt.Errorf("failed scanning row: %w", err)
		}
		matchedDocs = append(matchedDocs, doc)
	}

	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("error during row scanning: %w", err)
	}

	return matchedDocs, nil
}

func main() {
	dsn := "postgres://postgres:securepassword@localhost:5432/vectordb?sslmode=disable"
	store, err := NewVectorStore(dsn)
	if err != nil {
		log.Fatalf("Vector store init failed: %v", err)
	}
	defer store.db.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Initialize test embedding (1536-dimensional mock vector)
	mockEmbedding := make([]float32, 1536)
	mockEmbedding[0] = 0.015
	mockEmbedding[1] = -0.022
	mockEmbedding[1535] = 0.841

	doc := DocumentRecord{
		Content:   "Postgres declarative indexing strategies for high-dimensional vectors.",
		Metadata:  `{"author": "database_architect", "category": "indexing"}`,
		Embedding: mockEmbedding,
	}

	err = store.InsertDocument(ctx, doc)
	if err != nil {
		log.Fatalf("Failed to store vector document: %v", err)
	}

	matches, err := store.QuerySimilarDocuments(ctx, mockEmbedding, 0.70, 5)
	if err != nil {
		log.Fatalf("Error during search: %v", err)
	}

	fmt.Printf("Search complete. Found %d semantic matches.\n", len(matches))
}

Database Metrics Comparison Table

The choice between a sequential search scan, an IVFFlat index, and an HNSW graph index represents a trade-off between write ingestion speed, storage consumption, index build duration, and accuracy (recall). The data below outlines key metrics over standard test datasets of 1536-dimensional embeddings.

Index ConfigurationDataset Size (vectors)Search QPSRecall Rate (99th Pct)Index Build DurationIndex Storage Overhead
Exact Scan (None)100,00045100.0%0 sec0 MB
IVFFlat (lists=100)100,00085088.2%42 sec620 MB
HNSW (m=16, ef=64)100,0004,20098.9%185 sec1.1 GB
Exact Scan (None)1,000,0004100.0%0 sec0 MB
IVFFlat (lists=1000)1,000,00018082.5%480 sec6.2 GB
HNSW (m=24, ef=100)1,000,0002,80099.1%2,400 sec14.8 GB
HNSW (m=32, ef=128)1,000,0003,10099.6%3,900 sec19.5 GB

Note: HNSW indexes achieve high query throughput and recall rates, but they incur a larger storage footprint and take longer to build compared to IVFFlat indexes, which utilize simpler vector quantizers.


What Breaks in Production

IVFFlat Precision Drop

An IVFFlat index partitions vector spaces into list clusters around calculated centroids. If vectors are written to the database after building the index, the spatial distribution of the data will shift, but the centroids will remain fixed.

  • Mechanisms of Failure: As new data changes the spatial density, queries begin scanning clusters that no longer contain the true nearest neighbors. Search recall degrades from > 95% to &lt; 75% without generating system errors or warning logs.
  • Remediation: Establish a routine cron task to rebuild the IVFFlat index using REINDEX CONCURRENTLY as new records accumulate. If the dataset grows dynamically, choose an HNSW index instead. HNSW graphs do not rely on static clusters and adjust dynamically to incremental writes.

Slow Index Build Times Blocking Ingestion

Building HNSW graphs requires computing nearest neighbors for every vector inserted, which is a CPU-intensive task.

  • Mechanisms of Failure: Running CREATE INDEX on a table with more than 100,000 vectors of 1536 dimensions will utilize a full CPU core for hours. Because creating an index locks the table against structural modifications, long builds can block application writes, exhaust connection pools, and degrade database performance.
  • Remediation: Always run CREATE INDEX CONCURRENTLY to avoid blocking writes. In addition, when performing large data migrations, insert the vectors into the database first and build the HNSW index afterward. Building the index on existing data is more efficient than updating the graph structure for every single insert.

High Memory Consumption Under Active Index Builds

Building vector indexes requires loading large volumes of vector coordinates into working memory.

  • Mechanisms of Failure: If maintenance_work_mem is misconfigured or set too low during HNSW creation, the PostgreSQL worker processes are forced to spill temporary graph nodes to disk, which increases index build times by a factor of ten. Conversely, if maintenance_work_mem is set too high (e.g., 8GB on a server with 16GB total memory) and multiple parallel worker threads are running, the host operating system’s Out-Of-Memory (OOM) killer will terminate the PostgreSQL process, causing database downtime.

  • Remediation: Calculate memory allocations based on the system resources and the database configuration:

    Max Workers * maintenance_work_mem < 0.5 * Total RAM

    For example, on a server with 32GB of memory, configure:

SET max_parallel_maintenance_workers = 4;
SET maintenance_work_mem = '3GB';

FAQs

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.

Frequently Asked Questions

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.