Building Neo4j-Powered Applications with LLMs · Part II — Haystack과 Neo4j의 통합: AI 검색 구축 실무 가이드

MATCH (:Chapter {n: 4})-[:BUILDS]->(:MovieGraph)

영화 데이터셋으로
Neo4j 그래프를 짓다

지식그래프는 설계도만으로 서지 않는다. 원시 CSV 4만 5천 편의 영화 데이터를 정규화하고, 제약과 인덱스를 세우고, 노드와 관계를 부으면, 9만 노드와 32만 관계의 그래프가 일어선다. 이 장은 그 공정 전체를 코드로 걷는다.

Ravindranatha Anthapu · Siddhant Agarwal Packt Publishing, 2025 Chapter 4 · pp. 71–93

앞의 장들에서 우리는 지식그래프가 어떻게 변혁적 도구로 떠올랐는지 배웠다. 지식그래프는 다양한 데이터 포인트를 연결하는 구조화된 방식을 제공하며, 폭넓은 도메인에 걸쳐 더 똑똑한 검색과 추천, 추론을 가능케 한다. 개체 사이의 복잡한 관계를 포착하는 데 탁월하기에, 깊은 맥락 이해가 필요한 애플리케이션에서는 없어서는 안 될 존재다.

최첨단 그래프 데이터베이스 기술을 지닌 Neo4j는 지식그래프의 구축과 관리에서 선도적 플랫폼으로 도드라진다. 앞 장에서 보았듯, 전통적 관계형 데이터베이스와 달리 Neo4j는 고도로 연결된 데이터를 수월하게 다루도록 설계되어, 더 직관적인 질의와 더 빠른 통찰의 검색을 허락한다. 원시의 비구조화 데이터를 AI 기반 애플리케이션을 움직일 의미 있는 통찰로 변환하려는 개발자와 데이터 과학자에게 이상적인 선택인 이유다.

기술 요건

이 장의 실습을 성공적으로 해내려면 다음 도구들이 필요하다.

이 데이터셋은 MovieLens 데이터셋의 파생물이다 — F. Maxwell Harper and Joseph A. Konstan. 2015. The MovieLens Datasets: History and Context. ACM Transactions on Interactive Intelligent Systems (TiiS) 5, 4: 19:1–19:19. doi.org/10.1145/2827872

이 장의 모든 코드는 GitHub 저장소(…/ch4)에 있다. 폴더에는 영화 데이터셋과 Cypher 코드로 Neo4j 그래프를 만드는 데 필요한 모든 파일과 스크립트가 담겼다. 코드 예제를 따라가려면 저장소를 클론하거나 다운로드해 두어야 하며, 저장소에는 원시 데이터 파일에 접근하는 GCS 경로도 들어 있다.

(:Section {n: 1})-[:DESIGNS]->(:GraphModel)

효율적 검색을 위한 Neo4j 그래프 설계

잘 설계된 Neo4j 그래프는 검색 기능이 정확할 뿐 아니라 효율적이도록, 곧 관련 정보를 빠르게 가져오도록 보장한다. 그래프 안에서 데이터가 조직되는 방식은 검색 결과의 성능과 적합성에 직접적인 영향을 미친다. 효과적인 그래프 모델링의 원리를 이해하는 일이 결정적인 이유다. 이 절은 Neo4j 그래프를 올바르게 구조화하는 일의 중요성, 그것이 검색 과정에 미치는 영향, 그리고 그래프 모델을 설계하며 유념해야 할 핵심 고려 사항을 파고든다.

노드와 관계 타입을 정의할 때의 고려 사항

3장에서 상기하자. 모든 Neo4j 그래프의 기초는 노드와 관계 위에 선다. 노드는 영화나 사람(배우, 감독) 같은 개체를 나타내고, 관계는 그 개체들이 어떻게 연결되는지를 정의한다. 어떤 노드와 관계 타입을 고르느냐가 검색 질의의 효과를 좌우한다.

영화 데이터셋에서 노드는 전통적으로 Movies, Actors, Directors, Genres 같은 별개의 개체를 나타낼 것이다. 그러면 관계가 ACTED_IN, DIRECTED, BELONGS_TO처럼 이 노드들의 상호작용을 정의한다. 그러나 대안이 있고, 종종 더 효율적이다. 유사한 개체들을 단일 노드 타입으로 통합하는 것이다.

Actors와 Directors를 위한 별도 노드를 만드는 대신, 단일한 Person 노드를 만들 수 있다. 각 Person 노드의 성격 — 배우인지, 감독인지, 둘 다인지 — 은 그 노드가 Movie 노드와 맺는 관계의 타입으로 정의된다. Person 노드가 ACTED_IN 관계로 Movie 노드에 연결되어 있다면 그 사람은 그 영화의 배우다. 마찬가지로 DIRECTED 관계는 그 사람이 영화를 감독했음을 가리킨다. 완전한 그래프는 이후 절들에서 만든다. 그 전에, 왜 이 접근이 더 나은지부터 말하자. 3장에서 증명했듯 이 접근은 다음의 결과를 낳는다.

