Work/GraphDB

[Neo4j Sandbox Data 활용 1] Movie 데이터로 영화추천시스템 만들기

raeul0304 2025. 4. 28. 13:58

 

데이터 파악하기

 

전체 데이터 파악

CALL db.schema.visualization

 

 

 

레이블 파악

MATCH (n)
RETURN DISTINCT labels(n) AS labels, count(*) AS count

 

 

 

 

관계 파악

  • person끼리 서로 팔로우 하고 있는 관계 : FOLLOWS
  • PERSON과 MOVIE 객체(노드)와의 관계
    • acted_in : 배우
    • reviewed : 평가 (관객)
    • produced : 제작자/스탭진
    • wrote : 작가
    • directed : 감독
MATCH (p:Person)-[r:REVIEWED]->(m:Movie)
RETURN p, r, m

 

 

 

속성 파악

 

elementId, id는 다 공통적으로 있는 속성

  • 노드
    • movie
      • released, tagline, title
    • person
      • name
  • 관계
    • follows
    • acted_in
      • roles
    • reviewed
      • rating, summary
    • produced
    • wrote
    • directed

 

 

이렇게 데이터를 시각적으로 바로바로 볼 수 있는 것은 정말 좋은 것 같다. 직관적이어서!

계층적으로 데이터가 연결되어있을 때는 속도가 RDB보다 많이 빠르다고 하다.

이번 데이터는 basic한 것으로 영화 데이터이지만, 다른 산업 데이터를 다루다보면 분명 빛을 바라는 분야가 있겠지..

 

neo4j는 쿼리에 대한 결과를 볼 수 있지만, 이것으로 어떤 시스템을 만들 수는 없다.

그래서 python 환경에서 neo4j driver를 활용해 neo4j에서 검색한 결과를 활용하고자 한다.

 

 

노드 추출하기

🔻 MERGE

 

Merge절은 다음 두가지 작업을 위해 사용된다:

1. 기존 노드를 찾아서 연결하거나

2. 새 노드를 생성하고 연결한다

 

MERGE는 MATCH와 CREATE를 합친 기능으로, 찾거나 없으면 만드는 역할을 한다!

 

 

📍 기본 문법

MERGE (n:Label {property: value})
RETURN n

 

- n 이라는 변수를 가진 Label 타입의 노드를 찾고,

- 없으면 새로 만들어서 반환한다.

 

그래서 만약 MERGE (p: Person {name: 'Alice'}) RETURN n 을 한다면

name이 'Alice'인 Person 노드를 찾고, 없으면 새로 생성한다.

 

복합 속성 조합도 가능하다.

MERGE (u:User {id: '1234', email: 'abc@example.com'})
RETURN u

 

 

 

찾았을 때와 만들었을 때 각각 다르게 행동하도록 하고 싶다면 어떻게 해야할까?

바로바로 ON CREATE SET, ON MATCH SET을 사용하면 된다!

 

아래 예제는 MATCH와 CREATE를 결합한 것으로,
데이터가 매칭되었는지 또는 생성되었는지에 따라 추가 작업을 지정할 수 있도록 한다.

MERGE (p:PERSON {name: 'Su Min Choi'})
ON CREATE SET p.createdAt = timestamp()
ON MATCH SET p.lastLoggedInAt = timestamp()
RETURN p

 

위의 문장은, 해당 이름을 가진 Person 노드가 존재하지 않으면 새로 생성한다.

노드가 이미 존재한다면 lastLoggedInAt 속성을 현재 타임스탬프로 설정한다.

만약 노드가 존재하지 않아 새로 생성되었다면, createdAt 속성을 현재 타임스탬프로 설정한다.

 

 

 

비슷한 다른 예제를 살펴보면..

MERGE (m:Movie {title: 'Greyhound'})
ON CREATE SET m.released = "2020", m.lastUpdatedAt = timestamp()
ON MATCH SET m.lastUpdatedAt = timestamp()
RETURN m

 

"Greyhound"라는 제목을 가진 영화 노드를 생성하는 쿼리를 MERGE를 활용한 것이다.

노드가 존재하지 않는 경우, released 속성을 2020으로 설정하고 lastUpdatedAt 속성을 현재 타임스탬프로 설정한다.

노드가 이미 존재하는 경우에, lastUpdatedAt만 현재 타임스탬프로 설정한다.

 

 

 

관계도 MERGE 할 수 있는데 역시 예제를 살펴보자!

MATCH (p1: PERSON {name:"Alice"}), (p2: PERSON {name:"Bob"})
MERGE (p1) -[r:KNOWS]->(p2)
ON CREATE SET r.since = 2025
RETURN r

 

Alice가 Bob을 'KNOWS'하는 관계가 없으면 새로 만들고, 있으면 그대로 사용한다.

 

 

 

 

🔻 관계 만들기

 

전에도 살펴봤듯이, 특정 노드 간의 관계를 생성하고 싶다면 먼저 해당 노드를 찾은 후에 관계를 생성해줘야 한다.

따라서 MATCH를 통해 먼저 노드를 찾고, CREATE를 통해 관계를 생성해주면 된다.

 

📍 기본 예제

MATCH (p:PERSON), (m:Movie)
WHERE p.name = "Tom Hanks" AND m.title = "Cloud Atlas"
CREATE (p)-[w:WATCHED]->(m)
RETURN type(w)

 

 

 

 

🔻 관계 종류

 

Neo4j에서는 2가지의 관계 종류가 있다 : incoming, outgoing

 

-> 혹은 <- 으로 관계를 표현해주면 되는데, default가 -> 이어서 - 로 연결해도 작동이 된다고 한다.

 

 

 

 

다음 세가지 예제를 통해 cypher 구문을 더 익혀보자!

 

1. Cloud Atlas 영화를 누가 감독했는지 알아내기

MATCH (m: MOVIE {title: 'Cloud Atlas'})<-[d:DIRECTED]-(p:PERSON)
RETURN p.name

 

 

 

2. Tom Hanks와 협업한 모든 사람 찾기 (어떠한 영화든 상관 x)

MATCH (tom: PERSON {name: "Tom Honks"})-[:ACTED_IN]->(m:MOVIE)<-[:ACTED_IN]-(p:PERSON)
RETURN p.name

 

 

 

3. Cloud Atlas 영화와 연관있는 사람 모두 찾아내기

MATCH (p:PERSON)-[relatedTo]-(m:MOVIE {title: "Cloud Atlas"})
RETURN p.name, type(relatedTo)

 

기존에는 relationship명을 직접 사용해서 filtering을 했었는데 여기서는 관계의 타입이 아니라
변수 이름을 활용을 해서 모든 관계를 통칭한 것이다.

 

항목 의미
-[:TYPE]-> TYPE 이름을 명시해서 관계를 찾음
-[varName]-> 관계를 찾되, 관계 타입과 속성에 상관없이 찾고, 관계 자체를 varName 변수로 다룸

 

따라서 위 예제처럼 어떤 관계든 상관없이 연관이 있는 모든 노드를 추출하고자 할 때 찾은 관계를 변수에 저장해서 활용할 수 있다!

 

 

 

 

4. Kevin Bacon과 3 hops 떨어져있는 영화와 배우 찾기

MATCH (p:PERSON {name: 'Kevin Bacon'})-[*1..3]-(hollywood)
RETURN DISTINCT p, hollywood

 

위 구문에서는 방향을 무시하고, 1~3단계(hops) 떨어진 관계를 다라가고, 도착한 노드를 hollywood라는 변수에 저장하여 반환하고 있다.

3번과 비슷하게 hollywood는 변수일 뿐이다. 그래서 Kevin Bacon과 연결된 어떤 노드든 매칭이 될 수 있다.

이 노드가 Movie일수도 있고, 다른 Person일수도 있고, 다른 Label일 수도 있다!

 

 

 

이 구문에서처럼 hop를 뛰어 관계를 찾는 것을 'N hops query'라고 부른다.

 

📍 기본 문법

MATCH (startNode)-[*minHops..maxHops]-(endNode)
RETURN startNode, endNode

 

만약 2단계 떨어진 노드만 찾고 싶다면 [*2..2]로 사용하면 된다!

 

 

예제로 만약 서울에서 최대 5단계 떨어진 도시를 찾고 싶다면,

MATCH (start: City {name: 'Seoul'})-[*1..5]-(destination)
RETURN destination

 

이런 식으로 활용하면 된다!

 


이제 추천 시스템에 활용할만한 구문들을 살펴보겠다.

 

🔸 배우를 기반으로 비슷한 영화 추천

// 기준 영화 'The Matrix'를 봤을 때, 같은 배우가 출현한 다른 영화 추천
MATCH (m:Movie {title: "The Matrix"})<-[:ACTED_IN]-(p:Person)-[:ACTED_IN]->(other:Movie)
WHERE m <> other
RETURN other.title AS RecommendedMovie, COUNT(*) AS SameActors
ORDER BY SameActors DESC, other.title
LIMIT 5

 

'<>' 은 자기 자신은 제외하는 필터링이다.

따라서 위 구문에서는 'The Matrix' 영화를 제외한 결과를 출력하도록 함으로써, 이미 본 영화를 또 추천받는 상황을 피한 것이다.

 

 

 

🔸 리뷰 평점을 기반으로 높은 영화 추천

 

MATCH (:PERSON)-[r:REVIEWED]->(m:MOVIE)
WITH m, avg(r.rating) AS avgRating, count(r) AS reviewCount
WHERE reviewCount > 3
RETURN m.title AS Movie, avgRating, reviewCount
ORDER BY avgRating DESC
LIMIT 10

 

여기서 WITH이 등장을 하는데 좀 더 살펴보자!

 

🔻WITH 절

Cypher에서 WITH는 쿼리의 중간 결과를 전달하거나, 그룹핑/집계 후 필터링 할 때 사용한다.

근데 여기서 중요한 점은! WITH를 쓰는 순간, 그 이후 쿼리에서는 WITH에 적은 변수만 사용할 수 있다.

 

따라서 WITH에 m 없이
MATCH (p:PERSON)-[r:REVIEWED]->(m:Movie)
WITH avg(r.rating) AS avgRating

RETURN m.title, avgRating

로 사용하면 오류가 난다. 왜냐하면 WITH에 m을 넘기지 않았기 때문에 RETURN 절에서 m이라는 변수를 알아볼 수가 없다.

 

즉, WITH는 일종의 쿼리 블록(block) 구분자이다.

쿼리 최적화를 쉽게 하고, 불필요한 데이터를 끊어버리고, 쿼리를 명확하게 나누기 위해서 사용된다고 한다.

만약 MATCH에서 쓴 모두 변수가 계속 살아있다면, 쿼리 엔진이 엄청 복잡하고 느려졌을 것이기 때문...

 

 

 

🔸 내가 팔로우 하는 사람을 기반으로 영화 추천

MATCH (me:Person {name: 'Tom Honks'})-[:FOLLOWS]->(friend: Person)-[r:REVIEWED]->(movie:Movie)
WHERE NOT (me)-[:REVIEWED]->(movie)
RETURN m.title, avg(r.rating) as friendRating
ORDER BY friendRating DESC
LIMIT 5

 

내가 안 본 영화 중에, 내가 팔로우 하는 사람이 높게 평점을 준 영화를 추출하는 것으로

협업 필터링의 개념과 유사하다.

 

 

 

🔸 GDS nodeSimilarity를 이용한 비슷한 영화 추천 (GDS 라이브러리)

 

- nodeSimilarity : 그래프 기반 노드 유사도 계산

- 주로 "같은 배우", "같은 감독" 등의 연결 구조를 기반으로 추천

 

//1. GDS Projection 만들기
CALL gds.graph.project(
	'movie-similarity-graph',
    ['Movie', 'Person'],
    {
    	ACTED_IN: {type: 'ACTED_IN', orientation: 'UNDIRECTED'},
        DIRECTED: {type: 'DIRECTED', orientation: 'UNDIRECTED'},
    }
);

//2. 노드 유사도 계산하기
CALL gds.nodeSimilartiy.stream('movie-similarity-graph')
YIELD node1, node2, similarity
WHERE gds.util.asNode(node1).title = "The Matrix"
RETURN gds.util.asNode(node1).title AS Movie,
	gds.util.asNode(node2).title AS RecommendedMovie,
    similarity
ORDER BY similarity DESC
LIMIT 5;

 

첫 번째 단계 : Graph를 메모리에 올리고

두 번째 단계 : The Matrix와 유사한 영화 찾기

 

끝나면 Graph는 삭제 가능하다 : CALL gds.graph.drop('movie-similarity-graph');

 

 

🔻Similarity

Neo4j에서 제공하는 similarity 기법은 아래 링크에서 확인 가능하다.

https://neo4j.com/docs/graph-data-science/current/algorithms/similarity-functions/

https://neo4j.com/docs/graph-data-science/current/algorithms/node-similarity/

 

내용이 길어질 것 같아, similarity 기법에 관한 내용은 다음 포스트에서 작성을 해야겠다!

 

 

우선 위 cypher 구문에서는 Neo4j의 Graph Data Science(GDS) 라이브러리에서 제공하는 gds.nodeSimlarity 알고리즘을 사용하고 있다.

gds.nodeSimilarity 알고리즘은 주어진 노드들의 연결 패턴을 벡터화한 후, 코사인 유사도를 계산한다.

 

노드와 노드 간의 관계가 맺어져 있으면, 노드 간 연결 패턴을 기반으로 유사도를 구한다.

따라서 각 노드는 다른 노드들간의 연결 상태를 기준으로 벡터화가 된다.

 

예를 들어, 어떤 영화가 어떤 사용자들에게 리뷰되었는가 → 유저에 대한 0/1 벡터

ex.

The Matrix → [1, 0, 1, 1, 0, ...]  (리뷰한 유저 목록 기준)
Inception  → [1, 1, 0, 1, 0, ...]

 

 

 

그럼 다른 유사도 기법을 사용할 수는 없을까?

Default 유사도 계산 기법이 코사인 유사도인 것이고, similarityMetric 설정 옵션을 바꾸어 다른 기법을 사용할 수 있다.

 

📍설정 옵션 예시

CALL gds.nodeSimilarity.write({
	nodeProjection: 'Movie',
    relationshipProjection: 'REVIEWED',
    similarityMetric: 'COSINE',          //기본값
    writeRelationshipType: 'SIMILAR',
    writeProperty: 'score'
})
YIELD nodesCompared, relationshipWritten, averageSimilarity;

 

그래서 원한다면 similarityMetric: 'JACCARD'로 바꿀 수도 있는 것이다~!

 

 

 

 

🔸 GDS node2vec 임베딩 + 추천

 

- Node Embedding을 통해 유사 노드를 추천하는 방법

- DeepWalk, Node2Vec 방식

- 정확도가 높은 편

 

//1. Graph projection
CALL gds.graph.project(
	'movie-node2vec-graph',
    ['Movie', 'Person'],
    {
    	ACTED_IN : {type: 'ACTED_IN', orientation: 'UNDIRECTED'},
        DIRECTED : {type: 'DIRECTED', orientation: 'UNDRIECTED'}
    }
);

//2. node2vec 임베딩 생성
CALL gds.fastNode2Vec.write(
	'movie-node2vec-graph',
    {
    	embeddingDimension: 64,
        writeProperty: 'embedding'
    }
);

//3. 코사인 유사도로 비슷한 영화 찾기
MATCH (m1:Movie {title: "The Matrix"}), (m2:Movie)
WHERE m1 <> m2 AND exists(m1.embedding) AND exists(m2.embedding)
WITH m1, m2, gds.similarity.cosine(m1.embedding, m2.embedding) AS score
RETURN m2.title AS RecommendedMovie, score
ORDER BY score DESC
LIMIT 5;

 

 

이처럼 Neo4j에서도 빠른 계산을 위해 노드나 관계를 벡터로 변환하는 임베딩을 활용한다.

임베딩을 통해 각각의 노드를 고정된 차원의 수치 벡터(예: 128차원, 256차원)로 바꿀 수 있다.

임베딩을 하는 이유는, 노드와 노드 간 유사도를 쉽고 빠르게 계산하기 위해서 활용한다.

 

Neo4j에서 임베딩을 하는 대표적인 방법들은 아래와 같다.

방법 설명 특징
Node2Vec 랜덤 워크(Random Walk) 기반으로 주변 이웃 정보를 학습해서 임베딩 로컬 구조 반영
FastRP (Fast Random Projection) 빠른 방식으로 노드 임베딩 계산 대규모 그래프에 적합
GraphSAGE, GCN (ML 모델) 인접 노드 정보를 학습하는 딥러닝 기반 임베딩 (주로 외부 라이브러리에서)

 

 

 

 


Movie Recommendation System

 

우선 위에서 살펴본 데이터로 추천시스템에 활용할만한 속성과 관계는

1. cold start problem (새로운 사용자/아이템이 생겼을 때 추천을 연결지을 데이터 부족 문제)

- released, tagline 등 movie 속성 데이터 활용

> content based를 활용할 수 있음. 단, 초기에 사용자로부터 관심사 설문을 짧게 받아야 함
   ex. "좋아하는 장르/배우/영화 3개를 골라주세요"

 

 

2. content based recsys

- 영화 자체 데이터를 활용 : 동일 배우, 감독, 작가

- tagline : 영화 간 키워드 기반 유사도 측정 (NLP 분석 : tagline을 토큰화 - 주제 키워드 매칭)

- multi-hop 연결

- acted_in 역할(role) 활용 : 비슷한 캐릭터 위주로 추천 가능

 

 

3. collaborative filtering

- collaborative filtering에서 follow한 person의 정보 활용 + review 결합 : 내가 팔로우하는 사람 중에서 평점 높은 영화를 추천 우선순위에 반영

- follow 경로를 활용한 social graph 추천 : 친구의 친구의 친구...정보 활용 가능한 방식일듯

- rating 정보 활용 : person1과 비슷한 rating 점수를 준 사람은 비슷한 영화를 선호할 것이라고 생각하는 것 → MF, ALS 적용 가능

- summary 정보   llm으로 좋아하는 리뷰 성향 분석하거나, 감정 분석을 해서 긍정 리뷰가 많은 영화만 추천 필터링

 

여기서 rating에는 편향이 되어있을 수 있어서, "후한 사람" or "짠 사람" 성향을 보정한 협업 필터링을 만들 수 있을 것 같다.

그리고 released 정보는 선택한 영화와 같은 시대에 개봉된 것부터 정렬을 해서 보여주고자 한다.