Contents

User Guide: SVS Vamana Index and Vector Compression

Table of Contents

  1. Overview
  2. Prerequisites
  3. Quick Start
  4. Vamana Index in Depth
  5. Vector Compression: LeanVec and LVQ
  6. Performance Tuning
  7. Operational Considerations
  8. Monitoring
  9. Troubleshooting
  10. Appendix: Vamana vs. HNSW Comparison
  11. Reference: Parameter Summary

See also: Resource Sizing for Operators, a separate document, and the Known Limitations.


1. Overview

This guide covers the Vamana index access method and the two vector compression schemes — LeanVec and LVQ — that compress Vamana indexes. The svs extension provides them in addition to pgvector’s own search capability, and they are powered by Intel’s Scalable Vector Search (SVS) library.

What is the Vamana Index?

The Vamana index is a graph-based approximate nearest neighbor (ANN) index, similar in concept to HNSW but using a different graph construction strategy (originating from Microsoft Research’s DiskANN). Key properties:

  • High recall at competitive query latencies
  • Dynamic index — the index is built from the initial table data and supports incremental updates: INSERT adds vectors to the live graph immediately via SVSAddPoints, and DELETE + VACUUM removes vectors via SVSDeletePoints with automatic graph consolidation (see Section 7)
  • On-disk persistence — the SVS graph and TID mappings are serialized directly to files on the filesystem (via the SVS save/load API and atomic checkpoint sequence). Both are reloaded automatically on restart

What is Vector Compression?

Two optional compression schemes reduce the memory footprint of a Vamana index.

LeanVec combines:

  1. Dimensionality reduction (similar to PCA) — projects high-dimensional vectors into a lower-dimensional space
  2. Integer quantization — stores the projected vectors in 4-bit or 8-bit integers instead of 32-bit floats

LeanVec is ideal when your vectors have high dimensionality (e.g., 1536D from OpenAI embeddings) and memory is a limiting factor.

LVQ (Locally-adaptive Vector Quantization) quantizes each vector in its original space, with an optional second-level residual. It keeps the full dimensionality, so it needs no projection matrix, and it stores more per vector than LeanVec at the same bit width.


2. Prerequisites

Before enrolling databases, size max_worker_processes and max_parallel_workers for your deployment; see Resource Sizing.

  • The vector (pgvector) and svs extensions (version 0.1.0 or later) installed and enabled:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS svs;
  • Verify the extension version:
SELECT extversion FROM pg_extension WHERE extname = 'svs';
-- Expected: 0.1.0
  • An Intel Xeon processor with AVX-512 is strongly recommended for best performance. The Vamana index runs on x86-64 CPUs from any vendor. LeanVec and LVQ compression are supported only on Intel CPUs; on other CPUs, creating a compressed index fails with an error.

  • Required — Background Workers: Background workers must be running for all Vamana operations (search, INSERT, DELETE+VACUUM). Add svs to shared_preload_libraries in postgresql.conf and restart the server:

  shared_preload_libraries = 'svs'

This is required because the extension registers shared memory segments and a supervisor process (the launcher) at server start — actions that cannot be performed after the server is already running. Without this, no workers start and all index reads and writes will return an error.

  • Required — WAL level: The launcher needs wal_level = logical in postgresql.conf. Without it the launcher stops at startup with vamana launcher requires wal_level = logical, and no worker starts. See Required PostgreSQL settings for the full list of server settings.

  • Required — Enable your database: Each database that hosts Vamana indexes must be enrolled in the vamana_databases table so the launcher spawns a worker for it. Enrollment takes effect immediately, with no restart:

  -- Run this in the launcher's database (default: postgres)
  INSERT INTO vamana_databases (datname) VALUES ('mydb');

See Section 6.1 for the full model. A CREATE INDEX ... USING vamana in a database that is not enabled fails immediately with an actionable error.


3. Quick Start

This end-to-end example creates a table of document embeddings, populates it, builds a Vamana index, and runs a similarity search.

-- 1. Create a table with a 1536-dimensional embedding column
CREATE TABLE documents (
    id      bigserial PRIMARY KEY,
    content text,
    embedding vector(1536)
);

-- 2. Insert some rows (embeddings are produced by your application)
INSERT INTO documents (content, embedding)
VALUES
    ('Introduction to databases', '[0.1, 0.2, ...]'),
    ('Vector similarity search',  '[0.3, 0.1, ...]');

-- 3. Build a Vamana index for cosine-distance similarity
CREATE INDEX documents_vamana_idx
    ON documents
    USING vamana (embedding vector_cosine_ops);

-- 4. Query the five most similar documents to a query vector
SELECT id, content,
       1 - (embedding <=> '[0.2, 0.1, ...]') AS similarity
FROM documents
ORDER BY embedding <=> '[0.2, 0.1, ...]'
LIMIT 5;

Behavioral highlights:

Aspect Behavior
INSERT Vector added to the live graph immediately; next SELECT serves from the updated index
DELETE + VACUUM Vectors soft-deleted, graph edges patched, memory reclaimed when deletion pressure exceeds threshold
REINDEX needed for correctness? No

Edge cases to know about:

  • Indexes are loaded on demand, not eagerly at worker startup. The first SELECT, INSERT, or VACUUM touching a given index after a server restart pays that index’s load cost; subsequent operations serve from the warm cache. To pre-warm deliberately, call svs_warmup_index() or svs_warmup_database() (see Section 6.1).
  • Writes are serialized per database inside the worker. For high-concurrency ingest, batch rows in multi-row INSERT statements from a single backend to reduce contention.
  • Deletes are soft: VACUUM marks entries deleted, patches graph edges, and compacts when deletion pressure exceeds svs.compact_threshold_pct (default 10%).
  • External IDs are never reused for the life of the index. REINDEX resets the counter.

4. Vamana Index in Depth

4.1 Supported Vector Types and Distance Metrics

Vector Type L2 Distance (<->) Inner Product (<#>) Cosine Distance (<=>)
vector vector_l2_ops vector_ip_ops vector_cosine_ops
halfvec halfvec_l2_ops halfvec_ip_ops halfvec_cosine_ops

Maximum dimensions: 2000 for the Vamana index.

Storage format: An uncompressed halfvec index is stored as float16, so it occupies roughly half the resident bytes of the same data in a vector column. Under compression_type 1 or 2 the compressed format governs the stored size for both column types.

NULL vectors: Rows with a NULL value in the indexed column are silently skipped at index build and insert time — they are not added to the graph. A NULL query vector returns zero results immediately without scanning the index.

Zero vectors and cosine distance: A zero vector has no direction, so cosine distance (which normalizes by the vector’s magnitude) produces NaN when either the stored or query vector is all zeros. Avoid storing or querying with zero vectors when using vector_cosine_ops or halfvec_cosine_ops.

4.2 Index Build Parameters

These parameters are specified in the WITH (...) clause of CREATE INDEX and are stored permanently with the index.

Parameter Type Default Range Description
graph_degree integer 64 16 – 256 Maximum number of neighbors per graph node. Higher values improve recall but increase build time and memory.
alpha integer -1 -1 – 200 Graph pruning aggressiveness, stored as alpha × 100 (e.g., 120 = α 1.20). -1 uses the SVS default: 1.2 for L2, 0.95 for inner product and cosine. SVS rejects other values at build time unless they fit the metric: L2 needs 100 or more; inner product and cosine need 100 or less.
build_window_size integer -1 -1 – 1000 Search window used during graph construction. -1 defaults to 2 × graph_degree.
search_window_size integer 100 10 – 10000 Stored with the index but not currently used. The svs.search_window_size GUC (default 100) always sets the search window, at query time and at build time.
use_search_history boolean true — Maintain a visited-node set during graph search. Keeping this enabled (true) improves recall; disabling it may reduce per-query memory usage at some recall cost. Not changeable at query time — must be set at index creation.
compression_type integer 0 0 – 2 0 = none, 1 = LeanVec, 2 = LVQ.
compression_primary integer 4 see §5.2 Primary quantization precision, under either scheme.
compression_secondary integer 8 see §5.2 LeanVec secondary quantization, or the LVQ residual (0 = no residual, LVQ only).
leanvec_dims integer -1 -1 – 2000 Reduced dimensions for LeanVec. -1 = dimensions / 2. Ignored under LVQ.

4.3 Query-Time Parameters

svs.search_window_size is a session-scoped GUC, settable by any role, that can be changed at any time without rebuilding the index. (svs.search_num_threads, the other search-time knob, is a cluster-wide setting, not session-scoped — see §6.1.)

-- Increase the search window for higher recall (at the cost of latency)
SET svs.search_window_size = 200;

-- Restore to the default (100)
RESET svs.search_window_size;

Use SET LOCAL inside a transaction to apply a setting for a single query only:

BEGIN;
SET LOCAL svs.search_window_size = 200;
SELECT id FROM my_table ORDER BY embedding <=> '[...]' LIMIT 10;
COMMIT;
GUC Default Range Description
svs.search_window_size 100 10 – 10000 Number of candidates examined during a search. Higher = better recall, higher latency.

The search_window_size index reloption is not currently used. The GUC always applies. RESET svs.search_window_size returns it to 100, not to a reloption value.

4.4 Creating an Index

Minimal index (all defaults):

CREATE INDEX ON my_table USING vamana (embedding vector_cosine_ops);

Tuned for high recall on large datasets:

CREATE INDEX my_idx
    ON my_table
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 96,           -- denser graph for higher recall
        build_window_size = 192      -- wider search window during build
    );

