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 (ef_search, PKCE).

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:

docsassistant/graph/retrievers.py — vector search with graph expansion and an ACL filter (Python, 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

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 on aliases.

docsassistant/graph/text2cypher.py — a schema-injected, few-shot, read-only text-to-Cypher retriever (Python, 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:

  1. 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 the LOAD privilege (which LOAD CSV needs to read files and URLs) and must not be able to execute procedures beyond the few the queries need, such as db.index.fulltext.queryNodes — check the PUBLIC role’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.

  2. Read routing and read transactions: run the query with RoutingControl.READ (Python) or executeRead (Java), so a cluster sends it to a reader and the transaction cannot write.

  3. An EXPLAIN check: plan the query without running it and refuse it unless the plan’s query type is read-only. neo4j-graphrag’s `Text2CypherRetriever does exactly this in 1.21.0: it runs EXPLAIN with read routing, raises Text2CypherRetrievalError if the summary’s query_type is not "r", and only then executes the query, also with read routing.

  4. A clause check: the query type does not catch reads outside the graph. LOAD CSV and read procedures such as apoc.load.json plan as read-only, so refuse any LOAD and any CALL of a procedure outside an allow-list before the EXPLAIN. In text2cypher.py above, ClauseCheckedLLM runs check_clauses on the generated query before the retriever plans or runs it; the Java retriever below calls checkClauses.

  5. Limits: a transaction timeout, and a LIMIT on list queries, so an accidental Cartesian product cannot take the database down.

  6. 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:

docsassistant/graph/cypher_qa.py — GraphCypherQAChain with guard rails (Python, 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:

GraphRetrievers.java — a text-to-Cypher content retriever (Java, LangChain4j community 1.20.0-beta30)
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:

Text2CypherRetriever.java — generate, check with EXPLAIN, run read-only (Java, Spring AI 2.0.1, Neo4j Java driver 6.3.0)
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);
            }
        }
    }
}

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.