Text2Cypher & Graph Retrievers
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-09-30 against Neo4j 2026.09.0, neo4j-graphrag 1.21.0, the neo4j Python driver 6.3.1 and
langchain-neo4j 0.10.0 on Python 3.12, Spring AI 2.0.1 with the Neo4j Java driver 6.3.0, and LangChain4j 1.20.2 with
langchain4j-community-neo4j-retriever 1.20.0-beta30 (see the version
baseline). The code was checked against the released packages (imports resolved, Java compiled); the read-only check was
read from the released neo4j-graphrag source; nothing was run against a Neo4j database or a model.
GraphRAG & Knowledge Graph Construction built
DocsAssistant’s graph: Document and Chunk nodes with embeddings, Entity nodes (Component, Concept, API,
Tool) linked to their chunks by FROM_CHUNK, and relationships such as DEPENDS_ON. This page queries it. There are
three families of graph retriever, and the router of
Query Understanding & Routing chooses among them:
| Retriever | How it works | Good for |
|---|---|---|
Vector + Cypher |
Vector (or hybrid) search finds chunks; a fixed, reviewed Cypher fragment expands from each match. |
Most questions: text answers enriched with related entities. Safe, because the Cypher is yours. |
Hybrid |
Vector search plus a full-text index, scores combined. |
Names, codes and exact terms that embeddings blur ( |
Text-to-Cypher |
A model writes a Cypher query from the question and the schema; the query runs read-only. |
Counts, lists, paths and filters over entities ("which components depend on sorting?"). |
Vector search plus graph expansion: VectorCypherRetriever
VectorCypherRetriever runs a vector search on an index and appends a retrieval query to it, in which node is the
matched node and score its similarity. DocsAssistant’s retrieval query returns the chunk text, the related facts of
the entities the chunk mentions, and the ACL of the chunk’s document, so that forbidden chunks can be dropped:
neo4j-graphrag 1.21.0)import os
import neo4j
from neo4j_graphrag.embeddings import OllamaEmbeddings
from neo4j_graphrag.generation import GraphRAG
from neo4j_graphrag.llm import OllamaLLM
from neo4j_graphrag.retrievers import VectorCypherRetriever
driver = neo4j.GraphDatabase.driver(os.environ["NEO4J_URI"],
auth=(os.environ["NEO4J_USER"], os.environ["NEO4J_PASSWORD"]))
RETRIEVAL_QUERY = """
MATCH (node)-[:FROM_DOCUMENT]->(d:Document)
WHERE node.deletedAt IS NULL AND d.acl IN $readable_acls
OPTIONAL MATCH (e:__Entity__)-[:FROM_CHUNK]->(node)
OPTIONAL MATCH (e)-[r:DEPENDS_ON|EXPLAINS|IMPLEMENTS|USES]-(n:__Entity__)
WITH node, d, score, collect(DISTINCT e.name + ' ' + type(r) + ' ' + n.name)[..10] AS facts
RETURN node.text AS text, d.source AS source, node.id AS chunk_id, facts, score
ORDER BY score DESC
"""
retriever = VectorCypherRetriever(
driver,
index_name="chunk_embeddings",
retrieval_query=RETRIEVAL_QUERY,
embedder=OllamaEmbeddings(model=os.environ["EMBEDDING_MODEL"]),
)
def search(question: str, readable_acls: list[str], top_k: int = 8):
# query_params are passed to the Cypher; the ACL list comes from the verified caller, never from the question
return retriever.search(query_text=question, top_k=top_k, effective_search_ratio=4,
query_params={"readable_acls": readable_acls})
rag = GraphRAG(retriever=retriever, llm=OllamaLLM(model_name=os.environ["CHAT_MODEL"]))
Source: neo4j-graphrag — User Guide: RAG
(VectorCypherRetriever, GraphRAG).
The ACL filter here runs after the vector search chose its candidates, so a caller with few readable documents may
get fewer than results. effective_search_ratio=4 widens the candidate pool to four times top_k before the
retrieval query filters; when the ACL is a property of the chunk node itself, the filters argument pre-filters inside
the vector search instead. When the ACL domains are few, one vector index per domain or Neo4j’s filtered vector search in
the SEARCH clause also works (see
the Cypher SEARCH clause).
HybridRetriever and HybridCypherRetriever take the same arguments plus a fulltext_index_name, and combine the
vector and full-text scores before the retrieval query runs.
Text-to-Cypher
For "which components depend on the sorting library?" no passage contains the answer; the graph does. A model writes the query:
MATCH (c:Component)-[:DEPENDS_ON]->(:Component {name: 'Irurueta Sorting'})
RETURN c.name AS component ORDER BY component
Source: Cypher Manual — MATCH.
Three inputs decide whether the model gets it right:
-
The schema. Node labels with their properties, relationship types with their direction, and the patterns that exist.
neo4j_graphrag.schema.get_schema(driver)produces a text rendering; for a large graph, inject a curated schema that lists only the labels and relationships questions need, with one-line descriptions — fewer tokens and fewer wrong turns. -
Few-shot examples. Five to fifteen question/Cypher pairs covering the common shapes (count, list, path, aggregation, negation) fix most errors of direction and naming. Keep them in version control and test them.
-
Property values. Models guess names (
'Sorting'versus'Irurueta Sorting'). Resolve entity names first with a full-text lookup, and pass the canonical names in the prompt, or instruct the model to match onaliases.
neo4j-graphrag 1.21.0)import json
import os
import re
import neo4j
from neo4j_graphrag.exceptions import Text2CypherRetrievalError
from neo4j_graphrag.llm import OllamaLLM
from neo4j_graphrag.retrievers import Text2CypherRetriever
from neo4j_graphrag.retrievers.text2cypher import extract_cypher
from neo4j_graphrag.types import RetrieverResultItem
# a separate driver as the read-only graph user, not the ingestion (writer) driver of retrievers.py
readonly_driver = neo4j.GraphDatabase.driver(
os.environ["NEO4J_URI"], auth=(os.environ["NEO4J_READONLY_USER"], os.environ["NEO4J_READONLY_PASSWORD"]))
ALLOWED_PROCEDURES = {"db.index.fulltext.querynodes"} # entity-name lookup; lower case
CALL = re.compile(r"\bCALL\b\s*([{(]|[\w.`]+)?", re.IGNORECASE)
def check_clauses(cypher: str) -> None:
"""EXPLAIN reports LOAD CSV and read procedures such as apoc.load.* as read-only, so refuse them by name."""
if re.search(r"\bLOAD\b", cypher, re.IGNORECASE):
raise Text2CypherRetrievalError(f"Refusing LOAD: {cypher}")
for m in CALL.finditer(cypher):
target = m.group(1)
if target in ("{", "("):
continue # a CALL { ... } subquery
if target is None or target.replace("`", "").lower() not in ALLOWED_PROCEDURES:
raise Text2CypherRetrievalError(f"Refusing procedure call: {cypher}")
class ClauseCheckedLLM(OllamaLLM):
"""Checks the generated Cypher before the retriever runs EXPLAIN and the query."""
def invoke(self, input, *args, **kwargs):
response = super().invoke(input, *args, **kwargs)
check_clauses(extract_cypher(response.content))
return response
SCHEMA = """
Node properties:
Component {name: STRING, aliases: LIST<STRING>}
Concept {name: STRING}
API {name: STRING, kind: STRING}
Document {source: STRING, acl: STRING, updatedAt: DATE_TIME}
Relationships:
(:Component)-[:DEPENDS_ON]->(:Component)
(:Component)-[:EXPLAINS]->(:Concept)
(:API)-[:IMPLEMENTS]->(:Concept)
(:Component)-[:USES]->(:Tool)
"""
EXAMPLES = [
"USER INPUT: 'Which components depend on sorting?' QUERY: MATCH (c:Component)-[:DEPENDS_ON]->(s:Component) "
"WHERE s.name = 'Irurueta Sorting' OR 'sorting' IN s.aliases RETURN c.name",
"USER INPUT: 'How many concepts does the vector-rag section explain?' QUERY: MATCH (:Component {name: "
"'Vector Databases & RAG'})-[:EXPLAINS]->(k:Concept) RETURN count(k) AS concepts",
]
text2cypher = Text2CypherRetriever(
driver=readonly_driver,
llm=ClauseCheckedLLM(model_name=os.environ["CYPHER_MODEL"], model_params={"options": {"temperature": 0}}),
neo4j_schema=SCHEMA,
examples=EXAMPLES,
neo4j_database="docsgraph",
# keep each row as JSON instead of the default str(record)
result_formatter=lambda record: RetrieverResultItem(content=json.dumps(dict(record), default=str)),
)
def graph_answer_rows(question: str) -> tuple[list[dict], str]:
result = text2cypher.search(query_text=question)
rows = [json.loads(item.content) for item in result.items]
return rows, result.metadata.get("cypher", "") # the executed query, for citations and audit
Read-only execution and validation
A model-written query is untrusted code. As with text-to-SQL on Query Understanding & Routing, the defences are layered, and the database layer is the one that does not depend on parsing:
-
A read-only database user with access only to the graph database the assistant may read (
docsgraph), created with Neo4j’s role-based access control; see Neo4j Security and Administration. "Read-only" must also cover reads outside the graph: the role must not hold theLOADprivilege (whichLOAD CSVneeds to read files and URLs) and must not be able to execute procedures beyond the few the queries need, such asdb.index.fulltext.queryNodes— check thePUBLICrole’s grants too, since every user inherits them. The text-to-Cypher code connects with this user (NEO4J_READONLY_USER), never with the ingestion user. -
Read routing and read transactions: run the query with
RoutingControl.READ(Python) orexecuteRead(Java), so a cluster sends it to a reader and the transaction cannot write. -
An
EXPLAINcheck: plan the query without running it and refuse it unless the plan’s query type is read-only.neo4j-graphrag’s `Text2CypherRetrieverdoes exactly this in 1.21.0: it runsEXPLAINwith read routing, raisesText2CypherRetrievalErrorif the summary’squery_typeis not"r", and only then executes the query, also with read routing. -
A clause check: the query type does not catch reads outside the graph.
LOAD CSVand read procedures such asapoc.load.jsonplan as read-only, so refuse anyLOADand anyCALLof a procedure outside an allow-list before theEXPLAIN. Intext2cypher.pyabove,ClauseCheckedLLMrunscheck_clauseson the generated query before the retriever plans or runs it; the Java retriever below callscheckClauses. -
Limits: a transaction timeout, and a
LIMITon list queries, so an accidental Cartesian product cannot take the database down. -
ACLs: text-to-Cypher over entity nodes bypasses document ACLs unless the schema and the prompt make the model filter on them — which is not a security control. Either expose to text-to-Cypher only a projection of the graph without restricted content (a separate database or labels a read-only role can see), or restrict text-to-Cypher to callers who may read everything in the graph.
LangChain’s GraphCypherQAChain generates, runs and answers in one chain. It refuses to start unless
allow_dangerous_requests=True is passed, as an explicit acknowledgement that the model’s query runs against your
database; validate_cypher=True corrects relationship directions against the schema. Use it with the read-only user
and a curated schema:
langchain-neo4j 0.10.0)import os
from langchain.chat_models import init_chat_model
from langchain_neo4j import GraphCypherQAChain, Neo4jGraph
graph = Neo4jGraph(url=os.environ["NEO4J_URI"], username=os.environ["NEO4J_READONLY_USER"],
password=os.environ["NEO4J_READONLY_PASSWORD"], database="docsgraph",
timeout=5.0, enhanced_schema=False)
chain = GraphCypherQAChain.from_llm(
cypher_llm=init_chat_model(os.environ["CYPHER_MODEL"], temperature=0),
qa_llm=init_chat_model(os.environ["CHAT_MODEL"], temperature=0),
graph=graph,
include_types=["Component", "Concept", "API", "DEPENDS_ON", "EXPLAINS", "IMPLEMENTS"],
validate_cypher=True,
top_k=25,
return_intermediate_steps=True, # keep the generated Cypher for citations and audit
allow_dangerous_requests=True, # required: the model's query runs against the database
)
result = chain.invoke({"query": "Which components depend on the sorting library?"})
cypher = result["intermediate_steps"][0]["query"]
Return the generated Cypher with the answer (in the citations, with a "graph query" label), so users and reviewers can see how an aggregate answer was computed.
LangChain4j: Neo4jText2CypherRetriever
The LangChain4j community module ships a ContentRetriever that generates Cypher from the question and the graph
schema (read from the database by Neo4jGraph), runs it and returns the rows as content, with retries on syntax
errors and few-shot examples:
package com.example.docsassistant.l4j.graph;
import java.util.List;
import dev.langchain4j.community.rag.content.retriever.neo4j.Neo4jGraph;
import dev.langchain4j.community.rag.content.retriever.neo4j.Neo4jText2CypherRetriever;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import org.neo4j.driver.Driver;
public final class GraphRetrievers {
private GraphRetrievers() {
}
/** The driver must authenticate as the read-only graph user. */
public static ContentRetriever text2Cypher(Driver readOnlyDriver, ChatModel cypherModel) {
Neo4jGraph graph = Neo4jGraph.builder().driver(readOnlyDriver).build();
return Neo4jText2CypherRetriever.builder()
.graph(graph)
.chatModel(cypherModel)
.examples(List.of(
"Which components depend on sorting? -> MATCH (c:Component)-[:DEPENDS_ON]->(s:Component) "
+ "WHERE s.name = 'Irurueta Sorting' RETURN c.name",
"How many concepts does vector-rag explain? -> MATCH (:Component {name: 'Vector Databases & RAG'})"
+ "-[:EXPLAINS]->(k:Concept) RETURN count(k)"))
.maxRetries(2)
.build();
}
}
Source: LangChain4j — Neo4j (community text-to-Cypher retriever).
Use it as one target of the LanguageModelQueryRouter on
Query
Understanding & Routing. Because the retriever executes what the model writes, the read-only user is the essential
control here.
Spring AI: a text-to-Cypher retriever
Spring AI has no text-to-Cypher component. A DocumentRetriever that asks the model for Cypher, validates it with
EXPLAIN and runs it in a read transaction fits into the modular RAG advisor like any other retriever:
package com.example.docsassistant.rag.graph;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.neo4j.driver.Driver;
import org.neo4j.driver.SessionConfig;
import org.neo4j.driver.TransactionConfig;
import org.neo4j.driver.summary.QueryType;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.retrieval.search.DocumentRetriever;
public class Text2CypherRetriever implements DocumentRetriever {
record CypherQuery(String cypher) {
}
private static final Pattern LOAD = Pattern.compile("\\bLOAD\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern CALL = Pattern.compile("\\bCALL\\b\\s*([{(]|[\\w.`]+)?", Pattern.CASE_INSENSITIVE);
private static final Set<String> ALLOWED_PROCEDURES = Set.of("db.index.fulltext.querynodes"); // lower case
private final ChatClient chatClient;
private final Driver readOnlyDriver;
private final String schemaAndExamples;
public Text2CypherRetriever(ChatClient.Builder builder, Driver readOnlyDriver, String schemaAndExamples) {
this.chatClient = builder.build();
this.readOnlyDriver = readOnlyDriver;
this.schemaAndExamples = schemaAndExamples;
}
@Override
public List<Document> retrieve(Query query) {
CypherQuery generated = chatClient.prompt()
.system("Write one read-only Cypher query that answers the question, using only this schema. "
+ "Return at most 50 rows.\n" + schemaAndExamples)
.user(query.text())
.call()
.entity(CypherQuery.class);
if (generated == null || generated.cypher() == null) {
return List.of();
}
String cypher = generated.cypher();
checkClauses(cypher);
try (var session = readOnlyDriver.session(SessionConfig.forDatabase("docsgraph"))) {
QueryType type = session.executeRead(tx -> tx.run("EXPLAIN " + cypher).consume().queryType());
if (type != QueryType.READ_ONLY) {
throw new IllegalArgumentException("Refusing non-read-only Cypher: " + cypher);
}
List<Map<String, Object>> rows = session.executeRead(
tx -> tx.run(cypher).list(r -> r.asMap()),
TransactionConfig.builder().withTimeout(java.time.Duration.ofSeconds(5)).build());
String table = rows.stream().map(Map::toString).collect(Collectors.joining("\n"));
return List.of(Document.builder()
.text("Graph query result:\n" + table)
.metadata(Map.of("source", "knowledge-graph", "cypher", cypher))
.build());
}
}
/** EXPLAIN reports LOAD CSV and read procedures such as apoc.load.* as READ_ONLY, so refuse them by name. */
static void checkClauses(String cypher) {
if (LOAD.matcher(cypher).find()) {
throw new IllegalArgumentException("Refusing LOAD: " + cypher);
}
Matcher call = CALL.matcher(cypher);
while (call.find()) {
String target = call.group(1);
if ("{".equals(target) || "(".equals(target)) {
continue; // a CALL { ... } subquery
}
if (target == null
|| !ALLOWED_PROCEDURES.contains(target.replace("`", "").toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Refusing procedure call: " + cypher);
}
}
}
}
Source: Neo4j Java Driver Manual — Run your own transactions and Spring AI — Retrieval Augmented Generation.
The Java driver’s ResultSummary.queryType() classifies the planned query as READ_ONLY, READ_WRITE, WRITE_ONLY
or SCHEMA_WRITE; only READ_ONLY passes. That classification is not enough on its own: LOAD CSV and read
procedures such as apoc.load.json plan as read-only queries yet read files or URLs, so checkClauses refuses any
LOAD and any CALL of a procedure outside a short allow-list before the EXPLAIN. On the Spring AI side, Spring Data Neo4j or the driver’s
executableQuery API work equally well; what matters is the read transaction, the EXPLAIN check and the read-only
user.
The vector-Cypher and custom-Cypher retrievers, context-aware, filtered and multi-hop queries, and the LangChain4j and
Spring AI integrations with Neo4j follow the concepts of Anthapu and Agarwal, Building Neo4j-Powered Applications with
LLMs (Chapters 5, 6 and 9), paraphrased; the book predates the neo4j-graphrag text2cypher retrievers and the current
LangChain4j community retriever, which are documented here from their official documentation. See the
bibliography.