Back to Blog

Building Memory for LLM Agents with ArcadeDB

An agent that cannot remember is a chatbot with extra steps. This post shows how to build persistent memory for an LLM agent on a knowledge graph: storing episodes and the entities they mention, keeping facts current without losing their history, and recalling context with vector similarity and graph traversal in one query.

Everything here runs in-process with pip install arcadedb-embedded. No server, no separate vector database.


What Agent Memory Actually Is

“Memory” gets used loosely. In practice an agent needs three different things, and only two of them belong in a database.

Working memory is the context window: the current conversation, tool outputs, and scratchpad. It lives in the prompt and disappears when the request ends.

Episodic memory is what happened. Turn by turn, session by session: the user asked X, the agent called tool Y, the result was Z. It is append-only and inherently temporal.

Semantic memory is what the agent has learned. Durable facts about entities: this user prefers Postgres, this project depends on that service, this customer is on the enterprise plan. Facts change, and the changes matter.

The common mistake is to implement all three as a single vector index over conversation transcripts. That works for the first demo and degrades quickly.

Why a Vector Store Alone Is Not Enough

Embedding every turn and retrieving the top-k nearest is a reasonable baseline. It fails in four specific ways, and all four are relationship problems.

Superseded facts. The user said they work at Acme in March and at Globex in June. Both turns are in the index, both are semantically similar to “where does the user work”, and the retriever has no notion that one replaced the other. The agent confidently returns the wrong employer. A vector index has no concept of validity.

Entity identity. “Ana”, “Ana Ruiz”, and “[email protected]” are the same person. Cosine similarity does not know that. Without entity resolution, memory fragments across aliases and recall silently degrades as the number of sessions grows.

Structural questions. “What has this user asked about the billing service?” is a filter over a relationship, not a similarity search. A vector index can only approximate it, and approximation is exactly the wrong tool when the answer is deterministic.

Multi-hop recall. “Which of the user’s projects depends on the library that just broke?” requires traversing project to dependency to incident. No single stored turn contains that chain.

A knowledge graph answers all four directly, because it stores the relationships instead of hoping similarity implies them. What it is not good at on its own is fuzzy recall, which is why the useful design keeps both, in one engine.

The Schema

Four vertex types and three edge types are enough to start.

import arcadedb_embedded as arcadedb

DIM = 1536  # match your embedding model

db_ctx = arcadedb.create_database("./agent-memory")

with db_ctx as db:
    with db.transaction():
        # An episode is one thing that happened
        db.command("sql", "CREATE VERTEX TYPE Episode")
        db.command("sql", "CREATE PROPERTY Episode.content STRING")
        db.command("sql", "CREATE PROPERTY Episode.session STRING")
        db.command("sql", "CREATE PROPERTY Episode.ts DATETIME")
        db.command("sql", "CREATE PROPERTY Episode.embedding LIST OF FLOAT")

        # Entities the agent has learned about
        db.command("sql", "CREATE VERTEX TYPE Entity")
        db.command("sql", "CREATE PROPERTY Entity.name STRING")
        db.command("sql", "CREATE PROPERTY Entity.kind STRING")
        db.command("sql", "CREATE INDEX ON Entity (name) UNIQUE")

        # A durable fact, valid over an interval
        db.command("sql", "CREATE VERTEX TYPE Fact")
        db.command("sql", "CREATE PROPERTY Fact.predicate STRING")
        db.command("sql", "CREATE PROPERTY Fact.value STRING")
        db.command("sql", "CREATE PROPERTY Fact.valid_from DATETIME")
        db.command("sql", "CREATE PROPERTY Fact.valid_to DATETIME")

        db.command("sql", "CREATE EDGE TYPE MENTIONS")   # Episode -> Entity
        db.command("sql", "CREATE EDGE TYPE ASSERTS")    # Episode -> Fact
        db.command("sql", "CREATE EDGE TYPE ABOUT")      # Fact    -> Entity

        # HNSW index for semantic recall over episodes
        db.command("sql",
            f"CREATE INDEX ON Episode (embedding) LSM_VECTOR "
            f"METADATA {{ dimensions: {DIM}, similarity: 'COSINE' }}")

