Hikari-Systems/graphiti-slater

Run Graphiti's temporal knowledge graph on Slater: the FalkorDB dialect, transported over Bolt.

0

stars

6

commits

Python

primary language

Aug 16, 2026

updated

README

graphiti-slater

Run Graphiti's temporal knowledge graph on Slater.

Graphiti branches every query on driver.provider. Slater's Cypher surface is far closer to FalkorDB's than to Neo4j's — it borrowed FalkorDB's vecf32(), vec.cosineDistance() and db.idx.* namespace — so this adapter reports the FalkorDB dialect and transports it over Bolt. Graphiti's own query strings run unchanged; the adapter touches none of them.

from graphiti_core import Graphiti
from graphiti_slater import SlaterDriver

graphiti = Graphiti(graph_driver=SlaterDriver('bolt://slater:7687', 'graphiti', '…'))

For the stock Graphiti MCP server, which constructs its driver by name and offers no hook, put a sitecustomize.py on PYTHONPATH:

from graphiti_slater import install
install()

and configure the server with database.provider: "neo4j" pointing at Slater. It builds a SlaterDriver with no forking.

What the adapter actually does

Almost nothing, which is the point. Graphiti's writes come from models/{nodes,edges}/*_db_queries.py and have no extension point, so anything Slater cannot accept has to be fixed in Slater rather than papered over here. It has been. What remains is five things.

Why
provider = FALKORDBselects the branch whose Cypher Slater already speaks
SlaterSessionon the FalkorDB branch the bulk node save is a list of (query, params), which no Bolt session understands; and execute_write must not retry, because embeddings are generated inside the unit of work
search_interfaceroutes node similarity to the vector index (see below)
graph_operations_interfacerespells node deletes for the writable layer (see below)
build_indices_and_constraintsverifies the schema instead of creating it

The FalkorDB operations classes are reused as-is. Nothing under graphiti_core/driver/falkordb/operations/ imports the falkordb package — every method does records, _, _ = await executor.execute_query(...) then dict access, which neo4j.EagerResult and neo4j.Record both satisfy. The falkordb pip package is deliberately not a dependency; FalkorDriver is never imported, because it hard-imports that package at module scope.

Why node similarity is rerouted

Graphiti's FalkorDB node-similarity leg is a label scan that reads n.name_embedding as a column and scores it inline. On Slater an indexed embedding is routed out of the property record into the vector store, so a column read returns Null and that leg finds nothing — measured, not assumed. db.idx.vector.queryNodes finds the same vectors immediately, including ones written through the write delta moments earlier, so node similarity goes through the index. It is also strictly faster than the scan it replaces.

The score scales differ and the conversion happens in the query: Slater's score is the distance under the index metric, ascending; Graphiti's is (2 - cosineDistance) / 2, descending.

The index ranks before the filters (group id, labels, timestamps) are applied, so the adapter over-fetches (OVERSAMPLE) to keep recall. That cannot make a shortfall impossible — it is inherent to index-then-filter — so very selective filters may still return short.

Edge similarity is left alone. Slater's vector indexes are node-only, so an edge's fact_embedding stays an ordinary column that reads back verbatim, and Graphiti's inline vec.cosineDistance leg works exactly as written.

Fulltext: delegated, not overridden

Slater implements db.idx.fulltext.query{Nodes,Relationships} with the FalkorDB contract Graphiti expects — the same two-argument call, the same YIELD node|relationship, score, and score as BM25 ordered descending, which is what Graphiti's ORDER BY score DESC relies on. So all four fulltext legs run Graphiti's own query unchanged, and hybrid search fuses a real BM25 list with the similarity one.

Every leg sees writes immediately, with no consolidation in between. That needs Slater >= 0.25.1: relationships had no overlay arm before it, so a fact created or edited since the graph was built kept its old text until CALL slater.consolidate().

