Neo4j Graph Model
scinr builds a connected graph representation of input documents and extracted domain concepts inside Neo4j. This guide covers the node types, relationships, and query patterns produced by the scinr.newton extraction engine.
The graph serves as the primary output store. After a document passes through the Newton pipeline, all structural elements and extracted entities are persisted as interconnected nodes and relationships, enabling complex cross-document queries that would be difficult or impossible with a flat relational schema.
Two distinct pipelines produce different graph structures:
- Unstructured pipeline — processes PDFs, Word documents, and other narrative sources. Produces a hierarchy of headings, paragraphs, tables, and figures, with entities extracted from specific sections.
- Tabular pipeline — processes CSV, Excel, and other structured tables. Produces one model instance per row, with labeled entities for stable, deduplicated fields.
Node Types
:Document
Represents the original input file or folder that was ingested into the system. Every document in the graph is a :Document node.
(:Document {
name: "clinical_trial_report",
path: "/path/to/clinical_trial_report",
version: 1,
load_date: 2024-01-15T10:30:00,
latest: true,
is_folder: false,
tenant_id: "acme-corp",
created_by_user_id: "user-42",
job_id: "ingest-2026-09-06-a"
})
| Property | Type | Description |
|---|---|---|
name |
String | Name of the file or folder as provided at ingestion time. Without extension. |
path |
String | Full path identifier for this document within the ingestion hierarchy. Without extension. |
version |
Integer | Version number. Starts at 1; increments on replacement or update. Numbered per tenant: another tenant's document at the same path does not affect it. |
load_date |
DateTime | Timestamp when the document was first ingested. |
latest |
Boolean | Indicates if this is the latest version of the document. |
is_folder |
Boolean | Indicates if this node represents a folder (true) vs a file (false). |
raw_file_id |
String | Storage-backend id of the stored raw file (empty for folders / when no storage backend is configured). |
context_instructions |
String | null | Free-text ingestion context passed via run_pipeline(context_instructions=...); null when not supplied. |
tenant_id |
String | Owner tenant from run_pipeline(tenant_id=...), or the reserved value "__public__" for a public document (no tenant supplied) — never null. Part of the document identity: a :Document is unique on (tenant_id, path, version). Written on leaf and folder-parent nodes. See Multi-tenancy. |
created_by_user_id |
String | null | Caller-supplied id of the user that launched the ingestion, from run_pipeline(created_by_user_id=...). Always set (null when not supplied). |
job_id |
String | null | Caller-supplied ingestion job/run id from run_pipeline(job_id=...). Always set (null when not supplied). Usable as a bulk-delete selector — see Document Deletion. |
frozen |
Boolean | true while the document is a frozen stub: its subtree is in a snapshot, not in the graph. Absent until the document is frozen for the first time, false after a restore. See Document Freezing. |
frozen_blob_id, frozen_at |
String | Id of the snapshot in the freeze backend, and ISO-8601 timestamp of the freeze. Only on a frozen stub; removed by the restore. |
frozen_keep_structure_nodes, frozen_keep_annotations, frozen_keep_extraction_results |
Boolean | The keep_* flags the document was frozen with. Only on a frozen stub. |
frozen_cleanup_pending |
Boolean | true from the moment the stub is marked until its subtree has been deleted and garbage-collected. Still there afterwards only if the freeze was interrupted. |
last_backup_blob_id, last_backup_at |
String | Snapshot and ISO-8601 timestamp of the last freeze_document(delete_after_export=False). |
deletion_pending |
Boolean | Set by delete_document() before it deletes anything. A :Document that still carries it belongs to an interrupted deletion: call delete_document() again. |
Multi-tenancy: the tenant is part of the document identity
- Key. A
:Documentis identified by(tenant_id, path, version)(uniqueness constraintconstraint_document_tenant_path_version). Two tenants that ingest the same path get two independent uploads: their own leaf and folder:Documentnodes, versions, structure, annotations and extractions, with no relationship between them. Versioning (latest,HAS_NEWER_VERSION),update_mode,replaces, annotation, entity extraction anddelete_document()only ever select documents of one tenant. - Public documents. A document ingested without a tenant is public: readable by every tenant, but still its own upload. It is stored with
tenant_id: "__public__"(a non-null value, so it is indexed, usable inMERGE, and covered by the uniqueness constraint); on the write sideNoneand"__public__"are the same thing (public), and""is rejected. Reads expose the stored value ("__public__"), soNonestays free to mean "no tenant filter" — see Graph Navigation — Scope. - Everything below inherits it. Every per-document node (
:StructureNode,:InfoUnit,:ModelDecision,:ExtractionResult, …) carries the same scalartenant_id, and the tenant is folded into theuidof the content-deduplicated nodes (:ModelInstance,:LabeledEntity,:Entity), so identical content from two tenants — or from a tenant and a public document — is never merged into one node. A tenant readingtenant_id IN [$tenant, "__public__"]may therefore see two:ModelInstancenodes for the same key (its own and the public one). - No relationship crosses tenants — public included. Every relationship an extraction writes targets a
uidcomputed with its own tenant, so data relationships always stay inside one tenant. This is what lets the navigation API filter the tenant on the starting node only; the price is that a tenant's content is never linked to public content. See Graph Navigation — Why a traversal cannot leave the tenant. - Global catalog.
:CatalogModel,:ModelField,:EntityLabeland:Themeare shared by all tenants.
// Tenant acme's latest documents plus the public ones
MATCH (d:Document {latest: true})
WHERE d.tenant_id IN ["acme", "__public__"]
RETURN d.tenant_id, d.path, d.version;
:StructureNode
Represents a structural element within a document — a section, subsection, freeform block, table, field group, appendix, or row. Structure nodes form a tree rooted at the document.
(:StructureNode:Section {
id: "acme-corp::M3- Notice to applicant::1::1-foreword",
node_id: "1-foreword",
role: "section",
title: "Foreword",
theme: "pharmaceutical_quality",
source_page_ids: ["..."],
appearance_order: 1
})
| Property | Type | Description |
|---|---|---|
id |
String | Unique hierarchical identifier for this structure node (the primary key; a uniqueness constraint is enforced): {tenant_id}::{doc_path}::{version}::{ancestor node_ids/node_id} — e.g. __public__::M3/doc::2::5_3/5_3_1 for a nested node of a public document. The tenant prefix keeps two tenants' structure apart when they ingest the same path; the uids of :InfoUnit, :ModelDecision, :ExtractionResult and tabular rows are derived from it. Use this to address a node. |
node_id |
String | Short local identifier derived from the heading number or appearance-order slug. Convenient for display, but not guaranteed unique across the graph — do not use it as a key. |
role |
String | One of: section, subsection, freeform_block, table, field_group, appendix, row. Indexed. |
title |
String | Heading text, if this node represents a section heading. Null for leaf blocks like paragraphs. |
theme |
String | Theme path assigned to this node during extraction (default "default"). |
source_page_ids |
List | IDs of the converted source pages this node was extracted from. |
appearance_order |
Integer | 1-based order of appearance among sibling nodes. |
Key characteristics: Unlike :ModelInstance and :LabeledEntity (which use a single label plus a type property), :StructureNode genuinely carries a second dynamic label derived from its role property, applied via SET n:{RoleLabel} at ingestion time (see ingest/nodes.py). The mapping is deterministic PascalCase-of-role:
role (property) |
Second label |
|---|---|
section |
:Section |
subsection |
:Subsection |
freeform_block |
:FreeformBlock |
table |
:Table |
field_group |
:FieldGroup |
appendix |
:Appendix |
row |
:Row |
So a real node looks like (:StructureNode:Section {role: "section", ...}) — both the base label and the role-specific label coexist. The role property remains the recommended way to filter in Cypher (WHERE s.role = "table"), since it is indexed (idx_structure_node_role) and avoids needing to know the exact label spelling, but MATCH (s:Table) also works and is sometimes faster for label-scan-heavy queries.
:LabeledEntity
Represents a stable, deduplicated entity value extracted from a field marked with an entity_label in the schema. Labeled entities are created for fields that act as identifiers or cross-references — drug names, product codes, anatomical terms, etc.
(:LabeledEntity {
label: "ActiveSubstance",
value: "Metformin",
uid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
normalized_value: "metformin"
})
| Property | Type | Description |
|---|---|---|
label |
String | The entity label name (e.g., ActiveSubstance, AnatomicalStructure, ProductCode). |
value |
String | The canonical entity value. Used for deduplication across documents. |
uid |
String | Unique identifier for this specific entity node. |
normalized_value |
String | Normalized version of the value for case-insensitive matching. |
Key characteristics:
- Single label: The node has a single
:LabeledEntitylabel and uses alabelproperty to store the entity category. - Deduplication: If the same
valueis extracted from multiple documents of the same tenant, a single:LabeledEntitynode is reused. This enables cross-document entity matching and aggregation. Tenants (and public documents) never share one: the tenant is part of theuid. - Stable identity: The
uidremains constant for a given value, allowing reliable joins and traces.
:ModelInstance
Represents a single extracted entity record — one instance of a model class defined in the extraction schema. Each model instance stores all field values as node properties.
(:ModelInstance {
model_class: "AdverseEventModel",
uid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
event_type: "Headache",
severity: "Mild",
occurrence_count: 3
})
| Property | Type | Description |
|---|---|---|
model_class |
String | The model class name (e.g., AdverseEventModel, ProductRecord, LaboratoryResult). |
uid |
String | Unique identifier for this model instance. |
| (dynamic) | Varies | All model field values are stored as additional properties on the node. Property names match the field names defined in the schema. |
Key characteristics:
- Single label: The node has a single
:ModelInstancelabel and uses amodel_classproperty to store the model type. - One node per record: Each extracted entity produces exactly one model instance node. In the tabular pipeline, this corresponds to one row per node.
- Field values as properties: Every field defined in the model schema becomes a property on the node, enabling direct Cypher filtering without additional joins.
Other node types
These nodes complete the graph. The extraction, annotation, and catalog layers all use them.
| Label | Created by | Description |
|---|---|---|
:InfoUnit |
Ingestion (Stage 2) | The smallest semantic content unit of a :StructureNode — {uid, title, order, description}. Attached via [:HAS_INFO_UNIT]. The description / title fields back the fulltext search indexes. |
:ExtractionResult |
Entity extraction (Stage 4) | One per extracted :StructureNode. Anchors a section's extraction output; carries uid and model_class. |
:ModelDecision |
Annotation (Stage 3) | The annotation decision for a :StructureNode — {matched_model_class, confidence, reasoning}. |
:CatalogModel |
Catalog setup | A registered Pydantic extraction model. Keyed by name. |
:ModelField |
Catalog setup | One field of a :CatalogModel. Composite key (name, model). |
:EntityLabel |
Catalog setup | Schema-level entity_label singleton, keyed by label. |
:Theme |
Catalog setup | An extraction-model theme (from a catalog.py). |
:ProposedModel / :ProposedField |
Annotation | Details of a "no exact match" annotation proposal. |
:SupplementaryField / :ComplementaryMatch |
Annotation | Supplementary fields and complementary-model matches on a :ModelDecision. |
:Entity |
Triple fallback (Stage 4) | A subject/object node from the generic subject–predicate–object fallback used when no domain model matches. Global singleton, MERGE'd by normalized value. |
Role labels (:Section, :Subsection, :FreeformBlock, :Table, :FieldGroup, :Appendix, :Row) |
Ingestion | The second, role-derived label on every :StructureNode (see the :StructureNode section above). |
Relationship Types
Document Structure
These relationships form the hierarchical tree of a document.
| Relationship | From | To | Description |
|---|---|---|---|
[:HAS_STRUCTURE] |
:Document |
:StructureNode |
A document contains this top-level structure node. Connects the :Document only to its root structure node — never repeats between :StructureNodes. |
[:HAS_CHILD] |
:StructureNode |
:StructureNode |
Parent-child relationship in the document hierarchy. Headings point to their child headings, paragraphs, tables, etc. |
[:HAS_INFO_UNIT] |
:StructureNode |
:InfoUnit |
The semantic content units of a structure node. |
[:IS_COMPOSED_OF] |
:Document |
:Document |
Folder hierarchy — a folder document contains child documents. |
Extraction
These relationships link extracted entities back to the document sections they came from, enabling provenance tracing.
| Relationship | From | To | Description |
|---|---|---|---|
[:HAS_EXTRACTION] |
:StructureNode |
:ExtractionResult |
This section contains an extraction result. |
[:HAS_<FIELD>] |
:ExtractionResult |
:ModelInstance |
The extraction result contains this model instance (field name determines relationship type). |
[:REFERENCES] |
:ModelInstance |
:LabeledEntity |
A model instance references a globally deduplicated entity value (from an entity_label field). Always originates from :ModelInstance, never the reverse. |
[:USES_PRIMARY_MODEL] |
:ExtractionResult |
:CatalogModel |
The primary extraction model used for this section. |
[:USES_COMPLEMENTARY_MODEL] |
:ExtractionResult |
:CatalogModel |
A complementary model applied alongside the primary (0..*). |
Annotation & Catalog
| Relationship | From | To | Description |
|---|---|---|---|
[:HAS_MODEL_DECISION] |
:StructureNode |
:ModelDecision |
The annotation decision for this section. |
[:MATCHED_MODEL] |
:ModelDecision |
:CatalogModel |
The model class the annotation agent selected. |
[:HAS_FIELD] |
:CatalogModel |
:ModelField |
The declared fields of a catalog model. |
[:PRODUCES_ENTITY] |
:CatalogModel |
:EntityLabel |
An entity_label produced by one of the model's fields. |
[:BELONGS_TO_THEME] |
:CatalogModel |
:Theme |
The theme a catalog model belongs to. |
[:AGGREGATES] |
:CatalogModel |
:CatalogModel |
A parent (list-wrapper / aggregate) model that contains other models. |
Triple Fallback
When no domain model matches a section, a generic subject–predicate–object fallback runs instead.
| Relationship | From | To | Description |
|---|---|---|---|
[:HAS_ENTITY {role}] |
:ExtractionResult |
:Entity |
A subject or object entity of a fallback triple (role distinguishes them). |
Custom predicate (UPPER_SNAKE_CASE / normalized) |
:Entity |
:Entity |
The predicate edge of a triple. Predicate types are open-ended — there can be thousands of distinct ones; the Graph Navigation API filters them out by default. |
Entity Relationships (from field_relationships)
When a schema defines field_relationships between labeled entity fields, the graph creates domain-specific edges between :LabeledEntity nodes. The relationship type is the field relationship name in UPPER_SNAKE_CASE.
| Relationship | From | To | Description |
|---|---|---|---|
Custom UPPER_SNAKE_CASE |
:LabeledEntity |
:LabeledEntity |
Domain-specific semantic edge between two entity values (e.g., :TREATS, :CONTRAINDICATED_WITH, :PART_OF). |
Instance Relationships (from instance_relationships)
When a schema defines instance_relationships between model instances, the graph creates edges between :ModelInstance nodes. The relationship type is the instance relationship name in UPPER_SNAKE_CASE.
| Relationship | From | To | Description |
|---|---|---|---|
Custom UPPER_SNAKE_CASE |
:ModelInstance |
:ModelInstance |
Cross-model record edge (e.g., :ASSOCIATED_WITH, :PRECEDES, :BELONGS_TO). |
Cross-Section :ModelInstance Linking via instance_key
The instance_relationships mechanism described above creates a relationship, but the real power of the graph comes from instance_key: a json_schema_extra flag on one or more fields of a model that makes the same logical entity, extracted from different sections or documents, collapse into a single Neo4j node instead of creating a duplicate.
How the UID is computed: when a model declares one or more fields with "instance_key": True, the graph mapper computes a deterministic 16-character UID (make_instance_uid) from the model's class name plus every instance_key field, sorted alphabetically by field name, and each value normalized (Unicode NFKD, accents stripped, lowercased, whitespace collapsed) before hashing with SHA-256. This means:
- The UID does not depend on which document or section the value came from.
- The UID is effectively case-insensitive and accent-insensitive (because inputs are normalized before hashing), even though the hash function itself is not.
- A model without any
instance_keyfield gets a random UID (uuid4) on every extraction — no deduplication is possible, and each extraction always creates a brand-new node.
Real example from this graph — CTDSectionSpec uses ctd_code as its instance_key. The same section code referenced from a HAS_REQUIREMENTS edge (written first, as a shell) and later extracted directly from its own document section (written second, with full detail) resolve to the exact same node:
MATCH (a:ModelInstance {model_class: "CTDSectionSpec"})-[:HAS_REQUIREMENTS]->(:ModelInstance)-[:REQUIREMENT_APPLIES_TO]->(a2:ModelInstance {model_class: "CTDSectionSpec"})
RETURN a.ctd_code, a.uid = a2.uid AS same_node
LIMIT 3;
-- {a.ctd_code: "3.2.S.2.2", same_node: true}
-- {a.ctd_code: "3.2.S.6", same_node: true}
Shell nodes: when a source model references a target model via instance_relationships before the target has been extracted from its own section, the graph mapper writes a :ModelInstance node containing only its instance_key field(s) plus model_class and uid (a "shell"). The exact same MERGE ... ON CREATE SET ... ON MATCH SET ... Cypher pattern is used whether the node is being created as a shell or enriched later — there is no separate "shell-writing" code path. When the target model is later extracted from its own section, the graph mapper recomputes the same instance_key-derived UID, the MERGE finds the existing shell, and the remaining (non-key) fields are added to that same node via a plain property SET — with no guard against overwriting, but since only the shell's key fields were ever set, nothing is lost.
Real example — a CTDSectionSpec shell (3 properties) next to a fully-extracted one (11 properties), same model_class, same graph:
MATCH (m:ModelInstance {model_class: "CTDSectionSpec"})
RETURN m.ctd_code, size(keys(m)) AS property_count
ORDER BY property_count ASC
LIMIT 1;
-- {m.ctd_code: "3.2.p.6", property_count: 3} ← shell: only ctd_code + model_class + uid
MATCH (m:ModelInstance {model_class: "CTDSectionSpec"})
RETURN m.ctd_code, size(keys(m)) AS property_count
ORDER BY property_count DESC
LIMIT 1;
-- {m.ctd_code: "3.2.S.2.5", property_count: 11} ← fully extracted: title, nta_summary, cross_references, ...
Important caveats:
- The relationship to a shell target is created unconditionally as soon as the fixed join_via key fields are non-empty — there is no check that the target model will ever actually be extracted. If it never is, the shell simply stays a shell (3 properties) forever.
- Orphaned shells are not garbage-collected automatically after ingestion. A shell goes when delete_document() or freeze_document() removes the last instance that referenced it, or when collect_orphans() sweeps the tenant: both delete any ModelInstance/Entity no longer reachable from any ExtractionResult within 7 hops (see Document Deletion — Garbage Collection).
- A list field driving instance_relationships (fan-out) creates one shell per list item, each with its own UID derived from the fixed anchor key(s) plus that one item's value.
Query: find shell nodes for any model class (generic property-count heuristic)
-- A genuine shell only has its instance_key fields populated, so it will have
-- far fewer properties than the average fully-extracted instance of the same model_class.
-- This is domain-agnostic: it does not assume any specific field like `description` exists.
MATCH (m:ModelInstance)
WITH m.model_class AS model, avg(size(keys(m))) AS avgProps
MATCH (m2:ModelInstance {model_class: model})
WITH model, avgProps, m2, size(keys(m2)) AS propCount
WHERE propCount < avgProps * 0.5
RETURN model, count(*) AS likely_shell_count
ORDER BY likely_shell_count DESC;
Normalized Models (Tabular Pipeline)
The tabular pipeline (CSV/XLSX/XLS ingestion) supports an optional normalization step for fields whose raw source value needs to be turned into a structured sub-model before it becomes useful for cross-dataset linking. This is triggered by two json_schema_extra keys on a nested-submodel field: normalization_model: True and normalization_source_fields: [...].
Non-normalized vs. normalized fields — the core distinction:
- A non-normalized field (e.g.
raw_address: str) holds the value exactly as it came from the source dataset — whatever string was in that CSV/Excel column, untouched. It is populated purely by column-mapping, with no LLM call involved. - A normalized field (e.g.
normalized_address: NormalizedAddress | None) is a nested sub-model that theNormalizationEnginefills in by sending the listednormalization_source_fieldsto an LLM (with_structured_output()), validating the result withTypeAdapter.validate_python(), and writing it back onto the instance. This runs in memory, before the row is written to Neo4j — it never touches Cypher directly; the same graph writer (write_extraction_subgraph) used by the unstructured (PDF/DOCX) pipeline is reused afterward.
Why this matters for the graph — linking across datasets: the raw field on its own is just a string property; it does not participate in any deduplication unless it separately carries its own entity_label. The normalized sub-model, by contrast, is written to the graph as an ordinary :ModelInstance node (linked from its parent via [:HAS_<FIELDNAME>]) — and if that sub-model declares its own instance_key and/or entity_label fields (exactly as described in the section above), it deduplicates and links globally just like any other keyed ModelInstance. In other words: normalization does not create graph links by itself — it produces a clean, structured value that is much easier to key or label consistently than free-text, which is what actually enables reliable joins between rows/documents that describe the same real-world entity but wrote it differently (e.g. "123 Main St, Springfield" vs "123 main street springfield").
Example pattern (see docs/user-guides/normalization.md for the full guide):
class NormalizedAddress(ExtractionModel):
street: str | None = Field(default=None, description="...")
city: str | None = Field(default=None, description="...")
postal_code: str | None = Field(default=None, description="...")
country_code: str | None = Field(default=None, description="...")
class ContactRecord(ExtractionModel):
raw_address: str = Field(..., description="Free-text address exactly as it appears in the source column.")
normalized_address: NormalizedAddress | None = Field(
default=None,
description="Structured address derived from raw_address via LLM normalization.",
json_schema_extra={
"normalization_model": True,
"normalization_source_fields": ["raw_address"], # explicit — see caveat below
},
)
Resulting graph shape — note normalized_address is a separate node, never an embedded property map (Neo4j cannot store nested maps as a property value):
(:ModelInstance {model_class: "ContactRecord", raw_address: "123 Main St, Springfield"})
└─[:HAS_NORMALIZED_ADDRESS]→ (:ModelInstance {model_class: "NormalizedAddress", street: "123 Main St", city: "Springfield", ...})
Caveat — normalization_source_fields fallback: if this list is omitted or empty, the engine silently falls back to sending every other scalar field of the parent model to the LLM as context. Always set it explicitly to avoid leaking unrelated columns into the normalization prompt.
Versioning
| Relationship | From | To | Description |
|---|---|---|---|
[:HAS_NEWER_VERSION] |
:Document |
:Document |
The target document is a newer version that replaces the source document. |
The latest property on :Document nodes provides a direct indicator of the current version, while the [:HAS_NEWER_VERSION] relationship enables version chain traversal.
Graph Structure Diagrams
Unstructured Pipeline Output
Narrative documents (PDF, Word) produce a heading hierarchy with extracted entities anchored to specific sections:
(:Document {name: "clinical_trial.pdf", latest: true})
└─[:HAS_STRUCTURE]→ (:StructureNode {role: "section", title: "3. Results", latest: true})
└─[:HAS_CHILD]→ (:StructureNode {role: "section", title: "3.2 Adverse Events", latest: true})
└─[:HAS_CHILD]→ (:StructureNode {role: "freeform_block", latest: true})
└─[:HAS_EXTRACTION]→ (:ExtractionResult {model_class: "AdverseEventModel"})
└─[:HAS_EVENTS]→ (:ModelInstance {model_class: "AdverseEventModel", uid: "..."})
└─[:REFERENCES]→ (:LabeledEntity {label: "ActiveSubstance", value: "Metformin"})
In this pattern:
- The document contains a top-level section (
3. Results). - That section contains a sub-section (
3.2 Adverse Events). - The sub-section contains a freeform block with text.
- A model instance (
AdverseEventModel) was extracted from that block. - The
:LabeledEntity(ActiveSubstance) is reached through the:ModelInstancevia[:REFERENCES], not directly from:ExtractionResult. - The
:LabeledEntitynode is shared across documents if the same entity value appears elsewhere.
Tabular Pipeline Output
Structured documents (CSV, Excel) produce a flat table structure with one model instance per row:
(:Document {name: "products.csv", latest: true})
└─[:HAS_STRUCTURE]→ (:StructureNode {role: "table", latest: true})
└─[:HAS_CHILD]→ (:StructureNode {role: "row", latest: true})
└─[:HAS_EXTRACTION]→ (:ExtractionResult {model_class: "ProductRecord"})
└─[:HAS_FIELDS]→ (:ModelInstance {model_class: "ProductRecord", uid: "..."})
└─[:REFERENCES]→ (:LabeledEntity {label: "ProductCode", value: "PROD-001"})
└─[:HAS_CHILD]→ (:StructureNode {role: "row", latest: true})
└─[:HAS_EXTRACTION]→ (:ExtractionResult {model_class: "ProductRecord"})
└─[:HAS_FIELDS]→ (:ModelInstance {model_class: "ProductRecord", uid: "..."})
In this pattern:
- The document contains a single table structure node.
- Each table row is a child of the table.
- Each row produces one model instance (
ProductRecord) via an:ExtractionResult. - Labeled entity fields within the model instance are connected via
[:REFERENCES]from the:ModelInstancenode, not directly from:ExtractionResult.
Versioning
scinr supports two document update strategies, both reflected in the graph:
In-place Update (update_mode=True)
When update_mode=True, the existing document and all its downstream nodes are replaced in-place. The version property on the :Document node is reused (it does not increment), and the path remains the same. The ingestion loader finds the latest version by path, deletes its :StructureNode / :InfoUnit descendants, and re-inserts the new structure at the same version number.
The :Entity / :ModelInstance / :LabeledEntity nodes that only the old content reached are not deleted by the re-ingestion. collect_orphans(tenant_id=...) removes them (see Document Deletion — Garbage Collection). The re-ingestion does not check whether the version is frozen either (see Document Freezing — Caveats).
(:Document {
name: "clinical_trial_report.pdf",
path: "/path/to/clinical_trial_report.pdf",
version: 1,
load_date: 2024-06-01T09:00:00,
latest: true,
is_folder: false
})
Replacement (replaces="old_name")
When replaces is set, a new :Document node is created with the same path and an incremented version, and a [:HAS_NEWER_VERSION] relationship links the old document to the new one.
(:Document {name: "clinical_trial_report.pdf", path: "/path/to/clinical_trial_report.pdf", version: 1, latest: false})
└─[:HAS_NEWER_VERSION]→ (:Document {name: "clinical_trial_report.pdf", path: "/path/to/clinical_trial_report.pdf", version: 2, latest: true})
This preserves the full history of document versions in the graph, allowing queries to trace which version produced which extraction results. The latest property indicates the current version, while [:HAS_NEWER_VERSION] enables version chain traversal.
Document Deletion
When a document needs to be permanently removed from the graph (rather than updated in-place), use delete_document(). This function:
- Always acts within one tenant:
tenant_idis mandatory (None/"__public__"= public documents). - Removes the tenant's
:Documentnode(s) matching the givenpath(and optionallyversion), or every:Documentof the tenant carrying a givenjob_id(delete_document(job_id=..., tenant_id=...)), optionally narrowed further bycreated_by_user_id(job_idandcreated_by_user_idaccept a list). - Cascade-deletes all connected structure, annotation, and extraction nodes.
- Garbage-collects the
:Entity,:ModelInstance, and:LabeledEntitynodes of the tenant that the deletion left orphaned. - Works in bounded transactions, so a large folder does not overrun Neo4j's transaction memory; an interrupted deletion is finished by calling it again.
Unlike update_mode=True re-ingestion, deletion is irreversible — there is no undo. See the Document Deletion guide for details.
Document Freezing
freeze_document() is the reversible alternative. It exports the document's subgraph to a snapshot in the freeze backend and reduces the document to a stub:
- The
:Documentnode stays, withfrozen=trueandfrozen_blob_id, so the document is still listed and its version chain is intact. - The
:StructureNodetree, the:InfoUnitnodes, the annotations and the extraction results are deleted, unlesskeep_structure_nodes,keep_annotationsorkeep_extraction_resultskeep them. InfoUnits always go. - A kept
:ModelDecision/:ExtractionResultwhose:StructureNodewas deleted hangs from the:Documentinstead, through a temporary(:Document)-[:HAS_MODEL_DECISION|HAS_EXTRACTION]->()relationship. The restore removes it.
restore_document() rebuilds the subgraph from the snapshot, with the same ids and uids and no LLM calls. See the Document Freezing guide.
Query Patterns
Prefer Python?
The Graph Navigation module wraps these patterns in
typed, async methods — list root documents, walk the structure tree, pull
a document's model instances, filter by class and properties, and more — so
you rarely need to write Cypher by hand.
Find All Documents
List all ingested documents, ordered by most recent first:
MATCH (d:Document)
WHERE d.latest = true
RETURN d.name, d.path, d.version, d.load_date
ORDER BY d.load_date DESC;
Find All Entities of a Type
Aggregate extracted model instances by their field values:
MATCH (m:ModelInstance {model_class: "AdverseEventModel"})
RETURN m.event_type, m.severity, count(m) AS occurrences
ORDER BY occurrences DESC;
Find Entities Extracted from a Specific Document
Trace all model instances back through their source document:
MATCH (d:Document {latest: true})-[:HAS_STRUCTURE]->(:StructureNode)-[:HAS_CHILD*0..]->(s:StructureNode)-[:HAS_EXTRACTION]->(er:ExtractionResult)-[r]->(m:ModelInstance)
WHERE d.name = "clinical_trial_report.pdf"
RETURN m.model_class AS entity_type, count(m) AS count
ORDER BY count DESC;
The correct pattern to descend the hierarchy is [:HAS_STRUCTURE] (once, from the :Document to its root structure node) followed by [:HAS_CHILD*0..] (recursive between :StructureNodes) — not [:HAS_STRUCTURE*0..], since HAS_STRUCTURE only connects a :Document to its root structure node and never repeats between :StructureNodes. Also, the relationship between :ExtractionResult and :ModelInstance is [:HAS_<FIELD>] with a dynamic name based on the schema field, so the query uses a generic -[r]-> instead of a fixed relationship type.
Find Entity Relationships
List all domain-specific edges between labeled entities:
MATCH (a:LabeledEntity)-[r]->(b:LabeledEntity)
RETURN a.label AS source_type, type(r) AS relationship, b.label AS target_type, count(r) AS count
ORDER BY count DESC;
Find Cross-Model Connections
List all relationships between model instances:
MATCH (a:ModelInstance)-[r]->(b:ModelInstance)
RETURN a.model_class AS source_type, type(r) AS relationship, b.model_class AS target_type, count(r) AS count
ORDER BY count DESC;
Full Extraction Trace
Reconstruct the full provenance chain from document to extracted entity:
MATCH (d:Document {latest: true})-[:HAS_STRUCTURE]->(:StructureNode)-[:HAS_CHILD*0..]->(s:StructureNode)-[:HAS_EXTRACTION]->(er:ExtractionResult)-[r]->(m:ModelInstance)
RETURN d.name, s.title, s.role, m.model_class, m;
This query is useful for auditing: it shows exactly which section of which document produced each extracted entity.
Find Entity Usage Across Documents
Identify which documents share a particular entity value. Since labeled entities are deduplicated (shared across documents), find where the entity is referenced via model instances:
// Find which sections reference this entity via ModelInstance-[:REFERENCES]->LabeledEntity
MATCH (e:LabeledEntity {label: "ActiveSubstance", value: "Metformin"})
MATCH (m:ModelInstance)-[:REFERENCES]->(e)
MATCH (er:ExtractionResult)-[r]->(m)
MATCH (s:StructureNode)-[:HAS_EXTRACTION]->(er)
MATCH (d:Document)-[:HAS_STRUCTURE]->(:StructureNode)-[:HAS_CHILD*0..]->(s)
WHERE d.latest = true
RETURN DISTINCT d.name, s.title
ORDER BY d.name;
Note: [:HAS_EXTRACTION] connects exclusively :StructureNode→:ExtractionResult. The :ExtractionResult→:ModelInstance segment is always [:HAS_<FIELD>] (dynamic), hence the generic -[r]->.
Find Document Version History
Trace the lineage of a document through its versions:
MATCH (old:Document)-[:HAS_NEWER_VERSION*0..]->(new:Document)
WHERE new.path = "/path/to/clinical_trial_report.pdf" AND new.latest = true
RETURN old.name AS document_name, old.path AS path, old.version AS version, old.load_date AS load_date
ORDER BY old.version;
Indexes and Constraints
setup_schema() (run by run_ingestion, the pipeline, the tabular pipeline and the loader) creates every constraint and index below with IF NOT EXISTS; there is nothing to create by hand. The authoritative list is ingest/schema.py.
Unique constraints
| Label | Key | Notes |
|---|---|---|
:Document |
(tenant_id, path, version) |
The document identity. setup_schema() drops the legacy constraint_document_path_version. |
:StructureNode |
id |
The id is tenant-prefixed. |
:InfoUnit, :ExtractionResult, :ModelInstance, :LabeledEntity, :Entity |
uid |
The uid hashes the tenant. |
:ModelField |
(name, model) |
Catalog. |
:EntityLabel |
label |
Catalog. |
A lookup by id / uid is a seek on these constraints, as long as the pattern carries the label.
Single-property indexes
tenant_id on Document, StructureNode, InfoUnit, ExtractionResult, ModelInstance, ModelDecision, LabeledEntity and Entity; created_by_user_id / job_id on Document, StructureNode and ExtractionResult; plus Document(name), Document(latest), Document(path), StructureNode(role), StructureNode(row_index), StructureNode(source_page_ids), LabeledEntity(label), ExtractionResult(node_full_id) and ModelInstance(model_class).
uid on ModelDecision, ProposedModel, ProposedField, ComplementaryMatch and SupplementaryField, and CatalogModel(name). These are plain indexes, not constraints, because older graphs may hold duplicates. restore_document() looks nodes up through them, and creates them itself when they are missing (a graph ingested before they existed).
Composite tenant indexes
| Index | Properties | Serves |
|---|---|---|
idx_model_instance_tenant_model_class |
ModelInstance(tenant_id, model_class) |
get_model_instances_by_class / count_model_instances_by_class, properties by class |
idx_document_tenant_latest |
Document(tenant_id, latest) |
get_documents, list_root_documents / count_root_documents with latest_only, graph summary |
idx_document_tenant_name |
Document(tenant_id, name) |
lookups by name within a tenant (e.g. the replaces= resolution) |
idx_structure_node_tenant_role |
StructureNode(tenant_id, role) |
find_structure_nodes(role=...) |
idx_labeled_entity_tenant_label |
LabeledEntity(tenant_id, label) |
get_labeled_entities(label=...) |
The single tenant_id indexes are kept: a query over all tenants (tenant_id=None) has no tenant predicate and cannot use a composite index.
instance_key indexes
For every distinct field name marked instance_key: True in the registered models, ensure_catalog_models() creates
CREATE INDEX idx_mi_key_<field> IF NOT EXISTS
FOR (mi:ModelInstance) ON (mi.tenant_id, mi.`<field>`)
The label is always :ModelInstance, so models sharing a key field name share the index. Names that are not plain identifiers are sanitized to [A-Za-z0-9_] with a short hash suffix. New indexes are built in the background (POPULATING) and do not block ingestion.
- Full key: use
nav.get_model_instance_by_key(...)— it rebuilds theuidand seeks on the unique constraint. Do not filter withtoLower(mi.field) = ...: a function on the property prevents any index from being used. Key values are stored normalized (normalize_key). - Part of the key:
where={"field": normalize_key(value)}with the tenant usesidx_mi_key_<field>. - If a field stops being a key its index is not dropped (it is harmless). To clean it up:
SHOW INDEXES YIELD name WHERE name STARTS WITH 'idx_mi_key_', thenDROP INDEX <name>.
How the planner uses them
Checked with EXPLAIN ... USING INDEX on Neo4j 2026.05 Community (a hint the planner cannot honour is an error):
tenant_id IN [t, '__public__']can seek on thetenant_idprefix of a composite index, soinclude_public=Truestill uses it.- A composite index is only used when the query constrains every property in it (
IS NOT NULLcounts).path + latestcannot use(tenant_id, path, version); "latest version of this path" usesidx_document_pathinstead, andpathis very selective. - One index per node. With single indexes only, "instances of class X for tenant T" seeks on one of them and filters every node for the other — hence the composite indexes.
- No label, no index.
MATCH (n {uid: $uid})scans every node of every tenant. Every pattern that looks a node up by a property must carry its label;tests/unit/test_cypher_label_guard.pyfails on any unlabelled(var {prop: ...})pattern in the library.
Known limitation: get_document_stats' per-role counts traverse from the document and use no role index.
A predicate placed after a WITH (WITH n, x WHERE n.tenant_id = ...) is not a condition of the preceding MATCH and cannot drive its index seek: keep tenant and class filters in the MATCH's own WHERE.
Community edition
Neo4j Community does not support property existence constraints (IS NOT NULL), node key constraints or property type constraints; setup_schema() does not create them. Range indexes (single and composite), unique constraints (also composite) and fulltext indexes are all available, and are the only kinds scinr uses.
Verifying index usage
tests/integration/test_index_usage.py checks that the representative queries can use their index — it does not measure time and needs no data volume:
- Neo4j: runs
setup_schema(), creates oneinstance_keyindex, and runsEXPLAIN <query> USING INDEX <expected index>for each navigation query (captured from the real navigator), theinstance_keylookup and thegraph_mapperwrites. Nothing is executed. - MongoDB: in a throwaway
scinr_it_<uuid>database (dropped afterwards), checks thatget_storage()created the indexes, inserts a few documents and assertsIXSCANon the expected index and noCOLLSCANfor the page, raw-file inventory and delete queries.
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=... NEO4J_DATABASE=neo4j \
MONGODB_URI=mongodb://user:pass@localhost:27017/?authSource=admin \
pytest -m integration tests/integration/test_index_usage.py
Each backend is skipped when its variables are missing or the server is unreachable. The Neo4j user needs schema privileges (it runs setup_schema()); the MongoDB user needs to create and drop a database.
Graph Size Estimation
The following table provides rough estimates for planning database capacity:
| Input | Approximate Nodes | Approximate Relationships |
|---|---|---|
| 1 PDF (50 pages) | ~500 StructureNodes + extracted entities | ~600 (structure + extraction) |
| 10 PDFs (50 pages each) | ~5,000 StructureNodes + extracted entities | ~6,000 (structure + extraction) |
| 1 CSV (1,000 rows) | ~1,000 ModelInstances + labeled entities | ~1,000 (extraction) + entity/instance relationships |
| 1 Excel (5 sheets, 500 rows each) | ~2,500 ModelInstances + labeled entities | ~2,500 (extraction) + entity/instance relationships |
| 100 PDFs (mixed size) | ~50,000 StructureNodes + extracted entities | ~60,000 (structure + extraction) |
Notes:
- Actual counts depend on document complexity, schema definition, and extraction yield.
- Labeled entity deduplication reduces total node count when the same entity appears across multiple documents.
- Entity and instance relationships add proportionally to the relationship count based on schema configuration.
- For large-scale deployments, consider partitioning strategies and Neo4j clustering.
Shell Nodes — Two Distinct Concepts
"Shell node" can refer to two unrelated things in this graph — do not confuse them:
:ModelInstanceshells (the mechanism documented in detail above, under "Cross-Section:ModelInstanceLinking viainstance_key"): aModelInstancenode created ahead of time by aninstance_relationshipsreference, containing only itsinstance_keyfields until the target model is extracted from its own section. Detect these with the property-count heuristic query shown above.:Documentfolder nodes (is_folder: true): these are not a deduplication artifact at all — they are legitimate, permanent nodes representing a directory in the ingestion hierarchy (as opposed to a leaf file). They are not "incomplete" or waiting to be enriched;is_folder: trueis simply their normal, final state. Do not apply theModelInstanceshell-detection heuristic to:Documentnodes — checkis_folderdirectly instead: