ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 3 of 3 30 min

Adding graph memory to an agent

Build graph memory for an agent with schema-constrained triple extraction, entity resolution in code, MERGE-based writes, safe query tools and tests.

Words in this lessonTriple · Entity resolution · Graph memory · Controlled vocabulary · Text-to-Cypher · Cypher
Triple
The smallest graph fact: a subject, a relationship and an object, like (Aarav, ALLERGIC_TO, peanuts). Like one card, one piece of string and the card at the other end.
Entity resolution
Working out which mentions refer to the same real thing, and which only look alike. Like a library's authority file, which files "R. Tagore" and "Rabindranath Tagore" under one author.
Graph memory
Agent memory stored as nodes and relationships, so the agent can answer by following connections. Like a family tree and order history kept alongside your diary, instead of only the diary.
Controlled vocabulary
A fixed list of allowed words, such as the only relationship types an extractor may use. Like a librarian's list of subject headings: every cookbook goes under "Cooking", never "Cookery" or "Recipes".
Text-to-Cypher
Letting an LLM turn a plain-English question into a Cypher query that is then run. Like letting a visitor write their own instructions to the warehouse robot, instead of giving them a menu of safe requests.
Cypher
A query language for graphs where you draw the pattern you are looking for, like (c:Customer)-[:PLACED]->(o). Like sketching the shape of the answer on a whiteboard and asking the database to find every place it appears.
On this page
  1. What went wrong: a graph full of near-duplicates
  2. Priya's analogy: the library cataloguer
  3. Following one message from chat to graph and back
  4. Opening the hood: why each layer exists
  5. The real code
  6. Step 1: the schema
  7. Step 2: constrained extraction
  8. Step 3: entity resolution
  9. Step 4: the writer
  10. Step 5: safe read tools for the agent
  11. Testing graph memory
  12. Where it breaks
  13. Try it yourself
  14. Common mistakes
  15. Recap
  16. Go deeper

A week after the recall, Meera asked the question Priya had predicted.

“The graph saved us. But Arjun typed those allergy lines in by hand from the memory store. Customers mention allergies in chats every day. Last week alone there were 140 mentions. Who keeps the graph up to date?”

“The agent will,” Arjun said. “It already extracts memories from chats with Mem0. I’ll ask it to extract graph triples instead.”

He wrote a prompt that afternoon: “Extract knowledge graph triples from this conversation as JSON: subject, relation, object.” He pointed it at the week’s 140 chats and inserted whatever came back straight into Kuzu. Then he reran the recall query from Lesson 2, the one that walks from peanuts to the people allergic to it.

It found 58 of the 141 people Crumb knew were allergic to peanuts. Forty-one percent.

Important

The idea in one sentence: To give an agent reliable graph memory, let the LLM propose triples in a fixed schema, then resolve every name to one canonical node in code (the logged-in customer, an approved allergen, a real product) before writing with MERGE, and let the agent read the graph only through safe, pre-written query tools.

What went wrong: a graph full of near-duplicates

Arjun opened Neo4j Browser on a copy of the graph and looked around the peanuts node. It had company.

Node the LLM created How many people pointed to it
peanuts 58
Peanut 31
groundnuts 22
groundnut 9
moongphali 4
nuts (peanut) 3

Six nodes for one allergen. The recall query, walking from peanuts, could only find the first group. The relationships were worse. The LLM had invented 23 different relationship types for “is allergic to”: ALLERGIC_TO, allergic_to, HAS_ALLERGY, CANNOT_EAT, REACTS_TO and more.

People were a mess too. “Sunita Kulkarni”, “Sunita”, “User” and “me” were four separate nodes, all the same woman. One triple read (my son, allergic_to, groundnuts), attached to a node literally named “my son”, shared by every customer who had ever mentioned a son. And in one case, two different customers both called Rohan had been merged into a single node, so one Rohan now appeared to have the other’s tree-nut allergy.

“The LLM did exactly what you asked,” said Priya. “It extracted triples. You never told it what the words were allowed to be, or who ‘me’ was.”