Two costs, neither of which changes which documents match: scoring statistics go slightly stale between consolidations — a superseded document's old text is not retained, so it cannot be subtracted from the corpus, biasing recently-edited terms down — and the overlay analyses each candidate's text per query, bounded by the size of the write delta and its sealed segments rather than by the graph.

Setting search_interface is all-or-nothing: search_utils consults it before every leg. Legs Slater answers natively raise NotImplementedError so Graphiti falls back to its own implementation, and the ones with no such fallback are implemented here. A test reads that list out of the installed search_utils source rather than hard-coding it, so a graphiti-core upgrade that drops a fallback fails the test instead of failing at runtime.

Why node deletes are respelled

Graphiti deletes a node by firing one statement per core label and relying on a non-matching MATCH being a no-op:

for label in ['Entity', 'Episodic', 'Community']:
    MATCH (n:{label} {uuid: $uuid}) DETACH DELETE n

Slater's writable layer resolves a delete through the business key and treats no such node as an error — no Entity(uuid = …) node to delete — so the loop dies on its first statement and never reaches the label the node actually has. Deleting an episode fails having deleted nothing. operations.py reads the node's labels first and issues exactly one delete; a node that is not there deletes nothing and raises nothing.

node_delete_by_group_id is respelled for a second reason: Graphiti keys it on group_id, an ordinary property, and the writable layer only deletes by business key. So the group is read first and each uuid deleted on its own. It refuses the seed group outright — losing a seed row loses its label at the next consolidation.

edge_delete is deliberately not implemented. Slater refuses a keyed relationship delete, and the keyless pair form it accepts cannot spare an edge's siblings — Graphiti writes several RELATES_TO between the same pair, one per fact. That call site catches NotImplementedError, so Graphiti's own statement runs and fails loudly, which is better than deleting the wrong facts.

Setting this interface is mostly, but not entirely, per-method. Most call sites wrap the dispatch in try: … except NotImplementedError. Six do not — episodic_node_save_bulk, node_save_bulk, episodic_edge_save_bulk and edge_save_bulk in bulk_utils, plus node_load_embeddings_bulk and edge_load_embeddings_bulk in search_utils — so merely setting graph_operations_interface takes ownership of the ingest and embedding-load paths. They are answered by doing exactly what Graphiti's own branch does. A test reads that list out of the installed graphiti-core rather than hard-coding it, for the same reason the search-leg test does.

Schema

Slater has no runtime DDL. Indexes are declared when the generation is built, which is what lets it promise that a query's cost is knowable from the manifest. Graphiti expects to create its indexes at startup and for that to be idempotent.

So build_indices_and_constraints verifies instead of creating: it reads get_range_indices() and get_fulltext_indices() from the installed graphiti-core, splits each composite range index into the single-property ones Slater declares, and checks both sets against SHOW INDEXES — a multi-property fulltext index being one row there, as it is one index. A mismatch raises at startup, naming exactly which declarations are missing and how to regenerate the dump.

This is a real operational constraint, not a detail: the graph must be rebuilt when graphiti-core's schema changes, when the configured entity types change, or when the embedder dimension changes. The requirement is derived from the installed graphiti-core, never transcribed, so an upgrade changes what the adapter demands — and says so at startup rather than drifting silently.

Tests

pytest tests/test_driver_shape.py          # no database needed
SLATER_BOLT=bolt://127.0.0.1:7699 pytest   # adds the integration tests

test_driver_shape.py pins the dispatch facts that are invisible until something fails deep inside a search or a bulk save; each test names the call site that depends on it. Two of them enumerate the call sites graphiti-core dispatches without a NotImplementedError fallback — five search legs and six graph operations — by reading its source, so an upgrade that drops a guard fails a test instead of an ingest.

The integration tests write into graphiti-slater-it, and the full-text one into its own group so its rows cannot appear in another test's group-scoped assertion. They also carry episodes: [] on every planted edge: since Slater's edge full-text leg started answering for real, these rows get hydrated back into EntityEdge, which requires a list there.

Contributors

Hikari-Systems/graphiti-slater

Run Graphiti's temporal knowledge graph on Slater: the FalkorDB dialect, transported over Bolt.

0

stars

6

commits

Python

primary language

Aug 16, 2026

updated

README

graphiti-slater

Run Graphiti's temporal knowledge graph on Slater.

Graphiti branches every query on driver.provider. Slater's Cypher surface is far closer to FalkorDB's than to Neo4j's — it borrowed FalkorDB's vecf32(), vec.cosineDistance() and db.idx.* namespace — so this adapter reports the FalkorDB dialect and transports it over Bolt. Graphiti's own query strings run unchanged; the adapter touches none of them.

from graphiti_core import Graphiti
from graphiti_slater import SlaterDriver

graphiti = Graphiti(graph_driver=SlaterDriver('bolt://slater:7687', 'graphiti', '…'))

For the stock Graphiti MCP server, which constructs its driver by name and offers no hook, put a sitecustomize.py on PYTHONPATH:

from graphiti_slater import install
install()

and configure the server with database.provider: "neo4j" pointing at Slater. It builds a SlaterDriver with no forking.

What the adapter actually does

Almost nothing, which is the point. Graphiti's writes come from models/{nodes,edges}/*_db_queries.py and have no extension point, so anything Slater cannot accept has to be fixed in Slater rather than papered over here. It has been. What remains is five things.

Why
provider = FALKORDBselects the branch whose Cypher Slater already speaks
SlaterSessionon the FalkorDB branch the bulk node save is a list of (query, params), which no Bolt session understands; and execute_write must not retry, because embeddings are generated inside the unit of work
search_interfaceroutes node similarity to the vector index (see below)
graph_operations_interfacerespells node deletes for the writable layer (see below)
build_indices_and_constraintsverifies the schema instead of creating it

The FalkorDB operations classes are reused as-is. Nothing under graphiti_core/driver/falkordb/operations/ imports the falkordb package — every method does records, _, _ = await executor.execute_query(...) then dict access, which neo4j.EagerResult and neo4j.Record both satisfy. The falkordb pip package is deliberately not a dependency; FalkorDriver is never imported, because it hard-imports that package at module scope.

Why node similarity is rerouted

Graphiti's FalkorDB node-similarity leg is a label scan that reads n.name_embedding as a column and scores it inline. On Slater an indexed embedding is routed out of the property record into the vector store, so a column read returns Null and that leg finds nothing — measured, not assumed. db.idx.vector.queryNodes finds the same vectors immediately, including ones written through the write delta moments earlier, so node similarity goes through the index. It is also strictly faster than the scan it replaces.

The score scales differ and the conversion happens in the query: Slater's score is the distance under the index metric, ascending; Graphiti's is (2 - cosineDistance) / 2, descending.

The index ranks before the filters (group id, labels, timestamps) are applied, so the adapter over-fetches (OVERSAMPLE) to keep recall. That cannot make a shortfall impossible — it is inherent to index-then-filter — so very selective filters may still return short.

Edge similarity is left alone. Slater's vector indexes are node-only, so an edge's fact_embedding stays an ordinary column that reads back verbatim, and Graphiti's inline vec.cosineDistance leg works exactly as written.

Fulltext: delegated, not overridden

Slater implements db.idx.fulltext.query{Nodes,Relationships} with the FalkorDB contract Graphiti expects — the same two-argument call, the same YIELD node|relationship, score, and score as BM25 ordered descending, which is what Graphiti's ORDER BY score DESC relies on. So all four fulltext legs run Graphiti's own query unchanged, and hybrid search fuses a real BM25 list with the similarity one.

Every leg sees writes immediately, with no consolidation in between. That needs Slater >= 0.25.1: relationships had no overlay arm before it, so a fact created or edited since the graph was built kept its old text until CALL slater.consolidate().

Two costs, neither of which changes which documents match: scoring statistics go slightly stale between consolidations — a superseded document's old text is not retained, so it cannot be subtracted from the corpus, biasing recently-edited terms down — and the overlay analyses each candidate's text per query, bounded by the size of the write delta and its sealed segments rather than by the graph.

