Docs
View as markdown

Turn questions into Cypher with an LLM

Give a model the graph's schema from the db.schema procedures or get_schema, check each generated statement with EXPLAIN, and repair it from the notifications.

cyphermcpclipythonagents

Before you start: Your first graph in the CLI

This guide shows you how to turn a question into a Cypher statement One unit of Cypher that relate parses, plans and runs, such as a MATCH … RETURN read or a CREATE write; a script holds several, separated by ;. Read more in the glossary with a language model and check that statement before it runs. The model gets three things it cannot guess: this graph's schema, read from the engine; the places where relate's dialect differs from the Neo4j examples it was trained on; and a check that plans the statement with EXPLAIN, so a wrong label or a Neo4j-only procedure comes back as a message the model can repair, not as an empty result.

Prerequisites

  • The example graph from your first graph: three people and two projects. The first step creates it in the cells, and the terminal steps write it to team.rl.
  • For the terminal steps, relate on your PATH (Install relate). The cells run in your browser without it.
  • For the program, the Python client (pip install relate-db) and a client for the model of your choice. No step on this page calls a model; that call is the one line you supply.

Load the example graph

The cells on this page run in page order on one in-memory store. This cell writes the shared graph:

Cypher
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace)
Recorded result
(no rows) · nodes created: 5, relationships created: 5, properties set: 13, labels added: 5

The terminal steps use the same graph in a .rl file. This writes team.rl; every later command opens it --read-only:

relate query --database ./team.rl --mode envelope <<'EOF'
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace);
EOF
{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":2.510833,"execute_ms":2.21,"lex_parse_ms":0.01375,"lower_ms":0.02025,"parse_ms":0.281042,"plan_ms":0.014,"result_generation_ms":0.000166,"rewrite_ms":0.001333},"truncated":false}

Read the schema from the engine

Three statements give a model what it needs: each label A name that classifies a node, such as Person; a node has zero or more, and patterns, indexes and constraints refer to them. Read more in the glossary's properties with their types, each relationship type The single name every relationship carries, such as KNOWS or WORKS_ON; patterns match on it, and a relationship cannot change it. Read more in the glossary's properties, and which labels each type connects. db.schema.nodeTypeProperties() infers the first by scanning the data:

Cypher
CALL db.schema.nodeTypeProperties() YIELD nodeType, propertyName, propertyTypes
RETURN nodeType, propertyName, propertyTypes
ORDER BY nodeType, propertyName
Recorded result
nodeTypepropertyNamepropertyTypes
":Person""name"["STRING NOT NULL"]
":Person""role"["STRING NOT NULL"]
":Project""language"["STRING NOT NULL"]
":Project""name"["STRING NOT NULL"]

4 rows

db.schema.relTypeProperties() does the same for relationships. A type with no properties has one row with a null propertyName:

Cypher
CALL db.schema.relTypeProperties() YIELD relType, propertyName, propertyTypes
RETURN relType, propertyName, propertyTypes
ORDER BY relType
Recorded result
relTypepropertyNamepropertyTypes
"[:KNOWS]"null[]
"[:MAINTAINS]"null[]
"[:WORKS_ON]""since"["INTEGER NOT NULL"]

3 rows

Neither procedure says which way a relationship points or what it connects, and a model needs that to write a pattern. One statement lists every pattern that occurs:

Cypher
MATCH (a)-[r]->(b)
RETURN DISTINCT labels(a) AS source, type(r) AS type, labels(b) AS target
ORDER BY type
Recorded result
sourcetypetarget
["Person"]"KNOWS"["Person"]
["Person"]"MAINTAINS"["Project"]
["Person"]"WORKS_ON"["Project"]

3 rows

On a large store the pattern statement reads every relationship; keep its result with the prompt and refresh it when the schema changes. db.schema.visualization() returns the same labels and types as one row, for a tool that draws them.

Read the schema over MCP

An agent connected to relate's own MCP server has the same facts in one call. The get_schema tool returns each label with its count, its properties and types, and its outgoing relationships with the labels they reach. This sends initialize and a get_schema call to a read-only stdio server on team.rl; 2>/dev/null drops relate's startup lines:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"shell","version":"0"}}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_schema","arguments":{}}}' \
  | relate serve --mcp stdio --database ./team.rl --read-only 2>/dev/null
{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"relate-mcp","version":"0.1.2"}}}
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\"Person\":{\"count\":3,\"properties\":{\"name\":{\"type\":\"String\"},\"role\":{\"type\":\"String\"}},\"relationships\":{\"KNOWS\":{\"direction\":\"out\",\"labels\":[\"Person\"]},\"MAINTAINS\":{\"direction\":\"out\",\"labels\":[\"Project\"]},\"WORKS_ON\":{\"direction\":\"out\",\"labels\":[\"Project\"],\"properties\":{\"since\":{\"type\":\"Integer\"}}}},\"type\":\"node\"},\"Project\":{\"count\":2,\"properties\":{\"language\":{\"type\":\"String\"},\"name\":{\"type\":\"String\"}},\"type\":\"node\"}}","type":"text"}],"isError":false}}