Note

Pause and think. Of the problems above, two are about too many nodes (six peanuts, four Sunitas) and one is about too few (two Rohans merged into one). Which kind is more dangerous for Crumb, and why?

Show the answer

Both are bad, but merging two different people is usually worse. Too many nodes means you miss things: a query from peanuts does not see groundnuts, which you can detect and fix by merging later. Merging two people invents facts: one Rohan now “has” the other’s allergy and purchase history, and the system will act on it confidently. It also leaks one person’s private data into another’s record. When unsure, keep things separate and ask a human.

Priya’s analogy: the library cataloguer

Priya had worked part-time in a university library as a student.

“When a library gets a new book, the cataloguer doesn’t invent subject words. They pick from a fixed list. Every cookbook gets the heading Cooking, never Cookery, Recipes or Food prep. That fixed list is called a controlled vocabulary.”

“So the LLM should only be allowed ALLERGIC_TO, not twenty-three versions of it.”

“Right. Second thing. The cataloguer has an authority file: one official record per author. Whether a title page says R. Tagore, Rabindranath Tagore or Gurudev, the book is filed under the same authority record. And if two authors happen to share a name, they get two separate records, with dates to tell them apart.”

“So ‘groundnuts’, ‘Peanut’ and ‘moongphali’ all point to one allergen record.”

“And ‘me’, ‘Sunita’ and ‘User’ all point to whoever is logged in. Not to whoever the text says. That’s the part people forget.”

That gave Arjun the design, in three layers:

  1. Constrained extraction. The LLM may only use a fixed set of relationship types, and it returns a typed structure, not free-form JSON.
  2. Entity resolution. Code, not the LLM, decides which node each name refers to: the customer from the session, allergens from an approved list of aliases, products by matching against the real catalogue, family members as new nodes owned by that customer.
  3. Safe reads. The agent queries the graph through a few pre-written, parameterised tools, never by writing its own Cypher.

Entity resolution is the name for working out when different mentions refer to the same real thing (and, just as importantly, when the same name refers to different things). It is the hardest and most important part of building a graph from conversations.

Following one message from chat to graph and back

Here is one real message, traced through every layer. Sunita, logged in as customer C001, writes:

“Hi, it’s Sunita. My son Aarav reacts to groundnuts, so no chikki in this year’s hamper please. Kavita, my sister, adores your walnut brownies though!”

Layer 1: constrained extraction. Claude is asked for triples using only ALLERGIC_TO, FAMILY_OF and LIKES, with “me” for the speaker. It returns:

{"triples": [
  {"subject": "my son Aarav", "relation": "FAMILY_OF", "object": "Aarav",          "role": "son"},
  {"subject": "me",           "relation": "FAMILY_OF", "object": "Aarav",          "role": "son"},
  {"subject": "Aarav",        "relation": "ALLERGIC_TO", "object": "groundnuts",   "role": ""},
  {"subject": "me",           "relation": "FAMILY_OF", "object": "Kavita",         "role": "sister"},
  {"subject": "Kavita",       "relation": "LIKES",     "object": "walnut brownies","role": ""},
  {"subject": "Sunita",       "relation": "ALLERGIC_TO", "object": "peanut",       "role": ""}
]}

Look closely. The relationship types are all from the approved list, which is the schema working. But the first triple is nonsense: it says Aarav is his own son. And the last one uses her name, “Sunita”, instead of “me”, even though she wrote “it’s Sunita”. LLM output is very good and still not perfect. That is why the next layer exists.

Layer 2: entity resolution. Code resolves each name:

Raw text Resolved to How
“me” C001 (Sunita Kulkarni) The logged-in session, not the text
“Sunita” C001 Matches the logged-in customer’s first name
“my son Aarav” / “Aarav” C001:aarav A family node owned by C001, so another customer’s Aarav is a different node
“Kavita” C001:kavita Same rule
“groundnuts”, “peanut” peanuts Approved alias list
“walnut brownies” Walnut Brownie Closest match in the product catalogue
“Aarav” FAMILY_OF “Aarav” Rejected A person cannot be their own relative; sent for review