Parallel index build — SVS manages its own internal thread pool. The requested thread count comes from the vamana_databases.maintenance_num_threads catalog column for the current database, falling back to max_parallel_maintenance_workers when that column is NULL: - With no override and max_parallel_maintenance_workers = 0 (the PostgreSQL default), the build requests 1 thread. - With no override and max_parallel_maintenance_workers set to a positive value, that value is requested.

The requested count is not used directly: the build blocks on a grant from the launcher, which caps it against the database’s CPU budget alongside search’s thread grants. If no grant arrives within svs.worker_timeout_ms (default 5000 ms), CREATE INDEX fails with an error naming the launcher rather than falling back to an ungoverned thread count.

-- No override, max_parallel_maintenance_workers = 0: build requests 1 thread
CREATE INDEX my_idx ON my_table USING vamana (embedding vector_l2_ops);

-- Explicitly request more threads (e.g. in a shared environment)
SET max_parallel_maintenance_workers = 4;
CREATE INDEX my_idx ON my_table USING vamana (embedding vector_l2_ops);

-- Per-database override, independent of the session GUC
UPDATE vamana_databases SET maintenance_num_threads = 4 WHERE datname = current_database();

Note: maintenance_work_mem does not affect Vamana index builds; SVS manages its own memory allocation internally. Build memory is bounded cluster-wide by svs.max_build_memory.

4.5 Running Queries

Vamana is activated automatically when PostgreSQL uses the index for ORDER BY … LIMIT queries:

-- Top-5 nearest neighbors by L2 distance
SELECT id, embedding <-> '[…]' AS distance
FROM my_table
ORDER BY embedding <-> '[…]'
LIMIT 5;

-- Top-10 most similar by cosine similarity
SELECT id, 1 - (embedding <=> '[…]') AS similarity
FROM my_table
ORDER BY embedding <=> '[…]'
LIMIT 10;

-- Inner product similarity (for normalized embeddings)
SELECT id, (embedding <#> '[…]') * -1 AS score
FROM my_table
ORDER BY embedding <#> '[…]'
LIMIT 5;

Confirm the index is being used:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM my_table
ORDER BY embedding <=> '[…]'
LIMIT 5;
-- Look for "Index Scan using my_idx" in the output

5. Vector Compression: LeanVec and LVQ

5.1 How LeanVec and LVQ Work

Without compression, each vector is stored as a contiguous array of 32-bit floats — a 1536-dimensional vector occupies 6 KB. The two schemes cut this down in different ways.

LeanVec (compression_type = 1) reduces the dimensionality first, then quantizes:

Original D-dim float32 vectors
          │
          ▼  Step 1: Dimensionality reduction (PCA-like linear projection)
  Reduced d-dim vectors  (d = leanvec_dims, default D/2)
          │
          ▼  Step 2: Quantization
  Primary quantized vectors  (4-bit or 8-bit integers)
  + Secondary quantized residuals (4-bit or 8-bit integers)

LVQ (compression_type = 2) keeps all D dimensions and quantizes each vector in place, with an optional residual for a second level of precision:

Original D-dim float32 vectors
          │
          ▼  Per-vector quantization in the original D-dim space
  Primary quantized vectors  (4-bit or 8-bit integers)
  + optional residual        (4-bit or 8-bit integers, or none)

LVQ has no projection matrix and no reduced dimensionality, so leanvec_dims does not apply to it. At the same bit width LVQ therefore stores more than LeanVec — D values per vector rather than d — and it discards no dimensions.

Under either scheme the compressed representation is used for candidate filtering during search, and the full graph topology is preserved, so recall is determined by graph_degree and search_window_size as usual.

5.2 Compression Parameters

compression_type

Value Meaning
0 No compression (default)
1 LeanVec (dimensionality reduction + quantization)
2 LVQ (per-vector quantization, optional residual)

Parameters that do not belong to the scheme you selected are ignored, not rejected: leanvec_dims under LVQ, and every compression parameter under compression_type = 0.

compression_primary and compression_secondary

compression_primary accepts exactly four values under either scheme:

Value Data Type Bits per element
4 UINT4 (unsigned 4-bit) 4
-4 INT4 (signed 4-bit) 4
8 UINT8 (unsigned 8-bit) 8
-8 INT8 (signed 8-bit) 8

compression_secondary accepts the same four values, and additionally 0 — meaning no residual — which is valid for LVQ only. LeanVec always has a secondary level, so compression_secondary = 0 is rejected under compression_type = 1.

The defaults are compression_primary = 4, compression_secondary = 8. That pair is valid under both schemes, so naming compression_type alone is enough to get a working index either way.

Which combinations are supported depends on the scheme, because SVS compiles a fixed set of specializations:

Scheme Supported (primary, secondary) by bit width
LeanVec (1) (4, 4), (4, 8), (8, 8)
LVQ (2) (4, 0), (8, 0), (4, 4), (4, 8)

For LeanVec this is the same thing as the rule abs(compression_primary) ≤ abs(compression_secondary): - ✅ primary=4, secondary=4 - ✅ primary=4, secondary=8 - ✅ primary=8, secondary=8 - ❌ primary=8, secondary=4 — invalid

For LVQ an 8-bit primary carries no residual, so compression_primary = 8 requires compression_secondary = 0. Anything outside the table above is rejected at CREATE INDEX time with unsupported LVQ configuration rather than failing inside the library.

Signs: LVQ keeps only the bit count, so -4 and 4 request the same thing and the sign is ignored. Under LeanVec, use unsigned types (4, 8) unless your vectors contain negative values and you want to preserve the sign bit explicitly.

leanvec_dims

Value Meaning
-1 Automatically use original_dimensions / 2
1 to 2000 Explicit reduced dimension count (should be less than the original dimensions; the server does not check this)

More aggressive reduction (smaller leanvec_dims) saves more memory but may reduce recall. The default of D/2 is a good starting point for most embedding models.

This parameter applies to LeanVec only. LVQ quantizes in the original space, so a leanvec_dims given alongside compression_type = 2 is accepted and ignored.

5.3 Memory Savings Estimates

For a 1536-dimensional vector column:

Configuration Bytes per vector Compression ratio (vs raw float32)
No compression (float32) 6,144 1×
UINT8 + UINT8, leanvec_dims=768 ~2,322 ~2.6×
UINT4 + UINT8, leanvec_dims=768 ~1,938 ~3.2×
UINT4 + UINT4, leanvec_dims=768 ~1,170 ~5.3×
UINT4 + UINT4, leanvec_dims=256 ~914 ~6.7×

These are approximate index-side storage estimates. The PostgreSQL heap row storing the full vector is not compressed.

LeanVec stores the primary level at leanvec_dims dimensions and the secondary level at the full dimension D. Per vector it uses about leanvec_dims × primary_bits / 8 + 9 plus D × secondary_bits / 8 + 9 bytes. The 9 bytes in each term are a per-level header.

LVQ keeps all D dimensions. Per vector it uses about D × primary_bits / 8 + 9 bytes, plus D × residual_bits / 8 bytes for the residual (no residual: none). For D=1536: LVQ 8-bit about 1,545 B; LVQ 4-bit about 777 B; 4+4 about 1,545 B; 4+8 about 2,313 B. Alignment padding can add a few bytes.

5.4 Choosing a Compression Configuration

Goal Recommended Configuration
Baseline — minimal memory reduction with negligible accuracy loss compression_type=1, compression_primary=8, compression_secondary=8
Balanced — good memory savings, acceptable recall trade-off compression_type=1, compression_primary=4, compression_secondary=8
Maximum compression — smallest index, higher recall trade-off compression_type=1, compression_primary=4, compression_secondary=4, leanvec_dims=256
Large embeddings (≥2048D), targeting ~2.6× reduction compression_type=1, compression_primary=8, compression_secondary=8, leanvec_dims=-1

The rows above cover LeanVec only. This guide deliberately makes no recommendation between LeanVec and LVQ, or among the LVQ configurations: that trade-off has not been characterized here. §5.2 lists what each scheme accepts; settle the choice by measuring recall and index size on your own data.

Always measure recall on a representative dataset sample before deploying a heavily compressed index in production.

Training Data Requirement

LeanVec and LVQ use the vectors present at index creation time as training data for their compression matrices. Building an index on a table with very few rows produces an index with poor recall (no error is raised).

Recommended minimums before CREATE INDEX: - LeanVec (compression_type = 1): 100,000 rows (10,000 minimum) - LVQ (compression_type = 2): 10,000 rows

A WARNING is emitted at build time if the row count is below these thresholds. To fix, load more data and run REINDEX INDEX.

-- Example: WARNING fires because the table has only 100 rows
CREATE INDEX ON docs USING vamana (embedding vector_cosine_ops)
  WITH (compression_type = 1);
-- WARNING:  building LeanVec index with only 100 vectors;
--           recall may be poor (recommend >= 100000, minimum 10000)

A similar WARNING fires during cold-start rebuilds from the heap. It starts with “rebuilding”, for example “rebuilding LeanVec index with only N vectors…”.

5.5 Creating a Compressed Index

Baseline LeanVec (8-bit, auto dimensions):

CREATE INDEX documents_vamana_leanvec_idx
    ON documents
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 64,
        compression_type = 1,       -- enable LeanVec
        compression_primary = 8,
        compression_secondary = 8
        -- leanvec_dims          = -1 means dimensions/2 (default)
    );

