Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MovieGraph — a movie recommendation app on a graph database

A small, complete web application that models movies, people, genres, and user ratings as a property graph, and serves recommendations that are literally graph traversals. Built for CognoDB (a Neo4j/Bolt-compatible graph database), queried in Cypher over the Bolt protocol via the official neo4j-driver.

Stack: CognoDB (Cypher/Bolt) · Node.js + Express · React (Vite)

React (Vite) ──HTTP──> Express API ──Bolt (neo4j-driver)──> CognoDB
                          │
                          └── seed script (curated inline dataset)

All Cypher lives in server/src/queries/index.js; routes handle only HTTP concerns (validation, status codes). The frontend never touches the database.


The app

Three tabs, one per query.

Movies — search and content-based similarity

The Movies tab. A title search list on the left; on the right, the detail panel for The Incredibles showing year, average rating, genre chips, director, cast, and a list of similar movies each with a match score.

Opening a film returns its whole neighborhood — genres, cast, directors — in a single round trip, alongside similar titles ranked by the similarity query.

For you — collaborative filtering

The For you tab. A user selector set to User 3, above four recommended films — Interstellar, Pulp Fiction, The Godfather, Goodfellas — each showing an average score, how many people with similar taste backed it, and a Rate button.

Recommendations for the selected user, with the supporting evidence shown inline. Rating a film removes it from the list; undo brings it back.

Connections — shortest path between two actors

The Connections tab. Two actor dropdowns set to Keanu Reeves and Tom Hanks, and below them the resulting chain rendered as alternating teal person nodes and amber film nodes: Keanu Reeves, The Matrix, Laurence Fishburne, Mystic River, Kevin Bacon, Apollo 13, Tom Hanks.

The shortest path drawn as the graph it is — people in teal, films in amber. Keanu Reeves and Tom Hanks never shared a film, but three films link them.


Data model

                    ┌─────────┐
   ┌───ACTED_IN────▶│  Movie  │────IN_GENRE────┐
   │   {role}       │ movieId │                │
┌──┴────┐           │ title   │           ┌────┴───┐
│Person │──DIRECTED▶│ year    │           │ Genre  │
│ name  │           └────▲────┘           │ name   │
└───────┘                │                └────────┘
                       RATED
                {score, timestamp}
                         │
                    ┌────┴────┐
                    │  User   │
                    │ userId  │
                    └─────────┘

Nodes: Movie, Person, Genre, User Relationships: (Person)-[:ACTED_IN]->(Movie), (Person)-[:DIRECTED]->(Movie), (Movie)-[:IN_GENRE]->(Genre), (User)-[:RATED {score, timestamp}]->(Movie)

Modeling decisions (and why)

  • Ratings are relationship properties, not nodes. A rating has no identity of its own and nothing else in the model connects to it — it is purely a qualified edge between a user and a movie. If ratings grew into reviews (with comments, likes, replies), they would earn nodehood; until then, a Rating node would add a hop to every recommendation traversal for no modeling benefit.
  • Genres are nodes, not an array property on Movie. Genres are a shared, enumerable vocabulary, and "movies that share a genre" is a core traversal in the similarity query. As nodes, that co-membership is a two-hop path (m)-[:IN_GENRE]->(g)<-[:IN_GENRE]-(rec); as an array property it would be a full scan with list-intersection logic.
  • One Person label for actors and directors, two relationship types. The same human can act and direct. Role lives in the relationship type (ACTED_IN vs DIRECTED), so "everything this person worked on" is one pattern and queries can widen or narrow with [:ACTED_IN|DIRECTED].
  • Uniqueness constraints on every id-like property (movieId, personId, userId, genre name). Constraints double as indexes, so every MERGE during seeding is an index lookup rather than a scan, and the seed script is safely idempotent — running it twice produces the identical graph.
  • Referential integrity by construction: the ratings seed uses MATCH on Movie (not MERGE), so a rating for an unknown movie is skipped rather than creating a ghost Movie node holding only an id.

The dataset