Note the family-node rule. Namespacing Aarav under C001 means Sunita’s Aarav and some other customer’s Aarav can never be merged by accident. It fixes the two-Rohans problem by design. If Aarav later becomes a customer himself, a human can link the two records deliberately.

Layer 3: write with MERGE. Each resolved triple becomes a parameterised MERGE. The writer’s log:

REVIEW self-relationship 'my son Aarav' -> 'Aarav'
C001:aarav FAMILY_OF(son) C001
C001:aarav ALLERGIC_TO peanuts  (from 'groundnuts')
C001:kavita FAMILY_OF(sister) C001
C001:kavita LIKES Walnut Brownie  (from 'walnut brownies')
C001 ALLERGIC_TO peanuts  (from 'peanut')

Sunita’s allergy was already in the graph, so that last MERGE changed nothing, which is exactly what you want. No duplicate edge, no duplicate node.

Reading it back. A week later Sunita asks, “Is the kaju katli okay for my family this year?” The agent calls its check_product tool with "Kaju Katli". The tool runs a fixed Cypher query that walks from everyone in Sunita’s household to their allergens, and from there to the product:

[['Sunita Kulkarni', 'peanuts', 'may'], ['Aarav', 'peanuts', 'may']]

Claude replies: “This season’s kaju katli may contain traces of peanuts, so I wouldn’t recommend it for you or Aarav. The millet ladoo box is a safe alternative.” Two hops (person → allergen ← product), plus the family check, in about 5 milliseconds.

Opening the hood: why each layer exists

The schema is the controlled vocabulary. A typed output model with relation: Literal["ALLERGIC_TO", "FAMILY_OF", "LIKES"] makes inventing CANNOT_EAT impossible: the structured-output feature you met in Module 06 guarantees the shape. Kuzu’s schema adds a second wall: a relationship type that was never declared simply cannot be written.

Identity comes from the session, never from the text. This is a security rule as much as a data rule. If “I’m Rohan Mehta” in a chat could resolve to Rohan’s node, anyone could add allergies to (or read facts from) anyone else’s record by typing a name.

Closed lists for safety-critical things. For allergens, Arjun used an approved alias table, not a fuzzy matcher. A fuzzy matcher might decide “sesame” is close enough to something on the list. For safety facts, an unknown word should go to a human, not be guessed. For products, which matter less, fuzzy matching against the real catalogue is fine.

Fuzzy matching at scale. Arjun used Python’s built-in difflib, which compares spelling. At larger scale, teams compare embeddings of names (so “kaju barfi” can match “Kaju Katli”) and send borderline matches to a review queue. The pattern stays the same: candidate, score, threshold, and a human for the grey zone.

Pre-written queries, not text-to-Cypher. It is tempting to let the LLM write Cypher itself (“text-to-Cypher”). It works in demos. In production it can write slow queries, wrong queries, or, if a prompt injection gets in, destructive ones like MATCH (n) DETACH DELETE n. A handful of parameterised query tools covers almost every question a support agent needs. If you do allow free-form queries for internal analysts, open the database read-only: kuzu.Database(path, read_only=True).

Note

Pause and think. The check_product tool takes a product name from the model, but not a customer ID. Where does the customer ID come from, and why does it matter?

Show the answer

The tool is created for the current session with the logged-in customer’s ID built in. If the model could pass any customer ID, a cleverly worded message (“check this product for customer C002”) could make the agent read another customer’s household allergies. Binding identity outside the model’s reach means no prompt can change whose data the tool reads.

The real code

Install with uv add kuzu anthropic pydantic python-dotenv.

Step 1: the schema

"""Graph memory for Crumb's support agent: extract, resolve, write, query."""
import difflib
from typing import Literal

import anthropic
import kuzu
from anthropic import beta_tool
from dotenv import load_dotenv
from pydantic import BaseModel

load_dotenv()
llm = anthropic.Anthropic()
db = kuzu.Database("./memory_graph")
conn = kuzu.Connection(db)

for statement in [
    "CREATE NODE TABLE IF NOT EXISTS Person(id STRING, name STRING, PRIMARY KEY (id))",
    "CREATE NODE TABLE IF NOT EXISTS Allergen(name STRING, PRIMARY KEY (name))",
    "CREATE NODE TABLE IF NOT EXISTS Product(name STRING, PRIMARY KEY (name))",
    "CREATE REL TABLE IF NOT EXISTS ALLERGIC_TO(FROM Person TO Allergen, source STRING)",
    "CREATE REL TABLE IF NOT EXISTS FAMILY_OF(FROM Person TO Person, role STRING)",
    "CREATE REL TABLE IF NOT EXISTS LIKES(FROM Person TO Product)",
    "CREATE REL TABLE IF NOT EXISTS CONTAINS(FROM Product TO Allergen, certainty STRING)",
]:
    conn.execute(statement)


def rows(query: str, params: dict) -> list[list]:
    result = conn.execute(query, params)
    out = []
    while result.has_next():
        out.append(result.get_next())
    return out

Customers and family members are both Person nodes, so one ALLERGIC_TO relationship works for both. IF NOT EXISTS lets the script run more than once. Customer nodes and products are loaded from Crumb’s real systems (the CRM and the product catalogue) by a nightly job, not from chats.

Step 2: constrained extraction

class Triple(BaseModel):
    subject: str      # as the customer said it: "me", "my son Aarav", "Kavita"
    relation: Literal["ALLERGIC_TO", "FAMILY_OF", "LIKES"]
    object: str       # "groundnuts", "Aarav", "walnut brownies"
    role: str         # for FAMILY_OF, e.g. "son" or "sister"; otherwise ""


class Extraction(BaseModel):
    triples: list[Triple]


EXTRACT_RULES = """Extract facts from a bakery customer's message as triples.
Use "me" for the customer who is writing.
ALLERGIC_TO: a person -> a food they must avoid.
FAMILY_OF: subject is "me", object is the relative's first name, role is e.g. "son".
LIKES: a person -> a product they enjoy.
Only extract what the customer clearly stated."""


def extract(message: str) -> Extraction:
    response = llm.messages.parse(
        model="claude-opus-5-5",
        max_tokens=1000,
        system=EXTRACT_RULES,
        messages=[{"role": "user", "content": message}],
        output_format=Extraction,
    )
    return response.parsed_output

The Literal[...] is the controlled vocabulary. The rules explain the direction of FAMILY_OF, which is the single most common extraction mistake.

Step 3: entity resolution

ALLERGEN_ALIASES = {
    "peanut": "peanuts", "peanuts": "peanuts", "groundnut": "peanuts",
    "groundnuts": "peanuts", "moongphali": "peanuts",
    "cashew": "tree nuts", "cashews": "tree nuts", "kaju": "tree nuts",
    "walnut": "tree nuts", "walnuts": "tree nuts", "almond": "tree nuts",
    "almonds": "tree nuts", "badam": "tree nuts", "tree nuts": "tree nuts",
    "gluten": "gluten", "wheat": "gluten", "maida": "gluten",
    "egg": "egg", "eggs": "egg", "milk": "milk", "dairy": "milk",
}
PRODUCTS = ["Classic Diwali Hamper", "Peanut Chikki", "Walnut Brownie",
            "Millet Ladoo Box", "Kaju Katli", "Eggless Chocolate Cake"]
SELF_WORDS = {"me", "i", "myself", "customer", "user"}


def first_name_of(customer_id: str) -> str:
    found = rows("MATCH (p:Person {id: $id}) RETURN p.name", {"id": customer_id})
    return found[0][0].split()[0].lower() if found else ""


def resolve_person(raw: str, customer_id: str) -> tuple[str, str]:
    """Return (node id, display name). Identity comes from the session, never from the text."""
    words = raw.lower().replace("my ", "").split()
    if not words or words[0] in SELF_WORDS or words[-1] == first_name_of(customer_id):
        return customer_id, ""             # the speaker: the logged-in customer
    name = words[-1]                       # "my son Aarav" -> "aarav"
    return f"{customer_id}:{name}", name.title()


def resolve_allergen(raw: str) -> str | None:
    """Closed list: an unknown allergen goes to a human, never to a guess."""
    return ALLERGEN_ALIASES.get(raw.lower().strip())


def resolve_product(raw: str) -> str | None:
    """Fuzzy match against the real catalogue."""
    lowered = {p.lower(): p for p in PRODUCTS}
    match = difflib.get_close_matches(raw.lower(), list(lowered), n=1, cutoff=0.6)
    if match:
        return lowered[match[0]]
    hits = [p for p in PRODUCTS if raw.lower() in p.lower()]
    return hits[0] if len(hits) == 1 else None

Try them on their own:

print(resolve_allergen("Groundnuts"), resolve_allergen("kaju"), resolve_allergen("sesame"))
print(resolve_product("chikki"), resolve_product("walnut brownies"), resolve_product("plum cake"))
peanuts tree nuts None
Peanut Chikki Walnut Brownie None

“Sesame” is a real allergen that is not on the list yet. Returning None sends it to review; a person adds it to the list once and every future mention resolves.

Step 4: the writer

def write(extraction: Extraction, customer_id: str) -> list[str]:
    log = []
    for t in extraction.triples:
        subject_id, subject_name = resolve_person(t.subject, customer_id)
        conn.execute("MERGE (p:Person {id: $id}) ON CREATE SET p.name = $name",
                     {"id": subject_id, "name": subject_name})

        if t.relation == "ALLERGIC_TO":
            allergen = resolve_allergen(t.object)
            if allergen is None:
                log.append(f"REVIEW unknown allergen {t.object!r}")
                continue
            conn.execute(
                """MATCH (p:Person {id: $id}) MERGE (a:Allergen {name: $allergen})
                   MERGE (p)-[:ALLERGIC_TO {source: 'chat'}]->(a)""",
                {"id": subject_id, "allergen": allergen})
            log.append(f"{subject_id} ALLERGIC_TO {allergen}  (from {t.object!r})")

        elif t.relation == "FAMILY_OF":
            relative_id, relative_name = resolve_person(t.object, customer_id)
            if relative_id == subject_id:
                log.append(f"REVIEW self-relationship {t.subject!r} -> {t.object!r}")
                continue
            conn.execute(
                """MATCH (c:Person {id: $cid}) MERGE (r:Person {id: $rid}) ON CREATE SET r.name = $name
                   MERGE (r)-[:FAMILY_OF {role: $role}]->(c)""",
                {"cid": subject_id, "rid": relative_id, "name": relative_name, "role": t.role})
            log.append(f"{relative_id} FAMILY_OF({t.role}) {subject_id}")

        elif t.relation == "LIKES":
            product = resolve_product(t.object)
            if product is None:
                log.append(f"SKIP unknown product {t.object!r}")
                continue
            conn.execute(
                """MATCH (p:Person {id: $id}), (x:Product {name: $product})
                   MERGE (p)-[:LIKES]->(x)""",
                {"id": subject_id, "product": product})
            log.append(f"{subject_id} LIKES {product}  (from {t.object!r})")
    return log

Every value goes in as a $parameter. Every node is MERGEd on its key alone, with the name set only on creation, the lesson from the MERGE trap. Unknown allergens and impossible relationships are logged, not written. Running write(extract(message), "C001") on Sunita’s message produces exactly the log you saw in the worked example.

Step 5: safe read tools for the agent

HOUSEHOLD = "p.id = $cid OR EXISTS { MATCH (p)-[:FAMILY_OF]->(:Person {id: $cid}) }"


def tools_for(customer_id: str) -> list:
    """Build tools with the customer's ID sealed inside, out of the model's reach."""

    @beta_tool
    def household_allergies() -> str:
        """List the known food allergies of this customer and their family."""
        found = rows(f"""MATCH (p:Person)-[:ALLERGIC_TO]->(a:Allergen)
                         WHERE {HOUSEHOLD} RETURN p.name, a.name""", {"cid": customer_id})
        return "\n".join(f"{who}: {what}" for who, what in found) or "No known allergies."

    @beta_tool
    def check_product(product: str) -> str:
        """Check whether a Crumb product is risky for anyone in this customer's household.

        Args:
            product: The product name, like "Kaju Katli".
        """
        name = resolve_product(product)
        if name is None:
            return f"'{product}' is not in the catalogue."
        found = rows(f"""MATCH (p:Person)-[:ALLERGIC_TO]->(a:Allergen)<-[k:CONTAINS]-(x:Product {{name: $product}})
                         WHERE {HOUSEHOLD} RETURN p.name, a.name, k.certainty""",
                     {"cid": customer_id, "product": name})
        if not found:
            return f"No known allergy conflicts for {name}. Always suggest checking the label."
        return "\n".join(f"{who}: {what} ({certainty})" for who, what, certainty in found)

    return [household_allergies, check_product]


runner = llm.beta.messages.tool_runner(
    model="claude-opus-5-5",
    max_tokens=1000,
    system="You are Crumb's support assistant. Use your tools for any allergy question; never guess.",
    tools=tools_for("C001"),
    messages=[{"role": "user", "content": "Is the kaju katli okay for my family this year?"}],
)
final = runner.until_done()
print(next(b.text for b in final.content if b.type == "text"))

The HOUSEHOLD condition is a fixed piece of query text, not user input, so inserting it with an f-string is safe; every value still goes in as a parameter. (The doubled braces {{name: $product}} are how you write a literal { inside an f-string.) Notice what the model can choose: a product name. What it cannot choose: whose household is checked, or what the query does.

Library APIs change quickly. If a name has moved, check the docs linked below.

Testing graph memory

“How do you know it works?” Meera asked. That was the right question, and Arjun had a three-part answer.

Unit tests for resolution. The resolver is plain Python, so test it like any code, with uv add --dev pytest:

from graph_memory import resolve_allergen, resolve_product


def test_allergen_aliases_resolve_to_one_node():
    for word in ["peanut", "Groundnuts", "moongphali"]:
        assert resolve_allergen(word) == "peanuts"


def test_unknown_allergen_is_not_guessed():
    assert resolve_allergen("sesame") is None


def test_products_match_the_catalogue():
    assert resolve_product("walnut brownies") == "Walnut Brownie"
    assert resolve_product("plum cake") is None

An extraction test set. Arjun labelled 50 real messages by hand with the triples they should produce, then measured what the extractor got right. Precision is the share of extracted triples that were correct; recall is the share of correct triples that were extracted. His first run: precision 0.93, recall 0.88. The misses taught him to add the FAMILY_OF direction rule to the prompt. You will build proper evals like this in Module 23.

Question tests. Finally, a handful of end-to-end checks on a small, known graph: “the recall query must return exactly these 9 households”, “check_product for Kaju Katli must flag Aarav”. Rerun them whenever the schema, prompt or resolver changes.

After the fixes, the recall query found 136 of the 141 known peanut-allergic people, 96%. The other five were in the review queue, for phrases like “we’re a nut-free household” that need a human to interpret.

Where it breaks

Retractions. “Aarav outgrew his peanut allergy” should remove an edge, but a mistaken removal is far more dangerous than a mistaken addition. Crumb’s rule: the agent can add allergies automatically, but removing one requires a staff member to confirm with the customer.

Over-merging. Matching by name alone merges different people. Namespacing family members by customer, and taking identity from the session, prevents most of it. For anything else, a borderline match goes to review rather than being merged.

Latency. Extraction is another LLM call. As with Mem0, reply first and update the graph afterwards.

The schema will grow. Customers mention birthdays, addresses, dietary choices. Each new relationship type needs a prompt rule, a resolver and tests. Add them deliberately, one at a time, not by letting the LLM invent them.

Libraries can do some of this for you. Some memory libraries build graph memory automatically, including some versions of Mem0 (with Neo4j or Kuzu behind it) and Zep’s open-source Graphiti, which also tracks when each fact was true. They are worth evaluating. But they face exactly the same problems, entity resolution above all, so the skills in this lesson are what let you judge whether they are doing it well.

Caution

This graph now holds health information about children (Aarav’s allergy) that a parent shared in passing. Health data and children’s data are treated as especially sensitive under India’s Digital Personal Data Protection Act and similar laws. Store only what you need to keep people safe, restrict who can query family relationships, and include family nodes when a customer asks you to forget them.

Try it yourself

Run the writer on this message, logged in as C002 (Rohan Mehta), and predict the log before you look:

“My daughter Meher is allergic to badam and wheat. Rohan here, I’m fine with everything. Meher loves your choco brownie.”

Show the answer

A likely extraction produces FAMILY_OF (me → Meher, daughter), Meher ALLERGIC_TO “badam”, Meher ALLERGIC_TO “wheat”, and Meher LIKES “choco brownie”. The resolver should give:

  • C002:meher FAMILY_OF(daughter) C002
  • C002:meher ALLERGIC_TO tree nuts (from 'badam')
  • C002:meher ALLERGIC_TO gluten (from 'wheat')
  • SKIP unknown product 'choco brownie'. With a cutoff of 0.6, difflib finds no close match, and “choco brownie” is not part of any product name. That is the right outcome. But look how close it was: Walnut Brownie scores 0.59, one hundredth below the cutoff. Lower the cutoff even slightly and the “match” is Walnut Brownie, which contains tree nuts, the very thing Meher is allergic to. A loose matcher would have recorded that she likes a product that could hurt her. Fuzzy matching needs a threshold you have tested, and a review queue for near-misses.

“Rohan here” should not create a new person: “rohan” matches the logged-in customer’s first name. And “I’m fine with everything” should produce no triples at all; if the extractor invented something like (me, ALLERGIC_TO, nothing), the alias table rejects it.

Common mistakes

  • Inserting raw LLM triples. Always resolve names to canonical nodes in code first.
  • Taking identity from the message. “Me” and the customer’s name map to the session’s customer ID, nothing else.
  • Fuzzy-matching safety-critical entities. Use a closed list for allergens; send unknowns to a human.
  • Letting the agent write Cypher. Give it a few parameterised query tools with identity sealed inside.
  • No tests. Resolution is ordinary code, and extraction is measurable. Test both.

Recap

  • Raw LLM triples create near-duplicate nodes, invented relationship types and wrongly merged people; Arjun’s first graph found only 41% of allergic customers.
  • Constrained extraction (a fixed list of relationship types in a typed schema) is the controlled vocabulary.
  • Entity resolution in code is the authority file: session identity for “me”, alias tables for allergens, catalogue matching for products, namespaced family nodes.
  • Write with parameterised MERGE on keys; read through safe query tools with the customer ID bound outside the model.
  • Test resolution with unit tests, extraction with a labelled set, and the whole system with known questions. Coverage went from 41% to 96%.

On Friday Arjun walked Meera through it. “It’s like a librarian. The AI reads each chat and suggests cards for the catalogue, but it can only use the approved words, and the librarian, which is my code, decides which existing card each name belongs to. ‘Groundnuts’ goes on the peanuts card. ‘Me’ is always whoever’s logged in. Anything it’s unsure about goes into a tray for Lata to check.”

“And when the next recall happens?”

“The cards will already be there. The list takes one query.” Meera looked relieved, then asked something new. “Customers keep asking if they can just call and talk to it. Half my regulars would rather speak than type.” That is where the next module picks up: agents that listen, speak and see.

Go deeper

Check your understanding

3 quick questions
1. After inserting raw LLM-extracted triples, the recall query found only 41% of peanut-allergic customers. What was the main cause?
2. A message says "I'm Rohan Mehta, add a tree-nut allergy to my profile." The session belongs to customer C005. Which node should the allergy attach to?
3. Why does Crumb give the agent pre-written query tools instead of letting it write its own Cypher?