Build a Movie Recommender Using a Knowledge Graph (Neo4j + FastAPI + Streamlit)
How I ditched collaborative filtering and used graph traversal to recommend movies — and why it actually makes more sense.
Build a Movie Recommender Using a Knowledge Graph (Neo4j + FastAPI + Streamlit)
How I ditched collaborative filtering and used graph traversal to recommend movies — and why it actually makes more sense.

Visualizing the data in graph
Why Not Just Use Machine Learning?
When most people think “recommendation system,” they picture matrix factorization, collaborative filtering, or sentence embeddings. Those approaches are powerful — but they come with real drawbacks:
- They need large amounts of user interaction data to work well
- They’re often black boxes — you can’t easily explain why a movie was recommended
- They’re complex to set up for a small project
There’s a simpler, more intuitive approach that gets overlooked: Knowledge Graphs.
The insight is straightforward. If you liked Inception, you might like other films by Christopher Nolan, or other sci-fi thrillers, or other Warner Bros. productions. These are explicit, structured relationships — and graph databases are built exactly for this.
In this tutorial, we’ll build CineGraph: a movie recommendation system that uses a Neo4j knowledge graph, a FastAPI backend, and a Streamlit frontend. No ML training required.
What We’re Building
CSV Dataset → Neo4j AuraDB (Knowledge Graph)
↓
FastAPI Recommendation API
↓
Streamlit Frontend (User Interface)
Users search for a movie, pick it from a dropdown, and get recommendations across three signals: genre, director, and production company — with a combined score that weighs them intelligently.
Tech stack:
- Neo4j AuraDB (free tier) — graph database
- FastAPI — backend API
- Streamlit — frontend UI
- Python — everything else
- TMDB 5000 dataset — movie data
Part 1: The Knowledge Graph
What Is a Knowledge Graph?
A knowledge graph stores data as nodes (entities) and relationships (connections between them). Instead of rows and columns, you have a network of connected facts.
For CineGraph, our graph looks like this:
(Movie)-[:DIRECTED_BY]->(Director)
(Movie)-[:HAS_GENRE]->(Genre)
(Movie)-[:PRODUCED_BY]->(Company)
When you ask “find movies similar to Inception,” the graph traversal literally follows these edges — finding movies that share directors, genres, or companies with Inception.
Setting Up Neo4j AuraDB
Neo4j offers a free cloud-hosted tier called AuraDB. Sign up at neo4j.com/cloud/aura, create a free instance, and save your credentials.
You’ll get:
NEO4J_URI— something likeneo4j+s://xxxx.databases.neo4j.ioNEO4J_USERNAME— usuallyneo4jNEO4J_PASSWORD— your instance password
Connecting to Neo4j
# database.py
from neo4j import GraphDatabase
from dotenv import load_dotenv
import os
load_dotenv()
uri = os.getenv("NEO4J_URI")
username = os.getenv("NEO4J_USERNAME")
password = os.getenv("NEO4J_PASSWORD")
driver = GraphDatabase.driver(uri, auth=(username, password))
def get_driver():
return driver
Loading the Data
Download the TMDB 5000 Movies dataset from Kaggle. Then write a script to parse it and insert nodes and relationships into Neo4j.
# data_insertion.py (simplified)
import pandas as pd
import json
from database import get_driver
driver = get_driver()
df = pd.read_csv("tmdb_5000_movies.csv")
with driver.session() as session:
for _, row in df.iterrows():
movie_name = row["title"]
genres = json.loads(row["genres"])
companies = json.loads(row["production_companies"])
# Create Movie node
session.run("MERGE (m:Movie {name: $name})", name=movie_name)
# Create Genre nodes and relationships
for genre in genres:
session.run("""
MERGE (g:Genre {name: $name})
MERGE (m:Movie {name: $movie})-[:HAS_GENRE]->(g)
""", name=genre["name"], movie=movie_name)
# Repeat for directors and companies...
After running this, your graph will have ~4,800 movies connected to 20 genres, 2,350 directors, and 5,000+ companies via 31,000+ relationships.
Part 2: The Scoring Logic
Here’s the core idea behind CineGraph’s recommendations.
When you search for movies similar to Inception, we traverse the graph and count how many connections each candidate movie shares with it:
Signal Weight Reasoning Shared Genre +1 per genre Genre taste is common but not definitive Shared Director +2 Same director is a much stronger signal Shared Company +1 per company Studio style matters, but less so
Director gets double weight because it’s a stronger stylistic signal — you’re far more likely to enjoy another Nolan film than just any other thriller.
Part 3: The Recommendation Queries
This is where Neo4j shines. The query language Cypher lets you express graph traversals in a way that reads almost like English.
Genre-Based Recommendations
MATCH (m:Movie)
WHERE toLower(m.name) = toLower($movie)
MATCH (m)-[:HAS_GENRE]->(g:Genre)<-[:HAS_GENRE]-(other:Movie)
WHERE other <> m
RETURN other.name AS title, count(g) AS score
ORDER BY score DESC LIMIT $limit
Read this as: “Find my movie, then find all other movies that share at least one genre with it, count how many genres they share, and return them sorted by that count.”
Director-Based Recommendations
MATCH (m:Movie)
WHERE toLower(m.name) = toLower($movie)
MATCH (m)-[:DIRECTED_BY]->(d:Director)<-[:DIRECTED_BY]-(other:Movie)
WHERE other <> m
RETURN other.name AS title, count(d) AS score
ORDER BY score DESC LIMIT $limit
Combined Score (The Main Recommender)
MATCH (m:Movie)
WHERE toLower(m.name) = toLower($movie)
OPTIONAL MATCH (m)-[:HAS_GENRE]->(g:Genre)<-[:HAS_GENRE]-(other:Movie)
WHERE other <> m
WITH m, other, count(DISTINCT g) AS genre_score
OPTIONAL MATCH (m)-[:DIRECTED_BY]->(d:Director)<-[:DIRECTED_BY]-(other)
WITH m, other, genre_score, count(DISTINCT d) * 2 AS director_score
OPTIONAL MATCH (m)-[:PRODUCED_BY]->(c:Company)<-[:PRODUCED_BY]-(other)
WITH other, genre_score, director_score, count(DISTINCT c) AS company_score
RETURN other.name AS title,
genre_score + director_score + company_score AS total_score,
genre_score,
director_score,
company_score
ORDER BY total_score DESC LIMIT $limit
Notice the OPTIONAL MATCH — this ensures movies still appear even if they don't share a director or company, they just score 0 on that signal.
Wrapping Queries in Python
# recommender.py
from database import get_driver
driver = get_driver()
def recommend_by_genre(movie: str, limit: int = 10):
with driver.session() as session:
result = session.run("""
MATCH (m:Movie)
WHERE toLower(m.name) = toLower($movie)
MATCH (m)-[:HAS_GENRE]->(g:Genre)<-[:HAS_GENRE]-(other:Movie)
WHERE other <> m
RETURN other.name AS title, count(g) AS score
ORDER BY score DESC LIMIT $limit
""", movie=movie, limit=limit)
return [{"title": r["title"], "score": int(r["score"])} for r in result]
Part 4: The FastAPI Backend
FastAPI makes it trivial to expose our recommender functions as a REST API.
# main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from recommender import recommend_by_genre, recommend_by_director, recommend_combined
app = FastAPI(title="CineGraph API")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
@app.get("/recommend")
def combined_recommendations(movie: str, limit: int = 10):
results = recommend_combined(movie, limit)
if not results:
raise HTTPException(status_code=404, detail=f"Movie '{movie}' not found")
return {"movie": movie, "recommendations": results}
@app.get("/movies/search")
def search_movies(q: str):
from database import get_driver
driver = get_driver()
with driver.session() as session:
result = session.run("""
MATCH (m:Movie)
WHERE toLower(m.name) CONTAINS toLower($q)
RETURN m.name AS title
ORDER BY m.name LIMIT 10
""", q=q)
return {"results": [r["title"] for r in result]}
Run it with:
uvicorn main:app --reload
Your API is now live at http://localhost:8000. Check the auto-generated docs at /docs.
Pydantic Response Models
FastAPI uses Pydantic for response validation — always a good practice:
# models.py
from pydantic import BaseModel
from typing import List
class CombinedRecommendation(BaseModel):
title: str
total_score: int
genre_score: int
director_score: int
company_score: int
Part 5: The Streamlit Frontend
# app.py (key parts)
import streamlit as st
import requests, os
API_URL = os.getenv("API_URL", "http://localhost:8000")
st.title("🎬 CineGraph Movie Recommender")
search_query = st.text_input("Search for a movie", placeholder="e.g. Inception, Avatar...")
if search_query:
res = requests.get(f"{API_URL}/movies/search", params={"q": search_query})
suggestions = res.json().get("results", [])
movie_selected = st.selectbox("Select a movie", suggestions)
if movie_selected:
tab1, tab2, tab3, tab4 = st.tabs([
"⭐ Combined", "🎭 By Genre", "🎬 By Director", "🏢 By Company"
])
with tab1:
res = requests.get(f"{API_URL}/recommend", params={"movie": movie_selected})
for i, rec in enumerate(res.json()["recommendations"], 1):
st.write(f"**{i}.** {rec['title']}")
st.divider()
Run it with:
streamlit run app.py
Part 6: Project Setup
Install Dependencies
git clone <your-repo>
cd cinegraph
pip install -r requirements.txt
# requirements.txt
fastapi
uvicorn
neo4j
python-dotenv
streamlit
requests
pydantic
Environment Variables
# .env
NEO4J_URI=neo4j+s://your-instance.databases.neo4j.io
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=your-password
API_URL=http://localhost:8000
Running the App
You need two terminals:
# Terminal 1 — FastAPI
uvicorn main:app --reload
# Terminal 2 — Streamlit
streamlit run app.py
What We Learned
Knowledge graphs are underrated for recommendation systems. They’re:
- Explainable — you can always tell the user why a movie was recommended (“3 shared genres, same director”)
- No training data needed — works immediately with structured data
- Fast to query — graph traversals are highly optimized in Neo4j
- Easy to extend — add actors, release year, ratings as new node types and relationships
The tradeoffs:
- No personalization based on individual user history (though you could add user nodes to the graph)
- Recommendations are only as good as your metadata quality
- Cold start isn’t a problem here since recommendations are content-based, not user-based
What’s Next
A few directions to take this further:
- Add movie metadata — poster images, ratings, release year, plot overview
- User preference nodes — connect users to genres/directors they like, then traverse from them
- Semantic similarity layer — use sentence embeddings on plot summaries as a fourth signal
- Deploy — FastAPI to Railway or Render, Streamlit to Streamlit Cloud
Final Thoughts
This project is a great example of choosing the right tool for the problem. You don’t always need a neural network. Sometimes the data is the structure, and a graph database lets you query that structure directly.
If you found this useful, the full source code is on GitHub. Feel free to fork it, extend it, and share what you build.
Built by Shiva Kumar Billana & Nithin Chandra
메타데이터
- post_id
- 65126e7be3c2
- slug
- build-a-movie-recommender-using-a-knowledge-graph-neo4j-fastapi-streamlit-65126e7be3c2
- url
- https://medium.com/@billanashivakumar/build-a-movie-recommender-using-a-knowledge-graph-neo4j-fastapi-streamlit-65126e7be3c2
- canonical_url
- https://medium.com/@billanashivakumar/build-a-movie-recommender-using-a-knowledge-graph-neo4j-fastapi-streamlit-65126e7be3c2
- author_url
- https://medium.com/@billanashivakumar
- status
- ok
- fetched_at
- 2026-06-09 15:37:30