get_schema takes an optional sample_size, the number of nodes and relationships it examines per label; -1 examines all of them. The MCP server reference lists both tools a read-only server offers, and Connect over MCP has the host configuration.

Add the ontology as context

The inferred schema says what the data holds today, not what each label means. An ontology An Ontology Graph Spec document stored with a store, managed by the db.ontology.* procedures behind the ontology preview, and appliable as the graph type. Read more in the glossary stored with the store adds that: descriptions of the node and relationship types in words a model can use, and the properties each type must have. CALL db.ontology.get() returns the document as YAML or JSON, ready to paste into the prompt. The ontology procedures are a preview feature that runs only with RELATE_PREVIEW=ontology set; Use an ontology covers storing and reading one.

Tell the model what differs from Neo4j

Most Cypher a model has seen was written for Neo4j, and relate rejects a few of those habits: APOC and Graph Data Science procedures, CREATE DATABASE and USE with a database name, colon commands such as :param, index procedures, and a SEARCH score read without SCORE AS. Differences from Neo4j lists each one with the error relate returns and the form to write instead. Put that table in the prompt, or let the checks catch the habit and hand the model the row it broke.

A Neo4j-only procedure fails with a message that names it:

Cypher
CALL apoc.meta.schema()
Expected error
error: syntax error: ProcedureNotFound: apoc.meta.schema

Check each statement with EXPLAIN

EXPLAIN plans a statement without running it: writes, procedures and LOAD CSV are only planned. The plan comes back with the planner's notifications, and they are the repair signal. A statement that names a label or relationship type the store does not have still plans, and returns no rows when it runs, so without EXPLAIN a wrong guess looks like an empty answer. Here a model guessed WORKS_AT and Company:

Cypher
EXPLAIN MATCH (p:Person)-[:WORKS_AT]->(c:Company)
RETURN c.name AS company, count(p) AS people
Recorded result
(no rows)

The cell returns no rows; its JSON view holds the envelope with the notifications. In a terminal, --mode envelope prints them on one line:

relate --database ./team.rl --read-only --mode envelope "EXPLAIN MATCH (p:Person)-[:WORKS_AT]->(c:Company) RETURN c.name AS company, count(p) AS people" | jq -c '.notifications[] | {gql_status, description}'
{"gql_status":"01N50","description":"The label `Company` does not exist in this database."}
{"gql_status":"01N51","description":"The relationship type `WORKS_AT` does not exist in this database."}

Each notification also carries severity, title and a remedy. 01N50, 01N51 and 01N52 (an unknown property A named value stored on a node or relationship, such as name: 'Ada'; values are scalars, temporal and spatial values, vectors, or lists of them. Read more in the glossary key) are the ones a generated statement trips most; a statement that is valid for this schema plans with no notifications, or only with performance advice such as 03C91 (a missing index). The errors reference lists every notification. A syntax error or a Neo4j-only procedure fails the EXPLAIN itself: exit code 6, with the error object.

Run the repair loop

This program puts the steps together with the Python client. It writes team.rl, opens it read_only=True, builds the schema text a prompt carries, and checks two statements as a model might return them: a first guess and its repair. Nothing in it calls a model:

text_to_cypher.py
import relate

TEAM = """
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
       (grace:Person {name: 'Grace', role: 'engineer'}),
       (noor:Person {name: 'Noor', role: 'maintainer'}),
       (relate:Project {name: 'relate', language: 'Rust'}),
       (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
       (ada)-[:WORKS_ON {since: 2024}]->(relate),
       (grace)-[:WORKS_ON {since: 2023}]->(relate),
       (grace)-[:WORKS_ON {since: 2022}]->(tck),
       (noor)-[:MAINTAINS]->(relate),
       (ada)-[:KNOWS]->(grace)
"""

writer = relate.connect("./team.rl")
writer.execute(TEAM)
writer.close()


def schema_text(conn):
    """The labels, properties and relationship patterns, one line each."""
    props = {}
    for row in conn.execute(
        "CALL db.schema.nodeTypeProperties() YIELD nodeType, propertyName, propertyTypes "
        "RETURN nodeType, propertyName, propertyTypes ORDER BY nodeType, propertyName"
    ).data():
        props.setdefault(row["nodeType"], []).append(f"{row['propertyName']}: {row['propertyTypes'][0]}")
    lines = [f"({label} {{{', '.join(p)}}})" for label, p in props.items()]
    for row in conn.execute(
        "MATCH (a)-[r]->(b) RETURN DISTINCT labels(a)[0] AS source, type(r) AS type, "
        "labels(b)[0] AS target ORDER BY type"
    ).data():
        lines.append(f"(:{row['source']})-[:{row['type']}]->(:{row['target']})")
    return "\n".join(lines)


