Skip to content

Entity graphs on managed Postgres

The working example serves its read surface through an edge cache, so it keeps answering from stale responses even while the disposable database behind it is destroyed - x-pggraph-cache on every response says which. The measurements below remain the reference for the design, and two corpora now sit under them: seven public-domain US federal documents (1521 citation entities, 10016 mentions, 14233 edges) and 103 Australian local-government public documents - one council’s minutes, committee papers, contracts registers and tender notices across 2015-2026 - which added the people, organisations and registry identifiers that who-dealt-with-whom questions ask about (4787 entities, 28778 edges total). No graph database anywhere in it.

The requirement is a set of graph questions: who is connected to whom, by how strong a chain, clustered how, and traceable to which source. A dedicated graph store is the right tool when the requirement is instead Cypher-grade pattern matching as the team’s primary query language, unbounded or deep variable-length traversal under concurrent load at much larger scale, or an existing investment in a graph-native ecosystem. This workload is read-heavy, batch-loaded, bounded-hop and provenance-heavy. If it grows into one of those, the extraction, resolution and provenance work below carries over to whichever store sits at the end.

To build it, follow the implementation guide

  • same system, ordered steps, one verification per part.

Everything below was measured on disposable projects: Postgres 17.6, medium compute (2 vCPU / 4 GB), in ap-southeast-1 between 2026-08-10 and 2026-08-11, and in ap-southeast-2 on 2026-08-14 for the council corpus run. Query latencies are EXPLAIN ANALYZE’s own Execution Time with the cold first run discarded, so they are database work rather than round trip. Read the compute as a floor - a real deployment provisions more. Claims that are documented rather than measured are marked in Evidence.

TL;DR:

  • No property-graph store. Apache AGE is absent from the 78 available extensions, and there is no Cypher.1
  • pgrouting 3.4.1 is available and is not geospatial-only: 209 pgr_* functions, including Dijkstra, breadth- and depth-first search, k-shortest paths, connected components, articulation points, bridges and transitive closure, over ordinary relational edge tables.2
  • Recursive CTE traversal mostly settles the question. Depth-3 neighbourhood over 100000 entities / 400000 edges runs in about a fifth of a millisecond with two indexes present, against roughly 167.59 milliseconds without them, on a 602 millisecond index build,3 and holds flat to 64 concurrent readers (p50 ~0.3 ms, zero errors).
  • Two name collisions derail this discussion. pg_graphql is a GraphQL API over tables.4 ltree models single-parent hierarchies, and an entity graph is not a tree.5
  • SQL/PGQ is committed for PG19 and does not change the engine - it is rewritten to a standard relational query in the rewriter.6
  • Extraction belongs outside the database, and which runtime you pick moves the ceiling by 5.6x.
  • Do not size a corpus from its PDF bytes. The extracted-text ratio measured 0.048 to 1.0885 across seven documents.
  • Person and organisation extraction is noisy. The extractor is a candidate-generation example, not a production named-entity recognizer: a 24-row review of 561 persons and 2284 organizations found truncated names, labels split at line breaks and occasional sentence-level over-captures.
  • Cross-document bridging needs a corpus built for it. 20 of the 1521 US citation entities span documents; the council corpus engineered for overlap puts sitting councillors in 86-93 of its 103 documents.
  • Registry identifiers make resolution arithmetic. Every ABN candidate shape in the corpus (148 of them) was checked against the mod-89 checksum at extraction: 14 valid mentions, 134 rejected, zero false positives surviving.
  • Real co-occurrence graphs have hubs, and hubs break naive walks. A path-set recursive walk (UNION ALL) took 65721.220 ms at depth 3 on the 28778-edge real graph; level-set semantics (UNION) answers the same query in 154.924 ms. The synthetic benchmark, at average degree 8, never showed it.

Outside the databasePostgresSource documentsParse / OCREntity + relationextractiondocumentstext + provenanceEntity resolutionentitiesedgesid, source, target, costPostgRESTrecursive CTEneighbourhoodpgroutingpaths, components,bridgespg_trgm +fuzzystrmatchresolutionStatic UIon Workers