The separation matters: an Episode is immutable and says what was observed, a Fact is the interpretation, and the ASSERTS edge records which episode produced it. When the agent later gets something wrong, you can trace the belief back to the turn that caused it.

Writing a Turn to Memory

Each turn does three things: store the episode with its embedding, resolve the entities it mentions, and assert or update any facts.

from datetime import datetime, timezone

def remember(db, session, text, entities, facts):
    """Persist one turn: episode, entities, and any facts it asserts."""
    now = datetime.now(timezone.utc).isoformat()

    with db.transaction():
        db.command("sql",
            "CREATE VERTEX Episode SET content = ?, session = ?, "
            "ts = ?, embedding = ?",
            text, session, now, embed(text))

        for name, kind in entities:
            # UPSERT gives one vertex per distinct entity name
            db.command("sql",
                "UPDATE Entity SET name = ?, kind = ? UPSERT WHERE name = ?",
                name, kind, name)
            db.command("sql",
                "CREATE EDGE MENTIONS "
                "FROM (SELECT FROM Episode WHERE content = ? AND session = ?) "
                "TO (SELECT FROM Entity WHERE name = ?)",
                text, session, name)

        for subject, predicate, value in facts:
            assert_fact(db, subject, predicate, value, now, source=text,
                        session=session)

Keeping Facts Current Without Losing History

This is the part a vector store cannot do at all. When a new value arrives for a predicate that already has one, do not overwrite it: close the old fact and open a new one.

def assert_fact(db, subject, predicate, value, now, source, session):
    """Close any currently-valid fact for this predicate, then open a new one."""
    # 1. Invalidate the previous value, if any
    db.command("sql",
        "UPDATE Fact SET valid_to = ? "
        "WHERE predicate = ? AND valid_to IS NULL "
        "AND @rid IN (SELECT in('ABOUT').@rid FROM Entity WHERE name = ?)",
        now, predicate, subject)

    # 2. Record the new value
    db.command("sql",
        "CREATE VERTEX Fact SET predicate = ?, value = ?, valid_from = ?",
        predicate, value, now)
    db.command("sql",
        "CREATE EDGE ABOUT "
        "FROM (SELECT FROM Fact WHERE predicate = ? AND value = ? "
        "      AND valid_to IS NULL) "
        "TO (SELECT FROM Entity WHERE name = ?)",
        predicate, value, subject)
    db.command("sql",
        "CREATE EDGE ASSERTS "
        "FROM (SELECT FROM Episode WHERE content = ? AND session = ?) "
        "TO (SELECT FROM Fact WHERE predicate = ? AND value = ? "
        "    AND valid_to IS NULL)",
        source, session, predicate, value)

Now “where does the user work?” has one right answer, and “where did the user work in April?” is still answerable. Nothing was destroyed.

Recall: Similarity, Then Structure

Retrieval runs in two layers. Semantic recall finds episodes that resemble the question; structural recall pulls the facts that are currently true.

def recall(db, question, k=5):
    q = embed(question)

    # 1. Episodes that resemble the question, plus what they mention
    episodes = db.query("sql",
        "SELECT content, ts, distance, out('MENTIONS').name AS entities "
        "FROM ( SELECT expand(vector.neighbors('Episode[embedding]', ?, ?)) )",
        q, k)

    # 2. Facts that are currently true about those entities
    facts = db.query("sql",
        "SELECT predicate, value, out('ABOUT').name AS subject "
        "FROM Fact WHERE valid_to IS NULL "
        "AND out('ABOUT').name IN ?",
        [e for row in episodes for e in (row.get("entities") or [])])

    return episodes, facts

The second query is the one that makes the agent reliable. It returns only facts whose valid_to is null, so superseded beliefs cannot leak back into the prompt no matter how semantically similar the old episode was.

Wiring It Into a Framework

If you already drive your agent through a framework, you do not need to hand-roll the retrieval layer.

