Skip to content

Schema Management (Indexes & Constraints)

Drivine manages vector indexes, full-text indexes, range indexes, and uniqueness constraints across Neo4j, Memgraph, FalkorDB and Oracle — with idempotent, drift-aware ensure semantics and engine differences (DDL syntax, introspection, FalkorDB’s Redis-command constraints) handled for you. Opt-in: declare nothing, pay nothing.

Every PersistenceManager exposes indexes and constraints managers. Operations always run in auto-commit mode (schema DDL cannot run inside a data transaction):

// Idempotent — call on every startup
val result = persistenceManager.indexes.ensure(
VectorIndexSpec(label = "Proposition", property = "embedding", dimensions = 1536)
)
persistenceManager.indexes.ensure(RangeIndexSpec("Proposition", "contextId"))
persistenceManager.indexes.ensure(RangeIndexSpec("Message", listOf("sessionId", "createdAt"))) // composite
persistenceManager.indexes.ensure(FullTextIndexSpec("Chunk", "text")) // full-text (single property)
persistenceManager.indexes.ensure(FullTextIndexSpec("Entity", listOf("name", "description"))) // full-text (multi-property)
persistenceManager.constraints.ensure(UniquenessConstraintSpec("ChatSession", "sessionId"))
persistenceManager.constraints.ensure(UniquenessConstraintSpec("Membership", listOf("tenantId", "userId")))

ensure returns an EnsureResult:

Result Meaning
Created Nothing existed and the item was created, or it existed without part of what the engine makes for it (on Oracle, a range index without its index over the label’s nodes) and that part was made
AlreadyMatching A matching item exists; nothing changed
Drift An item exists with a different shape (e.g. vector dimensions changed). Nothing changed — call recreate(spec) to replace it (destructive)
Violation Constraint only: existing data violates it. Includes a bounded sample of the conflicting values
Recreated From an explicit recreate(spec) — old item dropped, new one created

Register a SchemaCatalog bean and Drivine ensures everything on startup (indexes before constraints):

@Bean
fun propositionSchema(embeddingService: EmbeddingService) = SchemaCatalog.of(
VectorIndexSpec("Proposition", "embedding", embeddingService.dimensions),
RangeIndexSpec("Proposition", "contextId"),
UniquenessConstraintSpec("Proposition", "id"),
)

Multiple catalog beans merge; identical declarations deduplicate; conflicting declarations for the same (kind, label, properties) fail startup.

Which databases a catalog applies to. By default a catalog broadcasts to every schema-capable registered database — engines without DDL support (Neptune, openCypher) are skipped with a warning. Narrow it when you need to:

SchemaCatalog.of(...) // all schema-capable databases (default)
SchemaCatalog.of(...).forDefaultDatabase() // only the primary (first-registered) datasource
SchemaCatalog.of(...).forDatabase("users") // one named datasource
SchemaCatalog.of(...).forDatabases("a", "b") // a specific set

Broadcast is lenient (skips engines that can’t do schema); an explicitly named target is strict — pointing it at an unknown or schema-incapable datasource fails startup. "default" resolves to the first-registered datasource, consistent with the rest of Drivine.

Targeting is replace/last-wins, not additive — use one forDatabases("a", "b") call to target several; chaining forDatabase(...) calls does not accumulate (last wins).

@NodeFragment(labels = ["Proposition"])
data class PropositionNode(
@NodeId
@RangeIndex
@Unique
val id: String,
@RangeIndex
val contextId: String,
val text: String,
@VectorIndex(similarity = SimilarityFunction.COSINE)
val embedding: List<Float>?,
@FullTextIndex // full-text index on this property (also drives loadMatching)
val body: String,
)
// Composite declarations go on the class
@NodeFragment(labels = ["Message"])
@RangeIndex(properties = ["sessionId", "createdAt"])
@Unique(properties = ["sessionId", "sequence"])
@FullTextIndex(properties = ["subject", "body"]) // multi-property full-text index
data class MessageNode(/* ... */)

Scan them into a catalog — vector dimensions come from your embedding model at runtime, via a VectorDimensionProvider:

@Bean
fun schema(embeddingService: EmbeddingService) = SchemaCatalog.fromFragments(
VectorDimensionProvider { _, _ -> embeddingService.dimensions },
PropositionNode::class,
MessageNode::class,
)

Works for Java fragments too (SchemaCatalog.fromFragments(JavaNode.class)).

Catalogs are applied by a SchemaManager bean, enforced once on startup. It’s also injectable, so you can drive schema changes at runtime — e.g. build indexes after a bulk load, or rebuild after re-embedding:

@Component
class Reindexer(private val schema: SchemaManager) {
fun afterBulkLoad() {
schema.enforce() // idempotent: create what's missing, recreate on version change
}
fun afterReembed() {
schema.recreateAll() // brute-force: drop + recreate every declared item
}
}

enforce() is safe to call repeatedly; recreateAll() is the destructive hammer. (drivine.schema.enabled=false disables only the startup run — the bean is still there for runtime calls.)

Drift detection only catches changes introspection can see (dimensions, similarity, properties). A change it can’t see — swapping the embedding model for one with the same dimensions — leaves stale vectors behind. Tag a catalog with a version token to force a one-time rebuild when that token changes:

@Bean
fun chunkSchema(embeddingService: EmbeddingService) = SchemaCatalog.of(
VectorIndexSpec("Chunk", "embedding", embeddingService.dimensions),
).withVersion(embeddingService.modelId) // bump this → recreate once

How it works: the last-applied token is stored in a reserved _DrivineSchema marker node per database (portable across the engines; on Oracle it is a row of the graph’s node table). On enforce(), a changed token drops and recreates that catalog’s items, then records the new token; a first-ever token is adopted without recreating, so turning versioning on never nukes a healthy schema.

Recreating a vector index rebuilds it from the stored embedding properties — it does not re-embed. After a model swap, re-embed the nodes first (same dimensions → the index even auto-updates), then enforce()/recreateAll() for the structural half. Drivine manages schema, not your embeddings.

drivine:
schema:
enabled: true # master switch for startup initialization
mode: FAIL_FAST # FAIL_FAST (default) or WARN on drift/violations
recreate-on-drift: false # destructive: rebuild items whose shape changed
recreate-on-startup: false # destructive: rebuild everything every startup
violation-sample-size: 10 # conflicting rows sampled on constraint violations
Neo4j Memgraph FalkorDB
Vector indexes CREATE VECTOR INDEX … IF NOT EXISTS WITH CONFIG {…}, uSearch metrics OPTIONS {…}, unnamed
Full-text indexes CREATE FULLTEXT INDEX … ON EACH […] (+ analyzer) CREATE TEXT INDEX … ON :L(props) CREATE FULLTEXT INDEX FOR (n:L) ON (n.prop), per-property, unnamed
Range indexes Named, composite supported Label-property style Per-label coverage; extended incrementally
Uniqueness REQUIRE … IS UNIQUE ASSERT … IS UNIQUE Redis command GRAPH.CONSTRAINT (not Cypher) — Drivine issues it at driver level, auto-creates the required backing index, and polls the asynchronous build
Item names Yes Vector only No

Oracle. Every item is an index over an expression, since a property is a path into a JSON column, and what Drivine made is recorded in the table <graph>_schema, which ensure and drift detection read. An item whose index or table was dropped by hand is made again by the next ensure.

Oracle
Vector indexes A table of embeddings (VECTOR(n, FLOAT32)) beside the nodes, kept in step by a trigger and filled from the nodes there are, with CREATE VECTOR INDEX … ORGANIZATION NEIGHBOR PARTITIONS over it. An embedding of another dimension leaves its node out of the index
Full-text indexes A table of the indexed text beside the nodes, kept in step by a trigger, with an Oracle Text search index over it. Without Oracle Text the index is recorded and nothing is made
Range indexes Two indexes. CREATE INDEX … (JSON_VALUE(props, '$.p')), the property read as text, over the property whatever the label, so two labels that index a property of one name share it: a save and a load by id find a node by it. And CREATE INDEX … (CASE WHEN JSON_EXISTS(n.labels, '$[*]?(@ == "Label")') THEN JSON_VALUE(n.props, '$.p') END), over the property of the label’s nodes alone, which a filter on the label’s nodes reads. Composite supported
Uniqueness A unique index over the property for the nodes that carry the label, with the range index a node is found by. A node that lacks one of the properties is not constrained. Over one property, the unique index stands for the range index’s index over the label’s nodes, and none is made beside it
Item names Recorded; the Oracle object’s name is derived from the graph, label and properties, and recorded with it. A drop drops the object the record names

The store’s identity (DrivineStore) and the schema marker are nodes of the graph on Oracle too, and the graph’s tables are made for them when they are the first thing asked of a new graph.

Neptune and generic openCypher have no schema management support — operations fail loudly rather than silently no-op.