cvcdocdb Documentation
cvcdocdb (Document Representation Model) is a Python library for graph-based document representation with Neo4j, Memgraph, Apache Jena (SPARQL) and an in-memory NetworkX backend.
The documentation has two parts: the general part (the core API, the backends, and the tutorials that work on every backend) and the optional modules (domain entities, ontology import, code generation, PyTorch loaders, Text2Cypher), each with its own installation notes, examples, tutorials and API.
General
Backends
Optional modules
Features
Graph-based document representation: Model documents as nodes and relations, including hierarchical structures such as Document → Section → Page.
Two backends: Full Neo4j integration via
Neo4jGraph(targeting Neo4j Community Edition; opt-in Enterprise-only features, see Neo4j editions: Community (default) and Enterprise), a Memgraph backend viaMemgraphGraph(same propagation policy, see Memgraph backend), an Apache Jena (SPARQL) backend viaJenaGraph(the graph as RDF 1.2 in Fuseki, see Apache Jena backend (SPARQL)), or an in-memoryNetworkXGraph(NetworkX) for testing and tutorials.Two entity levels: Root entities (
Node) and child entities (WeakNode) with composite primary keys and cascade delete propagation. Inserting a WeakNode inserts all its ancestors. A chain has at mostMAX_WEAK_CHAIN_DEPTH= 3 nodes (the root plus two levels, e.g.Document → Section → Page). Deeper WeakNodes emit aWeakNodeDepthWarning, and the next major version will give them an automatic surrogate key.Dependency auto-insertion: String properties (for example names) are automatically materialised as
Valornodes when the graph backend supports them.FK validation: Foreign key constraints on relations prevent dangling references.
ON DELETE strategies: CASCADE, RESTRICT, SET NULL.
Query & filtering: Secondary index on scalar properties, multi-filter search with intersection/union, and debug snapshots.
Vector search (NetworkX only): HNSW-based ANN indexing on node properties with
cosine,l2, andipdistance spaces.Propagation properties: Every node and edge can carry
_propagate,is_weak,parent_relationand_weak_init_doneflags that enable cascade delete and hierarchical traversal (see Propagation Properties).Transactional group creation:
create_group()creates a strong node together with its WeakNodes and WeakRelations in a single isolated transaction. On failure the entire group is rolled back.Lazy background initialization:
init_propagation()scans the backend graph, detects WeakNodes from edge structure, and initializes propagation properties. Supports background mode and progress callbacks.Graph viewer: a read-only, interactive viewer for any backend, as a Jupyter widget (
cvcdocdb.viewer.show,pip install "cvcdocdb[notebook]") or a local web page (python -m cvcdocdb.viewer); see Graph viewer.Backend-to-backend migration:
cvcdocdb.migration.migrate()copies an entire graph — nodes, edges, propagation properties and vector indexes — between any twoGraphStorebackends (e.g.NetworkXGraph→Neo4jGraph).
Optional modules (not imported by import cvcdocdb; see
Optional modules):
DRM semantic entities (
cvcdocdb.drm_entities):IndividuPadro,LlocPadro,Fotografia…RiC-O entities (
cvcdocdb.rico_entities), generated from the RiC-O ontology.RDF/OWL ontology conversion and class generation (
cvcdocdb.rdf_schema,cvcdocdb.schema_gen).PyTorch / PyTorch Geometric dataloader (
cvcdocdb.torch_dataloader): streams a graph into PyG-ready tensors without loading it all into memory.Natural-language queries:
Text2Queryon any backend, which usesText2SPARQL(cvcdocdb.text2sparql) onJenaGraphandText2Cypher(cvcdocdb.text2cypher) on the others.
Primary Key
Every node must have a primary key (pk) or a neo4j_id (or both).
The pk is either an int or a dict:
# Integer PK — converted to {"id": value}
node = Node(pk=42, main_label="Document")
# Dict PK — used as-is
node = Node(pk={"doc_id": "DOC-001", "version": 2}, main_label="Document")
# Auto-assigned PK — pass None explicitly; the backend generates an ID
node = Node(pk=None, main_label="AutoIdNode")
graph.insertNode(node) # node._primary_key becomes {"id": <generated_id>}
# Reconstructed from DB — neo4j_id is used as PK
node = Node(neo4j_id=123, main_label="Document") # _primary_key = {"id": 123}
Rules:
Node(pk={"id": 1}, main_label="X")→_primary_key = {"id": 1}Node(pk=42, main_label="X")→_primary_key = {"id": 42}Node(pk=None, main_label="X")→_primary_key = None(backend assigns after insert)Node(pk=None, neo4j_id=123, main_label="X")→_primary_key = {"id": 123}Node(main_label="X")→ ValueError — pk must be provided
A node with pk=None is valid — the backend (Neo4j or NetworkX) assigns
an auto-generated ID as the primary key after insertion. If no backend is
used, _primary_key remains None.
Query & Filtering
NetworkXGraph maintains a secondary index on scalar properties, enabling
fast lookups without scanning all nodes:
from cvcdocdb import NetworkXGraph, Node
graph = NetworkXGraph()
alice = Node(pk={"name": "Alice"}, main_label="Author")
alice["affiliation"] = "MIT"
bob = Node(pk={"name": "Bob"}, main_label="Author")
bob["affiliation"] = "Stanford"
graph.insertNode(alice)
graph.insertNode(bob)
# Find all authors from MIT
mit_authors = graph.find_nodes_by_property("affiliation", "MIT")
print(mit_authors) # [node_id_of_alice]
# Multi-filter search (intersection or union)
results = graph.find_nodes({"affiliation": "MIT"}, match="all")
# Debug snapshot
graph.print_debug()
Vector Indexing
The package exposes a common vector-index API on graph stores:
enable_vector_index(property_name, dimensions, space="cosine", ...)query_vector_index(property_name, vector, top_k=10)
Current backend support:
NetworkXGraph: supported (useshnswlib)Neo4jGraph: API is present but currently raisesNotImplementedError
Example (NetworkXGraph):
from cvcdocdb import NetworkXGraph, Node
graph = NetworkXGraph()
graph.enable_vector_index("embedding", dimensions=3, space="cosine")
graph.insertNode(Node(pk={"id": 1}, main_label="Doc", embedding=[1.0, 0.0, 0.0]), replace=True)
graph.insertNode(Node(pk={"id": 2}, main_label="Doc", embedding=[0.0, 1.0, 0.0]), replace=True)
results = graph.query_vector_index("embedding", [1.0, 0.0, 0.0], top_k=2)
print(results) # [(node_id, distance), ...]
graph.close()
Propagation Properties
Every node and edge in the graph can carry propagation-related properties. These properties come in two families: structural (describing the hierarchy) and operational (tracking initialization state).
Structural properties — describe the parent-child relationship:
_propagate(bool): marks edges that trigger cascade delete when the parent node is removed. Set automatically on WeakNode parent-child edges.init_propagation()also sets it on any edge into anis_weaknode that doesn’t carry the flag yet (an explicit_propagate=Falseis kept). For example, afterinit_propagation()aCITESedge pointing at a Section makesdeleteNode(src, propagation=True)delete that Section too.is_weakand_propagate(bool) on child nodes (WeakNodes) linked to their parent by a_propagateedge. Use them to find all the nodes that will be cascade-deleted when their parent is removed. They are set byinit_propagation()andcreate_group(), but not byinsertNode(), which only marks the edge.
Operational properties — track initialization state:
parent_relation(str) on child nodes: the type of the edge linking the child to its parent (e.g."HAS_SECTION","HAS_PAGE"). Set byinit_propagation()andcreate_group(), which also stores it on that edge._weak_init_done(bool): whether a node has been processed byinit_propagation(), i.e. its WeakNode children are initialized. Set on every node the scan processes, and on the strong node bycreate_group().
The same properties are set by every backend (NetworkXGraph,
Neo4jGraph and MemgraphGraph; see test/test_propagation_contract.py).
Example:
from cvcdocdb import Neo4jGraph, Node, WeakNode
graph = Neo4jGraph(
url="bolt://localhost:7687",
user="neo4j",
password="secret",
database="mydb",
)
doc = Node(pk={"doc_id": "DOC-1"}, main_label="Document")
graph.insertNode(doc)
section = WeakNode(
parent=doc,
parent_relation="HAS_SECTION",
pk={"section": "intro"},
main_label="Section",
)
graph.insertNode(section, insert_parent=True)
# The edge doc → section carries _propagate=True
# (the section node isn't marked yet)
graph.init_propagation()
# section.is_weak = True (structural: it's a WeakNode)
# section._propagate = True (structural: cascade-delete enabled)
# section.parent_relation = "HAS_SECTION" (operational: edge type)
# doc._weak_init_done = True (operational: children initialized)
# section._weak_init_done = True (processed too: it has no children)
Key distinction — is_weak vs _weak_init_done:
``is_weak`` lives on the child node and describes its role in the hierarchy. It is set by
init_propagation()when it detects a node connected via a_propagateedge.``_weak_init_done`` lives on the parent node and tracks whether that parent’s WeakNodes have been processed. It is set automatically by
create_group()because the group is created with edges that already carry_propagate=True.
# After create_group(doc, [section, page]):
doc._weak_init_done = True # parent: "my children are initialized"
section.is_weak = True # child: "I am a WeakNode" (+ _propagate, parent_relation)
page.is_weak = True # child: "I am a WeakNode" (+ _propagate, parent_relation)
# After init_propagation() on a pre-existing graph:
section.is_weak = True # child: "I was detected as a WeakNode"
doc._weak_init_done = True # parent: "I have been processed"
Transactional Group Creation
create_group() inserts a strong node together with its WeakNodes and
WeakRelations in a single atomic transaction. On failure the entire group
is rolled back so the graph is never left in a partially-created state.
Example:
from cvcdocdb import Neo4jGraph, Node, WeakNode
graph = Neo4jGraph(
url="bolt://localhost:7687",
user="neo4j",
password="secret",
database="mydb",
)
doc = Node(pk={"doc_id": "DOC-1"}, main_label="Document", title="My Document")
section = WeakNode(
parent=doc,
parent_relation="HAS_SECTION",
pk={"section": "intro"},
main_label="Section",
title="Introduction",
)
page = WeakNode(
parent=section,
parent_relation="HAS_PAGE",
pk={"page": 1},
main_label="Page",
content="Welcome to the document.",
)
# Atomic creation — all-or-nothing
doc_id = graph.create_group(
strong_node=doc,
weak_nodes=[section, page],
)
print(f"Created group with strong node id: {doc_id}")
# _weak_init_done is automatically set on the strong node
# because the edges already carry _propagate=True
Lazy Background Initialization
init_propagation() scans the backend graph and initializes propagation
properties on nodes and edges that are missing them. It detects WeakNodes
by looking for edges with _propagate=True and marks the child nodes
accordingly.
Example:
from cvcdocdb import Neo4jGraph
graph = Neo4jGraph(
url="bolt://localhost:7687",
user="neo4j",
password="secret",
database="mydb",
)
# Synchronous — blocks until complete
result = graph.init_propagation()
print(f"Initialized: {result}") # True on first call, False if already done
# Background mode — returns immediately, runs in daemon thread
graph.init_propagation(background=True)
# With progress callback
def progress(processed, total):
print(f" {processed}/{total} nodes processed")
graph.init_propagation(progress_callback=progress)
# Idempotent — second call returns False immediately
graph.init_propagation() # Returns False
Concurrency & Persistence (NetworkXGraph)
NetworkXGraph loads the whole persisted graph into memory once per
instance and writes the whole graph back to disk on every mutation. Two
instances (in one process or several) pointing at the same
persistence_path therefore each hold an independent in-memory copy of
the graph.
Every mutating method (insertNode, insertRelation, deleteNode,
create_group, init_propagation, enable_vector_index) guards its
load-mutate-save cycle with a cross-process file lock (via the filelock
package): before mutating, it reloads the latest on-disk state under the
lock, so the mutation is applied on top of whatever another process already
persisted rather than a stale snapshot. This prevents the classic “two
writers” lost-update race, where whichever instance saves last would
otherwise silently erase the other’s already-persisted changes.
Nested calls made by a single top-level mutation (for example
create_group() calling insertNode()/insertRelation()
internally, or deleteNode()’s cascade-delete recursion) detect that the
lock is already held and skip the reload/save step, so the whole operation
still reaches disk as one atomic write — and if it raises partway through,
nothing is written at all, rather than a partially-applied change.
Note
This makes concurrent access from independent processes/instances
safe (no silent data loss), but each mutating call is still fully
serialized process-wide through a single lock file — there is no
fine-grained (per-node/per-edge) concurrency. NetworkXGraph remains
an in-memory backend intended for testing and tutorials; for real
concurrent production workloads, use Neo4jGraph, which has proper
ACID transactions.
Installation
Install the package in development mode:
pip install -e .
Quick Start
from cvcdocdb import Neo4jGraph, Node, WeakNode
# Connect to Neo4j
graph = Neo4jGraph(
url="bolt://localhost:7687",
user="neo4j",
password="secret",
database="mydb",
)
# Create a document hierarchy
doc = Node(pk={"doc": "DOC-001"}, main_label="Document")
graph.insertNode(doc, replace=True)
section = WeakNode(parent=doc, pk={"section": 1}, main_label="Section")
graph.insertNode(section, insert_parent=True)
page = WeakNode(parent=section, pk={"page": 1}, main_label="Page")
graph.insertNode(page, insert_parent=True)
graph.close()
For the in-memory backend:
from cvcdocdb import NetworkXGraph, Node
graph = NetworkXGraph()
doc = Node(pk={"doc": "DOC-001"}, main_label="Document")
graph.insertNode(doc)
print(graph.get_node_ids()) # [1]
graph.close()
Memgraph backend
MemgraphGraph stores the graph in Memgraph. It
subclasses Neo4jGraph (Memgraph speaks Bolt and Cypher, and uses the same
neo4j driver), so the API and the change-propagation policy are
identical to the Neo4j backend:
Insert: a WeakNode inserts its parent, and the parent→child edge carries
_propagate=TRUE. A missing parent, child keys that don’t match the parent’s, or a duplicate key are refused. Dependencies becomeValornodes.Update:
update=Truemerges attributes.replace=Truedeletes the old node with propagation (its WeakNode descendants too) and recreates it.Delete: RESTRICT by default,
propagation=Truefor WeakNode descendants,detach=True(CASCADE), oron_delete="set_null".Relations: FK validation of both endpoints, plus
update/replace.
from cvcdocdb import MemgraphGraph
graph = MemgraphGraph("bolt://localhost:7687", "", "") # no auth by default
test/test_memgraph_graph.py runs the same propagation scenarios on Neo4j
and Memgraph and checks that both leave the same graph and raise the same
errors. The only Memgraph-specific code is pk indexes (SHOW INDEX INFO,
CREATE INDEX ON :Label(props)) and label/relationship-type listing for
schema_yaml(). The Neo4j Enterprise features are not available on
Memgraph (EnterpriseFeatureError; see Memgraph backend), and neither is drop_constraint().
Tested with Memgraph 3.13 (Community).
Neo4j editions: Community (default) and Enterprise
CVCDocDB targets Neo4j Community Edition. It is the edition the test suite
and CI run on (neo4j:5-community), and Neo4jGraph uses it by default
(edition="community"). Everything described above works on both Community
and Enterprise.
A few Neo4j Enterprise-only features are available as an opt-in mode
(see cvcdocdb.neo4j_enterprise):
graph = Neo4jGraph(url, user, password, edition="enterprise") # emits a UserWarning
graph.create_node_key_constraint("Document", ["doc"]) # NODE KEY
graph.create_property_existence_constraint("Document", "title") # IS NOT NULL
graph.create_property_type_constraint("Document", "year", "INTEGER") # Neo4j 5.9+
graph.create_database("projecte1") # multiple databases
graph.drop_database("projecte1")
In the default Community mode these methods raise EnterpriseFeatureError
without contacting the server. In Enterprise mode, every call also checks that
the server really is Enterprise (graph.server_edition()).
Warning
The Enterprise features require a valid Neo4j Enterprise license and
are not fully tested. The regular suite only checks the generated
Cypher and the Community-mode guards. The tests against a real Enterprise
server only run when NEO4J_ENTERPRISE_URL is set, and CI does not set
it. No constraint is created automatically. A NODE KEY on a label used
with several pk shapes rejects the nodes of the other shapes.
Configuration
DRM uses environment variables for Neo4j connections. Multiple targets are
supported via the NEO4J_TARGET selector:
export NEO4J_DEV_URL=bolt://dev-host:7687
export NEO4J_DEV_USER=neo4j
export NEO4J_DEV_PASSWORD=your_dev_password
export NEO4J_DEV_DATABASE=neo4j
For a custom target:
export NEO4J_TARGET=LOCAL
export NEO4J_LOCAL_URL=bolt://localhost:7687
export NEO4J_LOCAL_USER=neo4j
export NEO4J_LOCAL_PASSWORD=your_password
export NEO4J_LOCAL_DATABASE=neo4j
The real Neo4j tests default to DEV when NEO4J_TARGET is not set,
and fall back to plain NEO4J_* variables when prefixed variables are absent.
Running Tests
Run the test suite with pytest:
python -m pytest test/ -v
Tests use NetworkXGraph for all unit tests and Neo4jGraph for
integration tests against a real Neo4j instance (controlled by NEO4J_TARGET).
Building Documentation
Generate the HTML documentation with Sphinx:
cd docs
sphinx-build -b html . _build/html
Or use the virtual environment:
.venv/bin/sphinx-build -b html docs/ docs/_build/html/
Third-party software and licenses
CVCDocDB itself is licensed under the GNU General Public License v3 or later (see LICENSE in the repository). It does not include or redistribute any database server. It talks to Neo4j, Memgraph and Apache Jena Fuseki over their network protocols (Bolt, HTTP/SPARQL), and each server is a separate product under its own license. Installing, running and licensing a server is the responsibility of whoever deploys it.
Software |
Used by |
License |
|---|---|---|
Neo4j Community Edition |
|
GPL-3.0 |
Neo4j Enterprise Edition |
|
Commercial: needs an Enterprise license |
|
Business Source License 1.1. It is source-available, not open source: production use is limited by its Additional Use Grant, and it converts to Apache-2.0 on its Change Date. Enterprise features fall under the Memgraph Enterprise License. |
|
|
Apache-2.0 |
About Memgraph’s license. Memgraph Community Edition is under the Memgraph Business Source License 1.1. It lets you copy, modify and use Memgraph for non-production purposes, and, through its Additional Use Grant, in production for your own internal purposes. It does not allow you to:
embed or distribute Memgraph to third parties, or give third parties direct access to operate or control it as a standalone solution or service;
offer it as a database-as-a-service (or any equivalent model);
build a product that competes with Memgraph.
Using Memgraph with CVCDocDB inside your organisation (e.g. a research group’s own projects and users) is therefore internal use. Offering Memgraph itself to third parties needs a commercial license from Memgraph. Each Memgraph version becomes Apache-2.0 on the license’s Change Date, or four years after that version’s first release, whichever comes first. Enterprise features (e.g. multi-tenancy) need a Memgraph Enterprise License.
Python packages installed with CVCDocDB are under licenses compatible with the GPL-3.0:
neo4j(driver): Apache-2.0networkx,numpy: BSD-3-Clausefilelock: MITtqdm: MPL-2.0 and MITOptional extras:
neo4j-graphragandhnswlib(Apache-2.0),rdflib(BSD-3-Clause),pyyamlandtorch_geometric(MIT),torch(BSD-style; see its metadata).
The Docker images used by the test suite and CI (neo4j:5-community,
memgraph/memgraph, secoresearch/fuseki) are only pulled to run tests. They
are not part of the CVCDocDB package.
This section is informative, not legal advice: check each license for your use case.
Acknowledgements
This work has been partially supported by the Spanish project PID2024-157778OB-I00, Ministerio de Ciencia e Innovación, the Departament de Cultura of the Generalitat de Catalunya, and the CERCA Program / Generalitat de Catalunya. Adrià Molina is funded with the PRE2022-101575 grant provided by MCIN / AEI / 10.13039 / 501100011033 and by the European Social Fund (FSE+).