Setting search_interface is all-or-nothing: search_utils consults it before every leg. Legs Slater answers natively raise NotImplementedError so Graphiti falls back to its own implementation, and the ones with no such fallback are implemented here. A test reads that list out of the installed search_utils source rather than hard-coding it, so a graphiti-core upgrade that drops a fallback fails the test instead of failing at runtime.

Why node deletes are respelled

Graphiti deletes a node by firing one statement per core label and relying on a non-matching MATCH being a no-op:

for label in ['Entity', 'Episodic', 'Community']:
    MATCH (n:{label} {uuid: $uuid}) DETACH DELETE n

Slater's writable layer resolves a delete through the business key and treats no such node as an error — no Entity(uuid = …) node to delete — so the loop dies on its first statement and never reaches the label the node actually has. Deleting an episode fails having deleted nothing. operations.py reads the node's labels first and issues exactly one delete; a node that is not there deletes nothing and raises nothing.

node_delete_by_group_id is respelled for a second reason: Graphiti keys it on group_id, an ordinary property, and the writable layer only deletes by business key. So the group is read first and each uuid deleted on its own. It refuses the seed group outright — losing a seed row loses its label at the next consolidation.

edge_delete is deliberately not implemented. Slater refuses a keyed relationship delete, and the keyless pair form it accepts cannot spare an edge's siblings — Graphiti writes several RELATES_TO between the same pair, one per fact. That call site catches NotImplementedError, so Graphiti's own statement runs and fails loudly, which is better than deleting the wrong facts.

Setting this interface is mostly, but not entirely, per-method. Most call sites wrap the dispatch in try: … except NotImplementedError. Six do not — episodic_node_save_bulk, node_save_bulk, episodic_edge_save_bulk and edge_save_bulk in bulk_utils, plus node_load_embeddings_bulk and edge_load_embeddings_bulk in search_utils — so merely setting graph_operations_interface takes ownership of the ingest and embedding-load paths. They are answered by doing exactly what Graphiti's own branch does. A test reads that list out of the installed graphiti-core rather than hard-coding it, for the same reason the search-leg test does.

Schema

Slater has no runtime DDL. Indexes are declared when the generation is built, which is what lets it promise that a query's cost is knowable from the manifest. Graphiti expects to create its indexes at startup and for that to be idempotent.

So build_indices_and_constraints verifies instead of creating: it reads get_range_indices() and get_fulltext_indices() from the installed graphiti-core, splits each composite range index into the single-property ones Slater declares, and checks both sets against SHOW INDEXES — a multi-property fulltext index being one row there, as it is one index. A mismatch raises at startup, naming exactly which declarations are missing and how to regenerate the dump.

This is a real operational constraint, not a detail: the graph must be rebuilt when graphiti-core's schema changes, when the configured entity types change, or when the embedder dimension changes. The requirement is derived from the installed graphiti-core, never transcribed, so an upgrade changes what the adapter demands — and says so at startup rather than drifting silently.

Tests

pytest tests/test_driver_shape.py          # no database needed
SLATER_BOLT=bolt://127.0.0.1:7699 pytest   # adds the integration tests

test_driver_shape.py pins the dispatch facts that are invisible until something fails deep inside a search or a bulk save; each test names the call site that depends on it. Two of them enumerate the call sites graphiti-core dispatches without a NotImplementedError fallback — five search legs and six graph operations — by reading its source, so an upgrade that drops a guard fails a test instead of an ingest.

The integration tests write into graphiti-slater-it, and the full-text one into its own group so its rows cannot appear in another test's group-scoped assertion. They also carry episodes: [] on every planted edge: since Slater's edge full-text leg started answering for real, these rows get hydrated back into EntityEdge, which requires a list there.

Contributors

Languages

Python

89.1%

Cypher

7.7%

Dockerfile

3.2%