Ver3.5 ๐ธ๏ธ Neo4j ๊ทธ๋ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ก ์์ฑํ๋ ์ค๋งํธ ์ถ์ฒ ์์คํ ๊ตฌํ ์์ ๊ฐ์ด๋

๐ธ๏ธ Neo4j ๊ทธ๋ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ก ์์ฑํ๋ ์ค๋งํธ ์ถ์ฒ ์์คํ ๊ตฌํ ์์ ๊ฐ์ด๋
๊ด๊ณ(Relationship)๊ฐ ๊ณง ๋ฐ์ดํฐ๋ค! ๊ทธ๋ํ DB์ ์ฒ ํ๋ถํฐ ์ค์ ์ถ์ฒ ์๊ณ ๋ฆฌ์ฆ๊น์ง, ์น๊ตฌ์ฒ๋ผ ์ฝ๊ณ ์ฌ๋ฏธ์๊ฒ ํํค์ณ๋ณด์ ๐
์ฐ๋ฆฌ๊ฐ ํํ ์ฐ๋ MySQL, PostgreSQL ๊ฐ์ ๊ด๊ณํ ๋ฐ์ดํฐ๋ฒ ์ด์ค(RDBMS)๋ ์ ๋ง ํ๋ฅญํ ๋๊ตฌ์ผ. ๊ทผ๋ฐ ๋ฑ ํ ๊ฐ์ง ์ํฉ์์ ์์ฒญ๋๊ฒ ํ๋ค์ด์ง๋ ๊ฒฝ์ฐ๊ฐ ์์ด. ๋ฐ๋ก "๊ด๊ณ๊ฐ ๋ณต์กํ๊ฒ ์ฝํ์์ ๋"์ผ.
์๋ฅผ ๋ค์ด๋ณผ๊ฒ. ๋ทํ๋ฆญ์ค ๊ฐ์ ์คํธ๋ฆฌ๋ฐ ์๋น์ค์์ "์ด ์ํ๋ฅผ ๋ณธ ์ฌ๋๋ค์ด ์ข์ํ๋ ๋ค๋ฅธ ์ํ"๋ฅผ ์ฐพ์ผ๋ ค๋ฉด ์ด๋ป๊ฒ ํด์ผ ํ ๊น? RDBMS์์๋ JOIN์ ์ฌ๋ฌ ๋ฒ ํด์ผ ํ๋๋ฐ, ๊ด๊ณ๊ฐ ๊น์ด์ง์๋ก ์ฟผ๋ฆฌ๊ฐ ํญ๋ฐ์ ์ผ๋ก ๋๋ ค์ง๊ฑฐ๋ . ์ด๊ฑธ "JOIN ์ง์ฅ"์ด๋ผ๊ณ ๋ถ๋ฅด๊ธฐ๋ ํด ๐
๊ทธ๋ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ ์ด ๋ฌธ์ ๋ฅผ ์์ ํ ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ์ ๊ทผํด. ๋ฐ์ดํฐ ์์ฒด๋ฅผ ๋
ธ๋(Node)์ ์ฃ์ง(Edge, ๊ด๊ณ)๋ก ์ ์ฅํ๋ ๊ฑฐ์ผ. ๊ทธ๋์ "Alice์ ์น๊ตฌ๊ฐ ๋ณธ ์ํ ์ค SF ์ฅ๋ฅด์ธ ๊ฒ"์ ์ฐพ์ ๋, ๊ทธ๋ฅ ๊ทธ๋ํ๋ฅผ ๋ฐ๋ผ๊ฐ๋ฉด ๋ผ. JOIN ์์ด!
Neo4j๋ ์ด ๊ทธ๋ํ DB ์ธ๊ณ์์ ๊ฐ์ฅ ์ ๋ช
ํ๊ณ ๋ง์ด ์ฐ์ด๋ ์คํ์์ค ์๋ฃจ์
์ด์ผ. 2007๋
์ ์ฒ์ ๋์๊ณ , ํ์ฌ ์ ์ธ๊ณ ์์ฒ ๊ฐ ๊ธฐ์
์์ ์ฌ์ฉ ์ค์ด์ง. ํนํ ์ถ์ฒ ์์คํ
, ์์
๋คํธ์ํฌ ๋ถ์, ์ฌ๊ธฐ ํ์ง, ์ง์ ๊ทธ๋ํ ๋ถ์ผ์์ ๋
๋ณด์ ์ธ ์ฑ๋ฅ์ ๋ณด์ฌ์ค.
Neo4j๋ฅผ ์ ๋๋ก ์ฐ๋ ค๋ฉด ๋ช ๊ฐ์ง ํต์ฌ ๊ฐ๋ ์ ํ์คํ ์ดํดํด์ผ ํด. ์ด๋ ต์ง ์์, ๊ฐ์ด ํ๋์ฉ ์ดํด๋ณด์!
๋ ธ๋๋ ๊ทธ๋ํ์ ๊ธฐ๋ณธ ๋จ์์ผ. ์ฌ๋, ์ํ, ์ ํ, ๋์ ๋ฑ ์ด๋ค "๊ฐ์ฒด(Entity)"๋ ๋ ธ๋๋ก ํํํ ์ ์์ด. ๋ ธ๋์๋ ๋ ์ด๋ธ(Label)๊ณผ ํ๋กํผํฐ(Property)๋ฅผ ๋ถ์ผ ์ ์์ด.
// ์ฌ์ฉ์ ๋
ธ๋ ์์
(alice:User {name: "Alice", age: 28, city: "์์ธ"})
// ์ํ ๋
ธ๋ ์์
(inception:Movie {title: "์ธ์
์
", year: 2010, rating: 8.8})
// ๋ ์ด๋ธ: User, Movie
// ํ๋กํผํฐ: name, age, city, title, year, rating
๊ด๊ณ๋ ๋ ๋ ธ๋๋ฅผ ์ฐ๊ฒฐํ๋ ํ์ดํ์ผ. ๊ด๊ณ์๋ ํ์ (Type)๊ณผ ํ๋กํผํฐ๋ฅผ ๋ถ์ผ ์ ์์ด. ๊ด๊ณ๋ ํญ์ ๋ฐฉํฅ์ด ์์ด (๋จ๋ฐฉํฅ), ํ์ง๋ง ์ฟผ๋ฆฌํ ๋๋ ์๋ฐฉํฅ์ผ๋ก ํ์ํ ์ ์์ด.
// Alice๊ฐ ์ธ์
์
์ ์์ฒญํ๊ณ ํ์ 9์ ์ ์คฌ๋ค
(alice)-[:WATCHED {score: 9, date: "2024-01-15"}]->(inception)
// Alice์ Bob์ ์น๊ตฌ ๊ด๊ณ
(alice)-[:FRIEND_OF {since: "2020-03-01"}]->(bob)
// ์ธ์
์
์ SF ์ฅ๋ฅด์ ์ํ๋ค
(inception)-[:BELONGS_TO]->(sf:Genre {name: "SF"})
Neo4j๋ Cypher๋ผ๋ ์์ฒด ์ฟผ๋ฆฌ ์ธ์ด๋ฅผ ์ฌ์ฉํด. SQL์ฒ๋ผ ์ ์ธ์ ์ด๊ณ , ๊ทธ๋ํ ํจํด์ ASCII ์ํธ์ฒ๋ผ ํํํ๋ ๊ฒ ํน์ง์ด์ผ. ์ฒ์ ๋ณด๋ฉด ์ข ๋ฏ์ค ์ ์๋๋ฐ, ์ต์ํด์ง๋ฉด ์ ๋ง ์ง๊ด์ ์ด์ผ!
// ๊ธฐ๋ณธ ํจํด: MATCH - WHERE - RETURN
MATCH (u:User {name: "Alice"})-[:WATCHED]->(m:Movie)
WHERE m.rating > 8.0
RETURN m.title, m.year
ORDER BY m.rating DESC
// ๊ฒฐ๊ณผ: Alice๊ฐ ๋ณธ ์ํ ์ค ํ์ 8.0 ์ด์์ธ ๊ฒ๋ค
| Cypher ํค์๋ | ์ญํ | SQL ๋์ |
|---|---|---|
MATCH | ๊ทธ๋ํ ํจํด ํ์ | SELECT + FROM + JOIN |
WHERE | ์กฐ๊ฑด ํํฐ๋ง | WHERE |
RETURN | ๊ฒฐ๊ณผ ๋ฐํ | SELECT (๊ฒฐ๊ณผ ์ปฌ๋ผ) |
CREATE | ๋ ธ๋/๊ด๊ณ ์์ฑ | INSERT |
MERGE | ์์ผ๋ฉด ์์ฑ, ์์ผ๋ฉด ์ ๋ฐ์ดํธ | UPSERT |
SET | ํ๋กํผํฐ ์ ๋ฐ์ดํธ | UPDATE SET |
DELETE | ๋ ธ๋/๊ด๊ณ ์ญ์ | DELETE |
WITH | ์ค๊ฐ ๊ฒฐ๊ณผ ํ์ดํ๋ผ์ธ | CTE (WITH) |
Neo4j๋ ์ฑ๋ฅ์ ์ํ ์ธ๋ฑ์ค๋ฅผ ์ง์ํด. ํนํ ์์ฃผ ๊ฒ์ํ๋ ํ๋กํผํฐ์๋ ๋ฐ๋์ ์ธ๋ฑ์ค๋ฅผ ๊ฑธ์ด์ค์ผ ํด.
// ์ธ๋ฑ์ค ์์ฑ CREATE INDEX user_name_idx FOR (u:User) ON (u.name); CREATE INDEX movie_title_idx FOR (m:Movie) ON (m.title); // ์ ๋ํฌ ์ ์ฝ์กฐ๊ฑด (์๋์ผ๋ก ์ธ๋ฑ์ค๋ ์์ฑ๋จ) CREATE CONSTRAINT user_id_unique FOR (u:User) REQUIRE u.id IS UNIQUE; CREATE CONSTRAINT movie_id_unique FOR (m:Movie) REQUIRE m.id IS UNIQUE;
์, ์ด์ ์ค์ ๋ก Neo4j๋ฅผ ์ค์นํด๋ณด์! ๊ฐ์ฅ ๋น ๋ฅธ ๋ฐฉ๋ฒ์ Docker๋ฅผ ์ฌ์ฉํ๋ ๊ฑฐ์ผ. ๋ก์ปฌ ํ๊ฒฝ์ ๋๋ฝํ์ง ์๊ณ ๊น๋ํ๊ฒ ์คํํ ์ ์๊ฑฐ๋ .
# Neo4j ์ต์ ๋ฒ์ Docker ์คํ docker run \ --name neo4j-recommend \ -p 7474:7474 \ -p 7687:7687 \ -e NEO4J_AUTH=neo4j/password123 \ -e NEO4J_PLUGINS='["apoc", "graph-data-science"]' \ -v $HOME/neo4j/data:/data \ -v $HOME/neo4j/logs:/logs \ -d \ neo4j:5.15.0 # ์คํ ํ์ธ docker ps # ๋ธ๋ผ์ฐ์ ์์ http://localhost:7474 ์ ์!
http://localhost:7474์ ์ ์ํ๋ฉด ์น ๊ธฐ๋ฐ ์ฟผ๋ฆฌ ๋๊ตฌ๊ฐ ์ด๋ ค. ์ฌ๊ธฐ์ Cypher ์ฟผ๋ฆฌ๋ฅผ ์ง์ ์คํํ๊ณ ๊ทธ๋ํ๋ฅผ ์๊ฐํํ ์ ์์ด. ์ฒ์ ์ ์ ์ ID: neo4j, PW: password123์ผ๋ก ๋ก๊ทธ์ธ!
์ถ์ฒ ์์คํ ์ Python์ผ๋ก ๊ตฌํํ ๊ฑฐ๋๊น, Neo4j Python ๋๋ผ์ด๋ฒ๋ฅผ ์ค์นํด์ผ ํด.
# pip์ผ๋ก ์ค์น pip install neo4j # ๋๋ ์ถ์ฒ ์์คํ ์ ์ฒด ์คํ ํ๋ฒ์ pip install neo4j pandas numpy scikit-learn fastapi uvicorn
from neo4j import GraphDatabase
class Neo4jConnection:
def __init__(self, uri, user, password):
self.driver = GraphDatabase.driver(
uri,
auth=(user, password),
max_connection_pool_size=50, # ์ปค๋ฅ์
ํ ์ค์
connection_timeout=30
)
def close(self):
self.driver.close()
def query(self, query, parameters=None):
with self.driver.session(database="neo4j") as session:
result = session.run(query, parameters or {})
return [record.data() for record in result]
def write_query(self, query, parameters=None):
with self.driver.session(database="neo4j") as session:
result = session.execute_write(
lambda tx: tx.run(query, parameters or {}).data()
)
return result
# ์ฐ๊ฒฐ ์ธ์คํด์ค ์์ฑ
conn = Neo4jConnection(
uri="bolt://localhost:7687",
user="neo4j",
password="password123"
)
# ์ฐ๊ฒฐ ํ
์คํธ
result = conn.query("RETURN 'Neo4j ์ฐ๊ฒฐ ์ฑ๊ณต! ๐' AS message")
print(result)
# [{'message': 'Neo4j ์ฐ๊ฒฐ ์ฑ๊ณต! ๐'}]
APOC(Awesome Procedures On Cypher)๋ Neo4j์ ๊ณต์ ํ์ฅ ํ๋ฌ๊ทธ์ธ์ด์ผ. ์๋ฐฑ ๊ฐ์ง ์ ํธ๋ฆฌํฐ ํจ์๋ฅผ ์ ๊ณตํ๋๋ฐ, ์ถ์ฒ ์์คํ ๊ตฌํ์์๋ ์์ฃผ ์ฐ์ฌ. ์ Docker ๋ช ๋ น์ด์์ ์ด๋ฏธ ์ค์นํ์ผ๋ ๋ฐ๋ก ์ฌ์ฉ ๊ฐ๋ฅํด!
// APOC ์ค์น ํ์ธ
CALL apoc.help("apoc")
// APOC ์ ์ฉํ ํจ์ ์์
// 1. ๋ฐฐ์น ๋ฐ์ดํฐ ์ฝ์
CALL apoc.periodic.iterate(
"UNWIND range(1, 1000) AS id RETURN id",
"CREATE (u:User {id: id, name: 'User_' + id})",
{batchSize: 100}
)
// 2. JSON ํ์ฑ
CALL apoc.load.json("https://api.example.com/movies")
YIELD value
MERGE (m:Movie {id: value.id})
SET m.title = value.title
์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ์ถ์ฒ ์์คํ ์ ๋ง๋ค์ด๋ณด์! ์ํ ์ถ์ฒ ์์คํ ์ ์์๋ก ํ ๊ฒ. ์ค์ ๋ก ๋ทํ๋ฆญ์ค, ์์ฑ ๊ฐ์ ์๋น์ค๋ค์ด ๋น์ทํ ๊ตฌ์กฐ๋ฅผ ์ฌ์ฉํด.
// ===== ๋
ธ๋ ์์ฑ =====
// ์ฌ์ฉ์ ๋
ธ๋
CREATE (u:User {
id: "user_001",
name: "Alice",
age: 28,
city: "์์ธ",
joined: date("2023-01-15")
})
// ์ํ ๋
ธ๋
CREATE (m:Movie {
id: "movie_001",
title: "์ธ์
์
",
year: 2010,
rating: 8.8,
runtime: 148,
language: "์์ด"
})
// ์ฅ๋ฅด ๋
ธ๋
CREATE (g:Genre {id: "genre_001", name: "SF"})
CREATE (g2:Genre {id: "genre_002", name: "์ค๋ฆด๋ฌ"})
// ๊ฐ๋
๋
ธ๋
CREATE (d:Director {
id: "dir_001",
name: "ํฌ๋ฆฌ์คํ ํผ ๋๋",
nationality: "์๊ตญ"
})
// ํ๊ทธ ๋
ธ๋
CREATE (t:Tag {name: "๋ง์ธ๋๋ธ๋ก์"})
CREATE (t2:Tag {name: "๋ณต์กํํ๋กฏ"})
// ===== ๊ด๊ณ ์์ฑ =====
// ์ฌ์ฉ์๊ฐ ์ํ๋ฅผ ์์ฒญ
MATCH (u:User {id: "user_001"}), (m:Movie {id: "movie_001"})
CREATE (u)-[:WATCHED {
score: 9.0,
date: date("2024-01-20"),
completed: true
}]->(m)
// ์ํ๊ฐ ์ฅ๋ฅด์ ์ํจ
MATCH (m:Movie {id: "movie_001"}), (g:Genre {name: "SF"})
CREATE (m)-[:BELONGS_TO]->(g)
// ์ํ์ ๊ฐ๋
MATCH (m:Movie {id: "movie_001"}), (d:Director {id: "dir_001"})
CREATE (m)-[:DIRECTED_BY]->(d)
// ์ฌ์ฉ์ ๊ฐ ์น๊ตฌ ๊ด๊ณ
MATCH (u1:User {id: "user_001"}), (u2:User {id: "user_002"})
CREATE (u1)-[:FRIEND_OF {since: date("2022-06-01")}]->(u2)
CREATE (u2)-[:FRIEND_OF {since: date("2022-06-01")}]->(u1)
// ์ํ์ ํ๊ทธ ์ถ๊ฐ
MATCH (m:Movie {id: "movie_001"}), (t:Tag {name: "๋ง์ธ๋๋ธ๋ก์"})
CREATE (m)-[:TAGGED_WITH]->(t)
WATCHED, FRIEND_OF). ๊ทธ๋ฆฌ๊ณ ๊ด๊ณ๋ ๋์ฌ๋ ๋์ฌ๊ตฌ๋ก ํํํ๋ ๊ฒ ๊ฐ๋
์ฑ์ด ์ข์. ๋
ธ๋ ๋ ์ด๋ธ์ ๋ช
์ฌ๋ก!
์ถ์ฒ ์์คํ ์ ํต์ฌ ์๊ณ ๋ฆฌ์ฆ์ ํฌ๊ฒ ์ธ ๊ฐ์ง์ผ. ๊ฐ๊ฐ์ ์ฅ๋จ์ ์ด ์๊ณ , ์ค์ ์๋น์ค์์๋ ์ด๊ฒ๋ค์ ์กฐํฉํด์ ์ฌ์ฉํด. ํ๋์ฉ Neo4j๋ก ๊ตฌํํด๋ณด์!
ํ์ ํํฐ๋ง์ ํต์ฌ ์์ด๋์ด๋ "๋์ ๋น์ทํ ์ทจํฅ์ ๊ฐ์ง ์ฌ๋๋ค์ด ์ข์ํ ๊ฒ์ ๋๋ ์ข์ํ ๊ฒ"์ด์ผ. Neo4j์์๋ ์ด "๋น์ทํ ์ทจํฅ"์ ๊ณตํต์ผ๋ก ์์ฒญํ ์ํ์ ์๋ก ์ธก์ ํ ์ ์์ด.
// ===== ์ฌ์ฉ์ ๊ธฐ๋ฐ ํ์
ํํฐ๋ง =====
// "๋์ ๋น์ทํ ์ฌ์ฉ์๋ค์ด ๋ณธ ์ํ ์ถ์ฒ"
// 1๋จ๊ณ: ์ ์ฌํ ์ฌ์ฉ์ ์ฐพ๊ธฐ (๊ณตํต ์์ฒญ ์ํ ๊ธฐ๋ฐ)
MATCH (target:User {id: "user_001"})-[:WATCHED]->(m:Movie)
<-[:WATCHED]-(similar:User)
WHERE target <> similar
WITH similar, COUNT(m) AS commonMovies
ORDER BY commonMovies DESC
LIMIT 10
RETURN similar.name, commonMovies
// 2๋จ๊ณ: ์ ์ฌ ์ฌ์ฉ์๋ค์ด ๋ณธ ์ํ ์ค ๋ด๊ฐ ์ ๋ณธ ๊ฒ ์ถ์ฒ
MATCH (target:User {id: "user_001"})-[:WATCHED]->(watched:Movie)
WITH target, COLLECT(watched.id) AS watchedIds
MATCH (target)-[:WATCHED]->(m:Movie)
<-[:WATCHED]-(similar:User)-[:WATCHED]->(rec:Movie)
WHERE NOT rec.id IN watchedIds
AND target <> similar
WITH rec,
COUNT(DISTINCT similar) AS recommendedBy,
AVG(rec.rating) AS avgRating
ORDER BY recommendedBy DESC, avgRating DESC
LIMIT 10
RETURN rec.title, rec.year, rec.rating, recommendedBy
// ===== ์ฌํด๋ผ๋ ์ ์ฌ๋(Jaccard Similarity) ๊ธฐ๋ฐ ํ์
ํํฐ๋ง =====
// ๋ ์ ๊ตํ ์ ์ฌ๋ ๊ณ์ฐ
MATCH (u1:User {id: "user_001"})-[:WATCHED]->(m:Movie)
WITH u1, COLLECT(m.id) AS u1Movies
MATCH (u2:User)-[:WATCHED]->(m2:Movie)
WHERE u2 <> u1
WITH u1, u1Movies, u2, COLLECT(m2.id) AS u2Movies
// ์ฌํด๋ผ๋ ์ ์ฌ๋ = ๊ต์งํฉ / ํฉ์งํฉ
WITH u1, u2,
[x IN u1Movies WHERE x IN u2Movies] AS intersection,
u1Movies + [x IN u2Movies WHERE NOT x IN u1Movies] AS union_set
WITH u1, u2,
toFloat(SIZE(intersection)) / SIZE(union_set) AS jaccardSimilarity
WHERE jaccardSimilarity > 0.1
ORDER BY jaccardSimilarity DESC
LIMIT 5
RETURN u2.name, jaccardSimilarity
์ฝํ ์ธ ๊ธฐ๋ฐ ํํฐ๋ง์ "๋ด๊ฐ ์ข์ํ ์ํ์ ์์ฑ(์ฅ๋ฅด, ๊ฐ๋ , ํ๊ทธ ๋ฑ)๊ณผ ๋น์ทํ ์ํ๋ฅผ ์ถ์ฒ"ํ๋ ๋ฐฉ์์ด์ผ.
// ===== ์ฝํ
์ธ ๊ธฐ๋ฐ ํํฐ๋ง =====
// "๋ด๊ฐ ์ข์ํ ์ํ์ ๋น์ทํ ์์ฑ์ ์ํ ์ถ์ฒ"
// ๋ฐฉ๋ฒ 1: ๊ฐ์ ์ฅ๋ฅด + ๊ฐ์ ๊ฐ๋
์ํ ์ถ์ฒ
MATCH (u:User {id: "user_001"})-[w:WATCHED]->(liked:Movie)
WHERE w.score >= 8.0 // ๋์ ํ์ ์ ์ค ์ํ๋ง
WITH u, liked
// ๊ฐ์ ์ฅ๋ฅด์ ๋ค๋ฅธ ์ํ ์ฐพ๊ธฐ
MATCH (liked)-[:BELONGS_TO]->(g:Genre)<-[:BELONGS_TO]-(rec:Movie)
WHERE rec <> liked
// ๋ด๊ฐ ์ด๋ฏธ ๋ณธ ์ํ ์ ์ธ
AND NOT (u)-[:WATCHED]->(rec)
WITH rec,
COUNT(DISTINCT g) AS sharedGenres,
rec.rating AS movieRating
ORDER BY sharedGenres DESC, movieRating DESC
LIMIT 10
RETURN rec.title, rec.year, rec.rating, sharedGenres
// ๋ฐฉ๋ฒ 2: ํ๊ทธ ๊ธฐ๋ฐ ์ ์ฌ๋
MATCH (u:User {id: "user_001"})-[w:WATCHED]->(liked:Movie)-[:TAGGED_WITH]->(t:Tag)
WHERE w.score >= 7.0
WITH u, COLLECT(DISTINCT t.name) AS likedTags
MATCH (rec:Movie)-[:TAGGED_WITH]->(t2:Tag)
WHERE t2.name IN likedTags
AND NOT (u)-[:WATCHED]->(rec)
WITH rec, COUNT(t2) AS tagMatches
ORDER BY tagMatches DESC
LIMIT 10
RETURN rec.title, tagMatches
์ด๊ฒ ์ง์ง Neo4j์ ๊ฝ์ด์ผ! ๊ทธ๋ํ ๊ตฌ์กฐ๋ฅผ ํ์ฉํ ๊ฒฝ๋ก ๊ธฐ๋ฐ ์ถ์ฒ์ RDBMS๋ก๋ ๊ตฌํํ๊ธฐ ๋งค์ฐ ์ด๋ ต์ง๋ง, Neo4j์์๋ ์์ฐ์ค๋ฝ๊ฒ ํํ๋ผ.
// ===== ๊ทธ๋ํ ๊ฒฝ๋ก ๊ธฐ๋ฐ ์ถ์ฒ =====
// "์น๊ตฌ์ ์น๊ตฌ๊ฐ ์ข์ํ ์ํ" (์์
์ถ์ฒ)
MATCH (u:User {id: "user_001"})-[:FRIEND_OF*1..2]->(friend:User)
WHERE u <> friend
WITH u, friend,
length((u)-[:FRIEND_OF*..2]->(friend)) AS distance
MATCH (friend)-[w:WATCHED]->(rec:Movie)
WHERE w.score >= 8.0
AND NOT (u)-[:WATCHED]->(rec)
WITH rec,
SUM(1.0 / distance) AS socialScore, // ๊ฐ๊น์ด ์น๊ตฌ์ผ์๋ก ๊ฐ์ค์น ๋์
AVG(w.score) AS avgFriendScore
ORDER BY socialScore DESC, avgFriendScore DESC
LIMIT 10
RETURN rec.title, rec.year,
ROUND(socialScore * 100) / 100 AS socialScore,
ROUND(avgFriendScore * 10) / 10 AS avgFriendScore
// ===== PageRank ๊ธฐ๋ฐ ์ํ ์ค์๋ ๊ณ์ฐ =====
// Graph Data Science ๋ผ์ด๋ธ๋ฌ๋ฆฌ ํ์ฉ
CALL gds.graph.project(
'movieGraph',
['User', 'Movie'],
{WATCHED: {orientation: 'UNDIRECTED'}}
)
CALL gds.pageRank.stream('movieGraph')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS node, score
WHERE node:Movie
RETURN node.title, score
ORDER BY score DESC
LIMIT 10
FRIEND_OF*1..2์ฒ๋ผ ๊ฐ๋ณ ๊ธธ์ด ๊ฒฝ๋ก ํ์์ ๋ฐ์ดํฐ๊ฐ ๋ง์์๋ก ์ฑ๋ฅ์ ์ํฅ์ ์ค ์ ์์ด. ์ค์ ์๋น์ค์์๋ ์ต๋ ๊น์ด๋ฅผ ์ ํํ๊ณ , ์ธ๋ฑ์ค๋ฅผ ์ ์ค๊ณํด์ผ ํด!
Neo4j์ Graph Data Science(GDS) ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ ๊ทธ๋ํ ๋ถ์ ์๊ณ ๋ฆฌ์ฆ์ ๋ฐ๋ก ์ธ ์ ์๊ฒ ํด์ฃผ๋ ๊ฐ๋ ฅํ ๋๊ตฌ์ผ. ์ถ์ฒ ์์คํ ์์ ํนํ ์ ์ฉํ ์๊ณ ๋ฆฌ์ฆ๋ค์ ์ดํด๋ณด์!
GDS์ Node Similarity ์๊ณ ๋ฆฌ์ฆ์ ๋ ๋ ธ๋๊ฐ ์ผ๋ง๋ ๋น์ทํ ์ด์์ ๊ฐ์ง๊ณ ์๋์ง ๊ณ์ฐํด์ค. ํ์ ํํฐ๋ง์ ํต์ฌ ์ฐ์ฐ์ ์๋์ผ๋ก ํด์ฃผ๋ ๊ฑฐ์ผ!
// 1. ๊ทธ๋ํ ํ๋ก์ ์
์์ฑ (๋ฉ๋ชจ๋ฆฌ์ ๊ทธ๋ํ ๋ก๋)
CALL gds.graph.project(
'userMovieGraph', // ๊ทธ๋ํ ์ด๋ฆ
['User', 'Movie'], // ๋
ธ๋ ๋ ์ด๋ธ
{
WATCHED: {
orientation: 'UNDIRECTED',
properties: ['score'] // ๊ด๊ณ ๊ฐ์ค์น
}
}
)
YIELD graphName, nodeCount, relationshipCount
RETURN graphName, nodeCount, relationshipCount
// 2. Node Similarity ์คํ (์ ์ฌํ ์ฌ์ฉ์ ์ ์ฐพ๊ธฐ)
CALL gds.nodeSimilarity.stream('userMovieGraph', {
topK: 10, // ๊ฐ ๋
ธ๋๋น ์์ 10๊ฐ ์ ์ฌ ๋
ธ๋
similarityCutoff: 0.1, // ์ต์ ์ ์ฌ๋ ์๊ณ๊ฐ
nodeLabels: ['User'], // User ๋
ธ๋๋ง ๋น๊ต
relationshipTypes: ['WATCHED']
})
YIELD node1, node2, similarity
WITH gds.util.asNode(node1) AS u1,
gds.util.asNode(node2) AS u2,
similarity
RETURN u1.name, u2.name,
ROUND(similarity * 100) / 100 AS similarity
ORDER BY similarity DESC
LIMIT 20
// 3. ์ ์ฌ๋ ๊ด๊ณ๋ฅผ ๊ทธ๋ํ์ ์ ์ฅ (๋์ค์ ์ฌ์ฌ์ฉ)
CALL gds.nodeSimilarity.write('userMovieGraph', {
topK: 10,
similarityCutoff: 0.1,
nodeLabels: ['User'],
writeRelationshipType: 'SIMILAR_TO',
writeProperty: 'score'
})
YIELD nodesCompared, relationshipsWritten
RETURN nodesCompared, relationshipsWritten
Louvain ์๊ณ ๋ฆฌ์ฆ์ผ๋ก ๋น์ทํ ์ทจํฅ์ ๊ฐ์ง ์ฌ์ฉ์ ๊ทธ๋ฃน์ ์๋์ผ๋ก ์ฐพ์ ์ ์์ด. ์ด ๊ทธ๋ฃน ์ ๋ณด๋ฅผ ์ถ์ฒ์ ํ์ฉํ๋ฉด ๋ ์ ํํ ์ถ์ฒ์ด ๊ฐ๋ฅํด!
// Louvain ์ปค๋ฎค๋ํฐ ํ์ง
CALL gds.louvain.stream('userMovieGraph', {
nodeLabels: ['User'],
relationshipTypes: ['SIMILAR_TO'],
relationshipWeightProperty: 'score'
})
YIELD nodeId, communityId
WITH gds.util.asNode(nodeId) AS user, communityId
RETURN communityId,
COLLECT(user.name) AS members,
COUNT(*) AS groupSize
ORDER BY groupSize DESC
LIMIT 10
// ์ปค๋ฎค๋ํฐ ID๋ฅผ ๋
ธ๋์ ์ ์ฅ
CALL gds.louvain.write('userMovieGraph', {
nodeLabels: ['User'],
writeProperty: 'communityId'
})
YIELD communityCount, modularity
RETURN communityCount, modularity
// ๊ฐ์ ์ปค๋ฎค๋ํฐ ์ฌ์ฉ์๋ค์ ์ธ๊ธฐ ์ํ ์ถ์ฒ
MATCH (u:User {id: "user_001"})
WITH u.communityId AS myCommunity, u
MATCH (peer:User {communityId: myCommunity})-[w:WATCHED]->(m:Movie)
WHERE peer <> u
AND NOT (u)-[:WATCHED]->(m)
WITH m, COUNT(peer) AS peerCount, AVG(w.score) AS avgScore
ORDER BY peerCount DESC, avgScore DESC
LIMIT 10
RETURN m.title, peerCount, ROUND(avgScore * 10) / 10 AS avgScore
// ์ํ PageRank ๊ณ์ฐ (๋ง์ด ์์ฒญ๋๊ณ ์ข์ ํ์ ์ ๋ฐ์ ์ํ)
CALL gds.pageRank.write('userMovieGraph', {
nodeLabels: ['Movie'],
maxIterations: 20,
dampingFactor: 0.85,
writeProperty: 'pageRankScore'
})
YIELD nodePropertiesWritten, ranIterations
// PageRank ๋์ ์ํ = ํธ๋ ๋ํ ์ํ๋ก ์ถ์ฒ
MATCH (m:Movie)
WHERE m.pageRankScore IS NOT NULL
RETURN m.title, m.year,
ROUND(m.pageRankScore * 1000) / 1000 AS pageRank
ORDER BY m.pageRankScore DESC
LIMIT 10
์ด์ ์ง๊ธ๊น์ง ๋ง๋ Neo4j ์ถ์ฒ ๋ก์ง์ Python FastAPI๋ก ๊ฐ์ธ์ ์ค์ ์๋น์ค์์ ์ธ ์ ์๋ API๋ฅผ ๋ง๋ค์ด๋ณด์! ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์๋ ์ด๋ฐ ๋ฐฉ์์ผ๋ก ์ฌ๋ฅ ์ถ์ฒ ๊ธฐ๋ฅ์ ๊ตฌํํ ์ ์์ด.
recommendation-api/ โโโ main.py # FastAPI ์ฑ ์ง์ ์ โโโ database.py # Neo4j ์ฐ๊ฒฐ ๊ด๋ฆฌ โโโ models.py # Pydantic ๋ชจ๋ธ โโโ recommender.py # ์ถ์ฒ ์๊ณ ๋ฆฌ์ฆ ํด๋์ค โโโ routers/ โ โโโ recommend.py # ์ถ์ฒ API ๋ผ์ฐํฐ โ โโโ users.py # ์ฌ์ฉ์ API ๋ผ์ฐํฐ โโโ requirements.txt
# database.py
from neo4j import GraphDatabase
from contextlib import contextmanager
import os
class Neo4jDatabase:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.driver = GraphDatabase.driver(
os.getenv("NEO4J_URI", "bolt://localhost:7687"),
auth=(
os.getenv("NEO4J_USER", "neo4j"),
os.getenv("NEO4J_PASSWORD", "password123")
),
max_connection_pool_size=50
)
return cls._instance
@contextmanager
def get_session(self):
session = self.driver.session(database="neo4j")
try:
yield session
finally:
session.close()
def execute_read(self, query: str, params: dict = None):
with self.get_session() as session:
result = session.run(query, params or {})
return [record.data() for record in result]
def execute_write(self, query: str, params: dict = None):
with self.get_session() as session:
result = session.execute_write(
lambda tx: list(tx.run(query, params or {}))
)
return result
def close(self):
self.driver.close()
# ์ฑ๊ธํค ์ธ์คํด์ค
db = Neo4jDatabase()
# recommender.py
from database import db
from typing import List, Dict, Optional
class MovieRecommender:
def collaborative_filter(
self,
user_id: str,
limit: int = 10,
min_similarity: float = 0.1
) -> List[Dict]:
"""ํ์
ํํฐ๋ง ๊ธฐ๋ฐ ์ถ์ฒ"""
query = """
MATCH (target:User {id: $userId})-[:WATCHED]->(watched:Movie)
WITH target, COLLECT(watched.id) AS watchedIds
MATCH (target)-[:WATCHED]->(m:Movie)
<-[:WATCHED]-(similar:User)-[:WATCHED]->(rec:Movie)
WHERE NOT rec.id IN watchedIds
AND target <> similar
WITH rec,
COUNT(DISTINCT similar) AS recommendedBy,
AVG(rec.rating) AS avgRating,
COLLECT(DISTINCT similar.name)[..3] AS topRecommenders
ORDER BY recommendedBy DESC, avgRating DESC
LIMIT $limit
RETURN rec.id AS id,
rec.title AS title,
rec.year AS year,
rec.rating AS rating,
recommendedBy,
topRecommenders,
'collaborative' AS algorithm
"""
return db.execute_read(query, {
"userId": user_id,
"limit": limit
})
def content_based_filter(
self,
user_id: str,
limit: int = 10,
min_score: float = 7.0
) -> List[Dict]:
"""์ฝํ
์ธ ๊ธฐ๋ฐ ํํฐ๋ง ์ถ์ฒ"""
query = """
MATCH (u:User {id: $userId})-[w:WATCHED]->(liked:Movie)
WHERE w.score >= $minScore
WITH u, liked
MATCH (liked)-[:BELONGS_TO]->(g:Genre)<-[:BELONGS_TO]-(rec:Movie)
WHERE rec <> liked
AND NOT (u)-[:WATCHED]->(rec)
WITH rec,
COUNT(DISTINCT g) AS sharedGenres,
COLLECT(DISTINCT g.name)[..3] AS matchedGenres
ORDER BY sharedGenres DESC, rec.rating DESC
LIMIT $limit
RETURN rec.id AS id,
rec.title AS title,
rec.year AS year,
rec.rating AS rating,
sharedGenres,
matchedGenres,
'content_based' AS algorithm
"""
return db.execute_read(query, {
"userId": user_id,
"minScore": min_score,
"limit": limit
})
def social_recommend(
self,
user_id: str,
limit: int = 10,
max_depth: int = 2
) -> List[Dict]:
"""์์
๊ทธ๋ํ ๊ธฐ๋ฐ ์ถ์ฒ (์น๊ตฌ์ ์น๊ตฌ)"""
query = """
MATCH (u:User {id: $userId})-[:FRIEND_OF*1..$maxDepth]->(friend:User)
WHERE u <> friend
WITH u, friend,
length((u)-[:FRIEND_OF*..2]->(friend)) AS distance
MATCH (friend)-[w:WATCHED]->(rec:Movie)
WHERE w.score >= 7.5
AND NOT (u)-[:WATCHED]->(rec)
WITH rec,
SUM(1.0 / distance) AS socialScore,
AVG(w.score) AS avgFriendScore,
COUNT(DISTINCT friend) AS friendCount
ORDER BY socialScore DESC, avgFriendScore DESC
LIMIT $limit
RETURN rec.id AS id,
rec.title AS title,
rec.year AS year,
rec.rating AS rating,
ROUND(socialScore * 100) / 100 AS socialScore,
friendCount,
'social' AS algorithm
"""
return db.execute_read(query, {
"userId": user_id,
"maxDepth": max_depth,
"limit": limit
})
def hybrid_recommend(
self,
user_id: str,
limit: int = 10
) -> List[Dict]:
"""ํ์ด๋ธ๋ฆฌ๋ ์ถ์ฒ (ํ์
+ ์ฝํ
์ธ + ์์
์กฐํฉ)"""
# ๊ฐ ์๊ณ ๋ฆฌ์ฆ์์ ํ๋ณด ์์ง
collab = self.collaborative_filter(user_id, limit=20)
content = self.content_based_filter(user_id, limit=20)
social = self.social_recommend(user_id, limit=20)
# ์ ์ ํตํฉ (๊ฐ์ค ํ๊ท )
scores = {}
weights = {'collaborative': 0.4, 'content_based': 0.35, 'social': 0.25}
for item in collab:
mid = item['id']
scores[mid] = scores.get(mid, {'item': item, 'score': 0})
scores[mid]['score'] += weights['collaborative'] * (item.get('recommendedBy', 1))
for item in content:
mid = item['id']
if mid not in scores:
scores[mid] = {'item': item, 'score': 0}
scores[mid]['score'] += weights['content_based'] * (item.get('sharedGenres', 1))
for item in social:
mid = item['id']
if mid not in scores:
scores[mid] = {'item': item, 'score': 0}
scores[mid]['score'] += weights['social'] * (item.get('socialScore', 0.1))
# ์ ๋ ฌ ํ ๋ฐํ
sorted_items = sorted(
scores.values(),
key=lambda x: x['score'],
reverse=True
)[:limit]
return [
{**item['item'], 'hybridScore': round(item['score'], 3), 'algorithm': 'hybrid'}
for item in sorted_items
]
recommender = MovieRecommender()
# main.py
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from recommender import recommender
from typing import Optional
app = FastAPI(
title="Neo4j ์ํ ์ถ์ฒ API",
description="๊ทธ๋ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ธฐ๋ฐ ์ค๋งํธ ์ถ์ฒ ์์คํ
",
version="1.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"]
)
@app.get("/recommend/{user_id}")
async def get_recommendations(
user_id: str,
algorithm: str = Query(
default="hybrid",
enum=["collaborative", "content_based", "social", "hybrid"]
),
limit: int = Query(default=10, ge=1, le=50)
):
"""์ฌ์ฉ์ ๋ง์ถค ์ํ ์ถ์ฒ"""
try:
if algorithm == "collaborative":
results = recommender.collaborative_filter(user_id, limit)
elif algorithm == "content_based":
results = recommender.content_based_filter(user_id, limit)
elif algorithm == "social":
results = recommender.social_recommend(user_id, limit)
else: # hybrid
results = recommender.hybrid_recommend(user_id, limit)
if not results:
raise HTTPException(
status_code=404,
detail=f"์ฌ์ฉ์ {user_id}์ ๋ํ ์ถ์ฒ ๊ฒฐ๊ณผ๊ฐ ์์ต๋๋ค."
)
return {
"userId": user_id,
"algorithm": algorithm,
"count": len(results),
"recommendations": results
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/watch/{user_id}/{movie_id}")
async def record_watch(
user_id: str,
movie_id: str,
score: float = Query(default=7.0, ge=1.0, le=10.0)
):
"""์์ฒญ ๊ธฐ๋ก ์ ์ฅ"""
from database import db
from datetime import date
query = """
MATCH (u:User {id: $userId}), (m:Movie {id: $movieId})
MERGE (u)-[w:WATCHED]->(m)
SET w.score = $score,
w.date = date($today),
w.updatedAt = datetime()
RETURN u.name AS user, m.title AS movie, w.score AS score
"""
result = db.execute_read(query, {
"userId": user_id,
"movieId": movie_id,
"score": score,
"today": str(date.today())
})
return {"message": "์์ฒญ ๊ธฐ๋ก ์ ์ฅ ์๋ฃ", "data": result[0] if result else {}}
# ์คํ: uvicorn main:app --reload --port 8000
uvicorn main:app --reload --port 8000๊ทธ ๋ค์
http://localhost:8000/docs์ ์ ์ํ๋ฉด Swagger UI๋ก API๋ฅผ ๋ฐ๋ก ํ
์คํธํ ์ ์์ด!
์ถ์ฒ ์์คํ ์ ์ค์๊ฐ์ผ๋ก ์๋ตํด์ผ ํ๊ธฐ ๋๋ฌธ์ ์ฑ๋ฅ์ด ์ ๋ง ์ค์ํด. Neo4j์์ ์ฑ๋ฅ์ ์ต์ ํํ๋ ํต์ฌ ์ ๋ต๋ค์ ์์๋ณด์!
// ํ์ ์ธ๋ฑ์ค ์ค์
CREATE INDEX user_id_idx FOR (u:User) ON (u.id);
CREATE INDEX movie_id_idx FOR (m:Movie) ON (m.id);
CREATE INDEX movie_rating_idx FOR (m:Movie) ON (m.rating);
CREATE INDEX movie_year_idx FOR (m:Movie) ON (m.year);
// ๋ณตํฉ ์ธ๋ฑ์ค (์ฌ๋ฌ ์กฐ๊ฑด ๋์ ์ฌ์ฉ ์)
CREATE INDEX movie_year_rating FOR (m:Movie) ON (m.year, m.rating);
// ์ ๋ฌธ ๊ฒ์ ์ธ๋ฑ์ค (์ ๋ชฉ ๊ฒ์์ฉ)
CREATE FULLTEXT INDEX movie_title_search FOR (m:Movie) ON EACH [m.title];
// ์ ๋ฌธ ๊ฒ์ ์ฌ์ฉ ์์
CALL db.index.fulltext.queryNodes("movie_title_search", "์ธ์
์
")
YIELD node, score
RETURN node.title, score
ORDER BY score DESC
# Python Redis ์บ์ฑ ์ ์ฉ
import redis
import json
from functools import wraps
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def cache_recommendations(ttl: int = 300): # 5๋ถ ์บ์
def decorator(func):
@wraps(func)
def wrapper(self, user_id: str, *args, **kwargs):
cache_key = f"rec:{func.__name__}:{user_id}"
# ์บ์ ํ์ธ
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# ์บ์ ์์ผ๋ฉด ์ค์ ์ฟผ๋ฆฌ ์คํ
result = func(self, user_id, *args, **kwargs)
# ๊ฒฐ๊ณผ ์บ์ฑ
redis_client.setex(
cache_key,
ttl,
json.dumps(result, ensure_ascii=False)
)
return result
return wrapper
return decorator
class CachedRecommender(MovieRecommender):
@cache_recommendations(ttl=300)
def collaborative_filter(self, user_id, limit=10, min_similarity=0.1):
return super().collaborative_filter(user_id, limit, min_similarity)
@cache_recommendations(ttl=600) # ์ฝํ
์ธ ๊ธฐ๋ฐ์ ๋ ์ค๋ ์บ์
def content_based_filter(self, user_id, limit=10, min_score=7.0):
return super().content_based_filter(user_id, limit, min_score)
def invalidate_cache(self, user_id: str):
"""์ฌ์ฉ์ ์์ฒญ ๊ธฐ๋ก ์
๋ฐ์ดํธ ์ ์บ์ ๋ฌดํจํ"""
patterns = [
f"rec:collaborative_filter:{user_id}",
f"rec:content_based_filter:{user_id}",
f"rec:social_recommend:{user_id}",
f"rec:hybrid_recommend:{user_id}"
]
for key in patterns:
redis_client.delete(key)
// ์ฟผ๋ฆฌ ์คํ ๊ณํ ํ์ธ (EXPLAIN)
EXPLAIN
MATCH (u:User {id: "user_001"})-[:WATCHED]->(m:Movie)
RETURN m.title
// ์ค์ ์คํ ํต๊ณ ํ์ธ (PROFILE)
PROFILE
MATCH (u:User {id: "user_001"})-[:WATCHED]->(m:Movie)
WHERE m.rating > 8.0
RETURN m.title, m.rating
ORDER BY m.rating DESC
// ๋๋ฆฐ ์ฟผ๋ฆฌ ๋ก๊ทธ ํ์ธ
CALL dbms.listQueries()
YIELD queryId, query, elapsedTimeMillis
WHERE elapsedTimeMillis > 1000 // 1์ด ์ด์ ๊ฑธ๋ฆฐ ์ฟผ๋ฆฌ
RETURN queryId, query, elapsedTimeMillis
ORDER BY elapsedTimeMillis DESC
| ์ต์ ํ ๊ธฐ๋ฒ | ํจ๊ณผ | ์ ์ฉ ์์ |
|---|---|---|
| ์ธ๋ฑ์ค ์ถ๊ฐ | ์กฐํ ์๋ 10~100๋ฐฐ ํฅ์ | ์์ฃผ WHERE ์กฐ๊ฑด์ ์ฐ์ด๋ ํ๋กํผํฐ |
| Redis ์บ์ฑ | ๋ฐ๋ณต ์ฟผ๋ฆฌ ์๋ต 1ms ์ดํ | ๋ณ๊ฒฝ์ด ์ ์ ์ถ์ฒ ๊ฒฐ๊ณผ |
| ๋ฐฐ์น ์ฒ๋ฆฌ | ๋๋ ์ฝ์ ์๋ ํฅ์ | ์ด๊ธฐ ๋ฐ์ดํฐ ๋ก๋ฉ, ์ผ๊ด ์ ๋ฐ์ดํธ |
| ์ฟผ๋ฆฌ ์ต์ ํ | ๋ถํ์ํ ํ์ ์ ๊ฑฐ | PROFILE๋ก ๋ณ๋ชฉ ํ์ธ ํ |
| GDS ์ฌ์ ๊ณ์ฐ | ์ค์๊ฐ ๊ณ์ฐ ๋ถํ ์ ๊ฑฐ | ์ ์ฌ๋, PageRank ๋ฑ ๋ฌด๊ฑฐ์ด ์ฐ์ฐ |
| ์ปค๋ฅ์ ํ๋ง | ์ฐ๊ฒฐ ์ค๋ฒํค๋ ๊ฐ์ | ๊ณ ํธ๋ํฝ ์๋น์ค |
์ค์ ๋ก ํ ์คํธํด๋ณด๋ ค๋ฉด ๋ฐ์ดํฐ๊ฐ ํ์ํ์ง! Python์ผ๋ก ํ์ค์ ์ธ ํ ์คํธ ๋ฐ์ดํฐ๋ฅผ ๋ง๋ค์ด๋ณด์.
# seed_data.py โ ํ
์คํธ ๋ฐ์ดํฐ ์์ฑ
import random
from datetime import date, timedelta
from database import db
# ์ํ ๋ฐ์ดํฐ
MOVIES = [
{"id": "m001", "title": "์ธ์
์
", "year": 2010, "rating": 8.8},
{"id": "m002", "title": "์ธํฐ์คํ
๋ผ", "year": 2014, "rating": 8.6},
{"id": "m003", "title": "๋งคํธ๋ฆญ์ค", "year": 1999, "rating": 8.7},
{"id": "m004", "title": "๊ธฐ์์ถฉ", "year": 2019, "rating": 8.5},
{"id": "m005", "title": "์ฌ๋๋ณด์ด", "year": 2003, "rating": 8.4},
{"id": "m006", "title": "์ด๋ฒค์ ธ์ค", "year": 2012, "rating": 8.0},
{"id": "m007", "title": "๋คํฌ๋์ดํธ", "year": 2008, "rating": 9.0},
{"id": "m008", "title": "์ผ์ํฌํ์ถ", "year": 1994, "rating": 9.3},
{"id": "m009", "title": "๋ผ๋ผ๋๋", "year": 2016, "rating": 8.0},
{"id": "m010", "title": "๊ฒจ์ธ์๊ตญ", "year": 2013, "rating": 7.4},
]
GENRES = ["SF", "์ค๋ฆด๋ฌ", "์ก์
", "๋๋ผ๋ง", "๋ก๋งจ์ค", "์ ๋๋ฉ์ด์
", "๋ฒ์ฃ"]
MOVIE_GENRES = {
"m001": ["SF", "์ค๋ฆด๋ฌ"], "m002": ["SF", "๋๋ผ๋ง"],
"m003": ["SF", "์ก์
"], "m004": ["๋๋ผ๋ง", "์ค๋ฆด๋ฌ"],
"m005": ["์ค๋ฆด๋ฌ", "๋ฒ์ฃ"], "m006": ["์ก์
", "SF"],
"m007": ["์ก์
", "๋ฒ์ฃ"], "m008": ["๋๋ผ๋ง"],
"m009": ["๋ก๋งจ์ค", "๋๋ผ๋ง"], "m010": ["์ ๋๋ฉ์ด์
"]
}
def seed_database():
print("๐ฑ ๋ฐ์ดํฐ ์๋ฉ ์์...")
# ์ฅ๋ฅด ์์ฑ
for genre in GENRES:
db.execute_read(
"MERGE (g:Genre {name: $name})",
{"name": genre}
)
print(f"โ
์ฅ๋ฅด {len(GENRES)}๊ฐ ์์ฑ")
# ์ํ ์์ฑ + ์ฅ๋ฅด ์ฐ๊ฒฐ
for movie in MOVIES:
db.execute_read(
"""
MERGE (m:Movie {id: $id})
SET m.title = $title, m.year = $year, m.rating = $rating
""",
movie
)
for genre_name in MOVIE_GENRES.get(movie["id"], []):
db.execute_read(
"""
MATCH (m:Movie {id: $movieId}), (g:Genre {name: $genre})
MERGE (m)-[:BELONGS_TO]->(g)
""",
{"movieId": movie["id"], "genre": genre_name}
)
print(f"โ
์ํ {len(MOVIES)}๊ฐ ์์ฑ")
# ์ฌ์ฉ์ 100๋ช
์์ฑ
cities = ["์์ธ", "๋ถ์ฐ", "๋๊ตฌ", "์ธ์ฒ", "๊ด์ฃผ"]
for i in range(1, 101):
db.execute_read(
"""
MERGE (u:User {id: $id})
SET u.name = $name, u.age = $age, u.city = $city
""",
{
"id": f"u{i:03d}",
"name": f"์ฌ์ฉ์{i:03d}",
"age": random.randint(18, 55),
"city": random.choice(cities)
}
)
print("โ
์ฌ์ฉ์ 100๋ช
์์ฑ")
# ์์ฒญ ๊ธฐ๋ก ์์ฑ (์ฌ์ฉ์๋น ํ๊ท 5~15ํธ)
watch_count = 0
for i in range(1, 101):
user_id = f"u{i:03d}"
watched_movies = random.sample(MOVIES, random.randint(3, 10))
for movie in watched_movies:
score = round(random.uniform(5.0, 10.0), 1)
days_ago = random.randint(1, 365)
watch_date = date.today() - timedelta(days=days_ago)
db.execute_read(
"""
MATCH (u:User {id: $userId}), (m:Movie {id: $movieId})
MERGE (u)-[w:WATCHED]->(m)
SET w.score = $score, w.date = date($date)
""",
{
"userId": user_id,
"movieId": movie["id"],
"score": score,
"date": str(watch_date)
}
)
watch_count += 1
print(f"โ
์์ฒญ ๊ธฐ๋ก {watch_count}๊ฐ ์์ฑ")
# ์น๊ตฌ ๊ด๊ณ ์์ฑ (์ฌ์ฉ์๋น ํ๊ท 3~8๋ช
)
friend_count = 0
for i in range(1, 101):
friends = random.sample(
[j for j in range(1, 101) if j != i],
random.randint(2, 6)
)
for j in friends:
db.execute_read(
"""
MATCH (u1:User {id: $id1}), (u2:User {id: $id2})
MERGE (u1)-[:FRIEND_OF]->(u2)
""",
{"id1": f"u{i:03d}", "id2": f"u{j:03d}"}
)
friend_count += 1
print(f"โ
์น๊ตฌ ๊ด๊ณ {friend_count}๊ฐ ์์ฑ")
print("๐ ๋ฐ์ดํฐ ์๋ฉ ์๋ฃ!")
if __name__ == "__main__":
seed_database()
์ถ์ฒ ์์คํ ์ ๋ง๋ค์์ผ๋ฉด ์ผ๋ง๋ ์ ์๋ํ๋์ง ํ๊ฐํด์ผ๊ฒ ์ง? ์ฃผ์ ํ๊ฐ ์งํ๋ค์ ์์๋ณด์!
# evaluation.py โ ์ถ์ฒ ์์คํ
ํ๊ฐ
import numpy as np
from typing import List
def precision_at_k(recommended: List[str], relevant: List[str], k: int) -> float:
"""Precision@K ๊ณ์ฐ"""
recommended_k = recommended[:k]
hits = len(set(recommended_k) & set(relevant))
return hits / k if k > 0 else 0.0
def recall_at_k(recommended: List[str], relevant: List[str], k: int) -> float:
"""Recall@K ๊ณ์ฐ"""
recommended_k = recommended[:k]
hits = len(set(recommended_k) & set(relevant))
return hits / len(relevant) if relevant else 0.0
def ndcg_at_k(recommended: List[str], relevant: List[str], k: int) -> float:
"""NDCG@K ๊ณ์ฐ"""
recommended_k = recommended[:k]
# DCG ๊ณ์ฐ
dcg = sum(
1.0 / np.log2(i + 2) # i+2 because i is 0-indexed
for i, item in enumerate(recommended_k)
if item in relevant
)
# IDCG ๊ณ์ฐ (์ด์์ ์ธ DCG)
ideal_hits = min(len(relevant), k)
idcg = sum(1.0 / np.log2(i + 2) for i in range(ideal_hits))
return dcg / idcg if idcg > 0 else 0.0
def evaluate_recommender(recommender, test_data: dict, k: int = 10) -> dict:
"""์ ์ฒด ํ๊ฐ ์คํ"""
precisions, recalls, ndcgs = [], [], []
for user_id, relevant_items in test_data.items():
# ์ถ์ฒ ๊ฒฐ๊ณผ ๊ฐ์ ธ์ค๊ธฐ
recs = recommender.hybrid_recommend(user_id, limit=k)
recommended_ids = [r['id'] for r in recs]
# ์งํ ๊ณ์ฐ
p = precision_at_k(recommended_ids, relevant_items, k)
r = recall_at_k(recommended_ids, relevant_items, k)
n = ndcg_at_k(recommended_ids, relevant_items, k)
precisions.append(p)
recalls.append(r)
ndcgs.append(n)
avg_p = np.mean(precisions)
avg_r = np.mean(recalls)
f1 = 2 * avg_p * avg_r / (avg_p + avg_r) if (avg_p + avg_r) > 0 else 0
return {
f"Precision@{k}": round(avg_p, 4),
f"Recall@{k}": round(avg_r, 4),
f"F1@{k}": round(f1, 4),
f"NDCG@{k}": round(np.mean(ndcgs), 4)
}
# ์ฌ์ฉ ์์
# test_data = {"u001": ["m003", "m007", "m009"], ...}
# metrics = evaluate_recommender(recommender, test_data, k=10)
# print(metrics)
# {'Precision@10': 0.3, 'Recall@10': 0.6, 'F1@10': 0.4, 'NDCG@10': 0.45}
๋ง์ง๋ง์ผ๋ก, ์ค์ ํ๋ก๋์ ํ๊ฒฝ์์ Neo4j ๊ธฐ๋ฐ ์ถ์ฒ ์์คํ ์ ์ด๋ป๊ฒ ๋ฐฐํฌํ๋์ง ์ดํด๋ณด์!
# docker-compose.yml
version: '3.8'
services:
neo4j:
image: neo4j:5.15.0
container_name: neo4j
ports:
- "7474:7474"
- "7687:7687"
environment:
NEO4J_AUTH: neo4j/password123
NEO4J_PLUGINS: '["apoc", "graph-data-science"]'
NEO4J_dbms_memory_heap_max__size: 2G
NEO4J_dbms_memory_pagecache_size: 1G
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
healthcheck:
test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "password123", "RETURN 1"]
interval: 30s
timeout: 10s
retries: 5
redis:
image: redis:7-alpine
container_name: redis
ports:
- "6379:6379"
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
api:
build: .
container_name: recommend-api
ports:
- "8000:8000"
environment:
NEO4J_URI: bolt://neo4j:7687
NEO4J_USER: neo4j
NEO4J_PASSWORD: password123
REDIS_HOST: redis
depends_on:
neo4j:
condition: service_healthy
redis:
condition: service_started
command: uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
volumes:
neo4j_data:
neo4j_logs:
# ์ ์ฒด ์คํ ์คํ docker-compose up -d # ๋ก๊ทธ ํ์ธ docker-compose logs -f api # ๋ฐ์ดํฐ ์๋ฉ docker-compose exec api python seed_data.py # API ํ ์คํธ curl http://localhost:8000/recommend/u001?algorithm=hybrid&limit=5
์, ์ด์ Neo4j ๊ทธ๋ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ก ์ถ์ฒ ์์คํ ์ ๊ตฌํํ๋ ์ ์ฒด ์ฌ์ ์ ํจ๊ป ๋ฌ๋ ค์์ด! ๋ง์ง๋ง์ผ๋ก ํต์ฌ ๋ด์ฉ์ ์ ๋ฆฌํด๋ณผ๊ฒ.
*1..2)์ ์ ํ์ฉํ๋ฉด ๋ณต์กํ ์ถ์ฒ ๋ก์ง๋ ๊ฐ๊ฒฐํ๊ฒ ํํ ๊ฐ๋ฅํด.
์ฌ๋ฅ๋ท์์๋ ์ด๋ฐ ๊ทธ๋ํ ๊ธฐ๋ฐ ์ถ์ฒ ์์คํ
์ ํ์ฉํ๋ฉด, ๋จ์ํ ์นดํ
๊ณ ๋ฆฌ ๋งค์นญ์ ๋์ด์ "๋น์ ๊ณผ ๋น์ทํ ํ๋ก์ ํธ๋ฅผ ์งํํ ์ฌ๋๋ค์ด ์์ฃผ ์ฐพ๋ ์ฌ๋ฅ"์ด๋ "๋น์ ์ด ๊ตฌ๋งคํ ์ฌ๋ฅ๊ณผ ์๋์ง๊ฐ ์ข์ ๋ค๋ฅธ ์ฌ๋ฅ"์ ์ถ์ฒํ๋ ํจ์ฌ ์ค๋งํธํ ๊ฒฝํ์ ๋ง๋ค ์ ์์ด!
๊ทธ๋ํ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ ์ฒ์์ ๋ฏ์ค ์ ์์ง๋ง, ํ๋ฒ ์ต์ํด์ง๋ฉด "์ด๊ฑธ ์ ์ด์ ์ผ ์์์ง?" ์ถ์ ๋งํผ ๊ฐ๋ ฅํ ๋๊ตฌ์ผ. ํนํ ์ถ์ฒ ์์คํ
์ฒ๋ผ ๊ด๊ณ๊ฐ ํต์ฌ์ธ ๋๋ฉ์ธ์์๋ ์ ๋ง ๋น์ ๋ฐํ๊ฑฐ๋ . ์, ์ด์ ์ง์ Neo4j๋ฅผ ์ค์นํ๊ณ ๊ทธ๋ํ์ ์ธ๊ณ๋ก ๋ฐ์ด๋ค์ด๋ด! ๐
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.