Build a public-record entity graph on managed Postgres
This builds the thing the reference doc argues for: a corpus of public PDFs turned into an entity graph - people, organisations, checksum-validated ABNs, dated co-occurrence edges - queryable through a handful of read-only RPCs, with a static UI cached at the edge. The reference doc explains why and carries the measured latencies; this guide is the how, in order, with a verification step per part.
The full working tree for everything below is the pdf-corpus-graph
experiment in supabase-lab
(experiments/pdf-corpus-graph/). Fragments here are load-bearing excerpts;
clone that for the complete files.
Prerequisites: a Supabase Personal Access Token, OpenTofu, psql, bun,
poppler-utils (pdftotext, pdfinfo, pdftoppm), tesseract, and a
Cloudflare account for the serving worker. No ML models, no graph database,
no message broker.
Constants (read this first)
Section titled “Constants (read this first)”Everything that follows is a consequence of these measured facts. If your build contradicts them, something is wrong - they are the drift detectors.
| Fact | Value | Where it came from |
|---|---|---|
| Extension catalogue (PG 17.6, both ap-southeast regions probed) | pgrouting 3.4.1, pg_trgm 1.6, fuzzystrmatch 1.2, unaccent 1.1, vector 0.8.2, pgmq 1.5.1 present; age/apache_age/sqlg absent | catalogue queries 2026-08-10 and 2026-08-14 |
| Extraction ratio (extracted text / source bytes) | 0.0256 aggregate (AU council minutes) to 0.7633 aggregate (US federal) - genre swings it ~30x | measured on both corpora |
| TOAST compression on the extracted text column | 0.4893 (AU) / 0.3965 (US) of logical bytes | pg_column_size on loaded rows |
| Scanned-document detection threshold | ~270 chars/page: born-digital references run 2710-3242, an image-only scan 0.5 | G09 probe |
| Recursive-CTE traversal, real 28778-edge hub graph, depth 3 | 154.924 ms warm - and 65721.220 ms if the walk enumerates paths instead of level sets | 2026-08-14, medium compute |
| Full corpus rebuild from nothing | ~446 s provision + schema + seed + deploy | make up |
| End-to-end extraction over 111 documents | 31.6 s wall clock | the re-extract run |
Architecture
Section titled “Architecture”The database never parses a PDF. The loader does that on boring local compute (the Edge Function ceiling is 2504695 bytes and real documents blow through it - see the reference doc), writes rows, and SQL does everything else.
Part 1 - Provision the project
Section titled “Part 1 - Provision the project”One supabase_project resource via the Supabase OpenTofu provider: medium
compute, the region your readers are in (extension catalogues are per-region
per-platform-version until probed - this guide’s numbers are verified for
ap-southeast-1 and ap-southeast-2). Put the org id in a gitignored,
SOPS-encrypted tfvars; never the committed one.
After apply, wait for ACTIVE_HEALTHY on the Management API health endpoint,
then enable the extensions:
create extension if not exists pgrouting cascade; -- pulls postgiscreate extension if not exists pg_trgm;create extension if not exists fuzzystrmatch;create extension if not exists unaccent;create extension if not exists vector;Verify: select name, default_version from pg_available_extensions where name in ('pgrouting','pg_trgm','vector') shows the catalogue versions, and
select count(*) from pg_proc where proname like 'pgr\_%' returns 209.
Part 2 - Acquire a corpus engineered for overlap
Section titled “Part 2 - Acquire a corpus engineered for overlap”The corpus is chosen, not found. The property you want is recurrence: one issuing body’s documents across consecutive years, so the same people and organisations appear in dozens of them. Council minutes plus committee minutes plus the same council’s contracts registers and tender notices is the shape that works (the reference build’s US federal corpus, seven unrelated documents, produced 1.3% bridging - arithmetic, not a flaw).
Two acquisition tricks that matter:
- Find the enumerable archive. Councils on WordPress expose
/wp-json/wp/v2/media?search=minutes&per_page=100- a paginated, filterable JSON listing of every PDF. Enumerate the listing; never guess URLs. - Fetch with a browser UA. Council sites 403 non-browser agents.
Write a manifest (slug, genre, doc_date, source_url, source_bytes per
document), parse the meeting date out of the filename, dedupe by content
hash, and commit it - the manifest is the corpus definition. The loader then
fetches (or cache-hits), extracts text, and routes:
const pages = Number(pdfinfo.match(/^Pages:\s+(\d+)/m)?.[1] ?? "0");const text = await $`pdftotext ${pdf} -`.text();if (pages > 0 && text.length / pages < 270) { // image-only scan: pdftoppm -r 200 -gray, then tesseract per page}Bulk-load with one staged \copy (CSV handles embedded newlines; per-row
inserts are quoting roulette at tens of MB).
Verify: select genre, count(*), min(doc_date), max(doc_date) from corpus.documents group by 1 shows your corpus, and every row has
extracted_text non-null. The 103-document reference corpus measured
44317522 source bytes producing 1134495 extracted bytes.
Part 3 - The schema
Section titled “Part 3 - The schema”Four tables: corpus.documents (the text + provenance + doc_date), and
demo.entities / demo.mentions / demo.edges. The edge table’s columns
are named exactly id, source, target, cost because that is the
pgr_* inner-query contract (ANY-INTEGER for the first three) - a uuid
key cannot be handed to the algorithms at all, so entities carry a surrogate
bigint generated always as identity.
create table demo.edges ( source bigint not null references demo.entities(id), target bigint not null references demo.entities(id), doc_slug text not null references corpus.documents(slug), weight int not null default 1, doc_date date, -- the time axis, filled by trigger cost double precision generated always as (1.0 / weight) stored, id bigint generated always as identity unique, primary key (source, target, doc_slug));create index edges_source_idx on demo.edges(source);create index edges_target_idx on demo.edges(target);cost = 1/weight makes the cheapest Dijkstra path the most strongly
evidenced one, which is the question a reader actually asks. Both edge
columns are indexed: that pair of indexes is the difference between a
167.59 ms and a 0.22 ms depth-3 traversal.
doc_date is nullable and the US fixtures stay null on purpose: as-at
queries exclude undated edges, which is the honest reading of “as at” - only
what was known by then.
RLS goes ON for every table with ZERO policies (deny by default) and no table
grants to anon/authenticated. The entire read surface is functions.
Part 4 - Extraction, with a checksum gate
Section titled “Part 4 - Extraction, with a checksum gate”The extractor shape that survives contact with real corpora is set-based SQL:
regexp_matches with WITH ORDINALITY for the matches,
regexp_split_to_table with ordinality for the parts, window sums to
reconstruct character offsets. Two traps are encoded in the reference
implementation’s comments: Postgres’s word boundary is \y (\b is
backspace and silently matches nothing), and every regex group must be
non-capturing because the machinery reads (t.m)[1].
Persons and organisations are honorific/suffix patterns - Cr X,
Councillor X, Mayor X, ... Pty Ltd with the suffix case-flexible
(council resolutions are set in ALL CAPS; the first pass missed 3 of 103
documents). Precision is candidate-grade and that is stated wherever the
numbers are quoted.
Registry identifiers are where this stops being fuzzy. An ABN is eleven digits with a published check:
create or replace function demo.valid_abn(d text)returns boolean language sql immutable as $$ with digits as (select regexp_replace(d, '[^0-9]', '', 'g') as v), w as (select array[10,1,3,5,7,9,11,13,15,17,19] as weights) select case when length(v) = 11 then ( (substring(v from 1 for 1)::int - 1) * weights[1] + (select sum(substring(v from i + 1 for 1)::int * weights[i + 1]) from generate_series(1, 10) as i) ) % 89 = 0 else false end from digits, w$$;Candidates are filtered through it BEFORE insert: shape proposes, arithmetic
disposes. On the real corpus that was 148 digit-group candidates, 14 valid
mentions, 134 rejected, zero false positives. The case when length(v) = 11
guard is load-bearing - without it, shorter strings cast empty substrings to
int and the function errors at runtime.
Test the gate with known vectors before trusting any extraction:
select demo.valid_abn('45 153 592 173'), -- t (a real corpus ABN) demo.valid_abn('45 153 592 174'), -- f (one digit flipped) demo.valid_abn('89 001 288 400'); -- tAnd one trigger carries the time axis through every insert path, so no extractor or edge builder needs to know about dates:
create or replace function demo.fill_doc_date() returns trigger language plpgsql as $$begin if NEW.doc_date is null then select doc_date into NEW.doc_date from corpus.documents where slug = NEW.doc_slug; end if; return NEW;end $$;Part 5 - Edges
Section titled “Part 5 - Edges”An edge is co-presence within 400 characters of extracted text, rebuilt in
one statement from the mentions’ window sums, on conflict adding weights.
At this corpus scale the rebuild takes about a second. Then
refresh_counters() denormalises per-entity mention/document counts for the
read RPCs.
Part 6 - The read surface, and the hub trap
Section titled “Part 6 - The read surface, and the hub trap”Every read is a stable, security definer function with
set search_path = demo, corpus, public, extensions, granted to
anon, authenticated; tables stay ungrantable. The set: stats,
documents, search_entities (tiered exact -> punctuation-insensitive ->
prefix -> trigram), search_documents, provenance (byte-exact offsets),
neighbourhood, shortest_path, components, subgraph,
cross_document_entities, plus the editorial five: entity_timeline,
bridges_as_at, neighbourhood_as_at, entity_registry_ids, and an
entity_get lookup for deep links.
The as-at variants are the same walk with and g.doc_date <= p_as_of in the
edge predicate. entity_registry_ids is not a new graph at all - it reads
the existing co-proximity edges filtered to kind='abn', so “this
organisation printed this ABN in this document” falls out of machinery
already built.
Part 7 - PostgREST exposure
Section titled “Part 7 - PostgREST exposure”Widen db-schema in project config to public, demo, send
Content-Profile: demo on every request, and select pg_notify('pgrst', 'reload schema') after any DDL that adds or changes functions - a stale
schema cache reads as PGRST202 “no matches found” on functions that
obviously exist.
Verify the security posture as queries, not vibes:
-- zero tables without RLSselect count(*) from pg_tables where schemaname in ('demo','corpus') and not rowsecurity; -- 0-- anon reading a table directly: 42501curl -s https://$REF.supabase.co/rest/v1/entities?limit=1 -H "apikey: $ANON" -H 'Accept-Profile: demo'Part 8 - Serving, with an edge cache
Section titled “Part 8 - Serving, with an edge cache”A static UI can live anywhere except Supabase Storage (it serves HTML as
text/plain with nosniff - a deliberate anti-stored-XSS posture, no flag
to change it). The build uses a Workers static-assets deploy with a small
script that owns /rest/* only: POST bodies hashed into synthetic GET cache
keys, freshness tracked with x-cached-at against a six-hour TTL, and
serve-stale regardless of age when the origin errors - the demo keeps
answering while the disposable project is down.
const key = new Request(`https://pggraph.cache/${CACHE_VERSION}${url.pathname}?h=${hash}`);const cached = await caches.default.match(key);if (cached && Date.now() - Number(cached.headers.get("x-cached-at")) < CACHE_TTL_MS) return withMark(cached, "HIT");// ... origin fetch; on !ok or throw: if (cached) return withMark(cached, "STALE");const store = new Response(origin.clone().body, origin);store.headers.delete("set-cookie"); // Cache API rejects put() with Set-Cookiestore.headers.set("x-cached-at", String(Date.now()));ctx.waitUntil(caches.default.put(key, store));The set-cookie strip is not decorative: Cloudflare’s edge adds __cf_bm to
every response, the put throws inside waitUntil, and the symptom is a
permanent MISS with no error anywhere. Also note cf-cache-status: DYNAMIC
on responses is the zone edge cache saying nothing - it is a different
system from the Cache API and never applies to POST RPCs; trust only your own
x-pggraph-cache header.
Part 9 - Verify with a browser
Section titled “Part 9 - Verify with a browser”The demo’s e2e suite is five Playwright specs against the deployed site:
landing stats, the as-at filter emptying and restoring the bridging table
(await the RPC response before asserting, or the test proves nothing), the
registry pin showing 45 153 592 173 for the organisation that printed it,
selection surviving a reload via the ?entity= deep link, and the
origin-down state showing the diagnostic panel. Serial, read-only, ~7
seconds.
Lifecycle
Section titled “Lifecycle”Everything above lives in committed files and one make up reproduces it
(~446 s). Two lessons are encoded in the runbook because they cost real
incidents: never create a function by hand in the live database (a hand-made
demo.documents() survived nowhere and a fresh rebuild shipped a UI whose
corpus table 404’d), and never run a destructive seed path against a
populated project - the rebuild path is destroy-and-recreate, and the data
manifest plus loader is the idempotent layer.
What to do about it
Section titled “What to do about it”Every row names the RUNLOG section of the pdf-corpus-graph experiment, or
the supabase-lab drill, it rests on.
| Practice | Rests on |
|---|---|
Run loader SQL over the session pooler (5432) or the direct host, and put set statement_timeout in its own psql -q -c; PGOPTIONS does not survive the transaction pooler and show statement_timeout reads the 2min role default regardless | method notes |
| Cache fetched PDFs on disk keyed by content hash and never re-fetch: at 2504695 bytes the fetch was 9788 ms of an 11601 ms request, extraction 1663 ms | G02 |
| Do not derive a per-document timeout from byte size: a 6073678-byte fixture failed in 4105 ms, a 3595043-byte one took 16906 ms, and the two 14 MB fixtures ran 57165 ms and 60136 ms | G02 |
Implement search_documents as a STORED tsvector generated column with a GIN index, queried with websearch_to_tsquery so raw user input does not error; the index was 3192 kB and the inner query 112.6 ms median on 7 rows, where the planner chose a sequential scan | search tier |
To profile the read RPCs, EXPLAIN ANALYZE the inner query or set pg_stat_statements.track = all: security-definer bodies do not inline (the function-call plan shows only Function Scan) and never appear in top-by-time under track = top | search tier; sbperf audit |
Watch disk during load: the maximum disk size for a project is not discoverable via the Management API (only baseline_disk_io_mbs / max_disk_io_mbs exist) and the experiment project hit 88% full | G06, G08; sbperf audit |
After ACTIVE_HEALTHY, poll GET /v1/projects/{ref}/health?services=auth&services=rest&services=db and retry Storage or admin calls that answer TenantNotFound, 429 SlowDown or 500 "Database error checking email" for the first minutes: a fresh project reported healthy in 154 s (133 s in the 2026-08-17 battery) and its Storage tenant still answered TenantNotFound then 429 SlowDown for the first minutes (W21); on two other fresh projects the first POST /auth/v1/admin/users failed with 500 "Database error checking email" and succeeded about ten seconds later (provisioning note 2026-08-03) | edge-resilience W21; supabase-lab provisioning note 2026-08-03; platform-facts F02d asserts the same and has no recorded run |
Pre-warm every RPC the landing page issues after each deploy (a CACHE_VERSION bump invalidates every key): warm URLs served 200 HIT with the origin unroutable; a cold URL fails - the Workers runtime hands the worker’s fetch() a 403 response for the unroutable origin rather than throwing, so a catch-only fallback never fires, and the drill worker, once taught to treat 403 as origin failure, returned 503 to the client (artifact 2026-08-16) | edge-resilience W04, W24 |
| Retry the MISS -> HIT check from the same colo a few times before calling it a failure: the Cache API store is per-colocation with an asynchronous write, so two back-to-back probes can both MISS | the reference doc’s hosting note (Cloudflare Cache API semantics); no run measured a double MISS |
Budget a rebuild by phase: of the 446 s, citation extraction is 338 s, tofu apply 4 s, seed restore 1 s - only extraction is worth parallelising | Cold rebuild |
The 400-character co-proximity window, the 1000-row \copy batch, medium
compute, the six-hour cache TTL and the top-24 / top-3 renderer cut are design
choices; no run measured an alternative.
Verification checklist
Section titled “Verification checklist”- Catalogue probe returns the expected extension versions for YOUR region
- Corpus rows all carry extracted text; scanned docs routed to OCR
-
valid_abnpasses the known-vector trio - Every mention and edge on dated documents has
doc_date - An as-at query at a pre-corpus date returns zero rows
- RLS: zero unprotected tables; direct table read returns 42501
- A hub entity’s depth-3 neighbourhood answers in hundreds of ms, not a minute
-
x-pggraph-cacheflips MISS -> HIT on a repeated RPC - The Playwright suite passes against the deployment