The three stages on the left are yours. Document-to-graph pipelines are built this way whatever the store: the graph store is the last and most swappable stage, and the hard parts are extraction, entity resolution and provenance.


QuestionUseMeasured
Who is within N hopsRecursive CTE written as a level set (UNION), indexes on both edge columns0.22 ms at depth 3 on the synthetic graph (average degree 8); 154.924 ms on the real 28778-edge graph, and 65721.220 ms if the walk is a path set (UNION ALL)
What connects these two, how stronglypgr_dijkstra, cost as inverse evidence weight1092.61 ms over 400000 edges
Does the data clusterpgr_connectedComponents1098.11 ms
What is structurally criticalpgr_articulationPoints, pgr_bridges9883.44 ms, 254 bridges
Which things are named similarlypg_trgm + fuzzystrmatchindex-backed
Which documents mention XSTORED tsvector generated column + GIN, queried with websearch_to_tsquery112.6 ms median inner query, GIN 3192 kB, on 7 rows
Which things mean something similarpgvector as halfvec with an HNSW index0.43 ms HNSW query against 46.13 ms IVFFlat (lists=100) over 30000 x 1536 synthetic vectors; recall unmeasured - see Agent memory store

The CTE expands a bounded neighbourhood; pgr_dijkstra computes a global shortest path across the whole edge set.

pgr_* functions require an inner query exposing columns named exactly id, source, target and cost, optionally reverse_cost. The first three are typed ANY-INTEGER and the costs ANY-NUMERICAL.7 Two consequences:

  • A uuid primary key cannot be handed to pgr_*. Carry a surrogate integer key on entities if you want the algorithms.
  • Naming your columns source_id and target_id, the natural relational choice, forces an aliasing subquery at every call site.

Setting cost to the inverse of an evidence weight makes the cheapest path the most strongly evidenced one rather than the shortest hop count.


78 extensions are available. The graph answer is two facts, and either alone misleads: “no graph database” overstates the gap, and “there is a graph extension” overstates the capability.

CapabilityStatus
Property-graph store: node/edge storage plus a traversal languageNone available. No age, agensgraph, apache_age, sqlg
Graph-algorithm library: algorithms over relational edge tablespgrouting 3.4.1, requiring postgis

Also present, none of them graph stores, several of them used below:

ExtensionVersionRole in this design
vector0.8.2semantic similarity; storage, build and query latency measured (G05), recall not
pg_trgm1.6name resolution, fuzzy fallback
fuzzystrmatch1.2name resolution, fuzzy fallback
unaccent1.1name normalisation
ltree1.3hierarchies only - not the answer here
rum1.3ranking indexes
pgroonga3.2.5full-text search
pgmq1.5.1ingestion queue
pg_cron1.6.4scheduling
pg_net0.20.4outbound HTTP from SQL
http1.6outbound HTTP from SQL
pg_partman5.3.1table partitioning
hypopg1.4.1hypothetical indexes
  • pg_graphql exposes a GraphQL API over relational tables.4 It shares nothing with a graph database but the word. Anyone grepping an extension list for graph finds it and reports the question closed.
  • ltree models labelled paths through single-parent hierarchies.5 A tree is not a graph; it fails on the first node with two parents.

Both get offered as the answer, and neither applies today.

Apache AGE is not installable on managed Postgres here. It is absent from the 78 available extensions, so CREATE EXTENSION age cannot succeed. Upstream is active - releases exist for PG17 and PG18, and a PG19 branch exists - but those are release candidates, and availability is decided by whether the provider packages it.8 That makes it a packaging request to the provider.

SQL/PGQ is committed, and it is new syntax over the existing engine. It was committed on 2026-03-16 into the PG19 cycle, implementing the SQL:2023 standard’s GRAPH_TABLE pattern matching plus CREATE PROPERTY GRAPH DDL.6 9 Three things follow:

  1. PG19 is unreleased. The newest supported major series is PG18,10 and the platform measured here runs 17.6 - so the wait is PG19 general availability and then provider adoption.
  2. The commit notes the security-definer variant is not implemented.
  3. A property graph is a new relkind that, in the commit’s own words, “acts like a view in many ways” and “is rewritten to a standard relational query in the rewriter.”

PGQ replaces hand-rolled recursive CTEs with declarative graph patterns, which makes the queries easier to write, but per the third point the plan underneath is still the relational plan measured above. Waiting for PG19 gets better syntax over a capability that is already here.


PDF-to-text and OCR extensions exist. pgpdf adds a pdf type castable from bytea, parsing via poppler.11 pg_ocr runs an ONNX pipeline in-database.12 Neither is reachable on managed Postgres: neither is in the catalogue, and pg_ocr needs plpython3u while the only procedural languages available are plpgsql and plpgsql_check. Untrusted PL is arbitrary code execution as the database user, so its absence is a deliberate provider position.

In-database parsing would gain nothing anyway. Extracting all seven documents with pdftotext took about eleven seconds, a few percent of the pipeline, and pgpdf uses poppler too - so the best case is the same work on the database’s CPU instead of a stateless worker’s. Parsing is also the stage that most wants to scale horizontally, and the database is the component that cannot.

Scanned documents extract to nothing, silently. A corpus spanning decades includes scans, and a born-digital pipeline produces zero entities from them and looks broken. Detection was measured: an image-only scan yields 0.5 characters per page against 2710-3242 for born-digital references, so anything under roughly 270 characters per page routes to OCR. OCR itself is another external stage, not a database one.

Both runtimes ran the same task on the same fixtures with the same unpdf/pdf.js build, each in a V8 isolate, so the parser is held constant and the difference is the runtime envelope.

Fixture bytesSupabase Edge FunctionCloudflare Worker
191290okok - 40 pages
2504695ok, its ceilingok - 188 pages
3595043HTTP 546ok - 894 pages
6073678HTTP 546ok - 492 pages
14034445HTTP 546ok - 2780 pages
14930674HTTP 5461102 / HTTP 503

Error 1102 covers CPU or memory indistinguishably in the HTTP response, so the Cloudflare failure was checked in the analytics API, which reported exceededMemory. It is the 128 MB per-isolate limit; the CPU ceiling had been raised to the documented paid-plan maximum.13

The Edge Function has a second bound the fixtures never reached: a 150 s idle wall clock, after which the invoke dies with 504 IDLE_TIMEOUT (edge-resilience W13, 2026-08-15). The two 14 MB fixtures failed with the 546 at 57165 ms and 60136 ms, well inside 150 s, so on this corpus the resource limit binds before the idle clock.

A static read UI can live anywhere, since every data call goes browser to PostgREST with an anon key. Supabase Storage is not one of those places: it records index.html as text/html and serves it as text/plain with x-content-type-options: nosniff, so a browser renders the source as text. JS and CSS serve with correct types, so the downgrade is HTML-specific and deliberate - a public bucket takes arbitrary uploads, and serving HTML as HTML would make it a stored-XSS surface on the project’s own origin.

The live demo is therefore a Cloudflare Workers static-assets deployment, which serves text/html correctly. The same worker also proxies the read RPCs through the documented Cache-API pattern for POSTs - body hashed into a synthetic GET cache key - which gives the demo’s disposable database two things it needs. Repeated reads (stats, the document inventory, the bridging list) answer at the edge in single-digit milliseconds rather than round-tripping to the project region. And when the project is destroyed between runs, the demo keeps answering from stale cache entries instead of 502ing: freshness is tracked with an x-cached-at stamp against a six-hour TTL, and any origin failure falls back to whatever is stored, marked x-pggraph-cache: STALE. Two implementation details took time to find. The Cache API rejects put() of any response carrying Set-Cookie (Cloudflare’s own edge adds __cf_bm, so strip it on the stored copy or every request is a silent permanent MISS). The store is per-colocation with an asynchronous write, so two back-to-back probes can both MISS without anything being broken.

