Contents
Trigpoint operator guide (extension 0.5)
Trigpoint is a PostgreSQL index access method for pgvector’s vector type. It keeps one small int8 sketch per row
(a PCA head quantised to 8 bits, plus two norms per level), routes a query through an HNSW graph over those sketches to
a few cells, prunes each cell with a lower bound on the distance before it touches a heap row, and ranks what survives
by the exact distance from the heap. The image of an index lives once in shared memory per generation; inserts and
deletes go to an append log that VACUUM folds into the next generation. pgext/README.md is the reference; this guide
is the order an operator meets things in.
1. Install
Three ways, all producing the same extension:
- Release image:
docker build -f docker/Dockerfile.release --build-arg PG_MAJOR=17 -t trigpoint/postgres:0.5-pg17 .gives pgvector’s official image plus the extension, no compiler inside.PG_MAJORis 16, 17 or 18. - Debian package:
docker build -f docker/Dockerfile.release --build-arg PG_MAJOR=17 --target deb -o dist .leavesdist/postgresql-17-trigpoint_0.5.0_<arch>.deb;dpkg -iit on a host that haspostgresql-17andpostgresql-17-pgvector(0.8 or later). - From source:
make -C pgext && make -C pgext installwithpg_configof the target major on the PATH; a C++17 compiler for the core andcsrc/hnswlib(header-only, cloned from github.com/nmslib/hnswlib).
Then, in each database:
CREATE EXTENSION vector;
CREATE EXTENSION trigpoint; -- ALTER EXTENSION trigpoint UPDATE from an earlier version
SELECT trigpoint_version(); -- version, core revision, SIMD kernel, compiler, server
SELECT trigpoint_shared_available(); -- true on 17 and 18; on 16 only with shared_preload_libraries = 'trigpoint'
PostgreSQL 16 has no shared-memory registry for extensions. With shared_preload_libraries = 'trigpoint' in
postgresql.conf (a restart) the images are shared as on 17 and 18; without it the extension works unchanged but every
backend keeps its own copy of each image it opens: one image’s memory per backend and a slower first query per backend.
2. Build an index
CREATE INDEX t_v_ix ON t USING trigpoint (v vector_l2_ops); -- L2, <->
CREATE INDEX t_v_cos ON t USING trigpoint (v vector_cosine_ops); -- cosine, <=>
CREATE INDEX t_v_tuned ON t USING trigpoint (v vector_l2_ops) WITH (cells = 256, levels = '32,128,256', calibration_queries = 1000);
The build runs on maintenance workers (max_parallel_maintenance_workers) and needs maintenance_work_mem large
enough for every vector of the table in shared memory: about 6 bytes x rows x dimensions (n x D x 4 for the vectors
plus working arrays; 30.1 GB at 5M x 1024), and the build refuses below that, with or without workers. In a container
dynamic_shared_memory_type = posix needs /dev/shm to hold that area during the build and the image afterwards
(shm_size). The three options that matter:
| option | default | what it does |
|---|---|---|
cells |
0 = automatic (about 75 rows per cell, a power of two from 16 to 8192) | more cells = smaller cells, fewer rows scanned per probe, a larger router |
levels |
by dimension (e.g. '32,128,256' at 1536 dims) |
the cumulative widths of the sketch’s levels; the last is the sketch width, the first the router’s code width |
calibration_queries |
2000 | corpus points used to calibrate the lower bound (0 = the exact bound only, no pruning slack) |
seed makes a build deterministic. A 1M x 1536 build took 286 s on an x86 server’s maintenance workers;
trigpoint_info(index) reports the phases afterwards. CREATE INDEX CONCURRENTLY and REINDEX INDEX CONCURRENTLY work under concurrent
writers (the rows inserted meanwhile reach the log).
An index over an empty table works (a trivial model) and refits itself at the first REINDEX; create-then-load is fine, but a REINDEX after the load gives the model the data saw.
3. Query
SET enable_seqscan = off; -- only while checking that the index is used
SELECT id FROM t ORDER BY v <-> '[...]'::vector LIMIT 10;
SELECT id FROM t WHERE category = 7 ORDER BY v <-> '[...]'::vector LIMIT 10;
The settings that change what a scan does, all SET-able per session:
| setting | default | effect |
|---|---|---|
trigpoint.hits / trigpoint.probes |
200 / 32 | router hits asked of the graph, cells opened: the recall/latency dial (more probes, more recall, latency about linear in probes once the route is paid) |
trigpoint.delta |
0.01 | the calibrated bound’s miss probability (the share of true neighbours the pruning may drop, in expectation) |
trigpoint.k |
10 | neighbours computed by the first batch of a scan |
trigpoint.exact |
off | the certificate mode: exact bound over every cell, 100 % recall, several times slower |
trigpoint.iterative_scan |
relaxed_order | how a scan goes on when the executor asks for more than its first k rows (a filter above the index, a larger LIMIT): off = repeat with k doubled over the same cells; relaxed_order = double k and the cells opened per batch, never fetch a heap row twice; strict_order = the same, rows out of order dropped |
trigpoint.max_scan_tuples / trigpoint.max_probes |
20000 / 0 | caps on an iterative scan (heap rows fetched, cells opened; 0 = every cell): at a cap the query returns fewer rows than asked |
Filtered queries: the index does not see the WHERE (no index quals). A selective filter makes the executor discard
rows the index returned; the iterative scan keeps the stream going. On the 100k-row test corpus at 10 %, 1 % and 0.1 %
selectivity a LIMIT 10 came back full with recall 1.00 / 1.00 / 0.97 against the filtered exact answer, at 4x / 18x / 60x
the unfiltered latency: a 0.1 % filter means the executor looks at about 10,000 rows for its 10. If filtered latency
matters more than recall, cap the scan (max_scan_tuples), and consider a partial index per category.
EXPLAIN shows Index Scan using t_v_ix with Order By: (v <-> ...) and the filter as Filter:. There is no access
method hook for EXPLAIN’s output; SET trigpoint.debug = on prints a NOTICE per batch with the cells opened, survivors,
heap fetches and the time split (core, route, heap).
4. Memory
- The image of an index is about
n x (sketch width + 8 x levels + 40)bytes plus the router graph (n x (M0 + 1) x 4, M0 = 2 xm= 64 by default): 0.86 KB per row at the defaults, 856 MB at 1M rows, independent of the dimension.trigpoint_info(index)reportsimage_bytesandcore_bytes. - One copy per generation in dynamic shared memory, mapped by every backend that scans it (
trigpoint_shared()lists them,trigpoint_shared_total()sums them). A compaction creates the next generation; the previous one is unmapped when its last scan ends. trigpoint.max_shared_mbcaps the pinned images cluster-wide (0 = no cap). At the cap a load fails with sqlstate 53400, or, withtrigpoint.on_shared_cap = private, the backend reads a private copy (one image’s memory per backend, a LOG line each time).trigpoint.shared_image = offmakes every backend read a private copy always.trigpoint_backend_memory()is this backend’s resident size; a private copy shows up there.- The container’s
/dev/shm(composeshm_size) must hold every shared image plus, during a build, the build’s vector area. A dropped index’s image is released by the next build or image load in its database, so a drop followed by a rebuild needs room for the build’s area, not the old image too, unless a session that used the old index has not run a statement since the drop (it still maps the image until it does).
5. Maintenance
- Inserts append to the index’s log: a record per row (the sketch and norms, no vector), ~4 ms per row at 1M.
A backend replays the log since the image at its first scan after a change (
trigpoint.compact_records, 20,000, bounds that replay by forcing a compaction). - Deletes are tombstones in the log; the executor never sees a deleted row.
- VACUUM compacts when inserts plus tombstones since the image exceed
trigpoint.compact_frac(0.2) of its rows, or when the log holds more thantrigpoint.compact_recordsrecords: the live rows are rewritten into a new generation, the router repaired, the bound re-calibrated (trigpoint.recalibrate_queries, 500), and the relation truncated to the live image plus one page (trigpoint.compact_reclaim, on). Cost by size on an x86 server: about two minutes at 1M x 1536 (123–129 s after deleting 10 % and 25 %), of which 20–40 s is the re-calibration. - REINDEX rebuilds from scratch (the model, cells and calibration refit to the current rows): after a large change in the data’s distribution, or after corruption.
- Upgrades:
ALTER EXTENSION trigpoint UPDATEchanges the catalog only, one version step at a time from 0.2. The page format (3) is the same across releases and PostgreSQL majors;pg_upgradecarries indexes across majors without a REINDEX;pg_dumpstores the CREATE INDEX and the restore rebuilds.
6. Monitoring and checking
SELECT trigpoint_info('t_v_ix'); -- rows, cells, levels, image size, generation, log counts, compactions, build phases
SELECT * FROM trigpoint_shared(); -- the shared images: index, generation, bytes, loading, loaded at
SELECT * FROM trigpoint_shared_total(); -- images, loading, bytes, the cap
SELECT * FROM trigpoint_check('t_v_ix', false); -- pages, image header and sections, log records (fast)
SELECT * FROM trigpoint_check('t_v_ix', true); -- also every array's contents and the model's floats (O(n), ~1.6 µs per row)
The checker’s last row is summary: ok|CORRUPT, N problems. Run the deep form after a suspicious crash, before a
pg_upgrade, or when a query raised sqlstate XX002.
7. Failure modes
| symptom | meaning | action |
|---|---|---|
ERROR ... (sqlstate XX002), “corrupt image”, “page format”, “index image truncated” |
a page of the index does not agree with itself; the reader refused it rather than use it | trigpoint_check(index, true) names the page; REINDEX INDEX rebuilds |
| wrong neighbours with no error, checker clean | a flipped byte in the model’s floats that no structural check can see | REINDEX; enable data checksums (initdb --data-checksums, the default from PostgreSQL 18) so the page itself is refused |
would exceed trigpoint.max_shared_mb (53400) |
the cap on shared images | raise the cap, drop unused trigpoint indexes, or trigpoint.on_shared_cap = private |
no free slot in the shared image table (53000) |
more live image generations than the registry’s slots | drop unused indexes; the registry sweeps dropped ones itself |
| the first query of a backend is slow after a VACUUM | the compaction wrote a new generation; the first backend to need it loads it into shared memory (seconds at 1M), the others attach | expected; trigpoint.debug = on shows the load line |
| a query returns fewer rows than LIMIT under a filter | the iterative scan reached trigpoint.max_scan_tuples or trigpoint.max_probes |
raise the cap for that session |
query has D dimensions, index has D' |
the query’s dimension differs from the column’s | fix the query |
a build or VACUUM cancelled by statement_timeout |
the statement stopped at its next interrupt check | the index is left as it was (no index, or the old generation); rerun without the timeout |
8. Limits
vectorcolumns up to pgvector’s dimension limit; one indexed column per index; noINCLUDEcolumns; no index quals (aWHEREon the indexed column itself is refused); no backward scans; no planner-parallel scans.- L2 and cosine; inner product (
<#>) andhalfvecare not supported in 0.5. - Linux x86-64 (AVX2) and arm64 (NEON) are tested; the core compiles with scalar kernels elsewhere.
- The calibrated bound’s guarantee is statistical and assumes queries like the calibration set; a strong drift in the queries lowers recall until a REINDEX (or a compaction, which re-calibrates over the current rows).