End-to-end GraphRAG with FalkorDB, embeddings, and LangGraph routing
A hybrid GraphRAG workflow on a movie dataset: FalkorDB graph queries, MiniLM embeddings for vector search, and LangGraph routing deciding which path to use.
This article is published in English.
Large language models answer fluently from training data and still stumble when the question needs fresh structure: who directed which film, which actors co-starred, what hops connect two entities. GraphRAG systems address that gap by combining vector search over unstructured text with explicit graph queries over relationships, then letting an LLM reason across both.
This walkthrough builds a working workflow on movie data: FalkorDB as the graph store, Hugging Face MiniLM embeddings for semantic recall, Groq-hosted models for routing and answers, LangChain helpers for retrieval plumbing, and LangGraph to orchestrate the path. The point is not a demo cartoon; it is a pattern you can adapt to any domain where relationships matter as much as paragraphs.
Why graphs join vectors
Vector search finds similar chunks. Graph queries find typed paths. Ask for films Christopher Nolan directed and a graph edge is sharper than a nearest-neighbor guess. Ask for “mind-bending heist movies like Inception” and vectors shine. Real assistants need both, plus a router that decides which path to run—or when to run a hybrid.
MATCH (d:Director {name: 'Christopher Nolan'})-[:DIRECTED]->(m:Movie) RETURN m.title
MATCH (m:Movie)-[r]->(d:Director) RETURN m, r, d
Environment and FalkorDB
Create a virtual environment, install Jupyter if you want an exploratory notebook, and pin versions. LangChain’s surface moves; pinning is part of reproducibility.
$ pip install jupyterlab
$ jupyter lab
# 1. SETUP WSL (Run in Windows PowerShell as Administrator)
wsl --install
# Restart your computer if prompted, then create your Linux username/password.
# Inside the WSL/Ubuntu terminal, update packages:
sudo apt update && sudo apt upgrade -y
# 2. SET ENVIRONMENT VARIABLES (Paste your keys from Groq & Hugging Face)
export GROQ_API_KEY="your_groq_api_key_here"
export HUGGINGFACEHUB_API_TOKEN="your_hf_token_here"
# 3. ENVIRONMENT & DEPENDENCY SETUP
# Install Python pip and virtual environment tools
sudo apt install python3-pip python3-venv -y
# Create and activate a project directory
mkdir movie-graphrag && cd movie-graphrag
python3 -m venv venv
source venv/bin/activate
# Verify everything is correctly configured
echo "Groq Key: $GROQ_API_KEY"
echo "HF Token: $HUGGINGFACEHUB_API_TOKEN"
Run FalkorDB locally with Docker, or use a managed instance if that fits your team. Local Docker keeps experiments cheap:
docker run -p 6379:6379 -p 3000:3000 -it --rm -v ./data:/data falkordb/falkordb:edge
Install the exact library set this workflow was tested against after clearing older LangChain installs:
pip uninstall langchain langchain-core langchain-community langchain-groq \
langchain-huggingface langgraph -y
pip install falkordb pandas pydantic sentence-transformers
pip install "langchain==0.3.26"
pip install "langchain-core==0.3.68"
pip install "langchain-community==0.3.23"
pip install "langchain-groq==0.2.5"
pip install "langchain-huggingface==0.1.2"
pip install "langgraph==0.3.34"
Load the IMDB-style dataset
Download a Top-1000 style movies CSV, then load it with the imports and connection setup your notebook expects:
import os, re, sys, logging
import pandas as pd
from typing import List, TypedDict, Literal
from falkordb import FalkorDB
from langchain_community.document_loaders.csv_loader import CSVLoader
from langchain_community.vectorstores.falkordb_vector import FalkorDBVector
from langchain_community.graphs import FalkorDBGraph
from langchain_community.chains.graph_qa.falkordb import FalkorDBQAChain
from langchain_core.documents import Document
from langchain_core.prompts import ChatPromptTemplate
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.output_parsers import PydanticToolsParser
from pydantic import BaseModel, Field # pydantic v2 — not langchain_core.pydantic_v1
from langchain_huggingface import HuggingFaceEmbeddings # not langchain.embeddings (deprecated)
from langchain_groq import ChatGroq
from langgraph.graph import END, StateGraph
GROQ_API_KEY = "your_groq_api_key"
CSV_PATH = "imdb_top_1000.csv"
os.environ["HF_TOKEN"] = "your_hf_token"
logging.basicConfig(stream=sys.stdout, level=logging.WARNING)
# Connect to FalkorDB and select the graph
db = FalkorDB(host='localhost', port=6379)
graph = db.select_graph('imdb')
data = pd.read_csv(CSV_PATH)
Build the knowledge graph
Create movie nodes with properties such as title, year, and certificate. Create director and star nodes. Connect them with typed relationships so Cypher-style queries can answer cast and crew questions without hoping an embedding remembered the edge.
def clean_column(column_data):
"""Remove special characters that would break Cypher queries."""
return column_data.apply(
lambda x: re.sub(r"[^a-zA-Z0-9\s]", "", x) if pd.notnull(x) else 'NA'
)
for col in ['Series_Title', 'Director', 'Certificate', 'Runtime', 'Genre',
'Overview', 'Star1', 'Star2', 'Star3', 'Star4']:
data[col] = clean_column(data[col])
data['IMDB_Rating'] = data['IMDB_Rating'].fillna('NA')
data['Released_Year'] = pd.to_numeric(data['Released_Year'], errors='coerce').fillna(-1)
star_columns = ['Star1', 'Star2', 'Star3', 'Star4']
for index, row in data.iterrows():
movie_title = row['Series_Title']
director_name = row['Director']
released_year = int(row['Released_Year']) if row['Released_Year'] != -1 else None
if released_year is None:
print(f"Skipping '{movie_title}' — invalid year.")
continue
movie_node = (
f"MERGE (m:Movie {{"
f"title: '{movie_title}', year: {released_year}, "
f"certificate: '{row['Certificate']}', runtime: '{row['Runtime']}', "
f"genre: '{row['Genre']}', imdb_rating: {row['IMDB_Rating']}, "
f"description: '{row['Overview']}'"
f"}})"
)
director_node = f"MERGE (d:Director {{name: '{director_name}'}})"
directed_relation = "MERGE (d)-[:DIRECTED]->(m)"
star_nodes, starred_relations = "", ""
for s, star_col in enumerate(star_columns, start=1):
star_name = row[star_col]
if star_name != 'NA':
star_nodes += f"MERGE (s{s}:Star {{name: '{star_name}'}})\n"
starred_relations += f"MERGE (s{s})-[:STARRED_IN]->(m)\n"
cypher_query = (
f"{movie_node} {director_node} {directed_relation} "
f"{star_nodes.strip()}\n{starred_relations.strip()}"
)
graph.query(cypher_query)
print("Knowledge Graph created.")
Embeddings for semantic recall
Use all-MiniLM-L6-v2 through the modern Hugging Face integration package rather than deprecated import paths:
embedding_model = HuggingFaceEmbeddings(
model_name="sentence-transformers/all-MiniLM-L6-v2"
)
result = graph.query(
"MATCH (m:Movie) RETURN m.title AS title, m.description AS description"
)
documents = [
Document(
page_content=row[1] if row[1] else "",
metadata={"title": row[0], "description": row[1]}
)
for row in result.result_set if row[1]
]
vector_index = FalkorDBVector(host='localhost', port=6379, embedding=embedding_model)
vector_index.add_documents(documents)
print("Vector index created.")
Store vectors alongside or in coordination with the graph so hybrid nodes can pull movie titles from semantic search and then expand them through relationships.
GraphState for LangGraph
Define a TypedDict (or equivalent) GraphState that carries the user question, route decision, subqueries, vector hits, graph answers, and final response. LangGraph keeps that state across nodes so follow-ups can reuse context instead of restarting from an empty prompt.
class GraphState(TypedDict):
"""
Tracks state across the entire LangGraph workflow.
Every node reads from and writes to this dictionary.
"""
question: str
documents: object
movie_titles: List[str]
subqueries: object
prompt_with_context: object
Query routing
Not every question should hit every subsystem. A router classifies whether the question is primarily relational, primarily semantic, hybrid, or out of scope. That saves latency and reduces contradictory context.
llm = ChatGroq(groq_api_key=GROQ_API_KEY, model_name="llama-3.3-70b-versatile")
class RouteQuery(BaseModel):
"""Route a user query to the most relevant datasource."""
datasource: Literal["vector search", "graph query"] = Field(
...,
description="Route to 'vector search' for similarity queries, "
"'graph query' for relationship/fact queries.",
)
structured_llm_router = llm.with_structured_output(RouteQuery)
route_system_prompt = """You are an expert at routing user questions.
Route to VECTOR SEARCH for questions involving:
- Similarity (similar, related, like, closest, relevant)
- Themes or topics (movies about space, about love, etc.)
- "Find movies similar to X"
Route to GRAPH QUERY for questions involving:
- Specific facts (who directed X, what year was X released)
- Specific people (movies by Christopher Nolan)
- Filtering by property (movies from 1990, Action movies)
"""
route_prompt = ChatPromptTemplate.from_messages([
("system", route_system_prompt),
("human", "{question}"),
])
question_router = route_prompt | structured_llm_router
def route_question(state: GraphState):
source = question_router.invoke({"question": state["question"]})
if source.datasource == "vector search":
return "decomposer"
else:
return "graph_qa"
Decomposer
When the hybrid path runs, a decomposer breaks the user question into subqueries the vector and graph nodes can consume:
class SubQuery(BaseModel):
"""A single sub-query decomposed from the original question."""
sub_query: str = Field(
..., description="A unique paraphrasing of the original question.",
)
decompose_system = """You are an expert at breaking down complex questions.
Given a user question, split it into exactly two sub-queries:
1. A query for vector similarity search (find related/similar items)
2. A query for a graph database (find specific relationships or facts)
Example:
Question: Find movies directed by Christopher Nolan related to dreams.
sub_query 1: Find movies related to dreams or the subconscious.
sub_query 2: Find movies directed by Christopher Nolan.
"""
decompose_prompt = ChatPromptTemplate.from_messages([
("system", decompose_system),
("human", "{question}"),
])
llm_with_tools = llm.bind_tools([SubQuery])
parser = PydanticToolsParser(tools=[SubQuery])
query_analyzer = decompose_prompt | llm_with_tools | parser
def decomposer(state: GraphState):
question = state["question"]
subqueries = query_analyzer.invoke({"question": question})
return {"subqueries": subqueries, "question": question}
Vector search node
Implement similarity search with current LangChain retrieval helpers:
def vector_search(state: GraphState):
question = state["question"]
retriever = vector_index.as_retriever(search_kwargs={"k": 3})
qa_prompt = ChatPromptTemplate.from_messages([
("system",
"You are a movie expert. Use the context below to answer the question.\n\n"
"Context:\n{context}"),
("human", "{input}"),
])
combine_docs_chain = create_stuff_documents_chain(llm, qa_prompt)
retrieval_chain = create_retrieval_chain(retriever, combine_docs_chain)
result = retrieval_chain.invoke({"input": question})
source_docs = result.get("context", [])
extracted_data, movie_titles = [], []
for doc in source_docs:
title = doc.metadata.get("title", "Unknown")
description = doc.metadata.get("description", "")
extracted_data.append({"title": title, "description": description})
movie_titles.append(title)
return {
"movie_titles": movie_titles,
"documents": extracted_data,
"question": question,
"subqueries": state["subqueries"],
}
Graph QA node
Translate relational subquestions into graph queries. For a director filmography style ask, the graph should return titles tied by directed-by edges rather than a paraphrased guess:
def graph_qa(state: GraphState):
question = state["question"]
graph_wrapper = FalkorDBGraph(database="imdb", host="localhost", port=6379)
chain = FalkorDBQAChain.from_llm(
llm,
graph=graph_wrapper,
verbose=True,
allow_dangerous_requests=True,
)
result = chain.invoke({"query": question})
answer = result.get("result", str(result))
return {"documents": answer, "question": question}
Graph QA with vector context
The hybrid node takes titles recovered semantically and expands them with graph structure—cast, crew, related films—before the LLM writes a final answer:
def graph_qa_with_context(state: GraphState):
titles = state["movie_titles"]
question = state["question"]
input_query = (
f"Return the title, genre, director, and IMDB rating of movies "
f"with these titles: {str(titles)}"
)
graph_wrapper = FalkorDBGraph(database="imdb", host="localhost", port=6379)
chain = FalkorDBQAChain.from_llm(
llm,
graph=graph_wrapper,
verbose=True,
allow_dangerous_requests=True,
return_intermediate_steps=True,
)
result = chain.invoke({"query": input_query})
context = ""
steps = result.get("intermediate_steps", [])
if len(steps) > 1:
context = str(steps[1].get("context", ""))
return {
"documents": context,
"subqueries": state["subqueries"],
"question": question,
}
Respond node
Normalize the final natural-language answer, attach provenance where possible, and clear intermediate fields you do not want to leak to the client:
def respond(state: GraphState):
question = state["question"]
context = state["documents"]
respond_prompt = ChatPromptTemplate.from_messages([
("system",
"You are a helpful movie expert assistant.\n"
"Use the context below to answer the user's question clearly and concisely.\n\n"
"Context:\n{context}"),
("human", "{question}"),
])
answer = (respond_prompt | llm).invoke({"context": context, "question": question})
return {"documents": answer.content}
Wire the LangGraph workflow
Connect router, decomposer, vector, graph, hybrid, and respond nodes with conditional edges that match the route labels:
workflow = StateGraph(GraphState)
# Register all nodes
workflow.add_node("graph_qa", graph_qa)
workflow.add_node("decomposer", decomposer)
workflow.add_node("vector_search", vector_search)
workflow.add_node("graph_qa_with_context", graph_qa_with_context)
workflow.add_node("respond", respond)
# Conditional entry point — route_question decides the first node
workflow.set_conditional_entry_point(
route_question,
{
"decomposer": "decomposer",
"graph_qa": "graph_qa",
},
)
# Hybrid path
workflow.add_edge("decomposer", "vector_search")
workflow.add_edge("vector_search", "graph_qa_with_context")
workflow.add_edge("graph_qa_with_context", "respond")
workflow.add_edge("respond", END)
# Direct graph path
workflow.add_edge("graph_qa", END)
app = workflow.compile()
Visualize the graph for documentation and onboarding:
from IPython.display import Image, display
try:
display(Image(app.get_graph().draw_mermaid_png()))
except Exception:
pass
Test and interactive console
Run scripted questions that cover relational, semantic, and hybrid cases:
# Graph query — specific fact question
result1 = app.invoke({"question": "Which movies did Christopher Nolan direct?"})
print(result1["documents"])
Then wrap a simple console loop for manual exploration:
print("\n" + "=" * 60)
print("INTERACTIVE MODE — type a question (or 'quit' to exit)")
print("=" * 60)
while True:
user_question = input("\nYour question: ").strip()
if user_question.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
if not user_question:
continue
try:
result = app.invoke({"question": user_question})
print("\nANSWER:", result["documents"])
except Exception as e:
print(f"Error: {e}")
Results worth checking
Validate that Nolan-style director queries return graph-grounded titles, that vibe queries return semantically sensible films, and that hybrid queries cite both evidence types. When answers drift, inspect state dumps between nodes rather than only the final string.
Summary and adaptation tips
GraphRAG is a routing problem as much as a storage problem. FalkorDB (or any low-latency graph database) gives you typed structure. Embeddings give you fuzzy recall. LangGraph makes the control flow explicit enough to test. Swap movies for your domain—parts and suppliers, patients and protocols, services and dependencies—while keeping the same node roles: route, retrieve, expand, respond.
Pin versions, keep a golden question set per route type, and log state transitions. That operational discipline matters more than which demo dataset you started from.
Design notes that survive contact with production
Entity resolution will dominate real deployments. Movie titles are relatively clean; company names and product SKUs are not. Plan a normalization step before node creation, and keep a mapping from raw strings to canonical IDs. Without that, graph queries fragment and vector hits point at duplicates.
Schema evolution needs migrations just like SQL. Adding a new relationship type is easy in a notebook and hard once dashboards depend on old edges. Version your graph schema and store it next to the workflow code.
Latency budgets should be explicit. Router LLM calls add time before retrieval even starts. For high-QPS surfaces, consider rules or small classifiers for obvious relational patterns (“who directed X”) and reserve LLM routing for ambiguous text.
Evaluation should score route accuracy separately from answer quality. A perfect answer on the wrong route still burns budget and will fail when one subsystem is down. Chaos-test by disabling the graph or the vector index and confirming the workflow degrades honestly.
Security matters: graph query generation must be constrained. Prefer parameterized templates over free-form Cypher from a model. The same rule you apply to SQL tools applies here.
Cost control: cache embeddings for stable documents, cache router decisions for identical questions within a short TTL, and avoid hybrid expansion when pure graph already returns high-confidence entities.
When explaining this in an interview, draw the state object first, then the edges. Interviewers follow control flow faster than they follow package names. Mention FalkorDB for property-graph speed, MiniLM for local embeddings, and Groq only as one possible LLM host—your architecture should not be welded to a single inference vendor.
Finally, document failure messages users will see when the graph is empty or the router abstains. Silent hallucinations are how GraphRAG demos lose trust after the first week. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types. Keep route-level fixtures green before expanding the graph schema into new entity types.