Defaults (4-bit primary, 8-bit secondary, auto dimensions):

CREATE INDEX documents_vamana_default_idx
    ON documents
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 64,
        compression_type = 1        -- enable LeanVec
        -- compression_primary   = 4  (default)
        -- compression_secondary = 8  (default)
        -- leanvec_dims          = -1 means dimensions/2 (default)
    );

High compression (4-bit primary and secondary):

CREATE INDEX documents_vamana_hc_idx
    ON documents
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 64,
        compression_type = 1,
        compression_primary = 4,
        compression_secondary = 4
    );

Mixed precision (4-bit primary, 8-bit residual — balanced accuracy vs. memory):

CREATE INDEX documents_vamana_mixed_idx
    ON documents
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 64,
        compression_type = 1,
        compression_primary = 4,
        compression_secondary = 8
    );

Aggressive dimensionality reduction (explicit leanvec_dims):

-- 1536D embeddings reduced to 256 dimensions, then 8-bit quantized
CREATE INDEX documents_vamana_agr_idx
    ON documents
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 96,
        compression_type = 1,
        leanvec_dims = 256,
        compression_primary = 8,
        compression_secondary = 8
    );

LVQ, 8-bit, no residual (full dimensionality preserved):

CREATE INDEX documents_vamana_lvq8_idx
    ON documents
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 64,
        compression_type = 2,        -- enable LVQ
        compression_primary = 8,
        compression_secondary = 0    -- no residual (required with an 8-bit primary)
    );

LVQ, 4-bit primary with an 8-bit residual:

CREATE INDEX documents_vamana_lvq48_idx
    ON documents
    USING vamana (embedding vector_cosine_ops)
    WITH (
        graph_degree = 64,
        compression_type = 2,
        compression_primary = 4,
        compression_secondary = 8
    );

Because the defaults are (4, 8), the second example is also what a bare WITH (compression_type = 2) produces.


6. Performance Tuning

Recall vs. Latency Trade-offs

The core knob is search_window_size. A larger value scans more candidates and improves recall, but increases query time.

-- Quick experiment: measure recall@10 at three search window sizes
SET svs.search_window_size = 50;   -- fast, lower recall
-- run your benchmark query

SET svs.search_window_size = 100;  -- default
-- run your benchmark query

SET svs.search_window_size = 200;  -- slower, higher recall
-- run your benchmark query

Build Quality vs. Build Time

graph_degree build_window_size Recall Build time
32 64 (2×) Moderate Fast
64 (default) 128 (2×) Good Moderate
96 192 (2×) High Slow

The default build_window_size = -1 gives 2 × graph_degree.

General guidance: - For interactive search (p99 < 10 ms): start with graph_degree=64, search_window_size=100 and tune up. - For batch / offline retrieval: use graph_degree=96 or 128 and higher search_window_size. - LeanVec with 4-bit quantization can require a slightly larger search_window_size to achieve the same recall as uncompressed.

Parallel Builds

SVS manages its own internal thread pool for index builds. The requested thread count comes from the vamana_databases.maintenance_num_threads catalog column for the current database, falling back to max_parallel_maintenance_workers when that column is NULL:

  • With no override, 0 (PostgreSQL default) → the build requests 1 thread.
  • With no override, positive value → that value is requested.

The request is not granted directly: it blocks until the launcher grants it from the database’s CPU budget, alongside search’s grants. If no grant arrives within svs.worker_timeout_ms (default 5000 ms), CREATE INDEX fails naming the launcher rather than degrading to an ungoverned thread count.

-- No override, default 0: build requests 1 thread
CREATE INDEX … USING vamana (…);

-- Request more threads
SET max_parallel_maintenance_workers = 4;
CREATE INDEX … USING vamana (…);

-- Per-database override, independent of the session GUC
UPDATE vamana_databases SET maintenance_num_threads = 4 WHERE datname = current_database();

Note: Unlike HNSW, there is no separate PostgreSQL leader thread. Search threads are governed by the same launcher grant path but through svs.search_num_threads and its ceilings (see §6.1) instead of this setting.

6.1 Background Workers and Per-Database Enablement (Advanced)

The extension serves every Vamana operation through background workers. A worker owns the in-memory SVS graph for all Vamana indexes in one database: all write operations (INSERT, DELETE+VACUUM, consolidate, and compact) and all search requests are submitted to it by backends via shared-memory IPC and executed exclusively by the worker. This avoids per-backend index loads and enables high read concurrency.

The launcher and per-database workers

There is one worker per database, and a single supervisor process — the launcher — that starts, stops, restarts, and tracks them. The launcher is the only process registered at server start; every per-database worker is spawned dynamically. This means you can enable Vamana for a new database, pause it, or remove it entirely by changing a table, with no server restart and no postgresql.conf edit.

postmaster
  └── vamana launcher            (reads vamana_databases)
        └── vamana worker: mydb
        └── vamana worker: appdb
        └── ...

Prerequisite: The launcher only starts when the extension is loaded via shared_preload_libraries. Add the following to postgresql.conf and restart:

shared_preload_libraries = 'svs'

shared_preload_libraries is required because the extension registers shared memory segments and the launcher process at server start. Loading the extension later via CREATE EXTENSION does not start the launcher.

Enabling and disabling a database

Enablement lives in the vamana_databases table in the launcher’s home database (svs.launcher_database, default postgres). The launcher wakes as soon as a change commits. It does not wait for its 180 s fallback poll.

-- Enable Vamana for a database (no restart). A worker is spawned within moments.
INSERT INTO vamana_databases (datname) VALUES ('mydb');

-- Pause it (reversible — see Section 7). The worker checkpoints and stops;
-- on-disk state and the replication slot are kept, idle.
UPDATE vamana_databases SET enabled = false WHERE datname = 'mydb';

-- Resume it. The worker restarts and picks up where it left off.
UPDATE vamana_databases SET enabled = true WHERE datname = 'mydb';

Enrollment reserves the database’s worker slot synchronously at commit, so a CREATE INDEX ... USING vamana issued immediately afterward will not spuriously fail with a “not enabled” error. The worker itself spawns asynchronously a moment later; the first operation against an index waits (bounded by svs.worker_startup_timeout_ms) if the worker is still starting.

Do not use enabled = false then true to bounce a worker. Use svs_restart_worker() instead (below). Two updates in one transaction never produce an intermediate state the launcher can observe, and disabling is the “take this database offline” signal, not a restart primitive.

Restarting a single worker on demand

To bounce one database’s worker without taking the database offline — for example, to force a clean checkpoint and replication-slot advance right now rather than waiting for the debounced checkpoint policy:

Run it in the launcher’s database (svs.launcher_database). Enrollment must run in the launcher database. Rows written to vamana_databases in any other database have no effect.

SELECT svs_restart_worker('mydb');

