Dossier

Postgres under cognee, measured at a million nodes

At a million vectors cognee's default index outgrows a typical shared buffer pool, its filtered search scans every row, and deleting one document reads the whole graph back.

I loaded a million-node knowledge graph and a million 1,024-dimensional embeddings into Postgres, in the shapes cognee 1.6.6 writes them, and measured what PostgreSQL 18.6 and 19 RC1 do with them. The biggest effects came from cognee's defaults. Its HNSW index on full vectors is 8.19 GB at this size, twice the 4 GB of shared buffers, and a filtered search falls back to an exact scan that took 360 to 400 ms. A delete spends most of its time reading the dataset's whole graph back, so it slows as the dataset grows. And cognee's own Postgres graph adapter serialises every write behind one lock, which made it 12 to 20 times slower to load than an adapter without it. PostgreSQL 19 changed little for vectors and made graph loads somewhat faster, and its new REPACK (CONCURRENTLY) reclaimed a churned table without stalling reads.

This covers the storage layer only. No language model was called in any run, so nothing here says how fast cognee turns documents into a graph.

Method

The setup

Everything ran on one machine, one Postgres container at a time, with the same settings for every variant.

PartValue
HostAMD Ryzen AI 9 HX 470 (12 cores, 24 threads), 124 GiB RAM, Fedora 44, kernel 7.2.12, podman 5.8.9
Storagebtrfs on NVMe. Copy-on-write slows Postgres writes against ext4 or xfs, probably alike for both versions, though I did not test that
Postgres container8 CPUs, 16 GiB, shared_buffers=4GB (the usual quarter of memory), effective_cache_size=10GB, work_mem=14MB, maintenance_work_mem=512MB (8 GB and 7 parallel workers for vector index builds), max_wal_size=8GB
VersionsPostgreSQL 18.6 and 19rc1 (PGDG packages on the official images), pgvector 0.8.7 built from source the same way for both, VectorChord 1.1.1 on 18 only, since it has no build for 19 yet
cognee1.6.6
Graph dataSynthetic and deterministic, shaped like what cognee's cognify step (documents in, graph out) writes. Each document is 250 nodes (the document, its chunks, its entities) with about three edges per node. Shared entities recur across documents with Zipf popularity, so a few hubs collect thousands of edges. The million-node step is 8 datasets of 125,000 nodes and 2.94M edges in all
Vector dataThe first million English passages of Cohere's public wikipedia-2023-11-embed-multilingual-v3 set (1,024 dimensions, unit length). 1,000 queries from a later file of the same set. Exact top-10 ground truth computed in numpy

The vector tests reproduce cognee's pgvector table (id, a JSON payload, the vector), its index DDL and its query settings. The graph tests use an adapter of my own, derived from the Postgres graph adapter cognee 1.6.6 ships. It keeps cognee's two tables per dataset, the foreign keys from edges to nodes with ON DELETE CASCADE, and the six GIN indexes on the provenance arrays (cognee's source references, which record the documents a node or edge came from). It adds lz4 compression for the JSONB properties, a receipt table for deletes, and a neighbourhood read done in one recursive SQL statement. It also leaves out the part of cognee's adapter that matters most for writes. cognee's adapter takes a single cluster-wide advisory lock (pg_advisory_xact_lock) before every graph write, so concurrent writers queue behind each other. Mine sorts each batch by key so rows lock in a consistent order, and lets writers run in parallel. With eight writers, cognee's adapter loaded the 100,000-node step in 98 s and the million-node step in 1,460 s. Mine took 4.9 s and 125 s. Load figures below are for my adapter.

Vector index

cognee's default vector index outgrows shared buffers

cognee 1.6.6 creates an HNSW index on the full 32-bit vector column with pgvector's defaults (m 16, ef_construction 64) and sets hnsw.ef_search, the number of candidates a search keeps, to max(limit, 40). At a million 1,024-dimensional vectors that index is 8.19 GB. Even after a warm-up pass over all 1,000 queries, 26 to 29% of its block accesses missed the 4 GB of shared buffers and went to the OS cache.

pgvector's halfvec type stores 16-bit floats. An HNSW index on a halfvec cast keeps the table at full precision and is a third of the size.

CREATE INDEX ON items USING hnsw ((vector::halfvec(1024)) halfvec_cosine_ops);

-- the query has to use the same expression, or the planner cannot use the index
SELECT id FROM items ORDER BY vector::halfvec(1024) <=> $1::halfvec(1024) LIMIT 10;

It was 2.73 GB, and none of its block accesses missed shared buffers.

Latency against recall at 1M vectors, one clientp50 latency in ms against recall@10 for three indexes. Full-vector HNSW: 2.94 ms at 0.872, 4.52 at 0.948, 6.55 at 0.975. halfvec HNSW: 1.36 at 0.888, 2.55 at 0.953, 4.31 at 0.976. VectorChord halfvec: 1.20 at 0.930, 1.90 at 0.961.0 ms2 ms4 ms6 ms0.850.900.951.00recall@10full vectors 6.55 msHNSW halfvec 4.31 msVectorChord 1.9 mscognee, ef 40
Latency against recall, one client. Each point is one ef_search or probes setting. The dashed line marks cognee's setting, ef_search 40, on its default index.
Index size at 1M × 1,024 against 4 GB of shared buffersFull-vector HNSW 8.19 GB, 26 to 29% of index reads missed shared buffers. VectorChord full vectors 5.78 GB. VectorChord halfvec 3.04 GB. HNSW halfvec 2.73 GB, 0% missed.0 GB2 GB4 GB6 GB8 GBshared_buffers, 4 GBHNSW, full vectors8.19 GB, 26 to 29% missedVectorChord, full vectors5.78 GBVectorChord, halfvec3.04 GBHNSW, halfvec2.73 GB, 0% missed
Index size at a million vectors. Against 4 GB of shared buffers. The share of block accesses that missed shared buffers is from a warm run.
Index (1M × 1,024)SizeBulk buildSettingRecall@101 client p508 clients
HNSW, full vectors (cognee default)8.19 GB171 sef 400.8722.94 ms1,561 qps
ef 1000.9484.52 ms926 qps
HNSW, halfvec expression2.73 GB98 sef 400.8881.36 ms2,406 qps
ef 1000.9532.55 ms1,802 qps
VectorChord, halfvec3.04 GB59 sprobes 400.9301.20 ms3,424 qps
probes 800.9611.90 ms2,303 qps
Exact scannone1.0002,980 ms1.5 qps

The build times are a single CREATE INDEX after loading. cognee creates its index on an empty table and grows it with every insert, which I did not time.

At cognee's setting the halfvec index answered one client 2.2 times faster at the median (2.94 to 1.36 ms) and served 1.5 times the throughput with eight. To see how much of that is memory, I ran both indexes again in a 24 GiB container with 14 GB of shared buffers, where neither missed.

Everything in shared buffers (PG 18.6)Full vectorshalfvec
ef 40, 1 client p501.46 ms1.38 ms
ef 40, 8 clients2,007 qps2,536 qps
ef 100, 8 clients1,056 qps1,744 qps

With enough memory, the full-vector index answers one client as fast as halfvec, so the single-client gain was all memory. Under eight clients halfvec still served 1.26 to 1.65 times the throughput, plausibly because each distance reads half as many bytes. There are two ways to the same latency, then. Give Postgres about three times the memory for the index, or cast to halfvec, which also keeps the throughput lead. A real cognee instance holds several vector collections and the graph in one database, so the memory pressure there is higher than in this test, not lower.

Recall went from 0.872 to 0.888, a difference one build of each cannot resolve. On this corpus the lower precision cost no measurable recall.

A halfvec column instead of an expression index searched at the same speed and also halved the table (5.91 to 3.15 GB), but it rounds the stored vectors for good. cognee already uses the expression form for vectors above 2,000 dimensions.

ef_search depends on what cognee asks for. Its chunk retrieval asks for 5 results, so it runs at 40, where top-10 recall at a million vectors was 0.87. At 100 recall was 0.95, at 2.55 ms on the halfvec index. cognee's graph-completion search asks for 100 results and so already runs at 100, though I measured recall only for the top 10.

Filtering

Filtered search scans every row

cognee scopes a search to node sets with payload->'belongs_to_set' ?| tags and turns index scans off for that query (SET LOCAL enable_indexscan = off). Without pgvector's iterative scan, an HNSW search returns at most ef_search rows before the filter applies, so a selective filter could leave fewer results than asked for. The exact scan has recall 1.0. With a filter matching 10% of rows it took 393 ms on 19 RC1 and 360 to 369 ms on 18.6 with the table cached in shared buffers, the same with JIT on or off. An earlier 898 ms on 18.6 came from table pages read from outside shared buffers. It reads every row, so its cost grows with the table. My test payload was 200 characters of text, and cognee stores whole objects there, so the cast to jsonb on every row should cost more in practice, not less.

pgvector 0.8's iterative scan keeps walking the graph until enough rows pass the filter, or until it has scanned hnsw.max_scan_tuples (20,000 by default).

SET hnsw.iterative_scan = relaxed_order;
SET hnsw.ef_search = 100;

On the same queries it answered in 3.9 to 6.4 ms once warm, at a recall of 0.88 to 0.89. That is about 60 to 100 times faster than the exact scan, for about a tenth less recall.

Filtered vector search at 1M, 10% of rows match, one client, p50Exact scan 360 to 393 ms with the table cached. HNSW iterative scan first pass 15.7 to 18.1 ms with 4 GB of shared buffers, warm 3.89 to 6.35 ms. Ranges span PG18.6 and PG19 RC1 runs.1 ms10 ms100 ms1,000 msExact scan, as cognee runs it360 to 393 msHNSW iterative scan, first pass15.7 to 18.1 msHNSW iterative scan, warm3.89 to 6.35 ms
Filtered search, 10% of rows match, one client, median. Each bar spans the runs on 18.6 and 19 RC1, with the table cached. The first pass is with 4 GB of shared buffers. Log scale.

The first filtered pass took 15.7 to 18.1 ms on both versions, three to five times the warm figure and still about 20 times faster than the exact scan, because the filtered walk reaches parts of the 8 GB index that unfiltered queries had not loaded into 4 GB of shared buffers. With 14 GB there was no slow first pass. It followed an unfiltered warm-up; a fully cold start would be slower and was not measured. An early single pass on 19 looked like a regression (19.6 against 7.3 ms) for this reason, until three passes per version settled it.

Two conditions limit how far this carries. The tags in my test were unrelated to the vectors and matched 10% of rows. A more selective filter makes the walk longer and can hit the scan limit with fewer results. And it applies only where cognee asks for a fixed number of results. With a node set, cognee's graph-completion search asks for every matching row, which no index helps with.

Alternative

VectorChord served about 1.3 times the throughput

VectorChord's vchordrq index (IVF with RaBitQ quantisation) on the same halfvec cast reached HNSW's recall and served more queries per second. At a recall of 0.96 it served 2,303 queries per second, against 1,802 for halfvec HNSW at 0.95. Its single-client latency (1.90 against 2.55 ms), its build time (59 against 98 s) and its size (3.04 against 2.73 GB) all sit within what varied between runs here. cognee does not support VectorChord, so using it means changing the vector adapter. Its licence is different too. VectorChord is dual-licensed under AGPLv3 or the Elastic License 2.0, while pgvector uses the PostgreSQL License.

Versions

PostgreSQL 19 RC1 against 18.6

Every pgvector index variant on 19 RC1 came within about 20% of 18.6, in both directions, and index sizes were identical.

The graph loaded faster on 19, through my adapter (cognee's schema without its write lock, lz4 on). With one writer the million-node step took 463 and 483 s on 19 against 587 and 600 s on 18, in both repeats and in both run orders, about 20% less. Those runs were tight. With eight writers 19 was also faster (122 and 180 s against 211 and 216 s), but too spread out to put a number on. 19's release notes list faster foreign-key checks, and every edge insert checks two keys, so that may account for part of it. I had no run without the keys.

Tuning

Most storage settings sit inside the noise

Five switches, each at the million-node step with eight writers on 19 RC1, two runs each. Two concern cognee's own schema, by turning GIN fastupdate off on the six provenance indexes and by dropping two edge indexes that the primary key and covering indexes already serve. The GIN pending list size (16 MB instead of 4 MB) is a Postgres setting. lz4 and delete receipts exist only in my adapter (cognee uses Postgres's default pglz and writes no receipts), so switching them back shows what they cost.

Configuration (8 writers)Load, two runsNode batch p95
18.6, defaults211 / 216 s0.61 / 0.78 s
19 RC1, defaults122 / 180 s0.39 / 0.47 s
19 RC1, receipts off198 / 275 s0.70 / 0.97 s
19 RC1, pglz186 / 224 s0.57 / 0.70 s
19 RC1, GIN fastupdate off267 / 294 s1.19 / 1.50 s
19 RC1, GIN pending list 16 MB138 / 197 s0.55 / 0.61 s
19 RC1, two edge indexes dropped130 / 293 s0.39 / 0.91 s

The defaults alone ranged from 122 to 180 s, and every configuration ran slower the second time, so the load column cannot resolve an effect under about half. The batch column is steadier. Only fastupdate off stood out there, at 1.19 and 1.50 s against 0.39 to 0.97 s for everything else, because each insert then updates six GIN indexes in place instead of appending to a pending list. cognee leaves it on, which is the better choice. Receipts and lz4 showed no cost I could measure.

Deletes

A delete reads the whole graph

Deleting one document by its source reference (250 nodes and 750 edges) took about 4 s at the million-node step, on both versions. Postgres's part was small. The node DELETE ... RETURNING averaged 6.7 ms over 1,439 calls, and the edge delete well under a millisecond.

Step of cognee's removal, on 19 RC1Seconds per document, three documents
Remove the source reference from nodes that survive0.25 to 0.38
Delete nodes no other document owns0.18 to 0.32
Delete edges no other document ownsunder 0.01
get_graph_data inside the EdgeType cleanup3.15 to 3.61
Whole removal3.86 to 4.34

cognee keeps one EdgeType node per distinct edge text, for relationship search. After any delete that removes an edge, _cleanup_orphaned_edge_types calls get_graph_data() to learn whether a surviving edge still uses that text. The call reads every node and edge in the dataset, and it was about 83% of each delete.

This is cognee's shared delete code, so every graph adapter runs it. I deleted the same three 250-node documents at two dataset sizes, on my adapter and on cognee's own, on 19 RC1:

Adapter12,500 nodes per dataset125,000 nodes per dataset
Mine0.52 to 0.63 s3.76 to 4.32 s
cognee's own (postgres_demo)0.55 to 0.58 s5.39 to 5.67 s

Ten times the dataset made the same delete seven to ten times slower, and on cognee's adapter the whole-graph read was about 90% of it.

Maintenance

REPACK reclaims space without blocking reads

To produce churn I built one dataset of 125,000 nodes, then deleted 30% of its documents through cognee's delete and wrote them back, three times, which is heavier than most real use. Then I ran each method on the edge and node tables while a reader issued neighbourhood queries back to back and a writer added 20 nodes every half second. Each method ran once per version.

MethodNode tableEdge tableSlowest read during itCalls over 5 s
VACUUM, 19 RC1325 → 325 MB389 → 389 MB7.8 ms0
VACUUM FULL, 19 RC1333 → 208 MB389 → 320 MB1.8 s0
REPACK (CONCURRENTLY), 19 RC1329 → 206 MB388 → 320 MB18 ms0
VACUUM, 18.6338 → 338 MB386 → 386 MB10.1 ms0
VACUUM FULL, 18.6335 → 207 MB388 → 320 MB24.9 s5
Slowest neighbourhood read while each method ran on a churned graph, log scaleVACUUM 7.8 ms on 19 RC1 and 10.1 ms on 18.6. REPACK CONCURRENTLY 18 ms on 19 RC1. VACUUM FULL 1.8 s on 19 RC1 and 24.9 s on 18.6.1 ms10 ms100 ms1,000 ms10,000 msVACUUM, 19 RC17.8 msVACUUM, 18.610.1 msREPACK (CONCURRENTLY), 19 RC118 msVACUUM FULL, 19 RC11.8 sVACUUM FULL, 18.624.9 s
Slowest neighbourhood read while each method ran. One run per method and version. Log scale.

Churn bloated the indexes and TOAST far more than the heap. The heap moved by about 5 MB, while a rewrite took 125 MB (38%) off the node table and 69 MB (18%) off the edge table. Plain VACUUM returned nothing to the operating system. It marks space for reuse.

REPACK (CONCURRENTLY), new in PostgreSQL 19, rewrote both tables to the same size as VACUUM FULL, held its access-exclusive lock only for the final swap of files, and the slowest read while it ran took 18 ms. VACUUM FULL holds that lock for the whole rewrite. On 19 it stalled readers for 1.8 s. On 18 the edge table's rewrite took 25 s against 2.1 s on 19, and one read waited 24.9 s. I did not inspect the locks, but that is most likely time spent queueing for the lock behind my test's own reader and writer, so its length depends on the traffic around it. One run each gives the size of the stall as an example. The difference between a stall measured in seconds and one of 18 ms does not depend on the run.

Pitfall

A pitfall in my own read query

cognee's stock adapter does not have this problem; mine did. Straight after a bulk load, before autoanalyze has run, the new tables have no column statistics, and the planner chose a sequential scan and a hash join for the edge lookup inside my one-statement neighbourhood query. cognee's adapter looks up edges step by step with = ANY, which stays on the indexes either way. At the 100,000-node step, my depth-1 reads had a median of 6 to 7 ms until I ran ANALYZE on each dataset's tables after the load (about 4 s for eight datasets), and 1.3 to 1.7 ms after. In a real cognify run the writes are paced by the language model, so autoanalyze usually catches up during the load anyway.

Limits

What this does not show

The graph data is synthetic, and its shape is my model of what cognify writes. The queries for the vector tests are passages from the same corpus rather than question embeddings, so the recall figures are likely optimistic. Each vector variant was built and measured once, at one size and one dimension, and single-client latency here varies by up to about 1.5 times between runs. The graph runs had two repeats per configuration and the churn test one. The 16 GiB container left about 12 GB of OS cache beside shared buffers, which absorbed the full-vector index's misses; with less memory those misses go to disk. PostgreSQL 19 was a release candidate. VectorChord ran with default build options apart from its list count, and I did not test it above a million vectors or with filters.

Sources

What this leans on

Disclosure

In case you were wondering

Several AI agents assisted with this work and operated under my direction. The agents wrote the benchmark scripts and the adapter changes, ran the measurements, drafted this report from the run log and reviewed it. I set the questions, the settings and what to measure, and checked the numbers against the raw results.