Neo4j가 지식그래프 구축에 훌륭하다 해도, 데이터를 어떻게 모델링할지는 신중히 들여다보아야 한다. 좋은 데이터 지속 모델은 데이터 검색을 최적으로 만들고, 큰 부하를 더 잘 감당하게 해 준다. 이 장에서 우리는 한 걸음 물러서서 지식그래프를 이루는 것이 무엇인지, 그리고 Neo4j의 데이터 지속 접근으로 데이터 모델링을 다르게 바라보는 일이 더 강력한 지식그래프의 구축을 어떻게 돕는지 살핀다. 3장에서 정의한 접근들을 다시 찾아볼 필요가 있을지도 모른다. 그 접근들이 Personalized Fashion Recommendations(H&M 개인화) 데이터로 지식그래프를 짓는 힘이 되어 줄 것이다.
기술 요건
SQL과 Cypher에 익숙해야 한다. 데이터 모델링의 다양한 측면을 이해하는 데 SQLite와 Neo4j를 쓴다. 이 장에서 쓰는 도구는 다음과 같다.
- Neo4j Desktop
또는 Neo4j AuraNeo4j Desktop 매뉴얼 또는 Aura 문서를 참고한다. - H&M 데이터셋추천 시스템을 만드는 데 쓰는 데이터셋으로, Kaggle에서 구할 수 있다.
3장에서 배웠듯, 좋은 그래프 데이터 모델은 RAG 흐름의 검색 부분을 더 효과적으로 만든다. 관련 데이터를 가져오는 일이 더 빠르고 쉬워지는 것이다. 그래프 데이터 모델링을 빠르게 복기하고 싶다면 3장을 다시 펼쳐도 좋다. 이 장에서 우리는 시간을 차원으로 삼아 데이터를 모델링한다. 시간을 차원으로 하는 거래의 사슬은 데이터 검색을 매우 효율적이고 성능 좋게 만들어 준다.
(:Section {n: 1})-[:MODELS]->(:HnMGraph)
H&M 개인화 데이터셋으로 추천 그래프를 모델링하다
이 절에서 우리는 실생활의 대규모 H&M 개인화 데이터셋으로 그래프 데이터 모델을 만든다. 이 모델이 다가올 장들에서 만들 추천 엔진에 동력을 공급하게 된다.
2022년 H&M은 추천 엔진 구축 경진대회의 일환으로, 고객 거래 데이터를 고객·상품 등의 메타데이터와 함께 공개했다. 이 데이터셋에는 과거 거래 데이터와 고객·상품 메타데이터가 담겨 있다. 메타데이터는 의류 종류와 고객 나이 같은 단순한 데이터부터 상품 설명의 텍스트 데이터, 의류 이미지의 이미지 데이터까지 아우른다. 데이터셋의 특성을 논하며, 한 걸음씩 데이터를 지식그래프에 적재해 나간다. 데이터셋에 담긴 데이터는 이렇다.
article_id의 이미지. 데이터셋의 모든 품목에 이미지가 딸려 있는 것은 아니다. 그래프에 이미지를 저장하는 것은 비효율적일 뿐 아니라, 우리가 짓는 그래프 흐름에 필요하지도 않으므로 쓰지 않는다.
H&M 개인화 데이터셋의 추천용 지식그래프를 지을 때 우리 손에는 고객들이 수행한 거래의 목록이 들려 있고, 이것을 시간을 차원으로 하는 거래의 사슬로 표현하는 것이 우리에게 아주 잘 통할 수 있다. 데이터에 대한 우리의 이해를 그래프 데이터 모델에 더하면 추천은 더 값진 것이 된다. 이를테면 거래는 사건의 시퀀스다. 그러니 시퀀스로 모델링하는 것이 더 이치에 맞다. 전통적 데이터베이스와 달리 Neo4j는 이 거래들을 관계로 순차 연결된 그래프로 저장할 수 있게 해 준다.
데이터에 대한 우리의 지식을 그래프에 지속시킨다 — 그렇게 지식그래프가 만들어진다.
추천 그래프 만들기
추천 모델 그래프를 지으려면 데이터셋 각 파일 안의 데이터와 그것이 그래프에 기여하는 방식을 들여다보아야 한다. 그래프 구축에는 앞서 3장에서 논한 과정을 적용한다. 데이터를 적재하기 전에 Neo4j Desktop에서 두 가지를 해 둔다. 첫째, 로컬 데이터베이스를 만든다(생성 안내). 둘째, H&M 추천 데이터셋의 CSV 파일들을 이 데이터베이스의 import 디렉터리에 복사한다(방법이 궁금하면 커뮤니티 글 참고). 이제 데이터를 그래프 데이터베이스에 적재하자.
고객 데이터의 적재
고객 데이터에는 고객 ID, 나이, 패션 뉴스 수신 빈도, 활성 플래그, H&M 클럽 회원 상태, 우편번호가 담겨 있다. 고객 ID는 고객의 고유 ID다. 고객을 나타내는 노드가 고유하도록 UNIQUE 제약이 필요하다. 우편번호는 노드로 만든다. 우편번호로 고객을 쉽게 분리하고 싶어질 수 있기 때문이다. 데이터를 적재하기 전에, 만들어 둔 Neo4j 데이터베이스에 접속해 이 고유 제약들을 생성한다.
CREATE CONSTRAINT customer_id_idx FOR (n:Customer) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT postal_code_idx FOR (n:PostalCode) REQUIRE n.code IS UNIQUE;
LOAD CSV 질의를 Neo4j Browser에서 실행하려면 앞에 auto를 붙여야 한다.
LOAD CSV WITH HEADERS FROM "file:///customers.csv" as row WITH row CALL { WITH row MERGE (c:Customer {id:row.customer_id}) SET c.age = row.age FOREACH( ignoreME in CASE WHEN row.fashion_news_frequency = 'Regularly' THEN [1] ELSE [] END | SET c:FN_REGULAR ) FOREACH( ignoreME in CASE WHEN row.club_member_status = 'ACTIVE' THEN [1] ELSE [] END | SET c:CLUB_ACTIVE ) FOREACH( ignoreME in CASE WHEN row.club_member_status = 'PRE-CREATE' THEN [1] ELSE [] END | SET c:CLUB_PRE_CREATE ) FOREACH( ignoreME in CASE WHEN row.Active <> 'ACTIVE' THEN [1] ELSE [] END | SET c:INACTIVE ) MERGE(p:PostalCode {code:row.postal_code}) MERGE(c)-[:LIVES_IN]->(p) } IN TRANSACTIONS OF 1000 ROWS
이 스크립트는 1,000행을 하나의 커밋 배치로 삼아 고객 데이터를 적재한다. 눈여겨볼 대목이 둘 있다. Customer 노드에는 고유 ID인 customer_id 말고는 age라는 프로퍼티 하나만 있다는 것, 그리고 고객 데이터의 나머지 속성들은 Customer 노드의 레이블로 매핑했다는 것이다.
이 접근은 앞서 논한 소비 기반(consumption-based) 데이터 모델링 접근을 따른다. 패션 뉴스를 정기 구독하는 고객들이 어떻게 행동하는지 이해하고 싶다고 하자. 레이블은 이 정보를 가져오는 쉬운 길을 열어 준다. Neo4j는 레이블 기반 접근의 검색을 최적화한다. 이 고객 행동(패션 뉴스 구독)을 프로퍼티로 만들고 인덱스를 세워 가져올 수도 있겠지만, 그러려면 더 많은 저장 공간이 들고 인덱스 조회 비용까지 짊어져야 한다. 활성 클럽 회원이면서 패션 뉴스를 정기 구독하는 고객을 쓰고 싶다면, 레이블 기반 접근이 프로퍼티 저장에 견주어 이 정보를 더 효과적으로 가져오는 우위를 준다. 게다가 이 정보를 그래프로 표시할 때 사용자는 프로퍼티를 뒤질 필요 없이 레이블에서 정보를 바로 읽는다. 이런 방식의 데이터 소비가 더 자연스럽게 느껴지고, 질의도 더 자연스럽게 읽힌다.
품목 데이터의 적재
품목(article) 데이터에는 고유한 품목 ID와 설명 외에, 품목을 묘사하는 다른 카테고리들이 담겨 있다. 품목을 묘사하는 이 속성들을 그 자체로 노드로 만든다. 이를 위해 다음 고유 제약들을 생성한다.
CREATE CONSTRAINT product_code_idx FOR (n:Product) REQUIRE n.code IS UNIQUE; CREATE CONSTRAINT article_id_idx FOR (n:Article) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT product_type_id_idx FOR (n:ProductType) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT colour_group_idx FOR (n:ColorGroup) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT product_group_name_idx FOR (n:ProductGroup) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT graphical_appearance_id_idx FOR (n:GraphicalAppearance) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT perceived_colour_id_idx FOR (n:PerceivedColor) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT department_id_idx FOR (n:Department) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT section_id_idx FOR (n:Section) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT garment_group_id_idx FOR (n:GarmentGroup) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT article_index_id_idx FOR (n:Index) REQUIRE n.id IS UNIQUE; CREATE CONSTRAINT article_index_group_id_idx FOR (n:IndexGroup) REQUIRE n.id IS UNIQUE;
품목의 속성 대부분을 노드로 바꾸었음을 볼 수 있다. 그래프에 표현되는 데이터가 일종의 정규화를 거치는 셈이다. 다음 Cypher가 데이터를 그래프에 적재한다. 네 토막으로 나누어 읽는다.
LOAD CSV WITH HEADERS FROM "file:///articles.csv" as row WITH row CALL { WITH row MERGE(a:Article {id:row.article_id}) SET a.desc = row.detail_desc MERGE(p:Product {code:row.product_code}) SET p.name = row.prod_name MERGE(a)-[:OF_PRODUCT]->(p) MERGE(pt:ProductType {id:row.product_type_no}) SET pt.name = row.product_type_name MERGE(p)-[:HAS_TYPE]->(pt) WITH row, a, p MERGE(pg:ProductGroup {name:row.product_group_name}) MERGE(p)-[:HAS_GROUP]->(pg)
WITH row, a MERGE(g:GraphicalAppearance {id:row.graphical_appearance_no}) SET g.name = row.graphical_appearance_name MERGE (a)-[:HAS_GRAPHICAL_APPEARANCE]->(g) WITH row, a MERGE (c:ColorGroup {id: row.colour_group_code}) SET c.name = row.colour_group_name MERGE (a)-[:HAS_COLOR]->(c) WITH row, a MERGE (pc:PerceivedColor {id: row.perceived_colour_value_id}) SET pc.name = row.perceived_colour_value_name MERGE (a)-[:HAS_PERCEIVED_COLOR]->(pc) MERGE (pcm:PerceivedColor {id: row.perceived_colour_master_id}) SET pcm.name = row.perceived_colour_master_name MERGE (pc)-[:HAS_MASTER]->(pcm)
WITH row, a MERGE (d:Department {id:row.department_no}) SET d.name = row.department_name MERGE (a)-[:HAS_DEPARTMENT]->(d) WITH row, a MERGE (i:Index {id: row.index_code}) SET i.name = row.index_name MERGE (a)-[:HAS_INDEX]->(i) MERGE (ig:IndexGroup {id: row.index_group_no}) SET ig.name = row.index_group_name MERGE (i)-[:HAS_GROUP]->(ig)
WITH row, a MERGE (s:Section {id: row.section_no}) SET s.name = row.section_name MERGE (a)-[:HAS_SECTION]->(s) WITH row, a MERGE (gg:GarmentGroup {id: row.garment_group_no}) SET gg.name = row.garment_group_name MERGE (a)-[:HAS_GARMENT_GROUP]->(gg) } IN TRANSACTIONS OF 1000 ROWS
이 Cypher 질의에서 확인할 수 있듯, 그래프에는 정규화된 데이터가 지속된다. 품목을 묘사하는 여러 측면의 값들이 중복 없이 저장되는 것이다. 다음은 거래를 적재할 차례다.
거래 데이터의 적재
transactions_train.csv 데이터는 거래가 발생한 순서대로 담겨 있다. 그 덕분에 데이터를 적재하며 그래프 안에 시퀀스를 손쉽게 보존할 수 있다. 거래마다 한 행에 거래 날짜, 품목 ID, 고객 ID, 가격, 판매 채널이 담겨 있다. 유의할 점 하나 — 각 거래에는 고유 ID가 없다. 다음 Cypher로 데이터를 적재한다.
LOAD CSV WITH HEADERS FROM "file:///transactions_train.csv" as row WITH row CALL { WITH row MATCH (c:Customer {id:row.customer_id}) MATCH (a:Article {id:row.article_id}) WITH a, c, row CREATE (t:Transaction {date: row.t_dat, price: row.price, salesChannel: row.sales_channel_id}) CREATE (t)-[:HAS_ARTICLE]->(a) WITH c, t CALL { -- 기존 체인이 있는 고객: LATEST를 옮겨 단다 WITH c, t WITH c, t WHERE exists((c)-[:START_TRANSACTION]->()) OR exists((c)-[:LATEST]->()) MATCH (c)-[r:LATEST]->(lt) DELETE r CREATE (lt)-[:NEXT]->(t) CREATE (c)-[:LATEST]->(t) UNION -- 첫 거래인 고객: 열차의 머리를 만든다 WITH c, t WITH c, t WHERE NOT ( exists((c)-[:START_TRANSACTION]->()) OR exists((c)-[:LATEST]->()) ) CREATE (c)-[:START_TRANSACTION]->(t) CREATE (c)-[:LATEST]->(t) } } IN TRANSACTIONS OF 1000 ROWS
이 Cypher에서 볼 수 있듯, 주어진 고객의 첫 거래는 START_TRANSACTION 관계로 고객에 연결한다. 고객이 수행한 마지막 거래는 LATEST 관계로 추적한다. 고객의 거래가 계속 들어올 때마다 LATEST 관계를 가장 새 거래로 옮겨 달고, 이전에 LATEST로 연결되어 있던 거래와 새 거래를 NEXT 관계로 잇는다. 그러니 이 그래프에서 우리는 고객들의 거래를 트랜잭션 열차(transaction train)로 표현하고 있는 셈이다. 데이터셋 파일 이름 transactions_train.csv에 충실한 표현이다.
최종 그래프
모든 데이터를 적재하고 나면 그래프 모델은 그림 8.1과 같은 모습이 된다.
이 그래프에서 품목의 속성들이 여러 개별 노드로 부챗살처럼 펼쳐진(fanned out) 모습을 볼 수 있다. Customer 노드는 우편번호와 첫·마지막 거래에 연결되어 있다. Transaction은 Article과 연관되고, Transaction 노드들은 주어진 고객의 다음 거래로도 이어진다. 데이터가 적재되었으니, 이 데이터에서 그래프를 한층 더 강화해 데이터에 대한 우리의 이해와 아이디어를 그래프에 더하는 방법을 탐사하자.
(:Section {n: 2})-[:ENRICHES]->(:Season)
추천을 위한 최적화 — 그래프 모델링의 모범 사례
이제 우리가 소비하고 싶은 방식대로 데이터가 적재되고, 데이터의 문맥을 표현하는 그래프가 손에 있다. 그러나 이 그래프가 표현하는 것은 아직 원래 주어진 문맥뿐이다. 계절과 연도로 데이터를 소비하고 싶다면 여전히 그것을 가져오는 질의를 지어야 한다. Neo4j는 스키마가 선택적이니, 후처리(post-processing)로 추가 관계를 더해 그런 방식의 데이터 소비를 열어 볼 수 있다.
다음 Cypher 스크립트로 계절 관계를 만든다. 다섯 단계다.
- 고객마다 거래를 순회한다. START_TRANSACTION과 LATEST를 잡아
(s)-[:NEXT*]->(e)경로의 노드들을 펼친다. 월과 연도에 기초해 계절 값을 배정할 준비다. - 연도를 조정한다. 이를테면 월이 1이고 연도가 2020이면, 그 거래의 문맥으로는
WINTER_2019가 계절 이름이 된다 — 12월이 속한 겨울의 연도로 묶는 것이다. - 계절 값별로 거래를 모은다. 12·1·2월은 WINTER, 3·4·5월은 SPRING, 6·7·8월은 SUMMER, 9·10·11월은 FALL로 분류한다.
- 계절 값별 컬렉션의 첫 레코드를 얻는다.
head(collect(node))로 각 계절의 시작 거래를 잡는다. - 고객과 그 거래 사이에 계절 이름의 관계를 만든다. 관계 이름이 동적이므로
apoc.create.relationship프로시저를 쓴다.
MATCH (c:Customer) WITH c CALL { WITH c MATCH (c)-[:START_TRANSACTION]->(s) MATCH (c)-[:LATEST]->(e) WITH c, s, e MATCH p=(s)-[:NEXT*]->(e) WITH c, nodes(p) as nodes UNWIND nodes as node WITH c, node, node.date as d WITH c, node, toInteger(substring(d, 0,4)) as year, substring(d, 5,2) as month WITH c, node, CASE WHEN month="12" THEN year WHEN month="01" OR month="02" THEN year-1 ELSE year END as year, CASE WHEN month="12" OR month="01" OR month="02" THEN "WINTER" WHEN month="03" OR month="04" OR month="05" THEN "SPRING" WHEN month="06" OR month="07" OR month="08" THEN "SUMMER" WHEN month="09" OR month="10" OR month="11" THEN "FALL" END as season WITH c, node, season+'_'+year as relName WITH c, relName, head(collect(node)) as start WHERE relName is not null CALL apoc.create.relationship(c, relName, {}, start) YIELD rel WITH 1 as out return DISTINCT out } IN TRANSACTIONS OF 1000 ROWS WITH 1 as r RETURN DISTINCT r
이것이 아주 기초적인 접근이라는 점은 새겨 두자. 요점은, 데이터에 대한 우리의 이해에 기초해 그래프 안에 추가 문맥을 만들 수 있다는 것이다. 이런 접근들이 Neo4j를 지식그래프 구축에 매우 알맞게 만든다. 이렇게 데이터 접근을 쉽게 만들어 두면, 같은 데이터를 다르게 바라보아 더 많은 지능을 추출하는 새 아이디어들이 열린다. 그것도 추적 가능하고 이해 가능한, 단순한 방식으로.
데이터를 수동으로 적재하고 싶지 않다면, 데이터베이스 덤프 파일을 내려받을 수 있다.
이제 데이터에 더 많은 문맥이 더해졌다. 그래프 데이터 모델을 다시 보자.
데이터에 대한 우리의 이해를 이용해, 임의의 고객이 2019년 여름에 산 품목들을 가져오는 질의를 지어 보자.
MATCH (c:Customer)-[:SUMMER_2019]->(start), (c)-[:FALL_2019]->()<-[:NEXT]-(end) WITH c, start, end SKIP 100 LIMIT 1 MATCH p=(start)-[:NEXT*]->(end) WITH nodes(p) as nodes, relationships(p) as rels UNWIND nodes as node MATCH p=(node)-[:HAS_ARTICLE]->(a) RETURN a.desc as article
이 질의로 우리는 2019년 여름과 가을 모두에 구매 기록이 있는 고객들을 찾고, 그 목록에서 한 명을 골라, 품목 설명들을 가져온다. 질의의 출력은 다음과 같은 모습이다.
그림 8.3 — 한 고객의 SUMMER_2019 구매를 가져오는 Cypher 질의의 출력
질의를 보기만 해도 무엇을 하는 질의인지 이해하기 쉽다. SUMMER_2019를 시작점으로, FALL_2019 관계 앞의 거래를 끝점으로 삼아, 시작점에서 끝점까지 순회하며 그 거래들의 품목을 가져온다.
프로퍼티 기반 필터 대신 그래프 순회에 온전히 기대고 있음을 볼 수 있다. 그 덕분에 이 질의의 실행은 매우 효율적이다. Neo4j는 바로 이런 종류의 질의를 매우 효율적으로 실행하도록 지어졌다.
요약
이 장에서 우리는 그래프 데이터 모델을 바라보는 법을, 그리고 소비하는 방식에 기초해 모델을 지으면 데이터의 효율적 검색이 얼마나 쉬워지는지를 보았다. H&M 추천 데이터셋을 그 원리로 적재했고, 프로퍼티와 데이터에 대한 우리의 이해를 이용해 그래프를 증강하기까지 했다. 그래프에 더 많은 문맥이 더해졌고, 데이터 질의도 단순해졌다. 질의는 더 읽기 쉽고, 남에게 더 간명하게 설명할 수 있는 것이 되었다.
다음 장에서는 이 데이터 위에서 LLM을 이용해 그래프를 한층 더 강화하고, LLM이 어떻게 더 유능한 지식그래프를 우리 손에 쥐여 주는지 본다.