단순해진 데이터 모델

배우와 감독을 하나의 Person 노드로 나타내면 데이터 모델이 더 매끄러워진다. 그래프의 복잡성이 줄고, 이해와 관리가 쉬워진다.

향상된 질의 성능

노드 타입이 적을수록 그래프 데이터베이스는 질의 중 관계를 더 효율적으로 순회한다. 데이터베이스 엔진이 구별해야 할 개체가 줄어, 질의 실행 시간이 빨라진다.

중복의 감소

통합된 Person 노드는 정보 중복의 필요를 없앤다. 한 사람이 배우이자 감독인 경우, 겹치는 데이터를 지닌 노드 둘을 만드는 일을 피해 중복을 최소화하고 저장 공간을 아낀다.

유연한 관계 정의

더 유연하고 세밀한 관계 정의가 가능해진다. 한 사람이 여러 영화에서 다른 역할을 맡았다면(한 편은 출연, 다른 한 편은 연출), 여러 노드를 만들 필요 없이 관계가 그 역할들을 명확히 구별한다.

쉬운 유지보수와 확장성

데이터셋이 커질수록 단순한 노드 구조의 유지가 점점 중요해진다. 통합 노드 타입으로 일하면 새 역할이나 관계를 더하는 일이 더 수월해진다.

이 타입과 관계들을 신중히 선택하고 정의함으로써, 실세계의 연결을 비추는 그래프 구조가 만들어진다. 검색 질의는 더 직관적으로, 결과는 더 의미 있게, 시스템 전체는 더 효율적으로 변한다.

인덱싱과 제약이 검색 성능에 미치는 힘

Neo4j 그래프가 커질수록 인덱싱과 제약 적용의 중요성은 절대적인 것이 된다. 인덱스(index)는 Neo4j가 질의의 시작점을 빠르게 찾도록 해 주며, 특히 대규모 데이터셋에서 검색 성능을 극적으로 끌어올린다. 한편 제약(constraint)은 중복 노드나 무효한 관계의 생성을 막아 데이터 무결성을 보장한다.

배우와 감독에 통합 Person 노드를 쓰는 우리의 영화 데이터셋 맥락에서, 인덱싱은 한층 더 결정적이다. person_name이나 role 같은 프로퍼티로 노드를 인덱싱해, 특정 인물이나 영화 속 역할의 검색이 신속히 결과를 돌려주도록 만들 수 있다. 관계(ACTED_IN, DIRECTED)의 role 프로퍼티를 인덱싱해 특정 영화에 대한 관여로 인물을 빠르게 거르는 것도 가능하다.

제약은 그래프 무결성 유지에도 필수다. 제약은 데이터셋의 성격과 애플리케이션 요구에 기초해 신중히 설계해야 한다. 만능 해법은 없다. 다음은 영화 데이터셋에 맞춘 제약과 인덱스 생성을 보여주는 예제들이다. 인물 식별자의 고유성 보장, 노드·관계 프로퍼티 전반의 검색 성능 최적화 같은 흔한 시나리오를 담았다. 자신의 용례와 데이터 품질에 따라 이 패턴들을 각색해 데이터 무결성을 강제하고 질의 속도를 높이면 된다.

제약 1 — person_name 고유 제약 (단순화된 용례)cypher
-- 각 인물이 고유한 이름을 가진다고 가정할 수 있는 경우.
-- 한 사람이 여러 역할(배우이자 감독)을 맡아도 단일 노드로 표현되도록 보장한다.
-- 중복 노드의 우발적 생성을 막아 그래프를 깨끗하고 효율적으로 유지한다.
CREATE CONSTRAINT unique_person_name IF NOT EXISTS
FOR (p:Person)
REQUIRE p.person_name IS UNIQUE;
제약 2 — 더 신뢰할 수 있는 ID의 고유 제약cypher
-- 앞의 고유성 제약은 데이터에 대한 가정에 기초한다. 실세계에서는
-- 같은 이름의 다른 개인을 만나는 일이 흔하다. 이럴 때는 IMDb나 TMDb 같은
-- 외부 소스의 person_id처럼 더 신뢰할 수 있는 식별자로 고유성을 강제한다.
CREATE CONSTRAINT unique_person_id IF NOT EXISTS
FOR (p:Person)
REQUIRE p.person_id IS UNIQUE;
인덱스 1 — person_name (고유성을 강제하지 않을 때의 빠른 조회)cypher
-- 고유성은 강제하지 않지만 이름으로 인물을 자주 검색한다면,
-- person_name 인덱스가 질의 성능을 크게 개선한다.
CREATE INDEX person_name_index IF NOT EXISTS
FOR (p:Person)
ON (p.person_name);
인덱스 2 — Movie의 title 프로퍼티cypher
-- 영화는 특히 추천 시스템이나 검색 기능에서 제목으로 자주 질의된다.
-- title 인덱싱은 특정 영화 검색 시 빠른 조회를 보장한다.
CREATE INDEX movie_title_index IF NOT EXISTS
FOR (m:Movie)
ON (m.title);
인덱스 3 — ACTED_IN 관계의 role 프로퍼티cypher
-- 영화 속 특정 역할(주연, 카메오)로 배우를 걸러야 한다면,
-- ACTED_IN 관계의 role 인덱싱이 전체 관계 스캔을 피해 질의를 가속한다.
CREATE INDEX acted_in_role_index IF NOT EXISTS
FOR ()-[r:ACTED_IN]-()
ON (r.role);
Note