LangChain. The official langchain-arcadedb package provides ArcadeDBGraph, which implements LangChain’s GraphStore protocol and connects over ArcadeDB’s Bolt protocol.

from langchain_arcadedb import ArcadeDBGraph

graph = ArcadeDBGraph(
    url="bolt://localhost:7687",
    username="root",
    password="playwithdata",
    database="agent-memory",
)
print(graph.get_schema)

LlamaIndex. llama-index-graph-stores-arcadedb implements the LlamaIndex PropertyGraphStore interface.

MCP. ArcadeDB ships an MCP server, so an LLM can query the memory graph directly as a tool. See connecting your LLM to your database with MCP.

Note that the LangChain path needs a running server with the Bolt plugin enabled, whereas the embedded path above needs no server at all. Both talk to the same storage format.

Why This Runs Well

Recall sits in the agent’s request path, so traversal speed is not an academic concern. On the LDBC Graphalytics suite, run on identical hardware against seven other graph engines, ArcadeDB completes weakly connected components in 0.08s and PageRank in 0.10s, winning 5 of the 6 standard algorithms. On the LSQB pattern-matching benchmark, Q6, a two-hop traversal counting 1.67 billion rows, finishes in 110ms. Full methodology and the reproducible harness are on the benchmarks page.

Embedded mode removes the network entirely, which for a single-process agent is usually the largest remaining latency term.

Honest Comparison

ArcadeDB is not the only option that stores graph and vectors together. Memgraph and FalkorDB both index embeddings in the same store as the graph and both target agent memory explicitly, and either is a reasonable choice.

The differences worth weighing are licensing and breadth. Memgraph Community is BSL 1.1 and FalkorDB is SSPLv1, both source-available rather than OSI open source; ArcadeDB is Apache 2.0. ArcadeDB also stores documents and time series alongside the graph, and runs embedded in your Python or JVM process rather than only as a server. The full side-by-side is in open source knowledge graph and GraphRAG databases compared.


Frequently Asked Questions

What is memory for an LLM agent?

Agent memory is the state an agent keeps between turns and between sessions. It usually splits into episodic memory (what happened, turn by turn), semantic memory (durable facts learned about entities), and working memory (the current context window). Only the first two need a database.

Why is a vector store alone not enough for agent memory?

A vector store retrieves passages that resemble the query, but it cannot express that a fact was superseded, that two names refer to one person, or that a preference belongs to a project rather than a user. Those are relationships and validity intervals, which is what a graph stores.

How do you stop an agent from recalling outdated facts?

Do not overwrite facts; invalidate them. Give each fact edge a valid_from and valid_to timestamp, and when a new value arrives, close the old edge instead of deleting it. Retrieval then filters to edges where valid_to is null, and the history stays auditable.

Can ArcadeDB store agent memory and the embeddings together?

Yes. Embeddings live in a property on the same records that carry the graph, indexed with HNSW. A single transaction writes an episode, its vector, and its relationships, so semantic recall and relationship traversal run against one consistent copy of the data.

Does ArcadeDB work with LangChain for agent memory?

Yes. The official langchain-arcadedb package provides ArcadeDBGraph, which implements the LangChain GraphStore protocol and connects over ArcadeDB’s Bolt protocol. There is also a LlamaIndex PropertyGraphStore integration, and an MCP server that lets an LLM query the database directly.

Can agent memory run without a database server?

Yes. The arcadedb-embedded Python package runs the full engine inside your agent process, with a bundled Java runtime. For a single-process agent or a local assistant, that removes the server, the network hop, and the deployment entirely while keeping graph and vector recall.


Getting Started

pip install arcadedb-embedded

That is the whole install: the wheel bundles a Java runtime, so there is no JDK to set up. From there, the schema above is a working starting point you can adapt.

For the retrieval patterns behind this, see GraphRAG on ArcadeDB. For entity extraction and knowledge graph construction, see knowledge graphs. ArcadeDB is Apache 2.0 and free in production, with no node limits and no enterprise edition.