This drains and cleanly stops the worker (checkpointing cached indexes, best effort, until svs.shutdown_drain_budget_ms runs out) and respawns it. It raises an error if the named database is paused or not configured. Only enabled databases can be restarted. Internally it increments the row’s restart_generation column; the launcher observes the change through its normal reconcile pass and bounces that worker.

Removing a database permanently

Pausing (enabled = false) and removal are different operations. Pausing is reversible and keeps on-disk state; removal is permanent. Removal is a deliberate two-step action — see Section 7 for full details:

-- Step 1, connected to the target database: drop every Vamana index in it.
SELECT * FROM svs_teardown_database();

-- Step 2, connected to the launcher's database: remove the row.
DELETE FROM vamana_databases WHERE datname = 'mydb';

Running step 2 first is rejected: DELETE fails with an actionable hint if Vamana indexes still exist in that database, so on-disk save directories and replication slots are never orphaned.

Warming the cache

Because indexes load on demand, there is otherwise no way to get a hot cache without waiting on real query traffic. Force a warm cache deliberately, at a time you control (e.g. after a planned restart, or in a benchmark’s load phase):

SELECT svs_warmup_index('documents_vamana_idx');  -- one index
SELECT svs_warmup_database();                      -- every Vamana index in this database

svs_warmup_index() errors if the argument is not a Vamana index or no worker is available. svs_warmup_database() is best-effort: per-index failures other than a privilege denial warn and are skipped; an index the caller lacks SELECT on is skipped silently. It returns the number of indexes warmed.

Capacity

svs.max_databases (default 8) sizes the fixed shared-memory array of worker slots at server start. Enabling more databases than this limit fails the INSERT/UPDATE with:

ERROR: cannot enable database "mydb": svs.max_databases (8) already reached
HINT:  Increase svs.max_databases and restart, or disable another database first.

Plan for your expected number of Vamana-enabled databases plus headroom, since changing this limit requires a server restart.

Configuration parameters

Startup GUCs (postgresql.conf, require restart — PGC_POSTMASTER):

GUC Default Description
svs.launcher_database 'postgres' Database the launcher connects to in order to read vamana_databases. Set once at install time. This is the only remaining GUC that names a specific database.
svs.max_databases 8 Maximum number of databases that can run a worker concurrently. Sizes the per-database shared-memory array. Range 1 – 128.
svs.worker_restart_time 5 (s) Seconds the postmaster waits before restarting the launcher if it crashes. -1 disables auto-restart. Governs only the launcher, not per-database workers. Range -1 – 300.

Runtime-tunable GUCs (postgresql.conf or pg_reload_conf() — PGC_SIGHUP, no restart needed):

GUC Default Range Description
svs.search_num_threads 0 0 – 1024 Cluster-wide default search-thread count. 0 = auto (resolves to 1). Overridable per database via the vamana_databases.search_num_threads catalog column. Reload only (PGC_SIGHUP); no session SET.
svs.max_search_threads_per_db 0 0 – 1024 Ceiling on one database’s search-thread grant. 0 = follow max_parallel_workers. Reload only (PGC_SIGHUP); no session SET.
svs.max_total_search_threads 0 0 – 1024 Cluster-wide ceiling on search threads summed across all databases. 0 = follow max_parallel_workers; never exceeds it regardless of this setting. Reload only (PGC_SIGHUP); no session SET.
svs.worker_startup_timeout_ms 60000 1000 – 300000 Milliseconds a backend waits for a worker that is enabled but not yet started before returning an error.
svs.worker_timeout_ms 5000 100 – 60000 Milliseconds a backend waits for a worker IPC response before returning an error.
svs.worker_restart_backoff 1000 (ms) 100 – 300000 Base delay before the launcher respawns a crashed per-database worker, applied with escalating backoff on repeated crashes of the same database’s worker. The delay doubles on each repeated crash, up to 60000 ms. It resets after the worker stays up for 10 s.
svs.max_batch_size 0 0 – 1000 Maximum queries per SVS batch call. 0 = use MaxBackends.
svs.shutdown_drain_budget_ms 30000 0 – 600000 Time budget for the worker’s shutdown drain, checked between indexes. A single in-progress checkpoint is not preemptible, so the real bound is this budget plus one checkpoint’s worst case.
svs.worker_stop_timeout_ms 30000 0 – 600000 Milliseconds the launcher waits for a restarting worker to report stopped before giving up. Does not force-kill; the restart stays pending until the worker exits naturally.

svs.worker_restart_time and svs.worker_restart_backoff govern two different restart policies deliberately: a fixed interval for the launcher itself (via the postmaster’s native mechanism), and escalating backoff for per-database workers (a worker crash-looping on a corrupted index or an out-of-memory condition should not be respawned every second forever). They are not interchangeable.

Crash handling: If a per-database worker crashes, the launcher detects it near-instantly and respawns it, backing off exponentially on repeated crashes. If the launcher itself crashes, the postmaster restarts it, and it re-derives the full worker set from vamana_databases.

Per-database thread overrides (vamana_databases columns, take effect immediately, no reload needed):

Column Default Range Description
search_num_threads NULL 1 – 1024 This database’s own search thread count, overriding svs.search_num_threads. NULL = use the cluster-wide default.
search_threads_reserved NULL 0 – 1024 A floor on this database’s search-thread grant that the launcher’s elastic-remainder sharing will not take below, even under cluster-wide contention. NULL = no floor; this database competes purely on a best-effort basis.
maintenance_num_threads NULL 0 – 1024 This database’s own build thread request, overriding max_parallel_maintenance_workers for CREATE INDEX/REINDEX in this database. NULL = follow max_parallel_maintenance_workers; 0 = request 1 thread (serial), matching core’s own max_parallel_maintenance_workers = 0 semantics.

Both search_num_threads and search_threads_reserved feed the same launcher grant calculator as the cluster-wide GUCs; setting one does not bypass svs.max_search_threads_per_db or svs.max_total_search_threads.


7. Operational Considerations

Inserts, Updates, and Deletes

INSERT operations are applied incrementally to the live index graph. New rows are searchable immediately without REINDEX:

INSERT INTO documents (content, embedding) VALUES ('new doc', '[0.4, 0.5, ...]');
-- The new row is immediately searchable
SELECT id FROM documents ORDER BY embedding <=> '[0.4, 0.5, ...]' LIMIT 5;

UPDATE of an indexed vector column is handled as a DELETE + INSERT internally by PostgreSQL. The old vector is removed from the graph and the new vector is added.

DELETE removes heap rows but does not immediately update the index graph. Run VACUUM to remove deleted vectors from the index:

DELETE FROM documents WHERE id = 42;
VACUUM documents;  -- removes deleted vectors from the index graph

VACUUM performs three operations on the index: 1. Identifies dead TIDs and calls SVSDeletePoints to soft-delete them from the graph 2. Runs SVSConsolidate to patch graph edges around deleted entries 3. If more than 10% of vectors have been deleted, runs SVSCompact to reclaim memory

REINDEX rebuilds the entire index from scratch. Use it when you want a clean graph after a large number of inserts and deletes:

REINDEX INDEX CONCURRENTLY documents_vamana_idx;

Cold-cache behavior: Indexes are loaded on demand, not eagerly at worker startup. The worker becomes available as soon as it connects to its database; the first operation touching a given index loads that index from its on-disk save (replaying any committed operations from the replication slot since the last checkpoint) before that operation proceeds. Subsequent operations serve from the warm cache. An operation that arrives while the worker is still starting waits until the worker signals it is ready, bounded by svs.worker_startup_timeout_ms. If no saved copy exists on disk, the worker rebuilds that index from the heap on first access. To warm a cold cache deliberately rather than on first query, use svs_warmup_index()/svs_warmup_database() (see Section 6.1).

Pausing vs. Removing a Database

Disabling a database and removing it are different operations with different, well-defined effects. A DBA reasoning about “is it safe to leave this database like this” needs to know exactly what survives each:

Pause (enabled = false) Permanent removal (teardown + DELETE)
Worker process Stopped Stopped
On-disk save directory Kept Deleted
Replication slot Kept, idle, caught up Dropped
Catalog row Kept Removed
Resumable? Yes — re-enable and the worker resumes No — indexes must be rebuilt

Pause means “stop serving this database for now, but I may come back”: a maintenance window, a cost-driven scale-down, a database quiet on weekends. When you set enabled = false, the worker checkpoints cached indexes (flushing to disk and advancing the replication slot), within the time limit svs.shutdown_drain_budget_ms, then stops. An index not reached in that time keeps its last checkpoint, and this is logged. At rest, no worker runs; the replication slot is present and inactive. Re-enabling later replays the changes since each index’s last checkpoint, which is little or nothing for indexes that were checkpointed. Nothing ever escalates a pause into removal — a long-paused database stays exactly paused until you act.

