January 29, 2024·6 min read·engineeringpostgresqlpgvectorindexingvectors

pgvector 0.6 makes HNSW index construction more practical

Parallel HNSW builds reduce one of the costs of keeping vector search in PostgreSQL. Memory, WAL generation and container shared memory remain part of the construction process.

Building an approximate nearest-neighbour index can be a substantial part of the cost of adding vector search to a database. HNSW constructs a multilayer graph, and pgvector documents a tradeoff between its query performance and its slower, more memory-intensive construction. The 0.6.0 release on January 29 adds parallel HNSW index builds. It also reduces construction memory use and write-ahead log generation. Those changes address the work required before an application can benefit from the index.

HNSW itself arrived in pgvector 0.5.0, which already supported parallel construction for IVFFlat. The January release closes that particular difference between the two access methods. Its significance is operational. Choosing HNSW involves paying for a graph during creation and maintaining it as data changes, so reducing the creation cost makes the choice easier to justify for an existing table.

I still default to PostgreSQL for relational data, and its extension interface is a substantial reason for that preference. Vector search within the database is appealing when the vectors belong to records that already need relational queries. That preference depends on understanding the extension's costs. An index that takes substantial memory and time to construct becomes part of the database's operating requirements.

The graph changes the meaning of an index

The documentation shipped with 0.6.0 describes HNSW as a multilayer graph. Unlike IVFFlat, it has no training step and can be created before a table contains data. IVFFlat divides vectors into lists and searches a subset of the lists nearest the query vector. Its documentation recommends creating the index after loading data, because the list structure needs a useful sample.

These designs have different construction requirements. The documented HNSW tradeoff is better query performance for a given speed and recall balance, at the expense of slower builds and greater memory use. Parallel construction reduces one part of that expense. It does not make the two algorithms interchangeable, and the release makes no claim that a parallel build removes HNSW's memory requirements.

There is also a semantic difference between an approximate vector index and the usual expectation of an index as an implementation detail. pgvector performs exact nearest-neighbour search by default. Adding an approximate index trades some recall for speed, so a query can return different neighbours after the index becomes available. This is an application-visible choice even when the SQL statement remains unchanged.

The tagged documentation supports the following small schema and query. Its three-dimensional values make the distance operator visible without requiring an embedding generator.

CREATE EXTENSION vector;
CREATE TABLE items (
    id bigserial PRIMARY KEY,
    embedding vector(3)
);
INSERT INTO items (embedding) VALUES ('[1,2,3]'), ('[4,5,6]');
CREATE INDEX items_embedding_hnsw
    ON items USING hnsw (embedding vector_l2_ops);
SELECT id FROM items
ORDER BY embedding <-> '[3,1,2]'
LIMIT 5;

The example uses the L2 distance operator and its corresponding operator class. The documentation specifies separate operator classes for inner product and cosine distance. Matching the index to the distance function is part of the definition of the search. Selecting a different distance operator is not merely a cosmetic variation in how the result is displayed.

Parallel workers and graph memory control different costs

The new parallel construction support uses PostgreSQL's maintenance worker configuration. The 0.6.0 documentation gives max_parallel_maintenance_workers a default of two and explains that the leader participates as well. It also notes that a large worker setting can require increasing max_parallel_workers, whose documented default is eight. A request for more maintenance workers therefore exists within a broader worker allocation.

A build session can set an explicit worker limit before creating the index. The following statement retains the documented default while making that choice explicit.

SET max_parallel_maintenance_workers = 2;
CREATE INDEX items_embedding_cosine_hnsw
    ON items USING hnsw (embedding vector_cosine_ops);

This example creates a separate cosine-distance index on the earlier table. The number is a limit for the build configuration, not a performance result. The release notes do not establish a fixed speedup per worker, and parallel execution should not be described as a guarantee that elapsed time divides by the number of participants.

Memory has a different role. The documented fast construction path depends on the graph fitting inside maintenance_work_mem. Once it no longer fits, pgvector emits a notice that construction will take substantially more time. That notice gives the number of tuples loaded at the transition and suggests increasing the memory setting. It explains a change in the build's operating conditions while the build is still running.

A larger memory allowance and a larger worker allowance are consequently different decisions. One controls how much memory the construction graph can occupy before exceeding the allowance. The other controls the requested maintenance parallelism. Increasing workers does not establish that the graph fits in memory, and increasing memory does not itself request additional workers. The release addresses both costs, but they remain separately configurable.

The documentation explicitly warns against setting maintenance_work_mem high enough to exhaust server memory. That makes the graph's size relevant to the whole server, especially when PostgreSQL also serves ordinary application queries. The useful contribution of the new release is that the same construction task has more execution options and lower documented resource costs. It does not provide a workload-independent memory setting.

Construction has consequences beyond CPU time

The changelog separately records reduced WAL generation for HNSW builds. pgvector's documentation states that it uses PostgreSQL's write-ahead log and supports replication and point-in-time recovery. Construction therefore has implications for the database's logging machinery as well as for the process constructing the index. A reduction in generated WAL is relevant even when the build's elapsed time is acceptable.

No percentage reduction accompanies that changelog entry. The supported conclusion is that 0.6.0 reduces WAL generation during HNSW construction, not that all vector workloads produce less WAL or that replicas receive a particular throughput improvement. That distinction matters when comparing an initial bulk build with subsequent application inserts. The release names the construction operation specifically.

The same distinction applies to reduced memory usage. The changelog identifies HNSW index builds, while the documentation still describes HNSW as the more memory-intensive alternative to IVFFlat. Reading those statements together gives a useful interpretation of the release. An existing cost has been reduced, but the index still has a resource profile that differs from the other supported approximate access method.

I value that specificity in PostgreSQL tooling. An operator needs to know which operation changed before turning a release note into a capacity assumption. Here the construction path is a clear target, and the tagged documentation gives settings and progress information that correspond to that target.

Containers expose another memory limit

The 0.6.0 upgrade notes call out Docker separately. If maintenance_work_mem has been increased, the container's --shm-size must be at least that large to avoid an error during parallel HNSW construction. PostgreSQL's setting and the container's shared memory allocation are two configuration layers that must agree.

This is a concrete reason a SQL configuration change can be insufficient. The database can have a larger maintenance memory setting while its container still has a smaller shared memory allocation. In the release documentation, that mismatch causes an error in the new parallel build path. Appropriate configuration belongs to both the database session and the container that runs the database.

The image location changes too. Images now live under pgvector/pgvector, with tags for each supported PostgreSQL version instead of a latest tag. The upgrade notes give pgvector/pgvector:0.6.0-pg16 as an explicitly versioned example. For deployments using those images, selecting the right artifact is a separate part of adopting the extension release.

Observing the build and separating search tuning

The tagged README documents pg_stat_progress_create_index for monitoring index creation on PostgreSQL 12 and later. HNSW reports initializing and loading tuples phases. That gives a named operation to observe while construction runs, alongside the notice about the graph exceeding maintenance memory. Neither observation requires treating an application query's latency as a proxy for construction progress.

Search has its own HNSW setting, hnsw.ef_search, the size of the dynamic candidate list used during a query. The documented default is 40. Increasing it improves recall at a speed cost. Construction instead has m, the maximum connections per layer, and ef_construction, the candidate-list size used while building the graph. A higher ef_construction improves recall at a cost to build time and insert speed.

Those settings explain why faster construction is useful without resolving every vector-search decision. The application still chooses its distance function and the acceptable recall tradeoff. The database still needs a graph configuration suitable for that choice. Parallel HNSW builds reduce the cost of constructing that graph within PostgreSQL, while the documented query and construction settings retain their separate meanings.