The seed data (19 movies, 47 people, 8 users, ~35 ratings) is intentionally small and hand-curated so the graph's behavior is legible end to end:

  • Four genre clusters (animation / sci-fi / crime / drama) give the content-based similarity query real structure to find.
  • Users come in pairs with overlapping taste and one deliberate gap each (e.g. users 3 and 4 agree on sci-fi, but user 3 hasn't seen Interstellar), so collaborative filtering has a designed, verifiable "right answer."
  • The cast graph is deliberately connected across clusters (Kevin Bacon and Laurence Fishburne bridge otherwise-separate neighborhoods), so shortest-path queries return interesting multi-hop chains.

The seed uses batched UNWIND + MERGE, so the same script scales unchanged to a full MovieLens-sized dump — small data here is a presentation choice, not an architectural limit.


Why a graph database fits this problem

Recommendation is a relationship problem: every question the app answers is "what is near this thing, through which connections?" In a property graph those questions are short traversals; in a relational schema each one is a self-join pileup.

Concretely, collaborative filtering here is a two-hop walk: me → movies I liked → people who agreed → movies they liked that I haven't seen. In SQL that is the ratings table joined to itself twice, plus an anti-join for the "haven't seen" filter, with the optimizer working over ever-growing intermediate result sets. In Cypher it reads exactly like the sentence above, and the database walks only the neighborhoods it needs.

The six-degrees feature makes the difference categorical rather than convenient: shortestPath over a variable-length pattern ([:ACTED_IN*..12]) has no reasonable SQL equivalent — path-finding of unknown depth requires recursive CTEs that are painful to write and expensive to run, while it is a single native operation in a graph database.


The three main queries

All in server/src/queries/index.js, commented in place.

1. Content-based similarity — GET /movies/:id/similar

MATCH (m:Movie {movieId: $movieId})-[:IN_GENRE]->(g:Genre)<-[:IN_GENRE]-(rec:Movie)
WHERE rec <> m
WITH m, rec, count(g) AS genreScore
OPTIONAL MATCH (m)<-[:ACTED_IN|DIRECTED]-(p:Person)-[:ACTED_IN|DIRECTED]->(rec)
WITH rec, genreScore + count(p) * 3 AS score
RETURN rec.movieId AS movieId, rec.title AS title, score
ORDER BY score DESC, rec.title
LIMIT 10

Candidate movies are found through shared-genre paths, then boosted by shared people. The weighting (genre ×1, person ×3) encodes a judgment: a shared director or star is a stronger similarity signal than a shared genre. OPTIONAL MATCH keeps genre-only matches in the result set instead of filtering them out.

vs SQL: SQL would need a movie_genres table joined to itself to find shared genres, a credits table joined to itself for shared people, and a third join to combine the two scores. The graph already stores those connections, so each "shared X" is just a short hop between two movies.

2. Collaborative filtering — GET /users/:id/recommendations

MATCH (me:User {userId: $userId})-[r1:RATED]->(m:Movie)<-[r2:RATED]-(other:User)
WHERE r1.score >= 4 AND r2.score >= 4 AND other <> me
WITH me, other, count(m) AS overlap
ORDER BY overlap DESC
LIMIT 25
MATCH (other)-[r3:RATED]->(rec:Movie)
WHERE r3.score >= 4
  AND NOT EXISTS { (me)-[:RATED]->(rec) }
RETURN rec.movieId AS movieId, rec.title AS title,
       count(*) AS supporters,
       round(avg(r3.score) * 10) / 10 AS avgScore
ORDER BY supporters DESC, avgScore DESC
LIMIT 10

Stage 1 finds taste neighbors by counting co-liked movies; stage 2 collects what those neighbors liked, and the NOT EXISTS pattern predicate excludes anything the user has already rated. Rating a recommended movie in the UI and watching it drop off the list is this predicate working live.

vs SQL: this needs the ratings table — usually the biggest table — joined to itself three times, plus a fourth pass to filter out movies the user has already seen. SQL scans those large tables to rediscover who is connected to whom. The graph starts at one user and walks outward, so the work depends on that user's neighborhood rather than the total number of ratings in the system.

3. Shortest connection between two actors — GET /actors/path?from=&to=

MATCH (a:Person {name: $from}), (b:Person {name: $to})
MATCH p = shortestPath((a)-[:ACTED_IN*..12]-(b))
RETURN [n IN nodes(p) | coalesce(n.name, n.title)] AS path,
       length(p) / 2 AS movies

Two actors are "connected" when they appear in the same movie. This query finds the shortest such chain between any two actors — actor → shared movie → actor → shared movie → … — capped at 6 movies. The result is the full chain, which the UI draws as connected nodes. Try Keanu Reeves → Tom Hanks: they never appeared together, but a three-movie chain links them through The Matrix, Mystic River, and Apollo 13.

vs SQL: SQL has no built-in way to find a path of unknown length. You would write a recursive query that adds one hop at a time, manually tracks which actors it has already visited to avoid loops, and builds up the chain as it goes — then picks the shortest result. Cypher does it in one line with shortestPath(). This is the clearest case for using a graph database here.


Setup & run

Prerequisites

  • Node.js 18+
  • A running CognoDB instance (Neo4j/Bolt-compatible) and its Bolt URI, username, and password

1. Install

git clone <repo-url> movie-graph
cd movie-graph

cd server && npm install
cd ../client && npm install

2. Configure the database connection

cd server
cp .env.example .env

Edit server/.env:

DB_URI=bolt://localhost:7687   # your CognoDB Bolt endpoint
DB_USER=neo4j                  # your credentials
DB_PASS=your_password_here
PORT=3000

If the connection fails on startup, the usual suspects in order: try the neo4j:// scheme instead of bolt:// (or vice versa); use bolt+s:// / neo4j+s:// for TLS-only hosted instances; and if the instance runs without auth, credentials can be left as placeholders.

3. Create constraints and seed the graph

cd server
npm run schema   # 4 uniqueness constraints (also create the backing indexes)
npm run seed     # loads the curated dataset — idempotent, safe to re-run

Sanity check in the CognoDB query console:

MATCH (m:Movie) RETURN count(m);        // 19
MATCH (p:Person) RETURN count(p);       // 47
MATCH ()-[r:RATED]->() RETURN count(r); // ~35

4. Run the app

# terminal 1 — API on :3000
cd server && npm run dev

# terminal 2 — frontend on :5173
cd client && npm run dev

Open http://localhost:5173. API health check: http://localhost:3000/health should return {"api":"up","db":"up"}.

5. A guided demo path

  1. Movies — search "matrix", open it, browse similar movies (note the score column and the weighting explained under the list).
  2. For you — select User 3 (sci-fi taste): Interstellar is the top recommendation. Click Rate ★5 — it disappears from the list, because the NOT EXISTS filter now sees it as watched. Undo rating brings it back. Fully repeatable.
  3. Actor connections — Keanu Reeves → Tom Hanks, Find path: a three-movie chain rendered as the graph path it is.

Resetting

The seed is idempotent, so npm run seed restores all original ratings. For a guaranteed clean slate:

MATCH (n) DETACH DELETE n;

then re-run npm run schema (no-op if constraints exist) and npm run seed.


API reference

Method Endpoint Purpose
GET /health API + DB liveness
GET /movies?search= Title search
GET /movies/:id Detail: genres, cast, avg rating
GET /movies/:id/similar Content-based similar movies
GET /users Users (for the demo dropdown)
GET /users/:id/recommendations Collaborative-filtering recs
POST /users/:id/ratings Rate a movie {movieId, score}
DELETE /users/:id/ratings/:movieId Remove a rating (deletes the edge)
GET /actors Actors (for the path dropdowns)
GET /actors/path?from=&to= Shortest connection between actors

Project structure

movie-graph/
├── server/
│   ├── src/
│   │   ├── db.js              # Bolt driver singleton, read/write helpers
│   │   ├── schema.js          # uniqueness constraints (npm run schema)
│   │   ├── queries/index.js   # ALL Cypher, commented — start reading here
│   │   ├── routes/            # movies, users, actors — HTTP concerns only
│   │   └── index.js           # Express app + health check
│   └── seed/seed.js           # curated dataset, batched UNWIND + MERGE
└── client/                    # Vite + React: Movies / For you / Actor connections

About

A movie recommendation system based on graph data base such as Cogno Db.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages