
This blog is written by AI for SEO
How to Give AI Agents Persistent Memory in One Database
Most AI agents have no memory. You build a sophisticated reasoning loop, but the moment the session ends, the agent loses every piece of context it worked to acquire. Developers usually try to solve this by dumping conversation logs into a vector database like Pinecone or Weaviate. Basic semantic search works, but it fails to capture the complex relationships between entities or the chronological flow of events. If a user says, 'My manager Sarah just got promoted,' a vector search might find the word Sarah, but it won't update the reporting structure in the agent's internal model.
To build an agent that actually learns, you need a multi-layer memory architecture. You need to store what happened (episodic), what is true (semantic), and how things are connected (relational). Doing this usually requires duct-taping three different databases together, which creates a maintenance nightmare and high latency. This guide shows you how to implement all three layers in one engine using HelixDB, a Rust-native graph-vector database. You will move from a naive vector index to a unified memory store that lets your agent traverse relationships and perform semantic recall in a single query.
What Does an AI Agent Actually Need to Remember?
Vector search is not a complete memory strategy. When you rely solely on embeddings, your agent retrieves chunks of text that are mathematically similar to the input but often lack the structural context required for complex reasoning. A real AI agent memory architecture requires three distinct types of persistence that work together.
Before any of this, be honest about whether you need it. A single-session assistant with a short transcript does not need a memory store. Keep the transcript in the context window and move on. The rest of this guide is for the case where sessions are long, users come back, and the things the agent needs to connect were said days apart.
First is episodic memory. This is the chronological log of interactions. It lets the agent remember that a specific conversation happened on Tuesday and that the user was frustrated during that exchange. Without episodic memory, the agent cannot handle references like 'what did we talk about last week.'
Second is semantic memory. This represents the global knowledge the agent has acquired, including facts, definitions, and concepts that are not tied to a specific point in time. If the agent learns that a specific API endpoint requires an OAuth2 token, that fact should be stored semantically so it can be retrieved across all future sessions. Vector databases handle this part reasonably well, but they still treat facts as orphaned chunks rather than part of a larger knowledge base. You need a way to store these facts so they remain accessible regardless of the specific phrasing of the query.
Third is relational memory. This is where most RAG implementations fail. Relational memory maps the connections between entities. If Sarah manages the Engineering team and the Engineering team owns the Deployment service, the agent needs to understand those links. In a standard vector store, these relationships are buried inside text blobs. In a graph, they are rows you can traverse. By combining these three types into a unified structure, you avoid the 'goldfish effect' where the agent recognizes a keyword but forgets the surrounding context. HelixDB lets you model these layers as a graph where nodes represent entities or episodes and edges represent the relationships between them, with vector embeddings attached for semantic retrieval. This unified approach removes the need to manage a separate vector database vs graph database stack.
How Do You Run a Persistent HelixDB Instance Locally?
Two commands. helix init scaffolds the project, and helix start dev brings up a local instance on port 6969.
Where that instance keeps its data is a startup flag, not a product tier. The open-source build runs three ways: fully in memory, on disk with --disk, or against object storage, meaning S3 or any S3-compatible store including a local MinIO container. The engine, the SDKs and the HTTP interface are identical across all three, so you can prototype in memory and later point the same application code at a bucket without changing a line of it. The run modes page has the exact flags. HelixDB can also run embedded, in-process through the native SDK with no HTTP hop at all, and Helix Cloud is the managed version of the object-storage mode with a single writer serialising mutations and readers scaling horizontally.
You talk to it with POST /v2/query. There is no query language to learn and nothing to compile: you build queries with the native TypeScript, Rust, Go or Python SDK, in the same files as the rest of your application code, and the builder serialises to a JSON envelope. You can hand-write that JSON if you would rather.
One thing to get right before you write any memory: a vector index is created by a write query, not a client method, and creation is asynchronous.
import { g, writeBatch, VectorDistanceMetric } from "@helix-db/helix-db";
const createIndex = writeBatch()
.varAs(
"index",
g().createVectorIndexNodes(
"Episode",
"embedding",
1536,
VectorDistanceMetric.Cosine,
null,
),
)
.returning(["index"]);The last argument is the tenant property, and null gives you a global index. The request returning successfully means it was accepted, not that the index is ready, so poll getIndexOperation until every status reads succeeded and stop on blocked, aborted or timeout. Do not start writing episodes because the create call came back. The vector index lifecycle is documented in full.
How Should You Model Agent Memory as Nodes and Edges?
A flat list of documents is the enemy of persistent memory. Decide up front how the agent should carve up the world, because that decision is what makes retrieval answerable later. In HelixDB, you model your domain using nodes and edges. Nodes represent the 'things' in your agent's universe, such as Users, Projects, Sessions, or specific Facts. Edges represent the 'verbs' that connect them, such as 'WORKS_ON', 'SAID_IN', or 'IS_MEMBER_OF'. A vector is a property like any other: a top-level numeric array on a node or on an edge, the same shape you would model in Neo4j. What differs is where the index for it lives and what you can scope a search to.
For example, a 'User' node might have a name and a role property. An 'Episode' node representing a chat turn carries the turn text and an embedding property. Because the embedding sits on the node, you can write one query that walks to the episodes belonging to a user and ranks only those by similarity. You do not pull ids out of a vector index and then filter them in a second store. "Find the Episodes connected to User A whose content is semantically closest to this query" is a single request, and the next section shows exactly what it looks like.
Edges also support properties, which lets you add context to relationships. You can attach a 'strength' property to an edge to indicate how often two concepts are mentioned together, or a 'timestamp' property to track when a relationship was first established. This level of granularity is important for building a 'company brain' where the agent needs to know not just that a document exists, but who wrote it and which project it belongs to. There is no schema file to write and nothing to push before you query. Deciding your labels and edge types up front is a modelling discipline, not a deployment step, and it is what keeps the agent's memory from turning into a pile of text chunks.
How Do You Write a New Memory After Each Turn?
Memory should be an active process, not a passive log. After every turn in the conversation, your agent should run a 'memory extraction' step. This involves using an LLM to analyze the recent interaction and identify new entities, updated facts, or changes in relationships. If the user says, 'I am moving the deadline for the Phoenix project to Friday,' the agent should not just store that string. It should identify the node for 'Phoenix Project' and update its 'deadline' property, or create a new 'DeadlineUpdate' node connected to it.
One request is one transaction: every entry in a write batch commits or rolls back together. So the episode, the edge back to the user and the embedding all land in the same operation, which is what removes the drift you get when a graph write succeeds and a vector write does not.
import { g, writeBatch, NodeRef, SourcePredicate } from "@helix-db/helix-db";
const remember = writeBatch()
.varAs("user", g().nWithLabelWhere("User", SourcePredicate.eq("id", userId)))
.varAs(
"episode",
g().addN("Episode", {
content: turnText,
embedding: turnEmbedding,
occurredAt: occurredAtMillis,
}),
)
.varAs(
"authored",
g().n(NodeRef.var("user")).addE("AUTHORED", NodeRef.var("episode"), {}),
)
.returning(["episode"]);Note occurredAt as a plain numeric property. That is the field the time-range queries later in this guide read, and putting it on the node at write time costs nothing. Edges are additive here: addE adds, so the same user hitting the same document forty times gives you forty timestamped edges rather than one that keeps getting overwritten. That matters more than it sounds, because repeated events between the same two entities are exactly what an agent's history is made of. The writing-data guide covers batch semantics.
To make this process manageable, many developers use the Model Context Protocol (MCP). HelixDB provides native MCP endpoints, which lets agents discover tools and query the graph step-by-step. During the write phase, the agent can use these tools to check if a specific entity already exists before creating a duplicate. If the agent finds an existing node for 'Phoenix Project', it updates the current one instead of cluttering the database. This deduplication is a critical part of maintaining a clean memory store. Without it, the agent's context window will eventually fill with redundant, slightly different versions of the same information, leading to confusion and hallucinations.
How Do You Scope a Memory Search to One User or One Conversation?
You put the traversal first and let the vector search rank only what it reaches. This is the part most retrieval stacks get backwards, and it is the difference between a scoped search that works and one that quietly returns nothing.
The documented order is: graph traversal, then exact candidate membership, then vector ranking, then top k. The traversal membership is authoritative, so a result outside the candidate set cannot come back.
import {
g, readBatch, defineParams, param, SourcePredicate,
} from "@helix-db/helix-db";
const params = defineParams({
user_id: param.string(),
query_vector: param.array(param.f32()),
limit: param.i64(),
});
const recall = readBatch()
.varAs(
"episodes",
g().nWithLabelWhere("User", SourcePredicate.eq("id", params.user_id))
.out("AUTHORED")
.vectorSearchWith("Episode", "embedding", params.query_vector, params.limit)
.valueMap(["$id", "content", "occurredAt", "$distance"]),
)
.returning(["episodes"]);
const request = recall.toQueryRequest(
params,
{ user_id: "u-42", query_vector: queryVector, limit: 10n },
{ queryName: "recall_user_episodes" },
);The 10n is not a typo. An i64 parameter takes a BigInt literal.
Now the contrast worth understanding, because it is why the ordering matters. The obvious alternative is to search the whole Episode label and add a where clause for the user. HelixDB's own documentation is explicit that this is not a substitute: the high scorers you are about to exclude have already consumed the source top k, so you end up with fewer eligible results than you asked for. For agent memory that is not a subtle degradation. Ask for the ten closest episodes belonging to one user out of fifty thousand users, and the global top ten is almost entirely other people's memories, so after filtering you get nothing back and the agent behaves as though it has no history at all.
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 when the candidate set is smaller than k. That is the guarantee working rather than a bug, so do not treat a short result list as a failure.
Full-text search chains onto a traversal the same way, with BM25 scores through textSearchWith, and it follows the same order. So a hybrid query is scoped once and both halves obey the scope, rather than each half being filtered separately and reconciled in your application code. The filtering guide documents the pipeline. We went deeper on scoping in the guide on pre-filtering vector search on graph edges.
One caveat on tenant-partitioned indexes, since it is easy to misread: a tenant partition is an index partition, not access control. It narrows where the ANN search runs. It does not enforce authorisation, and you should not treat it as a permission boundary.
How Do You Query Agent Memory by Time Range?
Memory is not just about adding information. It is also about managing what is no longer true. A common problem in agentic systems is memory bloat, where the agent is overwhelmed by thousands of irrelevant historical facts. The fix is a range index on the timestamp you already wrote in the previous section.
You model history the way you would anywhere else, with timestamped nodes and edges. What makes reading it back cheap is the range index: it supports gt, gte, lt, lte and between, plus ordered scans in either direction. So "everything between Tuesday and Thursday" is an indexed scan over a sorted range, and "the last twenty things this user said" is a descending scan you can stop early rather than a sort over their entire history. It composes with the scoped vector search from the previous section too, so recency and semantic relevance resolve in one request instead of two passes and a merge in your application code. If a user changed their coding-style preference last week, the agent can see both versions and take the one with the later timestamp.
Handling contradictions is equally important. When the agent extracts a new fact that conflicts with an existing one, you have to decide how to resolve the clash. You can use the graph structure to store both facts but mark the older one as 'superseded' by creating an edge between them. This lets the agent maintain a history of how information evolved, which is useful for debugging. If you want a form of forgetting, do it with the same timestamp: a range scan bounded to the last N days is how you keep the agent's working context on recent material without deleting the history behind it.
Staleness is the version of this that bites hardest. If a project plan in the memory store is three months old and a newer document has landed since, the agent needs to know which one wins. Model documents and their versions as nodes with a supersedes edge and a timestamp, and the traversal starts from the current node by construction rather than by hoping the newer text scored higher. None of this is something the database decides for you, and no database does: graph versioning is a thing you design. What the engine gives you is the indexed read path that makes the design cheap to query.
Conclusion
Giving an AI agent persistent memory is not a matter of storing more data. It is a matter of storing the right connections. Relying on a patchwork of disconnected databases forces you to write complex glue code that inevitably breaks as your agent grows. By moving to a unified graph-vector engine like HelixDB, you eliminate the latency of multi-database round trips and the complexity of managing disparate schemas. Your agent gets a single, coherent source of truth where episodic, semantic, and relational data exist in one place.
If your agent is still in goldfish mode, stop duct-taping vector indices together. Run helix init and helix start dev, point it at the model above, and see how much of your glue code disappears. If the shape of it makes sense to you, star HelixDB on GitHub and tell us what broke.