Skip to content

GraphRAG with Neo4j

In a graph, data is linked: each incident with its customer, its machine and the technician who handles it. A text-chunk RAG sees loose pieces; a graph sees the relationships. That is why it answers questions that cross data or count, such as “how many open incidents does Lucía have?” or “which customer has had the most faults?”.

In NappAI everything is done with the Neo4j GraphRAG component: storing the data in the graph and answering from it. It connects to your Neo4j database with a Neo4j API credential (address, user and password; see Credentials).

When a graph beats chunks

We measured it on 17 and 18 October 2026 with six relationship and counting questions about a repair service’s incidents (24 incidents, 8 customers, 4 technicians, 4 machine models). An answer only counted as right if it was exact.

How it searchedRight answers
Chunks of the monthly reports, basic search0 of 6
Chunks with hybrid search and Local Rerank (10 results)2 of 6
The three whole reports inside the model’s instructions3 of 6
Neo4j GraphRAG, Vector + Graph operation (10 results)3 of 6
Neo4j GraphRAG, Text2Cypher operation without examples5 of 6
Neo4j GraphRAG, Text2Cypher operation with 2 examples5 of 6

What it shows:

  • Chunks mix states. An incident open in July and closed in August appears in two reports, and the model counts it as open or refuses to answer.
  • Following the graph helps cross data, for example “this customer’s open incidents and who handles them”, but it only sees the results it finds, so it does not count well.
  • Only querying the graph (Text2Cypher) counts, ranks and filters over all the data. What helps it most is knowing the graph’s real values (that the status is written abierta, that the dates are from 2026), and the component shows them to it: without those values, and without examples, it stayed at 2 of 6.
  • The question that fails in every case is “which technicians have repaired Vellani machines?”: the query also returns a technician whose only Vellani incident is still open. Read as “have worked on”, it would be right.
  • Putting all documents in the instructions does not help counting either: the model got both counts wrong.

These are only six questions on test data: they show the difference between techniques, they do not guarantee the result with yours.

See it working

The video stores a coffee machine repair service’s history in Neo4j both ways, then builds an agent that queries it without writing Cypher: 24 incidents, 8 bars, 4 technicians, 4 machines and 72 relationships.

What you see in the video

  1. Storing records (flow “Neo4j GraphRAG · store records”): Storage Files reads incidents.json with Content set to Text, and Neo4j GraphRAG in the Add Records operation stores each incident with Node Label Incident, Key Property code and three lines in Relationships that link it to its bar, its technician and its machine. Run it: 24 incidents, 72 relationships and both search indexes created automatically, in under a second.
  2. Graph Schema shows what the graph holds now: the labels with how many nodes each has, their properties with sample values, the relationships and the indexes.
  3. Storing documents (flow “Neo4j GraphRAG · store documents”): Storage Search finds the three monthly PDF reports, Storage Files reads them in batch, and Neo4j GraphRAG in the Build Graph operation extracts the entities with DeepSeek, guided by Allowed Patterns, Node Types and Extraction Instructions. Result: 40 nodes and 72 relationships from the text.
  4. The agent (flow “Neo4j GraphRAG · agent”) has three Neo4j GraphRAG tools: “A technician’s incidents” in Cypher Query, with a fixed query whose $technician and $status the agent fills in; “Similar cases” in Hybrid + Graph with the Retrieval Query left empty, which brings just each result’s neighbours; and “Query the graph” in Text2Cypher, with two examples in Examples.
  5. “Which open incidents does Lucía Ortega have?”: the agent calls the fixed query with technician Lucía Ortega and status open, and answers with all three, each with its bar and machine.
  6. “An Aurora is not dosing and shows E07: has this happened before, and who fixed it?”: the search finds the two identical cases, fixed by Javier Ruiz and Rocío Benítez by replacing the VL-1180 flowmeter.
  7. “How many incidents has each brand had?”: Text2Cypher writes the counting query (Vellani 9, Jaltec 8, Molinar 7). You can see the query it wrote by opening the tool call.

How to store data in Neo4j

A graph answers as well as it is built. There are three ways to store data, depending on where it comes from. All three are done with Neo4j GraphRAG, without writing Cypher, and create the search indexes on their own.