While paused, any query or write against a Vamana index in that database fails at once with an error that says the database is not enabled, rather than queuing quietly.

Permanent removal means “I am done with Vamana in this database.” It is a deliberate two-step action, run in the two databases it affects (PostgreSQL has no cross-database DDL):

-- Step 1, connected to the target database: drops every Vamana index in it,
-- deleting each index's on-disk save directory and dropping its replication slot.
SELECT * FROM svs_teardown_database();

-- Step 2, connected to the launcher's database: stops the launcher running a
-- worker for it.
DELETE FROM vamana_databases WHERE datname = 'mydb';

svs_teardown_database() runs with your own privileges (not elevated), so it can only drop indexes you own; it returns one row per index reporting whether each was dropped and, if skipped, why. It does not abort on the first index you cannot drop — it continues and reports the mixed result. Each failed drop also emits a WARNING to the server log (mirroring the reason column of that row), so failures are visible in logs as well as in the result set.

Order matters, and the wrong order is rejected. Running step 2 first — DELETE while Vamana indexes still exist — fails immediately:

ERROR: cannot remove "mydb" from vamana_databases: 3 vamana index(es) still exist in that database
HINT:  run svs_teardown_database() in "mydb" first

This check prevents most orphaned save directories and replication slots. It reads a shared-memory counter, so a concurrent CREATE INDEX can pass it.

Forgetting step 2 is safe, not harmful. If you run teardown but never remove the row, the real cleanup already happened; what remains is only an idle worker serving zero indexes. It is wasteful (it holds one worker slot) but harmless, and it is visible in pg_stat_vamana_worker (index_count = 0). The launcher deliberately does not auto-remove the row, since a database with zero indexes right now may simply be between CREATE INDEX calls.

TRUNCATE vamana_databases is revoked from PUBLIC. A trigger also rejects it for every role, including the owner and superusers, while Vamana indexes exist in any listed database (enabled or not). The error has the same hint as DELETE. Use the two-step removal per database instead.

TRUNCATE

TRUNCATE rebuilds every index on the table, including any Vamana index, via the same path ambuild uses — no manual REINDEX is needed:

TRUNCATE documents;
-- documents_vamana_idx is already empty and searchable; no REINDEX required

Typical Deployment Flow

