Generating Cypher Queries from Natural Language for Code-Graph-RAG: A Complete Implementation Guide
Code-Graph-RAG converts natural language questions about codebases into safe, bounded Cypher queries through an LLM-driven CypherGenerator class with multi-layer validation, as implemented in the vitali87/code-graph-rag repository.
The code-graph-rag project bridges natural language and graph databases by translating user questions into executable Cypher queries against a Memgraph knowledge graph. This article examines the complete pipeline—from prompt construction through safety validation—based on the actual source code implementation.
Architecture Overview
The system contains three tightly-coupled components responsible for the NL-to-Cypher transformation:
| Component | Role | Core Source |
|---|---|---|
| Multi-language parser | Extracts symbols (functions, classes, modules, imports) using Tree-sitter | cgr package |
| CypherGenerator | LLM-driven translator with sanitization and validation | codebase_rag/services/llm.py |
| RAG orchestrator | Combines semantic search, graph queries, and file reads | codebase_rag/services/llm.py (create_rag_orchestrator) |
The following sections trace how a natural language query flows through these components to become an executable Cypher statement.
Step 1: Prompt Construction with Embedded Schema
Every NL-to-Cypher translation begins with build_cypher_system_prompt in codebase_rag/prompts.py (lines 54-84). This function injects two critical artifacts into the system prompt:
GRAPH_SCHEMA_DEFINITION— The complete graph schema describing nodes (Function,Class,Module, etc.) and relationships (DEFINES,CALLS,INHERITS_FROM, etc.)CYPHER_QUERY_RULES— Strict construction guidelines preventing common LLM errors
from codebase_rag.prompts import build_cypher_system_prompt
# Scope to specific projects for smaller, more accurate schemas
system_prompt = build_cypher_system_prompt(active_projects=["myproject", "shared-lib"])
The resulting prompt constrains the LLM to valid graph traversals only, eliminating hallucinated node labels or relationship types.
Step 2: Provider Selection and Agent Initialization
The CypherGenerator class dynamically instantiates the configured LLM provider. In codebase_rag/services/llm.py (lines 26-29):
config = settings.active_cypher_config # OpenAI, Ollama, etc.
llm = _create_provider_model(config)
The Agent is then constructed with the system prompt and bound to this model. The generator supports provider switching without code changes—configuration drives the selection.
Step 3: Async Generation and Response Cleaning
The generate method (services/llm.py, lines 34-38) executes the agent:
result = await self.agent.run(natural_language_query)
raw_output = result.output
Before validation, _clean_cypher_response (lines 46-53) strips formatting artifacts:
def _clean_cypher_response(response: str) -> str:
# Remove markdown fences, backticks, and 'cypher' prefix
cleaned = response.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("```")[1]
if cleaned.startswith("cypher"):
cleaned = cleaned[6:]
return cleaned.strip().strip("`")
This guarantees a plain query string regardless of how the LLM formats its output.
Step 4: Multi-Layer Safety Validation
Three validation guards protect against harmful or expensive queries:
Read-Only Enforcement
_validate_cypher_read_only (lines 80-86) blocks DML operations:
forbidden = ["DELETE", "SET", "REMOVE", "CREATE", "MERGE"]
if any(keyword in query.upper() for keyword in forbidden):
raise LLMGenerationError(f"Query contains forbidden keyword: {keyword}")
Unbounded Path Prevention
_validate_no_unbounded_paths (lines 89-98) rejects variable-length paths without numeric limits:
# Rejects: [:CALLS*]
# Allows: [:CALLS*..5]
import re
if re.search(r"\*\s*[)\]]", query) and not re.search(r"\*\d+\.\.\d+", query):
raise LLMGenerationError("Unbounded variable-length path detected")
Procedure Whitelisting
_validate_call_procedures (lines 100-108) restricts CALL statements to approved prefixes:
allowed_prefixes = settings.CYPHER_ALLOWED_PROCEDURE_PREFIXES # e.g., ["gds.", "apoc."]
if not any(proc.startswith(prefix) for prefix in allowed_prefixes):
raise LLMGenerationError(f"Procedure {proc} not in whitelist")
All validators raise LLMGenerationError from codebase_rag/exceptions.py with actionable messages for debugging.
Step 5: RAG Orchestration Integration
The create_rag_orchestrator function builds a higher-level agent that coordinates multiple tools. From codebase_rag/prompts.py (lines 31-41), the orchestrator prompt includes:
- The same schema/rules block as the generator
- Tool descriptions for
query_graph,semantic_search,read_file, etc. - Project-specific instructions from
.cgr.mdfiles
from codebase_rag.services.llm import create_rag_orchestrator
from codebase_rag.tools import QueryGraphTool, SemanticSearchTool, ReadFileTool
tools = [QueryGraphTool(), SemanticSearchTool(), ReadFileTool()]
agent, _ = create_rag_orchestrator(
tools,
project_root=Path("/path/to/repo"),
active_projects=["myproject"],
)
The orchestrator decides execution strategy—semantic search for intent classification, direct graph queries for structural questions, or combined approaches for complex requests.
End-to-End Execution Flow
User NL Query
│
▼
┌─────────────────┐
│ RAG Orchestrator│ ← Builds plan using available tools
│ (Agent + Tools) │
└────────┬────────┘
│
┌────┴────┐
▼ ▼
Semantic query_graph tool
Search │
(optional) ▼
┌─────────────────┐
│ CypherGenerator │ ← build_cypher_system_prompt()
│ ───────────── │
│ _create_provider_model()
│ agent.run() │
│ _clean_cypher_response()
│ _validate_*() │
└────────┬─────────┘
▼
Memgraph
│
▼
Results → File reads → Final answer
Practical Usage Examples
Direct CypherGenerator Invocation
For programmatic access without the full orchestrator:
from codebase_rag.services.llm import CypherGenerator
import asyncio
async def main():
gen = CypherGenerator(active_projects=["myproject"])
query = await gen.generate(
"List all public functions in the utils module that have a @task decorator"
)
print(query)
# Output:
# MATCH (f:Function)-[:DEFINES]->(m:Module)
# WHERE m.name = 'utils' AND 'task' IN f.decorators
# RETURN f.name, f.path, f.qualified_name
# LIMIT 50;
asyncio.run(main())
The generator automatically appends semicolons and guarantees all safety constraints are satisfied.
CLI-Based Workflow
# Start infrastructure (Memgraph + Qdrant)
cgr daemon up
# Parse repository and populate graph
cgr start --repo-path /path/to/myproject --update-graph
# Interactive natural language queries
cgr query
> "Which classes inherit from BaseHandler and override the handle method?"
The CLI internally instantiates the same CypherGenerator and orchestrator described above.
Key Source Files
| File | Purpose |
|---|---|
codebase_rag/services/llm.py |
Core implementation: CypherGenerator, validators, orchestrator builder |
codebase_rag/prompts.py |
System prompt construction with schema embedding |
codebase_rag/exceptions.py |
LLMGenerationError and custom exception types |
codebase_rag/main.py |
CLI entry point (cgr start, cgr query) |
docs/sdk/cypher-generator.md |
Official SDK documentation |
Summary
- Prompt engineering drives accuracy: The
build_cypher_system_promptfunction embeds the complete graph schema and strict Cypher rules, preventing hallucinated queries - Safety is layered, not optional: Three validators (read-only, bounded paths, procedure whitelist) execute before any query reaches Memgraph
- Async-first architecture: The
generate()method isasyncfor production throughput - Orchestrator enables complex workflows: The
create_rag_orchestratorfunction combines NL-to-Cypher with semantic search and file operations - Configuration-driven providers: OpenAI, Ollama, and other backends swap via
settings.active_cypher_configwithout code changes
Frequently Asked Questions
What LLM providers does Code-Graph-RAG support for Cypher generation?
The system supports any provider compatible with the internal provider model interface, including OpenAI (GPT-4, GPT-3.5) and Ollama (local models). Provider selection occurs through settings.active_cypher_config in codebase_rag/services/llm.py (lines 26-29), with instantiation handled by _create_provider_model.
How does the system prevent LLMs from generating destructive Cypher queries?
Three sequential validators enforce safety: _validate_cypher_read_only blocks DML keywords like DELETE and SET; _validate_no_unbounded_paths rejects variable-length paths without numeric upper bounds; and _validate_call_procedures whitelists allowed procedure prefixes. Any failure raises LLMGenerationError from codebase_rag/exceptions.py.
Can I restrict Cypher generation to specific projects or codebases?
Yes. Pass active_projects=["project-name"] to CypherGenerator or create_rag_orchestrator. This scopes both the embedded schema (reducing token usage and hallucination risk) and the resulting queries (limiting to relevant subgraphs).
What happens when the LLM returns Cypher wrapped in markdown fences?
The _clean_cypher_response function in codebase_rag/services/llm.py (lines 46-53) automatically strips markdown code blocks, backticks, and the cypher language identifier prefix. This ensures validators receive plain query strings regardless of LLM output formatting.
How does the RAG orchestrator decide between semantic search and graph queries?
The orchestrator prompt in codebase_rag/prompts.py (lines 31-41) equips the LLM with tool descriptions for both semantic_search and query_graph. The model plans its approach based on the natural language question—using semantic search for vague intent ("find authentication code"), graph queries for precise structural relationships ("what calls validate_token"), or combinations thereof.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →