# Graph memory for AI agents

Source: https://tpiros.dev/blog/graph-memory-sqlite-gemini

An agent's durable facts, your name, your stack, the project you're on, are its semantic memory: decontextualised knowledge it should always have on hand. The common way to store them is a flat list of entries, and often the simplest version wins.

Plenty of agents keep these facts in a plain text file the model reads and edits, no embeddings at all, and it holds up well for a small set of facts. Others put them in a vector store and retrieve by similarity. I went through both, and the layers around them, in [Agent memory, end to end](https://tpiros.dev/blog/agent-memory-end-to-end/).

A flat list, text file or vector store, has 2 limits a graph removes. It treats every fact as an island, so it never connects "works on the payments migration" to "the deadline is June 30". And because it only appends, it collects both repeats and contradictions: a user who preferred coffee last month and drinks tea now leaves both entries in place, and it takes a separate consolidation pass to dedupe the duplicates and supersede the stale ones.

A graph makes both of those structural. Facts that share a subject share a node, so the connections live in the store instead of being rebuilt on every query. And every fact is an edge with a time range, so replacing an old value is part of the data model rather than a maintenance job bolted on top.

This is a walk through building that graph end to end.

## The shape of the data

A graph memory stores facts as triples: a subject, a relation, and an object.

```
(user) -[PREFERS]-> (coffee)
```

The subject and object are nodes, the real things the agent knows about. The relation is the edge between them. "My favourite drink is coffee" becomes one edge. "I'm leading the payments migration" becomes another, `(user)-[WORKS_ON]->(payments migration)`.

2 properties turn this from a list of sentences into a memory. Nodes are shared, so every fact about the user hangs off the same `user` node and you can walk from one fact to its neighbours. And every edge carries a time range, so a fact can be retired without being deleted.

Here is the whole graph the demo below builds. The faded edges are the retired ones, coffee and the Tesla, still in the store after the user switched to tea and to a BMW.

## The stack

3 pieces hold it up. `node:sqlite` is the SQLite that now ships inside Node, so storage needs nothing installed and no build step. `sqlite-vec` adds vectors and cosine search to that same file, so the graph and the embeddings sit together.

Gemini does 2 jobs: turning a sentence into triples, and turning text into vectors with `gemini-embedding-2`. The whole thing runs from one TypeScript file.

## Watch it run

Before the mechanics, here's the finished memory. Step through 5 messages and each one gets turned into triples and folded into the graph. Watch the third message: the user switches to tea, and the coffee fact is retired rather than stacked next to the new one.

Once the graph is built, ask it questions and it ranks the stored facts by relevance. Every triple and every score is captured from a real run.

The rest of this post is how each of those steps works.

## Turning text into triples

The first job is extraction: a sentence in, structured facts out. Gemini handles it with a forced JSON schema, so every message comes back as a list of triples rather than prose.

```json
{
  "subject": "user",
  "relation": "PREFERS",
  "object": "coffee",
  "singleValued": true
}
```

3 details earn their place in that object. The model is told to represent the speaker as a single `user` node, so "I", "my", and "me" all resolve to the same subject.

Relations come back in `UPPER_SNAKE_CASE` from a small preferred vocabulary, which keeps one idea from showing up as both `PREFERS` and `LIKES`. And each triple carries a `singleValued` flag, which the reconciliation step needs later.

## Storing nodes and edges

The schema is 2 ordinary tables plus a vector table. Nodes are entities. Edges are the facts, and every edge records when it became true and when it stopped:

```sql
CREATE TABLE nodes (
  id   INTEGER PRIMARY KEY AUTOINCREMENT,
  type TEXT NOT NULL,
  name TEXT NOT NULL
);

CREATE TABLE edges (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  source_id  INTEGER NOT NULL,
  target_id  INTEGER NOT NULL,
  relation   TEXT NOT NULL,
  valid_from TEXT NOT NULL,
  valid_to   TEXT            -- NULL = currently true; set = retired
);
```

The vectors live alongside them in a `sqlite-vec` virtual table, keyed by the row they describe:

```sql
CREATE VIRTUAL TABLE vec_nodes USING vec0(
  node_id   INTEGER PRIMARY KEY,
  embedding float[768] distance_metric=cosine
);
```

One SQLite file now holds the graph and its embeddings, and a `JOIN` can mix structured lookups with nearest-neighbour search in a single query.

## One node per thing

For the graph to mean anything, "Tamas" and "the user" have to land on the same node, while "tea" and "coffee" stay on different ones. That is entity resolution, and it's trickier than it looks.

The tempting approach is pure vector similarity: embed the new entity, find the nearest existing node, and merge if they are close. It doesn't hold up. Measure the cosine distance between a few bare terms and the problem is obvious:

```
0.18  "tea"  vs  "coffee"
0.31  "tea"  vs  "banana"
0.35  "tea"  vs  "photosynthesis"
```

Tea and coffee sit at 0.18, closer to each other than tea is to banana. Any threshold loose enough to merge real aliases is also loose enough to fuse 2 distinct drinks.

So the resolver uses vectors only to find candidates, then asks the model to make the call: are these 2 names the same real-world entity? `JS` and `JavaScript` come back as the same, `tea` and `coffee` come back as different. The embedding narrows the field; the model decides identity.

## Updating a fact without losing it

When the user switches from coffee to tea, the memory has to stop returning coffee. The blunt option is to delete the coffee edge, but that throws away history. "What did they used to drink?" becomes unanswerable.

Instead, reconciliation retires the old edge by stamping its `valid_to`, then inserts the new one. Nothing is ever deleted.

```js
// a new value for a functional relation retires the old one
if (existing && existing.target_id !== newTarget && t.singleValued) {
  db.prepare('UPDATE edges SET valid_to = ? WHERE id = ?').run(now(), existing.id);
}
```

Each edge stores the window it was true for: a `valid_from` when the fact was first seen, and a `valid_to` that stays `NULL` until something replaces it. After the switch, both edges sit in the table:

```
relation  target   valid_from   valid_to
PREFERS   coffee   2026-05-01   2026-06-11    ← retired, row still there
PREFERS   tea      2026-06-11   NULL          ← current
```

The coffee row was never removed; it just gained an end date. Recall only ever reads edges where `valid_to IS NULL`, so it sees tea and not coffee, while the dated coffee row stays available to any query that asks for the history.

## Which facts replace, and which accumulate

Retiring the old edge is correct for a favourite drink, where there's only one at a time. It's wrong for languages, where a new `KNOWS` should sit beside the existing ones. Reconciliation needs to know, per relation, whether a new value replaces or accumulates.

That's what the `singleValued` flag from extraction is for. The model marks `PREFERS`, `HAS_ROLE`, and `DRIVES` as functional, because you have one current value of each. It marks `KNOWS` and `HAS_PET` as not, because they pile up.

The graph never holds a hardcoded list of which relations are which. The model decides as it reads, so a relation the prompt never mentioned still reconciles correctly.

You can see both behaviours in the demo above. Selling the Tesla and buying a BMW retires the Tesla, because `DRIVES` is functional. Adding a second dog keeps the first, because `HAS_PET` is not.

## Recall

Recall takes a question and returns the most relevant current facts. The detail that makes it work is what gets embedded.

Embedding each object node on its own, "tea", can rank entities, but it drops the relation. A bare "tea" vector says nothing about whether the user prefers tea, dislikes it, or is allergic to it, so every question that brushes past tea pulls the same node. Recall instead embeds the whole fact as a sentence, "user prefers tea", so each vector carries the subject, the relation, and the object at once.

That makes recall a single ranked query: embed the question, score it against every current fact with `vec_distance_cosine`, and return the top few.

```sql
SELECT s.name, e.relation, o.name,
       vec_distance_cosine(ve.embedding, ?) AS score
FROM edges e
JOIN nodes s     ON s.id = e.source_id
JOIN nodes o     ON o.id = e.target_id
JOIN vec_edges ve ON ve.edge_id = e.id
WHERE e.valid_to IS NULL
ORDER BY score
LIMIT 4;
```

For "what does the user drink?", that returns:

```
- user PREFERS tea               0.21
- user HAS_PET golden retriever  0.34
- user HAS_NAME Tamas            0.35
```

The right fact leads with a clear gap. And because the match is on the whole sentence rather than a relation name, the same query works for relations you never special-cased: ask about a car, a pet, or a deadline and the matching fact rises to the top.

## What you end up with

A graph memory built this way handles 3 things a flat fact store cannot.

It knows how facts connect, because they share nodes. It remembers what used to be true, because edges are retired rather than deleted. And it ranks by relevance, because every fact is embedded as a sentence and scored against the question.

All of it fits in one file. The parts doing the real work are small: the `valid_to` column that turns deletion into history, the model deciding entity identity instead of a distance threshold, and the per-fact `singleValued` flag that decides what replaces what. The rest is SQLite.

This is only the semantic layer. An agent that runs for days also has to survive compaction, recall past conversations, and keep its fact store from filling with duplicates, which is the wider picture I covered in [Agent memory, end to end](https://tpiros.dev/blog/agent-memory-end-to-end/).