-- 1. Enable this database for Vamana (run in the launcher's database).
--    Skip if already enabled. A worker is spawned within moments.
INSERT INTO vamana_databases (datname) VALUES ('mydb')
    ON CONFLICT (datname) DO NOTHING;

-- 2. Build the index
CREATE INDEX CONCURRENTLY docs_vamana ON docs USING vamana (embedding vector_cosine_ops);

-- 3. Warm the worker cache before first INSERT (optional)
SELECT svs_warmup_index('docs_vamana');

-- 4. Begin serving writes
INSERT INTO docs (embedding) VALUES ('[...]');

Tuning svs.compact_threshold_pct

The percent-deleted threshold that triggers SVSCompact during VACUUM cleanup. Default 10. Range 0–100.

  • 0: compact on every VACUUM with any pending deletes. Tightest memory footprint; highest VACUUM cost.
  • 10 (default): compact once deleted entries exceed 10% of live vectors. Balances memory and VACUUM cost.
  • 100: disable compact entirely. Consolidate still runs (graph recall is preserved) but memory for deleted slots is never reclaimed without REINDEX.

Lower for memory-constrained deployments with heavy delete churn. Raise for write-heavy workloads where VACUUM latency matters more than memory. Change takes effect on the next VACUUM — no restart required.

This GUC also bounds how much of a database’s residency budget a rolled-back INSERT can leave uncredited before a compact is forced to reclaim it, so it is superuser-only (SET requires superuser or a role granted the parameter’s SET privilege). It is not settable by ordinary roles.

When to REINDEX

You do not need periodic REINDEX for correctness. You might still choose to REINDEX if:

  • Autovacuum has not run for an extended period and the deleted fraction is unusually high.
  • You have observed measurable recall drop in production after a long write horizon. Vamana graphs accumulate a small amount of topological debt over many incremental inserts; a fresh build produces the optimal graph for the current data.
  • You need to change a build-time parameter (graph_degree, compression_type, etc.).

VACUUM

VACUUM reclaims space from deleted heap rows and removes deleted vectors from the Vamana index graph. Run it regularly:

VACUUM ANALYZE documents;

Disk Space for On-Disk Persistence

The Vamana index graph and TID mappings are serialized directly to files on the filesystem. Disk space usage approximately scales as:

Disk ≈ N × (graph_degree + 1) × 4 bytes (graph, padded to full degree)
     + N × bytes_per_vector (see Section 5.3)

Without compression, bytes_per_vector is D × 4 for vector and D × 2 for halfvec.

PostgreSQL Restart / Crash Recovery

After a restart or crash, the launcher re-reads vamana_databases and respawns a worker for each enabled database. Each worker loads an index on first access: it reads the last checkpoint from disk and replays any committed operations recorded in the replication slot since that checkpoint before serving the first operation against that index. Because replay happens before that operation proceeds, the index is fully consistent by the time the first query or insert against it completes. There is no data loss for committed transactions. (Loading is per-index and on demand, so startup no longer scales with the number or size of indexes; see Section 6.1.)

Security and Deployment Notes

This section describes security-relevant deployment considerations that operators should be aware of.

On-Disk Index Protection

The serialized SVS index directory ($PGDATA/vamana_indexes/<dboid>/<relid>/) contains raw embedding vectors. Its confidentiality and integrity follow PostgreSQL’s $PGDATA permission posture, which the server derives from the mode of $PGDATA itself at startup — not from the umask of whoever started it. The extension states the modes it controls explicitly at the point of creation:

  • Directories (vamana_indexes/, vamana_indexes/<dboid>/ and vamana_indexes/<dboid>/<relid>/) are created through MakePGDirectory, which applies pg_dir_create_mode: 0700, or 0750 on a cluster initialized with group access (initdb -g).
  • The TID map (tidmap.bin) is created with pg_file_create_mode — 0600, or 0640 with group access — and that mode is applied to the file descriptor before any data is written, so the published file’s mode is exactly the requested one rather than a umask-reduced approximation of it. There is no execute bit in either case.
  • The SVS payload (the config/, data/ and graph/ subdirectories and their contents): the extension creates the three subdirectories with pg_dir_create_mode. The Intel SVS library creates the files inside them, and its save call takes no mode from the extension. The library requests 0666 for the files. They still land at 0600 (0640 with group access), but only because the server’s process umask is pg_mode_mask — the extension does not enforce their mode itself.

Temporary files are removed as soon as they are no longer needed. tidmap.bin is published by writing tidmap.bin.tmp and renaming it into place; the temp file is removed on every error path, and each save removes a leftover temp file before it creates a new one. A load removes a leftover only if it is older than the postmaster start, so a temp file left by a crash during the current server run stays until the next save of that index.

Important: A separately-encrypted tablespace volume does not protect the Vamana index files. The index relation’s own pages, including the metapage, follow the requested tablespace (ALTER INDEX ... SET TABLESPACE, CREATE INDEX ... TABLESPACE, default_tablespace). The serialized graph and vectors always go under $PGDATA/vamana_indexes/, whatever tablespace you choose. pg_class reports the chosen tablespace, but the vector data does not live there.

Operators who require encryption-at-rest for embedding data should use full-disk or volume-level encryption on the filesystem backing $PGDATA.

WAL Configuration Requirement

Vamana requires wal_level = logical in postgresql.conf. If wal_level is lower (replica or minimal), the launcher emits FATAL at startup. The postmaster restarts it after svs.worker_restart_time seconds (default 5), and it fails again. No worker starts. All Vamana index operations (search, insert, delete) fail with “background worker unavailable” errors until you correct the setting and restart the server.

# Required in postgresql.conf
wal_level = logical

Standby Replication Slot Configuration

On a standby server using a Vamana replication slot, hot_standby_feedback must be set to on:

# Required on standby servers with Vamana indexes
hot_standby_feedback = on

If hot_standby_feedback = off, the primary may remove WAL segments that the standby’s Vamana replication slot still needs. PostgreSQL silently invalidates the slot and emits only a WARNING in the standby’s server log. After invalidation, the standby BGW cannot replay committed changes, and a full index rebuild from the heap is required the next time the standby BGW restarts. Monitor standby logs for replication slot ... has been invalidated warnings.

vamana_databases Access

INSERT, UPDATE, DELETE, and TRUNCATE on vamana_databases are all revoked from PUBLIC; only the table owner (or a superuser) can change which databases run a Vamana worker.

Owning vamana_databases is superuser-equivalent in effect. The extension can only be created by a superuser, so the table starts out superuser-owned, but nothing stops a superuser from later handing it to an ordinary role (ALTER TABLE ... OWNER TO, REASSIGN OWNED) or granting that role INSERT, UPDATE, or DELETE on it. The copy in svs.launcher_database controls the whole cluster, not just the writer’s own database. A role that can write it can:

  • start a Vamana worker in any database, by name, including one the role cannot connect to; the worker runs as the bootstrap superuser;
  • pause, restart, or remove the worker of any enrolled database, making its Vamana indexes unavailable;
  • divide the cluster-wide search-thread, build-thread, residency-memory, and search-memory pools among databases, including reserving thread floors that take capacity from every other database.

The owner can additionally disable the table’s validation triggers and CHECK constraints, and can attach its own triggers, which then run with the privileges of whichever role next writes the table, including a superuser running svs_restart_worker(). Treat ownership of, and DML on, vamana_databases as you would superuser: do not transfer or grant either to a non-superuser role.

Because writes to this table carry that much weight, they are worth auditing. vamana_databases is written rarely (install time, enabling or disabling a database, policy changes), which makes it a good fit for pgaudit’s object-level auditing: grant a dedicated audit role privileges on vamana_databases and set pgaudit.role to it, so every write is logged regardless of who makes it. Prefer that over a blanket log_statement = 'mod', which is cluster- or session-wide rather than table-scoped and would also log every other table’s writes, which is noisy on any database with real traffic elsewhere.

Any role with CONNECT privilege can call pg_notify('vamana_databases_changed', '') directly: this is not a privilege escalation, since the payload is empty, and the launcher only wakes up and re-reads vamana_databases under its own privileges, so an unprivileged NOTIFY cannot change which databases are enabled.

The table also enforces CHECK constraints on its per-database resource columns: graph_memory_mb > 0, search_num_threads BETWEEN 1 AND 1024, residency_memory > 0, search_work_mem > 0, search_threads_reserved BETWEEN 0 AND 1024, and maintenance_num_threads BETWEEN 0 AND 1024. These reject out-of-range values at write time (a NULL means “use the GUC default”), so a bad row cannot reach the launcher.

Beyond those CHECK constraints, two triggers reject writes based on live state rather than the value alone. Lowering residency_memory on an UPDATE is rejected if the new value is below the database’s currently committed resident bytes — read from shared memory if its worker is live, or from svs_index_residency if it isn’t, so a worker that’s down doesn’t let a shrink through that would fail the next reload. Any INSERT or UPDATE is rejected if the resulting cluster-wide sum of search_work_mem would exceed svs.max_search_work_mem. The sum counts all rows, including disabled databases, and a NULL counts as svs.default_search_work_mem.


8. Monitoring

8.1 Build Progress

-- Monitor index build progress (poll every few seconds)
SELECT phase, blocks_done, blocks_total
FROM pg_stat_progress_create_index
WHERE relid = 'documents'::regclass;

blocks_done and blocks_total come from the heap scan. Vamana does not set tuples_done and tuples_total.

Phases: - initializing — setting up build state - loading tuples — buffering vectors from heap

8.2 Worker Status

pg_stat_vamana_worker reports one row per enabled database — including databases whose worker is starting, backing off after a crash, or serving zero indexes — mirroring how pg_stat_replication shows slots regardless of transient state:

SELECT (SELECT datname FROM pg_database WHERE oid = db_oid) AS datname,
       worker_pid, worker_state, index_count, heartbeat_ts
FROM pg_stat_vamana_worker;
Column Meaning
db_oid OID of the enabled database
worker_pid The worker’s PID, or NULL if no worker is currently running (starting, or crashed and awaiting respawn)
worker_state running, starting, backoff (crash backoff), unresponsive (the worker process is alive but its heartbeat is stale), or replica (this server is in recovery, serving a hot standby)
index_count Number of Vamana indexes in that database. 0 flags a database that may no longer need a worker (see Section 7). NULL on a standby, where the counter is not maintained.
evict_all true while the worker is draining every cached index (e.g. during a restart or shutdown); normally false
heartbeat_ts Last time the worker updated its heartbeat
residency_bytes_committed Live shared-memory total of resident index bytes for this database; NULL if the database has never been admitted
residency_bytes_reclaimable SVS memory held by aborted inserts, above the committed total and not yet compacted; NULL if the database has never been admitted
build_bytes_committed Live shared-memory total reserved for in-progress CREATE INDEX/REINDEX builds; NULL if the database has never been admitted
residency_memory_limit The resolved residency budget in effect for this database (its residency_memory override, or svs.default_residency_memory); NULL if the database has never been admitted
residency_drift residency_bytes_committed minus the durable sum from svs_index_residency; nonzero briefly during a load/unload race, persistently nonzero is a bug
search_work_mem_limit The resolved per-query search-scratch budget in effect for this database
search_scratch_bytes_in_flight Shared-memory total of search-scratch bytes currently reserved for in-flight queries in this database; resets to 0 on worker restart
search_threads_desired The thread count this database’s search would use with no contention, before any cluster-wide sharing is applied
search_threads_granted The thread count the launcher has actually granted this database’s search, after floors and elastic-remainder sharing across all enabled databases; what search uses right now
search_threads_reserved This database’s reserved search-thread floor (vamana_databases.search_threads_reserved, or 0/no floor if unset)
search_slots_registered Number of parked search-thread slots currently registered for this database, used to realize search_threads_granted as real, PostgreSQL-visible parallel workers; 0 until the database has at least one vamana index
max_search_threads_per_db The resolved per-database ceiling in effect (svs.max_search_threads_per_db, or the per-database override)

pg_stat_vamana_worker_slot reports one row per worker request slot per visible database — including idle (empty) slots, not just in-flight ones — for finer-grained diagnosis of what a worker is currently processing (slot_status, slot_kind, index_relid, error_message, search_scratch_bytes_per_query).

Cross-database visibility: both views are cluster-wide. An ordinary user sees only their own database’s rows; a pg_read_all_stats member sees all databases. When you care about the current database only, filter by db_oid = (SELECT oid FROM pg_database WHERE datname = current_database()).

8.3 Server Log Messages

The extension emits LOG-level messages to the PostgreSQL server log during long operations. These appear in the server log (not in the client session) and help diagnose what is happening during slow queries or builds.

Situation Log message
Index load triggered (first access / after restart) loading vamana index <oid>, with the save directory path on the accompanying DETAIL: line
TID map being loaded vamana index <oid>: loading TID map for N vectors (M slots total)
Index fully loaded from disk vamana index <oid> loaded from disk (N vectors, capacity M)
No saved copy on disk; rebuilding from table vamana worker: no saved copy for index <oid>, rebuilding
Rebuild started rebuilding vamana index from table data
Rebuild scan progress (every 100,000 tuples) vamana index <oid>: scanning table, N vectors collected

To see these messages in psql, ensure your client is connected to a session where client_min_messages = log (not the default). They are always written to the PostgreSQL log file regardless of client settings.

File system paths and library diagnostics are attached as log-only detail, so they appear on the DETAIL: line in the server log but are never sent to a client session at any client_min_messages setting. Read the server log file when you need the path.

-- Temporarily surface LOG messages in the client session
SET client_min_messages = log;
SELECT id FROM my_table ORDER BY embedding <=> '[…]' LIMIT 1;
RESET client_min_messages;

8.4 Index Size

SELECT pg_size_pretty(pg_relation_size('documents_vamana_idx')) AS index_size;

-- Compare compressed vs uncompressed
SELECT indexname,
       pg_size_pretty(pg_relation_size(indexname::regclass)) AS size
FROM pg_indexes
WHERE tablename = 'documents';

8.5 Inspect Index Options

-- View stored reloptions for a Vamana index
SELECT relname, reloptions
FROM pg_class
WHERE relname = 'documents_vamana_idx';

8.6 Write and Vacuum Activity

What How
Live vs. deleted vector count No SQL-level view currently; monitor via server log messages below.
Worker state per database Query pg_stat_vamana_worker (see Worker Status above).
Write serialization pressure vamana_index_rwlock is only held inside the worker process, not by backends; backends instead block on their own IPC slot latch. Query pg_stat_vamana_worker_slot for rows with slot_status = 'pending' or 'processing' to see queued work.
Rebuild frequency Look for rebuilding vamana index from table data in server logs. A healthy dynamic index sees this only on worker cold start after a restart or REINDEX.
Checkpoint activity Set log_min_messages = debug1 and look for “checkpoint complete, slot advanced to”. The message is DEBUG1, so a default log does not show it. Infrequent checkpoints indicate low write volume.

9. Troubleshooting

For behavior that is a design limit and not a fault, see Known Limitations.

9.1 Symptom: index not being used by the planner

Cause: The Vamana index only supports ORDER BY … LIMIT queries (nearest-neighbor scans). PostgreSQL will use a sequential scan instead if: - There is no LIMIT clause - The query does not use a supported distance operator (<->, <#>, <=>) - The planner estimates a sequential scan is cheaper (common on small tables)

Fix: Run EXPLAIN and look for “Index Scan using …”; if it is absent, the index is not being used:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM my_table ORDER BY embedding <=> '[...]' LIMIT 10;

Then force index use to confirm it works, and investigate the planner’s cost estimate:

SET enable_seqscan = off;
SELECT id FROM my_table ORDER BY embedding <=> '[...]' LIMIT 10;
RESET enable_seqscan;

9.2 Error: invalid compression_primary value: X

Cause: compression_primary is not one of the four allowed values.
Fix: Use only 4 (UINT4), -4 (INT4), 8 (UINT8), or -8 (INT8).

9.3 Error: invalid compression_secondary value: X

Cause: Same as above. Note that 0 — no residual — is additionally allowed under compression_type = 2, and only there.
Fix: Use 4, -4, 8, -8, or, for LVQ only, 0.

9.4 Error: compression_primary (8-bit) cannot have higher precision than compression_secondary (4-bit)

Cause: abs(compression_primary) > abs(compression_secondary) under LeanVec.
Fix: Ensure primary precision ≤ secondary precision, e.g., primary=4, secondary=8.

9.5 Error: unsupported LVQ configuration: 8-bit primary with 8-bit residual

Cause: The (compression_primary, compression_secondary) pair is not one SVS compiles an LVQ specialization for. The supported pairs, by bit width, are (4, 0), (8, 0), (4, 4) and (4, 8) — so an 8-bit primary must be paired with compression_secondary = 0.
Fix: Either drop the residual (compression_secondary = 0) or drop the primary to 4 bits.

9.6 Error: LeanVec compression is not supported on this CPU / LVQ compression is not supported on this CPU

Cause: LeanVec and LVQ compression are supported only on Intel CPUs. SVS checks the CPU vendor at build/load time, so other vendors fail even if the CPU supports AVX-512. SVS refuses rather than running on an unsupported code path. This is common when an index built on an Intel host is loaded on a host with a different CPU.
Fix: REINDEX with compression_type = 0 (no compression), or run this database on an Intel CPU.

A CREATE INDEX/REINDEX that hits this directly on the backend shows the full error, including the hint above. The same failure reached through the background worker (an on-disk load, a first insert into an empty-table index, or a heap rebuild) shows only the message naming the compression type; the worker’s error channel to the backend does not carry the hint. This is an existing limitation of that channel, not specific to this error.

9.7 Error: value X out of bounds for option "compression_type"

Cause: compression_type is outside [0, 2].
Fix: Use 0 (none), 1 (leanvec), or 2 (lvq).

9.8 Error: insert into index "X" would exceed svs.undo_log_max_entries

Cause: The current transaction has recorded svs.undo_log_max_entries undo entries (one per Vamana index insert, entries from a subtransaction rolled back with ROLLBACK TO SAVEPOINT are removed from the count, provided the worker is available) and the next insert would exceed it. This is a dedicated, budget-independent cap on per-transaction undo memory, separate from svs.max_residency_memory.
Fix: Commit in smaller batches, or have a superuser raise svs.undo_log_max_entries for the session (SET svs.undo_log_max_entries = ...) or cluster-wide (postgresql.conf + reload).

9.9 Symptom: unexpected recall with large leanvec_dims

Cause: leanvec_dims is not validated against the actual vector dimension at index creation time. If you set leanvec_dims to a value ≥ your vector dimension, the SVS library will receive that value and may behave unexpectedly. Fix: Set leanvec_dims to a value strictly less than your vector dimension, or use -1 for automatic (dimensions / 2).

9.10 Symptom: new rows not appearing in search results after INSERT

Cause: The database’s worker must be running before it can accept inserts. If no worker is available, the backend returns an ERROR rather than silently dropping the insert. The two common reasons are: the database is not enabled in vamana_databases, or the worker is still starting after a restart. Fix: Confirm the database’s worker is running (SELECT worker_state FROM pg_stat_vamana_worker WHERE db_oid = (SELECT oid FROM pg_database WHERE datname = current_database())) — if there is no row at all, the database is not enabled in vamana_databases (checking the row directly requires the table owner or a superuser, since SELECT on vamana_databases is not granted to PUBLIC). Ensure shared_preload_libraries = 'svs'. If the server just restarted, the first operation waits until the worker finishes startup (controlled by svs.worker_startup_timeout_ms); if the timeout is exceeded, increase it or investigate slow startup.

9.11 Symptom: low recall after compression

Cause: Aggressive compression (small leanvec_dims or 4-bit quantization) can reduce recall.
Fix: Increase search_window_size at query time, or rebuild with less aggressive compression settings (larger leanvec_dims, or switch to 8-bit).

9.12 Symptom: slow first query after restart

Cause: Indexes load on demand, so the first query against a given index after a restart pays that index’s load-and-replay cost. This is per index, not a single upfront load of the whole database. Fix: Warm the cache deliberately after the server is ready, rather than paying the cost on the first real query:

SELECT svs_warmup_database();   -- warm every Vamana index in this database
-- or a single index:
SELECT svs_warmup_index('documents_vamana_idx');

9.13 Symptom: INSERT does not appear in a SELECT from another session

Cause: All inserts go through the worker synchronously — the inserting session blocks until the worker ACKs. If a SELECT in another session does not return the inserted row, the most likely cause is that this database’s worker is not running.

Check: is this database’s worker running? sql SELECT worker_state, worker_pid FROM pg_stat_vamana_worker WHERE db_oid = (SELECT oid FROM pg_database WHERE datname = current_database());

Fix: ensure the database is enabled in vamana_databases and its worker_state is running. If the worker is running and rows are still missing, force a rebuild with REINDEX INDEX CONCURRENTLY <idx>.

9.14 Symptom: SELECT returns fewer than LIMIT rows after many DELETEs

Cause: Deleted vectors are soft-deleted and filtered at query time. The returned count can be less than k until VACUUM runs consolidation. Fix: Run VACUUM to trigger consolidate. If underfill persists after VACUUM, check server logs for SVS dynamic consolidate failed or BGW consolidate failed during vacuum cleanup warnings. If consolidation keeps failing, run REINDEX INDEX CONCURRENTLY <idx>.

9.15 Symptom: INSERT throughput drops at high concurrency

Cause: One worker serves every Vamana index in a database, so writes are serialized per database inside the BGW. Throughput is bounded by single-threaded SVS add.

Fix: - Batch rows in multi-row INSERT statements from a single backend. - Splitting into partitions in the same database does not spread write load, because they share one worker. To scale writes, place independent datasets in separate databases (one worker each).

9.16 Warning: SVSAddPoints failure in server log

Cause: SVS rejected the add. The INSERT fails with ERROR: vamana worker insert failed: ... and the statement aborts. The server log also has WARNING: SVS dynamic add points failed.
Fix: Read the SVS error text on the DETAIL: line that follows the WARNING. Correct the cause it names.

9.17 Warning: vamana index "X": background worker unavailable; dead vector removal skipped during VACUUM

Cause: VACUUM ran while the database’s worker was not available (e.g., immediately after a server restart, or while the worker is backing off after a crash). Dead vectors accumulate in the index graph until the worker is running again and VACUUM runs again; this is a temporary recall-quality issue, not a correctness issue.
Fix: Verify the database is enabled and its worker_state is running (pg_stat_vamana_worker), then run VACUUM again. If the warning persists, check server logs for startup errors.


10. Appendix: Vamana vs. HNSW Comparison

If your workload already uses the HNSW index, this table helps you decide whether to migrate to Vamana.

Feature HNSW Vamana
Graph structure Hierarchical multi-layer Single-layer
Memory (compressed) No built in graph compression Reduced with LeanVec or LVQ (see §5.3)
Parallel build model PostgreSQL worker processes SVS-managed OS threads
Build memory limit maintenance_work_mem svs.max_build_memory (maintenance_work_mem has no effect)
Incremental inserts update graph Yes Yes (via SVS dynamic API)
Intel AVX-512 optimization No Yes
Supported distance metrics L2, inner product, cosine, L1 L2, inner product, cosine
Max dimensions 2000 (vector), 4000 (halfvec) 2000 (vector and halfvec)

Benchmarks

For benchmark results, see the project wiki.

Parameter Migration from HNSW

HNSW parameter Vamana equivalent Notes
m graph_degree Max neighbors per node. HNSW default 16, range 2 to 100; Vamana default 64, range 16 to 256. Do not copy the value directly
ef_construction build_window_size Search window during build
hnsw.ef_search (GUC) svs.search_window_size (GUC) Runtime query beam width. HNSW default 40, range 1 to 1000; Vamana default 100, range 10 to 10000

11. Reference: Parameter Summary

Index Build Parameters (CREATE INDEX … WITH (…))

Parameter Default Min Max Notes
graph_degree 64 16 256 Max neighbors per node
alpha -1 -1 200 Pruning factor × 100; -1 = SVS default (1.2 for L2, 0.95 for inner product and cosine). L2 needs 100 or more; inner product and cosine need 100 or less
build_window_size -1 -1 1000 Build search window; -1 = 2×graph_degree
search_window_size 100 10 10000 Stored with the index but not currently used; the svs.search_window_size GUC always sets the search window (see section 4.2)
use_search_history true — — Maintain visited-node set during search; improves recall
compression_type 0 0 2 0=none, 1=leanvec, 2=lvq
compression_primary 4 -8 8 One of: 4, -4, 8, -8 (the server checks the allowed set at build time)
compression_secondary 8 -8 8 One of: 4, -4, 8, -8, plus 0 (no residual, LVQ only). LeanVec: ≥ primary precision. LVQ: pairs (4,0), (8,0), (4,4), (4,8) only
leanvec_dims -1 -1 2000 Reduced dims; -1 = dimensions/2. LeanVec only

Session GUC Parameters (SET …)

GUC Default Min Max Notes
svs.search_window_size 100 10 10000 Governs query-time search window size; always takes effect (the search_window_size index reloption is not used as a fallback in the current implementation)

Superuser-Settable GUC Parameters (SET …, superuser only; also settable in postgresql.conf + reload)

GUC Default Min Max Notes
svs.undo_log_max_entries 1048576 16 16777216 Per-transaction cap on undo-log entries recorded for the BGW write path. Counted per index insert, not per heap row: a table with two Vamana indexes uses two entries per row. Bounds per-backend undo memory to roughly entries * 40 bytes, independent of residency and build-memory budgets. A non-superuser cannot raise it, but is still bound by it.
svs.compact_threshold_pct 10 0 100 Percent-deleted threshold that triggers SVS compact during VACUUM; 0 = compact on every VACUUM with pending deletes, 100 = disable compact. It also bounds database-wide residency accounting, not just the caller’s own session

Server GUC Parameters (require restart — postgresql.conf only, PGC_POSTMASTER)

These GUCs are PGC_POSTMASTER only when svs is in shared_preload_libraries. Otherwise they are PGC_SIGHUP.

GUC Default Notes
svs.launcher_database 'postgres' Database the launcher reads vamana_databases from; set once at install time
svs.max_databases 8 Max databases that can run a worker concurrently (range 1–128); sizes the shared-memory slot array
svs.worker_restart_time 5 (s) Postmaster wait before restarting the launcher after a crash; -1 disables. Range -1–300

Runtime-tunable Server GUC Parameters (PGC_SIGHUP — reload with SELECT pg_reload_conf())

GUC Default Min Max Notes
svs.search_num_threads 0 0 1024 Cluster-wide default search-thread count; 0 = auto (resolves to 1). Overridable per database via vamana_databases.search_num_threads. Reload only; no session SET
svs.max_search_threads_per_db 0 0 1024 Ceiling on one database’s search-thread grant; 0 = follow max_parallel_workers. Reload only; no session SET
svs.max_total_search_threads 0 0 1024 Cluster-wide ceiling on search threads summed across all databases; 0 = follow max_parallel_workers, and never exceeds it regardless of this setting. Reload only; no session SET
svs.worker_startup_timeout_ms 60000 1000 300000 Wait for a not-yet-started worker (ms); exceeded → error
svs.worker_timeout_ms 5000 100 60000 Worker IPC response timeout (ms); exceeded → error
svs.worker_restart_backoff 1000 (ms) 100 300000 Base delay before respawning a crashed per-database worker; doubles on repeated crashes, up to 60000 ms; resets after 10 s of uptime
svs.max_batch_size 0 0 1000 Max queries per SVS batch call; 0 = MaxBackends
svs.shutdown_drain_budget_ms 30000 0 600000 Time budget for the worker’s shutdown drain, checked between indexes
svs.worker_stop_timeout_ms 30000 0 600000 Wait for a restarting worker to report stopped before giving up; does not force-kill
svs.max_slot_wal_size 10GB 64MB — If WAL retained by the replication slot exceeds this, the slot is dropped and the index is rebuilt from the heap
svs.max_build_memory 4096MB 1MB — Cluster-wide ceiling on the sum of every in-progress CREATE INDEX/REINDEX build’s peak memory
svs.max_residency_memory 100MB 1MB — Cluster-wide ceiling on the sum of every database’s resolved residency budget; checked at vamana_databases enrollment
svs.default_residency_memory 100MB 1MB — Residency budget for a database whose vamana_databases.residency_memory is NULL; independent of svs.max_residency_memory
svs.max_search_work_mem 100MB 1MB — Cluster-wide ceiling on the sum of every database’s resolved search-scratch budget; checked as a catalog aggregate at vamana_databases insert/update time
svs.default_search_work_mem 100MB 1MB — Search-scratch budget for a database whose vamana_databases.search_work_mem is NULL; independent of svs.max_search_work_mem
svs.checkpoint_debounce_window 300s 0 86400s Quiet-period wait after a write burst before triggering a checkpoint
svs.checkpoint_max_interval 3600s 1s 86400s Maximum time between checkpoints; safety net for constant-write workloads
svs.checkpoint_min_ops 10000 0 — Minimum number of write operations required before a checkpoint is considered (AND-logic filter)
svs.checkpoint_operations -1 (off) -1 — Simple op-count checkpoint trigger; activates simple mode for a positive value, which replaces the debounce logic entirely, including the svs.checkpoint_max_interval safety net (WAL growth is instead bounded by svs.max_slot_wal_size)
svs.checkpoint_interval -1 (off) -1 86400 Simple time-based checkpoint trigger in seconds; activates simple mode for a positive value, which replaces the debounce logic entirely, including the svs.checkpoint_max_interval safety net (WAL growth is instead bounded by svs.max_slot_wal_size)

SQL Functions

Function Runs in Requires Purpose
svs_restart_worker(dbname name) launcher’s database UPDATE on vamana_databases (revoked from PUBLIC; owner, superuser, or a role granted UPDATE) Drain, stop, and respawn one enabled database’s worker on demand
svs_teardown_database() target database Ownership of each index dropped (checked per-index) Drop every Vamana index in the current database (step 1 of permanent removal); returns one row per index
svs_warmup_index(regclass) target database SELECT on the index’s table Load one Vamana index into the worker cache
svs_warmup_database() target database SELECT on each index’s table (unreadable indexes are skipped, not an error) Load every Vamana index in the current database; returns the count warmed

Catalog and Views

Object Purpose
vamana_databases Per-database enablement (INSERT to enable, UPDATE ... SET enabled to pause/resume, two-step teardown + DELETE to remove)
svs_index_residency Durable record of each index’s last-known resident bytes, kept for databases whose worker isn’t currently live; not written to or read from directly by operators
pg_stat_vamana_worker One row per enabled database: worker PID, state, index count, heartbeat, and residency/build/search-work-mem accounting
pg_stat_vamana_worker_slot One row per in-flight IPC work-request slot

Operator Classes

Vector Type Operator Class Distance Operator Metric
vector vector_l2_ops <-> Euclidean (L2)
vector vector_ip_ops <#> Inner product
vector vector_cosine_ops <=> Cosine distance
halfvec halfvec_l2_ops <-> Euclidean (L2)
halfvec halfvec_ip_ops <#> Inner product
halfvec halfvec_cosine_ops <=> Cosine distance

Note on <#>: PostgreSQL index scans only support ascending order, so <#> internally returns the negative inner product to allow ORDER BY … ASC to retrieve the highest-scoring vectors. When displaying the score, multiply by -1: (embedding <#> query) * -1 AS score.