Cloudflare Worker (pggraph)browserstatic assets binding(page, JS, CSS)GET pageworker script/rest/* onlyPOST /rest/v1/rpc/*Cache APIper-colo storesynthetic GET key(path + body hash + version)store (Set-Cookie stripped)PostgRESTMISSHIT in TTLSTALE when origin downPostgres(disposable demo project)

Driving ingestion: a queue in the database, workers outside it

Section titled “Driving ingestion: a queue in the database, workers outside it”

Once extraction lives outside the database, something has to hand work to it and collect the results. pgmq is available on the platform, alongside pg_cron, pg_net and http,1516 and the pattern of queue-in-Postgres plus an external consumer is already documented for embedding generation.1718 For this workload:

  • The queue and the data are one database, so a consumer can delete the message and insert the extracted rows in a single transaction. A separate broker reintroduces the dual-write problem and needs idempotency keys to cover it.
  • Consumers are stateless, which is what parsing wants: it is the stage that parallelises, and the database is the component that cannot be scaled out by adding replicas.
  • Scale-to-zero consumers make a backfill cost nothing between runs.19
  • Region matters: put consumers in the region hosting the database, not wherever is convenient.20

The documented version of this pattern uses serverless functions as the consumer. For PDF work that substitution is wrong on the measurements above - the platform’s own function runtime stopped at 2504695 bytes. A long-running machine has neither that ceiling nor the 128 MB isolate limit.

Three more things to set deliberately:

  • Bulk writes should not go through the transaction pooler. It silently drops connection-time options, and large TOASTed writes are not what it is for. Use the direct connection for ingestion and keep the pooler for the read API.
  • Visibility timeout must exceed the real p99 job duration. Measure first. Too low duplicates work; too high means a crashed consumer holds its message that long.
  • A queue can be over-engineering. For a one-time historical backfill with a light ongoing tail, a bounded parallel runner is enough - fetching 40 MB across seven documents took about a minute at seven-way concurrency. Reach for the queue when arrival is continuous and unpredictable, or when per-document retry and audit are requirements.

Sizing a corpus you have not extracted yet

Section titled “Sizing a corpus you have not extracted yet”

The shortcut is to assume extracted text is a small fraction of the PDF and multiply. Measured across seven public-domain federal documents:

Genreextracted / sourceTOAST / logical
Positioned form0.0480.4765
Standard, control catalogue0.25730.3744
Budget narrative0.27750.4132
Legislation0.57990.3464
Annotated constitution0.84900.4668
Budget appendix0.90490.3387
Dense regulation1.08850.3896
Aggregate0.76330.3965
  • Extracted text can exceed its own PDF. Dense regulation came out above 1.0, because PDF already compresses its content streams.
  • Genre does not predict the ratio. The two regulation documents landed at 0.8490 and 1.0885; the two budget documents at 0.2775 and 0.9049. Text density and prior compression predict it.
  • TOAST roughly halves the stored size again, so logical bytes and database footprint are also different numbers.

A projection built on 5% is wrong by roughly 6x at the aggregate and by more than 20x on the least favourable document. Extract a genre-spanning sample and measure. Quote the result per document: a corpus projection stated per month of infrastructure cannot be compared against anything priced per document, and the comparison gets made in whatever units are quoted.

A worked figure from the council corpus uses inputs measured on 2026-08-14 (G12) and per-GB Supabase list prices dated 2026-08-10.2122 A minutes-like document ran about 430 KB of source PDF to about 11 KB of logical text and about 5.4 KB on disk after TOAST. At $0.125/GB/mo for database disk and $0.0213/GB/mo for the raw PDF in Storage, that is about a cent per thousand documents per month on the Supabase side, compute not priced. As a sizing scenario, a 4 TB source corpus at that ratio projects to about 102 GB logical and about 50 GB on disk - roughly $6/mo of disk plus $85/mo of raw storage, about $92/mo; the same 4 TB at the US federal corpus’s 0.7633 aggregate ratio projected to about $239/mo. Genre moved the projection 2.6x between two measured corpora, which is why the per-document figure has to come from a sample of the reader’s own genres.


The citation layer proves the machinery; the questions a reader asks of minutes are about people and organisations.

20 of the 1521 US citation entities appear in more than one document - 1.3%, which is what seven documents across five genres sharing almost no citation surface should produce. A corpus built to demonstrate bridging is one issuing body’s ordinary minutes, committee minutes, contracts registers and tender notices across consecutive years, where the same councillors and contractors recur by construction. Measured on the loaded graph, the top bridging entities are the sitting councillors at 86-93 of 103 documents each. The query side is cross_document_entities() - every entity spanning two or more documents, with the slugs attached.

Person and organisation extraction is the noisy stage

Section titled “Person and organisation extraction is the noisy stage”

In the 2026-08-14 run with the AU patterns, the deterministic honorific and suffix patterns found 292 distinct persons and 123 organisations across the council documents. The 561 / 2284 counts in the TL;DR come from the 2026-08-11 US-pattern extractor over the earlier corpus, and are recorded in this doc only, not in the RUNLOG. A graded 12+12 sample shows what to expect of them:

  • over-captured leading words (“Council accept the tender from Example Contractor Pty Ltd”)
  • line-break splits and newline-glued names
  • honorific-only rows (“Mrs”)
  • job titles as people - “Director Corporate” spans 92 documents and is not one

The council documents taught two things the US review could not. Council resolutions are set in ALL CAPS, so the first Pty Ltd suffix pattern missed 3 of 103 documents until the suffix was made case-flexible, and a source-document typo in a councillor’s surname survives as its own entity, which is the fuzzy-resolution case turning up unprompted. A production pipeline puts a model pass and a review step here; the demo labels its precision rather than tuning it toward plausible.

The time axis is a date on every mention and edge

Section titled “The time axis is a date on every mention and edge”

Those questions are temporal: who dealt with whom, five years ago, and in twenty other places. Minutes and filings are dated, so the metadata is free at ingestion: doc_date sits on every mention and edge, filled by a BEFORE INSERT trigger so no extractor needs to know. An entity’s history is entity_timeline() in document-date order, and every read has an as-at form: bridges_as_at() and neighbourhood_as_at() traverse only edges whose documents existed by the date. Measured discrimination on the recurring local builder: 0 neighbours as at 2020-01-01 against 171 as at 2026-12-31. The US documents carry no dates and are absent from as-at answers, which matches what “as at” means: only what was known by then.

Registry identifiers make resolution arithmetic

Section titled “Registry identifiers make resolution arithmetic”

pg_trgm and fuzzystrmatch stay as the fallback. Australian documents print ABNs, and an ABN carries a checksum - subtract 1 from the first digit, weight by 10,1,3,5,7,9,11,13,15,17,19, and the sum must divide by 89. The checksum makes each candidate pass or fail: 148 eleven-digit candidate shapes across the corpus, 14 valid mentions, 3 distinct entities, 134 rejected by arithmetic, zero false positives surviving. The join is exact where a key exists: the organisation behind a local wind farm (a ... PTY LTD name) co-occurs in a 2015 committee paper with its printed ABN, so a co-proximity edge pins the identity (entity_registry_ids() surfaces it), while the 2022 minutes name the same venture’s community fund through a councillor appointment. No fuzzy match in the pin; the trigram only links the name variants. Zero ACN tokens exist in this corpus, so no acn kind was built - an example of the corpus deciding the scope.

The traversal, bridging and cluster queries already existed, so the graph view is a radial node-link renderer over subgraph() output - concentric rings by CTE depth, no force layout, no dependency. Hub nodes make full edge sets unreadable, so the view draws the top 24 nodes by weight and the top 3 edges per node, and says so. The table beside the picture is what shows the graph is real.


Module ids are pdf-corpus-graph RUNLOG sections in supabase-lab unless named otherwise.

PracticeEvidenceModule
Write the recursive walk with UNION (level set), never UNION ALL, and cap the depthA degree-917 hub took the path-set walk to about 65721 ms at depth 3 on the 28778-edge graph; the level-set walk answers in 155-159 ms warm over the poolerthe fusion run, 2026-08-14 (RUNLOG figures)
Carry a surrogate integer key and name the edge columns id, source, target, cost; set cost to the inverse evidence weightpgRouting’s Dijkstra signature requires exactly those column names and an integer idG07; the pgRouting contract7
Store embeddings as halfvec and index with HNSW; make no recall claim from synthetic vectorshalfvec is 0.5042 of vector bytes on identical columns; HNSW ran 0.43 ms query against 46.13 ms IVFFlat at lists=100, build 6950 ms against 4130 msG05
Provision disk throughput deliberately for a read-heavy corpus with vector indexesIt is a separate addon field (baseline_disk_io_mbs=347, max_disk_io_mbs=2085), not a function of disk sizeG06; Cost shape
Quote cost per document from measured per-genre ratios with a worked exampleLabel the per-GB rates as list prices with their date; genre moved the same 4 TB from about $92/mo to about $239/moG12
Set per-document fetch timeouts independent of byte size and cache fetched bytesFetch was 9788 ms of an 11601 ms request at 2504695 bytes, and failures were not monotonic in size (6073678 bytes in 4105 ms, 3595043 bytes in 16906 ms, the 14 MB fixtures about a minute)G02
Give keyword search its own row: a STORED tsvector generated column with a GIN indexQuery it with websearch_to_tsquery so raw user input does not error (GIN 3192 kB; 112.6 ms median inner query; sequential scan chosen at 7 rows)search tier
When profiling, EXPLAIN ANALYZE the inner query or set pg_stat_statements.track = allSecurity-definer RPC bodies do not inline and are invisible under track = topsearch tier; sbperf audit
Use the direct connection or the session pooler for ingestion; set bulk-load timeouts in-sessionThrough the transaction pooler show statement_timeout returns the 2min role default whether or not you set it, because connection-time options are droppedmethod notes
Treat the Edge Function as bounded by a resource limit and by an idle wall clockHTTP 546 WORKER_RESOURCE_LIMIT (CPU or memory not distinguished) somewhere between 2504695 and 3595043 bytes, and a 150 s idle wall clock (504 IDLE_TIMEOUT); on this corpus the resource limit binds firstG02; edge-resilience W13
Route low-text pages to OCR outside the databaseAnything under about 270 characters per pageG09

Consumer count, long-poll interval and visibility timeout in the ingestion section are stated as rules with no run behind them; the 400-character edge window and the renderer cut are editorial choices.


  • The index ratio generalises. Two indexes turning a 167.59 ms depth-3 traversal into 0.22 ms is a statement about access paths. Absolute values scale with graph size and compute.
  • The traversal latencies are a floor. Taken on 2 vCPU / 4 GB.
  • The expansion ratios are corpus-specific. Do not reuse 0.7633; reuse the finding that one ratio is not safe.
  • The CTE traversal holds under concurrency. The depth-3 neighbourhood was pulled at concurrency 1, 4, 16 and 64 on the 100000/400000 graph: p50 flat at 0.3-0.7 ms, p95 at 2.9-3.6 ms, zero errors in 85 queries, on the same 2-vCPU medium - no knee, because the graph fits working memory. The pgrouting global algorithms are still single-query, uncontended numbers.
  • Traversal and extraction were measured on one graph on 2026-08-14. Before that run the traversal and pgrouting latencies came from a synthetic 100000/400000 graph and the extraction pipeline had run over seven real documents, so correctness and speed rested on different artifacts. The run over roughly 100 real documents (111: 103 council documents, the seven US fixtures and the scan fixture) showed the synthetic latencies do not transfer: the degree-917 hub turned the path-set walk into about 65721 ms at depth 3 on the 28778-edge graph, and the level-set rewrite answers in 155-159 ms warm over the pooler (RUNLOG figures). The run also produced the per-genre extraction ratios (0.0011 to 0.0362 across the council genres) that turn the cost projection from a band into a number (G12). The pgrouting latencies are still synthetic-graph only.
  • Extension availability is region-specific until probed. The catalogue probe ran in ap-southeast-1 and again in ap-southeast-2 for the council corpus run, and nowhere else. Re-run the catalogue probe in the deployment-target region before quoting pgrouting there.

ClaimHow it was checked
78 available extensions; no age, agensgraph, apache_age, sqlgMeasured - pg_available_extensions, exact-name match
pgrouting 3.4.1 available, requires postgis; 209 pgr_* functionsMeasured - catalogue query, then CREATE EXTENSION ... CASCADE and a pg_proc count
pgrouting algorithms run on a non-geospatial graph with integer idsMeasured - Dijkstra, components and bridges over a generated 100000/400000 graph, no geometry column
Depth-3 CTE 0.22 ms indexed against 167.59 ms unindexed, 602 ms buildMeasured - EXPLAIN ANALYZE median, cold run discarded, both index states
pgr_dijkstra column contract and ANY-INTEGER typingDocumented - pgRouting manual7
Extracted/source 0.048 to 1.0885, aggregate 0.7633, TOAST 0.3965Measured - seven documents extracted and loaded, byte counts from relation size
Edge Function ceiling 2504695 B; Worker ceiling 14034445 BMeasured - same fixtures and parser on both runtimes
The Worker failure is memory, not CPUMeasured - GraphQL analytics reported exceededMemory; the 128 MB isolate limit is documented13
Date.now() does not advance during Worker CPU executionDocumented14 and observed as 0 ms extract time
Storage serves HTML as text/plain with nosniffMeasured - response headers plus a screenshot of raw source rendering
SQL/PGQ committed 2026-03-16, rewritten in the rewriterDocumented - commit message and commitfest entry69
PG18 is the newest supported major seriesDocumented10
Apache AGE has PG17/PG18 release candidates and a PG19 branchDocumented - upstream tags and branches8
pgpdf parses via poppler; pg_ocr needs plpython3uDocumented1112
Only plpgsql and plpgsql_check procedural languages availableMeasured - catalogue query for ^pl
Maximum disk size for a single projectNot discoverable via the Management API. Billing/addons surfaces probed again 2026-08-11 (3 endpoints; only throughput fields exist). The ceiling question stays open - the experiment project itself measured 88% disk full at the 2026-08-11 audit
Person/organisation extraction qualityMeasured - 561 persons + 2284 orgs extracted live 2026-08-11; a 24-row sample showed name truncation, newline-split labels and sentence over-capture; recorded as a finding rather than tuned toward plausible
20 of 1521 citation entities span 2+ documents (18 in two, 2 in three)Measured - cross_document_entities() over the loaded corpus, 2026-08-11
Dates on edges, registry-identifier resolution, the 111-document real-pipeline runMeasured, 2026-08-14 - doc_date on mentions and edges via a BEFORE INSERT trigger, a hub entity resolving to 0 neighbours as at 2020-01-01 and 171 as at 2026-12-31; 148 ABN-shaped candidates gated by the mod-89 checksum to 14 valid mentions and 3 entities; 103 AU council documents loaded and extracted (292 persons / 123 organisations, 31.6 s wall clock for re-extract plus edge build, per-genre ratios 0.0011-0.0362, TOAST 0.4893)
Hub traversal semantics on a real co-occurrence graphMeasured - a degree-917 hub turned the path-set (UNION ALL) depth-3 walk into 65721.220 ms server-side; the level-set (UNION) walk answers the same query in 154.924 ms warm, same project, 2026-08-14
Edge-cached read surfaceMeasured - the worker caches POST bodies under synthetic GET cache keys, 6 h freshness via x-cached-at, serve-stale on origin failure; x-pggraph-cache HIT/MISS/STALE verified on the deployed domain, 2026-08-14
Functions created by hand in a live database do not survive a rebuildMeasured - demo.documents() existed in no committed file, only in an older live project, and a fresh rebuild shipped a UI whose corpus table 404’d (PGRST202). It is defined in demo/db/04-api.sql now
Browser-level end-to-end proof of the deployed demoMeasured - Playwright suite (demo/e2e/) over the live domain: landing, as-at bridging, entity graph + timeline + deep-link persistence, the registry-pin search flow, and the origin-down diagnostic state
Recall or quality of any vector indexNot measured. Synthetic vectors cannot support a recall claim
halfvec is 0.5042 of vector on identical columns; HNSW build 6950 ms and query 0.43 ms against IVFFlat build 4130 ms and query 46.13 ms at lists=100Measured - G05, 30000 chunks x 1536 dimensions, synthetic values, 2026-08-10; storage, build time and latency are value-independent, recall is not
Disk throughput is a separate addon field, not a function of disk size: baseline_disk_io_mbs=347, max_disk_io_mbs=2085Measured - G06, /projects/{ref}/billing/addons, 2026-08-10
Cost per document: about 430 KB source to about 11 KB logical to about 5.4 KB on disk for council minutes; a 4 TB corpus at about $92/mo against about $239/mo at the US federal ratioMeasured inputs (G12, 2026-08-14) times Supabase list prices dated 2026-08-10; compute not priced
Scanned / image-only PDFsDetection measured - an image-only NARA scan extracted 0.5 chars/page against 2710-3242 for born-digital references, so ~270 chars/page routes a document to OCR. OCR itself remains unavailable in-database: plpython3u absent from pg_available_extensions, verified by query 2026-08-11
Behaviour under concurrent traversalMeasured - depth-3 CTE at concurrency 1/4/16/64 on the 100000/400000 graph: p50 0.29 ms aggregate, p95 ~3 ms, 0 errors in 85 queries, same 2-vCPU medium, 2026-08-11. pgrouting algorithms remain single-query

Rows resting on the fusion run, the search tier, the sbperf audit or method notes are recorded in pdf-corpus-graph/RUNLOG.md rather than as modules.

ModuleExperimentTestArtifact
G02pdf-corpus-graphg02-edge-function-pdf-extraction.tsnone published
G05pdf-corpus-graphg05-vector-index-shape.tsnone published
G06pdf-corpus-graphg06-disk-configuration-surface.tsnone published
G07pdf-corpus-graphg07-pgrouting-on-entity-graph.tsnone published
G09pdf-corpus-graphg09-scanned-pdf-ocr.tsnone published
G12pdf-corpus-graphRUNLOG-
W13edge-resiliencew13-function-timeout.tsout/2026-08-16, out/2026-08-17
  1. Supabase, database extensions. https://supabase.com/docs/guides/database/extensions ↩

  2. pgRouting manual. https://docs.pgrouting.org/latest/en/index.html ↩

  3. PostgreSQL, recursive queries with WITH. https://www.postgresql.org/docs/current/queries-with.html ↩

  4. Supabase, GraphQL via pg_graphql. https://supabase.com/docs/guides/graphql ↩ ↩2

  5. PostgreSQL, ltree. https://www.postgresql.org/docs/current/ltree.html ↩ ↩2

  6. PostgreSQL commit, SQL Property Graph Queries (SQL/PGQ), 2026-03-16. https://www.postgresql.org/message-id/E1w247I-0000Tk-2Y%40gemulon.postgresql.org ↩ ↩2 ↩3

  7. pgRouting, pgr_dijkstra, including the Edges SQL inner-query contract. https://docs.pgrouting.org/latest/en/pgr_dijkstra.html ↩ ↩2 ↩3

  8. Apache AGE. https://age.apache.org/ ↩ ↩2

  9. PostgreSQL commitfest entry 4904, closed Committed in PG19-Final. https://commitfest.postgresql.org/patch/4904/ ↩ ↩2

  10. PostgreSQL, supported version list. https://www.postgresql.org/versions.json ↩ ↩2

  11. pgpdf, a PDF type for Postgres, parsing via poppler. https://pgxn.org/dist/pgpdf/ ↩ ↩2

  12. pg_ocr, OCR in Postgres via PL/Python3U and ONNX Runtime. https://github.com/Z-Xiao-M/pg_ocr ↩ ↩2

  13. Cloudflare Workers, platform limits. https://developers.cloudflare.com/workers/platform/limits/ ↩ ↩2

  14. Cloudflare Workers, security model - timers only advance after I/O. https://developers.cloudflare.com/workers/reference/security-model/ ↩ ↩2

  15. pgmq, a Postgres message queue. https://github.com/pgmq/pgmq ↩

  16. pg_cron, a job scheduler for Postgres. https://github.com/citusdata/pg_cron ↩

  17. Supabase Queues. https://supabase.com/docs/guides/queues ↩

  18. Supabase, automatic embeddings - the documented queue-plus-consumer architecture. https://supabase.com/docs/guides/ai/automatic-embeddings ↩

  19. Fly.io, autostop and autostart machines. https://fly.io/docs/launch/autostop-autostart/ ↩

  20. Fly.io regions. https://fly.io/docs/reference/regions/ ↩

  21. Supabase, “Manage your usage: disk size,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/disk-size ↩

  22. Supabase, “Storage pricing,” Supabase Docs. https://supabase.com/docs/guides/storage/pricing ↩