Back to blog
Open Source Graph Database Written in Rust: How HelixDB Is Built

This blog is written by AI for SEO

Open Source Graph Database Written in Rust: How HelixDB Is Built

HelixDB13 min read

Most agent stacks end up holding memory in two places. A vector database holds the embeddings, a graph database holds the relationships, and application code keeps the two in sync. Every retrieval crosses both, so you pay two round trips and own a sync layer nobody wanted. The latency is the smaller problem. The real one is drift: once the two stores disagree, a retrieval that reads a stale edge against a fresh embedding returns something confidently wrong, and nothing in your logs says so.

HelixDB was built to solve this duct tape problem. It is a unified graph-vector engine designed for developers who want a single, performant store for knowledge graphs and semantic memory. By writing the core engine from scratch in Rust, HelixDB eliminates the overhead of managing separate systems for vectors, graphs, and full-text search. This post looks under the hood of how this open source graph database written in Rust handles complex queries at scale without the performance penalties of a garbage-collected runtime.

Why Is HelixDB Written in Rust?

Rust is the vehicle here, not the argument. The argument is that an agent workload punishes unpredictable pauses, and a managed runtime gives you those by design. Collector behaviour varies a lot, so be careful how strongly you state this. A modern low-pause collector keeps the tail far tighter than an older one, and depending on collector and workload a collector can cost you throughput as much as latency. What running without one gives you is not automatically lower latency, it is a lifetime you decide rather than a runtime deciding for you. Rust gives memory safety without a collector, so HelixDB manages the lifetime of large vector indices and adjacency structures explicitly. That removes one source of variance. It does not remove allocation, synchronisation, scheduling or I/O, all of which you still pay for.

Memory layout is the second reason. A multi-hop traversal spends its time following adjacency, and in HelixDB that adjacency is persisted rather than resident, so a hop resolves against the tiered SSD and memory cache sitting above the durable layer rather than against a pointer in a live heap. How tightly nodes and edges pack decides how much of a walk is served from cache and how much becomes a fetch. Rust lets you lay those structures out deliberately instead of accepting whatever an object model hands you. Treat that as a design motivation rather than a measured result: the numbers that would settle it are specific to a workload and a cache size, and they are the ones to run on your own data.

Reliability is the final pillar, and the honest version of it is narrower than the way this usually gets pitched. Safe Rust rules out data races under its soundness assumptions, along with use-after-free and double-free, and it does that at compile time. It does not rule out logical race conditions, deadlocks, panics or resource exhaustion, and it says nothing about the unsafe blocks and SIMD paths an engine like this contains, whose correctness rests on review and testing rather than on the borrow checker. Consistency across concurrent readers and writers is the transaction layer's job, not the compiler's. So when you are shipping an AI agent memory architecture, what the language buys you is that one class of bug is gone before deployment. That is smaller than "it will not crash" and it is real.

What Does One Engine for Graph, Vectors and Full-Text Actually Change?

It changes where the join happens. Stitch a vector database to a graph database at the application layer and you are running two fetches and joining them in your own Python or Node code, with your process acting as a query planner that has no statistics and no index. In HelixDB the embedding is a property on the node or the edge, indexed natively by the same engine that stores the relationship. Writing a document means writing a node, its properties, its edges and its embedding in one batch, not writing to one system and then reconciling with another.

That is also what makes a genuinely hybrid query possible in one request. You can traverse the graph to reach a set of entities, run BM25 full-text scoring over that set, and rank the same set by vector similarity, without leaving the engine and without joining anything yourself. The scope is declared once and both halves of the hybrid obey it.

One process is also less to operate. There is one store to back up, one to restore, and one to reason about when a retrieval looks wrong. Because the graph and the embedding live in the same write path, there is no second system to fall behind and no reconciliation job to schedule. That is the foundation under any workable approach to giving AI agents persistent memory.

How Does Pre-Filtering Scope a Vector Search to a Traversal?

The traversal runs first and the vector search ranks only what it returned. That order is the whole point, and it is the opposite of what most stacks do. The documented pipeline is graph traversal, then exact candidate membership, then vector ranking, then top k, and the traversal membership is authoritative: a result outside the candidate set cannot come back. Full-text chains onto a traversal the same way, with BM25 scores and the same ordering, so a hybrid query is scoped once and both halves respect it. And because embeddings sit on edges as readily as on nodes, the candidate set can be a set of relationships rather than a set of entities, which is the idea behind scoping vector search to graph edges.

Take an agent working over one client's matter files. On a whole-label index you search everything and filter by client afterwards. In HelixDB you start at the client node, traverse to that client's documents, and the vector search ranks only those:

import { readBatch, g, defineParams, param, SourcePredicate } from "@helix-db/helix-db";

const params = defineParams({
  client: param.string(),
  query_vector: param.array(param.f32()),
  limit: param.i64(),
});

const recall = readBatch()
  .varAs(
    "hits",
    g()
      .nWithLabelWhere("Client", SourcePredicate.eq("name", params.client))
      .out("HAS_DOCUMENT")
      .vectorSearchWith("Document", "embedding", params.query_vector, params.limit)
      .valueMap(["$id", "title", "$distance"]),
  )
  .returning(["hits"]);

const request = recall.toQueryRequest(
  params,
  { client: "Acme", query_vector: queryVector, limit: 10n },
  { queryName: "documents_for_client" },
);

Two caveats worth stating plainly, because neither is a bug. 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 actually produced. That is the guarantee working.

It is worth being precise about why the ordering matters rather than treating it as an optimisation. Searching the whole label and adding a where clause afterwards is not the same operation, and HelixDB's own filtering guide says so: the high scorers you excluded have already consumed the source top k, so you are left with fewer eligible rows than you asked for. Ask for the ten nearest episodes across fifty thousand and then filter to one user, and you can easily get nothing back, not because nothing matched but because the ten you fetched all belonged to somebody else. Scoping first is what makes the k you asked for the k you can actually use. Note also that a tenant partition is a data-partitioning feature, not access control; if you need authorisation, that still belongs in your application.

Can I Run an Open Source Graph Database Without Object Storage?

Yes, and it is a startup flag rather than a product tier. The open source build runs three ways: fully in memory, on disk, or against S3-compatible object storage. Same engine, same SDKs, same endpoint in all three, which is documented on the run modes page.

That matters more than it sounds. You can prototype in memory, run on disk locally, and ship against S3-compatible object storage without touching application code, because the thing that changes is a flag and not your data layer. Getting a local instance up is two commands:

helix init
helix start dev

Helix Cloud is the managed version of the object-storage mode: the durable layer lives in object storage, a single writer serialises mutations, readers scale horizontally, and SSD plus memory act as cache above the bucket. The consequence for an agent workload is that capacity, latency and concurrency stop being the same dial. In an in-memory engine, storing more data and serving more traffic are the same purchase.

How Do You Query It Without a Query Language?

There is no query language to learn, which is the part people usually do not believe. Queries are JSON, built in your own application code with native SDK builders and sent as one POST to /v2/query. There is no query language to write and nothing compiled ahead of time: the builders serialise to JSON, and the database turns that JSON into the query it runs. Any language that can make an HTTP request can talk to it, so cURL is enough to get a first query out.

SDK builders exist for Rust, TypeScript, Python and Go, and they run inside your application rather than in front of the database. They serialise to the same JSON envelope, so behaviour is identical whether your backend is Go and your ingestion pipeline is Python. Writes use the same shape. Note that one request is one transaction, so the whole batch commits or none of it does:

import {
  BatchCondition,
  g,
  NodeRef,
  SourcePredicate,
  writeBatch,
} from "@helix-db/helix-db";

const ingest = writeBatch()
  .varAs(
    "client",
    g().nWithLabelWhere("Client", SourcePredicate.eq("name", "Acme")).limit(1),
  )
  .varAs("doc", g().addN("Document", { title, embedding, occurredAt: Date.now() }))
  .varAsIf(
    "edge",
    BatchCondition.varNotEmpty("client"),
    g().n(NodeRef.var("client")).addE("HAS_DOCUMENT", NodeRef.var("doc"), {}),
  )
  .returning(["client", "doc", "edge"]);

That lookup deserves two sentences of its own, because the obvious version of it is a silent bug. nWithLabelWhere returns a stream, not a promise of exactly one row. If no client matches, addE gets an empty source, succeeds without creating anything, and the document commits anyway as an orphan. If several match, you get several edges. Atomicity is working correctly in both cases: one request is one transaction, and it will happily commit a document with no client, because "every document belongs to exactly one client" is your invariant and not the engine's.

