Graph database for Obscura — Neo4j vs. what we already run
Date: 2026-08-15 · Status: research, no decision taken
TL;DR: Don't add Neo4j as infrastructure. The graph-shaped queries Obscura runs today are
shallow tree walks that Postgres already handles; the one genuinely graph-flavoured
opportunity (a knowledge graph feeding Ask-the-Archive) is better served by Apache AGE —
openCypher inside the Postgres we already ship — which inherits our backups, tenancy and
air-gap story instead of breaking all three.
1. What is actually graph-shaped in Obscura today
Measured against the codebase, not imagined:
| Relationship | Where it lives | How it's queried |
|---|---|---|
| Folder tree (ACL inheritance, retention, step-up, encryption gates) | folders.parent_id |
Go-side ancestor climbs (maxFolderClimb = 64) + 7 recursive CTEs across 3 adapters |
| Org authority (positions → roles, delegations, assistants) | positions, position_assignments, delegations |
joins; delegation binds at fan-out |
| Document ↔ letter ↔ workflow links | document_links, letter_document_links, letter_dispositions |
direct joins, 1–2 hops |
| ACL (deny-wins, groups, detach) | acl_entries, content_acl, group_members |
resolved per-request, already performance-tuned |
| Audit/seal provenance | append-only hash chain (mig 00147) | linear scans by design |
The honest observation: every one of these is ≤2 hops or a bounded tree climb. Postgres
recursive CTEs and the existing Go walks handle them. Nothing here waits on a graph engine —
there is no "find shortest path across 6 hops" query anywhere in the product, and none of the
features on the roadmap need one.
2. Where a graph would genuinely add something
Two candidates, one strong:
A. Knowledge graph under Ask-the-Archive (GraphRAG). Today retrieval is pgvector
similarity (mig 00071) — it finds passages that sound like the question. It cannot answer
multi-hop questions: "which agreements with vendor X carry termination clauses and were
signed by someone in Direktorat Y?" That needs entities (people, orgs, dates, clause types)
extracted at enrichment time into a graph, walked at ask time, with the vector index picking
entry points. This is the strongest real use — and it pairs directly with the
contract-intelligence idea (§5).
B. A "related documents" exploration UI — letter chains, disposisi trails, who-signed-what.
Nice, but the data is 1–2 hops and buildable on the existing tables; a graph DB is not the
blocker, a UI is.
3. Why Neo4j specifically is a poor fit for this product
- 🔴 Community Edition is one database, no RBAC, no clustering, no hot backup — those are
Enterprise (commercial). Cloud/VM2 is schema-per-tenant in one Postgres; on Neo4j CE,
tenant isolation would be entirely app-side discipline in every Cypher query, which is
exactly the class of bug the tenant-boundary audit exists to prevent. - 🔴 It breaks the backup doctrine. Backups are
pg_dump+mc mirror, age-encrypted,
pull-based, with a rehearsed restore. A Neo4j store sits outside all of it; CE has no
online backup, so it's either downtime dumps or an unprotected data set holding a copy of
the archive's most sensitive metadata. - A new always-on JVM service in a compose that already runs 15+ services with 3–4 GiB
sidecars, on hosts where we've had a dockerd crash under load (VM2) and 87% disk (demo).
Air-gapped installs gain another image, another licence conversation, another thing doctor
must probe. - Dual-write consistency. Postgres stays the source of truth (ACLs, retention, legal
hold are non-negotiably relational), so every document/link/position change must be
mirrored into the graph. That sync layer is where these projects actually die.
4. The recommended shape — if we want graph features
Phase 1 — prove the value with zero new infrastructure. Build the entity extraction
(the AI enrichment pipeline already exists — provider config mig 00146, embed sidecar) into
plain tables: entities, entity_mentions, entity_edges. Feed Ask-the-Archive retrieval
from vector hits expanded one hop through those tables. If answer quality doesn't move,
stop — we learned it cheaply.
Phase 2 — Apache AGE if the walks get deep. openCypher as a
Postgres extension: PG 11–18 supported, ASF-licensed, active (Jan 2026 release added
row-level security), shipped as a managed extension on Azure. Same database ⇒ same
pg_dump backup, same schema-per-tenant isolation, same transaction as the source-of-truth
write (no dual-write problem at all), nothing new in compose. The swap-in cost is a
custom Postgres image (pgvector + age), which we already do for other services.
Phase 3 — Neo4j only if a dedicated graph-analytics workload emerges that AGE
measurably can't serve (community-detection at millions of edges, Bloom-style visual
exploration as a sold feature). That's a licence + ops decision to make then, with
numbers.
4b. When to actually revisit AGE — written down so it stops being a judgement call
Reviewed 2026-08-18. Phase 1 shipped in part: entities + entity_aliases (mig 00198)
and a one-hop expansion feeding Ask-the-Archive. entity_edges never shipped, which is why
the product still has effectively one edge type — an entity is party to a contract:
SELECT DISTINCT cp.document_id FROM contract_parties cp
JOIN documents d ON d.id = cp.document_id AND d.deleted_at IS NULL
WHERE cp.entity_id = ANY($1) -- internal/contracts/adapters/entity_pg.go
Adopting AGE on top of that would be a graph engine querying a graph with one kind of edge in
it. The successor design is document_edges, a derived table
(docs/superpowers/specs/2026-08-18-investigation-module-design.md) — and once its rows are
uniform (src_kind, src_id, rel, dst_kind, dst_id), an arbitrary-depth walk over
heterogeneous edges is a single self-join, which is most of what AGE was wanted for.
🔴 The trigger is a query shape, not a row count
AGE buys expressiveness, not speed. It stores its graph in Postgres tables under
ag_catalog — it is not index-free adjacency the way Neo4j is — so for a simple traversal it
is doing broadly what a well-indexed recursive CTE does. Benchmark before believing any
performance claim; do not adopt it expecting one.
Adopt when someone asks for a query in this list, because these are the ones a recursive CTE
answers badly:
- Shortest path / all paths between two nodes — needs manual cycle detection and path
accumulation in SQL, and degrades fast. - Cycle detection — "find a vendor → signer → vendor loop", the procurement-fraud query.
- Per-hop predicates on a variable-length pattern — "any-length paths whose intermediate
edges are all signatures but whose endpoints are entities". - Maintenance mass — four or more distinct traversal queries each written as a bespoke
40-line CTE. This one stands alone even when every individual query performs fine.
The scale axis, with real numbers
Measured on the demo (2026-08-18): 178 documents → ~259 edges from the projection sources,
≈1.5 edges per document. A mature archive runs 3–8, so a 1M-document B2G deployment
projects to 3–8M edges.
| Edge count | Depth ≤ 3 | Depth ≥ 4 |
|---|---|---|
| < 1M — essentially every deployment | trivial | fine |
| 1–50M — a very large B2G archive | fine with indexes + hop caps | watch the hubs |
| > 50M and routine depth ≥ 4 | wants a real planner | and the question becomes AGE or Neo4j |
Obscura will essentially never reach a scale where AGE is needed for performance. If it is
ever adopted, it will be for query shape.
🔴 The trap: the real enemy is branching factor, and AGE does not fix it
What kills a traversal is a hub, not table size — a ministry that is party to 5,000
contracts, a Direktur Utama who signed 20,000 documents. A depth-3 walk from a hub touches
millions of rows in any engine. The fix is product design: cap hops, cap fan-out per hop,
and say in the UI when the result was truncated. Swapping the storage engine changes nothing
here.
The test to run when a trigger fires
- Write the query as a recursive CTE anyway.
EXPLAIN ANALYZEagainst the largest real corpus available.- Slow → it is hubs; AGE will not save you, fix fan-out instead.
- Fast but unmaintainable → this is the real AGE case, and it is an honest one.
- Only then: custom
pgvector+ageimage, and think hard aboutag_catalogunder
schema-per-tenant on VM2 before it touches a live multi-tenant database.
5. The "Icertis" pairing (to confirm)
If the second half of the ask is Icertis-style contract intelligence, the pairing makes
sense as one feature, not two technologies: extract parties / dates / values / obligations /
clause types at enrichment → structured fields + graph edges → obligation deadlines feed the
existing scheduler + notification pipeline (renewal reminders), clause library builds on the
templates module, risk flags on the classifications the DLP registry already carries. That
is an Obscura module (licensable, like ai/office), not an integration with Icertis —
which is a competing CLM platform, not a component: it brings its own repository, workflows
and e-sign, so adopting it means paying for what we already ship. It does offer on-premise
and hybrid deployment alongside its Azure SaaS, so the air gap is a cost, not the reason.
Sources: Neo4j open-core licensing FAQ ·
Neo4j CE limitations (community) ·
Neo4j — Wikipedia ·
Apache AGE release notes ·
AGE PG17/18 roadmap discussion ·
AGE on Azure PostgreSQL ·
What is Contract Intelligence — Icertis ·
Icertis platform