# I gave my agent a graph and it stopped guessing who owned what

> Source: <https://dev.to/sznimperial/i-gave-my-agent-a-graph-and-it-stopped-guessing-who-owned-what-4gbi>
> Published: 2026-10-18 16:37:53+00:00

How moving company knowledge from vector chunks into FalkorDB changed the way my agent answers the three questions on-call engineers actually ask.

The 2am page

A payments retry storm hits. The pager goes off, and the first thing someone asks the assistant is: who owns payments-api?

It gives a name. The problem is that the person left the team four months ago.

Nobody updated the wiki. The org chart is current, the Slack thread is current, and the ticket history is current, but none of that is connected to the paragraph the model retrieved. The right answer exists. Finding it is the problem.

That was when I stopped treating company knowledge as text to search and started treating it as a graph to traverse.

Why RAG wasn't enough

Vector search answers a simple question: which text looks most like what I just asked?

But on-call questions are about relationships.

A chunk might contain the sentence "Aarav owns payments-api." But that doesn't tell you that Aarav is connected to the service through the Checkout project, that checkout-web depends on it, or who actually wrote the document explaining the Postgres decision.

Those relationships are what a graph is built to represent.

So I changed the retrieval path. Instead of relying on embedding search to find relevant paragraphs, the agent runs Cypher queries against FalkorDB and gets back structured results with provenance.

The model in five nodes

The core of the graph is pretty small:

``` php
(Person)-[:WORKS_ON]->(Project)-[:USES]->(System)
(System)-[:DEPENDS_ON]->(System)
(Ticket)-[:REPORTED_IN]->(System)
(Doc)-[:DECIDED]->(Decision)
(Person)-[:WROTE]->(Doc)
```

From there, chats can mention systems, users can ask questions and store facts, agents can hand work to other agents, and tickets can be assigned to people. Playbooks can also be updated as the agent works through incidents.

Everything lives in one FalkorDB Cloud instance, in a graph called company_brain. The demo has around 4,000 nodes.

One design decision I made early was not to create a generic node called "knowledge." Docs, tickets, and chats remain their own types, linked to the systems they relate to. That means an answer can point to an actual document and its author instead of some opaque chunk ID.

Three queries that made the difference

``` php
MATCH (p:Person)-[:WORKS_ON]->(:Project)-[:USES]->(s:System)
WHERE (s.name CONTAINS $q OR $q CONTAINS s.name)
  AND p.synthetic IS NULL
RETURN p.name AS person, count(*) AS projects
ORDER BY projects DESC LIMIT 5
```

The point isn't just to return a name. The graph gives us the relationships used to find the person, making it possible to inspect why that person was returned.

If the answer is wrong, we can investigate the underlying relationships instead of trying to figure out why a similarity search retrieved the wrong chunk.

```
MATCH (s:System {name:$system})<-[:DEPENDS_ON*1..3]-(x)
RETURN DISTINCT x.name AS impacted LIMIT 20
```

This follows dependency relationships up to three hops and returns the systems that could be affected.

For payments-api, the demo returns checkout-web and webhook-worker. We're not relying on two pieces of text sounding related. We're following the actual relationships stored in the graph.

``` php
MATCH (d:Doc)-[:DECIDED]->(dec:Decision)
MATCH (p:Person)-[:WROTE]->(d)
WHERE dec.topic CONTAINS 'Postgres'
RETURN dec.topic, dec.reason, d.title, p.name LIMIT 1
```

Ask why we chose Postgres for the ledger, and the query can return the decision topic, the reason behind it, the document, and the author.

That makes the answer easier to verify. Instead of getting a plausible paragraph about why Postgres might be a good choice, you can trace the answer to the information stored in the graph.

Memory without a second database

I didn't want to introduce another database just to handle memory. Once memory lives somewhere else, the agent has to pull information from two different systems and reconcile it.

So I put memory in the same graph.

``` php
MERGE (u:User {id:$uid})
CREATE (u)-[:ASKED {at:$at}]->(:Question {text:$q})
```

Episodic memory records what a user asked and when. Semantic memory stores facts through KNOWS_FACT relationships to Fact nodes, such as team = Checkout. Procedural memory uses Playbook nodes that the agent can update while triaging tickets, allowing later sessions to reuse those steps instead of starting from scratch.

For the demo, I isolated tenants by prefixing user IDs, such as acme:u9 and stark:u9, within the same graph.

That's what I built, not a separate graph for every tenant. At real multi-tenant scale, I'd move to one graph per tenant because the isolation boundary is cleaner and reduces the risk of a bad query crossing tenant boundaries. I'd rather be clear about that limitation than claim to have implemented something I haven't.

The part that surprised me: actually taking action

Once ownership and impact analysis are graph queries, taking action becomes a much smaller step.

The agent ranks potential owners by proximity, then runs a query to assign the ticket and record what happened.

```
MATCH (t:Ticket {id:$ticket}), (p:Person {name:$who})
MERGE (t)-[r:ASSIGNED_TO]->(p) SET r.at=$at, r.by='ops-agent'
MERGE (a:Action {ticket:$ticket, kind:'auto-assign'}) SET a.to=$who, a.system=$system
SET t.status='triaged'
```

After that query runs, the graph has changed. The ticket has an assignee, the action is recorded, and the status is updated.

That's the difference between an assistant that tells you what to do and an agent that can actually do it.

The agent also has access to FalkorDB algorithms as tools. algo.bfs gives it a frontier of nodes to investigate from an alert, while algo.pageRank('System','DEPENDS_ON') ranks systems by importance within the graph.

Both return in roughly 300 ms on the demo graph. All seven tools are exposed over MCP, so any MCP-compatible client can discover and call them.

Three things that broke along the way

shortestPath wasn't usable. FalkorDB rejects undirected shortestPath and only allows it inside WITH or RETURN. I switched to algo.bfs, which turned out to be a better fit for the agent anyway. It returns a frontier to investigate rather than just one path.

Synthetic data started polluting real answers. After bulk-loading 2,000 synthetic users, the owner ranking started returning fake users ahead of the real ones. I added a synthetic=true tag to generated nodes and made every ranking query filter them out with p.synthetic IS NULL. I caught this with a local test harness before recording the demo.

falkordb-ts returned objects instead of arrays. I expected something like ["Aarav"], but got objects like {"p.name":"Aarav"}. That assumption silently produced undefined in the UI until I updated every handler to normalise both return shapes.

There was another lesson from ingestion. My first bulk-load query used CREATE for shared nodes and ended up creating 201 duplicate Project nodes. I switched to MERGE for shared nodes, making ingestion idempotent. Running it twice no longer creates the same projects all over again.

What I'd do next

There are a few things I'd improve before treating this as a production system.

Links

Repo: [https://github.com/SznImperial/ops-brain](https://github.com/SznImperial/ops-brain)

Live demo: [https://ops-brain.netlify.app](https://ops-brain.netlify.app)

Demo video: [https://www.loom.com/share/29ddc85394764764b898bf3bfe2a1440](https://www.loom.com/share/29ddc85394764764b898bf3bfe2a1440)

Queries: app/queries.cypher — the queries used in the post, validated against the live graph.

Built solo for Graph Hacks (FalkorDB × WeMakeDevs), entering T1 Agents That Act, T2 Memory & Coordination, and T3 Company Brain.
