Neo4j sink¶
openodke.sinks.neo4j is the Neo4j platform. It has two halves that form one design:
Neo4jSinkwrites aKnowledgeGraphwith batched, idempotentUNWIND … MERGE.Neo4jConstrainercompiles the ontology into the uniqueness constraints that make thoseMERGEs correct under concurrent writers, and into check queries for the rule Neo4j cannot enforce.
The ontology that shaped the prompt also shapes the store, so nobody writes the rules twice.
pip install "openodke[neo4j]"
The module imports on the base install, and printing the DDL or the write plan
needs no driver. Only connecting needs the extra; without it you get
the neo4j driver is not installed; run: pip install 'openodke[neo4j]'.
Neo4j 5.7 or later. Relationship uniqueness constraints arrived in 5.7.
Community Edition is enough, because nothing Enterprise-only is emitted. CI runs
the live tests against neo4j:5.26-community.
The shape, and why¶
| openodke | In Neo4j |
|---|---|
Entity |
(:Type:Entity {key}), MERGEd per type on key, with label, aliases, external_id, resolution_method, resolution_score, resolution_linker and the entity's attributes as properties |
edge Fact |
(s)-[:predicate {signature, …provenance}]->(o), MERGEd on the fact's signature |
literal Fact |
(s)-[:predicate {signature, …provenance}]->(:Claim {signature, predicate, subject_key, subject_type, value}) |
EntityLink |
(a)-[:SAME_AS \| SIMILAR \| DIFFERENT {score, reason, created_at, evidence_doc_ids, evidence_uris}]->(b) |
Every node also carries :Entity, so a link, which knows only keys, can find
both ends without their types. A link MATCHes its ends and never creates them.
Nodes are never merged into one another (DECISIONS #16).
A fact's relationship is MERGEd on signature, which is a SHA-256 of
Fact.signature: subject, predicate, object, polarity and the identity-bearing
qualifiers (Concepts). A second run
mints new fact ids and new clocks, finds the relationship it wrote, and updates it
rather than adding another. Two facts that differ only in a reconcilable
qualifier are one relationship; uptime at p50 and at p95 are two; a denial is its
own relationship with polarity: 'denied'.
A literal fact is the :Claim's relationship, not a property. A property
cannot carry provenance, nor two contested values, nor a denial. So the :Claim
is the record, and provenance lives on its relationship exactly as it does on an
edge. That gives one query shape for both kinds of fact.
The value is also projected onto the subject as s.predicate, because that is
what a Cypher query reaches for first. The projection is derived and lossy on
purpose:
- Only asserted, unscoped claims project. A denial is not a value, and a value with an identity-bearing qualifier means nothing without its scope.
- A single-valued predicate carries its best-supported claim (highest
support, thenconfidence). - A multi-valued predicate carries the list, when the sink knows the ontology.
A single-valued predicate with two objects is written as two relationships, not replaced. The store holds the conflict and a check query reports it. Picking a winner at write time would destroy the evidence for the loser.
Provenance on every fact relationship¶
| Property | From |
|---|---|
fact_id, signature, polarity, identity_keys |
the fact |
extractor, verdict, confidence, support |
the stages that touched it |
valid_from, valid_to |
the valid clock |
retrieved_at |
the freshest evidence: the last time a source confirmed it |
extracted_at |
KnowledgeGraph.created_at |
evidence_doc_ids, evidence_uris, evidence_starts, evidence_ends, evidence_tiers, evidence_retrieved_at |
parallel lists, one position per piece of evidence |
Neo4j lists hold no nulls and no maps. A missing uri is therefore "" and a
missing span is -1, and position i in every list is the same piece of
evidence. Reconcilable qualifiers become relationship properties. Anything Neo4j
cannot store (a map, a mixed or nested list) is written as JSON text rather than
dropped.
Names are protected in both directions. A qualifier named like a provenance
property lands as qualifier_<name>. An attribute or projected predicate named
like an entity field lands as attribute_<name> or property_<name>. Every
label, relationship type and property name from the ontology is backtick-quoted,
so an inferred schema cannot inject Cypher.
Using it¶
from openodke import Entity, Evidence, Fact, KnowledgeGraph, Ontology, Polarity, Span
from openodke.sinks.neo4j import Neo4jConstrainer, Neo4jSink
ontology = Ontology.from_dict(
{
"name": "companies",
"types": {"Person": {}, "Company": {}},
"predicates": {
"employer": {"domain": ["Person"], "range": "Company"},
"hq": {"domain": ["Company"]},
"sells": {"domain": ["Company"], "cardinality": "multi"},
},
}
)
ada = Entity(key="p:ada", type="Person", label="Ada Lovelace")
acme = Entity(key="c:acme", type="Company", label="Acme")
report = Evidence(
doc_id="annual-report",
uri="https://example.com/ar",
span=Span(doc_id="annual-report", start=0, end=64),
)
kg = KnowledgeGraph(
facts=(
Fact(subject=ada, predicate="employer", object_entity=acme, evidence=(report,)),
Fact(subject=acme, predicate="hq", object_value="Munich", support=3, evidence=(report,)),
Fact(subject=acme, predicate="hq", object_value="Berlin", evidence=(report,)),
Fact(
subject=acme,
predicate="sells",
object_value="customer data",
polarity=Polarity.DENIED,
evidence=(report,),
),
)
)
# Creating the sink does not connect, and neither call below sends anything.
with Neo4jSink("bolt://localhost:7687", auth=("neo4j", "change-me"), ontology=ontology) as sink:
ddl = sink.bootstrap(ontology, dry_run=True) # the DDL, for a DBA to apply by hand
planned = sink.statements(kg) # exactly what write() would run, in order
projections = [s for s in planned if "SET s." in s.cypher]
assert [s.rows for s in projections] == [[{"subject_key": "c:acme", "value": "Munich"}]]
print(planned[-1].cypher)
# UNWIND $rows AS row
# MATCH (s:`Company` {key: row.subject_key})
# SET s.`hq` = row.value
Neo4jSink(uri, auth, *, database=None, batch_size=500, driver=None, ontology=None)
also accepts a ready-made driver. ontology decides whether a projected value
is one value or a list, and bootstrap() sets it for you. Against a real server:
import os
uri, auth = os.environ["NEO4J_URI"], (os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"])
with Neo4jSink(uri, auth, database="neo4j") as sink:
sink.bootstrap(ontology) # every statement IF NOT EXISTS: safe on every start
sink.write(kg)
sink.write(kg) # a re-run: the same nodes and relationships, updated in place
print(sink.check(ontology)) # hq is single-valued and Acme holds two: {'hq': [...]}
Batches and transactions. Rows go in batches of batch_size, one managed
transaction each, so a failed batch rolls back whole and never half-writes.
Earlier batches stay committed. Because every statement is a MERGE, recovering
from a failure means running the write again. Write order is nodes, then the
relationships that MATCH them, then links, and one graph always compiles to the
same statements.
In a pipeline, pass the constrainer next to the sink. A constrainer compiled
for the sink's own platform is its other half, so it does not raise a
DoubleStageWarning. run() does not apply the DDL; call sink.bootstrap()
before the first write.
import warnings
from openodke import Pipeline
class NoFacts:
def extract(self, chunk, ontology):
return ()
with warnings.catch_warnings():
warnings.simplefilter("error")
pipeline = Pipeline(
ontology,
NoFacts(),
constrainer=Neo4jConstrainer(),
sinks=[Neo4jSink("bolt://localhost:7687", auth=("neo4j", "change-me"))],
)
assert list(pipeline.constraints()) == list(Neo4jConstrainer().constrain(ontology))
What Neo4j enforces¶
Once bootstrap() has run, Neo4j enforces the following in every edition:
- One node per (type, key): a uniqueness constraint per entity type. It is
what makes the sink's
MERGEcorrect under concurrent writers, since without it two writers can both miss and both create. It is also the index theMERGElooks up. - One relationship per (predicate, signature), and one
:Claimper signature: the same guarantee for facts.
It also creates indexes, which enforce nothing:
external_id, per type.- A full-text index over
labelandaliases, per type. Full-text is the index kind that reaches inside aLIST<STRING>. keyon:Entity, which is how a link finds a node without knowing its type.
for statement in ddl:
print(statement)
CREATE INDEX odke_entity_key IF NOT EXISTS FOR (n:`Entity`) ON (n.key)
CREATE CONSTRAINT odke_claim_signature IF NOT EXISTS FOR (c:`Claim`) REQUIRE c.signature IS UNIQUE
CREATE CONSTRAINT odke_key_Company IF NOT EXISTS FOR (n:`Company`) REQUIRE n.key IS UNIQUE
CREATE INDEX odke_external_id_Company IF NOT EXISTS FOR (n:`Company`) ON (n.external_id)
CREATE FULLTEXT INDEX odke_names_Company IF NOT EXISTS FOR (n:`Company`) ON EACH [n.label, n.aliases]
CREATE CONSTRAINT odke_key_Person IF NOT EXISTS FOR (n:`Person`) REQUIRE n.key IS UNIQUE
CREATE INDEX odke_external_id_Person IF NOT EXISTS FOR (n:`Person`) ON (n.external_id)
CREATE FULLTEXT INDEX odke_names_Person IF NOT EXISTS FOR (n:`Person`) ON EACH [n.label, n.aliases]
CREATE CONSTRAINT odke_signature_employer IF NOT EXISTS FOR ()-[r:`employer`]-() REQUIRE r.signature IS UNIQUE
CREATE CONSTRAINT odke_signature_hq IF NOT EXISTS FOR ()-[r:`hq`]-() REQUIRE r.signature IS UNIQUE
CREATE CONSTRAINT odke_signature_sells IF NOT EXISTS FOR ()-[r:`sells`]-() REQUIRE r.signature IS UNIQUE
Only what the ontology names is compiled. A type or predicate that turns up in a graph but not in the schema gets no constraint: the store enforces nothing it was not told about.
What Neo4j cannot enforce¶
- Relationship cardinality. No Neo4j constraint says "a Person has at most one
employer". Every
singlepredicate therefore gets a check query instead, marked// odke:check <predicate>, which returns the violators and changes nothing.sink.check(ontology)runs them in read transactions and returns{predicate: [violators]}. It checks and never repairs. The rule is used as a check, not as inference: a reasoner told "at most one" would conclude the two employers are one company. Only asserted, open-ended relationships count, because a denial is not a value and a relationship withvalid_toset expired correctly. The check groups by the predicate'sscope_keys, so uptime is single per percentile. - Existence, property-type and node-key constraints. Neo4j has these only in
Enterprise Edition, so none is emitted, and
EntityType.keysis not compiled. - Domain and range, such as "an employer is a Company". A
Validatoris the gate for those.
(check,) = [c for c in Neo4jConstrainer().checks(ontology) if "odke:check hq" in c]
print(check)
# // odke:check hq
# MATCH (s)-[r:`hq`]->(o)
# WHERE r.polarity = 'asserted' AND r.valid_to IS NULL
# WITH s, collect(DISTINCT coalesce(o.key, o.value)) AS objects
# WHERE size(objects) > 1
# RETURN labels(s) AS labels, s.key AS subject, objects
Provenance queries¶
Every fact relationship carries its receipts, so the questions below are plain Cypher reads:
// Why is this edge here?
MATCH (:Person {key: $person})-[r:employer]->(c:Company)
RETURN c.key, r.evidence_doc_ids, r.evidence_starts, r.evidence_ends, r.verdict, r.support;
// Every fact from one source, edges and claims alike.
MATCH (s:Entity)-[r]->(o) WHERE $doc IN r.evidence_doc_ids AND r.signature IS NOT NULL
RETURN s.key, type(r) AS predicate, coalesce(o.key, o.value) AS object, r.polarity;
// What changed since last week: facts written, or reconfirmed by a source, since then.
MATCH (s:Entity)-[r]->(o) WHERE r.extracted_at >= datetime() - duration('P7D')
RETURN s.key, type(r) AS predicate, coalesce(o.key, o.value) AS object, r.retrieved_at;
// Every conflict the cardinality checks would report, straight from the sink:
// sink.check(ontology)
// Forget a source: every relationship it is the only evidence for.
MATCH ()-[r]->() WHERE r.evidence_doc_ids = [$doc] DELETE r;