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.
| Part | Value |
|---|---|
| Host | AMD Ryzen AI 9 HX 470 (12 cores, 24 threads), 124 GiB RAM, Fedora 44, kernel 7.2.12, podman 5.8.9 |
| Storage | btrfs on NVMe. Copy-on-write slows Postgres writes against ext4 or xfs, probably alike for both versions, though I did not test that |
| Postgres container | 8 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 |
| Versions | PostgreSQL 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 |
| cognee | 1.6.6 |
| Graph data | Synthetic 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 data | The 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.
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.
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 runs | Node batch p95 |
|---|---|---|
| 18.6, defaults | 211 / 216 s | 0.61 / 0.78 s |
| 19 RC1, defaults | 122 / 180 s | 0.39 / 0.47 s |
| 19 RC1, receipts off | 198 / 275 s | 0.70 / 0.97 s |
| 19 RC1, pglz | 186 / 224 s | 0.57 / 0.70 s |
| 19 RC1, GIN fastupdate off | 267 / 294 s | 1.19 / 1.50 s |
| 19 RC1, GIN pending list 16 MB | 138 / 197 s | 0.55 / 0.61 s |
| 19 RC1, two edge indexes dropped | 130 / 293 s | 0.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 RC1 | Seconds per document, three documents |
|---|---|
| Remove the source reference from nodes that survive | 0.25 to 0.38 |
| Delete nodes no other document owns | 0.18 to 0.32 |
| Delete edges no other document owns | under 0.01 |
get_graph_data inside the EdgeType cleanup | 3.15 to 3.61 |
| Whole removal | 3.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:
| Adapter | 12,500 nodes per dataset | 125,000 nodes per dataset |
|---|---|---|
| Mine | 0.52 to 0.63 s | 3.76 to 4.32 s |
cognee's own (postgres_demo) | 0.55 to 0.58 s | 5.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.
| Method | Node table | Edge table | Slowest read during it | Calls over 5 s |
|---|---|---|---|---|
| VACUUM, 19 RC1 | 325 → 325 MB | 389 → 389 MB | 7.8 ms | 0 |
| VACUUM FULL, 19 RC1 | 333 → 208 MB | 389 → 320 MB | 1.8 s | 0 |
| REPACK (CONCURRENTLY), 19 RC1 | 329 → 206 MB | 388 → 320 MB | 18 ms | 0 |
| VACUUM, 18.6 | 338 → 338 MB | 386 → 386 MB | 10.1 ms | 0 |
| VACUUM FULL, 18.6 | 335 → 207 MB | 388 → 320 MB | 24.9 s | 5 |
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
- cognee 1.6.6 on PyPI.
cognee/infrastructure/databases/unified/provenance_delete_planner.py(_cleanup_orphaned_edge_types),cognee/infrastructure/databases/vector/pgvector/PGVectorAdapter.py(index and filtered search),cognee/infrastructure/databases/graph/postgres_demo/(the Postgres graph adapter) - pgvector 0.8.7, halfvec and iterative index scans, github.com/pgvector/pgvector
- VectorChord 1.1.1, vectorchord.ai, and its PostgreSQL 19 support issue, github.com/supervc-stack/VectorChord/issues/464
- PostgreSQL 19 release notes, REPACK, foreign-key checks and JIT default, postgresql.org/docs/19/release-19.html
- Cohere Labs,
wikipedia-2023-11-embed-multilingual-v3, huggingface.co/datasets/CohereLabs/wikipedia-2023-11-embed-multilingual-v3
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.