Sizing the datastores
You do not need a database administrator to read this page, and that is the point. Both Postgres instances ship with sizes chosen for the machine this vault actually runs on rather than the ones a container image assumes. You can leave every one of them alone: unset means the built-in default, and the built-in default is the sized configuration, not the stock one.
What is being sized, and why it matters here. On the measured demonstration vault — 264 documents, 6,822 chunks, 109,819,571 bytes of database — the vector index chunks_embedding_768_hnsw is 27,115,520 bytes, which is 24.7% of everything, and the 768-dimension vectors beside it are another 19.1%. Half of this product's bytes are what a search reads on every single question. A stock container gives that 128 MB of cache and 4 MB of sort memory.
A typo costs you the default, never the start-up. Unset, blank, unit-less, unparseable, zero, or larger than this machine all fall back to the built-in default, because a datastore that refuses to boot is the one holding the evidence you were about to look at. The value that was ignored is not swallowed: the administrator's performance panel names it and says why. Always write a unit. shared_buffers=512 is valid Postgres and means 512 blocks — 4 MB, thirty-two times smaller than the 128 MB this exists to raise — so a bare number is refused rather than guessed at.
One exception, and it is the only one. shm_size is a Docker field, not a Postgres parameter: the container runtime reads it before any process of ours exists, so nothing can catch a malformed value and fall back. docker compose refuses the stack and names the variable. Loud, but a refusal.
What ships, and why each number
| Service | Parameter | Setting | Default | Why this number |
|---|---|---|---|---|
vecstore | shared_buffers | SKYKEEP_PG_SHARED_BUFFERS | 512MB | Holds the whole measured database (104.7 MiB) five times over and the vector hot set (48 MiB of index plus vectors) ten times over; at the measured 186 KiB of hot vector bytes per document that is the working set of roughly 2,800 documents. 12.5% of an 8 GiB box rather than the textbook 25%, because the model runtime is the other tenant. |
vecstore | work_mem | SKYKEEP_PG_WORK_MEM | 16MB | Postgres' 4 MB is per sort or hash NODE, per statement, times parallel workers. Hybrid retrieval orders a candidate set by vector distance and ranks a tsvector query in one statement, and at 4 MB both spill to disk. The smallest raise of the four on purpose: it is the only dial here that multiplies. |
vecstore | maintenance_work_mem | SKYKEEP_PG_MAINTENANCE_WORK_MEM | 256MB | The HNSW dial. pgvector builds the graph in this budget and spills to a much slower two-pass build when it does not fit; the measured index is 25.9 MiB for 264 documents, so 256 MB is the in-memory build envelope for roughly a 2,600-document vault — which is what a restore or an embedder change rebuilds. |
vecstore | effective_cache_size | SKYKEEP_PG_EFFECTIVE_CACHE_SIZE | 2GB | Reserves nothing; it is what the planner believes the machine can keep cached between shared buffers and the OS. Understated it prefers sequential scans over exactly the index-shaped work this vault does. 2 GiB assumes about half of an 8 GiB box is generally available for file caching. |
vecstore | shm_size | SKYKEEP_PG_SHM_SIZE | 256mb | Where parallel workers put dynamic shared memory. At Docker's 64 MiB default a parallel index scan or a parallel HNSW build can die with “could not resize shared memory segment”, which reads as a product defect and is a container default. |
auditstore | shared_buffers | SKYKEEP_PG_AUDIT_SHARED_BUFFERS | 256MB | No vectors, no HNSW, no TOASTed content: a hash-chained append-only ledger of small JSON rows. What its readers want cached is the RECENT TAIL — the audit view's windowed pages and the per-agent usage aggregation — and 256 MB buys that without taking a second 512 MB bite out of the same box. |
auditstore | work_mem | SKYKEEP_PG_AUDIT_WORK_MEM | 8MB | Twice Postgres' default, half the vecstore's. The heaviest sort on this instance is an ORDER BY over a bounded window of the newest entries; there is no vector distance ordering here to spill. |
auditstore | maintenance_work_mem | SKYKEEP_PG_AUDIT_MAINTENANCE_WORK_MEM | 64MB | Postgres' own default, kept deliberately. There is no index build on this instance worth more, and the value is here so that an operator whose ledger has grown can raise it — not because the shipped number was wrong. |
auditstore | effective_cache_size | SKYKEEP_PG_AUDIT_EFFECTIVE_CACHE_SIZE | 1GB | Half the vecstore's hint, for the same reason its buffers are half: two instances share one page cache, and telling both that they have all of it would make both plan as if the other were not there. |
auditstore | shm_size | SKYKEEP_PG_AUDIT_SHM_SIZE | 64mb | Docker's own default, kept deliberately: nothing on this instance runs parallel workers over a large relation, so raising it would reserve memory for a plan that never runs. |
What to raise, on what evidence, and what it costs
shared_buffersWhen to raise it. Raise it when the vector index no longer fits in it. This is the dial the whole page exists for: on the measured demonstration vault the HNSW index alone is 24.7% of the database, and an index that is not cached is read off disk on every question.
SELECT pg_size_pretty(pg_relation_size('chunks_embedding_768_hnsw')); -- against: SHOW shared_buffers;What raising it costs. Reserved at start-up and held for the life of the process, empty vault or not. It is memory the model runtime on the same box then does not have, which is why the shipped default is 12.5% of an 8 GiB machine and not the textbook 25%.
work_memWhen to raise it. Raise it when statements are spilling their sorts to disk. Do this one last and in small steps.
EXPLAIN (ANALYZE, BUFFERS) <the slow query>; -- look for: Sort Method: external merge Disk: ...What raising it costs. It is per sort or hash NODE, per statement, times parallel workers — so the worst case is this number multiplied by every concurrent statement and every sort inside each one. It is the only dial here that can turn a generous setting into an out-of-memory kill.
maintenance_work_memWhen to raise it. Raise it when an index build is slow, or after a restore, a re-ingest, or an embedder change — all three rebuild the HNSW graph. pgvector builds the graph in this budget and falls back to a much slower two-pass build when it does not fit.
docker compose logs vecstore | grep -i 'hnsw graph' -- 'no longer fits into maintenance_work_mem' is the messageWhat raising it costs. Transient — it is held during a build or a vacuum, not for the life of the process. But it is not one copy: autovacuum workers inherit this value (autovacuum_work_mem defaults to -1, meaning 'use maintenance_work_mem'), so on Postgres' default three workers the worst case is three times what you set.
effective_cache_sizeWhen to raise it. Set it to roughly what this machine can actually keep cached — shared buffers plus the operating system's own file cache, minus what the model runtime is holding. Understating it makes the planner prefer sequential scans over exactly the index-shaped work this vault does.
free -g -- the 'available' column, less what ollama is resident for (docker stats --no-stream)What raising it costs. Nothing. It reserves no memory at all; it is a belief the planner holds. That is why it is the safest of the four to change and the least likely to be the reason anything is slow.
shm_sizeWhen to raise it. Raise it if parallel work is dying rather than merely slow.
docker compose logs vecstore | grep -i 'could not resize shared memory segment'What raising it costs. It is a tmpfs: the space is charged against the machine's memory when it is used. Raising it on the audit instance buys nothing — nothing there runs parallel workers over a large relation.
A starting point for a bigger box
Not a recommendation — a starting point. The shipped column assumes the small machine, because the small machine is the one that breaks, and because the model runtime, not Postgres, is the hungry tenant on this box. Measure before you move off it.
| Machine | shared_buffers | work_mem | maintenance_work_mem / effective_cache_size |
|---|---|---|---|
| 8 GiB (the shipped default) | 512MB | 16MB | 256MB / 2GB |
| 16 GiB | 1GB | 24MB | 512MB / 4GB |
| 32 GiB or more | 2GB | 32MB | 1GB / 8GB |
Measuring, rather than guessing
Every sentence above says measure, so there is a thing to measure with. Against a disposable verification stack — never this deployment, and never one holding anything you would miss — scripts/loadlane.py seeds a fresh compartment with N synthetic documents through the ordinary upload route, waits for every one of them to leave the processing queue, then runs M agents issuing searches and questions for a fixed duration, and prints one table: documents admitted per hour, search p50/p95/p99, and how far the audit chain's verified position trails the outbox. It takes the stack it points at entirely from the environment, it deletes nothing, and it refuses to start unless that environment carries the disposable-lane acknowledgment and the repository's lane posture guard agrees the target is not a demonstration. Run it once before you change a dial and once after; the difference is the evidence the sections above ask for, and --out writes the same table to a file so the comparison survives the terminal.
python3 scripts/loadlane.py --documents 200 --uploaders 4 --agents 8 --duration 300 --out before.mdMaking a change take effect
A Postgres memory parameter is read once, by the server process, when it starts. Editing the environment file changes nothing until the container is recreated — a restart of the vault's own service is not enough, because the value is resolved from the environment the container was created with.
scripts/skykeep.sh stopscripts/skykeep.sh startThen confirm the server agrees with you, which is the only check worth making:
docker compose exec vecstore psql -U <user> -d <database> -c 'SHOW shared_buffers; SHOW work_mem;'