Neo4j는 관계 프로퍼티 인덱스를 5.x 버전 이상에서만 지원한다.

제대로 구현된 인덱싱과 제약은 그래프를 더 튼튼하게, 검색 과정을 더 빠르고 신뢰할 수 있게 만든다. 사용자 경험이 좋아질 뿐 아니라 시스템의 연산 부하가 줄어, 더 확장 가능한 해법이 열린다. 다음 절에서는 열린 데이터의 힘을 빌려, 영화 데이터셋으로 그래프를 만드는 방법을 탐구한다.

(:Section {n: 2})-[:NORMALIZES]->(:Dataset)

영화 데이터셋의 활용

이 절은 Kaggle에 공개된 포괄적 메타데이터 컬렉션, TMDb(The Movie Database)의 활용에 집중한다. 이 데이터셋은 제목, 장르, 출연진, 제작진, 개봉일, 평점 등 영화에 관한 폭넓은 정보를 담고 있다. 4만 5천 편이 넘는 영화와 그 창작에 관여한 사람들의 상세 정보를 갖춘 이 데이터셋은, 영화 산업 안의 복잡한 관계를 포착하는 Neo4j 그래프를 짓기 위한 튼튼한 토대가 된다.

이 데이터셋으로 데이터를 지식그래프로 모델링하며, 실무의 맥락에서 데이터 통합을 배운다. 데이터를 조달하고, 준비하고, Neo4j로 임포트하는 법을 익히게 될 것이다. TMDb 같은 대규모 데이터셋을 다룰 때는, Neo4j 그래프에 통합하기 전에 데이터가 깨끗하고 일관되며 제대로 구조화되어 있는지 확인하는 일이 결정적이다. 원시 데이터는 정보가 풍부하지만 비일관성과 중복, 복잡한 구조를 자주 품고 있어 지식그래프의 성능과 정확성을 방해할 수 있다. 데이터 정규화(normalization)클리닝(cleaning)이 등장하는 지점이 바로 여기다.

왜 데이터를 정규화하고 클리닝하는가

일관성

원시 데이터에는 비슷한 정보가 다르게 기록된 변형이 섞여 있다. 영화 장르가 서로 다른 형식으로 적히거나 중복될 수 있다. 정규화는 유사한 데이터 포인트가 일관된 형식으로 기록되도록 보장한다. 실세계 데이터셋에서 이 문제를 다루기는 어렵지만, Neo4j는 Cypher 패턴 매칭, 노드 병합·중복 정리를 위한 APOC 프로시저, 관련 개체를 식별·통합하는 노드 유사도 알고리즘을 담은 Graph Data Science 라이브러리로 개체 연결(entity linkage)과 중복 제거를 돕는다.

효율성

정규화는 중복을 줄여 Neo4j 그래프의 효율을 끌어올린다. 데이터를 표준화된 형식으로 조직함으로써 저장 요구를 최소화하고 질의 성능을 최적화한다.

정확성

클리닝은 부정확한 레코드의 제거나 교정을 포함한다. 그래프에서 끌어낸 통찰이 정확하고 신뢰할 수 있는 데이터에 기초하도록 보장하는 필수 단계다.

확장성

깨끗하고 정규화된 데이터셋은 확장이 쉽다. 데이터셋이 커져도 표준화된 구조를 유지하면 그래프가 관리 가능하고, 늘어나는 부하 아래서도 잘 작동한다.

CSV 파일의 클리닝과 정규화

이제 TMDb에 포함된 각 CSV 파일을 클리닝하고 정규화한다. 데이터셋의 CSV 파일 일곱 개와 각각의 처리 방침은 다음과 같다.

