지식그래프 임포트, 엑셀·PPT·DOCX를 어떻게 합칠까?
번호 폴더로 시작한 임포터들
지식그래프에 데이터를 넣는 소스는 세 가지였거든요. 엑셀, PPT, DOCX. 각 소스에 대응하는 파싱·적재 스크립트가 따로 있었고, 처음엔 번호 폴더로 관리했습니다. 01_excel_import/, 02_ppt_import/, 03_docx_import/ 이런 식으로요.
번호 폴더는 작업한 순서대로 쌓여서 "언제 뭘 만들었는지"는 분명합니다. 그런데 한 달만 지나도 01이 무슨 임포터였는지 헷갈리거든요. 누가 새로 합류하면 전부 열어보면서 뭘 하는 코드인지 확인해야 합니다. 구조 자체가 검색을 요구하는 구조였어요.
3개 임포터의 코드가 서로 닮아 있었습니다
셋을 나란히 놓고 보니 꽤 많은 코드가 복붙돼 있었습니다. Neo4j MERGE 쿼리, 원본 문서와 엮어주는 SOURCED_FROM 관계 생성, 온톨로지에 없는 라벨/관계를 잡아내는 미매핑 감지, 그리고 거버넌스 서버(Spring Boot)로 보내는 콜백까지. 데이터 파싱 방법만 다르고 그 다음 단계는 거의 동일했거든요.
이게 왜 문제가 되냐면요.
온톨로지 스키마가 바뀌면 세 군데를 다 고쳐야 합니다
미매핑 감지 로직을 엑셀에만 업데이트하고 나머지 두 개를 빠뜨리면, PPT랑 DOCX에서 온 미매핑 신호는 영영 거버넌스에 올라오지 않아요
새 소스(예: PDF)를 붙인다? 기존 임포터 하나 통째로 복사해서 파싱 부분만 바꾸는 작업이 됩니다. 그 과정에서 새 버그가 들어옵니다
특히 세 번째가 체감이 컸어요. 소스가 3개일 땐 복붙이 참을만한데, 4개째 붙이는 순간부터는 "복붙 + 미세 수정"이 관성이 되어서 그 구조를 깨기가 더 어려워집니다.
파싱 결과를 같은 모양으로 정리만 하면, 그 다음은 똑같습니다
문제를 뜯어보니 단순했습니다. 소스는 제각각이지만, 파싱 결과를 엔티티 + 관계 + 미매핑이라는 같은 모양으로 정리만 하면, 그 다음부터는 전부 똑같은 절차거든요. MERGE 만들고, SOURCED_FROM 달고, 미매핑 모아서 콜백 보내고.
데이터 파이프라인에서 오래 써온 Transform/Load 분리 패턴입니다. ETL에선 원래 "소스에서 꺼내기(Extract) → 공통 포맷으로 바꾸기(Transform) → 목적지에 넣기(Load)"로 나누는데, 우리 임포터는 그 구분 없이 파일 파싱부터 Neo4j MERGE까지 한 함수에 다 들어있었습니다.
그래서 폴더 구조를 역할 기반으로 바꿨습니다.
importer/
common.py ← 공통 Load 함수 한 개
excel/import_.py ← 엑셀 Transform
ppt/import_.py ← PPT Transform
docx/import_.py ← DOCX Transform폴더 이름이 번호가 아니라 역할이 되니까, 뭘 어디서 고쳐야 하는지가 파일 트리만 봐도 보입니다. Load 쪽을 고치고 싶으면 common.py를 열고, 새 소스를 붙이려면 importer/ 아래에 폴더 하나 더 만들면 됩니다.

공통 포맷(entities_data) — 소스와 Load를 연결하는 약속
각 소스의 Transform 함수는 이 형태의 JSON을 반환하는 게 약속이에요.
{
"summary": "핵심 내용 1~2문장",
"entities": {
"System": [{"name": "hostname", "ip": "...", "type": "WAS"}],
"Service": [{"name": "서비스명", "category": "..."}]
},
"relationships": {
"RUNS_ON": [{"from": "서비스명", "to": "hostname"}]
},
"_discovered": {
"entities": {"새라벨": [{"name": "...", "reason": "추출 근거"}]},
"relationships": {"새관계": [{"from": "...", "to": "..."}]}
}
}핵심은 세 섹션입니다.
entities: 라벨별 엔티티 리스트
relationships: 관계 타입별 리스트
_discovered: ontology.yaml에 없는 라벨/관계 (정규 타입과 발견 타입을 분리하는 구조)
정규/발견 트랙 분리는 예전 글에서 설계한 원칙을 그대로 적용한 겁니다. 정규 추출은 ontology.yaml에 있는 타입만 쓰고, 모르는 건 _discovered로 모아서 거버넌스에 올리는 방식이에요.
이 포맷으로만 맞춰주면, Load는 import_extracted_entities() 함수 하나가 다 처리합니다. MERGE, SOURCED_FROM, 미매핑 감지, 콜백 데이터 준비까지 전부요.
Canonical Path — 모든 경로가 하나의 함수를 통과합니다
이 구조에서 핵심 원칙은 "어떤 소스든 Neo4j에 적재되기 전에 반드시 한 함수를 거친다"는 겁니다. 내부적으로는 Canonical Path라고 부르고 있어요.
흐름은 이렇게 됩니다.
엑셀 row → _transform_excel_to_entities() → 공통 포맷
PPT/DOCX LLM JSON → (이미 공통 포맷으로 추출되도록 프롬프트 구성) → 공통 포맷
공통 포맷 → import_extracted_entities() → Neo4j
PPT/DOCX는 LLM 프롬프트 단계에서 이미 공통 포맷을 맞춰서 뱉도록 설계돼 있어서, Transform이라고 할만한 게 거의 없어요. 엑셀만 row 데이터를 공통 포맷으로 바꾸는 Transform 로직이 필요하거든요.
이 원칙의 효과는 "한 곳만 고치면 된다" 입니다. 미매핑 감지 로직을 개선하면 모든 소스에 즉시 적용되고, MERGE 쿼리를 최적화해도 한 번만 하면 끝납니다.

새 소스 추가 절차 — 함수 하나 쓰고 호출하면 끝
이 구조 덕분에 새 소스(예: PDF)를 붙이는 건 꽤 단순해졌습니다.
importer/pdf/import_.py 만들고, PDF → 공통 포맷으로 바꾸는 Transform 함수를 작성
그 함수 끝에서 import_extracted_entities() 호출
Load 쪽은 건드릴 게 없어요. 미매핑 감지, 콜백, Neo4j MERGE는 전부 공통 함수가 알아서 합니다. 반환 dict의 unmapped_signals, raw_extraction도 자동으로 채워지고, 콜백까지 자동으로 전달됩니다.
새 소스를 붙일 때 "아 이 로직 복사해서 조금 수정하고…" 하는 고민 자체가 없어졌어요. 오히려 Transform 함수만 잘 짜는 데 집중하게 됩니다.
공통 포맷이 정말 공통이어야 합니다
이 구조의 함정은 "공통 포맷이 정말 공통이어야 한다"는 데 있어요. 엑셀은 엑셀만의 특수 필드가 있고 PPT는 PPT만의 필드가 있다면, 공통 포맷이 아니라 "엑셀용 포맷"과 "PPT용 포맷"이 따로 생기는 거거든요. 그러면 Load 함수가 또 if 분기를 태우게 되고, 결국 "공통"이라는 말이 이름뿐이 됩니다.
그래서 공통 포맷을 설계할 때 일부러 "모든 소스에 보편적으로 있을 수 있는 것만" 담았습니다. 엑셀의 특정 컬럼, PPT의 슬라이드 번호 같은 소스 고유 정보는 raw_extraction이라는 별도 필드에 싣고, 공통 포맷 본체는 엔티티·관계·미매핑 세 개만 담게 했어요. 공통 포맷은 "소스 중립적"이어야 한다는 원칙을 계속 지키는 게 생각보다 쉽지 않습니다.
마무리
번호 폴더에서 역할 기반 폴더로 바꾸고, 소스별 독립 임포터에서 Transform/Load 분리 구조로 옮겼습니다. Neo4j MERGE 로직은 한 곳에서만 관리되고, 미매핑 감지는 모든 소스에 자동으로 적용됩니다. 새 소스를 붙일 때는 Transform 함수 하나 작성하고 공통 Load 함수를 호출하는 걸로 끝납니다.
리팩토링은 소스가 3개일 때 하는 게 적당했어요. 4개, 5개로 늘어난 뒤엔 복붙된 코드의 관성 때문에 정리하기가 더 어렵거든요. 파이프라인에 새 소스를 붙일 계획이 하나라도 있다면, Transform/Load 분리는 일찍 적용하는 걸 추천합니다.