def check(conn, statement):
    """Plan the statement; return the problems a model can repair, or []."""
    try:
        summary = conn.execute("EXPLAIN " + statement).consume()
    except relate.Error as e:
        return [f"{e.gql_status}: {e}"]
    return [f"{n['gql_status']}: {n['description']}" for n in summary.notifications if n["severity"] == "WARNING"]


conn = relate.connect("./team.rl", read_only=True)
print(schema_text(conn))

first_guess = "MATCH (p:Person)-[:WORKS_AT]->(c:Company) RETURN c.name AS company, count(p) AS people"
repaired = "MATCH (p:Person)-[:WORKS_ON]->(proj:Project) RETURN proj.name AS project, count(p) AS people ORDER BY people DESC"

for statement in (first_guess, repaired):
    problems = check(conn, statement)
    print("problems:" if problems else "no problems", *problems, sep="\n  ")

print(conn.execute(repaired).data())
conn.close()
(:Person {name: STRING NOT NULL, role: STRING NOT NULL})
(:Project {language: STRING NOT NULL, name: STRING NOT NULL})
(:Person)-[:KNOWS]->(:Person)
(:Person)-[:MAINTAINS]->(:Project)
(:Person)-[:WORKS_ON]->(:Project)
problems:
  01N50: The label `Company` does not exist in this database.
  01N51: The relationship type `WORKS_AT` does not exist in this database.
no problems
[{'project': 'relate', 'people': 2}, {'project': 'opencypher-tck', 'people': 1}]

The loop around it sends the question, the schema text and the Differences table to the model, checks what comes back, and sends the problems back until there are none or a retry budget runs out. ask_model stands for your model client:

loop.py
def answer(conn, question, ask_model, retries=3):
    prompt = f"Schema:\n{schema_text(conn)}\n\nQuestion: {question}\nReply with one Cypher statement."
    statement = ask_model(prompt)
    for _ in range(retries):
        problems = check(conn, statement)
        if not problems:
            return conn.execute(statement).data()
        statement = ask_model(f"{prompt}\n\nThis statement:\n{statement}\nhas these problems:\n" + "\n".join(problems))
    raise RuntimeError(f"no valid statement after {retries} repairs: {problems}")

The store is open read-only, so a generated write fails with WriteOnReadOnlyAccessDatabase instead of changing the graph. To bound a statement that plans cleanly but runs long, run it from the CLI with --timeout.

Run the checked statement

Once the check passes, run the statement against the store, read-only. A person reads the table; an agent reads the envelope and the exit code, and sends a non-zero exit back into the loop with the error object's message:

At a terminal

relate --database ./team.rl --read-only "MATCH (p:Person)-[:WORKS_ON]->(proj:Project) RETURN proj.name AS project, count(p) AS people ORDER BY people DESC"

From an agent

Exit code 0 means the rows in the envelope are the answer; 6 means the statement was rejected, and the error object on stdout says why; 7 means it ran past --timeout:

relate --database ./team.rl --read-only --mode envelope --timeout 10s "MATCH (p:Person)-[:WORKS_ON]->(proj:Project) RETURN proj.name AS project, count(p) AS people ORDER BY people DESC"

Verify

The repaired statement returns one row per project with its head count, and planning it reports no unknown names:

Cypher
MATCH (p:Person)-[:WORKS_ON]->(proj:Project)
RETURN proj.name AS project, count(p) AS people
ORDER BY people DESC
Recorded result
projectpeople
"relate"2
"opencypher-tck"1

2 rows

relate --database ./team.rl --read-only --mode envelope "EXPLAIN MATCH (p:Person)-[:WORKS_ON]->(proj:Project) RETURN proj.name AS project, count(p) AS people ORDER BY people DESC" | jq -c '.notifications'
[]

If it goes wrong

What you seeCauseRemedy
No rows, and EXPLAIN reports 01N50, 01N51 or 01N52The statement names a label, relationship type or property key the store does not haveSend the notification back to the model with the schema text
syntax error: ProcedureNotFound: <name>, exit code 6A procedure from a Neo4j library, such as APOCThe Write instead column of Differences from Neo4j
semantic error: DatabaseNotFound: Graph not found: <name>, exit code 6A USE clause with a database name; a store has only the fixed namespacesDrop the USE clause; Namespaces explains the three names
semantic error: WriteOnReadOnlyAccessDatabase: …, exit code 6, or Only MATCH queries are allowed for read-query from MCPThe model wrote a write: the CLI store is open read-only, as configured, and MCP's read_cypher refuses writes on any serverKeep it read-only for questions; a write goes through a separate, reviewed path
semantic error: SemanticError: preview feature 'ontology' is not enabled: set RELATE_PREVIEW=ontologydb.ontology.get() without the preview opt-inSet RELATE_PREVIEW=ontology in the environment of the process that opens the store