credits.csv사용 각 영화의 출연진·제작진 상세 정보를 문자열화된 JSON 객체로 담는다. 캐릭터, 배우, 감독, 프로듀서에 관한 상세만 추출한다.
keywords.csv사용 각 영화의 줄거리 키워드. 영화의 주제적 요소를 분류하고 식별하는 데 필수적이며, 검색·추천·콘텐츠 분석에 쓰인다.
links.csv생략 MovieLens 전체 데이터셋의 각 영화를 TMDb·IMDB 항목과 잇는 메타데이터. 외부 데이터베이스와의 통합이 필요한 미래 프로젝트에는 유용하지만, 현재 분석에는 필수가 아니어서 건너뛴다.
links_small.csv생략 9,000편 부분집합의 TMDb·IMDb ID. 더 작은 데이터셋이 필요한 시나리오에 유용하지만, 우리는 이미 Kaggle의 전체 데이터셋을 쓰고 있으므로 쓰지 않는다.
movies_metadata.csv사용 45,000편의 상세 정보 — 포스터, 배경, 예산, 수익, 개봉일, 언어, 제작 국가·회사 등. 장르, 제작사, 제작 국가, 사용 언어 등 관련 노드별 CSV 여러 개로 정규화한다.
ratings.csv생략 27만 사용자가 남긴 2,600만 개 평점과 75만 태그의 전체 MovieLens 데이터셋. 방대한 사용자 상호작용 데이터지만 현재 목표에 필수가 아니어서 건너뛴다.
ratings_small.csv사용 700명 사용자가 9,000편에 남긴 100,000개 평점의 부분집합. 전체 대신 이 파일을 쓴다.

먼저 credits.csv다. 출연진과 제작진 정보를 추출·전개·정규화하는 전체 파이프라인이다.

credits.csv — 출연진·제작진 정규화python
# CSV 파일 로딩
df = pd.read_csv('./raw_data/credits.csv')

# 관련 출연진 정보를 추출하는 함수
def extract_cast(cast_str):
    cast_list = ast.literal_eval(cast_str)
    return [
        {'actor_id': c['id'], 'name': c['name'],
         'character': c['character'], 'cast_id': c['cast_id']}
        for c in cast_list
    ]

# 관련 제작진 정보를 추출하는 함수 — 감독과 프로듀서만
def extract_crew(crew_str):
    crew_list = ast.literal_eval(crew_str)
    relevant_jobs = ['Director', 'Producer']
    return [
        {'crew_id': c['id'], 'name': c['name'], 'job': c['job']}
        for c in crew_list if c['job'] in relevant_jobs
    ]

# 각 행에 추출 함수 적용
df['cast'] = df['cast'].apply(extract_cast)
df['crew'] = df['crew'].apply(extract_crew)

# 리스트를 개별 행으로 전개
df_cast = df.explode('cast').dropna(subset=['cast'])
df_crew = df.explode('crew').dropna(subset=['crew'])

# 전개된 데이터 정규화
df_cast_normalized = pd.json_normalize(df_cast['cast'])
df_crew_normalized = pd.json_normalize(df_crew['crew'])

# 인덱스 중복 방지를 위한 리셋, 중복 행 제거
df_cast_normalized = df_cast_normalized.reset_index(drop=True).drop_duplicates()
df_crew_normalized = df_crew_normalized.reset_index(drop=True).drop_duplicates()

# 정규화된 DataFrame에 영화 ID를 다시 붙인다
df_cast_normalized['tmdbId'] = df_cast.reset_index(drop=True)['id']
df_crew_normalized['tmdbId'] = df_crew.reset_index(drop=True)['id']

# 갱신된 컬럼명으로 저장
df_cast_normalized.to_csv(os.path.join(output_dir, 'normalized_cast.csv'), index=False)
df_crew_normalized.to_csv(os.path.join(output_dir, 'normalized_crew.csv'), index=False)

# 검증용 샘플 출력
print("Sample of normalized cast data:"); print(df_cast_normalized.head())
print("Sample of normalized crew data:"); print(df_crew_normalized.head())

다음은 keywords.csv다. 문자열화된 JSON에서 키워드 이름만 뽑아, 영화별로 하나의 행에 모은다.

keywords.csv — 키워드 정규화·집계python
# CSV 파일 로딩
df = pd.read_csv('./raw_data/keywords.csv')

# 키워드 추출·정규화 함수
def normalize_keywords(keyword_str):
    if pd.isna(keyword_str) or not isinstance(keyword_str, str):
        return []          # NaN이거나 문자열이 아니면 빈 리스트
    # 문자열화된 JSON을 딕셔너리 리스트로 변환
    keyword_list = ast.literal_eval(keyword_str)
    # 각 키워드의 'name'만 리스트로 반환
    return [kw['name'] for kw in keyword_list]

df['keywords'] = df['keywords'].apply(normalize_keywords)

# tmdbId별로 모든 키워드를 한 행으로 결합
df_keywords_aggregated = df.groupby('id', as_index=False).agg({
    'keywords': lambda x: ', '.join(sum(x, []))
})
df_keywords_aggregated.rename(columns={'id': 'tmdbId'}, inplace=True)
df_keywords_aggregated.to_csv(
    os.path.join(output_dir, 'normalized_keywords.csv'), index=False)
print(df_keywords_aggregated.head())

가장 큰 덩어리는 movies_metadata.csv다. 45,000편의 상세 정보를 담은 이 파일을, 우리 데이터셋의 관련 노드를 각각 대표하는 여러 CSV — 장르, 제작사, 제작 국가, 사용 언어 — 로 정규화한다. 데이터를 이 별도 파일들로 쪼갬으로써, 이 풍부한 정보를 더 쉽게 관리하고 활용할 수 있게 된다. 다섯 단계로 진행한다.

  1. 필요한 임포트로 시작한다. import pandas as pd, import ast, 그리고 pd.read_csv('./raw_data/movies_metadata.csv')로 파일을 로딩한다.
  2. 장르·제작사·국가·언어를 추출하고 정규화한다. 장르와 제작사의 예를 아래 코드로 보인다. 나머지 코드는 저장소에 있다.
  3. 추출 함수를 적용하고 리스트를 행으로 전개한다. 네 컬럼 각각에 applyexplode + dropna로 장르·제작사·국가·언어 DataFrame을 만든다.
  4. 전개된 데이터를 정규화한다. 장르의 경우 pd.json_normalize 후 인덱스를 리셋하고, tmdbId를 다시 붙이고, genre_id를 정수로 강제한 뒤 normalized_genres.csv로 저장한다.
  5. 컬렉션 이름을 추출한다. belongs_to_collection에서 "name"만 뽑고, 필요한 13개 필드를 추려 adult를 정수로 변환하고 idtmdbId로 개명해 normalized_movies.csv로 저장한다.
movies_metadata.csv — 장르·제작사 추출 함수python
# 장르 추출·정규화 함수
def extract_genres(genres_str):
    if pd.isna(genres_str) or not isinstance(genres_str, str):
        return []
    genres_list = ast.literal_eval(genres_str)
    return [{'genre_id': int(g['id']), 'genre_name': g['name']}
            for g in genres_list]

# 제작사 추출·정규화 함수
def extract_production_companies(companies_str):
    if pd.isna(companies_str) or not isinstance(companies_str, str):
        return []
    companies_list = ast.literal_eval(companies_str)
    if isinstance(companies_list, list):
        return [{'company_id': int(c['id']),
                 'company_name': c['name']}
                for c in companies_list]
    return []
movies_metadata.csv — 컬렉션 이름 추출과 영화 저장python
# "belongs_to_collection"에서 "name"만 추출
def extract_collection_name(collection_str):
    if isinstance(collection_str, str):
        try:
            collection_dict = ast.literal_eval(collection_str)
            if isinstance(collection_dict, dict):
                return collection_dict.get('name', "None")
        except (ValueError, SyntaxError):   # 문자열 파싱 실패 처리
            return "None"
    return "None"

df_movies = df[['id', 'original_title', 'adult', 'budget', 'imdb_id',
    'original_language', 'revenue', 'tagline', 'title',
    'release_date', 'runtime', 'overview', 'belongs_to_collection']].copy()
df_movies['belongs_to_collection'] = \
    df_movies['belongs_to_collection'].apply(extract_collection_name)
df_movies['adult'] = df_movies['adult'].apply(
    lambda x: 1 if x == 'TRUE' else 0)   # 'adult'를 정수로 변환
df_movies.rename(columns={'id': 'tmdbId'}, inplace=True)
df_movies.to_csv('./normalized_data/normalized_movies.csv', index=False)

이 과정을 통해 우리는 원시의 반구조화 데이터를, 이제 Neo4j 그래프로 통합될 준비를 마친 깨끗하고 정규화된 데이터셋으로 변환하는 법을 배웠다. 이 준비가 튼튼하고 효율적이며 효과적인 AI 기반 검색·추천 시스템 구축의 길을 닦는다.

(:Section {n: 3})-[:IMPORTS]->(:AuraDB)

코드 예제로 만드는 영화 지식그래프

이 절에서는 정규화된 데이터셋을 Neo4j로 임포트해 완전히 작동하는 지식그래프로 변환한다.

AuraDB Free 인스턴스 설정

Neo4j로 지식그래프를 만들기 시작하려면 먼저 AuraDB Free 인스턴스를 설정해야 한다. AuraDB Free는 클라우드에 호스팅되는 Neo4j 데이터베이스로, 로컬 설치나 인프라 관리를 걱정하지 않고 빠르게 시작하게 해 준다. 절차는 이렇다. console.neo4j.io를 방문해 Google 계정이나 이메일로 로그인하고, Create Free Instance를 클릭한다. 인스턴스가 프로비저닝되는 동안 팝업 창에 데이터베이스 접속 자격증명이 나타난다. 팝업에서 다음 정보를 반드시 다운로드해 안전하게 보관해야 한다. 애플리케이션을 Neo4j에 연결하는 데 필수적이다.

