
This blog is written by AI for SEO
How to Migrate from Neo4j to HelixDB: A Migration Guide
Engineering teams building production AI agents often hit a performance ceiling with Neo4j. The typical RAG stack uses Neo4j for the knowledge graph and a separate database like Pinecone or Milvus for vector embeddings. This multi-database architecture forces developers to write complex glue code to synchronize node IDs and metadata across different network calls. Every retrieval crosses the network twice, and you own the consistency problem between the two stores for as long as you run them.
Migrating to HelixDB replaces that with a single Rust engine doing graph traversal, vector ANN and BM25 full-text search, with ACID transactions across all three. This guide is the practical version: what maps cleanly out of Neo4j, what needs a decision, how to load the data, how to rewrite your Cypher, and what you give up by leaving. We will be direct about that last part, because you are the one on call afterwards.
Step 1: Decide Whether the Migration Is Worth It
Neo4j is the incumbent and it earned that. The tooling, the visualisation, the driver ecosystem and the size of the Cypher talent pool are all ahead of everyone else, and foundation models write good Cypher because there is so much of it to learn from. None of that changes if you leave, so be honest about whether you need to.
You should probably stay on Neo4j if your team is fluent in Cypher and productive in it, if you lean on the Graph Data Science library for algorithms or on visual exploration, if your workload is analytical rather than agent-shaped, or if your working set fits in memory and is not growing with your user count.
The reason to move is what happens when the vector side grows. Neo4j's own operations manual asks you to budget spare filesystem cache for the vector index on top of heap and page cache: roughly 40 percent of the physical index size when quantized, closer to 100 percent unquantized. That is their number, not ours, and their operations manual's memory configuration guide has the current figures. Do that arithmetic against the embedding count you expect in a year before you decide anything else.
HelixDB is built in Rust, and the case for switching is functional first and ergonomic last. You get vector pre-filtering and true hybrid retrieval inside one ACID engine, so a similarity search can be scoped to a tenant partition or to the exact set of nodes a traversal reaches, instead of sweeping a global index and discarding most of the hits afterwards. Post-filtering a global ANN result is not just slower. It can return nothing at all when the filter is selective, because the top k came back and none of it belonged to the tenant you were asking about.
Then scale. The open-source build runs three ways as a startup configuration: fully in memory, on disk, or against object storage, meaning S3 or any S3-compatible store including a local MinIO container. Same engine, same SDKs, same endpoint in all three, so capacity stops being the same dial as cost. Helix Cloud is the managed version of the object-storage mode, with a single writer, readers that scale horizontally, and SSD and memory caching above the bucket. That is also the answer to the uptime and availability question a platform lead will ask third.
Licence freedom is the last one and it takes a minute to check. The HelixDB open-source core is Apache-2.0, in the LICENSE file on the repo, which matters if you are embedding a database inside something you ship. If you want the longer side-by-side on retrieval models rather than migration mechanics, we covered that in vector database vs graph database and in HelixDB vs Neo4j.
Step 2: Audit Your Neo4j Schema and Vector Indexes
Before moving any data, map your Neo4j labels and properties to the HelixDB data model. Most of it carries over unchanged, because both are labelled property graphs. Node labels stay node labels, relationship types become edge labels, properties stay properties.
Two things need a decision rather than a copy. Multi-label nodes: Neo4j lets a node carry several labels, so pick a primary one and model the rest as properties or as edges to a type node. And vectors: in HelixDB a vector is a property on a node or an edge, exactly as in Neo4j, but it has to be a top-level numeric array rather than nested inside a map. Vector indexes normalize to float32 and support cosine, euclidean and manhattan distance, and the query vector's dimension has to match the index.
Time is worth planning properly, and it maps across unchanged. You model history the way you already do in Neo4j, with timestamped nodes and edges, because graph versioning is a thing you design rather than a thing a database hands you.
What carries the load once you have modelled it is the range index. Numeric and string properties support gt, gte, lt, lte and between, plus ordered scans in either direction, so "everything this agent saw between Tuesday and Thursday" is an indexed scan over a sorted range rather than a full traversal filtered afterwards, and "the last twenty things that happened to this user" is a descending scan you can stop early. Index the timestamp fields you actually filter on when you get to step 5, and the recency half of agent memory stops being the expensive half. It composes too: chain a vector search onto a time-bounded traversal and recency and semantic relevance resolve in one request.
Step 3: Pause Writes and Export a Snapshot You Can Check
Export Something You Can Replay
The export is where migrations go wrong, and almost always in the same way: someone exports a graph that is still taking writes, then spends two days reconciling counts that were never going to match.
Pause every writer first. Then read the graph in one read transaction, with label-scoped queries rather than one query for the whole thing, and sort every record by its stable ID so two exports of the same data produce the same file. Write a SHA-256 sidecar next to it and keep the two together.
Version the snapshot and discriminate every record by kind, so an unsupported or ambiguous record fails at the door instead of halfway through the load:
{
"version": 1,
"exportedAt": "2026-08-11T00:00:00.000Z",
"nodes": [
{ "kind": "document", "documentId": "d-q3-board", "title": "Q3 board notes", "embedding": [0.021, -0.118] },
{ "kind": "entity", "entityId": "e-acme", "name": "Acme", "type": "Organisation" }
],
"relationships": [
{ "kind": "mentions", "relationshipId": "r-q3-acme", "fromDocumentId": "d-q3-board", "toEntityId": "e-acme", "count": 3 }
]
}Note what is not in there: Neo4j's internal node and relationship IDs. Do not migrate them. They are not stable across a restore, and a migration keyed on them is a migration you cannot safely replay. Use the business IDs already on your nodes, and if your relationships have no stable ID of their own, add one before you export. Every idempotent step later in this guide depends on that ID existing.
Write Down What You Are Actually Moving
A typical GraphRAG schema on the Neo4j side looks something like this. Write yours out before you touch anything, because the migration is mostly this document turned into code.
// constraints and indexes on the source
CREATE CONSTRAINT doc_id IF NOT EXISTS
FOR (d:Document) REQUIRE d.id IS UNIQUE;
CREATE VECTOR INDEX doc_embedding IF NOT EXISTS
FOR (d:Document) ON (d.embedding)
OPTIONS {indexConfig: {
`vector.dimensions`: 1536,
`vector.similarity_function`: 'cosine'
}};
// the shape you are moving
(:Document {id, title, text, embedding, createdAt})
(:Entity {id, name, type})
(:Document)-[:MENTIONS {count}]->(:Entity)
(:Document)-[:REFERENCES]->(:Document)Get the inventory out of the database rather than out of your memory of it:
CALL db.labels();
CALL db.relationshipTypes();
SHOW INDEXES;
MATCH (d:Document)
RETURN count(d) AS docs,
size(head(collect(d.embedding))) AS dimension;That last query gives you the embedding dimension you have to declare on the HelixDB side. It is the single most common thing people get wrong, and you find out at load time rather than at design time.
Getting the Data Out
A Neo4j native .dump is their backup format, and HelixDB cannot load it. Their dump and restore documentation covers what it is actually for. What you want is a logical export you transform explicitly.
The Neo4j driver plus label-scoped Cypher is the straightforward path, because it puts the shape of the output under your control and lets you sort and check as you go. If your team already runs APOC, short for Awesome Procedures On Cypher, Neo4j's standard extension library where the export procedures live, its JSON export works as a source too, because it preserves array types and CSV does not. File export is off by default and enabled in apoc.conf, and the file lands in the import directory rather than wherever you ran the query from.
CALL apoc.export.json.all("graph.json", {useTypes: true});Either way, treat the output as an intermediate. You still transform it into the versioned snapshot above, because that is the artefact everything downstream is checked against.
If You Are Coming from Neptune Instead
Everything after the export is a transformation script, so the rest of this guide holds whatever the source is. Only this step changes.
Amazon Neptune exports through neptune-export, which runs either as a managed service or as a command-line utility, documented in the AWS Neptune user guide. Property-graph exports publish to S3, with node data split into separate files by label combination, so your transformation reads a directory rather than one file.
One line in their docs should shape your cutover plan rather than surprise you during it: AWS state that if the cluster's data changes during an export, the consistency of the exported data is not guaranteed, and the fix they offer is to have the job clone the cluster and export from the clone. That is the same reason to pause writes before the snapshot, arriving from the other direction.
Verify that your embeddings are included in the export. Some teams store large vector arrays in a separate service. If that is your case, join the export with your vector store export on a common ID field, because HelixDB expects them in the same record. This is also the right time to strip out legacy properties and orphan nodes that have accumulated in your graph.
Step 4: Validate the Snapshot Before You Write Anything
Run the validation while Neo4j writes are still paused, and run it against the file rather than against the database, so a failure costs you a re-export and not a half-loaded target.
Check the checksum, the dump version, the exact property types, that node and relationship IDs are unique, that arrays you rely on are not empty, and that both endpoints of every relationship actually exist in the node set. The failures you want to catch here read like this:
duplicate Document.documentId: d-q3-boardr-q3-acme references missing Entity e-acmechecksum mismatch for graph.v1.jsonan unknown
versionorkind
If validation fails, fix the exporter or the mapping and take a new snapshot. Do not hand-edit the dump to get past a check. The point of the checksum is that the file you validated is the file you loaded, and an edited dump quietly gives that up at the exact moment you would want it.
Step 5: Install HelixDB and Create Your Indexes
Scaffold a project and bring up a local instance:
mkdir helix-migration && cd helix-migration
helix init
helix start devThat gives you an instance on port 6969 speaking the same interface Helix Cloud does.
Run mode is a startup flag, not a product tier. In-memory is the default, --disk gives you a persistent local run backed by a MinIO sidecar, and the same engine runs against S3 or any S3-compatible store. Prototype the migration in memory, re-run it on disk, ship it against object storage, without the loading script changing.
While you are set up, install the agent skills:
helix skills installThose are query-authoring skills, including helix-query-typescript, helix-query-rust and helix-memory-system, that a coding agent reads so it writes real HelixDB queries instead of guessing at an API. On a migration you are rewriting every read path you own, which is exactly the job worth handing an agent that has the current API in front of it. They install globally to ~/.agents/skills, or into the project with --project, and the CLI command reference has the rest of the surface.
There is no schema file and no compile step. Indexes are created by a write query, with the same SDK you write and read everything else with. For a GraphRAG workload that is usually a vector index on the property holding your document embeddings, plus range indexes on the timestamp fields you filter on.
import { writeBatch, g, VectorDistanceMetric } from "@helix-db/helix-db";
writeBatch()
.varAs(
"index",
g().createVectorIndexNodes(
"Document",
"embedding",
1536,
VectorDistanceMetric.Cosine,
null,
),
)
.returning(["index"]);The arguments are the node label, the property holding the embedding, the dimension, the distance metric, and a tenant property to partition the index on. Pass null for a global index. Cosine, euclidean and manhattan are the available metrics, and the dimension has to match your vectors exactly, which is why you counted it in step 3. The vector index guide has the full lifecycle.
You want equality indexes as well, on the identity properties the snapshot is keyed on: one per node label on its business ID, and one on relationshipId for each edge label. Those are what make the load idempotent and the verification cheap. Submit them with createIndexIfNotExists so re-running the setup is harmless.
Then wait for them. Index creation is asynchronous, and a request coming back means it was accepted, not that the index is usable. Parse the operation ID from each response, poll getIndexOperation until every status reads succeeded, and stop if one comes back blocked or aborted or times out. Starting the node batches because the index request was accepted is the fastest route to a load you have to throw away.
Create the indexes before the bulk load rather than after. Index creation returns before the backfill has necessarily finished and a new generation stays invisible until it validates and activates, so it is easier to have the load write into an index that already exists than to wait on a backfill over everything you just inserted.
The engine is written from scratch in Rust and it is OLTP, so what you are migrating to is a database you can write to under load, not a read-only analytics copy. If you are going straight to Helix Cloud, point the CLI at your cloud endpoint instead of a local instance. Everything below is identical either way, which is the whole point of the run modes.
Step 6: Transform and Load Your Data into HelixDB
Convert the Neo4j JSON export into HelixDB writes. Every operation, bulk loads included, is a JSON envelope sent as one POST to /v2/query. You do not hand-write that JSON. You build the query with the native Rust, TypeScript, Go or Python SDK inside your own transformation script and the SDK serializes it. Nothing is compiled and nothing is generated.
Load every node before you load any edge, and key both passes on the stable IDs from step 3 rather than on anything Neo4j generated. That is what makes the load replayable.
Make the node write an upsert rather than an insert. Look for the node by its business ID, update it if it is there, create it if it is not, and the same batch can run twice without duplicating anything:
const body = writeBatch()
.varAs(
"existing",
g().nWithLabel("Document").where(Predicate.eqParam("documentId", "documentId")),
)
.varAsIf(
"updated",
BatchCondition.varNotEmpty("existing"),
g()
.n(NodeRef.var("existing"))
.setProperty("title", PropertyInput.param("title"))
.setProperty("embedding", PropertyInput.param("embedding")),
)
.varAsIf(
"created",
BatchCondition.varEmpty("existing"),
g().addN("Document", {
documentId: PropertyInput.param("documentId"),
title: PropertyInput.param("title"),
embedding: PropertyInput.param("embedding"),
}),
);
const request = writeBatch()
.forEachParam("rows", body)
.returning(["updated", "created"])
.toQueryRequest(rowsParams, { rows }, { queryName: "upsert_documents" });Relationships follow the same shape, keyed on relationshipId: drop the existing edge by ID and write it again, so a replay replaces rather than duplicates. Because nodes land first, both endpoints are already there to attach to.
One HelixDB request is one transaction. If a request fails, nothing in that batch committed, so once the writes themselves are idempotent a failed batch is safe to retry whole. Retry the complete batch on an HTTP 409 conflict rather than picking through it for the rows that made it. Keep batches bounded, a few hundred to a thousand operations, because an oversized batch turns one bad record into a rollback of everything you just sent. The write batch semantics are worth reading before you pick a size.
Run one loader process against a fresh target. Sequential replay is safe, but edge equality indexes are not uniqueness constraints, so two loaders racing each other can still produce duplicates that neither of them notices. An interrupted load is fine: rerun it and the upserts settle. The Python SDK fits neatly here if your ETL already lives in Python.
Step 7: How Do I Rewrite a Cypher Query Without Cypher?
Your Cypher does not carry over, and there is no HelixDB query language to learn in its place. Queries are built with the SDK in whatever language your service is already written in, then serialized to JSON and sent in one request. A Cypher string you concatenated and shipped over Bolt becomes a builder chain your own compiler checks.
Translate behaviour, not tokens. For each query, write down five things before you touch the builder: which property is the indexed anchor, which way the edges run, which edge properties the result needs, which fields come out, and how the rows are ordered. Then keep the repository interface your application already calls and replace only the adapter underneath it. That gives the migration a real finish line: it is done when both implementations return equal objects, not when the new code compiles.
Two builder shapes cover most of the work. project pulls named fields off whatever the stream is currently standing on, and on an edge stream Projection.fromEndpoint reaches through to the node at the other end, which is how a Cypher pattern comprehension that collects the documents pointing at an entity becomes one edge traversal. When you need fields from both the edge and the node it leads to, bind them and project by binding name: .outE("MENTIONS").bind("mention").outN().bind("entity").
One difference to plan for rather than discover: HelixDB does not order rows after a binding projection, so a Cypher query ending in an ORDER BY over collected rows gets its sort in the adapter instead. That is a few lines in the repository, not a redesign, but it is much better found while you are writing the query than while you are diffing results.
// Cypher:
// MATCH (u:User {id: $uid})-[:AUTHORED]->(d:Document)
// RETURN d LIMIT 20
import { readBatch, g, SourcePredicate } from "@helix-db/helix-db";
readBatch()
.varAs(
"docs",
g()
.nWithLabelWhere("User", SourcePredicate.eq("id", uid))
.out("AUTHORED")
.limit(20)
.valueMap(["$id", "title"]),
)
.returning(["docs"]);This is the part of the migration an agent is genuinely good at, and the reason to install the skills in step 5. The translation is mechanical once you know the builder names, and mechanical plus repetitive across a whole codebase is the shape of work worth delegating. Read what it produces, though. The traversal semantics carry over from Cypher almost directly; the filtering semantics do not, because a source predicate that can be pushed down to an index is a different method from a predicate that filters the stream afterwards.
The retrieval queries are where the migration actually pays. In Neo4j a GraphRAG step is usually a CALL db.index.vector.queryNodes for the semantic half, a MATCH for the structural half, and application code stitching the two together. In HelixDB both halves are operations in the same request against the same engine, so there is no window where one side has moved and the other has not.
import { defineParams, param } from "@helix-db/helix-db";
const params = defineParams({
uid: param.string(),
query_vector: param.array(param.f32()),
limit: param.i64(),
});
readBatch()
.varAs(
"hits",
g()
.nWithLabelWhere("User", SourcePredicate.eq("id", params.uid))
.out("AUTHORED")
.vectorSearchWith("Document", "embedding", params.query_vector, params.limit)
.valueMap(["$id", "title", "$distance"]),
)
.returning(["hits"]);That is the query worth migrating for, so be precise about what it does. The traversal runs first and defines an exact candidate set, the vector ranking runs over that set, and then you take the top k. Traversal membership is authoritative: a result outside the candidate set cannot come back. That is the opposite of the usual arrangement, where you take a global top k and filter it afterwards and a selective filter hands you an empty list because none of the k belonged to the user you asked about.
Exact membership here means the output is validated against the traversal set, not that the engine compares every candidate embedding by brute force. Approximate structures still do the ranking. Two other requirements are easy to miss: the candidate label and property need a compatible vector index at the right dimension, and you should project $distance before traversing away from a ranked hit or it will not survive into the response. If the index is tenant-partitioned, build the candidate stream from the same partition the index was built with. Expect fewer rows than your limit sometimes, too, because the result is bounded by the number of unique candidates the traversal reached.
All four SDKs emit the same JSON, so a Go service and a TypeScript service see the same query surface. In TypeScript, Rust and Python the call picks the node or edge form from the traversal state it is standing on; Go spells the two out separately.
Step 8: Verify Parity and Cut Over
Run the same repository calls against both databases and compare the objects that come back. Node and edge counts by label, properties, relationship directions, edge values, and the full result of every read path your application actually uses.
Do not cut over on counts alone. Equal counts hide reversed edges, missing properties and changed ordering, and all three of those pass a count check and then fail in production. While you are there, replay the entire snapshot a second time and confirm nothing moves. That is the cheapest proof that the load is genuinely idempotent, and it is the thing you will want to be sure of if the cutover goes long and you need to re-run it.
Retrieval is worth checking on its own terms. Run a vector search per index to confirm each one answers at the right dimension, and compare ranked results against your Neo4j baseline. Expect small ranking differences, because the two engines use different index structures. What you are looking for is not identical order, it is the same documents coming back for the same question.
Keep writes paused through the final verification, then change the dependency injection in your application from the Neo4j repository to the HelixDB one, deploy, and run smoke tests through the real API rather than against the database. Keep the source snapshot until the rollback window closes.
If you cannot take a maintenance window, the shape changes but the steps do not: take the snapshot, then keep a change stream or dual writes running against both databases, drain the remaining changes, and verify before you switch. The changes still have to arrive as the same idempotent writes the loader uses, which is the other reason to build the load that way.
Measure the latency of your own pipeline afterwards rather than trusting anyone's number, ours included. What you removed is a network hop and a synchronisation step, so the size of the win depends entirely on how much of your latency lived there.
Migration Checklist
Every node and relationship has a stable application ID, and no Neo4j internal IDs are being migrated.
Writes are paused for the snapshot and for the final cutover.
Multi-label nodes have an explicit single-label mapping.
The checksum and the schema validation both pass before any write.
Every index reads succeeded before the first node batch.
Nodes load before relationships.
A full replay produces no duplicates.
Counts, directions, properties, edge values and repository results all match.
The application runs its smoke tests against HelixDB.
The source snapshot is retained for the rollback window.
Conclusion
Migrating from Neo4j to HelixDB makes sense for teams that have outgrown the duct-tape approach to AI memory. Consolidating your knowledge graph, vector search, and metadata into a single Rust engine removes the operational complexity and high latency of multi-database retrieval. You end up with one engine to run, one place where the data is consistent, and a storage mode you pick at startup rather than at purchase.
If you are tired of managing separate clusters for every data type in your RAG pipeline, helix init and helix start dev will have a local instance up in a couple of minutes, and you can point yesterday's export at it. Star HelixDB on GitHub if the approach is one you want to follow.