Schema
Declare indexes and constraints on the fragments, and hold them on every database at start-up:
class Chunk(NodeFragment, labels=["Chunk"], schema=[RangeIndex(["section_id", "position"])]): uuid: Annotated[str, NodeId(), Unique()] section_id: str position: int text: Annotated[str, FullTextIndex()] embedding: Annotated[list[float] | None, VectorIndex(similarity="COSINE")] = None
class AppDrivine(DrivineAssembly): def schema_options(self) -> SchemaOptions: return SchemaOptions([SchemaCatalog.from_fragments(Chunk, dimensions=lambda label, prop: 1536)])
async with AppDrivine() as drivine: # makes what is missing on the way in; idempotent ...RangeIndex(ignore_case=True) and Unique(ignore_case=True) on a CaseInsensitive field are made over the lowercased
copy a save keeps of it, so they serve eq_ignore_case and refuse two values that differ only in case. Ensuring one
fills the copies in first. See Lookups that ignore case. The
object manager keeps the copy in step, but Cypher you send yourself doesn’t: set the copy in the same statement, or
recreate the index, or drop and ensure the constraint, afterwards to fill it in again
(how).
Entering the assembly calls start(), which you may call yourself instead: await drivine.start(), from your
application assembly, say. It registers the databases whether or not there are catalogs, as drivine4j does, so a
missing DATABASE_TYPE raises DrivineError there rather than on first use. Without the assembly, enforce it yourself:
await SchemaManager(registry, factory, options).enforce().
ensureis idempotent. An item that is there is left; one there with another shape (a vector index’s dimensions, say) is reported as DRIFT and left, unlessrecreate_on_drift. A uniqueness constraint the data already breaks is a VIOLATION, with a sample of the keys that repeat: each itsproperties, thecountof nodes that hold it, andnodes, up to the sample size of them, each named by the engine’s id (elementId) and, for a constraint read from a fragment, itsNodeId(uniqueness_constraint(..., node_id_property="uuid")by hand). This is a breaking change from 0.1, where each entry was the repeated value itself: it is now aViolatingKey, the value in itsproperties. A sample size of 0 skips reading the data first, and the engine’s refusal of a constraint the data breaks is raised. FalkorDB doesn’t refuse one: it builds it in the background and lists the build as FAILED. Drivine then drops the failed constraint, which enforces nothing, and reads the data: a key held more than once is a VIOLATION, as in drivine4j, with an empty sample at sample size 0, and a build that fails with no key held more than once raises. Once the data is clean,ensuremakes the constraint. A constraint FalkorDB already lists as FAILED, or still UNDER CONSTRUCTION (another process’s build, or an earlier run’s), is not taken as ALREADY_MATCHING: one under construction is waited on, and matches once built, and a failed one is dropped and made again.dropalso removes a constraint FalkorDB failed to build.mode="FAIL_FAST", the default, raises on either;"WARN"logs.- Indexes are built before
ensurereturns. Neo4j and FalkorDB build an index in the background, after the statement that makes it has returned, so on themensureandrecreatewait until each index they made, or found there, is built: Neo4j’sdb.awaitIndex, by the index’s name, and on FalkorDBdb.indexes()asked every 200 ms until it lists the label’s indexes as OPERATIONAL. Memgraph and the SQL stores build an index within the statement, and nothing is waited for. The wait is at mostSchemaOptions(index_build_timeout=60)seconds (Neo4j’s procedure counts whole seconds, so a timeout is rounded up to them, and only a zero timeout checks once). An index whose build failed (on Neo4j, one over a text too large for a range index, say), or that is still being built when the wait ends, is left as it is, to finish building or to stay failed.gom.indexes.ensureby hand raises it asIndexNotBuilt, aDrivineErrorthat names the index and gives the engine’s reason; a wait that fails otherwise (an index not there, a connection lost) raises its own error. Applied by theSchemaManager, it is a problem of its owner, as a drift is:mode="FAIL_FAST"raises once the owner’s other items are applied, listing each such index asBUILD FAILEDorSTILL BUILDING, and doesn’t record the owner’s marker;"WARN"logs each so, and the item is reported as what was done (CREATED,ALREADY_MATCHINGorRECREATED). Either way the next apply waits for it again. On FalkorDB an index still being built answers a query with part of the rows: the wait covers the indexes Drivine’sensuremakes, but one made otherwise, by Cypher of your own or another process, can give partial rows until FalkorDB lists it as OPERATIONAL. - Versions.
catalog.with_version("2")recreates the catalog’s items once, the first time it is enforced. Items an owner applied before and no longer declares are reported, never dropped. Each owner’s version and inventory are kept in a_DrivineSchemanode, as drivine4j and TypeScript keep them. - Schema known only at runtime, a vector index whose dimensions come with the embedding model or an index for each
label a tenant declares, is given to the manager under an owner:
await schema_manager.ensure("dice", vector_index("Proposition", "embedding", 1536)), orensure_catalog(catalog)for a catalog naming its owner and databases. The items join what the owner declared, for the life of the manager, and are applied under the same options; a laterenforceorrecreate_allapplies them with the rest. A declaration that conflicts with another owner’s, or the owner’s own, raises, and nothing is kept or applied. The marker records them as ensured at runtime (runtime:before the key, as drivine4j writes it), so the next process’senforcekeeps them rather than calling them orphans; when it recreates the owner (on a version change, underrecreate_on_startup, or byrecreate_all), it marks them (runtime-stale:), and the nextensurerecreates them. A version change is left toenforce. - Catalogs are read once, from the options the manager is built with; a catalog made later isn’t seen, and schema
known later is given to
ensure. With nothing to apply, no catalog or only empty ones and nothing ensured, the manager says so at INFO: that it found noSchemaCatalog, or that all it found are empty, naming their owners. So a missing catalog, and one left empty until an embedding model exists, can be told from an application with no schema. - A vector field needs its
VectorIndex. Drivine knows a field is an embedding from the marker alone: without it the field is saved as a plain list, not as a vector FalkorDB’s index serves, andload_nearestcan’t find the index. So a catalog’svector_indexover a fragment’s field without one is refused with aDrivineErrornaming the class, the field and the spec, whether the catalog is enforced first or the fragment’s model is built first. One given toensureis refused the same way, before it is kept or applied. A spec beside the marker is allowed (it tunes the index), as is one on a label no fragment maps. - An index in the way. Neo4j refuses a uniqueness constraint while a plain range index covers the same label and
properties.
ensurereports that as BLOCKED_BY_INDEX, naming the index, and changes nothing:FAIL_FASTraises,WARNlogs. Withreplace_blocking_index=True(ongom.constraints.ensure, or inSchemaOptions) the data is read, and unless a value repeats the index is dropped and the constraint made, its own index taking the plain one’s place. A constraint given toschema_manager.ensureis reported and replaced the same way. Drivine’s own keys (the schema marker’s, the store’s) always replace one. On FalkorDB the constraint needs the range index, and Memgraph, Oracle and Postgres make it beside one, so none is ever reported in the way there. - Taking items back.
await schema_manager.remove("dice", spec), orremove_catalog(catalog)to name databases, is the counterpart ofensure. Each item is found by its kind, label and properties, in any order, whatever its name. It is forgotten among whatensuregave the owner, so a laterenforcedoesn’t make it again, and taken out of the owner’s marker. It is dropped, unless a startup catalog (of any owner) or another owner’s runtime items declare it, or another owner’s_DrivineSchemamarker in the database records it, whichever manager or process wrote it: then it is left in place, with a warning naming who declares it. An item that isn’t there is forgotten all the same. - By hand:
gom.indexes.ensure(range_index("Session", ["lastActivityAt", "sessionId"])),gom.indexes.recreate,gom.indexes.drop,gom.constraints.ensure(uniqueness_constraint("Person", "email")),gom.constraints.recreate, andlist. - Each engine’s DDL is written for it: named items on Neo4j, Memgraph’s text and vector indexes, FalkorDB’s index per
property and its constraint commands, Oracle’s tables beside the nodes, and Postgres’s partial indexes over a label’s
nodes, recorded in the graph’s catalog as drivine4j records them (a pgvector HNSW index for a vector index, a trigram
index for a full-text one). Neptune has no schema management. Vector index tuning (
hnsw_m,hnsw_ef_construction, and an engine’s own options) is passed where the engine takes it, and reported where it doesn’t.
Neo4j schema DDL on the SQL stores
Section titled “Neo4j schema DDL on the SQL stores”Postgres, Oracle and SQLite run Neo4j’s schema DDL, given as Cypher (cypher_statement(...)) to a
PersistenceManager, so that a schema file written for Neo4j, such as an APOC export’s, replays as it is. Drivine reads
each statement and applies it through the schema machinery, as the schema API applies a spec: the item it makes has the
name, the expressions and the inventory entry the API gives the same spec, so a later enforce of an application that
declares it finds it matching, not drifted or orphaned. The statement gives no rows. Neo4j, Memgraph and FalkorDB are
sent the statement as it is. DDL sent as SQL is SQL, and runs as it is.
| DDL | Applied as |
|---|---|
CREATE [RANGE] INDEX [name] [IF NOT EXISTS] FOR (n:L) ON (n.a, n.b), or Neo4j 4’s ON :L(a, b) |
range_index("L", ["a", "b"]) |
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:L) REQUIRE n.p IS [NODE] UNIQUE, REQUIRE (n.a, n.b) IS UNIQUE, or Neo4j 4’s ON (n:L) ASSERT n.p IS UNIQUE |
uniqueness_constraint |
CREATE FULLTEXT INDEX … FOR (n:L) ON EACH [n.a, …] |
full_text_index; its fulltext.analyzer is read, and not applied yet (#442), and fulltext.eventually_consistent is ignored |
CREATE VECTOR INDEX … FOR (n:L) ON n.p OPTIONS {indexConfig: {…}} |
vector_index: vector.dimensions (required), vector.similarity_function (cosine or euclidean, in any case), vector.hnsw.m and vector.hnsw.ef_construction, and vector.quantization.enabled as Neo4j’s EngineVectorOptions; indexProvider is ignored |
CREATE TEXT INDEX … |
Neo4j’s TEXT index, which the SQL stores don’t make, so the statement fails |
DROP INDEX name [IF EXISTS], DROP CONSTRAINT name [IF EXISTS] |
SchemaManager.remove of the item that goes by the name |
CALL db.awaitIndexes(…), CALL db.awaitIndex(…) |
Nothing: an index on a SQL store is whole once it is made |
A statement is read as DDL only where it opens with one of these keyword sequences, so Cypher that merely uses the words,
such as CREATE index = (a)-[:R]->(b), is Cypher. Names, labels and properties may be in backticks, with spaces, and a
doubled backtick for one. Keywords are read in any case, and a statement may end with ;.
Refused by name. POINT and LOOKUP indexes; an index or constraint on relationships; one over several labels
((n:A|B)); existence (IS NOT NULL), key (IS NODE KEY, IS KEY) and type (IS :: TYPE) constraints; a vector index
over several properties, with WITH [...] properties, or without vector.dimensions; Neo4j 4’s DROP … ON by label
and properties; a name, or OPTIONS, given as a parameter. The message says what is not made and why, and the Cypher
compiler refuses the statement with the same message. One a SQL store honours is refused by the compiler too, as a
schema command to run through a PersistenceManager. The Cypher spec’s schema.yaml pins how each statement reads, in
all three libraries.
The ddl owner. What DDL creates is ensured with SchemaManager.ensure under an owner of its own, ddl, never an
application’s: an application’s own owner records what its catalogs declare, and an ensure of DDL under it would take
the place of that. So the items are recorded in the _DrivineSchema marker of scope ddl, as ensured at runtime, and an
application that declares the same item co-owns it. A DROP removes the item from the ddl owner and drops it, as Neo4j
does, unless another owner records it: then it is left in place, with a warning, and no longer goes by the name
dropped, so the application’s next enforce finds it as it was. The marker’s and the store stamp’s own constraints
(_DrivineSchema(scope), DrivineStore(key)), which an export of a Drivine graph carries, are ensured as Drivine
ensures them and recorded in no inventory; any other schema for those labels is refused, and a DROP of either too.
Names and aliases. The item is made under the name the schema API gives it (Label_property_unique, …). The DDL’s
name, where the item goes by another, is kept as an alias in the item’s record in <graph>_schema, a list aliases
among its details, as drivine4j keeps it, so that a DROP by it finds the item, in this process or a later one: an
APOC export’s DROP CONSTRAINT UNIQUE_IMPORT_NAME, its last statement, drops the constraint its schema file named so.
A DROP looks the name up as the API’s name first, then as an alias. A recreate (drift, a version change,
recreate_all) carries an item’s aliases over. Over an item of the same properties in another order, the DDL’s spec is
ensured as that item, which goes by its own name, so the DDL’s name, or the API’s name for its spec, is kept as an
alias of it.
CREATE follows Neo4j: over an item of the same kind, label and properties that is there already, IF NOT EXISTS does
nothing but add the alias (and leaves an item of another shape as it is, with a warning), and without it the statement
fails. A name another item goes by, by the API’s name or an alias, is refused, with IF NOT EXISTS or without. A
DROP of a name no item goes by fails, and does nothing with IF EXISTS; DROP INDEX of a constraint’s name fails, as
does DROP CONSTRAINT of an index’s.
Transactions. DDL is refused inside an open transaction (the transactional manager, or the delegating one inside
transaction() or @transactional()), save db.awaitIndexes, which does nothing there: Drivine applies schema on
connections of its own, committed as they go, which the transaction’s rollback would not undo. Run it outside the
transaction.
Search
Section titled “Search”A fragment’s VectorIndex field drives load_nearest, and its FullTextIndex drives load_matching. Either takes a
fragment or a view, searched by its root, and gives each hit with its score, best first:
hits = await gom.load_nearest(Chunk, embedding, top_k=5, where=refs(Chunk).section_id.eq(section))for hit in hits: print(hit.score, hit.value.text)
found = await gom.load_matching(Chunk, "graph AND database", top_k=10, threshold=0.5)- Breaking change in 0.8.0: full-text search on a graph engine needs the
compilerextra.load_matchingis the shared compiler’s on every store (see Cypher on a relational database), which theneo4jandfalkordbextras don’t install: add it,pip install "drivine[neo4j,compiler]", or a search on Neo4j, Memgraph or FalkorDB raises aDrivineErrorsaying so. Where the compiler has no wheel (an Intel Mac, musl Linux) there is no full-text search on a graph engine; every other statement works there as before. - One scale on every engine. A nearest score is a similarity, higher nearer: (1 + cos) / 2 for cosine, 1 / (1 + d²) for euclidean. A matching score is scaled so that the best hit is 1: the best with every label of the root, as the index is over the first.
- Memgraph’s cosine scores are read from its distance, not its similarity, which is the absolute value of
1 - distance (memgraph/memgraph#5199). Earlier releases read the similarity, so a vector more than 90 degrees from
the query scored as its mirror image does: a cosine of -0.6 as 0.8, not 0.2. Those scores, and the hits a cosine
thresholdkeeps, change; positive cosines and euclidean scores don’t. - Full-text scores on Postgres, SQLite and Oracle without Oracle Text changed after 0.3.2. A hit scored by how
often the query’s terms occur in it. It now scores by BM25, as Neo4j’s index scores it (see
Postgres): a rare term outweighs a common one, and a short text outranks a long one that
holds a term as often. A term written again at once,
kiwi kiwi kiwi, is counted each time, where it was counted every other time. Aprefix*adds the same to every hit that holds it, as on Neo4j. The hits can come in another order, and athresholdcan keep others. - A query’s words on Postgres, SQLite and Oracle changed after 0.3.2. A word was a run of what Python’s
\wholds. It is now one of what the text’s[[:alnum:]_]holds (see Postgres): a Hindi word is no longer split at its vowel sign, andm²xis the phrasem x, as Neo4j’s analyzer reads it. - Filters apply after the search:
threshold,where, and a view’s required relationships. So a search can return fewer thantop_k;search_kasks the index for more, andtop_ktrims after the filter. - A full-text search is bounded where the index yields. A matching score is scaled over every hit the index
yields, so on Neo4j, Memgraph and FalkorDB
top_kbounds the hits too, and an ordinary query that matches most of a corpus costs no more than them. The index is asked for the besttop_khits. Where something filters them, it is asked fortop_k × 10(FILTERED_OVER_FETCH), and the filter keeps the besttop_kof those. Filters are awhere, a view’s required relationships and paths, and a label beyond the first (the index is over the first); a threshold keeps the best hits, so it isn’t one. So a filter that passes fewer than one in ten of the best hits can return fewer thantop_krows while the index holds more that pass it. The index is asked for no more than 2,147,483,647 hits, and Memgraph’s for no more than 1,000,000, however largetop_kis. propertynames the field to search when a fragment declares more than one index;partition_labelsearches the vector index of another label. Full-text text is the engine’s own query language, passed as it is; Postgres, SQLite and Oracle read the core of Lucene’s, as Neo4j does, so in a"phrase"a*is text, not aprefix*.
Index advice
Section titled “Index advice”An ordered query with no range index over exactly its ordered properties scans and sorts. Drivine says so, once for
each label and properties, naming the index to make. gom.index_advice = IndexAdvicePolicy.FAIL raises instead, for
development and CI (or override index_advice() on the assembly); OFF says nothing. The index list is read once:
gom.index_advisor.refresh() after making indexes as the application runs.
Store identity
Section titled “Store identity”await gom.store_identity() gives the store’s stable id, minted in the store on first sight: two connections see the
same id when, and only when, they look at the same data.