NEO4J_URI=neo4j+s://<your-instance-id>.databases.neo4j.io
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=<your-generated-password>
AURA_INSTANCEID=<your-instance-id>
AURA_INSTANCENAME=<your-instance-name>

AuraDB로 데이터 임포트하기

AuraDB Free 인스턴스가 가동되었으니, 정규화된 데이터셋을 임포트해 지식그래프를 만들 차례다. CSV 파일 준비, 인덱스·제약 설정, 데이터 임포트, 관계 생성 — 이 모두를 하나의 Python 스크립트로 해낸다.

  1. CSV 파일을 임포트할 수 있게 준비한다. 생성한 파일들(normalized_movies.csv, normalized_genres.csv 등)이 깨끗하고 잘 구조화되어 있으며 접근 가능한 URL에 호스팅되어 있는지 확인한다. graph_build.py 스크립트는 공개 클라우드 저장소(예: storage.googleapis.com/movies-packt/normalized_movies.csv)에서 파일을 가져오므로, 어디에도 수동으로 업로드할 필요가 없다.
  2. 그래프 질의 최적화를 위한 인덱스와 제약을 더한다. 데이터 적재 전에 고유 제약과 인덱스를 만드는 일은 무결성 보장과 성능 최적화에 결정적이다. 스크립트는 tmdbId, movieId, company_id 같은 ID의 고유성을 보장하고 actor_id, crew_id, user_id 같은 프로퍼티에 인덱스를 만드는 Cypher 명령을 포함한다.
  3. 데이터를 임포트하고 노드를 만든다. load_movies()가 모든 영화 메타데이터를 더하고, load_genres()·load_production_companies()·load_countries() 등이 Genre, ProductionCompany, Country, SpokenLanguage 같은 관련 노드를 만든다. 인물 데이터는 load_person_actors()load_person_crew()로, 추가 프로퍼티는 load_links()·load_keywords()·load_ratings()로 더한다.
  4. 관계를 만든다. 각 로더 함수는 노드만 만들지 않고 의미 있는 관계도 세운다 — Movie↔Genre의 HAS_GENRE, Movie↔ProductionCompany의 PRODUCED_BY, Movie↔SpokenLanguage의 HAS_LANGUAGE, Movie↔Country의 PRODUCED_IN, Movie↔Person의 ACTED_IN·DIRECTED·PRODUCED, Movie↔User의 RATED 등.
  5. 전체 스크립트를 실행한다. Neo4j Python 드라이버 설치(pip install neo4j)를 확인한 뒤 python graph_build.py를 실행한다. 스크립트는 순서대로 .env 파일의 자격증명으로 AuraDB 인스턴스에 접속하고, 데이터베이스를 정리하고, 인덱스·제약을 더하고, 호스팅된 CSV로 모든 노드 데이터와 관계를 벌크 적재한다. 전체 스크립트는 ch4/graph_build.py에 있다.
graph_build.py — 인덱스·제약 명령cypher
"CREATE CONSTRAINT unique_tmdb_id IF NOT EXISTS FOR (m:Movie)
 REQUIRE m.tmdbId IS UNIQUE;",
"CREATE CONSTRAINT unique_movie_id IF NOT EXISTS FOR (m:Movie)
 REQUIRE m.movieId IS UNIQUE;",
"CREATE CONSTRAINT unique_prod_id IF NOT EXISTS FOR
 (p:ProductionCompany) REQUIRE p.company_id IS UNIQUE;",
"CREATE CONSTRAINT unique_genre_id IF NOT EXISTS FOR (g:Genre)
 REQUIRE g.genre_id IS UNIQUE;",
"CREATE CONSTRAINT unique_lang_id IF NOT EXISTS FOR
 (l:SpokenLanguage) REQUIRE l.language_code IS UNIQUE;",
"CREATE CONSTRAINT unique_country_id IF NOT EXISTS FOR (c:Country)
 REQUIRE c.country_code IS UNIQUE;",
"CREATE INDEX actor_id IF NOT EXISTS FOR (p:Person) ON (p.actor_id);",
"CREATE INDEX crew_id IF NOT EXISTS FOR (p:Person) ON (p.crew_id);",
"CREATE INDEX movieId IF NOT EXISTS FOR (m:Movie) ON (m.movieId);",
"CREATE INDEX user_id IF NOT EXISTS FOR (p:Person) ON (p.user_id);"
노드 적재 예시python
graph.load_movies(
    'https://storage.googleapis.com/movies-packt/normalized_movies.csv',
    movie_limit)

완료되면 Neo4j Browser로 임포트를 검증한다.

verify — 임포트 검증cypher
MATCH (m:Movie)-[:HAS_GENRE]->(g:Genre)
RETURN m.title, g.genre_name
LIMIT 10;

그림 4.1은 9만 개 이상의 노드와 32만 개 이상의 관계로 연결된 영화 그래프를 보여준다. Movie, Genre, Person, ProductionCompany 같은 노드가 저마다의 색으로 표현되고, ACTED_IN, HAS_GENRE, PRODUCED_BY 같은 관계들이 상호 연결된 메타데이터의 그물을 펼쳐 보인다.

PRODUCED_IN DIRECTED ACTED_IN HAS_GENRE PRODUCED_BY HAS_LANGUAGE RATED PRODUCED Movie Country Director Actor Genre ProductionCompany SpokenLanguage User Producer Person ACTED_IN·RATED·DIRECTED
그림 4.1 — 영화 데이터셋의 Neo4j 그래프 · 90K+ 노드 · 320K+ 관계

Python과 Cypher로 데이터가 성공적으로 임포트되고 지식그래프가 완전히 구축되었으니, 다음 장에서 생성형 AI 기반 검색 애플리케이션 구축으로 뛰어들 준비가 되었다. 다음 절에서는 복잡한 관계를 다루고 데이터에서 더 깊은 통찰을 끌어내는 고급 Cypher 기법으로 들어간다.

(:Section {n: 4})-[:TRAVERSES]->(:ComplexPattern)

기본을 넘어 — 복잡한 그래프 구조를 위한 고급 Cypher 기법

지식그래프의 크기와 복잡성이 자라면, 질의와 데이터 관리 능력에 대한 요구도 함께 자란다. Neo4j의 강력한 질의 언어 Cypher는 복잡한 그래프 구조를 다루고 더 정교한 데이터 분석을 가능케 하는 다양한 고급 기능을 제공한다. 이 절에서는 경로 패턴, 가변 길이 관계, 서브쿼리, 그래프 알고리즘을 포함한 고급 Cypher 기법을 탐구한다. 이 기법들을 이해하면 얽히고설킨 관계를 효율적으로 관리하고, 더 깊은 분석을 수행하고, 고급 용례를 위한 지식그래프의 잠재력을 온전히 풀어낼 수 있다.

가변 길이 관계

Cypher의 가변 길이 관계(variable-length relationship)는 노드 사이의 다양한 길이의 경로를 매칭하게 해 준다. 계층 구조나 여러 단계로 떨어진 네트워크를 탐색할 때 특히 유용하다. 특정 배우와 3단계 이내로 연결된 모든 영화를 찾는 예다.

variable-length — 3단계 이내의 연결cypher
MATCH (a:Actor {name: 'Tom Hanks'})-[:ACTED_IN*1..3]-(m:Movie)
RETURN DISTINCT m.title;

*1..3은 관계 경로가 1단계에서 3단계 사이일 수 있음을 지정한다. 가변 길이 관계는 특정 촌수 이내의 모든 사람을 찾으려는 소셜 네트워크 분석이나, 여러 수준에 걸쳐 부모-자식 관계를 탐색하려는 계층적 데이터셋 같은 시나리오에 이상적이다.

경로 패턴을 이용한 패턴 매칭

Neo4j에서는 이름 붙인 경로 패턴을 만들 수 있고, 경로들을 연쇄할 수도 있다. 먼저 경로 패턴의 정의다. Cypher는 질의 전체에서 재사용할 수 있는 이름 붙인 경로 패턴의 정의를 허락한다. 질의의 가독성이 높아지고, 복잡한 관계를 단일 패턴 안에 캡슐화할 수 있다.

named path — 이름 붙인 경로cypher
MATCH path = (a:Actor)-[:ACTED_IN]->(m:Movie)
RETURN path;

여기서 path는 이후의 연산이나 서브쿼리에서 재사용할 수 있는 이름 붙인 경로 패턴이다. 다음은 경로 패턴의 연쇄다. Cypher는 여러 경로 패턴을 결합해 그래프 안에서 복잡한 순회를 수행하게 해 준다. 간접적 관계를 드러내거나 특정 기준을 만족하는 여러 경로를 발견하려 할 때 특히 쓸모 있다. 영화 데이터셋에서 협업을 탐사하는 예를 보자. 어떤 배우가 — 아마도 다른 영화를 통해 — 이전에 협업했던 감독과 함께 일한 영화들을 찾고 싶다고 하자. 배우에서 영화로, 다시 감독으로 경로를 연쇄한 뒤, 같은 배우-감독 쌍을 잇는 또 다른 영화가 있는지 보는 것이다.

chained paths — 반복 협업의 발견cypher
MATCH (a:Actor {name: "Tom Hanks"})-[:ACTED_IN]->(m1:Movie)
      <-[:DIRECTED_BY]-(d:Director)
MATCH (a)-[:ACTED_IN]->(m2:Movie)<-[:DIRECTED_BY]-(d)
WHERE m1 <> m2
RETURN a.name AS actor, d.name AS director,
       collect(DISTINCT m1.title) + collect(DISTINCT m2.title) AS movies

이런 패턴 연쇄는 직업적 관계와 반복되는 협업을 식별하거나, 네트워크 안의 간접적 영향력을 분석하는 데 대단히 유용하다.

서브쿼리와 절차적 논리

복잡한 질의의 처리에는 서브쿼리와 프로시저를 쓸 수 있다. 모듈식 질의를 위한 서브쿼리부터 보자. Cypher의 서브쿼리는 복잡한 질의를 모듈식의 재사용 가능한 구성 요소로 쪼개게 해 준다. 대규모 그래프를 다루거나 같은 데이터셋에 여러 연산을 수행해야 할 때 특히 도움이 된다.

subquery — 액션 영화와 감독cypher
CALL {
  MATCH (m:Movie)-[:HAS_GENRE]->(g:Genre {name: 'Action'})
  RETURN m
}
MATCH (m)-[:DIRECTED_BY]->(d:Director)
RETURN d.name, COUNT(m) AS action_movies_directed;

서브쿼리가 모든 액션 영화를 가져오고, 바깥 질의가 그 영화들을 감독과 매칭한다. 다음은 CALL을 이용한 절차적 논리다. Cypher의 CALL 절은 프로시저를 호출하고 그 결과를 이후 질의에 쓰게 해 준다. 그래프 알고리즘의 실행이나 커스텀 프로시저의 호출 같은 고급 데이터 처리에 필수적이다. 우리는 이미 graph_build.pyload_ratings() 함수에서 이를 적용했다. CALL { ... } IN TRANSACTIONS 패턴으로 대규모 데이터셋을 50,000행 단위로 나누어 효율적으로 적재하는 것이다.

CALL IN TRANSACTIONS — 5만 행 청크 적재cypher
LOAD CSV WITH HEADERS FROM $csvFile AS row
CALL (row) {
  MATCH (m:Movie {movieId: toInteger(row.movieId)})
  WITH m, row
  MERGE (p:Person {user_id: toInteger(row.userId)})
  ON CREATE SET p.role = 'user'
  MERGE (p)-[r:RATED]->(m)
  ON CREATE SET r.rating = toFloat(row.rating),
                r.timestamp = toInteger(row.timestamp)
} IN TRANSACTIONS OF 50000 ROWS;

이 접근은 성능과 트랜잭션 무결성을 지키면서 거대한 CSV 임포트를 다룰 수 있게 해 준다. 실세계 그래프 애플리케이션에서 CALL이 발휘하는 수많은 강력한 용례 가운데 하나일 뿐이다.

중첩 질의 다루기

복잡한 그래프 구조에서는 여러 질의의 결과를 결합해야 할 때가 있다. Cypher는 질의를 중첩해 한 질의의 결과를 다른 질의로 넘기게 해 주며, 여러 기준으로 결과를 거르거나 다듬을 때 유용하다.

nested — 수익 1억 달러 초과 영화의 장르cypher
MATCH (m:Movie)
WHERE m.revenue > 100000000
CALL {
  WITH m
  MATCH (m)-[:HAS_GENRE]->(g:Genre)
  RETURN g.name AS genre
}
RETURN m.title, genre;

중첩된 질의가 수익으로 영화를 거른 뒤 그 영화들의 장르를 찾아 결과를 정제한다. 이 Cypher 기법들이 복잡한 그래프 구조에 맞설 힘을 주고, 더 깊은 통찰과 더 정교한 분석을 가능케 한다. 더 깊이 탐구하려면 고급 질의 튜닝 튜토리얼을 참고하면 된다.

요약

이 장에서 우리는 원시의 반구조화 데이터를 지식그래프 통합 준비를 마친 깨끗하고 정규화된 데이터셋으로 변환하는 작업을 해냈다. 이어서 그래프 모델링의 모범 사례를 탐구하며, 검색 효율을 높이고 그래프가 확장 가능하고 성능 좋게 유지되도록 노드와 관계를 구조화하는 방법에 집중했다. 그다음 여러 Cypher 기법에 맞섰다. 가변 길이 관계, 패턴 매칭, 서브쿼리, 그래프 알고리즘을 다루는 역량이 이제 갖춰졌다. 가장 얽히고설킨 데이터 관계까지 감당하는 지식그래프 기반 검색을 만들 준비가 된 것이다.

다음 장에서는 한 걸음 더 나아가 Haystack을 Neo4j에 통합하는 방법을 탐구한다. 이 실무 가이드는 지식그래프 안에 강력한 검색 기능을 만드는 법을 보여주며, 지능형 검색 솔루션을 위해 Neo4j와 Haystack 양쪽의 잠재력을 온전히 활용하게 해 줄 것이다.

NEXT → Chapter 5 · Implementing Powerful Search Functionalities with Neo4j