
This blog is written by AI for SEO
Moving Off Memgraph: Migrating Agent Memory to HelixDB
Memgraph earned its reputation on real-time stream processing and high-frequency graph computation. If your workload involves sliding-window fraud detection over Kafka streams with a fixed-size dataset, keeping every node and relationship in memory makes sense.
Production agent memory is a completely different workload. Agent systems accumulate episodic context, conversation turns, entity graphs, and document embeddings over months. The dataset does not fit inside a fixed memory envelope; it expands continuously as users converse with your system. In an in-memory engine, capacity and cost are the same dial. Object storage is priced in cents per GB per month; provisioned memory is priced in tens of dollars per GB per month. So cold episodic history and embeddings you touch once a week cost the same per gigabyte as the working set you query constantly.
This guide covers moving off Memgraph to HelixDB, an open-source graph-vector database written in Rust. You will audit your existing Cypher schema, map node labels and edge properties to HelixDB, export your data cleanly, and rewrite Cypher retrieval as JSON queries where the graph traversal and the vector search happen in one call.
Should You Actually Move Off Memgraph?
Start here, because for a real share of readers the answer is no. If you are running sliding-window computation over Kafka streams, or graph algorithms on a working set you can size, Memgraph is built for that and does it well, and nothing below is an argument for moving.
Memgraph is a C++ in-memory engine with ACID transactions and on-disk persistence, and their Community edition is free, open source and described on their own pricing page as production-ready. It ships Cypher, stream connectors for Kafka, Pulsar and Redpanda, vector search, the MAGE algorithm library, and high-availability replication with unlimited replicas at no extra licence cost. Automatic failover is the line that moves you to Enterprise. None of that is the problem here.
The problem is where the bytes live. Push millions of episodic turns, summary nodes and 1536-dimension embeddings into an in-memory graph and every gigabyte you keep is a gigabyte of provisioned host RAM. Read throughput has the same shape, because each replica you add for concurrency holds another full copy of the graph.
One caveat worth stating plainly, because it cuts against the obvious version of this argument: Memgraph now prices an Enterprise AI Platform tier on graph data alone, and says vector indexes are unlimited and do not count toward that licence. So if embeddings are the bulk of what you store, their pricing already answers that. What it does not change is the graph itself, which stays resident in memory and priced on memory. Agent memory is graph-heavy, entities and relationships and episodes and timestamps, not only vectors, and that half is what grows with every user you add.
Much of this episodic interaction data quickly goes cold. With a pure in-memory architecture, you pay top-tier cloud RAM pricing for dead context simply because it lives in the same graph traversal index. Compensating by offloading vectors to a dedicated vector store like Pinecone or Milvus creates exactly the multi-database plumbing problem engineering teams try to avoid. You end up synchronizing two databases over network calls, reconciling state, and chasing split-brain consistency bugs.
HelixDB solves this workload mismatch by decoupling storage tiers. Built from scratch in Rust, HelixDB stores graph topology, vector embeddings, key-value data, and document stores within a unified OLTP engine. In Helix Cloud, the architecture separates compute and persistent object storage with SSD caching on reader and writer nodes. You get low-latency graph traversals over hot working memory without keeping two years of inactive agent conversations resident in expensive RAM. Capacity, concurrency, and retrieval latency become independent operational dials rather than coupled constraints.
What Does Your Memgraph Side Actually Look Like?
Do not start writing migration scripts until you have mapped every query pattern your agent framework executes. Most engineering teams using Memgraph for AI agent memory run three distinct retrieval operations: entity resolution, conversational k-hop expansion, and semantic recall over interaction history.
Connect to your Memgraph instance using mgconsole and inspect your existing label indexes and constraints:
SHOW INDEX INFO;
SHOW CONSTRAINT INFO;Take note of properties indexed for lookup. Common agent schemas index User(id), Session(id), and Entity(name). Next, profile your most frequent read queries. In Memgraph, an agent loading context for a user typically executes a Cypher pattern like:
MATCH (u:User {id: $user_id})-[:HAS_SESSION]->(s:Session)
MATCH (s)-[:CONTAINS_TURN]->(t:Turn)-[:MENTIONS]->(e:Entity)
RETURN e.name, e.summary, t.content
ORDER BY t.timestamp DESC LIMIT 20;And get the embedding dimension before you create anything on the target side, because the index has to match it exactly:
MATCH (m:MemoryNode) WHERE m.embedding IS NOT NULL
RETURN size(m.embedding) AS dim, count(*) AS n
ORDER BY n DESC LIMIT 5;Identify where you bolted on vector similarity. If you used Memgraph vector search modules or maintained a foreign key pointer to an external vector database, catalog the embedding dimensionality and metric (cosine, Euclidean, or dot product).
Next, audit write throughput. Calculate the average writes per conversation turn. A standard agent turn writes one Turn node, two to five MENTIONS edges, and updates entity state properties. HelixDB handles both graph mutations and vector insertions in the same transaction over HTTP via its /v2/query endpoint, which makes write reconciliation simpler than managing out-of-band vector updates.
What Maps Across Unchanged, and What Does Not?
Memgraph uses the property graph model: labeled nodes, directed relationships with types, and key-value properties on both. HelixDB natively implements an open-source OLTP graph-vector model in Rust, so your core entities translate cleanly without structural distortion.
Here is the direct mapping:
First, Node Labels become HelixDB node collections or labels. A Memgraph :User or :AgentMemory node maps directly to a node with equivalent label identifiers in HelixDB.
Second, Relationship Types translate directly into HelixDB directed edges. A [:RECALLS] or [:OBSERVED] relationship retains its direction and edge-level properties.
Third, embedding properties stay properties. Vectors in HelixDB are numeric array properties on nodes and edges, exactly as they are in Memgraph, so the data model maps across unchanged. The difference is where the index lives: Memgraph holds it in memory with the rest of the graph, while HelixDB tiers it across memory, disk and object storage.
Fourth, timestamps stay timestamps. You model history with timestamped nodes and edges exactly as you do now, and the range index is what makes reading it back cheap: gt, gte, lt, lte and between, plus ordered scans in either direction, so "the last twenty turns" is a descending scan you can stop early.
If your Memgraph schema has a User node connected via an OWNS edge to a MemoryNode containing a raw text block, a created_at timestamp, and an embedding vector, that translates directly to HelixDB. In HelixDB, this maps to a graph node for User, an edge OWNS, and a target node MemoryNode containing raw text, metadata properties, and the vector embedding attached directly to the node record. You eliminate external reference IDs and the synchronization glue code required when juggling disconnected stores.
How Do You Get the Data Out and Load It Without Duplicating on a Retry?
Exporting data from Memgraph can be handled via Cypher CSV dumps or JSON exports using the mgclient Python driver. For high-volume agent memory, avoid single-file JSON dumps that consume massive process memory. Stream your data in batches of 5,000 to 10,000 nodes using Python.
Extract nodes and relationships into structured payloads:
import mgclient
conn = mgclient.connect(host='127.0.0.1', port=7687)
cursor = conn.cursor()
# Label-scoped and ordered by a stable business key, so the export is
# reproducible and resumable rather than one giant unordered scan.
cursor.execute('''
MATCH (n:MemoryNode)
RETURN n.uid AS uid, labels(n) AS labels, properties(n) AS props
ORDER BY n.uid
''')
nodes = cursor.fetchall()Transform the extracted records into HelixDB mutation payloads. HelixDB accepts JSON queries sent to /v2/query, built with native SDK builders that run inside your own application code. There are SDKs for TypeScript, Rust, Go and Python, so you write the query in the language you already ship. The builders serialise to JSON and the database turns that JSON into the query it runs, so there is no query language to learn and nothing to generate ahead of time.
Construct your batch creation payload with operations specifying the target label, properties, and attached vector array. Then send the payload as a JSON POST request to http://localhost:6969/v2/query on a local instance.
Do not carry Memgraph's internal ids across. They are not stable across a restore, so key everything on business ids you control and put a stable relationshipId property on every edge so a replayed batch can find and replace the right one.
Create your indexes before any data lands. Index creation is a write query rather than a client method, and it is asynchronous: the request returning does not mean the index is ready.
import { writeBatch, g, VectorDistanceMetric } from "@helix-db/helix-db";
// Dimension must match what your Memgraph audit reported.
const createIndex = writeBatch()
.varAs("index", g().createVectorIndexNodes("MemoryNode", "embedding", 1536, VectorDistanceMetric.Cosine, null))
.returning(["index"]);Then poll the index operation until every status reads succeeded, and stop on blocked, aborted or timeout rather than starting node batches on a half-built index.
Then run node insertion first and edges in a second pass. One request is one transaction, so if a batch comes back 409 you retry the whole batch rather than the failed row, and shaping the load as an idempotent upsert keyed on your business id is what makes that retry safe instead of duplicating.
import {
BatchCondition,
g,
NodeRef,
SourcePredicate,
writeBatch,
} from "@helix-db/helix-db";
// Second pass: both endpoints were written in the first pass, so look them up
// by business id rather than creating them again, and only write the edge if
// the lookup actually found them.
// One exported record from the batch you streamed out of Memgraph.
const row = batch[i];
const loadEdges = writeBatch()
.varAs(
"user",
g().nWithLabelWhere("User", SourcePredicate.eq("uid", row.userUid)).limit(1),
)
.varAs(
"memory",
g()
.nWithLabelWhere("MemoryNode", SourcePredicate.eq("uid", row.memoryUid))
.limit(1),
)
.varAsIf(
"edge",
BatchCondition.varNotEmpty("user"),
g().n(NodeRef.var("user")).addE("OWNS", NodeRef.var("memory"), {
relationshipId: row.relId,
occurredAt: row.createdAt,
}),
)
.returning(["user", "memory", "edge"]);The guard matters more on a migration than anywhere else, because a load script runs unattended over hundreds of thousands of rows. nWithLabelWhere returns a stream, not a promise of exactly one row, so a missing endpoint makes addE a no-op that reports success. Without the uniqueness indexes from step 3 on User.uid and MemoryNode.uid, a duplicate endpoint writes duplicate edges instead. Have the loader treat an empty edge binding as a row to re-queue rather than a row that landed: a request that returns HTTP 200 having created nothing is exactly the failure that shows up later as missing relationships nobody can account for.
How Do You Rewrite Retrieval So Graph, Vector and Full-Text Run in One Query?
The biggest win when moving off Memgraph is query unification. In Memgraph, combining semantic vector similarity with multi-hop neighborhood traversal often means executing an approximate nearest neighbor lookup, collecting IDs, and then piping those IDs into Cypher match clauses. If you also need full-text search, you have to query an auxiliary service.
HelixDB unifies vector similarity search, graph traversal, and native full-text search in a single dynamic JSON query. Instead of writing fragmented queries across multiple engines, you express the full retrieval pipeline in one JSON query against /v2/query.
Consider an agent query that must find semantically similar memory chunks inside one user's relationship graph. You start at the User node, walk out to what they own, and rank only those:
import { SourcePredicate, defineParams, g, param, readBatch } from "@helix-db/helix-db";
const params = defineParams({
user_uid: param.string(),
query_vector: param.array(param.f32()),
limit: param.i64(),
});
// Traversal first: the User walk decides which vectors are eligible at all.
const query = readBatch()
.varAs(
"matches",
g()
.nWithLabelWhere("User", SourcePredicate.eq("uid", params.user_uid))
.out("OWNS")
.vectorSearchWith("MemoryNode", "embedding", params.query_vector, params.limit)
.valueMap(["$id", "body", "occurredAt", "$distance"]),
)
.returning(["matches"]);
const request = query.toQueryRequest(
params,
{ user_uid: "u_8814", query_vector: embedding, limit: 10n },
{ queryName: "recallForUser" },
);The order matters, and it is the opposite of the pattern you are moving away from. The traversal runs first and produces a candidate stream; membership of that stream is exact and authoritative, so a result outside it cannot come back. Vector ranking then runs over those candidates and returns the top k. Full-text chains on the same way with BM25 scores, which means a hybrid query is scoped once and both halves respect the scope.
Two honest caveats. Exact membership does not mean the engine compares every candidate embedding one by one; approximate structures still do the ranking, and the output is validated against the traversal set. And a scoped search can return fewer than k rows, bounded by how many unique candidates the traversal produced. That is the guarantee working, not a bug.
Compare that with searching a whole label and filtering afterwards. The high scorers you then discard have already consumed your top k, so asking for the ten closest memories across fifty thousand and then keeping only this user's can leave you with two results, or none.
How Do You Validate and Cut Over Without Risking Live Agent State?
Never perform a hard cutover on production agent state. Run a dark-launch phase where your application layer writes to both Memgraph and HelixDB in parallel.
Set up a dual-write interceptor in your agent's memory persistence layer. Every time an agent records an interaction turn or creates an episodic entity, send the write to both databases asynchronously. Direct your read path through a shadow comparator: execute the primary retrieval query on Memgraph and mirror the query to HelixDB. Log execution latency, memory footprint, and recall accuracy.
Verify retrieval quality by comparing the top-k entities returned to the agent prompt. If your Memgraph setup relied on separate BM25 text indices, test HelixDB's native full-text search alongside vector recall.
One thing to be precise about while you validate: scoping a search to a traversal is a retrieval mechanism, not an authorization boundary. It changes which candidates are eligible, and it is not a substitute for access control in your application layer. Keep whatever authorization you have today.
Once shadow validation runs error-free for 72 hours, switch your production read traffic to HelixDB. Monitor your application gateway for latency spikes. With the read and write paths transferred, shut down your Memgraph cluster and release the provisioned RAM instances. The reduction in operational complexity and compute spend will be immediate.
Conclusion
Agent memory does not behave like a streaming analytics pipeline. If your working set is bounded and you are doing real-time computation over it, Memgraph is the right tool and this migration is not for you. The case for moving is narrower than a cost comparison: it is that an unbounded, mostly-cold knowledge graph keeps growing against a dial where capacity and memory are the same number.
Moving that workload to HelixDB puts graph traversal, vector search and full-text retrieval in one Rust engine, over storage that tiers across memory, disk and object storage. The retrieval win is the part that survives any pricing change on either side: scope the traversal once, and both the vector and the full-text halves rank only what that traversal already decided was eligible.
Run helix init and then helix start dev to get a local instance up, or spin one up on Helix Cloud. Storage is a startup flag rather than a product tier, so the same engine and the same SDK calls run fully in memory, on disk, or against S3-compatible object storage, and your application code does not change when you move between them.
If this is the shape of problem you are working on, star HelixDB on GitHub.