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.
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 = FALKORDB | selects the branch whose Cypher Slater already speaks |
SlaterSession | on 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_interface | routes node similarity to the vector index (see below) |
graph_operations_interface | respells node deletes for the writable layer (see below) |
build_indices_and_constraints | verifies 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.
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.
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.
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.
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.
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.
6 commits
Python
89.1%
Cypher
7.7%
Dockerfile
3.2%
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.
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 = FALKORDB | selects the branch whose Cypher Slater already speaks |
SlaterSession | on 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_interface | routes node similarity to the vector index (see below) |
graph_operations_interface | respells node deletes for the writable layer (see below) |
build_indices_and_constraints | verifies 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.
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.
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.
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.
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.
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.
6 commits
Python
89.1%
Cypher
7.7%
Dockerfile
3.2%