Structured recordsGraph extracted from documentsChunks linked to entities
OperationAdd Records with RelationshipsBuild Graph with a schemaBuild Graph with Save Source Chunks
For data that…already has fields: tables, sheets, databases, other applicationsis in text: reports, work orders, emailsis long reference text: manuals, policies, FAQs
Uses an AI modelNoOne read per documentOne read per chunk
What we measured24 rows: 0.8 s, no model calls9 documents: 10 reads, 11 s80 chunks of 800 characters: 80 reads, 21 s
Graph qualityExact: 24 incidents, 8 customers, 4 technicians, 4 machines, 72 correct relationshipsThe same, with the full schema: 72 correct relationships and each incident’s final statusThe same entities and, in addition, 80 chunks linked to the entities they mention
What it enablesText2Cypher, Cypher Query and exact ”+ Graph” searchesThe same, from textSearching passages like a vector store and reaching the entities from them

1. Structured records: Add Records

If your data already has columns, this is the most exact and cheapest way: no model is involved. Each row becomes a node of type Node Label, identified by Key Property, and the columns you name in Relationships become linked nodes:

customer -> FOR_CUSTOMER -> Customer.name
technician -> ASSIGNED_TO -> Technician.name
model -> ON_MACHINE -> Machine.model (brand)

Each line says: the value of the customer column is the name of a Customer node, and the incident is joined to it with FOR_CUSTOMER. If two rows have the same customer, they share the node. The columns in brackets (brand) are stored on the linked node.

With the 24 test incidents, the resulting graph was identical to the one we built by hand: the same nodes and the same 72 relationships, not one more or one less. Loading again duplicates nothing, and if a row changes technician, the old relationship is replaced. If you need something that does not fit in those three lines, such as storing the date as a date, Write Query lets you write the full query (see the example).

2. Graph extracted from documents: Build Graph with a schema

When the information is in text, an AI model reads it and extracts the entities and relationships. The key is to give it a schema. We measured it with the 9 documents of the knowledge base (3 manuals, a warranty policy, a guide, an FAQ and 3 monthly incident reports), with DeepSeek:

SchemaWhat ended up in the graph
None252 nodes of 48 different types for 4 real ones (“Incident” and “Incidencia”, “Client” and “Cliente”…), 58 relationship types and one incident split in two. The model wrote more than 4 times as much text and took 34 s.
Node Types and Relationship Types (or only Allowed Patterns)24 incidents, 8 customers and 4 technicians, with the 72 correct relationships. But 14 machines: the model took spare parts from the manuals for machines and wrote the same model with and without its brand.
Plus each type’s properties in Node TypesThe spare parts disappear: 7 machines, still with the model written with and without its brand.
Plus one line in Extraction Instructions4 machines. The graph matches the one from the records: 24, 8, 4 and 4 nodes and 72 correct relationships.
Plus the status values in Node TypesEach incident keeps its final status even if it was closed a month later, and a list of “pending” items does not turn open into pending.

The schema that gave the best result (the knowledge base was in Spanish; here with English names):

  • Allowed Patterns:

    (Incident)-[:FOR_CUSTOMER]->(Customer)
    (Incident)-[:ASSIGNED_TO]->(Technician)
    (Incident)-[:ON_MACHINE]->(Machine)
  • Node Types: Incident(code, date, status: open | closed, error_code), Customer(name), Technician(name), Machine(model, brand). The first property of each type is the one that identifies it: two mentions with the same code are the same incident.

  • Extraction Instructions: “A Machine model is written without its brand (Brío 2G, Aurora 3G, M64 OD); the brand goes in brand.”

With it, Text2Cypher answered 5 of the 6 questions right, the same as with the records graph.

3. Chunks linked to entities: Save Source Chunks

With Save Source Chunks, besides the entities, each incoming text is stored as a chunk linked to the entities it mentions and to the next chunk of the same document. Connect a text splitter first, such as Recursive Character Text Splitter, so each chunk is a short passage.

With the 9 documents split into 80 chunks:

  • Searching the chunks (write chunk_vector in Vector Index and chunk_text in Full-text Index) worked like a vector store with the same chunks: in 30 reference questions (how something is done, what a part is, what a policy or a report says), the passage with the answer was among the first 5 results in 83 % of the cases, and was the first in 63 % with Hybrid.
  • With Hybrid + Graph, each chunk comes with the incidents, customers and machines it mentions.
  • It costs one model read per chunk (80 versus 10) and the model sees less context in each one: one extra incident appeared, without a code.

Which one to choose

  • Data with fields: Add Records. Whenever you can get the data as rows, it is the most exact.
  • Data in documents: Build Graph with the full schema. Without a schema, the graph is good for exploring, not for counting.
  • Reference texts (how to do something, what a policy says): a vector store or Save Source Chunks if you also want to reach the entities from the passages.
  • They can be combined in the same graph: the records with Add Records and the manuals with Save Source Chunks.