So enforce it or state it. The real fix is a uniqueness index on the lookup property, created with IndexSpec.nodeUniqueEquality("Client", "name"), which makes the match zero-or-one at the storage layer. limit(1) above bounds the multiple-match case if you do not have that index yet. varAsIf with BatchCondition.varNotEmpty gates the edge on the lookup having actually found something, and returning client and edge alongside doc is what lets the caller tell an attached document from an orphaned one instead of reading a successful commit as a successful attach.

Index creation is a write query too, not a client method, and it is asynchronous. The call returns before the backfill finishes, so poll getIndexOperation until every status reads succeeded and stop on blocked, aborted or timeout. Starting to write batches because the request was accepted is the mistake worth avoiding:

import { writeBatch, g, VectorDistanceMetric } from "@helix-db/helix-db";

const index = writeBatch()
  .varAs(
    "index",
    g().createVectorIndexNodes("Document", "embedding", 1536, VectorDistanceMetric.Cosine, null),
  )
  .returning(["index"]);

The JSON shape happens to suit agents, since a model that is good at emitting structured output is better at building a query object than at building a query string. Bulk writes, filtering predicates and range queries all fit in one request, and range indexes give you gt, gte, lt, lte and between plus ordered scans in either direction, so "the last twenty things" is a descending scan you can stop early rather than a sort over everything.

How Does HelixDB Compare to Other Open Source Graph Databases?

Worth being accurate about the field, because it moves. Kuzu was the well-known embedded option and its repository was archived in October 2025; LadybugDB is the successor project in the same C++ lineage rather than a rebrand or a GitHub-tracked fork, and it is actively developed. SurrealDB is multi-model and takes vectors seriously, documenting several index types including a disk-resident one, so the honest contrast there is general-purpose backend against purpose-built agent memory rather than any gap in capability. FalkorDB aims squarely at GraphRAG and says so. This is not a category where the competition is asleep.

The difference that actually decides things is workload shape. Set HelixDB against LadybugDB and their own positioning names it: they are optimised for complex analytical workloads over very large databases, which is OLAP. Agent memory is OLTP, many small reads and writes arriving from many concurrent sessions. A columnar, vectorised design is genuinely lighter on memory and genuinely better at scanning a lot of rows to touch a few columns, and it is the wrong shape for transactional agent traffic. That is a topology argument, not a claim that anyone built something badly.

The other thing to put side by side on an open source page is the licence, because it decides whether you can embed the engine in something you ship. HelixDB is Apache-2.0. FalkorDB is under the Server Side Public License. Memgraph and SurrealDB are both under the Business Source License, each with a 2030 change date after which they convert to Apache, which is a real distinction and worth stating rather than glossing. Kuzu and LadybugDB are MIT. So the accurate claim is narrow: among the actively developed engines here, HelixDB is on a permissive licence and the BSL and SSPL options are not, while the MIT lineage is permissive too. If you are embedding a database inside a product you distribute, that is the row in the table you read first.

How Do I Try It?

HelixDB is open source under Apache-2.0, so you can self-host it, embed it and contribute to the engine without a licence conversation. helix init scaffolds a project and helix start dev brings up a local instance; add --disk if you want it on disk rather than in memory. The CLI also covers deploys and cloud instances, and helix skills install pulls down the agent skills that help a coding assistant write correct queries against it. If you want to see how any of this actually works, the engine is on GitHub and the Rust is readable.

Then try to break it. Load it past what you think is reasonable and run the traversals you would actually run in production rather than the ones that look good in a benchmark. Start with a single scoped retrieval and grow it into an episodic memory as the agent needs one.

The docs cover the query surface for every SDK, and the filtering guide is the one to read if the pre-filtering order above is the part you care about.

One honest note on fit. If your retrieval is a single top-k lookup over one flat corpus and relationships never enter into it, a plain vector store is a simpler thing to run and you should keep it. This is worth the switch when the relationships are load-bearing.

Conclusion

The case for an open source graph database written in Rust is not that Rust is fast. It is that graph traversal, vector ranking and BM25 sit in the same query and obey the same scope, and that comes from the data model, the query operators and the transaction implementation rather than from the language. What Rust buys is the freedom to lay those structures out explicitly and run without a collector, which serves that design without being the reason it holds. That removes the sync layer between your vector store and your graph, and with it the drift that makes a retrieval quietly wrong. It runs in memory on your laptop and against object storage in production on the same code path, and the licence does not get in the way if you ship it inside something. If that sounds like your stack, star it on GitHub and go build something against it.