The operations of Neo4j GraphRAG

OperationWhat it doesExample question
Add RecordsStores rows as nodes and relationships.—
Build GraphExtracts entities and relationships from documents.—
VectorSearches by meaning.”Dripping faults”
HybridSearches by meaning and by words.”Incidents with the group gasket”
Vector + Graph / Hybrid + GraphThe same, with the nodes related to each result.”An espresso machine is leaking from underneath”
Text2CypherThe model writes the query that answers.”How many open incidents does Anna Ross have?”
Cypher QueryRuns your query with the values it is given.A technician’s incidents in a given status
Graph SchemaDescribes what the graph contains.—

Searching without knowing Cypher

You don’t need to write queries to make the most of the graph:

  • Vector + Graph and Hybrid + Graph with an empty Retrieval Query: each result comes with the nodes it is connected to, in easy-to-read lines:

    Incident INC-2608-07 (date: 2026-08-26, status: open, error_code: E01): The machine showed E01 due to lack of water from the mains…
    ON_MACHINE -> Machine Aurora 3G (brand: Vellani)
    FOR_CUSTOMER -> Customer Bar El Postigo
    ASSIGNED_TO -> Technician Manuel Campos
  • Graph Schema: describes the graph with the node types and how many there are, their properties with example values (status: one of 'closed', 'open', date: from '2026-07-03' to '2026-09-29'), the relationships and the indexes. It is the first thing to look at before writing examples or queries.

  • Text2Cypher: the model writes the query. If it fails or finds nothing, it reviews it once.

  • Cypher Query: someone who knows Cypher writes the query once, with gaps like $technician, and from then on anyone uses it: the node shows one field per gap and, as a tool, the agent fills them in.

You don’t need the index names either: storing creates them as incident_vector and incident_text, and searching with the fields empty uses the only index of each kind there is.

Search indexes

Add Records and Build Graph create the indexes when storing, if they don’t exist:

  1. With a model in Embedding, the search-by-meaning index (Vector Index, by default <type>_vector).
  2. The search-by-words index (Full-text Index, by default <type>_text), in the language of Full-text Analyzer. In Add Records it covers the text and the short columns, such as the code or the status, so a question about “INC-2608-07” finds its incident.
  3. If an index with that name already exists for other data or with vectors of another size (another embeddings model), the component says so instead of mixing them.

If your graph already existed in Neo4j and you prefer to create the indexes by hand from Neo4j Browser or Neo4j Aura, these are the equivalent queries for Incident nodes:

CREATE VECTOR INDEX incident_vector IF NOT EXISTS
FOR (n:Incident) ON n.embedding
OPTIONS {indexConfig: {`vector.dimensions`: 768, `vector.similarity_function`: 'cosine'}}
CREATE FULLTEXT INDEX incident_text IF NOT EXISTS
FOR (n:Incident) ON EACH [n.text]
OPTIONS {indexConfig: {`fulltext.analyzer`: 'english'}}

Retrieval Query recipes

In the ”+ Graph” operations, Retrieval Query is optional: empty, each result brings its related nodes. Write it when you want to choose what to bring. It runs on each result: node is the node found and score its score.

Bring only some related nodes (the video’s). OPTIONAL MATCH keeps the result even if a relationship is missing:

OPTIONAL MATCH (node)-[:FOR_CUSTOMER]->(c:Customer)
OPTIONAL MATCH (node)-[:ASSIGNED_TO]->(t:Technician)
OPTIONAL MATCH (node)-[:ON_MACHINE]->(m:Machine)
RETURN node.text AS text, node.code AS code, node.status AS status,
toString(node.date) AS date, c.name AS customer, t.name AS technician,
m.model AS machine, score

Filter by status, for example to keep only closed incidents, the ones that have a fix. The filter applies to the results found, so raise Number of Results if you filter a lot:

WITH node, score WHERE node.status = 'closed'
OPTIONAL MATCH (node)-[:ASSIGNED_TO]->(t:Technician)
RETURN node.text AS text, node.code AS code, t.name AS technician, score

Return readable text, so the agent reads it as a sentence:

OPTIONAL MATCH (node)-[:FOR_CUSTOMER]->(c:Customer)
OPTIONAL MATCH (node)-[:ASSIGNED_TO]->(t:Technician)
RETURN 'Incident ' + node.code + ' (' + node.status + '), customer ' + coalesce(c.name, '-')
+ ', technician ' + coalesce(t.name, '-') + ': ' + node.text AS text, score

Cypher for agents

An agent queries the graph with the tools you give it. This combination covers almost everything:

ToolOperationWhat the agent uses it for
query_technical_serviceText2CypherSpecific questions: who, how many, which ones, in what status.
find_similar_faultsHybrid + GraphFinding cases similar to a description, with their context.
graph_schemaGraph SchemaKnowing what is in the graph before asking, or when a query finds nothing.
technician_incidentsCypher QueryA frequent question with a fixed, exact query: the agent only writes the technician and the status.

Examples recipes for Text2Cypher

In Examples you write typical questions with the query that answers them, with your graph’s real names (you see them in Graph Schema). The model takes them as a pattern for new questions. Cover the patterns people ask most:

Question: How many closed incidents does Rocío Benítez have?
Cypher: MATCH (i:Incident)-[:ASSIGNED_TO]->(t:Technician {name: 'Rocío Benítez'}) WHERE i.status = 'closed' RETURN count(i) AS total
Question: Which customers had incidents on Jaltec machines in August?
Cypher: MATCH (i:Incident)-[:ON_MACHINE]->(m:Machine {brand: 'Jaltec'}), (i)-[:FOR_CUSTOMER]->(c:Customer) WHERE i.date STARTS WITH '2026-08' RETURN DISTINCT c.name AS customer
Question: Which incidents has Bar La Giralda's Brío 2G had, and who handles them?
Cypher: MATCH (i:Incident)-[:FOR_CUSTOMER]->(c:Customer {name: 'Bar La Giralda'}), (i)-[:ON_MACHINE]->(m:Machine {model: 'Brío 2G'}) OPTIONAL MATCH (i)-[:ASSIGNED_TO]->(t:Technician) RETURN i.code AS code, i.status AS status, t.name AS technician
Question: Which technician has the most open incidents?
Cypher: MATCH (i:Incident {status: 'open'})-[:ASSIGNED_TO]->(t:Technician) RETURN t.name AS technician, count(i) AS open ORDER BY open DESC LIMIT 1

Tips for your examples:

  • Two to four are enough: counting with a filter, going through two relationships, filtering by dates and “the one with the most”.
  • Use the values as they are in the graph: 'open', not 'Open'.
  • If you store dates as dates (with Write Query and date(...)), filter with i.date >= date('2026-08-01'); if they are text, with STARTS WITH '2026-08'.
  • Return named columns (AS total, AS customer): the agent reads those names.
  • Each Text2Cypher answer carries the query used. If one comes out wrong, add an example with that pattern.

Tool names and descriptions

In each component’s Tool tab, the description must say what the graph contains and what questions it answers. It is what the agent reads to decide:

ComponentTool NameTool Description
Text2Cypherquery_technical_serviceAnswers specific questions about the technical service’s incidents (code, date, open or closed status, error code), their customers, technicians and machines: how many, whose, which ones, in what status.
Hybrid + Graphfind_similar_faultsFinds incidents similar to a described fault and returns how they were fixed, the customer, the technician and the machine.
Graph Schemagraph_schemaDescribes what kinds of data, relationships and values the technical service graph has.
Cypher Querytechnician_incidentsLists a technician’s incidents in a given status (open or closed), with their customer.

Agent instructions

In the agent’s System Prompt, say when to use each tool and what to do if they find nothing:

Always answer with the technical service's data, using your tools:
- query_technical_service, for specific questions about incidents, customers,
technicians and machines: who, how many, which ones, in what status.
- find_similar_faults, to find faults similar to the one described,
how they were fixed and who handled them.
- If a query returns nothing, check graph_schema to see how names and values
are written, and try again.
Do not make anything up: if the tools do not bring it, say so.

The same agent can also have a document search, as in the technical assistant.

Safety

Text2Cypher, Cypher Query and Graph Schema always run read-only: if a query tried to create, change or delete data, Neo4j refuses it and nothing is touched. Only the nodes you configure in Add Records or Build Graph write. Even so, for an agent that serves customers, use a Neo4j user that can only read in the credential.

Tips

  • The same vector model when storing (Add Records / Build Graph) and searching (Vector / Hybrid).
  • Number of Results (advanced): ask for 5 or more in searches. The component always asks the index for at least 50 candidates and keeps the best, so you lose no precision by asking for few. In Text2Cypher and Cypher Query it limits the rows the agent gets (up to 20 per result).
  • The full-text index analyzer must be your language’s (english, spanish…). With the standard one, keyword searches in other languages find less.
  • Symbols in the question (slashes, colons, quotes) are not a problem: the component treats them as plain text in the keyword search, and a code such as INC-2608-07 is searched as a whole.