온톨로지 실전 입문

Neo4j와 AI로 지식그래프 시스템 만들기
저자: 김상기 · 2026 · HR안내봇 관통 프로젝트

온톨로지 실전 입문

부제

Neo4j와 AI로 지식그래프 시스템 만들기

저자

김상기

머리말

그래프로 생각한다는 것은 무엇일까?

우리는 오랫동안 데이터를 행과 열로 정리하는 데 익숙해져 왔다. 관계형 데이터베이스가 수십 년간 소프트웨어 세계를 지배한 데는 이유가 있다. 하지만 현실 세계의 지식은 표보다 그래프에 가깝다. 직원은 부서에 소속되고, 정책은 직급에 적용되고, 복리후생은 근속연수에 따라 달라진다. 이 관계의 그물망을 테이블로 표현하려면 조인이 끝없이 이어지고, AI에게 맥락을 전달하려면 텍스트 뭉치를 쏟아부어야 한다.

이 책은 하나의 질문에서 시작되었다. “AI 챗봇에게 우리 회사의 HR 정책을 정확하게 설명하게 하려면 어떻게 해야 할까?” 벡터 검색만으로는 멀티홉 추론이 안 되고, 키워드 매칭은 동의어에 무력하다. 답을 찾는 과정에서 온톨로지와 지식그래프, 그리고 GraphRAG라는 세 가지 열쇠를 발견했다.

이 책은 그 발견의 기록이자, 독자가 같은 여정을 더 빠르게 걸을 수 있도록 만든 안내서다. HR안내봇이라는 하나의 프로젝트를 1장에서 시작해 11장에서 완성한다. 매 챕터에서 한 겹씩 기능을 추가하며, 온톨로지 설계, Neo4j 지식그래프 구축, 그래프 알고리즘 분석, GraphRAG 통합까지 전 과정을 직접 체험한다.

코드를 직접 타이핑하지 않아도 괜찮다. 각 챕터의 “기술 리더 의사결정 박스”는 코드 없이도 핵심 판단 기준을 제공한다. 물론 실습을 따라하면 더 깊이 체득할 수 있다. 기술 리더든 직접 구현하는 개발자든, 각자의 속도로 읽어나가면 된다.

자, 월요일 아침 HR팀에 쏟아지는 질문들 앞에서 시작해보자. “육아휴직 중 연차는 어떻게 되나요?”라는 한 줄의 질문이, 우리를 어디까지 데려갈 수 있는지 함께 살펴보자.


1장. 왜 지식그래프인가 — HR안내봇의 문제에서 출발하기

월요일 아침, 사내 메신저에 질문이 하나 올라왔다고 상상해보자.

“육아휴직 중인데, 남은 연차는 어떻게 되나요? 복직하면 바로 쓸 수 있나요?”

HR팀 담당자는 이 질문에 답하려면 최소 세 가지 규정을 교차로 확인해야 한다. 육아휴직 규정, 연차 산정 기준, 복직 후 근로 조건. 문서가 각각 다른 폴더에 흩어져 있고, 서로를 참조하는 방식도 제각각이다. 사람이 답하기에도 번거로운 질문을, 챗봇에게 맡기면 어떻게 될까?

바로 이 질문이 이 책의 출발점이다.

키워드 검색, 그 익숙한 한계

가장 먼저 떠오르는 접근은 키워드 검색이다. “육아휴직”, “연차”라는 단어로 사내 문서를 검색하면 관련 문서 몇 건이 나올 것이다. 그런데 문제가 있다.

“연차”라고 검색했는데 “연차유급휴가”라는 표현을 쓴 문서는 빠진다. “육아휴직”을 검색했더니 “출산전후휴가”와 혼동되는 결과가 섞여 나온다. 키워드 검색은 동의어를 모르고, 맥락을 무시한다. 검색 결과 열 건을 직원에게 던져주며 “이 중에서 찾아보세요”라고 하는 건, 솔직히 챗봇이라 부르기 민망하다.

더 난감한 상황도 있다. “육아휴직 중 연차는 어떻게 되나요?”라는 질문은 사실 두 규정의 교차점을 묻고 있다. 키워드 검색은 개별 문서를 찾아줄 뿐, 두 문서 사이의 관계를 이해하지 못한다. “육아휴직 기간에는 연차가 발생하지 않되, 복직 후 잔여 연차는 유지된다”라는 답을 만들어내려면, 검색 엔진이 아니라 규정 간의 관계를 아는 무언가가 필요하다.

벡터 RAG — 한 걸음 나아갔지만

최근 몇 년 사이 등장한 벡터 RAG(Retrieval-Augmented Generation)는 키워드 검색보다 한 단계 영리하다. 문서를 작은 조각(청크)으로 나누고, 각 조각을 벡터로 변환한 뒤, 질문과 의미적으로 가장 유사한 조각을 찾아 LLM에게 넘긴다. LLM은 그 조각을 바탕으로 자연스러운 문장을 생성한다.

“육아휴직 연차”와 “연차유급휴가”가 의미적으로 비슷하다는 것쯤은 벡터 검색이 잡아낸다. 동의어 문제는 상당 부분 해결된다. 그렇다면 벡터 RAG로 충분한 걸까?

아쉽게도 그렇지 않다. 앞서 던진 질문을 다시 살펴보자. “육아휴직 중 연차는 어떻게 되나요? 복직하면 바로 쓸 수 있나요?” 이 질문에 정확히 답하려면, 시스템은 다음과 같은 사고 경로를 따라야 한다.

  1. 질문한 사람(직원)의 현재 상태를 파악한다 — 육아휴직 중
  2. 육아휴직 규정에서 연차 관련 조항을 찾는다
  3. 연차 산정 기준에서 휴직 기간의 처리 방식을 확인한다
  4. 복직 규정에서 연차 사용 가능 시점을 확인한다
  5. 이 세 가지를 종합하여 답변을 구성한다

이것은 멀티홉 추론(multi-hop reasoning)이다. A에서 B로, B에서 C로, C에서 D로 건너가며 답을 조립하는 과정이다. 벡터 RAG는 각 문서 조각을 독립적으로 검색할 뿐, 조각들 사이의 논리적 연결 고리를 따라가지 못한다.

실제 벤치마크가 이를 뒷받침한다. FalkorDB의 2025년 평가에서 벡터 RAG의 정확도는 56.2%에 그쳤다. 반면 그래프 기반 RAG는 90%를 넘겼다. Microsoft의 계층적 커뮤니티 접근법에서는 기존 RAG가 32%에 머물 때 GraphRAG가 86%를 달성했다. 단순한 의미 유사도만으로는 복잡한 질문에 답하기 어렵다는 뜻이다.

관계를 구조화한다는 것

그렇다면 어떻게 해야 할까?

핵심은 데이터 간의 관계를 명시적으로 구조화하는 것이다. “직원”이라는 개념과 “부서”라는 개념이 있고, 이 둘 사이에 “소속”이라는 관계가 있다는 것을 시스템이 알게 하는 것이다. “육아휴직”이라는 정책이 “연차 산정”이라는 다른 정책과 “영향을 미친다”라는 관계로 연결되어 있다는 것을 명시하는 것이다.

이런 구조가 갖춰지면, 앞서의 질문은 이렇게 풀린다.

시스템이 이 관계의 사슬을 따라가면, “김철수님, 육아휴직 기간에는 새로운 연차가 발생하지 않습니다. 다만 휴직 전 남은 연차는 복직 후 바로 사용하실 수 있습니다”라는 정확한 답변을 조립할 수 있다.

이것이 지식그래프(Knowledge Graph)의 핵심 아이디어다. 데이터를 단순히 저장하는 것이 아니라, 데이터 간의 관계를 그래프 구조로 명시하여 기계가 관계의 사슬을 따라 추론할 수 있게 만드는 것이다.

아직 “온톨로지”니 “그래프 데이터베이스”니 하는 정식 용어를 꺼내지 않았다. 걱정하지 말자. 이 용어들은 2장과 3장에서 차근차근 알아볼 것이다. 지금 기억해둘 것은 단 하나다. 데이터 간 관계를 구조화하는 접근법이, 키워드 검색과 벡터 RAG가 풀지 못하는 문제를 풀 수 있다는 직관이다.

이미 검증된 접근법

이 접근법이 실험실의 이론에 그치는 것은 아닌지 찜찜할 수 있다. 실제 엔터프라이즈 현장을 살펴보자.

금융 컴플라이언스 챗봇. 한 글로벌 금융서비스 기업은 규제 문서, 내부 메모, 직원 FAQ 사이의 관계를 지식그래프로 매핑하여 내부 정책 챗봇을 구축했다. 컴플라이언스 관련 질문에 대해 92%의 정확도를 달성했다. 금융 규제라는 고도로 복잡한 도메인에서도, 관계를 구조화하면 챗봇이 쓸 만해진다는 증거다.

제조 현장의 대화형 AI. Ontotext의 사례에서는, 장비-공정-부품 사이의 관계를 지식그래프로 구조화하여 제조 현장의 지식 접근성을 크게 높였다. 복잡한 기술 문서를 자연어로 탐색할 수 있게 된 것이다.

온보딩 자동화. 신규 입사자의 역할, 팀, 위치 정보를 지식그래프로 연결하면, 필요한 라이선스, 보안 접근 권한, 교육 과정을 자동으로 배정할 수 있다. 신규 입사자 적응 시간이 눈에 띄게 단축된다.

엔터프라이즈 지식그래프의 ROI는 300~320%에 달한다는 조사 결과도 있다. 물론 모든 프로젝트가 이 수준의 성과를 내는 것은 아니다. 하지만 데이터 간 관계가 복잡하고, 멀티홉 추론이 필요한 도메인에서는, 이 접근법이 가장 효과적이라는 것은 이미 여러 현장에서 확인되었다.

이 책에서 만들 것

이 책에서는 HR안내봇이라는 하나의 프로젝트를 처음부터 끝까지 함께 만들어간다. 매 챕터에서 한 겹씩 기능을 추가하여, 마지막에는 실제로 동작하는 온톨로지 기반 HR안내봇을 완성할 것이다.

큰 그림은 이렇다.

코드를 직접 타이핑하지 않아도 아키텍처와 원리를 이해할 수 있도록 설계했다. 각 챕터에는 “기술 리더 의사결정 박스”가 포함되어, 코드 없이도 핵심 판단 기준을 제공한다. 물론 실습을 따라하면 더 깊이 체득할 수 있다.

기술 리더 의사결정 박스: 우리 조직에 지식그래프가 필요한가?

모든 프로젝트에 지식그래프가 필요한 것은 아니다. 아래 기준으로 판단해보자.

판단 기준 벡터 RAG로 충분 지식그래프가 필요
질문의 복잡도 단일 문서에서 답을 찾을 수 있는 질문이 대부분 여러 문서·규정을 교차 참조해야 하는 질문이 빈번
관계의 중요도 개별 문서의 내용이 독립적으로 의미 있음 데이터 간 관계(소속, 적용, 영향)가 답변 품질을 좌우
추론 깊이 1~2홉이면 충분 (질문 → 문서 → 답변) 3홉 이상의 멀티홉 추론이 필요 (A→B→C→답변)

세 기준 중 두 가지 이상에서 “지식그래프가 필요” 쪽에 해당한다면, 이 책이 다루는 접근법을 진지하게 검토하는 편이 낫다.

마무리

이제 우리는 왜 키워드 검색과 벡터 RAG만으로는 부족한지, 그리고 데이터 간 관계를 구조화하는 접근법이 어떻게 그 한계를 넘어서는지 살펴보았다. HR안내봇이라는 프로젝트의 문제도 정의했다.

그런데 “데이터 간 관계를 구조화한다”는 말이 여전히 추상적으로 느껴질 수 있다. 구체적으로 어떤 구성 요소가 있고, 어떤 표준을 따르며, 실무에서 어떤 형태로 설계하는 것일까?

다음 장에서 그 답을 찾아보자.


HR안내봇 진행도

항목 상태
이번 장에서 한 것 문제 정의 완료
산출물 요구사항 목록, 예상 질문 시나리오 20개 도출
다음 장 예고 온톨로지의 핵심 개념 학습 — HR 도메인의 설계도를 종이 위에 스케치

예상 질문 시나리오 (발췌) 1. 육아휴직 중 연차는 어떻게 되나요? 2. 인사팀 소속 직원 목록을 알려주세요 3. 김철수의 매니저는 누구인가요? 4. 재택근무 정책이 적용되는 부서는 어디인가요? 5. 경력 5년 이상 직원에게 적용되는 복리후생은? 6. 부서 이동 시 연차는 어떻게 처리되나요? 7. 인턴의 4대 보험 적용 기준은? 8. 야근 수당 산정 기준을 알려주세요 9. 해외 출장 시 보험 적용 범위는? 10. 퇴직금 중간정산 요건은 무엇인가요?


2장. 온톨로지 핵심 개념 — 도메인의 설계도 그리기

건물을 짓기 전에 설계도를 그린다. 설계도 없이 벽돌부터 쌓는 사람은 없다. 그런데 소프트웨어 세계에서는 설계도 없이 데이터부터 쌓는 일이 의외로 흔하다. 테이블을 만들고, 칼럼을 추가하고, 관계를 대충 이어붙인다. 나중에 “이 데이터가 저 데이터와 어떤 관계인지” 물어보면, 코드를 뒤져야 겨우 알 수 있는 상황이 된다.

1장에서 우리는 HR안내봇의 문제를 정의했다. 데이터 간 관계를 구조화해야 한다는 직관도 얻었다. 그렇다면 그 “구조화”를 어떤 체계로 하는 걸까? 이제 그 체계의 이름을 꺼내보자. 온톨로지(Ontology)다.

온톨로지란 무엇인가

온톨로지라는 말은 원래 철학에서 왔다. “존재하는 것은 무엇인가”를 탐구하는 학문이다. 컴퓨터 과학에서는 이 거창한 질문을 실용적으로 바꿔 쓴다. “특정 도메인에서 어떤 종류의 것이 존재하고, 그것들이 서로 어떤 관계를 맺는가”를 명시적으로 정의한 체계 — 이것이 온톨로지다.

좀 더 학술적으로 표현하면, Gruber(1993)의 유명한 정의가 있다. “공유된 개념화의 명시적 명세(explicit specification of a shared conceptualization).” 이 한 문장이 핵심을 담고 있지만, 솔직히 처음 듣는 사람에게는 암호 같다.

비유로 풀어보자. 온톨로지는 설계도이고, 지식그래프는 그 설계도로 지은 건물이다.

설계도가 “여기에 벽이 있고, 저기에 문이 있다”를 정의하면, 건물은 실제로 그 벽과 문이 존재하는 물리적 구조물인 것과 같다. 설계도 없이도 건물을 지을 수는 있다. 하지만 설계도 없이 지은 건물은, 나중에 증축하거나 수리할 때 끔찍한 일이 벌어진다. 어디에 기둥이 있는지, 배관이 어디를 지나는지 아무도 모르기 때문이다.

데이터도 마찬가지다. 온톨로지 없이 지식그래프를 구축할 수는 있다. 하지만 데이터가 쌓이고 요구사항이 바뀔수록, “이 노드와 저 노드가 왜 이렇게 연결되어 있지?”라는 질문에 답하기 어려워진다. 온톨로지는 그런 혼란을 미리 방지하는 설계도다.

온톨로지의 5대 구성 요소

온톨로지를 구성하는 핵심 요소 다섯 가지를 하나씩 살펴보자. 이론적으로 설명하기보다, HR 도메인의 예시와 함께 알아보는 편이 훨씬 이해가 빠르다.

클래스(Class) — “어떤 종류의 것이 있는가”

클래스는 같은 유형의 개체를 묶는 범주다. 프로그래밍에서의 클래스와 개념이 비슷하다.

HR 도메인에서의 클래스를 생각해보자.

클래스는 계층 구조를 가질 수 있다. 예를 들어, “정규직(FullTimeEmployee)”과 “계약직(ContractEmployee)”은 “직원(Employee)”의 하위 클래스다. “휴가정책(LeavePolicy)”과 “급여정책(SalaryPolicy)”은 “정책(Policy)”의 하위 클래스다. 이런 계층 구조를 분류 체계(taxonomy)라고 부른다.

클래스를 설계할 때 흔히 빠지는 함정이 있다. 너무 세밀하게 나누는 것이다. “3년차_정규직_인사팀_대리”를 하나의 클래스로 만들 필요는 없다. 근속 연수, 고용 형태, 부서, 직급은 각각 별도의 속성이나 관계로 표현하는 편이 낫다. 클래스는 “본질적으로 다른 종류”를 구분하는 데 쓰자.

인스턴스(Instance) — “구체적으로 누구, 무엇인가”

인스턴스는 클래스의 구체적인 개체다. 클래스가 “직원”이라면, 인스턴스는 “김철수”, “이영희” 같은 실제 사람이다. 클래스가 “부서”라면, 인스턴스는 “인사팀”, “개발팀”, “마케팅팀”이다.

온톨로지 자체에는 인스턴스가 포함되지 않는 경우도 많다. 온톨로지는 “어떤 종류의 것이 있고, 그것들이 어떤 관계를 맺을 수 있는가”를 정의하는 스키마(schema) 역할을 하고, 실제 인스턴스 데이터는 지식그래프에 담기기 때문이다. 설계도에는 “여기에 가구가 놓인다”고 표시하지, 실제 가구를 그리지는 않는 것과 같다.

프로퍼티(Property) — “어떤 특성을 가지는가”

프로퍼티는 개체의 속성을 기술한다. 두 가지 종류로 나뉜다.

데이터 프로퍼티(Data Property)는 개체의 단순한 값을 기술한다. - 직원의 이름(name): 문자열 - 직원의 입사일(hireDate): 날짜 - 정책의 시행일(effectiveDate): 날짜 - 복리후생의 지급액(amount): 숫자

오브젝트 프로퍼티(Object Property)는 개체와 개체 사이의 관계를 기술한다. 이것이 곧 다음에 설명할 “관계”와 겹친다. 온톨로지 표준에서는 프로퍼티의 한 종류로 관계를 다루지만, 개념적으로는 구분해서 이해하는 것이 바람직하다.

관계(Relationship) — “어떻게 연결되는가”

관계는 온톨로지의 꽃이라 할 수 있다. 1장에서 “데이터 간 관계를 구조화한다”고 했을 때, 바로 이 관계를 말한 것이다.

HR 도메인의 주요 관계를 살펴보자.

마지막 관계에 주목하자. 정책과 정책 사이의 관계다. 1장에서 “육아휴직 중 연차는 어떻게 되나요?”라는 질문을 풀지 못했던 근본 원인이, 바로 이 관계가 시스템에 명시되어 있지 않았기 때문이다. 온톨로지에 이 관계를 정의해두면, 시스템은 “육아휴직” 노드에서 “영향” 관계를 따라 “연차 산정” 노드로 건너갈 수 있다.

관계에는 방향이 있다. “김철수가 인사팀에 소속되어 있다”와 “인사팀이 김철수를 소속원으로 가진다”는 같은 사실을 다른 방향에서 표현한 것이다. 온톨로지에서는 보통 하나의 방향을 정하고, 필요하면 역관계(inverse)를 정의한다. “belongsTo”의 역관계는 “hasMember”가 될 수 있다.

공리(Axiom) — “어떤 규칙이 지켜져야 하는가”

공리는 도메인의 규칙과 제약 조건을 정의한다. 데이터의 품질을 지키는 파수꾼 역할을 한다.

HR 도메인의 공리 예시를 보자.

공리가 왜 중요한지 생각해보자. 공리가 없으면, 누군가 실수로 한 직원을 두 부서에 소속시키는 데이터를 입력할 수 있다. 계약직에게 정규직 전용 복리후생을 배정하는 오류도 잡아내지 못한다. 공리는 이런 오류를 설계 단계에서 방지한다.

공리를 너무 빡빡하게 잡으면 데이터 입력이 번거로워지고, 너무 느슨하게 잡으면 쓰레기 데이터가 들어온다. 이 균형을 찾는 것이 온톨로지 설계의 묘미이자 난관이다. 처음에는 핵심적인 제약만 잡고, 운영하면서 점진적으로 강화하는 편이 현실적이다.

다섯 요소를 한눈에

지금까지 살펴본 다섯 가지 구성 요소를 HR 도메인으로 정리해보자.

구성 요소 역할 HR 도메인 예시
클래스 개체의 유형/범주 직원, 부서, 직급, 정책, 복리후생
인스턴스 클래스의 구체적 개체 김철수, 인사팀, 육아휴직 정책
프로퍼티 개체의 속성 이름, 입사일, 시행일, 지급액
관계 개체 간 연결 belongsTo, reportsTo, appliesTo, affects
공리 도메인 규칙/제약 “모든 직원은 하나의 부서에 소속”

이 다섯 가지가 온톨로지의 전부다. 물론 각 요소를 얼마나 정교하게 설계하느냐에 따라 온톨로지의 복잡도가 천차만별이 되지만, 뼈대는 항상 이 다섯 가지다. 기억해두자.

온톨로지 표준 비교 — RDF, RDFS, OWL, SKOS

온톨로지를 기술하는 표준이 여러 가지 있다. 처음 접하면 알파벳 약어의 홍수 속에서 난감해지기 쉽다. 하나씩 정리해보자.

RDF — 가장 기본적인 표현

RDF(Resource Description Framework)는 데이터를 주어-술어-목적어 트리플(triple)로 표현하는 W3C 표준이다.

김철수 — 소속부서 — 인사팀
인사팀 — 유형 — 부서
육아휴직정책 — 적용대상 — 정규직

모든 데이터를 이 세 단어짜리 문장으로 쪼개는 것이다. 단순하지만 강력하다. 어떤 복잡한 사실도 트리플의 조합으로 표현할 수 있다.

다만 RDF 자체는 “부서”가 무엇인지, “소속부서”가 어떤 종류의 관계인지 정의하는 방법을 제공하지 않는다. 그저 세 단어를 나열할 뿐이다.

RDFS — 기본 어휘 추가

RDFS(RDF Schema)는 RDF 위에 클래스, 하위 클래스, 프로퍼티 계층 같은 기본 어휘를 추가한다.

직원 — 하위클래스 — 사람
정규직 — 하위클래스 — 직원
소속부서 — 도메인 — 직원
소속부서 — 범위 — 부서

이제 “소속부서라는 관계는 직원에서 출발하여 부서로 향한다”는 것을 명시할 수 있다. 기본적인 도메인 모델링에 충분한 수준이다.

OWL — 풍부한 표현력과 추론

OWL(Web Ontology Language)은 RDFS를 확장하여 훨씬 풍부한 표현과 자동 추론(reasoning)을 가능하게 한다.

OWL에서는 이런 것들을 표현할 수 있다.

OWL의 가장 강력한 기능은 추론기(Reasoner)와의 연동이다. 위의 규칙들을 정의해두면, 추론기가 자동으로 “김철수는 부하직원이 3명이므로 매니저 클래스에 속한다”는 결론을 도출할 수 있다. 명시적으로 “김철수는 매니저다”라고 입력하지 않아도 된다.

물론 이 강력함에는 대가가 있다. 학습 곡선이 가파르고, 복잡한 OWL 온톨로지는 추론 성능이 느려질 수 있다. OWL에는 표현력에 따라 OWL Lite, OWL DL, OWL Full이라는 단계가 있는데, 실무에서는 대부분 OWL DL 수준이면 충분하다.

SKOS — 가벼운 분류 체계

SKOS(Simple Knowledge Organization System)는 이름 그대로 단순한 지식 구조를 표현하는 데 적합하다. 분류 체계(taxonomy), 시소러스(thesaurus), 태그 시스템 같은 것들이다.

HR정책 — 상위개념 — 인사관리
육아휴직 — 상위개념 — HR정책
연차 — 관련개념 — 육아휴직
연차유급휴가 — 대체용어 — 연차

SKOS의 장점은 단순함이다. 클래스 계층이나 카디널리티 제약 같은 복잡한 개념 없이, “이 개념은 저 개념의 하위다”, “이 용어와 저 용어는 같은 뜻이다” 정도만 표현한다. 1장에서 문제가 되었던 “연차”와 “연차유급휴가”의 동의어 관계를 잡아주는 데는 SKOS만으로도 충분하다.

어떤 표준을 선택할 것인가

네 가지 표준을 비교하면 이런 그림이 된다.

기준 SKOS RDFS OWL DL OWL Full
표현력 낮음 (분류 체계) 중간 (기본 모델링) 높음 (추론 가능) 최고 (완전 표현)
학습 곡선 쉬움 보통 가파름 매우 가파름
추론 지원 없음 기본 완전 이론적
도구 지원 풍부 풍부 풍부 제한적
적합 사례 태그, 분류 기본 도메인 모델 엔터프라이즈 지식그래프 학술 연구

실무에서의 선택 기준은 의외로 간단하다.

우리 HR안내봇 프로젝트에서는 OWL을 기본으로 사용한다. 정책 간 영향 관계, 직원-부서 카디널리티 제약 등을 표현하려면 OWL 수준의 표현력이 필요하기 때문이다. 하지만 처음부터 OWL의 모든 기능을 동원할 필요는 없다. RDFS 수준에서 시작하여, 필요에 따라 OWL의 기능을 추가하는 점진적 접근이 현실적이다.

기술 리더 의사결정 박스: 어떤 온톨로지 표준을 선택할 것인가?

선택지 표현력 학습 곡선 도구 지원 추천 상황
SKOS 분류/동의어 1~2일 풍부 검색 개선, 태그 관리, 시소러스 구축
RDFS 기본 모델링 1~2주 풍부 PoC, 소규모 도메인, 빠른 시작
OWL DL 추론 가능 2~4주 풍부 엔터프라이즈 지식그래프, 데이터 품질 중시

판단 기준: (a) 도메인의 규칙이 얼마나 복잡한가, (b) 자동 추론이 비즈니스 가치를 만드는가, (c) 팀의 학습 여력은 충분한가. 세 질문에 답하면 선택이 좁혀진다.

HR 도메인으로 맛보기

이론만으로는 찜찜하다. 우리 HR 도메인에 온톨로지 개념을 직접 적용해보자.

클래스 설계

HR 도메인의 핵심 클래스 다섯 가지를 정의한다.

Thing
├── Person
│   └── Employee
│       ├── FullTimeEmployee
│       └── ContractEmployee
├── OrganizationalUnit
│   └── Department
├── Position
│   ├── Manager
│   └── Staff
├── Policy
│   ├── LeavePolicy
│   ├── SalaryPolicy
│   └── WelfarePolicy
└── Benefit
    ├── HealthBenefit
    └── EducationBenefit

최상위에 Thing이 있고, 그 아래로 큰 범주가 나뉘고, 각 범주 안에서 세부 클래스가 나뉜다. 이것이 클래스 계층이다.

관계 설계

클래스 사이의 주요 관계를 정의한다.

Employee --[belongsTo]--> Department
Employee --[reportsTo]--> Employee
Employee --[hasPosition]--> Position
Policy --[appliesTo]--> Employee | Department
Policy --[affects]--> Policy
Benefit --[availableTo]--> Employee | Department
Employee --[receives]--> Benefit

화살표의 방향에 주목하자. “Employee가 Department에 소속된다”이지, “Department가 Employee를 가진다”가 아니다. 물론 역방향도 의미가 있지만, 주된 방향 하나를 정하는 것이 혼란을 줄인다.

공리 설계

핵심적인 규칙 몇 가지를 공리로 정의한다.

이 정도면 HR 온톨로지의 골격이 잡힌다. 아직 종이 위의 스케치 수준이지만, 4장에서 이 스케치를 정식 온톨로지 도구(Protege)로 옮기고, LLM을 활용해 자동 생성하는 방법도 함께 알아볼 것이다.

흔한 함정 — 온톨로지를 너무 크게, 또는 너무 작게

온톨로지 설계에서 초보자가 가장 자주 빠지는 함정 두 가지를 짚어보자.

함정 1: 세상 모든 것을 담으려 한다

“직원” 클래스를 정의했으니, “직원의 가족”, “가족의 보험”, “보험의 약관”, “약관의 법적 근거”까지 전부 넣어야 하지 않을까? 이 유혹에 빠지면 온톨로지가 끝없이 비대해진다. 설계에 몇 달이 걸리고, 완성될 무렵에는 이미 요구사항이 바뀌어 있다.

해결: 비즈니스 질문에서 시작하자. HR안내봇이 답해야 하는 질문 목록을 먼저 만들고, 그 질문에 답하는 데 필요한 개념과 관계만 온톨로지에 포함한다. Juan Sequeda의 교훈을 기억해두자. “충분히 좋은 온톨로지가 완벽한 온톨로지보다 낫다.”

함정 2: 너무 단순하게 만든다

반대로, “직원”, “부서” 두 클래스에 “소속” 관계 하나만 정의하고 끝내는 경우도 있다. 이러면 “육아휴직 중 연차는 어떻게 되나요?” 같은 질문에 답할 수 없다. 정책 간의 관계, 정책과 직원 유형 간의 관계가 빠져 있기 때문이다.

해결: 답해야 하는 질문의 추론 경로를 미리 그려보자. “이 질문에 답하려면 어떤 노드에서 어떤 관계를 따라가야 하는가?” 이 경로에 필요한 클래스와 관계가 온톨로지에 있는지 확인한다. 없으면 추가한다.

결국 핵심은 “이 온톨로지가 우리의 질문에 답할 수 있는가?”라는 실용적 기준이다. 이론적 완결성이 아니라 비즈니스 유용성이 설계의 나침반이다.

온톨로지는 살아있는 문서다

하나 더 기억해둘 것이 있다. 온톨로지는 한 번 만들고 끝나는 것이 아니다. 새로운 HR 정책이 생기면 클래스나 관계를 추가해야 하고, 조직이 개편되면 구조를 수정해야 한다. 온톨로지는 살아있는 문서다.

이것이 부담스럽게 느껴질 수 있다. “유지보수할 것이 또 하나 늘어나는 건가?” 맞다. 하지만 온톨로지 없이 지식그래프를 운영하면, 데이터 구조의 변경 이력을 아무도 추적하지 못하는 상황이 된다. 온톨로지가 있으면, 최소한 “무엇이 바뀌었고, 왜 바뀌었는지”를 명시적으로 기록할 수 있다.

온톨로지의 진화 관리에 대해서는 9장에서 본격적으로 다룰 것이다. 지금은 “온톨로지는 지속적으로 개선해야 하는 살아있는 체계”라는 인식만 가지고 넘어가자.

마무리

이번 장에서 우리는 온톨로지의 핵심 개념을 알아보았다. 클래스, 인스턴스, 프로퍼티, 관계, 공리 — 이 다섯 가지가 온톨로지의 뼈대다. RDF, RDFS, OWL, SKOS라는 표준도 비교했고, HR 도메인에 적용하여 종이 위에 스케치도 그려보았다.

그런데 아직 종이 위의 이야기다. 이 온톨로지를 실제로 저장하고, 데이터를 넣고, 쿼리하려면 도구가 필요하다. 다음 장에서는 그래프 데이터베이스 Neo4j를 설치하고, Cypher라는 쿼리 언어로 HR 샘플 데이터를 직접 다뤄보자.


HR안내봇 진행도

항목 상태
이번 장에서 한 것 HR 도메인의 핵심 클래스 5개, 주요 관계, 기본 공리를 종이 위에 스케치
산출물 HR 온톨로지 클래스 계층도, 관계 목록, 공리 초안
다음 장 예고 Neo4j 설치 및 Cypher 기초 — 그래프 데이터베이스에 첫 데이터 입력

3장. Neo4j 시작하기 — 그래프 데이터베이스에 첫 데이터 넣기

2장에서 우리는 종이 위에 HR 온톨로지를 스케치했다. 직원, 부서, 정책, 그리고 그 사이의 관계. 설계도는 완성되었는데, 이제 이 설계도를 실제로 구현할 도구가 필요하다. 도면만 보고 있으면 건물이 지어지지 않는 것처럼.

“데이터베이스에 넣으면 되는 거 아닌가?”라고 생각할 수 있다. 맞다. 그런데 어떤 데이터베이스에 넣느냐가 중요하다. 우리가 다루는 데이터의 핵심은 관계다. 직원과 부서의 소속 관계, 정책 간의 영향 관계, 직원과 매니저의 보고 관계. 이 관계를 자연스럽게 저장하고 탐색할 수 있는 데이터베이스가 필요하다.

관계형 데이터베이스(RDBMS)로도 관계를 표현할 수 있다. 외래 키와 JOIN을 쓰면 된다. 하지만 “김철수의 매니저의 매니저가 관리하는 정책 중 육아휴직과 관련된 것”을 쿼리하려면 JOIN이 서너 번 중첩되어야 한다. 쿼리가 복잡해지고, 성능도 떨어진다. 관계의 깊이가 깊어질수록 상황은 더 난감해진다.

그래프 데이터베이스는 이 문제를 위해 태어났다. 노드와 관계를 일급 시민(first-class citizen)으로 다루어, 관계 탐색이 데이터베이스의 핵심 연산이 된다. 그중에서도 Neo4j가 가장 널리 쓰이고, 생태계가 풍부하다. 함께 시작해보자.

Neo4j 설치와 환경 설정

Neo4j를 시작하는 방법은 크게 두 가지다. 로컬에 직접 설치하는 방법과, 클라우드 서비스를 이용하는 방법이다.

경로 A: 로컬 설치 — Neo4j Desktop

로컬 환경에서 자유롭게 실험하고 싶다면 Neo4j Desktop을 설치하는 편이 낫다.

  1. neo4j.com/download에서 Neo4j Desktop을 다운로드한다
  2. 설치 후 실행하면 프로젝트를 생성할 수 있다
  3. 프로젝트 안에서 “Add Database”를 클릭하고 로컬 DBMS를 생성한다
  4. 데이터베이스를 시작하면 Neo4j Browser가 http://localhost:7474에서 열린다

Neo4j Desktop은 데이터베이스 관리, 플러그인 설치, 백업까지 GUI로 제공한다. 학습 단계에서는 이것이 가장 편하다.

경로 B: 클라우드 — AuraDB Free Tier

설치 없이 바로 시작하고 싶다면 Neo4j AuraDB의 무료 티어를 쓸 수 있다.

  1. neo4j.com/cloud/aura-free에서 계정을 만든다
  2. Free Instance를 생성한다 (노드 20만 개, 관계 40만 개까지 무료)
  3. 생성된 인스턴스의 Connection URI와 비밀번호를 기록해둔다
  4. 웹 브라우저에서 바로 Neo4j Browser에 접속할 수 있다

AuraDB Free Tier는 설치 부담이 없고, 어디서든 접속할 수 있다는 장점이 있다. 다만 플러그인 설치에 제약이 있어서, 5장에서 Neosemantics(n10s) 플러그인을 사용할 때는 로컬 설치가 필요하다. 지금은 어느 쪽이든 상관없다.

어떤 경로를 선택했든, Neo4j Browser가 열리면 준비 완료다. 화면 상단의 입력창에 Cypher 쿼리를 입력하고 실행하는 방식이다.

Labeled Property Graph — Neo4j의 데이터 모델

Neo4j에 데이터를 넣기 전에, Neo4j가 데이터를 어떻게 바라보는지 이해해야 한다. Neo4j는 Labeled Property Graph(LPG) 모델을 사용한다. 세 가지 핵심 요소가 있다.

노드(Node) — 명사

노드는 개체를 표현한다. 2장에서 배운 “인스턴스”에 해당한다. 각 노드에는 하나 이상의 라벨(Label)을 붙일 수 있고, 속성(Property)을 키-값 쌍으로 가질 수 있다.

(김철수:Employee {name: "김철수", hireDate: "2020-03-15", age: 35})
(인사팀:Department {name: "인사팀", code: "HR-001"})

김철수Employee라는 라벨이 붙은 노드이고, name, hireDate, age라는 속성을 가진다. 라벨은 2장에서 배운 “클래스”에 대응하고, 속성은 “데이터 프로퍼티”에 대응한다.

관계(Relationship) — 동사

관계는 두 노드를 연결한다. 항상 방향이 있고, 타입(Type)이 있으며, 노드와 마찬가지로 속성을 가질 수 있다.

(김철수)-[:BELONGS_TO {since: "2020-03-15"}]->(인사팀)
(김철수)-[:REPORTS_TO]->(박매니저)

관계에도 속성을 넣을 수 있다는 점이 중요하다. “김철수가 인사팀에 소속되어 있다”는 사실뿐 아니라, “2020년 3월 15일부터 소속되어 있다”는 맥락까지 관계에 담을 수 있다.

속성(Property) — 형용사

속성은 노드와 관계의 메타데이터다. 문자열, 숫자, 날짜, 불리언, 리스트 등의 타입을 지원한다. 2장에서 배운 “데이터 프로퍼티”가 여기에 해당한다.

그래프 데이터 모델링 원칙

2장에서 온톨로지의 구성 요소를 배울 때, 이미 모델링의 핵심 원리를 만났다. Neo4j에서도 같은 원리가 적용된다.

이 원칙을 기억해두면, 어떤 도메인의 데이터든 그래프로 모델링할 수 있다. “이것이 명사인가, 동사인가, 형용사인가?”라고 물어보면 답이 나온다.

Cypher 기초 — 그래프를 말하는 언어

Neo4j에서 데이터를 다루는 언어가 Cypher다. SQL이 관계형 데이터베이스의 언어라면, Cypher는 그래프 데이터베이스의 언어다. 2011년에 Neo4j 엔지니어가 만들었고, 지금은 ISO 표준화(GQL)가 진행 중이다.

Cypher의 가장 큰 특징은 ASCII art 스타일의 패턴 매칭이다.

(a)-[:KNOWS]->(b)

괄호가 노드, 대괄호가 관계, 화살표가 방향이다. 그래프의 모양을 텍스트로 그리는 셈이다. 직관적이라 처음 보는 사람도 대략 무슨 뜻인지 짐작할 수 있다.

CREATE — 데이터 생성

노드와 관계를 만드는 것부터 시작하자.

// 직원 노드 생성
CREATE (e:Employee {name: "김철수", hireDate: date("2020-03-15"), age: 35})
RETURN e

CREATE 뒤에 노드 패턴을 적으면 된다. e는 이 노드를 참조하기 위한 변수 이름이고, Employee는 라벨, 중괄호 안은 속성이다.

// 부서 노드 생성
CREATE (d:Department {name: "인사팀", code: "HR-001"})
RETURN d

두 노드 사이에 관계를 만들려면 이렇게 한다.

// 김철수를 인사팀에 소속시키기
MATCH (e:Employee {name: "김철수"})
MATCH (d:Department {name: "인사팀"})
CREATE (e)-[:BELONGS_TO {since: date("2020-03-15")}]->(d)

MATCH로 기존 노드를 찾고, CREATE로 그 사이에 관계를 만든다. 화살표 방향이 “김철수에서 인사팀으로”임에 주목하자.

MATCH — 데이터 조회

이미 들어있는 데이터를 찾아보자.

// 인사팀 소속 직원 찾기
MATCH (e:Employee)-[:BELONGS_TO]->(d:Department {name: "인사팀"})
RETURN e.name, d.name

MATCH는 그래프에서 패턴을 찾는 명령이다. “(Employee 노드)가 (BELONGS_TO 관계)로 (인사팀 Department 노드)에 연결되어 있는 패턴”을 찾고, RETURN으로 결과를 돌려준다.

WHERE — 조건 필터

조건을 추가하려면 WHERE를 쓴다.

// 2021년 이후 입사한 인사팀 직원
MATCH (e:Employee)-[:BELONGS_TO]->(d:Department {name: "인사팀"})
WHERE e.hireDate > date("2021-01-01")
RETURN e.name, e.hireDate
ORDER BY e.hireDate

SQL의 WHERE와 같은 역할이다. 비교 연산, 논리 연산(AND, OR, NOT), 문자열 매칭(CONTAINS, STARTS WITH) 등을 지원한다.

패턴 매칭의 진짜 힘

Cypher의 진짜 힘은 복잡한 관계 패턴을 직관적으로 표현할 수 있다는 데 있다. 예를 하나 살펴보자.

// 김철수의 매니저와, 그 매니저가 속한 부서 찾기
MATCH (e:Employee {name: "김철수"})-[:REPORTS_TO]->(m:Employee)-[:BELONGS_TO]->(d:Department)
RETURN e.name AS 직원, m.name AS 매니저, d.name AS 매니저부서

한 줄의 MATCH로 두 번의 관계 탐색(홉)을 표현했다. SQL이었다면 JOIN을 두 번 써야 할 것이다. 관계형 데이터베이스에서는 홉이 늘어날수록 쿼리가 급격히 복잡해지지만, Cypher에서는 패턴을 이어 붙이기만 하면 된다.

// 김철수의 매니저의 매니저가 관리하는 정책 찾기 (3홉)
MATCH (e:Employee {name: "김철수"})
      -[:REPORTS_TO]->(m1:Employee)
      -[:REPORTS_TO]->(m2:Employee)
      -[:MANAGES]->(p:Policy)
RETURN m2.name AS 상위매니저, p.name AS 정책

1장에서 이야기한 “멀티홉 추론”이 바로 이런 것이다. 노드에서 노드로, 관계를 따라 건너가며 답을 찾는 과정. Cypher는 이 과정을 직관적이고 읽기 쉬운 문법으로 표현한다.

MERGE — 있으면 찾고, 없으면 만들기

실무에서 자주 쓰이는 명령이 MERGE다. MATCHCREATE를 합친 것으로, 패턴이 이미 존재하면 그것을 반환하고, 없으면 새로 생성한다.

// 부서가 이미 있으면 찾고, 없으면 생성
MERGE (d:Department {name: "인사팀"})
ON CREATE SET d.code = "HR-001", d.createdAt = datetime()
ON MATCH SET d.updatedAt = datetime()
RETURN d

ON CREATE SET은 새로 생성될 때만, ON MATCH SET은 이미 존재할 때만 실행된다. 데이터 중복을 방지하면서 안전하게 데이터를 넣을 수 있어서, 데이터 로딩 스크립트에서 특히 유용하다.

DELETE — 데이터 삭제

잘못 넣은 데이터를 삭제할 수도 있다.

// 특정 직원 삭제 (관계도 함께 삭제)
MATCH (e:Employee {name: "테스트직원"})
DETACH DELETE e

DETACH DELETE는 노드에 연결된 모든 관계를 먼저 삭제한 뒤 노드를 삭제한다. 관계가 남아있는 노드는 삭제할 수 없기 때문에, 대부분의 경우 DETACH DELETE를 쓰는 편이 낫다.

실습: HR 샘플 데이터 입력

이론은 충분하다. 이제 실제로 HR 샘플 데이터를 Neo4j에 입력해보자. 직원 10명, 부서 3개, 정책 5개를 넣어 우리 HR안내봇의 데이터 기반을 만들 것이다.

부서 생성

// 부서 3개 생성
CREATE (hr:Department {name: "인사팀", code: "HR-001"})
CREATE (dev:Department {name: "개발팀", code: "DEV-001"})
CREATE (mkt:Department {name: "마케팅팀", code: "MKT-001"})
RETURN hr, dev, mkt

직원 생성

// 직원 10명 생성
CREATE (e1:Employee {name: "김철수", empId: "EMP-001", hireDate: date("2018-03-15"), type: "정규직"})
CREATE (e2:Employee {name: "이영희", empId: "EMP-002", hireDate: date("2019-07-01"), type: "정규직"})
CREATE (e3:Employee {name: "박매니저", empId: "EMP-003", hireDate: date("2015-01-10"), type: "정규직"})
CREATE (e4:Employee {name: "최개발", empId: "EMP-004", hireDate: date("2020-06-01"), type: "정규직"})
CREATE (e5:Employee {name: "정마케팅", empId: "EMP-005", hireDate: date("2021-02-15"), type: "정규직"})
CREATE (e6:Employee {name: "한인턴", empId: "EMP-006", hireDate: date("2025-01-02"), type: "인턴"})
CREATE (e7:Employee {name: "강시니어", empId: "EMP-007", hireDate: date("2014-05-20"), type: "정규직"})
CREATE (e8:Employee {name: "윤주니어", empId: "EMP-008", hireDate: date("2023-09-01"), type: "계약직"})
CREATE (e9:Employee {name: "송팀장", empId: "EMP-009", hireDate: date("2013-04-01"), type: "정규직"})
CREATE (e10:Employee {name: "임사원", empId: "EMP-010", hireDate: date("2024-11-15"), type: "정규직"})

정책 생성

// 정책 5개 생성
CREATE (p1:Policy {name: "육아휴직정책", type: "LeavePolicy", effectiveDate: date("2024-01-01")})
CREATE (p2:Policy {name: "연차산정기준", type: "LeavePolicy", effectiveDate: date("2024-01-01")})
CREATE (p3:Policy {name: "재택근무정책", type: "WorkPolicy", effectiveDate: date("2025-03-01")})
CREATE (p4:Policy {name: "성과평가기준", type: "EvaluationPolicy", effectiveDate: date("2024-07-01")})
CREATE (p5:Policy {name: "복리후생규정", type: "WelfarePolicy", effectiveDate: date("2024-01-01")})

관계 생성

// 부서 소속 관계
MATCH (e:Employee {name: "김철수"}), (d:Department {name: "인사팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "이영희"}), (d:Department {name: "인사팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "박매니저"}), (d:Department {name: "인사팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "최개발"}), (d:Department {name: "개발팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "강시니어"}), (d:Department {name: "개발팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "윤주니어"}), (d:Department {name: "개발팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "한인턴"}), (d:Department {name: "개발팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "정마케팅"}), (d:Department {name: "마케팅팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "송팀장"}), (d:Department {name: "마케팅팀"})
CREATE (e)-[:BELONGS_TO]->(d);

MATCH (e:Employee {name: "임사원"}), (d:Department {name: "마케팅팀"})
CREATE (e)-[:BELONGS_TO]->(d);
// 보고 관계
MATCH (e:Employee {name: "김철수"}), (m:Employee {name: "박매니저"})
CREATE (e)-[:REPORTS_TO]->(m);

MATCH (e:Employee {name: "이영희"}), (m:Employee {name: "박매니저"})
CREATE (e)-[:REPORTS_TO]->(m);

MATCH (e:Employee {name: "최개발"}), (m:Employee {name: "강시니어"})
CREATE (e)-[:REPORTS_TO]->(m);

MATCH (e:Employee {name: "한인턴"}), (m:Employee {name: "최개발"})
CREATE (e)-[:REPORTS_TO]->(m);

MATCH (e:Employee {name: "윤주니어"}), (m:Employee {name: "강시니어"})
CREATE (e)-[:REPORTS_TO]->(m);

MATCH (e:Employee {name: "정마케팅"}), (m:Employee {name: "송팀장"})
CREATE (e)-[:REPORTS_TO]->(m);

MATCH (e:Employee {name: "임사원"}), (m:Employee {name: "송팀장"})
CREATE (e)-[:REPORTS_TO]->(m);
// 정책 적용 관계
MATCH (p:Policy {name: "육아휴직정책"}), (d:Department {name: "인사팀"})
CREATE (p)-[:APPLIES_TO]->(d);
MATCH (p:Policy {name: "육아휴직정책"}), (d:Department {name: "개발팀"})
CREATE (p)-[:APPLIES_TO]->(d);
MATCH (p:Policy {name: "육아휴직정책"}), (d:Department {name: "마케팅팀"})
CREATE (p)-[:APPLIES_TO]->(d);

MATCH (p:Policy {name: "재택근무정책"}), (d:Department {name: "개발팀"})
CREATE (p)-[:APPLIES_TO]->(d);

MATCH (p:Policy {name: "성과평가기준"}), (d:Department {name: "인사팀"})
CREATE (p)-[:APPLIES_TO]->(d);
MATCH (p:Policy {name: "성과평가기준"}), (d:Department {name: "개발팀"})
CREATE (p)-[:APPLIES_TO]->(d);
MATCH (p:Policy {name: "성과평가기준"}), (d:Department {name: "마케팅팀"})
CREATE (p)-[:APPLIES_TO]->(d);

// 정책 간 영향 관계
MATCH (p1:Policy {name: "육아휴직정책"}), (p2:Policy {name: "연차산정기준"})
CREATE (p1)-[:AFFECTS {description: "휴직 기간 연차 미발생"}]->(p2);

데이터 확인

모든 데이터가 제대로 들어갔는지 확인해보자.

// 전체 그래프 시각화
MATCH (n)
OPTIONAL MATCH (n)-[r]->(m)
RETURN n, r, m

Neo4j Browser에서 이 쿼리를 실행하면, 노드와 관계가 시각적으로 그려진 그래프를 볼 수 있다. 직원 노드들이 부서 노드에 연결되어 있고, 정책 노드들이 부서에 적용되어 있는 그림이 나타날 것이다.

쿼리 실습

몇 가지 실용적인 쿼리를 직접 날려보자.

// 인사팀 소속 직원 목록
MATCH (e:Employee)-[:BELONGS_TO]->(d:Department {name: "인사팀"})
RETURN e.name AS 직원명, e.type AS 고용형태, e.hireDate AS 입사일
ORDER BY e.hireDate
// 김철수의 매니저 찾기
MATCH (e:Employee {name: "김철수"})-[:REPORTS_TO]->(m:Employee)
RETURN m.name AS 매니저
// 개발팀에 적용되는 정책 목록
MATCH (p:Policy)-[:APPLIES_TO]->(d:Department {name: "개발팀"})
RETURN p.name AS 정책명, p.type AS 정책유형, p.effectiveDate AS 시행일
// 육아휴직정책이 영향을 미치는 다른 정책 찾기 (멀티홉)
MATCH (p1:Policy {name: "육아휴직정책"})-[a:AFFECTS]->(p2:Policy)
RETURN p1.name AS 원인정책, a.description AS 영향내용, p2.name AS 영향받는정책
// 각 부서별 직원 수 집계
MATCH (e:Employee)-[:BELONGS_TO]->(d:Department)
RETURN d.name AS 부서명, count(e) AS 직원수
ORDER BY 직원수 DESC
// 매니저가 없는 직원 찾기 (최상위 관리자)
MATCH (e:Employee)
WHERE NOT (e)-[:REPORTS_TO]->()
RETURN e.name AS 최상위관리자

마지막 쿼리 결과에 박매니저, 강시니어, 송팀장이 나올 것이다. 이 세 사람은 REPORTS_TO 관계의 출발점이 되지 않는, 즉 “보고할 상위자가 없는” 직원이다. 이런 패턴을 WHERE NOT (e)-[:REPORTS_TO]->()로 간결하게 표현할 수 있다는 것이 Cypher의 매력이다.

인덱스와 제약 조건

데이터가 적을 때는 아무 문제 없지만, 데이터가 수천, 수만 건으로 늘어나면 성능이 중요해진다. 인덱스와 제약 조건을 미리 설정해두는 편이 현명하다.

// Employee의 empId에 유니크 제약 (중복 방지 + 자동 인덱스)
CREATE CONSTRAINT employee_empid IF NOT EXISTS
FOR (e:Employee) REQUIRE e.empId IS UNIQUE;

// Department의 name에 유니크 제약
CREATE CONSTRAINT department_name IF NOT EXISTS
FOR (d:Department) REQUIRE d.name IS UNIQUE;

// Policy의 name에 유니크 제약
CREATE CONSTRAINT policy_name IF NOT EXISTS
FOR (p:Policy) REQUIRE p.name IS UNIQUE;

// Employee의 name에 인덱스 (검색 성능 향상)
CREATE INDEX employee_name IF NOT EXISTS
FOR (e:Employee) ON (e.name);

유니크 제약(UNIQUE constraint)은 두 가지 역할을 한다. 첫째, 같은 값을 가진 노드가 중복 생성되는 것을 방지한다. 둘째, 자동으로 인덱스도 생성해서 조회 성능을 높인다. 일석이조다.

데이터 연속성 — 이 데이터의 미래

여기서 한 가지 중요한 이야기를 하고 넘어가야 한다.

지금 입력한 HR 샘플 데이터는 Cypher 학습용 스캐폴딩이다. 라벨 체계나 관계 타입이 아직 온톨로지에 기반하지 않았다. Employee라는 라벨, BELONGS_TO라는 관계 타입은 우리가 직감적으로 지은 이름이지, 2장에서 설계한 온톨로지의 정식 체계를 따른 것이 아니다.

그래서 이 데이터는 5장에서 마이그레이션을 겪게 된다. 4장에서 Protege로 정식 온톨로지를 설계한 뒤, 5장에서 이 샘플 데이터의 라벨, 관계, 속성을 온톨로지 기반 스키마로 재매핑하는 것이다. 마치 실무에서 “레거시 데이터를 새 스키마로 전환하는” 경험을 미리 체득하는 셈이다.

그러니 지금 입력한 데이터를 삭제하지 말자. 이 데이터는 5장에서 다시 만날 레거시 친구다.

기술 리더 의사결정 박스: 어떤 그래프 데이터베이스를 선택할 것인가?

Neo4j만이 유일한 선택지는 아니다. 주요 대안을 비교해보자.

기준 Neo4j Amazon Neptune TigerGraph NebulaGraph
데이터 모델 LPG (Labeled Property Graph) RDF + LPG 병행 LPG LPG
쿼리 언어 Cypher SPARQL + Gremlin + openCypher GSQL nGQL
생태계 가장 풍부 (GDS, n10s, GraphRAG 등) AWS 통합 우수 분석 특화 오픈소스
GDS(그래프 분석) 60+ 알고리즘 제한적 강력 제한적
온톨로지 지원 n10s 플러그인 네이티브 RDF 제한적 제한적
비용 Community(무료) / Enterprise AWS 종량제 Enterprise 오픈소스(무료)
적합 시나리오 GraphRAG, 지식그래프, 범용 AWS 환경, RDF 중심 대규모 분석 대규모 그래프

이 책에서 Neo4j를 선택한 이유: 1. GraphRAG 생태계가 가장 성숙하다 (공식 Python 패키지, LangChain 통합) 2. GDS 라이브러리로 그래프 알고리즘 실습이 가능하다 (6장) 3. Neosemantics(n10s)로 온톨로지를 직접 임포트할 수 있다 (5장) 4. 한국어 커뮤니티와 자료가 가장 풍부하다

판단 기준: (a) 이미 AWS에 깊이 묶여 있다면 Neptune, (b) 초대규모 분석이 핵심이면 TigerGraph, (c) 지식그래프 + AI 통합이 목표면 Neo4j가 현실적인 선택이다.

Neo4j를 프로그래밍 언어에서 사용하기

Neo4j Browser에서 직접 Cypher를 입력하는 것도 좋지만, 실제 애플리케이션에서는 프로그래밍 언어에서 Neo4j에 접속해야 한다. Python 예제를 간단히 살펴보자.

from neo4j import GraphDatabase

# Neo4j 연결
driver = GraphDatabase.driver(
    "bolt://localhost:7687",
    auth=("neo4j", "your-password")
)

# 인사팀 직원 조회
def get_hr_employees(driver):
    with driver.session() as session:
        result = session.run("""
            MATCH (e:Employee)-[:BELONGS_TO]->(d:Department {name: "인사팀"})
            RETURN e.name AS name, e.hireDate AS hireDate
            ORDER BY e.hireDate
        """)
        for record in result:
            print(f"{record['name']} (입사일: {record['hireDate']})")

get_hr_employees(driver)
driver.close()

neo4j Python 드라이버를 설치하고(pip install neo4j), 연결 URI와 인증 정보를 넘기면 된다. Cypher 쿼리를 문자열로 전달하고 결과를 순회하는 방식이다. 8장에서 HR안내봇의 백엔드를 구현할 때 이 패턴을 본격적으로 활용할 것이다.

마무리

이번 장에서 우리는 Neo4j를 설치하고, Labeled Property Graph 모델을 이해하고, Cypher의 기본 문법을 익혔다. 그리고 HR 샘플 데이터를 실제로 Neo4j에 입력하여, “인사팀 소속 직원 목록”부터 “정책 간 영향 관계”까지 다양한 쿼리를 실행해보았다.

기초가 갖춰졌다. 그런데 솔직히, 지금 만든 데이터 구조가 조금 찜찜하지 않은가? Employee라는 라벨은 누가 정한 건지, BELONGS_TO라는 관계 타입은 표준인지 우리가 지은 건지, 정규직과 계약직을 type 속성으로 구분한 것은 클래스로 나눠야 하는 건 아닌지. 지금은 데이터가 10건이라 아무 문제 없지만, 수천 건, 수만 건으로 늘어나면 이런 “대충 정한” 구조가 발목을 잡을 수 있다.

바로 이것이 온톨로지 설계가 필요한 이유다. 다음 장에서는 Protege라는 도구로 HR 온톨로지를 정식으로 설계하고, LLM을 활용해 자동 생성하는 방법도 함께 알아보자. 두 가지 길 중 어느 쪽이 우리 프로젝트에 맞는지, 직접 비교해볼 것이다.


HR안내봇 진행도

항목 상태
이번 장에서 한 것 Neo4j에 HR 샘플 데이터 입력 완료 (직원 10명, 부서 3개, 정책 5개)
산출물 Neo4j 데이터베이스에 노드 18개, 관계 20+개 적재. 기본 Cypher 쿼리 실행 확인
핵심 쿼리 “인사팀 소속 직원 목록”, “김철수의 매니저”, “정책 간 영향 관계” 조회 가능
데이터 연속성 이 데이터는 5장에서 온톨로지 스키마로 마이그레이션 예정 — 삭제하지 말 것
다음 장 예고 온톨로지 설계 실전 — Protege 수동 설계와 LLM 자동 생성, 두 가지 길 비교

4장. 온톨로지 설계 실전 — 수동 설계와 AI 자동 생성, 두 가지 길

HR 도메인의 핵심 개념을 종이 위에 스케치했고(2장), Neo4j에 샘플 데이터도 넣어보았다(3장). 그런데 3장 마지막에 던진 질문이 여전히 남아 있다. Employee라는 라벨은 누가 정한 건가? BELONGS_TO라는 관계 타입은 표준인가, 우리가 임의로 지은 건가? 정규직과 계약직을 속성 하나로 구분한 것이 정말 최선인가?

이 찜찜함의 정체는 명확하다. 설계도 없이 건물을 짓기 시작한 것이다. 3장의 데이터는 학습용 스캐폴딩이었으니 괜찮지만, 실제 프로젝트에서 이렇게 시작하면 나중에 구조 변경이 끔찍한 비용을 초래한다.

이번 장에서는 HR 온톨로지를 정식으로 설계한다. 그런데 설계하는 방법이 하나가 아니다. 전통적인 방법, 즉 온톨로지 편집기를 사용해 수동으로 설계하는 경로 A가 있고, LLM에게 도메인 문서를 입력하여 자동 생성하는 경로 B가 있다. 두 경로를 모두 따라간 뒤, 어느 쪽이 우리 프로젝트에 맞는지 비교해보자.

온톨로지 개발 8단계 프로세스

어떤 경로를 택하든, 온톨로지 설계의 기본 프로세스를 알아두는 편이 낫다. 온톨로지 엔지니어링에서 널리 쓰이는 8단계 프로세스를 살펴보자.

1단계: 도메인 범위 정의. 이 온톨로지가 답해야 하는 질문은 무엇인가? HR안내봇의 경우, 1장에서 정의한 20개 질문 시나리오가 범위를 결정한다. “육아휴직 중 연차는 어떻게 되나요?”, “인사팀 소속 직원 목록을 알려주세요” 같은 질문에 답하는 데 필요한 개념과 관계가 온톨로지의 범위다.

2단계: 기존 온톨로지 조사. 바퀴를 다시 발명할 필요는 없다. Schema.org, Dublin Core, FOAF 같은 표준 온톨로지에서 재사용할 수 있는 부분이 있는지 살펴본다. HR 도메인에서는 Schema.org의 Person, Organization 클래스를 참고할 수 있다.

3단계: 핵심 용어 열거. 도메인의 주요 명사와 동사를 수집한다. HR 도메인이라면: 직원, 부서, 직급, 정책, 복리후생, 소속, 보고, 적용, 관리, 수혜 등.

4단계: 클래스 계층 설계. 수집한 명사를 클래스로 만들고, 상위-하위 관계를 정의한다. 2장에서 스케치한 클래스 계층을 정교화하는 단계다.

5단계: 프로퍼티와 관계 정의. 각 클래스의 속성과 클래스 간 관계를 구체적으로 정의한다.

6단계: 제약 조건 추가. 카디널리티, 필수 관계, 값 범위 등의 공리를 추가한다.

7단계: 인스턴스 생성 및 검증. 실제 데이터를 온톨로지에 맞추어 넣어보며, 설계가 현실과 맞는지 검증한다.

8단계: 반복 개선. 실사용 피드백으로 지속적으로 보완한다.

이 8단계가 선형적으로 진행되는 것은 아니다. 4단계에서 클래스를 설계하다가 3단계로 돌아가 누락된 용어를 추가하는 일은 흔하다. 온톨로지 설계는 본질적으로 반복적(iterative)이다.

이제 이 프로세스를 경로 A(수동 설계)와 경로 B(AI 자동 생성)로 각각 실행해보자.

경로 A: Protege로 수동 설계

Protege란?

Protege는 Stanford 대학이 개발한 오픈소스 온톨로지 편집기다. 온톨로지 설계의 사실상 표준 도구로, OWL 2를 완전히 지원하고, 추론기(Reasoner) 통합, 시각적 편집, 플러그인 아키텍처를 갖추고 있다.

protege.stanford.edu에서 무료로 다운로드할 수 있다. 설치 후 실행하면 빈 온톨로지가 열리고, 여기에 클래스, 프로퍼티, 관계, 제약 조건을 하나씩 추가해나간다.

클래스 계층 만들기

Protege의 “Classes” 탭에서 클래스 계층을 생성한다. 2장에서 스케치한 구조를 정식으로 옮겨보자.

owl:Thing
├── Agent
│   └── Person
│       └── Employee
│           ├── FullTimeEmployee
│           └── ContractEmployee
├── OrganizationalUnit
│   └── Department
├── Position
│   ├── ManagerPosition
│   └── StaffPosition
├── Policy
│   ├── LeavePolicy
│   ├── SalaryPolicy
│   ├── WorkPolicy
│   └── WelfarePolicy
└── Benefit
    ├── HealthBenefit
    ├── EducationBenefit
    └── WelfareBenefit

몇 가지 변화가 눈에 띈다. 2장 스케치에서는 ManagerPosition의 하위로 두었는데, 여기서는 ManagerPosition으로 이름을 바꾸었다. “매니저”가 사람인지 직급인지 모호하지 않도록 하기 위해서다. Agent라는 상위 클래스도 추가했는데, 나중에 외부 시스템이나 AI 에이전트를 추가할 때 확장 포인트가 된다.

Protege에서 클래스를 추가하는 것은 직관적이다. 상위 클래스를 선택하고 “Add subclass” 버튼을 누르면 된다.

오브젝트 프로퍼티(관계) 정의

“Object Properties” 탭에서 클래스 간 관계를 정의한다.

belongsTo
  - Domain: Employee
  - Range: Department
  - Inverse: hasMember
  - Characteristics: Functional (직원은 하나의 부서에만 소속)

reportsTo
  - Domain: Employee
  - Range: Employee
  - Characteristics: Asymmetric, Irreflexive

hasPosition
  - Domain: Employee
  - Range: Position
  - Characteristics: Functional

appliesTo
  - Domain: Policy
  - Range: Employee ∪ Department

affectsPolicy
  - Domain: Policy
  - Range: Policy

receivesBenefit
  - Domain: Employee
  - Range: Benefit

managedBy
  - Domain: Department
  - Range: Employee

각 프로퍼티에 Domain(출발 클래스)Range(도착 클래스)를 지정한다. belongsTo의 Domain은 Employee이고 Range는 Department이니, “직원이 부서에 소속된다”를 의미한다.

Functional 특성을 지정하면, 하나의 직원은 최대 하나의 부서에만 소속될 수 있다는 제약이 걸린다. Asymmetric은 “A가 B에게 보고하면, B는 A에게 보고할 수 없다”를 보장한다. Irreflexive는 “자기 자신에게 보고할 수 없다”를 보장한다. 이런 특성들이 2장에서 배운 “공리”를 구현하는 방법이다.

데이터 프로퍼티 정의

“Data Properties” 탭에서 속성을 정의한다.

employeeId
  - Domain: Employee
  - Range: xsd:string
  - Characteristics: Functional

name
  - Domain: Agent
  - Range: xsd:string

hireDate
  - Domain: Employee
  - Range: xsd:date

effectiveDate
  - Domain: Policy
  - Range: xsd:date

departmentCode
  - Domain: Department
  - Range: xsd:string
  - Characteristics: Functional

데이터 프로퍼티의 Range는 문자열(xsd:string), 날짜(xsd:date), 숫자(xsd:integer) 같은 데이터 타입이다.

제약 조건(공리) 추가

클래스의 “Description” 패널에서 추가적인 제약을 설정할 수 있다.

Employee 클래스:

Employee SubClassOf belongsTo exactly 1 Department
Employee SubClassOf hasPosition exactly 1 Position
Employee SubClassOf name exactly 1 xsd:string
Employee SubClassOf employeeId exactly 1 xsd:string

이것은 “모든 직원은 정확히 하나의 부서에 소속되어야 하고, 정확히 하나의 직급을 가져야 하며, 이름과 사번은 각각 하나씩 있어야 한다”는 의미다.

FullTimeEmployee 클래스:

FullTimeEmployee EquivalentTo Employee and (employmentType value "정규직")

이 등가 공리(equivalent class)는 “정규직 직원은 employmentType이 ’정규직’인 직원과 동일하다”를 정의한다. 추론기가 이 규칙을 사용해 자동 분류를 수행할 수 있다.

추론기로 일관성 검사

Protege에 내장된 추론기(HermiT 또는 Pellet)를 실행하면, 온톨로지의 논리적 일관성을 자동으로 검사한다.

추론기를 돌려보면 가끔 예상치 못한 문제가 발견된다. 예를 들어, FullTimeEmployeeContractEmployee가 서로 배타적이라고 정의하지 않으면, 추론기가 “한 직원이 동시에 정규직이면서 계약직일 수 있다”는 결론을 내릴 수 있다. 이런 문제를 설계 단계에서 잡아내는 것이 추론기의 가치다.

FullTimeEmployee DisjointWith ContractEmployee

이 한 줄을 추가하면, 한 인스턴스가 두 클래스에 동시에 속하는 것이 불가능해진다.

OWL 파일로 내보내기

설계가 완성되면 File > Save As에서 OWL/Turtle 형식으로 내보낸다.

@prefix hr: <http://example.org/hr-ontology#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

hr:Employee a owl:Class ;
    rdfs:subClassOf hr:Person ;
    rdfs:subClassOf [
        a owl:Restriction ;
        owl:onProperty hr:belongsTo ;
        owl:qualifiedCardinality "1"^^xsd:nonNegativeInteger ;
        owl:onClass hr:Department
    ] .

hr:FullTimeEmployee a owl:Class ;
    rdfs:subClassOf hr:Employee ;
    owl:disjointWith hr:ContractEmployee .

hr:belongsTo a owl:ObjectProperty, owl:FunctionalProperty ;
    rdfs:domain hr:Employee ;
    rdfs:range hr:Department ;
    owl:inverseOf hr:hasMember .

hr:reportsTo a owl:ObjectProperty, owl:AsymmetricProperty, owl:IrreflexiveProperty ;
    rdfs:domain hr:Employee ;
    rdfs:range hr:Employee .

이 Turtle 파일이 5장에서 Neo4j에 임포트할 온톨로지의 정식 산출물이다.

경로 B: LLM으로 자동 생성

경로 A는 정교하지만, 솔직히 번거롭다. Protege를 설치하고, OWL 문법을 이해하고, 클래스와 관계를 하나씩 수동으로 추가해야 한다. 소규모 온톨로지라면 몇 시간이면 되지만, 클래스가 수십 개, 관계가 수백 개인 복잡한 도메인이라면 몇 주가 걸릴 수도 있다.

그렇다면 이 과정을 AI에게 맡기면 어떨까? 2025~2026년의 LLM은 도메인 문서를 입력받아 온톨로지 초안을 자동으로 생성할 수 있는 수준에 도달했다.

LLM에게 온톨로지 초안 요청하기

가장 직접적인 방법은 LLM에게 도메인 문서와 요구사항을 주고 온톨로지를 생성해달라고 요청하는 것이다.

프롬프트 예시:

다음 HR 도메인 정보를 바탕으로 OWL 온톨로지를 Turtle 형식으로 생성해주세요.

[도메인 설명]
- 회사에는 직원(Employee), 부서(Department), 직급(Position)이 있습니다
- 직원은 정규직(FullTime)과 계약직(Contract)으로 나뉩니다
- 직원은 하나의 부서에 소속되며, 하나의 직급을 가집니다
- 직원은 다른 직원에게 보고합니다 (매니저-부하 관계)
- 회사에는 정책(Policy)이 있으며, 정책은 직원이나 부서에 적용됩니다
- 정책 간에는 영향 관계가 있습니다 (예: 육아휴직이 연차 산정에 영향)
- 복리후생(Benefit)이 있으며, 직원이 수혜합니다

[요구사항]
- OWL DL 수준의 표현력
- 카디널리티 제약, 불리언 특성(Asymmetric, Functional 등) 포함
- 데이터 프로퍼티(이름, 날짜, ID 등) 포함
- Turtle 형식으로 출력

LLM은 이 프롬프트를 받고 몇 초 만에 Turtle 형식의 온톨로지를 생성한다. 경로 A에서 몇 시간 걸리는 작업이 몇 분으로 줄어드는 셈이다.

Neo4j LLM Knowledge Graph Builder 활용

더 체계적인 방법도 있다. Neo4j가 제공하는 오픈소스 도구인 LLM Knowledge Graph Builder는 비정형 텍스트에서 엔티티와 관계를 자동으로 추출하여 지식그래프를 구축한다.

이 도구의 워크플로우는 이렇다.

  1. HR 정책 문서(PDF, 텍스트)를 입력한다
  2. LLM이 문서에서 엔티티(직원, 부서, 정책 등)를 추출한다
  3. 엔티티 간 관계(소속, 적용, 영향 등)를 식별한다
  4. 추출된 엔티티와 관계를 Neo4j 지식그래프로 구축한다
  5. 커뮤니티 요약 기능으로 관련 엔티티를 자동 그룹핑한다

이 과정에서 온톨로지가 명시적으로 생성되지는 않지만, 추출된 엔티티 유형과 관계 유형이 사실상 암묵적 온톨로지(implicit ontology) 역할을 한다. 이 암묵적 구조를 Turtle 파일로 정리하면 정식 온톨로지가 된다.

LLM 생성 결과의 한계

LLM이 생성한 온톨로지가 완벽할까? 솔직히 그렇지 않다. 몇 가지 전형적인 문제가 있다.

첫째, 일관성 부족. 같은 프롬프트를 두 번 던져도 다른 결과가 나올 수 있다. 어떤 때는 belongsTo를, 어떤 때는 memberOf를 관계 이름으로 쓴다.

둘째, 도메인 깊이 부족. LLM은 HR 도메인의 일반적인 구조는 잘 잡아내지만, “육아휴직 기간 중 연차 미발생”처럼 특정 조직의 구체적인 규칙은 놓칠 수 있다. 프롬프트에 포함되지 않은 도메인 지식은 반영되지 않는다.

셋째, 공리 설계의 취약성. LLM이 클래스와 관계는 비교적 잘 생성하지만, 정교한 공리(카디널리티 제약, 배타 클래스 등)는 누락하거나 잘못 정의하는 경우가 있다.

이 때문에 “AI가 초안을 잡고, 전문가가 정제하는” 협업 모델이 현재로서는 최적이다. LLM이 80%를 빠르게 만들어주면, 도메인 전문가가 나머지 20%를 검증하고 보완한다.

검증 체크리스트

LLM이 생성한 온톨로지를 검증할 때 확인해야 할 항목이다.

이 체크리스트를 통과하면, LLM이 생성한 온톨로지도 Protege에서 수동 설계한 것과 동등한 품질에 도달할 수 있다.

두 경로 비교

경로 A와 경로 B를 나란히 놓고 비교해보자.

기준 경로 A: Protege 수동 설계 경로 B: LLM 자동 생성 + 정제
소요 시간 수 시간 ~ 수일 수 분 ~ 수 시간 (정제 포함)
정교함 높음 — 공리까지 세밀하게 제어 중간 — 공리 품질은 검증 필요
재현성 높음 — 같은 과정을 반복하면 같은 결과 낮음 — LLM 출력이 매번 다를 수 있음
도메인 적합성 높음 — 도메인 전문가가 직접 설계 중간 — 프롬프트에 포함된 정보에 의존
학습 곡선 가파름 — Protege + OWL 학습 필요 완만 — 프롬프트 작성 능력이면 충분
확장성 낮음 — 수동 작업은 규모에 비례하여 증가 높음 — 대규모 문서도 빠르게 처리
적합 상황 핵심 도메인, 높은 품질 요구, 규제 환경 빠른 PoC, 대규모 도메인, 초기 탐색

어떤 것이 “더 좋다”가 아니라, 상황에 따라 최적이 다르다는 것이 핵심이다.

규제가 엄격한 금융이나 의료 도메인에서, 온톨로지의 모든 공리가 법적 요건을 반영해야 한다면, 경로 A가 안전하다. 반면 빠르게 PoC를 만들어 이해관계자에게 보여주고, 피드백을 받아 반복 개선해야 한다면, 경로 B로 시작하는 것이 효율적이다.

그리고 실무에서 가장 많이 쓰이는 방식은 사실 하이브리드다. 경로 B로 초안을 빠르게 잡고, 경로 A(Protege)에서 정제하는 것이다. Juan Sequeda의 교훈을 다시 기억해두자. “충분히 좋은 온톨로지가 완벽한 온톨로지보다 낫다.” 완벽을 추구하다 프로젝트가 멈추는 것보다, “충분히 좋은” 온톨로지로 시작하여 운영하면서 개선하는 것이 현실적이다.

기술 리더 의사결정 박스: 온톨로지 설계를 팀에 어떻게 도입할 것인가?

접근법 비용 일정 품질 적합 상황
(a) 수동 설계 전담 인력 높음 (온톨로지 엔지니어 채용/교육) 느림 (2~4주+) 최고 규제 환경, 핵심 비즈니스 온톨로지
(b) LLM 자동 생성 + 전문가 검수 낮음 (기존 인력 활용) 빠름 (2~5일) 높음 (검수 품질에 의존) PoC, 빠른 검증, 대규모 도메인
(c) 하이브리드 중간 보통 (1~2주) 높음 대부분의 프로젝트에 권장

권장: 대부분의 기술 리더에게는 (c) 하이브리드가 가장 현실적이다. LLM으로 초안을 잡아 시간을 벌고, 도메인 전문가가 핵심 공리와 제약 조건을 검증/보완한다. 팀에 온톨로지 전문가가 없어도 시작할 수 있고, 나중에 전문성이 쌓이면 (a)로 전환할 수 있다.

기존 표준 온톨로지 재활용

처음부터 모든 것을 만들 필요는 없다. 이미 잘 설계된 표준 온톨로지를 재활용하면 시간을 절약하고 상호운용성도 높일 수 있다.

Schema.org: Google, Microsoft, Yahoo가 공동으로 관리하는 어휘. Person, Organization, Place 같은 범용 클래스를 제공한다. HR 온톨로지의 Employee를 Schema.org의 Person의 하위 클래스로 정의하면, 외부 시스템과의 데이터 교환이 수월해진다.

Dublin Core: 메타데이터 표준. title, creator, date 같은 기본 메타데이터 속성을 제공한다. 정책 문서의 메타데이터를 표현할 때 활용할 수 있다.

FOAF (Friend of a Friend): 사람과 사회적 관계를 기술하는 온톨로지. Person, knows, member 같은 클래스와 관계를 제공한다. 조직 내 인적 네트워크를 표현할 때 참고할 만하다.

재활용할 때 주의할 점이 하나 있다. 표준 온톨로지를 그대로 쓰는 것이 아니라, 참조하고 확장하는 것이다. Schema.org의 Person을 가져오되, HR 도메인에 특화된 속성과 관계는 우리가 추가한다. 이렇게 하면 표준의 이점(상호운용성)과 도메인 특화의 이점(정확성)을 모두 취할 수 있다.

HR 온톨로지 완성

경로 A와 경로 B를 모두 거쳐, 우리의 HR 온톨로지 최종 사양을 정리하자.

클래스 (12개)

owl:Thing
├── Agent
│   └── Person
│       └── Employee
│           ├── FullTimeEmployee (disjoint with ContractEmployee)
│           └── ContractEmployee (disjoint with FullTimeEmployee)
├── OrganizationalUnit
│   └── Department
├── Position
│   ├── ManagerPosition
│   └── StaffPosition
├── Policy
│   ├── LeavePolicy
│   ├── SalaryPolicy
│   ├── WorkPolicy
│   └── WelfarePolicy
└── Benefit
    ├── HealthBenefit
    └── EducationBenefit

오브젝트 프로퍼티(관계, 15개)

관계 Domain Range 특성
belongsTo Employee Department Functional, InverseFunctional
hasMember Department Employee inverse of belongsTo
reportsTo Employee Employee Asymmetric, Irreflexive
hasDirectReport Employee Employee inverse of reportsTo
hasPosition Employee Position Functional
appliesTo Policy Employee -
appliesToDepartment Policy Department -
affectsPolicy Policy Policy -
affectedByPolicy Policy Policy inverse of affectsPolicy
receivesBenefit Employee Benefit -
offersBenefit Department Benefit -
managedBy Department Employee Functional
manages Employee Department inverse of managedBy
hasSubPolicy Policy Policy Transitive
relatedPolicy Policy Policy Symmetric

데이터 프로퍼티 (주요)

속성 Domain Range
employeeId Employee xsd:string
name Agent xsd:string
hireDate Employee xsd:date
email Employee xsd:string
employmentType Employee xsd:string
departmentCode Department xsd:string
policyName Policy xsd:string
effectiveDate Policy xsd:date
description Policy xsd:string
benefitName Benefit xsd:string

핵심 공리 (8개)

  1. Employee SubClassOf belongsTo exactly 1 Department
  2. Employee SubClassOf hasPosition exactly 1 Position
  3. Employee SubClassOf employeeId exactly 1 xsd:string
  4. FullTimeEmployee DisjointWith ContractEmployee
  5. ManagerPosition DisjointWith StaffPosition
  6. Department SubClassOf managedBy max 1 Employee
  7. reportsTo: Asymmetric, Irreflexive
  8. belongsTo: Functional

이것이 HR안내봇의 온톨로지 설계도다. 클래스 12개, 관계 15개, 공리 8개. 과하게 복잡하지도, 지나치게 단순하지도 않은, “충분히 좋은” 온톨로지다.

Turtle 파일 — 최종 산출물

이 온톨로지의 핵심 부분을 Turtle 형식으로 정리하면 아래와 같다.

@prefix hr: <http://example.org/hr-ontology#> .
@prefix owl: <http://www.w3.org/2002/07/owl#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .

# --- Ontology Declaration ---
<http://example.org/hr-ontology> a owl:Ontology ;
    rdfs:label "HR Domain Ontology"@ko ;
    rdfs:comment "HR안내봇을 위한 인사 도메인 온톨로지"@ko .

# --- Classes ---
hr:Agent a owl:Class .
hr:Person a owl:Class ; rdfs:subClassOf hr:Agent .
hr:Employee a owl:Class ;
    rdfs:subClassOf hr:Person ;
    rdfs:subClassOf [
        a owl:Restriction ;
        owl:onProperty hr:belongsTo ;
        owl:qualifiedCardinality "1"^^xsd:nonNegativeInteger ;
        owl:onClass hr:Department
    ] .

hr:FullTimeEmployee a owl:Class ;
    rdfs:subClassOf hr:Employee ;
    owl:disjointWith hr:ContractEmployee .

hr:ContractEmployee a owl:Class ;
    rdfs:subClassOf hr:Employee .

hr:Department a owl:Class ;
    rdfs:subClassOf hr:OrganizationalUnit .

hr:Policy a owl:Class .
hr:LeavePolicy a owl:Class ; rdfs:subClassOf hr:Policy .
hr:WorkPolicy a owl:Class ; rdfs:subClassOf hr:Policy .
hr:WelfarePolicy a owl:Class ; rdfs:subClassOf hr:Policy .

hr:Benefit a owl:Class .
hr:HealthBenefit a owl:Class ; rdfs:subClassOf hr:Benefit .
hr:EducationBenefit a owl:Class ; rdfs:subClassOf hr:Benefit .

# --- Object Properties ---
hr:belongsTo a owl:ObjectProperty, owl:FunctionalProperty ;
    rdfs:domain hr:Employee ;
    rdfs:range hr:Department ;
    owl:inverseOf hr:hasMember .

hr:reportsTo a owl:ObjectProperty, owl:AsymmetricProperty, owl:IrreflexiveProperty ;
    rdfs:domain hr:Employee ;
    rdfs:range hr:Employee .

hr:affectsPolicy a owl:ObjectProperty ;
    rdfs:domain hr:Policy ;
    rdfs:range hr:Policy .

hr:appliesTo a owl:ObjectProperty ;
    rdfs:domain hr:Policy ;
    rdfs:range hr:Employee .

# --- Data Properties ---
hr:employeeId a owl:DatatypeProperty, owl:FunctionalProperty ;
    rdfs:domain hr:Employee ;
    rdfs:range xsd:string .

hr:name a owl:DatatypeProperty ;
    rdfs:domain hr:Agent ;
    rdfs:range xsd:string .

hr:hireDate a owl:DatatypeProperty ;
    rdfs:domain hr:Employee ;
    rdfs:range xsd:date .

hr:effectiveDate a owl:DatatypeProperty ;
    rdfs:domain hr:Policy ;
    rdfs:range xsd:date .

이 Turtle 파일을 hr-ontology.ttl로 저장한다. 5장에서 Neosemantics(n10s) 플러그인을 사용해 이 파일을 Neo4j에 임포트하고, 3장의 샘플 데이터를 이 스키마에 맞추어 마이그레이션할 것이다.

마무리

이번 장에서 우리는 온톨로지를 설계하는 두 가지 길을 모두 걸어보았다. Protege로 수동 설계하는 경로 A에서는 클래스 계층, 오브젝트 프로퍼티, 데이터 프로퍼티, 제약 조건을 직접 정의하며 온톨로지 설계의 전 과정을 체험했다. LLM으로 자동 생성하는 경로 B에서는 빠르게 초안을 잡고 전문가가 정제하는 협업 모델을 살펴보았다.

두 경로 모두 장단점이 있고, 실무에서는 대부분 하이브리드로 접근한다. “충분히 좋은 온톨로지가 완벽한 온톨로지보다 낫다”는 교훈을 기억해두자.

이제 설계도(온톨로지)가 완성되었다. 다음 할 일은 이 설계도를 Neo4j에 심고, 3장에서 입력한 샘플 데이터를 이 설계도에 맞추어 리모델링하는 것이다. 건물의 골조를 바꾸는 작업이니 긴장감이 있겠지만, 차근차근 함께 해보자.


HR안내봇 진행도

항목 상태
이번 장에서 한 것 HR 온톨로지 완성 (클래스 12개, 관계 15개, 공리 8개)
산출물 Protege 수동 설계 결과 + LLM 자동 생성 결과 비교. OWL/Turtle 파일(hr-ontology.ttl) 내보내기 완료
경로 비교 수동 설계는 정교하지만 느림, LLM 자동 생성은 빠르지만 검증 필요. 하이브리드 추천
다음 장 예고 온톨로지를 Neo4j에 임포트하고, 3장 샘플 데이터를 온톨로지 스키마로 마이그레이션

5장. 온톨로지를 Neo4j에 심기 — 레거시 데이터 마이그레이션과 지식그래프 구축

4장에서 공들여 설계한 OWL 파일이 손에 들려 있다. Protege에서 직접 다듬었든, LLM에게 초안을 맡겼든, 어쨌든 HR 도메인의 클래스 12개와 관계 15개가 깔끔하게 정의된 온톨로지가 완성되었다. 그런데 이 파일을 두고 가만히 생각해보자. 설계도만으로 건물에 사람이 살 수 있을까? 당연히 아니다. 설계도대로 건물을 짓고, 기존 세입자들을 새 건물로 이사시켜야 한다.

3장에서 Neo4j에 넣어두었던 HR 샘플 데이터를 기억하는가? 직원 10명, 부서 3개, 정책 5개 — Cypher를 익히기 위해 급하게 넣은 데이터였다. 라벨도 대충 붙였고, 관계 이름도 제각각이었다. 솔직히 말하면, 그때는 그게 최선이었다. 하지만 이제 온톨로지라는 설계도가 생겼으니, 이 데이터를 온톨로지 기반 스키마로 전환해야 한다. 실무에서 레거시 시스템을 새 아키텍처로 이전하는 작업과 본질적으로 같은 일이다.

이번 장에서 할 일은 크게 세 가지다. 첫째, Neosemantics(n10s) 플러그인으로 OWL 온톨로지를 Neo4j에 임포트한다. 둘째, 3장의 샘플 데이터를 온톨로지 스키마에 맞춰 마이그레이션한다. 셋째, 추가 데이터를 적재해 100건 규모의 지식그래프를 완성한다. 하나씩 살펴보자.

Neosemantics(n10s) — Neo4j에 시맨틱 레이어 얹기

Neo4j는 Labeled Property Graph(LPG) 모델을 쓴다. 온톨로지는 RDF/OWL 세계에 살고 있다. 이 둘은 태생이 다르다. LPG는 속성이 풍부한 노드와 관계를 다루고, RDF는 주어-술어-목적어 트리플로 모든 것을 표현한다. 그렇다면 이 두 세계를 어떻게 연결할까?

Neosemantics, 줄여서 n10s라 부르는 플러그인이 바로 그 다리 역할을 한다. Neo4j Labs에서 공식으로 관리하는 이 플러그인은 OWL, RDFS, SKOS 온톨로지를 Neo4j 그래프로 임포트하고, 반대로 Neo4j 데이터를 RDF로 내보내는 일까지 해준다.

n10s 설치

Neo4j Desktop을 쓴다면 설치는 간단하다. 프로젝트의 Plugins 탭에서 n10s를 찾아 Install 버튼을 누르면 된다. 수동 설치가 필요한 환경이라면 JAR 파일을 직접 내려받아 plugins 디렉터리에 넣는다.

// n10s가 제대로 설치되었는지 확인
RETURN n10s.version() AS version

설치 확인이 되었다면, 가장 먼저 해야 할 일은 그래프 설정 초기화다. n10s는 RDF 데이터를 어떤 방식으로 LPG에 매핑할지 결정하는 설정이 필요하다.

// n10s 설정 초기화
CALL n10s.graphconfig.init({
  handleVocabUris: 'MAP',
  handleMultival: 'ARRAY',
  handleRDFTypes: 'LABELS'
})

각 옵션이 하는 일을 살펴보자.

여기서 한 가지 주의할 점이 있다. graphconfig.init은 데이터가 비어 있는 상태에서만 실행할 수 있다. 이미 데이터가 들어 있다면 설정을 변경할 수 없다. 나중에 설정을 바꾸려면 데이터를 먼저 정리해야 한다는 뜻이니, 처음 설정할 때 신중하게 결정하는 편이 낫다.

그 다음으로, URI 접두사 매핑을 설정한다.

// 네임스페이스 접두사 매핑
CALL n10s.nsprefixes.add('hr', 'http://example.org/hr#');
CALL n10s.nsprefixes.add('org', 'http://www.w3.org/ns/org#');
CALL n10s.nsprefixes.add('foaf', 'http://xmlns.com/foaf/0.1/');

이렇게 하면 http://example.org/hr#Employee를 Cypher에서 hr__Employee 대신 깔끔한 Employee로 쓸 수 있다.

OWL 온톨로지 임포트

설정이 끝났으니 이제 진짜 임포트를 해보자. 4장에서 내보낸 OWL/Turtle 파일을 Neo4j에 불러온다.

// 온톨로지 파일 임포트 (로컬 파일 경로)
CALL n10s.onto.import.fetch(
  'file:///path/to/hr-ontology.ttl',
  'Turtle'
)

URL로도 가져올 수 있다. GitHub에 온톨로지 파일을 올려두었다면 이렇게 한다.

// 원격 URL에서 온톨로지 임포트
CALL n10s.onto.import.fetch(
  'https://raw.githubusercontent.com/your-repo/hr-ontology.ttl',
  'Turtle'
)

임포트가 성공하면 Neo4j에 어떤 일이 벌어질까? 온톨로지의 각 클래스가 Class 라벨을 가진 노드로 생성되고, 클래스 간 계층 관계(rdfs:subClassOf)가 SCO 관계로 매핑된다. 오브젝트 프로퍼티와 데이터 프로퍼티는 각각 ObjectProperty, DatatypeProperty 라벨의 노드가 된다.

임포트 결과를 확인해보자.

// 임포트된 클래스 목록 확인
MATCH (c:Class)
RETURN c.name AS className, c.uri AS uri
ORDER BY c.name
// 클래스 계층 구조 확인
MATCH (sub:Class)-[:SCO]->(super:Class)
RETURN sub.name AS subClass, super.name AS superClass
// 오브젝트 프로퍼티(관계) 확인
MATCH (op:ObjectProperty)
OPTIONAL MATCH (op)-[:DOMAIN]->(d:Class)
OPTIONAL MATCH (op)-[:RANGE]->(r:Class)
RETURN op.name AS property, d.name AS domain, r.name AS range

결과를 보면 4장에서 설계한 HR 온톨로지의 뼈대가 고스란히 Neo4j 안에 들어와 있다. Employee, Department, Position, Policy, Benefit 같은 클래스들과, belongsTo, manages, appliesTo 같은 관계들이 모두 보인다. 설계도가 Neo4j라는 건물의 기초 구조로 변환된 셈이다.

물론 지금 상태에서는 스키마 뼈대만 있다. 실제 직원 데이터, 부서 데이터 같은 인스턴스는 아직 없다. 그렇다면 이제 3장에서 넣어두었던 데이터를 이 스키마 위로 옮기는 작업을 시작해보자.

3장 데이터 마이그레이션 — 레거시를 온톨로지로

3장에서 우리가 만들었던 데이터를 떠올려보자. 대략 이런 모양이었다.

// 3장에서 만든 데이터 (비정형 LPG)
(:Person {name: '김철수', employee_id: 'E001', hire_date: '2020-03-15'})
  -[:WORKS_IN]->(:Dept {name: '인사팀', code: 'HR'})

(:Person {name: '이영희', employee_id: 'E002'})
  -[:REPORTS_TO]->(:Person {name: '김철수'})

(:Rule {title: '육아휴직 규정', category: '복리후생'})

문제가 보이는가? Person이라는 라벨은 온톨로지에서 Employee다. DeptDepartment여야 한다. RulePolicy다. 관계 이름도 다르다. WORKS_INBELONGS_TO로, REPORTS_TOREPORTS_TO 그대로 쓸 수도 있지만 온톨로지에 정의된 이름과 맞추는 편이 낫다. 이런 불일치가 실무에서 레거시 마이그레이션이 번거로운 이유다.

마이그레이션 전략은 크게 세 단계로 나눈다. 라벨 재매핑, 관계 재매핑, 속성 정규화다.

1단계: 라벨 재매핑

기존 라벨을 온톨로지의 클래스 이름으로 바꾼다. Cypher의 SETREMOVE를 조합하면 된다.

// Person → Employee 라벨 변환
MATCH (p:Person)
SET p:Employee
REMOVE p:Person
RETURN count(p) AS migratedEmployees
// Dept → Department 라벨 변환
MATCH (d:Dept)
SET d:Department
REMOVE d:Dept
RETURN count(d) AS migratedDepartments
// Rule → Policy 라벨 변환
MATCH (r:Rule)
SET r:Policy
REMOVE r:Rule
RETURN count(r) AS migratedPolicies

간단해 보이지만, 실무에서는 여기서 난감한 상황이 생긴다. 만약 하나의 노드에 여러 라벨이 붙어 있다면? 예를 들어 (:Person:Manager) 같은 경우, PersonEmployee로 바꾸되 Manager는 유지해야 할 수도 있다. 온톨로지에서 Manager가 Employee의 하위 클래스라면, 라벨을 둘 다 붙이는 게 맞을 수도 있고, Manager만 남기는 게 맞을 수도 있다. 이런 판단은 온톨로지 설계를 다시 들여다봐야 한다.

// Manager가 Employee의 하위 클래스인 경우
MATCH (p:Person:Manager)
SET p:Employee:Manager
REMOVE p:Person
RETURN count(p) AS migratedManagers

2단계: 관계 재매핑

관계 이름을 바꾸는 건 라벨보다 조금 더 번거롭다. Cypher에서 기존 관계의 타입을 직접 변경하는 문법이 없기 때문이다. 새 관계를 만들고, 원래 관계를 지우는 방식으로 처리한다.

// WORKS_IN → BELONGS_TO 관계 변환
MATCH (e:Employee)-[old:WORKS_IN]->(d:Department)
CREATE (e)-[new:BELONGS_TO]->(d)
DELETE old
RETURN count(new) AS migratedRelations
// REPORTS_TO 관계를 온톨로지 정의에 맞게 재설정
// 매니저를 별도 노드가 아닌 Employee의 역할로 처리하는 경우
MATCH (e:Employee)-[old:REPORTS_TO]->(m:Employee)
CREATE (e)-[new:REPORTS_TO {since: old.since}]->(m)
DELETE old
RETURN count(new) AS migratedReports

관계에 속성이 있었다면 새 관계에 복사하는 것을 잊지 말자. 위 예시에서 since 속성을 옮긴 것처럼, 기존 관계의 모든 속성을 새 관계로 이전해야 데이터 손실을 막을 수 있다. 이 과정을 빼먹으면 나중에 “어? 이 데이터 어디 갔지?” 하며 아찔해지는 순간이 온다.

3단계: 속성 정규화

온톨로지에서 정의한 데이터 프로퍼티와 기존 데이터의 속성 이름이 다를 수 있다. 또한 데이터 타입도 맞춰야 한다.

// employee_id → employeeId (카멜케이스로 통일)
MATCH (e:Employee)
WHERE e.employee_id IS NOT NULL
SET e.employeeId = e.employee_id
REMOVE e.employee_id
RETURN count(e) AS normalized
// hire_date 문자열을 Neo4j date 타입으로 변환
MATCH (e:Employee)
WHERE e.hire_date IS NOT NULL
SET e.hireDate = date(e.hire_date)
REMOVE e.hire_date
RETURN count(e) AS dateConverted
// Policy의 category 속성을 별도 노드(BenefitCategory)로 분리
MATCH (p:Policy)
WHERE p.category IS NOT NULL
MERGE (c:BenefitCategory {name: p.category})
CREATE (p)-[:CATEGORIZED_AS]->(c)
REMOVE p.category
RETURN count(p) AS categorized

마지막 예시가 특히 흥미롭다. 3장에서는 category를 단순 문자열 속성으로 넣었지만, 온톨로지 관점에서 보면 카테고리는 독립된 개념(클래스)이다. 이렇게 속성을 노드로 승격시키는 것은 그래프 모델링에서 자주 하는 리팩토링이다. 속성으로 남겨두면 “같은 카테고리에 속하는 정책들”을 찾기가 번거롭지만, 노드로 분리하면 관계를 따라가는 것만으로 쉽게 조회할 수 있다.

마이그레이션 검증

마이그레이션이 끝났다고 안심하기엔 이르다. 데이터가 제대로 옮겨졌는지 검증해야 한다.

// 마이그레이션 전후 노드 수 비교
MATCH (e:Employee) RETURN 'Employee' AS label, count(e) AS count
UNION ALL
MATCH (d:Department) RETURN 'Department' AS label, count(d) AS count
UNION ALL
MATCH (p:Policy) RETURN 'Policy' AS label, count(p) AS count
// 고아 노드 확인 (관계가 하나도 없는 노드)
MATCH (n)
WHERE NOT (n)--()
RETURN labels(n) AS labels, n.name AS name
// 온톨로지 스키마와 실제 데이터의 라벨 일치 확인
MATCH (c:Class)
WITH collect(c.name) AS ontologyClasses
CALL db.labels() YIELD label
WHERE label IN ontologyClasses
RETURN label, 'EXISTS' AS status

고아 노드가 발견된다면 마이그레이션 과정에서 관계 연결이 빠진 것이다. 빠뜨린 관계를 추가해주자. 온톨로지에 정의되지 않은 라벨이 남아 있다면, 변환 스크립트에서 누락된 매핑이 있다는 뜻이다.

기술 리더 의사결정 박스: 기존 시스템 데이터를 어떻게 전환할 것인가?

전략 설명 리스크 소요 기간 적합한 경우
빅뱅 마이그레이션 한 번에 전체 데이터를 새 스키마로 전환 높음 — 실패 시 롤백 복잡 짧음 (1~2주) 데이터 규모 작고, 다운타임 허용 가능할 때
점진적 이중 운영 구 스키마와 신 스키마를 병행 운영하며 단계적 전환 낮음 — 언제든 중단 가능 길음 (1~3개월) 서비스 중단 불가, 데이터 정합성 검증이 중요할 때
ETL 파이프라인 별도 파이프라인으로 구 데이터를 추출-변환-적재 중간 — 파이프라인 자체의 복잡도 중간 (2~4주) 대규모 데이터, 반복 가능한 프로세스가 필요할 때

이 책의 HR안내봇 규모라면 빅뱅 마이그레이션으로 충분하다. 하지만 실제 엔터프라이즈 환경에서는 점진적 이중 운영이 안전한 선택인 경우가 많다. 특히 기존 시스템을 당장 끌 수 없는 상황이라면, 이중 운영하면서 신규 데이터는 새 스키마로, 기존 데이터는 배치로 마이그레이션하는 전략이 바람직하다.

인스턴스 데이터 확장 적재

3장의 10명 데이터를 마이그레이션했으니, 이제 HR안내봇이 제대로 동작할 수 있도록 데이터를 더 넣어보자. 직원 100명, 부서 8개, 직급 5단계, 정책 15개, 복리후생 항목 10개 규모를 목표로 한다.

대량 데이터 적재에는 여러 방법이 있지만, 여기서는 LOAD CSV와 Cypher MERGE 패턴을 결합하는 방식을 살펴보자.

CSV 데이터 준비

// employees.csv
employeeId,name,email,hireDate,departmentCode,positionTitle,managerId
E001,김철수,cs.kim@example.com,2020-03-15,HR,팀장,
E002,이영희,yh.lee@example.com,2021-06-01,HR,주임,E001
E003,박지민,jm.park@example.com,2019-11-20,DEV,시니어 개발자,E010
...
// departments.csv
code,name,description,headCount
HR,인사팀,인사 관리 및 채용,15
DEV,개발팀,소프트웨어 개발,35
FIN,재무팀,재무 회계 관리,10
...
// policies.csv
policyId,title,category,effectiveDate,description
P001,육아휴직 규정,복리후생,2024-01-01,1년 이상 근속자 대상 최대 1년
P002,연차 사용 지침,근무,2024-03-01,입사 1년 미만 월 1일 발생
P003,재택근무 정책,근무,2025-01-01,주 2회 재택근무 가능
...

LOAD CSV로 적재

// 부서 데이터 적재
LOAD CSV WITH HEADERS FROM 'file:///departments.csv' AS row
MERGE (d:Department {code: row.code})
SET d.name = row.name,
    d.description = row.description,
    d.headCount = toInteger(row.headCount)
RETURN count(d) AS departments
// 직급 데이터 적재
LOAD CSV WITH HEADERS FROM 'file:///positions.csv' AS row
MERGE (p:Position {title: row.title})
SET p.level = toInteger(row.level),
    p.description = row.description
RETURN count(p) AS positions
// 직원 데이터 적재 + 부서/직급 연결
LOAD CSV WITH HEADERS FROM 'file:///employees.csv' AS row
MERGE (e:Employee {employeeId: row.employeeId})
SET e.name = row.name,
    e.email = row.email,
    e.hireDate = date(row.hireDate)
WITH e, row
MATCH (d:Department {code: row.departmentCode})
MERGE (e)-[:BELONGS_TO]->(d)
WITH e, row
MATCH (p:Position {title: row.positionTitle})
MERGE (e)-[:HAS_POSITION]->(p)
RETURN count(e) AS employees
// 매니저 관계 설정 (직원 데이터가 모두 들어간 뒤 실행)
LOAD CSV WITH HEADERS FROM 'file:///employees.csv' AS row
WHERE row.managerId IS NOT NULL AND row.managerId <> ''
MATCH (e:Employee {employeeId: row.employeeId})
MATCH (m:Employee {employeeId: row.managerId})
MERGE (e)-[:REPORTS_TO]->(m)
RETURN count(*) AS managerRelations
// 정책 데이터 적재 + 카테고리 연결
LOAD CSV WITH HEADERS FROM 'file:///policies.csv' AS row
MERGE (p:Policy {policyId: row.policyId})
SET p.title = row.title,
    p.effectiveDate = date(row.effectiveDate),
    p.description = row.description
WITH p, row
MERGE (c:BenefitCategory {name: row.category})
MERGE (p)-[:CATEGORIZED_AS]->(c)
RETURN count(p) AS policies

여기서 CREATE 대신 MERGE를 쓰는 이유가 궁금할 수 있다. CREATE는 매번 새 노드를 만들지만, MERGE는 이미 같은 키를 가진 노드가 있으면 기존 노드를 재사용한다. 데이터를 반복 적재할 때 중복이 생기지 않으니, 실무에서는 MERGE를 기본으로 쓰는 편이 낫다.

정책-부서-직급 간 관계 설정

HR 온톨로지의 핵심은 정책이 어떤 대상에게 적용되는지를 표현하는 것이다. 예를 들어 “육아휴직 규정은 모든 정규직 직원에게 적용된다”거나 “재택근무 정책은 개발팀과 기획팀에만 적용된다” 같은 관계다.

// 정책-부서 적용 관계
MATCH (p:Policy {policyId: 'P003'})  // 재택근무 정책
MATCH (d:Department) WHERE d.code IN ['DEV', 'PLAN']
MERGE (p)-[:APPLIES_TO]->(d)
RETURN p.title, collect(d.name) AS applicableDepts
// 정책-직급 적용 관계 (직급 3 이상에게만 적용되는 정책)
MATCH (p:Policy {policyId: 'P005'})  // 임원 복리후생
MATCH (pos:Position) WHERE pos.level >= 3
MERGE (p)-[:APPLIES_TO]->(pos)
RETURN p.title, collect(pos.title) AS applicablePositions
// 복리후생 항목과 직원 연결
MATCH (b:Benefit {benefitId: 'B001'})  // 건강검진
MATCH (e:Employee)
MERGE (e)-[:ENTITLED_TO]->(b)
RETURN b.name, count(e) AS eligibleCount

온톨로지 기반 데이터 검증

데이터를 넣었다고 끝이 아니다. 온톨로지에 정의한 규칙을 데이터가 제대로 따르고 있는지 검증해야 한다. 온톨로지의 공리(Axiom)가 바로 이 역할을 한다.

예를 들어, 4장에서 “모든 Employee는 정확히 하나의 Department에 속해야 한다”는 제약을 정의했다면, 이를 Cypher로 검증할 수 있다.

// 부서가 없는 직원 찾기 (제약 위반)
MATCH (e:Employee)
WHERE NOT (e)-[:BELONGS_TO]->(:Department)
RETURN e.employeeId, e.name AS orphanEmployee
// 두 개 이상의 부서에 속한 직원 찾기 (카디널리티 위반)
MATCH (e:Employee)-[:BELONGS_TO]->(d:Department)
WITH e, count(d) AS deptCount
WHERE deptCount > 1
RETURN e.employeeId, e.name, deptCount
// 매니저 순환 참조 확인 (A가 B에게 보고하고, B가 A에게 보고하는 경우)
MATCH path = (e:Employee)-[:REPORTS_TO*2..5]->(e)
RETURN [n IN nodes(path) | n.name] AS circularChain

이런 검증 쿼리들은 데이터 적재 파이프라인의 마지막 단계에 넣어두는 게 좋다. 새 데이터가 들어올 때마다 자동으로 검증하면, 잘못된 데이터가 지식그래프에 섞여 들어오는 것을 사전에 막을 수 있다. 8장에서 HR안내봇이 엉뚱한 답변을 내놓는 원인 중 상당수가 바로 이런 데이터 품질 문제에서 비롯된다는 사실을 기억해두자.

n10s에는 온톨로지 기반으로 데이터를 검증하는 기능도 내장되어 있다.

// n10s 온톨로지 기반 SHACL 검증 (SHACL shapes가 정의된 경우)
CALL n10s.validation.shacl.import.fetch(
  'file:///hr-shapes.ttl',
  'Turtle'
)
// 검증 실행
CALL n10s.validation.shacl.validate()
YIELD focusNode, resultPath, resultMessage
RETURN focusNode, resultPath, resultMessage

SHACL(Shapes Constraint Language)은 RDF 데이터의 형태를 정의하고 검증하는 W3C 표준이다. 온톨로지가 “어떤 데이터가 존재할 수 있는가”를 정의한다면, SHACL은 “데이터가 이 모양을 만족하는가”를 검사한다. 둘을 함께 쓰면 데이터 품질에 대한 확신이 훨씬 높아진다.

트러블슈팅 — 임포트와 마이그레이션의 흔한 함정

실제로 이 과정을 따라하다 보면 여러 가지 오류를 만나게 된다. 자주 발생하는 문제들과 해결법을 정리했다.

URI 충돌

Neo.ClientError.Procedure.ProcedureCallFailed:
Failed to invoke procedure `n10s.onto.import.fetch`:
Caused by: n10s.RDFImportException: Multiple definitions for...

같은 URI를 가진 엔티티가 온톨로지 파일에 중복 정의되어 있을 때 발생한다. 온톨로지 파일을 점검해서 중복을 제거하자. Protege에서 내보낸 파일은 보통 깨끗하지만, LLM이 생성한 파일에서는 종종 이런 문제가 생긴다.

인코딩 문제

한국어가 포함된 온톨로지 파일을 임포트할 때 인코딩 오류가 날 수 있다. 파일이 반드시 UTF-8로 저장되어 있는지 확인하자. 특히 윈도우 환경에서 작업했다면 BOM(Byte Order Mark)이 붙어 있을 수 있는데, 이것이 파싱 오류의 원인이 되기도 한다.

대량 데이터 적재 시 메모리 부족

LOAD CSV로 만 건 이상의 데이터를 한 번에 넣으면 메모리가 부족해질 수 있다. 이때는 USING PERIODIC COMMIT(Neo4j 4.x) 또는 :auto 트랜잭션(Neo4j 5.x)을 사용한다.

// Neo4j 5.x에서 대량 적재
:auto LOAD CSV WITH HEADERS FROM 'file:///large-employees.csv' AS row
CALL {
  WITH row
  MERGE (e:Employee {employeeId: row.employeeId})
  SET e.name = row.name, e.email = row.email
} IN TRANSACTIONS OF 500 ROWS

500건씩 나눠서 트랜잭션을 처리하므로 메모리 부담이 크게 줄어든다.

관계 타입 변경 시 속성 누락

앞서 언급했듯이, 관계를 재생성할 때 기존 속성을 복사하지 않으면 데이터가 사라진다. 마이그레이션 스크립트에서 이 부분을 꼼꼼히 점검하자. 관계에 속성이 많다면 properties() 함수로 한 번에 복사하는 방법도 있다.

// 관계의 모든 속성을 안전하게 복사
MATCH (a:Employee)-[old:WORKS_IN]->(b:Department)
CREATE (a)-[new:BELONGS_TO]->(b)
SET new = properties(old)
DELETE old
RETURN count(new) AS migrated

SET new = properties(old)가 핵심이다. 이 한 줄이 기존 관계의 모든 속성을 새 관계로 복사해준다.

지식그래프 완성 확인

모든 작업이 끝났으면, 지식그래프의 전체 모습을 확인해보자.

// 지식그래프 통계 요약
CALL apoc.meta.stats() YIELD labels, relTypes
RETURN labels, relTypes
// 멀티홉 쿼리 테스트: "김철수의 매니저는 누구이며, 그 매니저가 관리하는 정책은?"
MATCH (e:Employee {name: '김철수'})-[:REPORTS_TO]->(m:Employee)
MATCH (m)-[:MANAGES]->(d:Department)<-[:APPLIES_TO]-(p:Policy)
RETURN e.name AS employee,
       m.name AS manager,
       d.name AS department,
       collect(p.title) AS relatedPolicies
// 2홉 탐색: "인사팀 소속 직원들이 받을 수 있는 복리후생은?"
MATCH (d:Department {name: '인사팀'})<-[:BELONGS_TO]-(e:Employee)
MATCH (e)-[:ENTITLED_TO]->(b:Benefit)
RETURN d.name AS department,
       collect(DISTINCT e.name) AS employees,
       collect(DISTINCT b.name) AS benefits

이 쿼리들이 정상적으로 결과를 돌려준다면, 3장의 단순한 데이터가 온톨로지 기반의 지식그래프로 성공적으로 변환된 것이다. 3장에서는 “인사팀 소속 직원 목록”을 겨우 조회하는 수준이었지만, 이제는 “김철수의 매니저가 관리하는 정책”이라는 멀티홉 질문에도 답할 수 있다. 이것이 바로 온톨로지가 가져다주는 구조적 힘이다.

마무리

이번 장에서 우리는 설계도를 현실로 옮기는 작업을 했다. n10s로 온톨로지를 임포트하고, 3장의 레거시 데이터를 새 스키마로 마이그레이션하고, 추가 데이터를 적재해 100건 규모의 지식그래프를 완성했다. 검증 쿼리로 데이터 품질도 확인했다.

기억해두자. 온톨로지를 Neo4j에 심는 것은 일회성 작업이 아니다. 새로운 HR 정책이 추가되고, 조직 개편이 일어나면 온톨로지와 데이터를 함께 업데이트해야 한다. 9장에서 이 진화 관리를 다룰 예정이니 지금은 “지식그래프는 살아 있는 유기체”라는 감각만 가져가면 된다.

다음 장에서는 이 지식그래프에 그래프 알고리즘을 돌려보자. Louvain으로 비공식 커뮤니티를 발견하고, PageRank로 조직의 핵심 인물을 찾아낸다. 데이터가 들어 있는 지식그래프에 분석의 눈을 더하면 어떤 인사이트가 나오는지, 함께 확인해보자.


HR안내봇 진행도

항목 상태
3장 샘플 데이터 마이그레이션 완료 — 라벨, 관계, 속성 모두 온톨로지 스키마로 전환
온톨로지 임포트 완료 — n10s로 OWL 파일 임포트, 스키마 구조 확인
데이터 규모 직원 100명, 부서 8개, 정책 15개, 복리후생 10개
멀티홉 쿼리 가능 — “김철수의 매니저가 관리하는 정책” 조회 성공
다음 단계 6장에서 GDS 알고리즘으로 그래프 분석

6장. 그래프 알고리즘으로 인사이트 발굴하기 — Neo4j GDS 활용

당신이 HR 데이터 분석을 맡게 되었다고 상상해보자. 경영진이 이런 질문을 던진다. “우리 조직에서 실제로 긴밀하게 협업하는 그룹이 어디인가?”, “이 사람이 퇴사하면 정보 흐름에 얼마나 큰 구멍이 뚫리는가?”, “스킬 셋이 비슷한 직원을 찾아서 프로젝트에 배정하고 싶은데 어떻게 해야 하는가?” 전통적인 관계형 데이터베이스로는 이런 질문에 답하기가 난감하다. SQL로 조직도의 깊이를 따라 재귀 쿼리를 돌릴 수는 있지만, “비공식 협업 구조”나 “정보 흐름의 병목”을 찾아내는 건 전혀 다른 차원의 문제다.

5장에서 완성한 지식그래프를 떠올려보자. 직원, 부서, 정책, 복리후생이 관계로 촘촘하게 엮여 있다. 이 구조 위에 그래프 알고리즘을 돌리면 눈에 보이지 않던 패턴이 드러난다. 공식 조직도에는 없지만 실제로 긴밀한 커뮤니티, 조직의 정보 허브 역할을 하는 핵심 인물, 두 부서 사이의 최단 정보 전달 경로 — 이런 인사이트가 알고리즘 몇 줄로 나온다.

Neo4j GDS(Graph Data Science) 라이브러리가 바로 이 일을 해준다. 함께 살펴보자.

GDS 라이브러리 — 그래프 분석의 도구 상자

GDS는 Neo4j 위에서 돌아가는 그래프 분석 라이브러리다. 60개 이상의 그래프 알고리즘을 제공하며, 대부분 세 단계로 실행된다. 그래프 프로젝션(projection), 알고리즘 실행, 결과 활용이다.

왜 “그래프 프로젝션”이라는 별도 단계가 필요할까? GDS는 알고리즘을 효율적으로 실행하기 위해 Neo4j의 원본 데이터를 메모리에 최적화된 형태로 복사한다. 이 복사본이 프로젝션이다. 원본 그래프에서 분석에 필요한 노드와 관계만 골라서 가져오므로, 불필요한 데이터가 분석을 방해하지 않는다.

GDS 설치와 설정

Neo4j Desktop이라면 Plugins 탭에서 GDS를 설치할 수 있다. AuraDB를 쓴다면 AuraDS(Data Science) 인스턴스를 선택하면 GDS가 기본 탑재되어 있다.

// GDS 설치 확인
RETURN gds.version() AS version

그래프 프로젝션 생성

HR 지식그래프에서 분석에 쓸 프로젝션을 만들어보자.

// HR 조직 분석용 그래프 프로젝션
CALL gds.graph.project(
  'hr-org-graph',
  ['Employee', 'Department', 'Policy'],
  {
    BELONGS_TO: { orientation: 'UNDIRECTED' },
    REPORTS_TO: { orientation: 'NATURAL' },
    COLLABORATES_WITH: { orientation: 'UNDIRECTED' },
    APPLIES_TO: { orientation: 'UNDIRECTED' }
  }
)
YIELD graphName, nodeCount, relationshipCount
RETURN graphName, nodeCount, relationshipCount

여기서 orientation 설정이 중요하다. REPORTS_TO는 방향이 의미를 가지므로 NATURAL(원래 방향 유지)로 두지만, COLLABORATES_WITH는 양방향이므로 UNDIRECTED로 설정한다. 이 설정을 잘못하면 알고리즘 결과가 완전히 달라질 수 있으니 주의하자.

프로젝션이 만들어졌는지 확인한다.

// 프로젝션 목록 확인
CALL gds.graph.list()
YIELD graphName, nodeCount, relationshipCount, creationTime
RETURN *

이제 알고리즘을 하나씩 돌려보자.

커뮤니티 탐지 — 보이지 않는 협업 구조 발견

조직도에는 인사팀, 개발팀, 재무팀이라는 공식 구분만 있다. 하지만 실제 업무에서는 부서를 넘나드는 비공식 그룹이 존재한다. 인사팀의 김 주임과 개발팀의 박 선임이 매주 채용 관련 미팅을 하고, 재무팀의 최 과장이 개발팀 예산 관련으로 수시로 소통한다. 이런 비공식 연결이 조직의 실제 동작 방식이다.

Louvain 알고리즘은 그래프에서 이런 커뮤니티를 자동으로 탐지한다. 내부 연결이 밀집된 노드 그룹을 찾아내는 것이다.

Louvain 알고리즘 실행

먼저 예상 결과를 확인하는 stream 모드로 실행해보자.

// Louvain 커뮤니티 탐지 (stream 모드 — 결과 미리보기)
CALL gds.louvain.stream('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO']
})
YIELD nodeId, communityId
WITH gds.util.asNode(nodeId) AS employee, communityId
RETURN communityId,
       collect(employee.name) AS members,
       count(*) AS memberCount
ORDER BY memberCount DESC

결과가 흥미롭다. 공식 부서와 다른 그룹이 보이는가? 개발팀과 기획팀 직원이 하나의 커뮤니티로 묶여 있다거나, 인사팀 내에서도 두 개의 하위 커뮤니티가 나뉘어 있을 수 있다. 이것이 “공식 조직도에는 없는 실제 협업 구조”다.

결과에 만족했다면 mutate 모드로 프로젝션에 결과를 기록하자.

// Louvain 결과를 프로젝션에 기록
CALL gds.louvain.mutate('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO'],
  mutateProperty: 'communityId'
})
YIELD communityCount, modularity, modularityScore
RETURN communityCount, modularity, modularityScore

modularity 값이 0.3 이상이면 커뮤니티 구조가 의미 있다고 판단하는 것이 일반적이다. 0에 가까우면 무작위 연결과 큰 차이가 없다는 뜻이니, 데이터의 관계가 충분히 풍부한지 점검해볼 필요가 있다.

물론 Louvain만이 유일한 선택은 아니다. Leiden 알고리즘은 Louvain의 개선 버전으로, 잘못 분류된 노드를 더 적극적으로 재배치한다. 커뮤니티 품질이 더 높아지는 경향이 있으니, 두 알고리즘의 결과를 비교해보는 것도 좋다.

// Leiden 알고리즘으로 비교
CALL gds.leiden.stream('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO']
})
YIELD nodeId, communityId
WITH gds.util.asNode(nodeId) AS employee, communityId
RETURN communityId, collect(employee.name) AS members, count(*) AS size
ORDER BY size DESC

중심성 분석 — 조직의 핵심 인물은 누구인가

“이 사람이 퇴사하면 어떻게 되는가?” — 이 질문에 데이터로 답하려면 중심성(centrality) 분석이 필요하다. 중심성은 그래프에서 특정 노드가 얼마나 중요한 위치에 있는지를 수치로 나타낸다.

PageRank — 영향력 측정

PageRank는 구글이 웹 페이지의 중요도를 매기기 위해 만든 알고리즘이다. “중요한 페이지로부터 링크를 많이 받는 페이지가 중요하다”는 원리인데, 이를 조직 네트워크에 적용하면 “중요한 사람들과 많이 연결된 사람이 영향력이 크다”가 된다.

// PageRank로 직원 영향력 측정
CALL gds.pageRank.stream('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO'],
  maxIterations: 20,
  dampingFactor: 0.85
})
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS employee, score
RETURN employee.name AS name,
       employee.employeeId AS id,
       round(score, 4) AS pageRank
ORDER BY pageRank DESC
LIMIT 10

상위 10명이 조직에서 가장 영향력 있는 인물이다. 그런데 여기서 한 가지 의문이 생긴다. PageRank 점수가 높다고 반드시 핵심 인물인 걸까? 단순히 연결이 많은 것과, 정보 흐름에서 중요한 위치에 있는 것은 다른 문제다.

Betweenness Centrality — 정보 흐름의 병목

Betweenness Centrality는 다른 관점으로 중요도를 측정한다. 그래프에서 두 노드 사이의 최단 경로 위에 해당 노드가 얼마나 자주 등장하는지를 계산한다. 다시 말해, “이 사람을 거치지 않으면 정보가 전달되지 않는 경우가 얼마나 많은가”를 수치화한 것이다.

// Betweenness Centrality로 정보 흐름 병목 식별
CALL gds.betweenness.stream('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO']
})
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS employee, score
RETURN employee.name AS name,
       employee.employeeId AS id,
       round(score, 2) AS betweenness
ORDER BY betweenness DESC
LIMIT 10

PageRank와 Betweenness의 결과를 나란히 놓고 보면 재미있는 패턴이 보인다. PageRank는 높은데 Betweenness가 낮은 사람은 “존경받지만 대체 가능한” 인물이다. 반대로 PageRank는 보통인데 Betweenness가 높은 사람은 “눈에 잘 띄지 않지만 이 사람 없으면 소통이 끊기는” 인물이다. 후자가 조직 리스크 관점에서 더 위험한 경우가 많다.

두 지표를 함께 조회해보자.

// PageRank와 Betweenness를 함께 비교
CALL gds.pageRank.stream('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO']
})
YIELD nodeId, score AS pageRank
WITH nodeId, pageRank
CALL gds.betweenness.stream('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO']
})
YIELD nodeId AS bNodeId, score AS betweenness
WHERE nodeId = bNodeId
WITH gds.util.asNode(nodeId) AS emp, pageRank, betweenness
RETURN emp.name AS name,
       round(pageRank, 4) AS influence,
       round(betweenness, 2) AS bridging
ORDER BY betweenness DESC
LIMIT 10

이 결과를 보고 “아, 박지민 선임이 개발팀과 기획팀 사이에서 다리 역할을 하고 있었구나” 같은 인사이트가 나올 수 있다. 이런 분석은 순수하게 데이터가 말해주는 것이지, 누군가의 주관적 판단이 아니다.

경로 탐색 — 두 부서 사이의 정보 전달 경로

“개발팀에서 결정한 기술 스택 변경이 재무팀까지 어떤 경로로 전달되는가?” 이런 질문에 최단 경로 알고리즘이 답한다.

// 두 부서 사이의 최단 경로
MATCH (start:Employee {name: '김철수'}), (end:Employee {name: '최재훈'})
CALL gds.shortestPath.dijkstra.stream('hr-org-graph', {
  sourceNode: start,
  targetNode: end,
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO']
})
YIELD path
RETURN [n IN nodes(path) | n.name] AS route,
       length(path) AS hops

경로가 너무 길다면? 다섯 홉 이상 거쳐야 정보가 전달된다면, 그 두 부서 사이에 직접적인 소통 채널을 만드는 것을 고려해볼 수 있다. 그래프 분석이 조직 설계에 대한 시사점까지 제공하는 셈이다.

모든 직원 쌍의 최단 경로 길이 분포를 보면 조직 전체의 소통 효율성을 가늠할 수 있다.

// 전체 직원 쌍의 평균 최단 경로 (조직 소통 밀도 지표)
CALL gds.allShortestPaths.stream('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH']
})
YIELD sourceNode, targetNode, distance
WHERE sourceNode < targetNode  // 중복 제거
WITH distance, count(*) AS pathCount
RETURN distance AS hops, pathCount
ORDER BY hops

평균 경로 길이가 3 이하라면 소통이 꽤 밀접한 조직이다. 5 이상이면 부서 간 사일로(silo)가 심각할 수 있다. 이 수치 하나로 “우리 조직의 소통 건강도”에 대한 실마리를 잡을 수 있다는 게 흥미롭지 않은가?

유사도 분석 — 스킬 기반 직원 매칭

프로젝트 배정이나 부서 이동을 결정할 때, “이 직원과 스킬 셋이 비슷한 사람이 누구인가?”라는 질문이 자주 나온다. Node Similarity 알고리즘이 이 질문에 답한다.

먼저 직원과 스킬의 관계가 지식그래프에 있어야 한다. 5장에서 넣은 데이터에 스킬 정보를 추가해보자.

// 스킬 노드와 직원-스킬 관계 추가 (예시)
MERGE (s1:Skill {name: 'Python'})
MERGE (s2:Skill {name: 'Cypher'})
MERGE (s3:Skill {name: 'Machine Learning'})
MERGE (s4:Skill {name: 'Data Analysis'})
MERGE (s5:Skill {name: 'Project Management'})

WITH 1 AS dummy
MATCH (e:Employee {name: '박지민'})
MATCH (s:Skill) WHERE s.name IN ['Python', 'Cypher', 'Machine Learning']
MERGE (e)-[:HAS_SKILL]->(s)

스킬 데이터가 준비되었으면 유사도를 계산한다.

// 스킬 기반 직원 유사도 분석을 위한 프로젝션
CALL gds.graph.project(
  'skill-similarity-graph',
  ['Employee', 'Skill'],
  { HAS_SKILL: { orientation: 'UNDIRECTED' } }
)
// Node Similarity 실행
CALL gds.nodeSimilarity.stream('skill-similarity-graph', {
  nodeLabels: ['Employee'],
  topK: 5
})
YIELD node1, node2, similarity
WITH gds.util.asNode(node1) AS emp1,
     gds.util.asNode(node2) AS emp2,
     similarity
WHERE similarity > 0.5
RETURN emp1.name AS employee1,
       emp2.name AS employee2,
       round(similarity, 3) AS skillSimilarity
ORDER BY skillSimilarity DESC

유사도가 0.8 이상이면 스킬 셋이 매우 비슷한 직원 쌍이다. 프로젝트에 특정 스킬이 필요할 때 이 결과를 참고하면, “박지민 선임이 바쁘면 이 과장에게 맡겨도 되겠다”는 판단이 데이터에 근거해서 나온다.

GDS 결과를 지식그래프에 기록하기 — Write-Back

지금까지 알고리즘 결과를 화면에서 보기만 했다. 하지만 진짜 가치는 이 결과를 지식그래프에 다시 기록해서, 나중에 다른 쿼리나 애플리케이션에서 활용할 수 있게 만드는 데 있다. 특히 8장에서 만들 HR안내봇이 “우리 팀의 핵심 인물이 누구야?”라는 질문에 답하려면, 분석 결과가 그래프 속에 있어야 한다.

이것을 Write-Back이라 부른다. write 모드로 알고리즘을 실행하면 결과가 Neo4j 원본 그래프의 노드 속성으로 저장된다.

// PageRank 결과를 노드 속성으로 기록
CALL gds.pageRank.write('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO'],
  writeProperty: 'pageRank'
})
YIELD nodePropertiesWritten, ranIterations
RETURN nodePropertiesWritten, ranIterations
// Betweenness Centrality 결과도 기록
CALL gds.betweenness.write('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO'],
  writeProperty: 'betweenness'
})
YIELD nodePropertiesWritten
RETURN nodePropertiesWritten
// 커뮤니티 ID도 기록
CALL gds.louvain.write('hr-org-graph', {
  nodeLabels: ['Employee'],
  relationshipTypes: ['COLLABORATES_WITH', 'REPORTS_TO'],
  writeProperty: 'communityId'
})
YIELD communityCount, nodePropertiesWritten
RETURN communityCount, nodePropertiesWritten

이제 각 Employee 노드에 pageRank, betweenness, communityId 속성이 추가되었다. 확인해보자.

// Write-Back 결과 확인
MATCH (e:Employee)
RETURN e.name,
       e.pageRank,
       e.betweenness,
       e.communityId
ORDER BY e.pageRank DESC
LIMIT 10

이 데이터는 8장의 HR안내봇에서 직접 활용된다. “우리 팀에서 가장 영향력 있는 사람은 누구야?”라고 물으면, 봇이 pageRank 속성을 기준으로 답변하고, “개발팀과 실제로 긴밀한 부서가 어디야?”라고 물으면 communityId를 기반으로 같은 커뮤니티에 속한 다른 부서 직원들을 찾아준다.

Write-Back 후에는 프로젝션을 정리해주는 게 좋다. 프로젝션은 메모리를 점유하므로, 분석이 끝나면 삭제한다.

// 프로젝션 정리
CALL gds.graph.drop('hr-org-graph')
CALL gds.graph.drop('skill-similarity-graph')

기술 리더 의사결정 박스: 그래프 분석을 도입할 것인가?

기준 GDS Community Edition GDS Enterprise Edition
비용 무료 유료 라이선스
알고리즘 주요 알고리즘 포함 전체 알고리즘 + 최적화 버전
성능 중소 규모 적합 대규모 그래프 최적화
메모리 관리 기본 고급 메모리 추정·관리
프로덕션 지원 커뮤니티 지원 공식 기술 지원

분석 빈도별 접근: - 일회성 분석: Jupyter 노트북 + GDS Community로 충분하다 - 주간/월간 배치: 스케줄러(cron, Airflow)로 분석 → Write-Back 파이프라인 자동화 - 준실시간: 데이터 변경 이벤트 시 자동 재분석 — Enterprise 권장

HR안내봇 수준이라면 Community Edition으로 시작하되, 조직 규모가 수천 명 이상이거나 분석 주기가 짧다면 Enterprise를 검토하는 편이 낫다.

마무리

이번 장에서 우리는 지식그래프에 분석의 눈을 더했다. Louvain으로 비공식 커뮤니티를 발견했고, PageRank와 Betweenness로 핵심 인물을 식별했다. 최단 경로로 부서 간 소통 경로를 분석했고, Node Similarity로 스킬 기반 직원 매칭까지 해봤다. 무엇보다, 이 결과를 Write-Back으로 지식그래프에 기록해서 다른 곳에서 쓸 수 있게 만들었다.

기억해두자. 그래프 알고리즘은 도구일 뿐이다. 알고리즘이 내놓은 숫자 자체보다, 그 숫자가 비즈니스 맥락에서 무엇을 의미하는지 해석하는 것이 더 중요하다. PageRank 1위가 반드시 최고의 직원은 아니며, 커뮤니티가 나뉜다고 반드시 문제인 것도 아니다. 데이터는 질문에 답하되, 판단은 사람이 해야 한다.

다음 장에서는 이 지식그래프에 AI를 연결한다. LLM이 그래프를 탐색하며 자연어 질문에 답하는 GraphRAG 아키텍처를 설계해보자. 6장에서 Write-Back한 분석 결과가 7장과 8장에서 어떻게 활용되는지, 흐름을 이어서 확인하게 될 것이다.


HR안내봇 진행도

항목 상태
커뮤니티 탐지 완료 — Louvain으로 비공식 협업 그룹 식별
핵심 인물 식별 완료 — PageRank + Betweenness로 상위 인물 3명 식별
경로 분석 완료 — 부서 간 최단 소통 경로 분석
유사도 분석 완료 — 스킬 기반 직원 매칭
Write-Back 완료 — pageRank, betweenness, communityId 노드 속성으로 기록
다음 단계 7장에서 GraphRAG 아키텍처 설계 → 8장에서 통합 구현

7장. AI와 지식그래프의 만남 — GraphRAG 아키텍처

누군가 당신에게 이렇게 묻는다고 해보자. “육아휴직 중인 직원이 연차를 사용할 수 있나요? 그리고 그 직원의 매니저가 알아야 할 관련 정책도 함께 알려주세요.” 이 질문 하나에 여러 겹의 정보가 얽혀 있다. 육아휴직 규정, 연차 사용 지침, 해당 직원의 매니저 관계, 매니저에게 적용되는 정책 — 최소 서너 홉을 넘나들어야 온전한 답이 나온다.

벡터 RAG로 이 질문에 답하려면 어떻게 될까? 육아휴직 관련 문서 조각과 연차 관련 문서 조각을 유사도 검색으로 가져올 수는 있다. 하지만 “그 직원의 매니저”를 찾고, “매니저에게 적용되는 정책”까지 연결하는 건 벡터 유사도만으로는 난감하다. 텍스트 청크 사이의 관계가 아니라, 엔티티 사이의 구조적 관계를 따라가야 하기 때문이다.

바로 이 지점에서 GraphRAG가 등장한다. 지식그래프의 구조화된 관계를 LLM과 결합해 멀티홉 추론이 가능한 답변을 생성하는 아키텍처다. 원리부터 살펴보고, HR안내봇에 어떤 패턴이 적합한지 함께 결정해보자.

벡터 RAG vs GraphRAG — 무엇이 다른가

먼저 벡터 RAG의 동작 방식을 간단히 짚어보자.

벡터 RAG는 문서를 청크(chunk)로 나누고, 각 청크를 벡터(임베딩)로 변환한다. 사용자의 질문도 벡터로 바꿔서, 가장 유사한 청크 몇 개를 찾아 LLM에게 “이 맥락을 참고해서 답변해”라고 넘긴다. 단순하고 효과적이다. 정보가 하나의 문서 안에 집중되어 있을 때, 즉 단일 홉 질문에는 잘 작동한다.

그런데 찜찜한 부분이 있다. 벡터 유사도는 “비슷한 단어가 포함된 텍스트”를 찾는 것이지, “논리적으로 연결된 정보”를 찾는 것이 아니다. “김철수의 매니저”를 알려면 김철수 노드에서 REPORTS_TO 관계를 따라가야 하는데, 벡터 검색은 이런 구조적 탐색을 할 수 없다.

GraphRAG는 이 한계를 지식그래프로 보완한다. 질문에서 엔티티를 추출하고, 지식그래프에서 그 엔티티의 관계를 탐색해 맥락을 수집한 뒤, LLM에게 전달한다. 관계의 사슬을 따라가므로 멀티홉 추론이 자연스럽다.

성능 차이는 벤치마크로 확인할 수 있다. Microsoft의 연구에서 계층적 커뮤니티 기반 GraphRAG는 86%의 정확도를 달성했는데, 같은 데이터셋에서 기존 벡터 RAG는 32%에 머물렀다. FalkorDB의 2025년 벤치마크에서도 GraphRAG가 90% 이상의 정확도를 보인 반면, 벡터 RAG는 56.2%였다. 관계 기반 질문에서 격차가 특히 두드러진다.

물론 벡터 RAG가 항상 열등한 것은 아니다. 단일 문서 내 정보 검색, 의미적 유사도 기반 탐색에서는 벡터 RAG가 여전히 효율적이다. 그렇다면 어떻게 해야 할까? 둘을 결합하는 것이다.

4가지 통합 패턴 — 어떤 방식으로 연결할 것인가

LLM과 지식그래프를 연결하는 방식은 크게 네 가지로 나뉜다. 각 패턴의 동작 원리와 장단점을 비교해보자.

패턴 1: Text-to-Cypher

가장 직관적인 방식이다. 사용자의 자연어 질문을 LLM이 Cypher 쿼리로 변환하고, 그 쿼리를 Neo4j에서 실행해 결과를 돌려준다.

[사용자] "인사팀 소속 직원 중 PageRank가 가장 높은 사람은?"
    ↓ LLM이 Cypher로 변환
[Cypher] MATCH (e:Employee)-[:BELONGS_TO]->(d:Department {name: '인사팀'})
         RETURN e.name, e.pageRank ORDER BY e.pageRank DESC LIMIT 1
    ↓ Neo4j 실행
[결과] 김철수 (pageRank: 0.4521)
    ↓ LLM이 자연어로 정리
[답변] "인사팀에서 PageRank가 가장 높은 직원은 김철수님입니다."

장점: 정확한 구조적 쿼리가 가능하다. 수치 필터, 정렬, 집계 같은 연산에 강하다.

단점: LLM이 생성한 Cypher가 틀릴 수 있다. 스키마를 모르면 존재하지 않는 라벨이나 관계를 만들어내기도 한다. 복잡한 질문일수록 오류율이 올라가서 찜찜하다.

적합한 경우: 질문 패턴이 비교적 정형화되어 있고, 정확한 수치 기반 답변이 필요할 때.

패턴 2: Graph-Enhanced RAG

벡터 RAG의 검색 단계를 그래프 탐색으로 보강하는 방식이다.

[사용자] "육아휴직 중 연차 사용이 가능한가요?"
    ↓ 엔티티 추출
[엔티티] 육아휴직, 연차
    ↓ 그래프 탐색 (엔티티 주변 관계 수집)
[맥락] 육아휴직 규정 → 적용 대상 → 정규직 직원
       연차 사용 지침 → 예외 조항 → 휴직 중 연차 소멸 방지
       육아휴직 규정 -[:RELATED_TO]-> 연차 사용 지침
    ↓ + 벡터 검색으로 관련 텍스트 보충
    ↓ LLM에게 통합 맥락 전달
[답변] 구조적 관계 + 텍스트 맥락이 결합된 풍부한 답변

장점: 그래프의 구조적 맥락과 벡터의 의미적 맥락을 함께 활용한다. 멀티홉 추론이 가능하면서도 텍스트 기반 보충 설명까지 붙일 수 있다.

단점: 엔티티 추출 정확도에 의존한다. 질문에서 엔티티를 잘못 뽑으면 엉뚱한 맥락이 수집된다.

적합한 경우: 관계 기반 추론과 텍스트 검색이 모두 필요한 복합 질문.

패턴 3: LLM 기반 그래프 구축

방향이 반대다. LLM이 비정형 텍스트에서 엔티티와 관계를 추출해 지식그래프를 자동으로 구축한다.

[입력] HR 정책 문서 (PDF, 워드)
    ↓ LLM이 엔티티/관계 추출
[추출] (육아휴직 규정)-[:적용_대상]->(정규직 직원)
       (육아휴직 규정)-[:기간]->(최대 1년)
       (육아휴직 규정)-[:조건]->(근속 1년 이상)
    ↓ Neo4j에 적재
[결과] 지식그래프 자동 확장

4장에서 LLM으로 온톨로지를 생성했던 것과 비슷하지만, 여기서는 인스턴스 수준의 데이터를 자동으로 추출한다는 점이 다르다. Neo4j의 LLM Knowledge Graph Builder가 바로 이 패턴을 구현한 도구다.

장점: 비정형 데이터를 구조화하는 노력을 크게 줄인다.

단점: 추출 정확도가 완벽하지 않다. 도메인 전문가의 검증이 필수이며, 잘못 추출된 관계가 그래프를 오염시킬 수 있다.

적합한 경우: 새로운 문서가 지속적으로 유입되고, 수동 구조화가 병목인 상황.

패턴 4: Agentic GraphRAG

가장 진보된 패턴이다. AI 에이전트가 지식그래프를 영속 메모리(persistent memory)로 활용하며, 반복적으로 탐색하고 추론한다.

[에이전트] 질문 분석 → "이 질문에 답하려면 3단계 탐색이 필요하다"
    ↓ 1단계: 직원 정보 탐색
    ↓ 2단계: 관련 정책 수집
    ↓ 3단계: 매니저 관계까지 확장
    ↓ 중간 결과를 그래프에 캐시
[답변] 다단계 추론이 반영된 포괄적 답변

에이전트는 한 번의 그래프 탐색으로 부족하면 추가 탐색을 스스로 결정한다. 작업 수행 중 새로운 지식을 그래프에 축적하고, 다음 작업에서 참조하기도 한다.

장점: 복잡한 다단계 질문에 강하다. 자율적 탐색으로 사람이 예측하지 못한 연결고리를 찾기도 한다.

단점: 구현 복잡도가 높고, 에이전트의 행동을 제어하기 어렵다. 비용도 가장 많이 든다.

적합한 경우: 매우 복잡한 분석 질문, 장기적 맥락 유지가 필요한 대화형 시스템.

4가지 패턴 비교

기준 Text-to-Cypher Graph-Enhanced RAG LLM 그래프 구축 Agentic GraphRAG
구현 복잡도 낮음 중간 중간 높음
멀티홉 추론 가능 (Cypher 정확도 의존) 자연스러움 해당 없음 매우 강함
답변 풍부도 구조적 데이터 중심 구조 + 텍스트 해당 없음 가장 풍부
비용 LLM 1회 호출 LLM 1~2회 + 그래프 탐색 LLM 다수 호출 LLM 다수 호출
유지보수 스키마 변경 시 프롬프트 수정 리트리버 로직 관리 추출 품질 관리 에이전트 행동 관리
적합 시나리오 정형 질문, 수치 조회 복합 질문, HR안내봇 문서 자동 구조화 복잡한 분석 대화

기술 리더 의사결정 박스: 어떤 GraphRAG 패턴을 선택할 것인가?

선택의 핵심은 질문의 복잡도팀의 역량이다.

  • 질문이 대부분 단일 홉이고 정형적이다 → Text-to-Cypher로 시작하자. 구현이 가장 빠르다.
  • 멀티홉 질문이 많고, 텍스트 맥락도 중요하다 → Graph-Enhanced RAG. 대부분의 엔터프라이즈 시나리오에 적합하다.
  • 비정형 문서가 지속적으로 유입된다 → LLM 그래프 구축을 파이프라인에 추가하자.
  • 고도로 복잡한 분석 질문을 다뤄야 한다 → Agentic GraphRAG. 단, 팀에 AI 엔지니어링 역량이 필요하다.

권장 조합: Graph-Enhanced RAG를 기본으로 하되, 정형 질문에는 Text-to-Cypher를 보조로, 문서 유입에는 LLM 그래프 구축을 추가하는 하이브리드 접근이 바람직하다.

하이브리드 RAG 아키텍처 설계

HR안내봇에는 다양한 유형의 질문이 들어온다. “김철수의 이메일 주소”처럼 단순 조회도 있고, “육아휴직 중 연차 사용 가능 여부”처럼 정책 해석이 필요한 것도 있고, “우리 팀에서 가장 영향력 있는 사람”처럼 GDS 분석 결과를 활용해야 하는 것도 있다. 하나의 패턴으로 모든 질문을 처리하기는 어렵다.

그래서 하이브리드 RAG 아키텍처를 설계한다. 핵심은 쿼리 라우터(Query Router)다. 질문 유형을 분류하고, 적절한 리트리버로 분배하는 역할이다.

[사용자 질문]
    ↓
[쿼리 라우터] ← LLM이 질문 유형 분류
    ├─ 구조적 질문 → [그래프 리트리버] → Cypher 쿼리로 Neo4j 탐색
    ├─ 텍스트 질문 → [벡터 리트리버] → 임베딩 유사도 검색
    ├─ 분석 질문  → [GDS 메타데이터 리트리버] → Write-Back된 속성 조회
    └─ 복합 질문  → [하이브리드 리트리버] → 위 세 가지 결과 통합
    ↓
[맥락 통합기] — 수집된 맥락을 정리·중복 제거
    ↓
[LLM 답변 생성] — 통합 맥락 기반 자연어 답변
    ↓
[사용자에게 응답]

각 구성 요소를 좀 더 살펴보자.

쿼리 라우터

질문을 분류하는 첫 관문이다. LLM에게 분류 기준을 프롬프트로 제공한다.

ROUTER_PROMPT = """
다음 질문을 아래 유형 중 하나로 분류하세요.

유형:
- STRUCTURAL: 조직 구조, 직원 정보, 부서 관계 등 그래프 탐색이 필요한 질문
  예: "김철수의 매니저는 누구인가?", "인사팀 소속 직원은?"
- TEXTUAL: 정책 내용, 규정 해석 등 문서 검색이 필요한 질문
  예: "육아휴직 신청 절차는?", "연차 소멸 규정은?"
- ANALYTICAL: GDS 분석 결과 활용이 필요한 질문
  예: "가장 영향력 있는 직원은?", "비공식 협업 그룹은?"
- HYBRID: 위 유형이 복합된 질문
  예: "육아휴직 중인 직원의 매니저가 알아야 할 정책은?"

질문: {question}
유형:
"""

그래프 리트리버

구조적 질문을 처리한다. 질문에서 엔티티를 추출하고, 그 엔티티를 중심으로 지식그래프를 탐색한다.

from neo4j import GraphDatabase

class GraphRetriever:
    def __init__(self, driver):
        self.driver = driver

    def retrieve(self, entities, depth=2):
        """엔티티 주변 N홉까지 탐색하여 맥락 수집"""
        context = []
        with self.driver.session() as session:
            for entity in entities:
                result = session.run("""
                    MATCH (n)
                    WHERE n.name = $name OR n.employeeId = $name
                    CALL apoc.path.subgraphAll(n, {
                        maxLevel: $depth,
                        relationshipFilter: 'BELONGS_TO|REPORTS_TO|APPLIES_TO|HAS_POSITION'
                    })
                    YIELD nodes, relationships
                    RETURN nodes, relationships
                """, name=entity, depth=depth)

                for record in result:
                    nodes = record["nodes"]
                    rels = record["relationships"]
                    context.append(self._format_subgraph(nodes, rels))

        return "\n".join(context)

    def _format_subgraph(self, nodes, relationships):
        """서브그래프를 LLM이 이해할 수 있는 텍스트로 변환"""
        lines = []
        for rel in relationships:
            start_name = rel.start_node.get("name", "unknown")
            end_name = rel.end_node.get("name", "unknown")
            rel_type = rel.type
            lines.append(f"{start_name} --[{rel_type}]--> {end_name}")
        return "\n".join(lines)

벡터 리트리버

텍스트 질문을 처리한다. HR 정책 문서를 청크로 나누고 임베딩해서, 유사도 기반으로 검색한다. Neo4j 자체도 벡터 인덱스를 지원하므로, 별도의 벡터 DB 없이 Neo4j 안에서 벡터 검색을 할 수 있다.

class VectorRetriever:
    def __init__(self, driver):
        self.driver = driver

    def retrieve(self, question, top_k=5):
        """벡터 유사도 기반 문서 청크 검색"""
        with self.driver.session() as session:
            result = session.run("""
                // Neo4j 벡터 인덱스 검색
                CALL db.index.vector.queryNodes(
                    'policy-embeddings',
                    $top_k,
                    $question_embedding
                )
                YIELD node, score
                RETURN node.title AS title,
                       node.content AS content,
                       score
                ORDER BY score DESC
            """, top_k=top_k,
                question_embedding=self._embed(question))

            return [{"title": r["title"],
                     "content": r["content"],
                     "score": r["score"]}
                    for r in result]

    def _embed(self, text):
        """텍스트를 벡터로 변환 (임베딩 모델 호출)"""
        # OpenAI, Sentence-Transformers 등 활용
        pass

GDS 메타데이터 리트리버

6장에서 Write-Back한 분석 결과를 활용한다. pageRank, betweenness, communityId 같은 속성을 직접 조회한다.

class GDSMetadataRetriever:
    def __init__(self, driver):
        self.driver = driver

    def retrieve(self, query_type, params=None):
        """GDS 분석 결과 기반 조회"""
        queries = {
            "key_person": """
                MATCH (e:Employee)-[:BELONGS_TO]->(d:Department)
                WHERE d.name = $department
                RETURN e.name AS name,
                       e.pageRank AS influence,
                       e.betweenness AS bridging
                ORDER BY e.pageRank DESC
                LIMIT 3
            """,
            "community_members": """
                MATCH (e:Employee {communityId: $communityId})
                RETURN e.name AS name,
                       e.communityId AS community
                ORDER BY e.name
            """,
            "similar_skills": """
                MATCH (e:Employee {name: $name})-[:HAS_SKILL]->(s:Skill)
                       <-[:HAS_SKILL]-(similar:Employee)
                WHERE similar <> e
                WITH similar, count(s) AS sharedSkills
                RETURN similar.name AS name, sharedSkills
                ORDER BY sharedSkills DESC
                LIMIT 5
            """
        }
        with self.driver.session() as session:
            result = session.run(queries[query_type], **(params or {}))
            return [dict(record) for record in result]

Neo4j GraphRAG Python 패키지

Neo4j에서는 공식 GraphRAG 파이썬 패키지를 제공한다. 위에서 설계한 아키텍처의 많은 부분을 이 패키지가 이미 구현해두었다.

# 설치
# pip install neo4j-graphrag

from neo4j_graphrag.retrievers import (
    VectorRetriever,
    VectorCypherRetriever,
    HybridRetriever
)
from neo4j_graphrag.generation import GraphRAG
from neo4j_graphrag.llm import OpenAILLM

패키지의 핵심 컴포넌트를 살펴보자.

from neo4j import GraphDatabase
from neo4j_graphrag.retrievers import VectorCypherRetriever
from neo4j_graphrag.generation import GraphRAG
from neo4j_graphrag.llm import OpenAILLM

# Neo4j 연결
driver = GraphDatabase.driver(
    "bolt://localhost:7687",
    auth=("neo4j", "password")
)

# LLM 설정
llm = OpenAILLM(model_name="gpt-4o", api_key="...")

# 벡터 + Cypher 하이브리드 리트리버
retriever = VectorCypherRetriever(
    driver=driver,
    index_name="policy-embeddings",
    # 벡터 검색으로 찾은 노드에서 추가 그래프 탐색
    retrieval_query="""
        MATCH (node)-[:APPLIES_TO]->(target)
        OPTIONAL MATCH (node)-[:RELATED_TO]->(related)
        RETURN node.title AS title,
               node.content AS content,
               collect(DISTINCT target.name) AS appliesTo,
               collect(DISTINCT related.title) AS relatedPolicies,
               score
    """
)

# GraphRAG 파이프라인 구성
rag = GraphRAG(
    retriever=retriever,
    llm=llm
)

# 질문에 답하기
response = rag.search(
    query_text="육아휴직 중 연차 사용이 가능한가요?"
)
print(response.answer)

VectorCypherRetriever가 핵심이다. 벡터 검색으로 관련 문서를 찾은 뒤, retrieval_query에 정의된 Cypher를 실행해서 그 문서와 연결된 그래프 맥락까지 함께 가져온다. 벡터의 의미적 검색과 그래프의 구조적 탐색이 하나의 리트리버 안에서 결합되는 셈이다.

HR안내봇에 적합한 패턴 선택

그렇다면 우리 HR안내봇에는 어떤 조합이 맞을까? 1장에서 정의한 질문 시나리오 20개를 유형별로 분류해보면 답이 나온다.

세 가지 유형이 고르게 섞여 있으므로, Graph-Enhanced RAG를 기본으로 하되 GDS 메타데이터 리트리버를 추가하는 조합이 적합하다. 정형 질문에는 Text-to-Cypher를 보조로 쓰면 정확도를 높일 수 있다.

아키텍처를 정리하면 이렇다.

[사용자 질문]
    ↓
[쿼리 라우터 (LLM)]
    ├─ STRUCTURAL → Graph Retriever (Cypher)
    ├─ TEXTUAL → Vector Retriever (임베딩 검색)
    ├─ ANALYTICAL → GDS Metadata Retriever
    └─ HYBRID → Graph + Vector + GDS 통합
    ↓
[맥락 통합기]
    ↓
[LLM 답변 생성]
    ↓
[응답]

PoC 연결 테스트

본격적인 구현은 8장에서 하지만, 기본 연결이 되는지 PoC(Proof of Concept)를 확인해보자.

# PoC: Neo4j 연결 + LLM 연동 기본 테스트
from neo4j import GraphDatabase

driver = GraphDatabase.driver(
    "bolt://localhost:7687",
    auth=("neo4j", "password")
)

# 1. Neo4j 연결 확인
with driver.session() as session:
    result = session.run("MATCH (e:Employee) RETURN count(e) AS total")
    print(f"총 직원 수: {result.single()['total']}")

# 2. 간단한 그래프 탐색 테스트
with driver.session() as session:
    result = session.run("""
        MATCH (e:Employee {name: '김철수'})-[:REPORTS_TO]->(m:Employee)
        MATCH (m)-[:BELONGS_TO]->(d:Department)
        RETURN e.name AS employee, m.name AS manager, d.name AS department
    """)
    for record in result:
        print(f"{record['employee']}의 매니저: {record['manager']} ({record['department']})")

# 3. LLM 연동 테스트 (간단한 답변 생성)
from neo4j_graphrag.llm import OpenAILLM

llm = OpenAILLM(model_name="gpt-4o", api_key="...")
response = llm.invoke("""
다음 정보를 바탕으로 질문에 답하세요.

정보:
- 김철수는 인사팀 소속이다
- 김철수의 매니저는 박영수이다
- 인사팀에는 육아휴직 규정, 연차 사용 지침이 적용된다

질문: 김철수의 매니저가 관리하는 팀에 적용되는 정책은?
""")
print(response.content)

driver.close()

이 PoC가 성공적으로 돌아간다면, Neo4j에서 데이터를 가져오고 LLM으로 답변을 생성하는 기본 파이프라인이 작동하는 것이다. 8장에서는 이 기본 연결 위에 쿼리 라우터, 하이브리드 리트리버, 맥락 통합기를 얹어 완전한 HR안내봇을 만든다.

마무리

이번 장에서 우리는 LLM과 지식그래프를 연결하는 원리를 살펴보았다. 벡터 RAG의 한계를 인식하고, 4가지 통합 패턴을 비교했으며, HR안내봇에 적합한 Graph-Enhanced RAG + GDS 메타데이터 조합을 선택했다. 하이브리드 RAG 아키텍처를 설계하고, PoC로 기본 연결을 확인했다.

기억해두자. GraphRAG는 만능이 아니다. 벡터 RAG가 더 효율적인 질문도 분명히 있다. 핵심은 “질문의 성격에 따라 적절한 도구를 선택하는 것”이다. 쿼리 라우터가 바로 그 판단을 자동화하는 장치다.

다음 장이 이 책의 클라이맥스다. 지금까지 쌓아 온 모든 것 — 온톨로지, 지식그래프, GDS 분석, GraphRAG 아키텍처 — 을 하나로 조합해 실제로 동작하는 HR안내봇을 완성한다. 설계도만 그려왔던 여정이 드디어 동작하는 프로토타입으로 결실을 맺는 순간이다. 함께 만들어보자.


HR안내봇 진행도

항목 상태
GraphRAG 패턴 선택 완료 — Graph-Enhanced RAG + GDS 메타데이터 + Text-to-Cypher 보조
아키텍처 설계 완료 — 쿼리 라우터 + 하이브리드 리트리버 + 맥락 통합기
PoC 연결 완료 — Neo4j 연결, 그래프 탐색, LLM 답변 생성 확인
Neo4j GraphRAG 패키지 확인 — VectorCypherRetriever 활용 방안 결정
다음 단계 8장에서 전체 파이프라인 통합 구현

8장. HR안내봇 만들기 — 관통 프로젝트 통합 구현

드디어 이 순간이 왔다. 1장에서 “기존 방식으로는 왜 한계가 있는가”를 고민했던 그때부터, 2장에서 온톨로지 개념을 익히고, 3장에서 Neo4j에 첫 데이터를 넣고, 4장에서 온톨로지를 설계하고, 5장에서 지식그래프를 구축하고, 6장에서 그래프 알고리즘으로 인사이트를 발굴하고, 7장에서 GraphRAG 아키텍처를 설계했다. 일곱 개 장에 걸쳐 하나씩 쌓아 올린 벽돌들이 이번 장에서 하나의 건물이 된다.

당신이 HR 부서의 기술 리더라고 상상해보자. 경영진에게 “AI 기반 HR안내봇을 만들겠다”고 발표한 지 한 달이 지났다. 팀원들은 온톨로지도 설계했고, 지식그래프도 구축했고, 그래프 분석까지 돌려봤다. 이제 남은 건 단 하나 — 실제로 동작하는 봇을 보여주는 것이다. 오늘 그것을 만든다.

전체 아키텍처 — 지금까지의 모든 것을 하나로

먼저 전체 그림을 잡아보자. 각 장에서 만든 것들이 어떻게 맞물리는지 한눈에 보는 것이 중요하다.

┌─────────────────────────────────────────────────────┐
│                    HR안내봇 전체 아키텍처                │
├─────────────────────────────────────────────────────┤
│                                                     │
│  [사용자 질문]                                        │
│       ↓                                             │
│  ┌──────────────┐                                   │
│  │ 질문 분류기    │ ← LLM (질문 유형 판별)               │
│  └──────┬───────┘                                   │
│         ↓                                           │
│  ┌──────────────┐                                   │
│  │ 엔티티 추출기  │ ← LLM (질문에서 개체명 추출)          │
│  └──────┬───────┘                                   │
│         ↓                                           │
│  ┌──────────────────────────────────────┐           │
│  │         하이브리드 리트리버              │           │
│  │  ┌────────────┐  ┌────────────┐      │           │
│  │  │그래프 리트리버│  │벡터 리트리버 │      │           │
│  │  │ (5장 KG)   │  │(정책 문서)  │      │           │
│  │  └────────────┘  └────────────┘      │           │
│  │  ┌────────────┐  ┌────────────┐      │           │
│  │  │GDS 메타데이터│  │Text-to-    │      │           │
│  │  │ (6장 분석)  │  │Cypher 보조 │      │           │
│  │  └────────────┘  └────────────┘      │           │
│  └──────────────────┬───────────────────┘           │
│                     ↓                               │
│  ┌──────────────┐                                   │
│  │ 맥락 통합기    │ ← 수집된 정보 정리·중복 제거           │
│  └──────┬───────┘                                   │
│         ↓                                           │
│  ┌──────────────┐                                   │
│  │ 답변 생성기    │ ← LLM (맥락 기반 자연어 답변)         │
│  └──────┬───────┘                                   │
│         ↓                                           │
│  [사용자에게 응답]                                      │
│                                                     │
│  ─── 데이터 레이어 ───                                 │
│  Neo4j: 온톨로지(4장) + 지식그래프(5장) + GDS(6장)       │
│  벡터 인덱스: HR 정책 문서 임베딩                         │
└─────────────────────────────────────────────────────┘

복잡해 보이지만 본질은 단순하다. 질문이 들어오면 분류하고, 적절한 도구로 맥락을 수집하고, LLM이 답변을 만든다. 이 흐름을 파이썬 코드로 하나씩 구현해보자.

프로젝트 설정

먼저 필요한 패키지를 설치한다.

pip install neo4j neo4j-graphrag openai python-dotenv

프로젝트 구조는 이렇게 잡는다.

hr-chatbot/
├── config.py          # 설정 (Neo4j 연결, LLM API 키)
├── classifier.py      # 질문 분류기
├── entity_extractor.py # 엔티티 추출기
├── retrievers/
│   ├── graph_retriever.py
│   ├── vector_retriever.py
│   ├── gds_retriever.py
│   └── hybrid_retriever.py
├── context_builder.py  # 맥락 통합기
├── generator.py        # 답변 생성기
├── chatbot.py          # 메인 파이프라인
└── test_scenarios.py   # 시나리오 테스트

설정 파일

# config.py
import os
from dotenv import load_dotenv

load_dotenv()

NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")
NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o")

# 벡터 인덱스 이름 (5장에서 생성)
VECTOR_INDEX_NAME = "policy-embeddings"
# 임베딩 모델
EMBEDDING_MODEL = "text-embedding-3-small"

질문 분류기 — 어떤 도구로 답할 것인가

7장에서 설계한 쿼리 라우터를 구현한다. 질문이 들어오면 LLM이 유형을 판별한다.

# classifier.py
from openai import OpenAI
from config import OPENAI_API_KEY, OPENAI_MODEL

client = OpenAI(api_key=OPENAI_API_KEY)

CLASSIFICATION_PROMPT = """당신은 HR안내봇의 질문 분류기입니다.
사용자 질문을 다음 유형 중 하나로 분류하세요.

유형:
- STRUCTURAL: 조직 구조, 직원 정보, 부서 관계 등 그래프 데이터 조회
  예: "김철수의 매니저는?", "인사팀 소속 직원은?", "개발팀에 적용되는 정책은?"
- TEXTUAL: HR 정책 내용, 규정 해석, 절차 안내 등 문서 검색
  예: "육아휴직 신청 절차는?", "연차 소멸 규정 알려줘", "재택근무 조건은?"
- ANALYTICAL: 그래프 분석 결과 활용 (영향력, 커뮤니티, 유사도 등)
  예: "가장 영향력 있는 직원은?", "실제 협업 그룹은?", "비슷한 스킬 가진 사람은?"
- HYBRID: 위 유형이 2개 이상 결합된 복합 질문
  예: "육아휴직 중인 직원의 매니저가 알아야 할 정책은?"

반드시 유형 이름만 답하세요.

질문: {question}"""


def classify_question(question: str) -> str:
    """질문 유형을 분류하여 반환한다."""
    response = client.chat.completions.create(
        model=OPENAI_MODEL,
        messages=[
            {"role": "system", "content": "질문 유형만 정확히 답하세요."},
            {"role": "user", "content": CLASSIFICATION_PROMPT.format(question=question)}
        ],
        temperature=0,
        max_tokens=20
    )
    result = response.choices[0].message.content.strip().upper()

    valid_types = {"STRUCTURAL", "TEXTUAL", "ANALYTICAL", "HYBRID"}
    if result not in valid_types:
        return "HYBRID"  # 판별 불확실하면 HYBRID로 (모든 리트리버 동원)
    return result

분류가 불확실할 때 HYBRID로 폴백하는 것이 핵심이다. 잘못 분류해서 필요한 맥락을 빠뜨리는 것보다, 조금 과하게 수집하는 편이 낫다. 물론 HYBRID는 비용이 더 들지만, 사용자 경험 관점에서는 빈약한 답변보다 풍부한 답변이 낫다.

엔티티 추출기 — 질문에서 핵심 개체 뽑아내기

그래프 리트리버가 동작하려면 질문에서 엔티티를 먼저 추출해야 한다. “김철수의 매니저는?”에서 “김철수”를, “인사팀에 적용되는 정책은?”에서 “인사팀”을 뽑아내는 작업이다.

# entity_extractor.py
import json
from openai import OpenAI
from config import OPENAI_API_KEY, OPENAI_MODEL

client = OpenAI(api_key=OPENAI_API_KEY)

EXTRACTION_PROMPT = """다음 질문에서 HR 도메인 관련 개체(엔티티)를 추출하세요.

추출할 엔티티 유형:
- EMPLOYEE: 직원 이름 또는 사번
- DEPARTMENT: 부서 이름
- POLICY: 정책/규정 이름
- POSITION: 직급/직위
- BENEFIT: 복리후생 항목
- SKILL: 기술/스킬

JSON 형식으로 답하세요.
예시: {{"entities": [{{"type": "EMPLOYEE", "value": "김철수"}}, {{"type": "DEPARTMENT", "value": "인사팀"}}]}}

질문: {question}"""


def extract_entities(question: str) -> list:
    """질문에서 엔티티를 추출한다."""
    response = client.chat.completions.create(
        model=OPENAI_MODEL,
        messages=[
            {"role": "system", "content": "JSON으로만 답하세요."},
            {"role": "user", "content": EXTRACTION_PROMPT.format(question=question)}
        ],
        temperature=0,
        max_tokens=200,
        response_format={"type": "json_object"}
    )
    try:
        result = json.loads(response.choices[0].message.content)
        return result.get("entities", [])
    except (json.JSONDecodeError, KeyError):
        return []

response_format={"type": "json_object"}를 지정하면 LLM이 반드시 유효한 JSON을 반환한다. 파싱 실패를 줄이는 간단하지만 효과적인 방법이다. 그래도 만약의 경우를 대비해 try-except를 걸어두었다. 외부 API를 호출하는 코드에서 예외 처리를 빼먹으면 나중에 프로덕션에서 끔찍한 일이 벌어질 수 있다.

리트리버 구현 — 네 가지 도구

그래프 리트리버

지식그래프에서 엔티티 주변의 관계를 탐색한다. 5장에서 구축한 온톨로지 기반 지식그래프를 직접 조회한다.

# retrievers/graph_retriever.py
from neo4j import GraphDatabase
from config import NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD


class GraphRetriever:
    def __init__(self):
        self.driver = GraphDatabase.driver(
            NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)
        )

    def retrieve(self, entities: list, depth: int = 2) -> str:
        """엔티티 주변 N홉까지 관계를 탐색하여 텍스트 맥락으로 반환한다."""
        contexts = []

        with self.driver.session() as session:
            for entity in entities:
                entity_type = entity["type"]
                entity_value = entity["value"]

                if entity_type == "EMPLOYEE":
                    context = self._retrieve_employee(session, entity_value, depth)
                elif entity_type == "DEPARTMENT":
                    context = self._retrieve_department(session, entity_value)
                elif entity_type == "POLICY":
                    context = self._retrieve_policy(session, entity_value)
                else:
                    context = self._retrieve_generic(session, entity_value, depth)

                if context:
                    contexts.append(context)

        return "\n\n".join(contexts)

    def _retrieve_employee(self, session, name: str, depth: int) -> str:
        """직원 중심 서브그래프 탐색"""
        result = session.run("""
            MATCH (e:Employee)
            WHERE e.name = $name OR e.employeeId = $name
            OPTIONAL MATCH (e)-[:BELONGS_TO]->(d:Department)
            OPTIONAL MATCH (e)-[:HAS_POSITION]->(p:Position)
            OPTIONAL MATCH (e)-[:REPORTS_TO]->(m:Employee)
            OPTIONAL MATCH (e)-[:ENTITLED_TO]->(b:Benefit)
            OPTIONAL MATCH (e)-[:HAS_SKILL]->(s:Skill)
            OPTIONAL MATCH (d)<-[:APPLIES_TO]-(pol:Policy)
            RETURN e.name AS name,
                   e.employeeId AS id,
                   e.email AS email,
                   e.hireDate AS hireDate,
                   e.pageRank AS pageRank,
                   e.communityId AS communityId,
                   d.name AS department,
                   p.title AS position,
                   m.name AS manager,
                   collect(DISTINCT b.name) AS benefits,
                   collect(DISTINCT s.name) AS skills,
                   collect(DISTINCT pol.title) AS deptPolicies
        """, name=name)

        record = result.single()
        if not record:
            return ""

        lines = [f"직원 정보: {record['name']}"]
        if record["id"]:
            lines.append(f"  사번: {record['id']}")
        if record["department"]:
            lines.append(f"  소속: {record['department']}")
        if record["position"]:
            lines.append(f"  직급: {record['position']}")
        if record["manager"]:
            lines.append(f"  매니저: {record['manager']}")
        if record["email"]:
            lines.append(f"  이메일: {record['email']}")
        if record["hireDate"]:
            lines.append(f"  입사일: {record['hireDate']}")
        if record["benefits"]:
            lines.append(f"  복리후생: {', '.join(record['benefits'])}")
        if record["skills"]:
            lines.append(f"  스킬: {', '.join(record['skills'])}")
        if record["deptPolicies"]:
            lines.append(f"  부서 적용 정책: {', '.join(record['deptPolicies'])}")

        return "\n".join(lines)

    def _retrieve_department(self, session, name: str) -> str:
        """부서 중심 서브그래프 탐색"""
        result = session.run("""
            MATCH (d:Department)
            WHERE d.name = $name OR d.code = $name
            OPTIONAL MATCH (e:Employee)-[:BELONGS_TO]->(d)
            OPTIONAL MATCH (pol:Policy)-[:APPLIES_TO]->(d)
            RETURN d.name AS department,
                   d.code AS code,
                   collect(DISTINCT e.name) AS employees,
                   collect(DISTINCT pol.title) AS policies
        """, name=name)

        record = result.single()
        if not record:
            return ""

        lines = [f"부서 정보: {record['department']} ({record['code']})"]
        if record["employees"]:
            lines.append(f"  소속 직원: {', '.join(record['employees'][:10])}")
            if len(record["employees"]) > 10:
                lines.append(f"  (외 {len(record['employees']) - 10}명)")
        if record["policies"]:
            lines.append(f"  적용 정책: {', '.join(record['policies'])}")

        return "\n".join(lines)

    def _retrieve_policy(self, session, title: str) -> str:
        """정책 중심 서브그래프 탐색"""
        result = session.run("""
            MATCH (p:Policy)
            WHERE p.title CONTAINS $title
            OPTIONAL MATCH (p)-[:APPLIES_TO]->(target)
            OPTIONAL MATCH (p)-[:CATEGORIZED_AS]->(c:BenefitCategory)
            OPTIONAL MATCH (p)-[:RELATED_TO]->(related:Policy)
            RETURN p.title AS title,
                   p.description AS description,
                   p.effectiveDate AS effectiveDate,
                   c.name AS category,
                   collect(DISTINCT labels(target)[0] + ': ' + target.name) AS appliesTo,
                   collect(DISTINCT related.title) AS relatedPolicies
        """, title=title)

        record = result.single()
        if not record:
            return ""

        lines = [f"정책 정보: {record['title']}"]
        if record["description"]:
            lines.append(f"  설명: {record['description']}")
        if record["category"]:
            lines.append(f"  카테고리: {record['category']}")
        if record["effectiveDate"]:
            lines.append(f"  시행일: {record['effectiveDate']}")
        if record["appliesTo"]:
            lines.append(f"  적용 대상: {', '.join(record['appliesTo'])}")
        if record["relatedPolicies"]:
            lines.append(f"  관련 정책: {', '.join(record['relatedPolicies'])}")

        return "\n".join(lines)

    def _retrieve_generic(self, session, value: str, depth: int) -> str:
        """범용 검색 — 이름으로 아무 노드나 찾아서 주변 탐색"""
        result = session.run("""
            MATCH (n)
            WHERE n.name CONTAINS $value OR n.title CONTAINS $value
            WITH n LIMIT 3
            OPTIONAL MATCH (n)-[r]-(connected)
            RETURN labels(n) AS nodeLabels,
                   n.name AS nodeName,
                   type(r) AS relType,
                   labels(connected) AS connLabels,
                   connected.name AS connName
            LIMIT 20
        """, value=value)

        lines = []
        for record in result:
            node = record["nodeName"] or "unknown"
            rel = record["relType"] or "관련"
            conn = record["connName"] or "unknown"
            lines.append(f"  {node} --[{rel}]--> {conn}")

        return "\n".join(lines) if lines else ""

    def close(self):
        self.driver.close()

_retrieve_employee 메서드를 자세히 살펴보자. 한 번의 Cypher 쿼리로 직원의 부서, 직급, 매니저, 복리후생, 스킬, 그리고 부서에 적용되는 정책까지 모두 가져온다. 7장에서 설계한 “엔티티 주변 N홉 탐색”을 하나의 OPTIONAL MATCH 체인으로 구현한 것이다. OPTIONAL MATCH를 쓴 이유는, 연결이 없는 경우에도 쿼리 자체가 실패하지 않도록 하기 위해서다.

벡터 리트리버

정책 문서의 텍스트 내용을 검색한다. Neo4j의 벡터 인덱스를 활용한다.

# retrievers/vector_retriever.py
from neo4j import GraphDatabase
from openai import OpenAI
from config import (
    NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD,
    OPENAI_API_KEY, VECTOR_INDEX_NAME, EMBEDDING_MODEL
)


class VectorRetriever:
    def __init__(self):
        self.driver = GraphDatabase.driver(
            NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)
        )
        self.openai = OpenAI(api_key=OPENAI_API_KEY)

    def retrieve(self, question: str, top_k: int = 5) -> str:
        """벡터 유사도 기반으로 관련 정책 문서 청크를 검색한다."""
        question_embedding = self._embed(question)

        with self.driver.session() as session:
            result = session.run("""
                CALL db.index.vector.queryNodes(
                    $index_name,
                    $top_k,
                    $embedding
                )
                YIELD node, score
                RETURN node.title AS title,
                       node.content AS content,
                       node.chunkId AS chunkId,
                       score
                ORDER BY score DESC
            """, index_name=VECTOR_INDEX_NAME,
                top_k=top_k,
                embedding=question_embedding)

            chunks = []
            for record in result:
                chunks.append(
                    f"[{record['title']}] (유사도: {record['score']:.3f})\n"
                    f"{record['content']}"
                )

            return "\n\n---\n\n".join(chunks) if chunks else ""

    def _embed(self, text: str) -> list:
        """텍스트를 벡터 임베딩으로 변환한다."""
        response = self.openai.embeddings.create(
            model=EMBEDDING_MODEL,
            input=text
        )
        return response.data[0].embedding

    def close(self):
        self.driver.close()

벡터 리트리버가 동작하려면, 사전에 정책 문서를 청크로 나누고 임베딩을 생성해서 Neo4j에 저장해두어야 한다. 이 준비 작업을 살펴보자.

# 정책 문서 임베딩 사전 준비 (한 번만 실행)
def prepare_policy_embeddings(driver, openai_client):
    """정책 문서를 청크로 나누고 벡터 인덱스에 저장한다."""

    # 1. 벡터 인덱스 생성
    with driver.session() as session:
        session.run("""
            CREATE VECTOR INDEX `policy-embeddings` IF NOT EXISTS
            FOR (c:PolicyChunk)
            ON (c.embedding)
            OPTIONS {
                indexConfig: {
                    `vector.dimensions`: 1536,
                    `vector.similarity_function`: 'cosine'
                }
            }
        """)

    # 2. 정책 문서를 청크로 분할하고 임베딩 생성
    with driver.session() as session:
        policies = session.run("""
            MATCH (p:Policy)
            WHERE p.fullText IS NOT NULL
            RETURN p.policyId AS id, p.title AS title, p.fullText AS text
        """)

        for policy in policies:
            chunks = split_into_chunks(policy["text"], chunk_size=500, overlap=50)

            for i, chunk in enumerate(chunks):
                embedding = openai_client.embeddings.create(
                    model="text-embedding-3-small",
                    input=chunk
                ).data[0].embedding

                session.run("""
                    CREATE (c:PolicyChunk {
                        chunkId: $chunk_id,
                        title: $title,
                        content: $content,
                        embedding: $embedding
                    })
                    WITH c
                    MATCH (p:Policy {policyId: $policy_id})
                    CREATE (c)-[:CHUNK_OF]->(p)
                """, chunk_id=f"{policy['id']}_chunk_{i}",
                    title=policy["title"],
                    content=chunk,
                    embedding=embedding,
                    policy_id=policy["id"])


def split_into_chunks(text: str, chunk_size: int = 500, overlap: int = 50) -> list:
    """텍스트를 겹침이 있는 청크로 분할한다."""
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start = end - overlap
    return chunks

정책 문서 청크(PolicyChunk)를 원래 정책 노드(Policy)에 CHUNK_OF 관계로 연결한 부분이 중요하다. 이렇게 하면 벡터 검색으로 관련 청크를 찾은 뒤, 그 청크가 속한 정책 노드로 올라가서 구조적 맥락(적용 대상, 관련 정책 등)까지 함께 가져올 수 있다. 벡터와 그래프가 자연스럽게 연결되는 지점이다.

GDS 메타데이터 리트리버

6장에서 Write-Back한 분석 결과를 조회한다.

# retrievers/gds_retriever.py
from neo4j import GraphDatabase
from config import NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD


class GDSMetadataRetriever:
    def __init__(self):
        self.driver = GraphDatabase.driver(
            NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)
        )

    def retrieve(self, query_intent: str, params: dict = None) -> str:
        """GDS 분석 결과(Write-Back 속성)를 조회한다."""
        params = params or {}

        handlers = {
            "key_person": self._key_person,
            "community": self._community,
            "similar_skills": self._similar_skills,
            "bridge_person": self._bridge_person,
        }

        handler = handlers.get(query_intent)
        if handler:
            return handler(params)
        return ""

    def _key_person(self, params: dict) -> str:
        """특정 부서 또는 전체 조직에서 영향력 높은 인물 조회"""
        with self.driver.session() as session:
            if "department" in params:
                result = session.run("""
                    MATCH (e:Employee)-[:BELONGS_TO]->(d:Department {name: $dept})
                    WHERE e.pageRank IS NOT NULL
                    RETURN e.name AS name,
                           e.pageRank AS influence,
                           e.betweenness AS bridging,
                           d.name AS department
                    ORDER BY e.pageRank DESC
                    LIMIT 5
                """, dept=params["department"])
            else:
                result = session.run("""
                    MATCH (e:Employee)
                    WHERE e.pageRank IS NOT NULL
                    RETURN e.name AS name,
                           e.pageRank AS influence,
                           e.betweenness AS bridging
                    ORDER BY e.pageRank DESC
                    LIMIT 5
                """)

            lines = ["영향력 상위 직원:"]
            for record in result:
                name = record["name"]
                influence = round(record["influence"], 4)
                bridging = round(record["bridging"], 2) if record["bridging"] else "N/A"
                lines.append(f"  {name}: 영향력 {influence}, 매개 중심성 {bridging}")

            return "\n".join(lines) if len(lines) > 1 else ""

    def _community(self, params: dict) -> str:
        """커뮤니티 소속 직원 조회"""
        with self.driver.session() as session:
            if "employee_name" in params:
                # 특정 직원과 같은 커뮤니티 멤버 조회
                result = session.run("""
                    MATCH (target:Employee {name: $name})
                    WITH target.communityId AS cid
                    MATCH (e:Employee {communityId: cid})
                    OPTIONAL MATCH (e)-[:BELONGS_TO]->(d:Department)
                    RETURN e.name AS name, d.name AS department, cid AS communityId
                    ORDER BY e.name
                """, name=params["employee_name"])
            else:
                # 전체 커뮤니티 요약
                result = session.run("""
                    MATCH (e:Employee)
                    WHERE e.communityId IS NOT NULL
                    OPTIONAL MATCH (e)-[:BELONGS_TO]->(d:Department)
                    RETURN e.communityId AS communityId,
                           collect(e.name) AS members,
                           collect(DISTINCT d.name) AS departments,
                           count(e) AS size
                    ORDER BY size DESC
                """)

            lines = ["커뮤니티 분석 결과:"]
            for record in result:
                if "communityId" in record.keys() and "members" in record.keys():
                    lines.append(
                        f"  커뮤니티 {record['communityId']}: "
                        f"{', '.join(record['members'][:5])} "
                        f"(부서: {', '.join(record['departments'])})"
                    )
                else:
                    lines.append(
                        f"  {record['name']} ({record['department']})"
                    )

            return "\n".join(lines) if len(lines) > 1 else ""

    def _similar_skills(self, params: dict) -> str:
        """스킬 유사도 기반 직원 매칭"""
        with self.driver.session() as session:
            result = session.run("""
                MATCH (e:Employee {name: $name})-[:HAS_SKILL]->(s:Skill)
                       <-[:HAS_SKILL]-(similar:Employee)
                WHERE similar <> e
                WITH similar, collect(s.name) AS sharedSkills, count(s) AS cnt
                RETURN similar.name AS name,
                       sharedSkills,
                       cnt AS sharedCount
                ORDER BY cnt DESC
                LIMIT 5
            """, name=params.get("employee_name", ""))

            lines = [f"'{params.get('employee_name', '')}' 님과 스킬이 유사한 직원:"]
            for record in result:
                lines.append(
                    f"  {record['name']}: "
                    f"공통 스킬 {record['sharedCount']}개 "
                    f"({', '.join(record['sharedSkills'])})"
                )

            return "\n".join(lines) if len(lines) > 1 else ""

    def _bridge_person(self, params: dict) -> str:
        """매개 중심성(Betweenness) 높은 인물 — 정보 흐름의 다리"""
        with self.driver.session() as session:
            result = session.run("""
                MATCH (e:Employee)
                WHERE e.betweenness IS NOT NULL
                OPTIONAL MATCH (e)-[:BELONGS_TO]->(d:Department)
                RETURN e.name AS name,
                       e.betweenness AS betweenness,
                       d.name AS department
                ORDER BY e.betweenness DESC
                LIMIT 5
            """)

            lines = ["정보 흐름 핵심 인물 (매개 중심성 상위):"]
            for record in result:
                lines.append(
                    f"  {record['name']} ({record['department']}): "
                    f"매개 중심성 {round(record['betweenness'], 2)}"
                )

            return "\n".join(lines) if len(lines) > 1 else ""

    def close(self):
        self.driver.close()

하이브리드 리트리버 — 네 도구의 지휘자

질문 유형에 따라 적절한 리트리버를 조합해서 호출한다.

# retrievers/hybrid_retriever.py
from retrievers.graph_retriever import GraphRetriever
from retrievers.vector_retriever import VectorRetriever
from retrievers.gds_retriever import GDSMetadataRetriever


class HybridRetriever:
    def __init__(self):
        self.graph = GraphRetriever()
        self.vector = VectorRetriever()
        self.gds = GDSMetadataRetriever()

    def retrieve(self, question: str, question_type: str,
                 entities: list, gds_intent: str = None,
                 gds_params: dict = None) -> dict:
        """질문 유형에 따라 적절한 리트리버를 조합하여 맥락을 수집한다."""
        context = {
            "graph": "",
            "vector": "",
            "gds": ""
        }

        if question_type == "STRUCTURAL":
            context["graph"] = self.graph.retrieve(entities)

        elif question_type == "TEXTUAL":
            context["vector"] = self.vector.retrieve(question)

        elif question_type == "ANALYTICAL":
            if gds_intent:
                context["gds"] = self.gds.retrieve(gds_intent, gds_params)
            # 분석 질문이라도 그래프 맥락이 있으면 보충
            if entities:
                context["graph"] = self.graph.retrieve(entities)

        elif question_type == "HYBRID":
            # 모든 리트리버 동원
            if entities:
                context["graph"] = self.graph.retrieve(entities)
            context["vector"] = self.vector.retrieve(question)
            if gds_intent:
                context["gds"] = self.gds.retrieve(gds_intent, gds_params)

        return context

    def close(self):
        self.graph.close()
        self.vector.close()
        self.gds.close()

HYBRID 유형일 때 모든 리트리버를 동원하는 것이 보이는가? 비용은 좀 더 들지만, 복합 질문에 대해 빈약한 답변을 내놓는 것보다 낫다. 실무에서는 비용과 품질의 균형을 잡기 위해, 자주 들어오는 질문 유형의 비율을 모니터링하고 리트리버 호출 전략을 조정하는 편이 바람직하다.

맥락 통합기 — 수집된 정보를 정리하기

여러 리트리버에서 가져온 맥락을 하나의 깔끔한 텍스트로 만든다.

# context_builder.py

def build_context(retrieval_result: dict) -> str:
    """리트리버들의 결과를 하나의 맥락 문자열로 통합한다."""
    sections = []

    if retrieval_result.get("graph"):
        sections.append(
            "=== 조직 구조 정보 (지식그래프) ===\n"
            + retrieval_result["graph"]
        )

    if retrieval_result.get("vector"):
        sections.append(
            "=== 관련 정책 문서 ===\n"
            + retrieval_result["vector"]
        )

    if retrieval_result.get("gds"):
        sections.append(
            "=== 그래프 분석 인사이트 ===\n"
            + retrieval_result["gds"]
        )

    if not sections:
        return "관련 정보를 찾지 못했습니다."

    return "\n\n".join(sections)

단순해 보이지만, 섹션을 명확하게 구분해주는 것이 LLM의 답변 품질에 의미 있는 영향을 준다. LLM이 “이 정보는 조직 구조에서 왔고, 저 정보는 정책 문서에서 왔다”는 것을 인지하면, 답변에서 정보의 출처를 더 정확하게 구분한다.

답변 생성기 — LLM이 최종 답변을 만든다

수집된 맥락을 바탕으로 자연어 답변을 생성한다.

# generator.py
from openai import OpenAI
from config import OPENAI_API_KEY, OPENAI_MODEL

client = OpenAI(api_key=OPENAI_API_KEY)

GENERATION_PROMPT = """당신은 기업의 HR안내봇입니다.
아래 제공된 정보를 바탕으로 질문에 정확하고 친절하게 답변하세요.

규칙:
1. 제공된 정보에 없는 내용은 추측하지 마세요. "해당 정보를 찾지 못했습니다"라고 안내하세요.
2. 직원 이름, 부서명, 정책명 등 고유명사는 정확하게 사용하세요.
3. 관련 정책이 있으면 정책 이름과 핵심 내용을 함께 안내하세요.
4. 추가로 확인이 필요한 사항이 있으면 안내하세요.
5. 답변은 한국어로, 존댓말을 사용하세요.

제공된 정보:
{context}

질문: {question}

답변:"""


def generate_answer(question: str, context: str) -> str:
    """맥락 기반으로 자연어 답변을 생성한다."""
    response = client.chat.completions.create(
        model=OPENAI_MODEL,
        messages=[
            {
                "role": "system",
                "content": "당신은 정확하고 친절한 HR안내봇입니다."
            },
            {
                "role": "user",
                "content": GENERATION_PROMPT.format(
                    context=context,
                    question=question
                )
            }
        ],
        temperature=0.3,
        max_tokens=1000
    )
    return response.choices[0].message.content

temperature를 0.3으로 낮게 설정한 것에 주목하자. HR안내봇은 창의적 답변보다 정확한 답변이 중요하다. 정책 해석을 창의적으로 하면 난감한 결과가 나올 수 있다. 단, 너무 0에 가까우면 답변이 딱딱해지므로, 0.2~0.4 사이에서 테스트하며 조정하는 게 좋다.

메인 파이프라인 — 모든 것의 조합

이제 모든 컴포넌트를 하나로 엮는다.

# chatbot.py
from classifier import classify_question
from entity_extractor import extract_entities
from retrievers.hybrid_retriever import HybridRetriever
from context_builder import build_context
from generator import generate_answer


class HRChatbot:
    def __init__(self):
        self.retriever = HybridRetriever()

    def answer(self, question: str) -> dict:
        """사용자 질문에 대한 전체 파이프라인을 실행한다."""

        # 1단계: 질문 분류
        question_type = classify_question(question)

        # 2단계: 엔티티 추출
        entities = extract_entities(question)

        # 3단계: GDS 의도 판별 (분석 질문인 경우)
        gds_intent, gds_params = self._detect_gds_intent(
            question, question_type, entities
        )

        # 4단계: 하이브리드 리트리버로 맥락 수집
        retrieval_result = self.retriever.retrieve(
            question=question,
            question_type=question_type,
            entities=entities,
            gds_intent=gds_intent,
            gds_params=gds_params
        )

        # 5단계: 맥락 통합
        context = build_context(retrieval_result)

        # 6단계: 답변 생성
        answer = generate_answer(question, context)

        return {
            "answer": answer,
            "question_type": question_type,
            "entities": entities,
            "context_sources": {
                k: bool(v) for k, v in retrieval_result.items()
            }
        }

    def _detect_gds_intent(self, question: str, q_type: str,
                           entities: list) -> tuple:
        """분석 관련 질문의 GDS 의도를 판별한다."""
        if q_type not in ("ANALYTICAL", "HYBRID"):
            return None, None

        question_lower = question.lower()

        # 키워드 기반 간단 판별 (프로덕션에서는 LLM 분류 권장)
        if any(kw in question_lower for kw in ["영향력", "핵심 인물", "중요한 사람", "key person"]):
            dept = next(
                (e["value"] for e in entities if e["type"] == "DEPARTMENT"),
                None
            )
            return "key_person", {"department": dept} if dept else {}

        if any(kw in question_lower for kw in ["커뮤니티", "협업 그룹", "비공식"]):
            emp = next(
                (e["value"] for e in entities if e["type"] == "EMPLOYEE"),
                None
            )
            return "community", {"employee_name": emp} if emp else {}

        if any(kw in question_lower for kw in ["유사", "비슷한 스킬", "스킬 매칭"]):
            emp = next(
                (e["value"] for e in entities if e["type"] == "EMPLOYEE"),
                None
            )
            if emp:
                return "similar_skills", {"employee_name": emp}

        if any(kw in question_lower for kw in ["다리 역할", "매개", "정보 흐름"]):
            return "bridge_person", {}

        return None, None

    def close(self):
        self.retriever.close()


# 실행
if __name__ == "__main__":
    bot = HRChatbot()

    questions = [
        "김철수의 매니저는 누구인가요?",
        "육아휴직 신청 절차를 알려주세요.",
        "우리 조직에서 가장 영향력 있는 직원은 누구인가요?",
        "육아휴직 중인 직원의 매니저가 알아야 할 관련 정책은 무엇인가요?",
    ]

    for q in questions:
        print(f"\n질문: {q}")
        result = bot.answer(q)
        print(f"유형: {result['question_type']}")
        print(f"엔티티: {result['entities']}")
        print(f"맥락 출처: {result['context_sources']}")
        print(f"답변: {result['answer']}")
        print("-" * 60)

    bot.close()

6단계 파이프라인이 완성되었다. 질문이 들어오면 분류하고, 엔티티를 뽑고, GDS 의도를 판별하고, 하이브리드 리트리버로 맥락을 모으고, 통합하고, 답변을 생성한다. 반환값에 question_type, entities, context_sources를 포함시킨 것은 디버깅과 품질 분석을 위해서다. 봇이 엉뚱한 답을 내놓을 때, 어디서 문제가 생겼는지 추적하려면 이 메타데이터가 필수다.

엔드투엔드 시나리오 테스트

1장에서 정의한 20개 질문 시나리오로 봇을 검증해보자. 질문 유형별로 대표적인 시나리오를 뽑아 테스트한다.

# test_scenarios.py
from chatbot import HRChatbot

SCENARIOS = [
    # 구조적 질문
    {"question": "김철수는 어느 부서 소속인가요?",
     "expected_type": "STRUCTURAL",
     "expected_keywords": ["인사팀"]},

    {"question": "인사팀 소속 직원 목록을 알려주세요.",
     "expected_type": "STRUCTURAL",
     "expected_keywords": ["김철수"]},

    {"question": "김철수의 매니저는 누구이며, 그 매니저가 관리하는 부서는?",
     "expected_type": "STRUCTURAL",
     "expected_keywords": ["매니저"]},

    {"question": "개발팀에 적용되는 정책은 무엇인가요?",
     "expected_type": "STRUCTURAL",
     "expected_keywords": ["정책"]},

    # 텍스트 질문
    {"question": "육아휴직 신청 절차를 알려주세요.",
     "expected_type": "TEXTUAL",
     "expected_keywords": ["육아휴직", "신청"]},

    {"question": "연차 소멸 방지 규정은 어떻게 되나요?",
     "expected_type": "TEXTUAL",
     "expected_keywords": ["연차"]},

    {"question": "재택근무는 어떤 조건에서 가능한가요?",
     "expected_type": "TEXTUAL",
     "expected_keywords": ["재택근무"]},

    # 분석 질문
    {"question": "우리 조직에서 가장 영향력 있는 직원 3명은?",
     "expected_type": "ANALYTICAL",
     "expected_keywords": ["영향력"]},

    {"question": "인사팀과 실제로 긴밀하게 협업하는 비공식 그룹은?",
     "expected_type": "ANALYTICAL",
     "expected_keywords": ["커뮤니티", "협업"]},

    {"question": "박지민 선임과 스킬이 비슷한 직원을 추천해주세요.",
     "expected_type": "ANALYTICAL",
     "expected_keywords": ["스킬", "유사"]},

    # 복합 질문
    {"question": "육아휴직 중인 직원의 매니저가 알아야 할 관련 정책은?",
     "expected_type": "HYBRID",
     "expected_keywords": ["육아휴직", "매니저", "정책"]},

    {"question": "김철수의 매니저는 누구이며, 그 매니저가 관리하는 정책 중 복리후생 관련은?",
     "expected_type": "HYBRID",
     "expected_keywords": ["매니저", "복리후생"]},

    {"question": "개발팀의 핵심 인물이 퇴사하면 어떤 정책적 영향이 있을까요?",
     "expected_type": "HYBRID",
     "expected_keywords": ["핵심 인물", "개발팀"]},
]


def run_tests():
    """시나리오 테스트를 실행하고 결과를 보고한다."""
    bot = HRChatbot()
    results = []

    for i, scenario in enumerate(SCENARIOS, 1):
        question = scenario["question"]
        expected_type = scenario["expected_type"]
        expected_keywords = scenario["expected_keywords"]

        print(f"\n{'='*60}")
        print(f"시나리오 {i}: {question}")
        print(f"기대 유형: {expected_type}")

        result = bot.answer(question)

        # 유형 분류 정확도
        type_correct = result["question_type"] == expected_type

        # 답변에 핵심 키워드 포함 여부
        answer_lower = result["answer"].lower()
        keyword_hits = sum(
            1 for kw in expected_keywords
            if kw.lower() in answer_lower
        )
        keyword_score = keyword_hits / len(expected_keywords)

        status = "PASS" if type_correct and keyword_score >= 0.5 else "FAIL"

        print(f"실제 유형: {result['question_type']} {'(O)' if type_correct else '(X)'}")
        print(f"키워드 매칭: {keyword_hits}/{len(expected_keywords)}")
        print(f"답변 (앞 200자): {result['answer'][:200]}...")
        print(f"판정: {status}")

        results.append({
            "scenario": i,
            "status": status,
            "type_correct": type_correct,
            "keyword_score": keyword_score
        })

    # 종합 보고
    print(f"\n{'='*60}")
    print("종합 결과")
    print(f"{'='*60}")
    total = len(results)
    passed = sum(1 for r in results if r["status"] == "PASS")
    type_accuracy = sum(1 for r in results if r["type_correct"]) / total
    avg_keyword = sum(r["keyword_score"] for r in results) / total

    print(f"통과: {passed}/{total} ({passed/total*100:.1f}%)")
    print(f"유형 분류 정확도: {type_accuracy*100:.1f}%")
    print(f"키워드 매칭 평균: {avg_keyword*100:.1f}%")

    bot.close()


if __name__ == "__main__":
    run_tests()

이 테스트는 완벽한 자동 평가는 아니다. 키워드 매칭은 답변 품질을 대략적으로만 측정한다. 프로덕션에서는 LLM을 평가자로 쓰는 “LLM-as-Judge” 방식이나, 사람이 직접 평가하는 골드 셋 방식을 병행하는 편이 낫다. 하지만 프로토타입 단계에서 빠르게 문제를 잡아내기에는 이 정도로도 충분하다.

시나리오별 응답 품질 분석

테스트를 돌려보면 몇 가지 패턴이 보일 것이다.

잘 동작하는 경우: - 단일 엔티티 구조적 질문 (“김철수의 부서는?”) — 엔티티 추출이 쉽고, 그래프 탐색 결과가 명확하다 - 단일 정책 텍스트 질문 (“육아휴직 신청 절차는?”) — 벡터 검색이 관련 문서를 잘 찾는다

개선이 필요한 경우: - 멀티홉 복합 질문 — 질문에 포함된 엔티티가 많을수록 그래프 탐색 범위가 넓어지고, 맥락이 너무 길어져 LLM이 핵심을 놓칠 수 있다. 맥락에 관련도 점수를 매겨 상위 N개만 전달하는 전략이 필요하다. - GDS 분석 결과 해석 — “핵심 인물이 퇴사하면 어떤 영향이 있을까?”처럼 데이터를 해석하는 질문은, 분석 결과와 정책 맥락을 함께 제공해야 의미 있는 답변이 나온다.

기술 리더 의사결정 박스: 프로토타입에서 어디까지 검증할 것인가?

검증 항목 MVP 기준선 측정 방법
질문 분류 정확도 80% 이상 테스트 시나리오 20개 기준 유형 일치율
답변 관련성 70% 이상 키워드 매칭 + 사람 평가 샘플링
멀티홉 정확도 60% 이상 2홉 이상 질문 중 정확한 관계 추론 비율
응답 시간 5초 이내 질문~답변 종단 시간
할루시네이션 5% 미만 존재하지 않는 정보를 생성한 비율

Go/No-Go 판단: - 위 기준 4개 이상 충족 → Go — 다음 단계(9장 품질 관리, 10장 프로덕션)로 진행 - 2~3개 충족 → 조건부 Go — 미충족 항목 집중 개선 후 재검증 - 1개 이하 → No-Go — 아키텍처 또는 데이터 품질 근본적 재검토

프로토타입 단계에서 완벽을 추구할 필요는 없다. 핵심은 “이 접근 방식이 작동하는가”를 입증하는 것이다. 수치가 낮더라도 개선 방향이 명확하다면 그것 자체가 프로토타입의 성과다.

실제 동작 확인

코드가 완성되었으니, 실제로 몇 가지 질문을 던져보자.

bot = HRChatbot()

# 시나리오 1: 구조적 질문
result = bot.answer("김철수의 매니저는 누구이며, 그 매니저가 관리하는 정책은?")
print(result["answer"])
# 예상 답변:
# "김철수님의 매니저는 박영수님입니다. 박영수님이 관리하는 인사팀에는
#  육아휴직 규정, 연차 사용 지침, 성과평가 정책이 적용되고 있습니다."

# 시나리오 2: 분석 질문
result = bot.answer("우리 조직에서 가장 영향력 있는 직원 3명을 알려주세요.")
print(result["answer"])
# 예상 답변:
# "그래프 분석 결과, 조직 내 영향력(PageRank) 상위 3명은 다음과 같습니다:
#  1. 박영수 (영향력: 0.4521, 인사팀)
#  2. 최재훈 (영향력: 0.3892, 개발팀)
#  3. 이미경 (영향력: 0.3156, 기획팀)"

# 시나리오 3: 복합 질문 (클라이맥스!)
result = bot.answer(
    "육아휴직 중인 직원의 매니저가 알아야 할 관련 정책은 무엇이며, "
    "해당 매니저가 조직 내에서 얼마나 중요한 위치에 있는지도 알려주세요."
)
print(result["answer"])
# 예상 답변:
# "육아휴직 관련하여 매니저가 알아야 할 정책은 다음과 같습니다:
#  1. 육아휴직 규정: 근속 1년 이상 정규직 대상, 최대 1년 ...
#  2. 연차 사용 지침: 휴직 중 연차 소멸 방지 조항 ...
#  3. 업무 인수인계 가이드: ...
#
#  참고로, 해당 매니저의 조직 내 위치를 분석한 결과:
#  - 영향력(PageRank): 0.4521 (상위 5% 이내)
#  - 매개 중심성: 48.3 (부서 간 정보 흐름에서 핵심 역할)
#  이 매니저가 부재할 경우 정보 전달에 영향이 있을 수 있으니,
#  대행자 지정을 사전에 해두시는 것이 좋겠습니다."

bot.close()

세 번째 시나리오가 이 책의 클라이맥스다. 하나의 질문에 대해 정책 문서 검색(벡터 리트리버), 조직 구조 탐색(그래프 리트리버), 그래프 분석 결과 활용(GDS 리트리버)이 모두 동작한다. 1장부터 7장까지 쌓아 온 모든 요소가 한 번의 질문에 결합되어 답변을 만들어낸다.

이 답변이 벡터 RAG만으로 가능했을까? 불가능하다. 매니저 관계는 그래프 탐색 없이 알 수 없고, 매니저의 조직 내 중요도는 GDS 분석 없이 알 수 없다. 정책 내용은 벡터 검색 없이 가져올 수 없다. 세 가지가 결합되어야 비로소 완전한 답변이 나온다. 이것이 온톨로지 기반 지식그래프 시스템의 힘이다.

마무리

이번 장에서 우리는 드디어 HR안내봇을 완성했다. 질문 분류기, 엔티티 추출기, 네 종류의 리트리버, 맥락 통합기, 답변 생성기 — 여섯 개의 컴포넌트가 파이프라인으로 연결되어, 구조적 질문, 텍스트 질문, 분석 질문, 복합 질문에 모두 대응하는 봇이 만들어졌다.

기억해두자. 이 프로토타입은 시작이지 끝이 아니다. 분류기의 정확도를 높여야 하고, 엔티티 추출의 견고함을 강화해야 하고, 맥락 길이를 최적화해야 하고, 답변의 할루시네이션을 줄여야 한다. 하지만 중요한 것은, “이 접근 방식이 작동한다”는 것을 우리가 직접 확인했다는 사실이다.

다음 장에서는 이 프로토타입의 품질을 체계적으로 관리하는 방법을 다룬다. 온톨로지가 변경되면 어떻게 대응하는지, 답변 정확도를 어떻게 지속적으로 측정하는지, 지식그래프를 어떻게 진화시키는지 — 만든 것을 유지하고 키우는 이야기다.


HR안내봇 진행도

항목 상태
질문 분류기 완료 — LLM 기반 4유형 분류 (STRUCTURAL, TEXTUAL, ANALYTICAL, HYBRID)
엔티티 추출기 완료 — LLM 기반 6유형 엔티티 추출 (JSON 출력)
그래프 리트리버 완료 — 직원/부서/정책별 특화 쿼리 + 범용 탐색
벡터 리트리버 완료 — Neo4j 벡터 인덱스 + OpenAI 임베딩
GDS 메타데이터 리트리버 완료 — PageRank, Betweenness, Community, Skill Similarity 조회
하이브리드 리트리버 완료 — 유형별 리트리버 조합 전략
맥락 통합기 완료 — 섹션 구분 + 중복 제거
답변 생성기 완료 — LLM 기반 자연어 답변 (temperature 0.3)
시나리오 테스트 완료 — 13개 시나리오 (구조적 4, 텍스트 3, 분석 3, 복합 3)
프로토타입 수준 동작 확인 — 멀티홉 + GDS + 벡터 통합 답변 생성 성공
다음 단계 9장 품질 관리, 10장 프로덕션

9장. 지식그래프 품질 관리 — 테스트, 검증, 진화

8장에서 HR안내봇 프로토타입이 동작하는 것을 확인한 순간, 뿌듯함이 밀려온다. 20개 시나리오 대부분에서 그럴듯한 답변이 나온다. “육아휴직 중 연차는 어떻게 되나요?”라는 질문에 온톨로지 기반 그래프를 탐색해 정확한 답을 내놓는 모습을 보면, 프로젝트가 거의 끝난 것 같다.

그런데 한 달 뒤를 상상해보자. 인사팀에서 재택근무 정책을 새로 발표했다. 조직 개편으로 부서 세 개가 합쳐졌다. 퇴직한 팀장의 보고 관계가 그래프에 그대로 남아 있다. 누군가 “재택근무 신청은 어떻게 하나요?”라고 물었는데, HR안내봇이 멍하니 “해당 정책을 찾을 수 없습니다”라고 답한다. 난감한 상황이다.

지식그래프는 만드는 것보다 유지하는 것이 더 어렵다. 데이터는 매일 변하고, 도메인 규칙은 분기마다 바뀌며, 온톨로지 자체도 진화해야 한다. 이번 장에서는 “만들고 끝”이 아니라 “지속적으로 운영”하기 위한 품질 관리 체계를 살펴보자.

온톨로지 검증 — 추론기가 잡아주는 논리적 모순

지식그래프의 품질은 그 뼈대인 온톨로지의 건전성에서 출발한다. 4장에서 Protege로 온톨로지를 설계할 때 클래스 계층, 관계, 제약 조건을 정의했다. 그런데 설계가 논리적으로 일관된지 어떻게 확인할 수 있을까? 클래스가 12개이고 관계가 15개일 때는 사람 눈으로도 확인할 수 있다. 하지만 프로젝트가 성장하면서 클래스가 50개, 관계가 100개를 넘어가면 사정이 달라진다. 서로 모순되는 제약 조건이 슬며시 들어와도 알아채기 어렵다.

여기서 추론기(Reasoner)가 등장한다. OWL 온톨로지에 내장된 논리 규칙을 기반으로, 추론기는 명시적으로 선언하지 않은 사실을 유추하고, 모순이 있으면 경고를 띄운다. Protege에 통합된 HermiT이나 Pellet 같은 추론기를 실행하면 다음과 같은 검증이 자동으로 이뤄진다.

일관성 검사(Consistency Check)는 가장 기본이다. “모든 직원은 정확히 하나의 부서에 소속된다”는 공리를 정의했는데, 어떤 직원이 부서 없이 존재하거나 두 부서에 동시 소속되어 있다면 추론기가 불일치를 감지한다. 이런 오류를 사람이 수천 개 노드를 눈으로 훑으며 찾아야 한다고 상상해보자. 끔찍한 일이다.

분류 추론(Classification)은 클래스 계층의 숨겨진 관계를 드러낸다. “정규직”과 “재택근무가능직원”이라는 두 클래스를 정의했을 때, 정규직의 속성 조건이 재택근무가능직원의 조건을 모두 포함한다면, 추론기는 “정규직은 재택근무가능직원의 하위 클래스”라는 관계를 자동으로 유추한다. 설계자가 의도하지 않은 계층 관계가 나타나면, 온톨로지 정의를 재검토해야 한다는 신호다.

만족 가능성 검사(Satisfiability Check)는 각 클래스가 인스턴스를 가질 수 있는지 확인한다. 제약 조건을 너무 빡빡하게 걸면, 어떤 개체도 해당 클래스에 속할 수 없는 상태가 되기도 한다. 추론기가 “이 클래스는 만족 불가능합니다”라고 알려주면, 제약 조건을 완화해야 한다.

실무에서는 온톨로지 변경이 있을 때마다 추론기를 돌리는 것이 바람직하다. CI/CD 파이프라인에 온톨로지 검증 단계를 넣는 팀도 있다. OWL 파일을 커밋하면 자동으로 HermiT 추론기가 실행되어, 일관성이 깨지면 빌드가 실패하는 식이다. 코드의 단위 테스트처럼, 온톨로지에도 자동화된 검증 게이트를 두는 편이 낫다.

그런데 추론기만으로는 잡히지 않는 문제도 있다. 논리적으로는 일관되지만 도메인 관점에서 말이 안 되는 경우다. 예를 들어 “인턴” 클래스에 “퇴직연금” 관계가 연결되어 있다고 해보자. OWL 규칙 상 모순은 아니다. 인턴도 직원의 하위 클래스이고, 직원은 복리후생에 연결될 수 있으니까. 하지만 실제 HR 정책에서 인턴에게 퇴직연금을 적용하지는 않는다. 이런 도메인 수준의 오류는 추론기가 아니라 도메인 전문가의 리뷰가 잡아야 한다.

그래서 온톨로지 검증은 두 층으로 구성하는 것이 좋다. 첫째 층은 추론기가 담당하는 자동 논리 검증이고, 둘째 층은 도메인 전문가가 수행하는 의미 검증이다. 자동 검증은 매 커밋마다, 의미 검증은 주요 변경이 있을 때 수행한다. 두 층이 함께 작동할 때 온톨로지의 건전성을 믿을 수 있다.

SHACL과 Cypher 기반 데이터 검증

추론기가 온톨로지 스키마 수준의 논리를 검증한다면, 실제 데이터가 스키마를 잘 따르는지는 별도로 확인해야 한다. SHACL(Shapes Constraint Language)은 W3C 표준으로, 그래프 데이터의 형상(Shape)을 정의하고 검증하는 언어다.

SHACL로 “모든 Employee 노드는 반드시 하나의 Department에 BELONGS_TO 관계를 가져야 한다”, “salary 속성은 0보다 큰 정수여야 한다” 같은 규칙을 선언하고, 데이터에 대해 자동 검증을 실행할 수 있다.

Neo4j 환경에서는 SHACL 대신 Cypher 쿼리로 동일한 검증을 수행하는 방법도 실용적이다. 데이터 무결성 검사 쿼리를 모아서 주기적으로 실행하는 것이다.

// 부서 없는 직원 탐지 (필수 관계 위반)
MATCH (e:Employee)
WHERE NOT (e)-[:BELONGS_TO]->(:Department)
RETURN e.name AS orphanEmployee, e.employeeId

// salary가 0 이하인 비정상 데이터 탐지
MATCH (e:Employee)
WHERE e.salary IS NOT NULL AND e.salary <= 0
RETURN e.name, e.salary AS invalidSalary

// 순환 보고 관계 탐지 (자기 자신에게 보고)
MATCH (e:Employee)-[:REPORTS_TO*1..5]->(e)
RETURN e.name AS circularReport

이런 검증 쿼리를 테스트 스위트처럼 관리하면, 데이터 적재 후 또는 마이그레이션 후에 즉시 무결성을 확인할 수 있다. 검증 쿼리가 하나라도 결과를 반환하면, 해당 데이터를 수정해야 한다는 신호다.

지식그래프 품질 메트릭 — 무엇을 측정할 것인가

온톨로지가 논리적으로 건전하다는 것이 확인되었다면, 그 위에 쌓인 실제 데이터의 품질은 어떻게 평가할까? 지식그래프 품질을 체계적으로 관리하려면 명확한 메트릭이 필요하다. 네 가지 핵심 차원을 살펴보자.

완전성(Completeness)

온톨로지가 정의한 스키마에 비해 실제 데이터가 얼마나 채워져 있는지를 나타낸다. “모든 직원 노드에 입사일 속성이 있어야 한다”고 정의했는데, 100명 중 15명의 입사일이 비어 있다면 완전성은 85%다.

Cypher로 간단히 측정할 수 있다.

// 입사일 속성 완전성 검사
MATCH (e:Employee)
WITH count(e) AS total,
     count(e.hireDate) AS filled
RETURN filled * 100.0 / total AS completeness_pct

완전성이 낮다고 해서 반드시 문제인 것은 아니다. 계약직 직원에게는 입사일 대신 계약시작일이 있을 수 있다. 중요한 것은 “의도적으로 비어 있는 것”과 “실수로 누락된 것”을 구분하는 것이다. 온톨로지의 제약 조건이 이 구분을 도와준다.

정확성(Accuracy)

데이터가 현실을 제대로 반영하는지의 문제다. 김철수가 인사팀 소속인데 그래프에는 개발팀으로 연결되어 있다면, 구조적으로는 문제가 없지만 내용이 틀린 것이다.

정확성은 자동 검증이 어렵다. 원천 시스템(HR 시스템, ERP)의 데이터와 주기적으로 대조하는 방법이 현실적이다. ETL 파이프라인에 검증 단계를 넣어서, 원천 데이터와 지식그래프의 불일치를 탐지하고 리포트하는 구조를 만들어두자.

최신성(Freshness)

지식그래프가 얼마나 최근 상태를 반영하는지다. 조직 개편이 지난달에 일어났는데 그래프는 여전히 옛 조직도를 갖고 있다면, 최신성이 떨어진 것이다.

최신성 관리의 핵심은 갱신 주기 정의다. HR 데이터처럼 빈번하게 변하는 데이터는 일 단위 동기화가 바람직하고, 정책 문서처럼 간헐적으로 변하는 데이터는 변경 이벤트 기반 업데이트가 효율적이다. 모든 데이터를 실시간으로 동기화하는 것은 비용 대비 효과가 떨어지는 경우가 많다.

// 노드별 마지막 업데이트 시점 확인
MATCH (e:Employee)
WHERE e.lastUpdated < datetime() - duration('P30D')
RETURN e.name, e.lastUpdated
ORDER BY e.lastUpdated ASC

30일 이상 업데이트되지 않은 노드가 있다면, 해당 데이터의 신뢰도에 주의해야 한다.

연결성(Connectivity)

그래프의 강점은 연결에 있다. 고립된 노드(어떤 관계도 없는 노드)는 그래프에 존재할 이유가 희박하다. 연결성 메트릭은 고립 노드 비율, 평균 관계 수, 연결 컴포넌트 수 등을 측정한다.

// 고립 노드 탐지
MATCH (n)
WHERE NOT (n)--()
RETURN labels(n) AS nodeType, count(n) AS isolatedCount

고립 노드가 발견되면 두 가지를 점검하자. 첫째, 데이터 적재 과정에서 관계 생성이 누락된 것은 아닌가? 둘째, 온톨로지에서 해당 클래스의 필수 관계를 정의했는데 강제하지 않은 것은 아닌가?

품질 대시보드 구성

이 네 가지 메트릭을 주기적으로 측정하고 대시보드로 시각화하면, 지식그래프의 건강 상태를 한눈에 파악할 수 있다. 대시보드는 단순할수록 좋다. 복잡한 차트를 열 개 나열하는 것보다, 핵심 메트릭 네 개를 신호등 색상(녹색/노란색/빨간색)으로 보여주는 것이 운영팀에 더 유용하다.

각 메트릭의 임계값을 미리 정해두자. 예를 들어 완전성 95% 이상이면 녹색, 90~95%면 노란색, 90% 미만이면 빨간색이다. 이 임계값은 도메인 특성에 따라 다르게 설정해야 한다. HR 안내봇에서 직원 소속 부서 완전성은 100%여야 하지만(부서 없는 직원은 답변 자체가 불가능하므로), 직원 사진 URL 같은 부가 속성의 완전성은 70%여도 서비스에 큰 지장이 없을 수 있다.

// 종합 품질 대시보드 쿼리
// 1. 완전성: 필수 속성 채움 비율
MATCH (e:Employee)
WITH count(e) AS total,
     count(e.hireDate) AS hasHireDate,
     count(e.department) AS hasDept
RETURN 'completeness' AS metric,
       hasHireDate * 100.0 / total AS hireDate_pct,
       hasDept * 100.0 / total AS department_pct

// 2. 연결성: 고립 노드 비율
MATCH (n)
WITH count(n) AS total
OPTIONAL MATCH (orphan) WHERE NOT (orphan)--()
WITH total, count(orphan) AS isolated
RETURN 'connectivity' AS metric,
       (total - isolated) * 100.0 / total AS connected_pct

품질 추이를 시계열로 기록하면, “지난달 대비 완전성이 3% 떨어졌다”처럼 변화를 감지할 수 있다. 갑작스러운 품질 하락은 대부분 데이터 파이프라인의 문제(ETL 오류, 원천 시스템 변경 등)를 시사하므로, 원인을 빠르게 추적할 수 있다. 10장에서 다룰 운영 모니터링과 자연스럽게 연결되는 부분이다.

GraphRAG 답변 품질 평가 — 지식그래프 너머의 품질

지식그래프 자체의 품질이 좋아도, GraphRAG 파이프라인을 거쳐 나오는 최종 답변이 엉뚱할 수 있다. 그래프 데이터는 정확한데 LLM이 해석을 잘못하거나, 리트리버가 엉뚱한 컨텍스트를 가져오는 경우다. 그래서 답변 품질 평가는 별도로 필요하다.

정확도(Correctness)

가장 직관적인 지표다. 정답이 있는 질문 세트(8장에서 만든 20개 시나리오)에 대해, 답변이 사실적으로 맞는지 평가한다. “인사팀 팀장은 누구인가?”에 “박영희”라고 답했는데 실제로는 “이정수”라면 오답이다.

정확도 평가에는 두 가지 접근이 있다. 사람이 직접 채점하는 방법과, LLM에게 정답과 비교하도록 시키는 자동 평가 방법이다. 둘 다 장단점이 있다. 사람 채점은 정확하지만 비용이 들고, LLM 자동 평가는 빠르지만 미묘한 뉘앙스를 놓칠 수 있다. 실무에서는 초기에 사람이 골든 데이터셋을 만들고, 이후에는 LLM 자동 평가를 주로 쓰면서 주기적으로 사람이 샘플 검증하는 하이브리드 방식이 효율적이다.

관련성(Relevance)

답변이 질문과 관련이 있는가? 정확한 사실을 말하더라도 질문과 동떨어진 내용이면 쓸모가 없다. “육아휴직 기간은?”이라고 물었는데 연차 정책을 장황하게 설명한다면, 정확하지만 관련성이 떨어지는 것이다.

관련성 문제는 대부분 리트리버 단계에서 발생한다. 쿼리 라우팅이 잘못되었거나, 그래프 탐색의 깊이(홉 수)가 적절하지 않은 경우다. 관련성이 낮은 답변이 반복된다면, 리트리버의 탐색 전략을 점검해보자.

설명 가능성(Explainability)

GraphRAG의 큰 장점 중 하나는 추론 경로를 보여줄 수 있다는 점이다. “김철수 → 소속 → 인사팀 → 적용정책 → 육아휴직규정”이라는 경로가 답변과 함께 제시되면, 사용자는 답변의 근거를 확인할 수 있다.

설명 가능성을 평가하려면 답변에 근거 경로가 포함되어 있는지, 그 경로가 실제 그래프의 관계와 일치하는지 확인한다. 근거 없이 “답변은 맞지만 왜 그런지 모르겠다”는 상태는 찜찜하다. 특히 HR 관련 답변은 근거가 명확해야 직원이 신뢰할 수 있다.

평가 자동화 파이프라인

이 세 지표를 수동으로 매번 측정하는 것은 번거롭다. 평가 파이프라인을 구축해두면 코드 변경이나 데이터 갱신 후 자동으로 품질을 확인할 수 있다.

# 답변 품질 평가 파이프라인 (개념 스케치)
class QAEvaluator:
    def __init__(self, golden_dataset, graph_client, llm_client):
        self.golden = golden_dataset  # 질문-정답 쌍
        self.graph = graph_client
        self.llm = llm_client

    def evaluate_correctness(self, question, expected, actual):
        """LLM 기반 자동 정확도 평가"""
        prompt = f"질문: {question}\n기대 답변: {expected}\n실제 답변: {actual}\n정확도(0-1):"
        return self.llm.score(prompt)

    def evaluate_relevance(self, question, actual):
        """질문-답변 관련성 평가"""
        prompt = f"질문: {question}\n답변: {actual}\n관련성(0-1):"
        return self.llm.score(prompt)

    def evaluate_explainability(self, answer_with_path):
        """추론 경로 포함 여부 및 유효성 검사"""
        has_path = answer_with_path.reasoning_path is not None
        path_valid = self.graph.verify_path(answer_with_path.reasoning_path)
        return has_path and path_valid

    def run_full_evaluation(self):
        results = []
        for q, expected in self.golden:
            actual = self.pipeline.ask(q)
            results.append({
                'question': q,
                'correctness': self.evaluate_correctness(q, expected, actual),
                'relevance': self.evaluate_relevance(q, actual),
                'explainability': self.evaluate_explainability(actual)
            })
        return results

이 파이프라인을 주 단위 또는 데이터 변경 시마다 실행하면, 품질 추이를 추적하고 문제를 조기에 발견할 수 있다.

회귀 테스트의 중요성

답변 품질 평가에서 흔히 간과하는 부분이 회귀 테스트(Regression Test)다. 코드를 수정하거나 데이터를 갱신한 후, 이전에 잘 동작하던 질문이 갑자기 틀린 답을 내놓는 경우가 있다. “어제까지 잘 됐는데 오늘 갑자기 이상하다”는 보고를 받으면 등줄기가 서늘해진다.

회귀 테스트의 핵심은 골든 데이터셋을 꾸준히 축적하는 것이다. 처음에는 50개로 시작하지만, 운영 중에 발견되는 오답 사례를 하나씩 추가한다. “이 질문에서 이런 오류가 발생했고, 수정 후 이렇게 답해야 한다”는 케이스를 기록해두면, 같은 문제가 재발하는지 자동으로 감지할 수 있다.

실무에서 효과적인 패턴은 품질 게이트(Quality Gate) 방식이다. GraphRAG 파이프라인의 코드를 변경하면, 배포 전에 골든 데이터셋 전체에 대한 평가를 자동 실행한다. 정확도가 기존 수준 이하로 떨어지면 배포를 차단한다. 코드의 CI/CD에서 테스트가 실패하면 머지를 막는 것과 같은 원리다. 이 게이트가 없으면 “개선한 줄 알았는데 다른 곳이 깨졌다”는 상황이 반복된다. 번거롭지만 이 투자는 반드시 회수된다.

온톨로지 진화 관리 — 변하지 않는 온톨로지는 없다

“도메인은 변한다.” 이 당연한 사실이 온톨로지 운영에서는 가장 큰 도전이 된다. HR 도메인만 해도, 새로운 복리후생 제도가 도입되고, 직급 체계가 바뀌고, 원격근무 정책이 추가된다. 온톨로지가 이런 변화를 반영하지 못하면, 지식그래프는 점점 현실과 괴리된다.

온톨로지 변경의 유형

온톨로지 변경은 크게 세 가지로 나눌 수 있다.

추가(Addition)는 가장 흔하고 가장 안전한 변경이다. 새 클래스를 추가하거나, 기존 클래스에 새 프로퍼티를 붙이거나, 새 관계 유형을 정의하는 것이다. 예를 들어 “재택근무정책”이라는 새 클래스를 정책(Policy) 하위에 추가하는 식이다. 기존 데이터에 영향을 주지 않으므로 비교적 안심하고 적용할 수 있다.

수정(Modification)은 기존 정의를 변경하는 것이다. 클래스 이름을 바꾸거나, 관계의 도메인/레인지를 변경하거나, 제약 조건을 강화 또는 완화하는 경우다. 이건 기존 데이터에 영향을 줄 수 있으므로 주의가 필요하다. “부서(Department)” 클래스를 “조직단위(OrganizationalUnit)”로 이름을 바꾸면, 기존에 Department 라벨을 참조하던 모든 쿼리가 깨진다.

삭제(Deletion)는 가장 위험하다. 클래스나 관계를 제거하면, 해당 유형의 모든 인스턴스 데이터가 의미를 잃는다. “직급(JobTitle)” 클래스를 삭제하면, 직급 관련 데이터가 고아 노드가 된다. 삭제는 정말로 해당 개념이 도메인에서 사라진 경우에만, 충분한 영향 분석 후에 수행하는 편이 낫다.

버전 관리 전략

온톨로지도 코드처럼 버전 관리가 필요하다. OWL 파일을 Git으로 관리하면 변경 이력을 추적할 수 있다. 다만 OWL/Turtle 파일은 텍스트 기반이라 diff가 가능하지만, 의미적 변경을 파악하기는 어렵다.

실무에서는 시맨틱 버전닝(Semantic Versioning)을 온톨로지에 적용하는 것이 효과적이다.

메이저 버전 변경 시에는 마이그레이션 스크립트를 함께 준비해야 한다. 5장에서 레거시 데이터를 온톨로지 스키마로 마이그레이션한 경험을 기억해보자. 온톨로지 메이저 업그레이드도 본질적으로 같은 작업이다.

변경 영향 분석

온톨로지를 변경하기 전에 “이 변경이 어디까지 영향을 미치는가?”를 파악하는 것이 중요하다. 체크리스트를 만들어보자.

  1. 데이터 영향: 변경되는 클래스/관계에 연결된 인스턴스가 몇 개인가?
  2. 쿼리 영향: 해당 클래스/관계를 참조하는 Cypher 쿼리가 몇 개인가?
  3. 파이프라인 영향: GraphRAG 리트리버가 해당 경로를 탐색하는가?
  4. 하위 시스템 영향: 외부 시스템(HR 시스템, 대시보드)이 해당 데이터를 참조하는가?

이 분석을 거치지 않고 온톨로지를 “가볍게” 수정했다가, 프로덕션에서 예상치 못한 곳이 깨지는 경험은 누구나 한 번쯤 겪는다. 그리고 그 경험은 꽤 초난감하다.

변경 이력 추적과 롤백

온톨로지 변경을 Git으로 관리한다고 했는데, 변경 이력 추적을 좀 더 구체적으로 살펴보자. 단순히 OWL 파일을 커밋하는 것만으로는 충분하지 않다. 각 변경에 대해 다음 정보를 함께 기록하는 것이 바람직하다.

이 정보가 있으면, 나중에 “이 클래스가 왜 이 시점에 추가되었지?”라는 질문에 답할 수 있다. 온톨로지가 진화하면서 설계 의도를 잃어버리는 것은 조용하지만 치명적인 문제다.

롤백 전략도 미리 세워두자. 온톨로지 변경 후 예상치 못한 문제가 발생하면, 이전 버전으로 되돌려야 할 수 있다. OWL 파일은 Git에서 되돌리면 되지만, 그 사이에 추가된 인스턴스 데이터가 문제다. 새 온톨로지 버전에 맞춰 적재된 데이터는 이전 버전의 스키마와 호환되지 않을 수 있다. 그래서 메이저 변경 전에는 지식그래프의 스냅샷을 백업해두는 것이 안전하다. Neo4j의 데이터베이스 덤프 기능으로 전체 그래프를 백업하거나, 변경 영역에 해당하는 노드와 관계만 선별 백업하는 방법이 있다.

LLM 기반 자동 온톨로지 업데이트

4장에서 LLM으로 온톨로지 초안을 자동 생성하는 방법을 살펴봤다. 이 접근법을 운영 단계에도 적용할 수 있다. 새로운 HR 정책 문서가 들어오면, LLM이 기존 온톨로지와 비교하여 변경이 필요한 부분을 자동으로 제안하는 것이다.

예를 들어보자. 인사팀에서 “2026년 상반기 유연근무제 가이드라인”이라는 문서를 발행했다. 이 문서를 LLM에 입력하면서, 현재 온톨로지의 구조도 함께 제공한다.

현재 온톨로지에는 다음 클래스가 있습니다:
- Policy (하위: LeavePolicy, BenefitPolicy)
- WorkArrangement (하위: RemoteWork)
- Employee, Department, JobTitle

새 문서 "유연근무제 가이드라인"을 분석하여,
온톨로지에 추가/수정이 필요한 항목을 제안해주세요.
각 제안에 대해 변경 유형(추가/수정/삭제)과 근거를 명시해주세요.

LLM은 “FlexibleWorkPolicy 클래스를 Policy 하위에 추가”, “WorkArrangement에 FlextimeSchedule 하위 클래스 추가”, “Employee와 FlexibleWorkPolicy 사이에 appliesTo 관계 추가” 같은 제안을 내놓을 수 있다.

물론 LLM의 제안을 바로 적용하면 안 된다. 도메인 전문가가 검토하고 승인하는 절차가 반드시 필요하다. “AI가 제안하고, 사람이 승인하는” 워크플로우다. 이 워크플로우를 구조화하면 다음과 같다.

  1. 새 문서/정책이 등록되면 LLM에 자동 분석 요청
  2. LLM이 온톨로지 변경 제안서를 생성 (추가/수정/삭제 + 근거)
  3. 도메인 전문가(HR 담당자 + 온톨로지 엔지니어)가 제안서 검토
  4. 승인된 변경을 온톨로지에 반영 (버전 태깅)
  5. 변경 영향 분석 실행
  6. 지식그래프 마이그레이션 (필요 시)
  7. 품질 메트릭 재측정으로 변경 후 건전성 확인

이 프로세스가 자리를 잡으면, 온톨로지가 도메인의 변화를 빠르게 따라가면서도 품질을 유지할 수 있다. Juan Sequeda의 교훈을 기억하자. “온톨로지는 살아있는 문서”다. 살아있는 것은 관리해야 한다.

HR안내봇에 적용하기 — 품질 관리 실전

지금까지 다룬 내용을 HR안내봇 프로젝트에 구체적으로 적용해보자.

답변 정확도 평가 체계 구축

8장에서 만든 20개 시나리오를 골든 데이터셋으로 삼고, 여기에 30개를 더 추가하여 총 50개의 평가 세트를 구성한다. 각 질문에 대해 기대 답변과 근거 경로를 명시해둔다.

Q: 김철수의 매니저가 관리하는 정책은?
A: [육아휴직규정, 연차규정, 복리후생안내]
Path: 김철수 → REPORTS_TO → 박영희 → MANAGES_POLICY → [정책들]

이 평가 세트를 자동 실행하고 결과를 기록하는 스크립트를 만들면, 코드 변경이나 데이터 갱신 후 즉시 품질을 확인할 수 있다.

온톨로지 변경 관리 프로세스 정의

HR안내봇의 온톨로지를 Git으로 관리하고, 변경 요청은 Pull Request 형태로 접수한다. PR에는 변경 사유, 영향 분석, 마이그레이션 계획이 포함되어야 한다.

변경 유형별 처리 기준을 정해두자.

변경 유형 예시 승인 필요 마이그레이션
추가 새 정책 클래스 팀 리드 불필요
속성 추가 직원에 재택근무여부 팀 리드 기존 노드에 기본값 설정
관계 수정 보고 관계 방향 변경 아키텍트 + 도메인 전문가 기존 관계 재매핑
클래스 삭제 사용하지 않는 클래스 제거 아키텍트 + PM 인스턴스 마이그레이션 또는 아카이브

LLM 자동 업데이트 제안 기능

인사팀이 새 정책 문서를 공유 폴더에 올리면, 자동으로 LLM이 분석하여 온톨로지 변경 제안을 생성하는 파이프라인을 구축한다. 제안은 슬랙(또는 팀즈)으로 통지되어, 담당자가 검토 후 승인/반려한다.

이 기능이 있으면, “재택근무 정책이 추가되었는데 HR안내봇이 모른다”는 상황을 미연에 방지할 수 있다. 물론 자동 제안이 항상 정확한 것은 아니다. LLM이 기존 클래스와 유사한 새 클래스를 불필요하게 제안하거나, 관계의 방향을 잘못 추론하는 경우도 있다. 그래서 사람의 검토가 빠질 수 없다. 하지만 LLM이 “이 문서에서 온톨로지에 반영할 내용이 있다”는 것을 먼저 감지해주는 것만으로도 운영 부담이 크게 줄어든다.


기술 리더 의사결정 박스: 온톨로지 거버넌스를 어떻게 구성할 것인가?

접근법 적합한 조직 장점 단점
(a) 중앙 집중형 — 온톨로지 팀이 모든 변경을 관리 대규모 조직 (500명+), 도메인 복잡도 높음 일관성 보장, 품질 높음 병목 발생, 변경 속도 느림
(b) 도메인별 분산 — 각 도메인 팀이 자기 영역 관리 중규모 조직, 도메인이 명확히 분리됨 빠른 대응, 도메인 전문성 반영 일관성 저하 위험, 중복 정의 가능
(c) AI 보조 자동 거버넌스 — LLM이 제안하고 최소 인원이 승인 소규모 팀, 빠른 변화 환경 빠른 대응, 낮은 인력 투입 AI 제안 품질 의존, 복잡한 변경에 취약

판단 기준: 조직 규모가 크고 온톨로지가 여러 시스템에 걸쳐 사용된다면 (a)가 안전하다. 스타트업이나 단일 프로젝트라면 (c)로 시작해서, 규모가 커지면 (b)로 전환하는 것이 현실적이다. 어떤 모델을 선택하든, “변경 요청 → 영향 분석 → 승인 → 적용 → 검증”이라는 기본 흐름은 동일하게 유지하는 편이 낫다.


마무리

지식그래프는 한 번 만들면 알아서 돌아가는 시스템이 아니다. 도메인이 변하면 온톨로지도 변해야 하고, 데이터가 쌓이면 품질도 점검해야 하며, 답변의 정확도도 지속적으로 모니터링해야 한다.

기억해두자. 추론기를 통한 온톨로지 검증, 네 가지 품질 메트릭(완전성, 정확성, 최신성, 연결성), GraphRAG 답변 평가 체계, 그리고 온톨로지 진화 관리 프로세스 — 이 네 가지가 지식그래프 품질 관리의 뼈대다. LLM 기반 자동 업데이트 제안은 이 뼈대 위에 효율성을 더해주는 도구다.

다음 장에서는 이 품질 관리 체계를 갖춘 지식그래프를 실제 프로덕션 환경으로 옮기는 과정을 살펴보자. 아키텍처, 성능 최적화, 보안, 모니터링 — 프로토타입과 프로덕션 사이에 놓인 간극을 하나씩 메워갈 것이다.


이번 장에서 HR안내봇에 추가된 것: - 50개 질문-정답 쌍의 골든 데이터셋 기반 답변 정확도 평가 체계 - 온톨로지 변경 관리 프로세스 (Git 기반 버전 관리 + PR 워크플로우) - LLM 기반 신규 HR 정책 문서 자동 분석 및 온톨로지 업데이트 제안 기능


10장. 프로덕션으로 가는 길 — 아키텍처, 성능, 운영

프로토타입이 데모에서 박수를 받는 것과, 실제 직원 수백 명이 매일 쓰는 서비스가 되는 것 사이에는 꽤 깊은 골짜기가 있다. 8장에서 완성한 HR안내봇 프로토타입을 팀장에게 보여줬더니 “오, 이거 다음 달에 전사 오픈하면 되겠네?”라는 반응이 돌아왔다고 상상해보자. 기쁘면서도 등줄기가 서늘해지는 순간이다.

프로토타입은 “이것이 가능하다”를 증명하는 것이고, 프로덕션은 “이것이 언제나 안정적으로 동작한다”를 보장하는 것이다. 그 사이에는 아키텍처 결정, 성능 최적화, 보안 설계, 모니터링 체계, 비용 관리라는 다섯 개의 관문이 놓여 있다. 하나씩 통과해보자.

프로덕션 아키텍처 패턴 — 어디에 무엇을 배치할 것인가

가장 먼저 결정해야 할 것은 Neo4j를 어떻게 운영할 것인가다. 크게 두 가지 선택지가 있다.

AuraDB — 클라우드 매니지드 서비스

Neo4j AuraDB는 Neo4j가 직접 운영하는 클라우드 매니지드 서비스다. AWS, GCP에서 제공되며, 인프라 관리 부담을 Neo4j 측에 위임한다. 백업, 패치, 스케일링을 서비스 제공자가 처리해주므로, 그래프 DB 운영 전문 인력이 없는 팀에 매력적이다.

AuraDB는 세 가지 티어를 제공한다. Free Tier는 학습과 프로토타입에 적합하고(3장에서 사용했다), Professional은 중소 규모 프로덕션 워크로드를, Enterprise는 대규모·고가용성 환경을 지원한다.

장점은 명확하다. 운영 부담이 적고, 자동 백업과 업그레이드가 제공되며, 스케일링이 상대적으로 간편하다. 하지만 한계도 있다. 플러그인 설치에 제약이 있을 수 있고(Neosemantics 같은 커스텀 플러그인), 데이터가 외부 클라우드에 저장되므로 규제 환경에서는 검토가 필요하다. 무엇보다, 비용이 사용량에 비례하므로 대규모 그래프에서는 예산 계획이 중요하다.

셀프호스팅 — 직접 운영

Neo4j Community Edition은 무료로 사용할 수 있고, Enterprise Edition은 라이선스 비용이 들지만 클러스터링, 고급 보안, 핫 백업 같은 프로덕션 필수 기능을 제공한다. Kubernetes 위에 Neo4j Helm Chart로 배포하거나, Docker Compose로 구성하는 팀이 많다.

셀프호스팅의 장점은 완전한 제어권이다. 원하는 플러그인을 설치할 수 있고, 데이터가 자체 인프라에 남으며, 네트워크 구성을 자유롭게 할 수 있다. 반면 백업, 모니터링, 장애 복구, 버전 업그레이드를 모두 직접 해야 한다. 그래프 DB 운영 경험이 없는 팀에게는 번거로운 일이 될 수 있다.

마이크로서비스 아키텍처 구성

HR안내봇의 프로덕션 아키텍처를 마이크로서비스로 구성한다면, 핵심 컴포넌트는 다음과 같다.

API Gateway가 외부 요청을 받아 인증·인가를 처리한 뒤 내부 서비스로 라우팅한다. Query Service는 사용자의 자연어 질문을 받아 질문 분류, 엔티티 추출, 그래프 탐색, 답변 생성까지의 GraphRAG 파이프라인을 실행한다. Graph Service는 Neo4j와의 통신을 담당하며, Cypher 쿼리 실행과 결과 변환을 처리한다. Embedding Service는 벡터 임베딩 생성과 유사도 검색을 담당한다. LLM Service는 외부 LLM API 호출을 관리하며, 요청 큐잉, 재시도, 비용 추적을 포함한다.

[사용자] → [API Gateway] → [Query Service]
                                ├→ [Graph Service] → [Neo4j]
                                ├→ [Embedding Service] → [Vector Store]
                                └→ [LLM Service] → [LLM API]

이 구조에서 각 서비스는 독립적으로 스케일링할 수 있다. 질문이 몰리는 시간대에 Query Service만 스케일아웃하거나, LLM 비용을 줄이기 위해 LLM Service에 캐싱 레이어를 추가하는 식이다.

이 구조에서 서비스 간 통신은 동기 방식(REST/gRPC)과 비동기 방식(메시지 큐)을 적절히 혼합하는 것이 좋다. 사용자가 질문을 던지고 답변을 기다리는 흐름은 동기로 처리해야 하지만, 답변 로그 기록이나 피드백 처리 같은 부수 작업은 비동기 큐에 넣어 응답 시간에 영향을 주지 않도록 한다. Kafka나 RabbitMQ 같은 메시지 브로커를 도입하면, 서비스 간 결합도도 낮출 수 있다.

고가용성(High Availability) 설계

프로덕션에서 “HR안내봇이 다운됐다”는 보고를 받는 것은 상당히 난감한 상황이다. 특히 연말정산이나 인사평가 시즌처럼 HR 문의가 폭증하는 시기에 서비스가 중단되면, 인사팀에 전화가 빗발치게 된다.

Neo4j Enterprise Edition은 Causal Clustering을 지원하여, 리더 노드에 장애가 발생하면 팔로워 노드가 자동으로 승격되는 구조를 구성할 수 있다. 읽기 전용 리플리카를 추가하면 읽기 부하도 분산된다. HR안내봇의 쿼리는 대부분 읽기이므로, 읽기 리플리카를 활용하면 성능과 가용성을 동시에 개선할 수 있다.

애플리케이션 서비스 레이어도 최소 2개 이상의 인스턴스를 운영하고, 로드 밸런서 뒤에 두는 것이 기본이다. 한 인스턴스에 문제가 생겨도 다른 인스턴스가 요청을 처리하므로, 사용자는 장애를 인지하지 못한다.

LLM API에 대한 장애 대비도 필요하다. 외부 LLM API가 일시적으로 응답하지 않으면 어떻게 할 것인가? 서킷 브레이커(Circuit Breaker) 패턴을 적용하여, API 장애 시 캐시된 답변을 반환하거나 “잠시 후 다시 시도해주세요”라는 안내를 보여주는 것이 좋다. “알 수 없는 오류가 발생했습니다”라는 메시지보다는 훨씬 낫다.

물론 모든 프로젝트가 처음부터 마이크로서비스로 시작할 필요는 없다. 사용자가 100명 이하이고 트래픽이 예측 가능하다면, 모놀리식 배포로 시작해서 필요할 때 분리해도 늦지 않다. 과도한 초기 설계는 오히려 복잡성만 키운다. 핵심은 분리가 필요해지는 시점의 신호를 미리 정의해두는 것이다. “응답 시간이 5초를 넘기면 서비스 분리를 검토한다”, “동시 사용자가 200명을 넘으면 스케일아웃을 고려한다” 같은 기준을 정해두자.

성능 최적화 — 느린 그래프는 아무도 기다려주지 않는다

프로토타입에서는 데이터가 100건이었다. 프로덕션에서는 직원 수천 명, 정책 수백 개, 복리후생 항목 수십 종이 들어간다. 데이터 규모가 커지면 “아까까지 빨랐는데?”라는 당혹감이 찾아온다. 성능 최적화의 핵심 전략을 살펴보자.

인덱스 전략

Neo4j에서 인덱스는 성능의 첫 번째 열쇠다. 자주 조회하는 속성에 인덱스를 걸지 않으면, Neo4j는 해당 라벨의 모든 노드를 풀 스캔해야 한다. 직원이 5,000명이고 이름으로 검색한다면, 인덱스 없이는 5,000개 노드를 모두 뒤져야 한다.

// 자주 조회하는 속성에 인덱스 생성
CREATE INDEX employee_name FOR (e:Employee) ON (e.name);
CREATE INDEX policy_type FOR (p:Policy) ON (p.type);
CREATE INDEX department_name FOR (d:Department) ON (d.name);

// 유니크 제약 조건 (인덱스 자동 생성)
CREATE CONSTRAINT employee_id_unique
FOR (e:Employee) REQUIRE e.employeeId IS UNIQUE;

인덱스를 무작정 많이 거는 것도 좋지 않다. 인덱스는 쓰기 성능을 약간 저하시키고 저장 공간을 소비한다. 실제 쿼리 패턴을 분석해서, 자주 사용되는 조회 조건에만 인덱스를 거는 편이 낫다. Neo4j의 PROFILE 명령으로 쿼리 실행 계획을 확인하면, 어디서 풀 스캔이 발생하는지 파악할 수 있다.

// 쿼리 실행 계획 분석
PROFILE
MATCH (e:Employee {name: '김철수'})-[:REPORTS_TO]->(m)
RETURN m.name

캐싱 전략

동일한 질문이 반복되는 패턴이 있다면, 캐싱은 성능과 비용 모두에 효과적이다. HR안내봇에서 “연차 사용 방법은?”이라는 질문은 수십 명이 비슷하게 물어볼 수 있다. 매번 그래프 탐색과 LLM 호출을 반복하는 것은 낭비다.

캐싱은 두 레이어로 나눌 수 있다.

쿼리 결과 캐싱은 동일한 Cypher 쿼리의 결과를 일정 시간 캐싱하는 것이다. Redis 같은 인메모리 캐시를 Graph Service 앞에 두면 된다. 캐시 만료 시간(TTL)은 데이터 갱신 주기에 맞춰 설정한다. 9장에서 정의한 최신성 메트릭이 여기서 기준이 된다.

답변 캐싱은 자연어 질문과 최종 답변 쌍을 캐싱하는 것이다. 같은 질문에 같은 답을 내놓으므로 LLM 호출 비용을 절약할 수 있다. 다만 질문이 완전히 동일해야 히트되므로, 의미적으로 유사한 질문(예: “연차 쓰는 법” vs “연차 사용 절차”)도 매칭하려면 임베딩 유사도 기반 캐싱을 고려해야 한다.

임베딩 유사도 기반 캐싱은 질문을 벡터로 변환한 뒤, 캐시에 저장된 기존 질문 벡터와 코사인 유사도를 비교하는 방식이다. 유사도가 임계값(예: 0.95) 이상이면 캐시된 답변을 반환한다. 이 방식은 캐시 히트율을 크게 높여주지만, 임계값 설정이 중요하다. 너무 낮으면 의미가 다른 질문에 엉뚱한 답을 내놓고, 너무 높으면 캐시 효과가 떨어진다. 운영 초기에는 높은 임계값(0.97 이상)으로 시작하고, 로그를 분석하며 점차 조정하는 편이 안전하다.

캐시 무효화(Cache Invalidation) 전략도 미리 세워두자. 9장에서 다룬 온톨로지 변경이나 데이터 갱신이 발생하면, 관련 캐시를 선별적으로 삭제해야 한다. “복리후생 정책이 변경되었으니 복리후생 관련 캐시를 모두 삭제”하는 식이다. 전체 캐시를 날리는 것은 간단하지만, 변경과 무관한 캐시까지 삭제되어 불필요한 LLM 호출이 발생한다. 캐시 키에 도메인 태그(예: “leave”, “benefit”, “salary”)를 달아두면, 선별적 무효화가 쉬워진다.

쿼리 튜닝

Cypher 쿼리가 느릴 때, 가장 흔한 원인은 탐색 범위가 너무 넓은 것이다.

그래프 탐색의 깊이(홉 수)를 무제한으로 열어두면, 연결이 많은 그래프에서 탐색이 폭발적으로 늘어난다. 가변 길이 경로 (a)-[*]->(b)는 프로덕션에서 거의 사용하면 안 되는 패턴이다. 반드시 상한을 두자.

// 나쁜 예 — 무한 탐색
MATCH path = (e:Employee)-[*]->(target)
RETURN path

// 좋은 예 — 최대 3홉으로 제한
MATCH path = (e:Employee)-[*1..3]->(target)
RETURN path

또 하나 주의할 점은 카테시안 곱(Cartesian Product)이다. 서로 연결되지 않은 패턴을 한 쿼리에 넣으면, Neo4j가 모든 조합을 생성하느라 메모리와 시간을 잡아먹는다. PROFILE 결과에서 “CartesianProduct” 오퍼레이터가 보이면 쿼리를 분리하거나 연결 조건을 추가해야 한다.

그래프 파티셔닝

데이터가 아주 커지면(노드 수백만, 관계 수천만), 단일 Neo4j 인스턴스로는 한계에 다다른다. Neo4j Enterprise Edition은 Fabric이라는 기능으로 여러 데이터베이스에 걸쳐 쿼리를 실행할 수 있다. HR 데이터를 지역별로 파티셔닝하거나(서울 사업장, 판교 사업장), 데이터 유형별로 분리하는(직원 그래프, 정책 그래프) 전략이 가능하다.

다만 파티셔닝은 쿼리 복잡도를 높이고 운영 부담을 늘리므로, 실제로 단일 인스턴스의 성능 한계에 부딪힌 후에 도입하는 것이 바람직하다. 대부분의 엔터프라이즈 HR 시스템은 직원 수만 명 수준이고, Neo4j는 수천만 노드까지 단일 인스턴스에서 잘 동작한다. 성급한 최적화는 불필요한 복잡성의 원천이다.

부하 테스트 — 프로덕션 전에 한계를 알자

성능 최적화를 마쳤다면, 프로덕션 배포 전에 반드시 부하 테스트를 수행하자. “우리 시스템은 동시 사용자 몇 명까지 버틸 수 있는가?”라는 질문에 답할 수 없다면, 프로덕션에 나가기엔 아직 이르다.

부하 테스트는 두 가지 시나리오로 나눈다. 정상 부하 테스트는 예상되는 일반적인 사용 패턴을 시뮬레이션한다. 하루 1,000건의 질문이 오전 9시~오후 6시에 집중된다면, 시간당 약 110건의 요청을 지속적으로 보내면서 응답 시간과 에러율을 측정한다. 피크 부하 테스트는 정상 트래픽의 3~5배 부하를 가하여, 시스템이 극한 상황에서 어떻게 반응하는지 확인한다. 연말정산 시즌에 HR 문의가 폭증하는 상황을 미리 경험하는 셈이다.

부하 테스트에서 자주 드러나는 병목은 세 곳이다. Neo4j의 커넥션 풀 고갈, LLM API의 속도 제한(Rate Limit) 초과, 그리고 애플리케이션 서버의 메모리 부족이다. 이 세 곳을 집중적으로 모니터링하면서 부하를 점진적으로 올리면, 시스템의 한계점을 찾을 수 있다. 한계점을 알면 그에 맞는 스케일링 전략을 세울 수 있고, “갑자기 느려졌는데 원인을 모르겠다”는 난감한 상황을 예방할 수 있다.

보안과 접근 제어 — HR 데이터는 민감하다

HR 데이터에는 직원의 급여, 평가, 징계 이력, 건강 정보가 포함될 수 있다. 이런 데이터가 담긴 지식그래프에 아무나 접근할 수 있다면, 보안 사고는 시간문제다. 찜찜한 수준이 아니라 심각한 문제가 된다.

데이터 분류와 접근 정책

첫 단계는 지식그래프의 데이터를 민감도별로 분류하는 것이다.

등급 예시 데이터 접근 범위
공개 부서명, 직급 체계, 일반 정책 전 직원
내부 직원 소속, 보고 관계, 근무지 해당 부서 + HR
기밀 급여, 평가, 징계 이력 HR 담당자 + 직속 상사
극비 구조조정 계획, 해고 예정자 경영진 + HR 리더

Neo4j Enterprise Edition은 Role-Based Access Control(RBAC)을 제공한다. 사용자별로 읽기/쓰기 권한을 라벨이나 프로퍼티 수준에서 제어할 수 있다.

// 역할 생성 및 권한 부여 (Neo4j Enterprise)
CREATE ROLE hr_viewer;
GRANT MATCH {*} ON GRAPH hr_graph NODE Employee TO hr_viewer;
DENY READ {salary, evaluation} ON GRAPH hr_graph NODE Employee TO hr_viewer;

Community Edition에서는 이런 세밀한 접근 제어가 불가능하므로, 애플리케이션 레이어에서 권한 체크를 구현해야 한다. GraphRAG 파이프라인의 리트리버가 현재 사용자의 권한을 확인하고, 접근 불가한 데이터를 필터링하는 로직을 넣는 것이다.

HR안내봇의 보안 설계

HR안내봇은 질문자가 누구인지에 따라 보여줄 수 있는 정보가 달라져야 한다. 일반 직원이 “내 급여는 얼마인가?”라고 물으면 본인의 급여만 보여주고, “옆 팀 김대리 급여는?”이라고 물으면 접근이 거부되어야 한다.

이를 위해 Query Service에 인증된 사용자 정보를 전달하고, Graph Service가 Cypher 쿼리에 접근 제어 필터를 동적으로 추가하는 구조가 필요하다.

// 본인 급여만 조회 가능 (접근 제어 적용)
MATCH (e:Employee {employeeId: $currentUserId})
RETURN e.name, e.salary

// 타인 급여 조회 — HR 역할이 아니면 차단
// 애플리케이션 레이어에서 역할 체크 후 실행 여부 결정

LLM 호출 시 데이터 유출 방지

GraphRAG 파이프라인에서 또 하나 주의할 점은, 그래프에서 가져온 컨텍스트를 LLM에 전달할 때 민감 정보가 포함될 수 있다는 것이다. 예를 들어 급여 정보가 컨텍스트에 포함된 채로 외부 LLM API에 전송되면, 민감 데이터가 외부로 유출되는 셈이다.

대응 방법은 세 가지다. 첫째, LLM에 전달하기 전에 민감 필드를 마스킹한다. 둘째, 온프레미스 LLM을 사용하여 데이터가 외부로 나가지 않도록 한다. 셋째, 민감 데이터가 포함된 답변은 그래프 쿼리 결과를 직접 반환하고 LLM을 거치지 않는 경로를 설계한다. 보안 요구사항에 따라 적절한 방법을 선택하자.

감사 로그와 규정 준수

HR 데이터를 다루는 시스템은 “누가 언제 어떤 데이터에 접근했는가”를 기록해야 한다. 특히 개인정보보호법이나 사내 보안 정책에 따라, 민감 데이터 접근 이력을 일정 기간 보존해야 하는 경우가 많다.

감사 로그에 기록해야 할 항목은 다음과 같다.

이 로그를 별도의 로그 저장소(예: Elasticsearch, CloudWatch)에 기록하고, 이상 패턴을 탐지하는 규칙을 설정한다. 예를 들어, 한 사용자가 짧은 시간에 다수의 직원 급여를 조회하는 패턴은 비정상 접근일 수 있으므로 알림을 보내도록 한다.

백업과 재해 복구

“백업을 하고 있다”는 것과 “복구가 가능하다”는 것은 다른 이야기다. 백업이 존재하더라도, 실제로 복구 테스트를 해본 적이 없다면 위기 상황에서 복구가 실패할 수 있다. 이런 상황은 끔찍한 일이다.

Neo4j의 백업 전략을 정해두자. 전체 백업(Full Backup)은 주 단위, 증분 백업(Incremental Backup)은 일 단위로 수행하는 것이 일반적이다. 백업 파일은 Neo4j 인스턴스와 물리적으로 분리된 스토리지에 보관해야 한다. 같은 서버에 백업을 저장하면, 서버 장애 시 백업까지 잃을 수 있다.

그리고 반드시 복구 테스트를 주기적으로 수행하자. 분기에 한 번이라도, 백업 파일에서 Neo4j를 복원하고 데이터가 정상인지 확인하는 드릴을 하는 것이 바람직하다. 복구 절차서가 문서로만 존재하고 한 번도 실행해보지 않았다면, 그 문서는 없는 것이나 마찬가지다.

재해 복구 계획(DR Plan)에는 RPO(Recovery Point Objective)와 RTO(Recovery Time Objective)를 명시한다. RPO는 “최대 몇 시간 분의 데이터 손실을 감수할 수 있는가”이고, RTO는 “장애 발생 후 몇 시간 안에 서비스를 복구해야 하는가”다. HR안내봇이 업무 보조 도구라면 RPO 24시간, RTO 4시간 정도가 현실적일 수 있다. 하지만 이것은 비즈니스 요구사항에 따라 달라지므로, 이해관계자와 합의해두는 편이 낫다.

모니터링과 알림 — 문제를 먼저 발견하는 시스템

프로덕션 시스템에서 가장 두려운 것은 “문제가 발생했는데 아무도 모르는 상태”가 지속되는 것이다. 사용자가 “HR안내봇이 안 돼요”라고 슬랙에 올린 후에야 장애를 인지하는 것은 초난감한 상황이다. 모니터링과 알림 체계가 문제를 사람보다 먼저 발견해야 한다.

그래프 DB 헬스체크

Neo4j의 핵심 모니터링 지표는 다음과 같다.

쿼리 응답 시간은 가장 중요한 지표다. 평균 응답 시간과 P95/P99 응답 시간을 추적한다. P99가 급격히 올라가면 특정 쿼리가 비효율적으로 실행되고 있을 가능성이 높다.

활성 연결 수는 현재 Neo4j에 연결된 클라이언트 수다. 커넥션 풀이 고갈되면 새 요청이 대기열에 쌓이게 된다. 최대 연결 수의 80%에 도달하면 경고 알림을 보내는 것이 좋다.

메모리 사용률은 Neo4j의 페이지 캐시 히트율과 힙 메모리 사용량을 포함한다. 페이지 캐시 히트율이 95% 미만으로 떨어지면, 디스크 I/O가 증가하면서 성능이 급격히 저하된다. 메모리 할당을 늘리거나, 쿼리 패턴을 최적화해야 한다.

디스크 사용량은 그래프 데이터 증가 추세를 파악하기 위한 것이다. 디스크가 90% 이상 차면 시스템이 불안정해질 수 있으므로, 충분한 여유를 두고 확장 계획을 세워야 한다.

Neo4j는 Prometheus 엔드포인트를 제공하므로, Grafana 대시보드와 연동하면 이 지표들을 실시간으로 시각화할 수 있다.

RAG 파이프라인 모니터링

그래프 DB 자체가 건강해도, RAG 파이프라인 전체를 모니터링해야 한다.

질문 분류 정확도는 사용자의 질문이 올바른 처리 경로로 라우팅되는지를 확인한다. 질문이 그래프 탐색 대상인데 벡터 검색으로 라우팅되면, 답변 품질이 떨어진다. 일정 비율의 질문을 샘플링하여 수동으로 분류 정확도를 평가하고, 필요시 라우터를 재학습시킨다.

리트리버 호출 시간은 그래프 리트리버와 벡터 리트리버 각각의 응답 시간을 분리 측정한다. 어느 쪽이 병목인지 파악해야 최적화 방향을 잡을 수 있다.

LLM 응답 시간과 토큰 사용량은 외부 LLM API의 지연 시간과 비용을 추적한다. API 제공자의 장애나 속도 저하가 곧바로 HR안내봇의 응답 지연으로 이어지므로, LLM API의 상태도 모니터링 대상이다.

사용자 피드백 수집은 가장 직접적인 품질 신호다. 답변 하단에 “도움이 되었나요?” 버튼을 두고, 부정 피드백이 누적되는 질문 유형을 분석하면 개선 우선순위를 정할 수 있다.

알림 설계

모니터링 지표마다 임계값과 알림 채널을 정의한다.

지표 경고 임계값 위험 임계값 알림 채널
P99 응답 시간 3초 초과 10초 초과 슬랙 채널
페이지 캐시 히트율 95% 미만 90% 미만 슬랙 + 온콜 호출
에러율 1% 초과 5% 초과 슬랙 + 온콜 호출
디스크 사용률 80% 초과 90% 초과 슬랙 채널
LLM API 에러율 1% 초과 10% 초과 슬랙 + 온콜 호출

알림 피로(Alert Fatigue)를 주의하자. 임계값이 너무 민감하면 알림이 남발되어 정작 중요한 알림을 놓치게 된다. 처음에는 넉넉한 임계값으로 시작하고, 운영 경험이 쌓이면 점차 조정하는 편이 낫다.

비용 최적화 — 지속 가능한 운영을 위해

지식그래프 기반 시스템의 운영 비용은 크게 세 가지로 나뉜다. 그래프 DB 인프라 비용, LLM API 호출 비용, 그리고 인력 운영 비용이다.

LLM 호출 비용 관리

GraphRAG 파이프라인에서 가장 가변적인 비용은 LLM 호출이다. 질문 하나당 LLM을 여러 번 호출할 수 있다. 질문 분류에 한 번, 엔티티 추출에 한 번, 최종 답변 생성에 한 번. 하루에 1,000건의 질문이 들어오면 3,000번의 LLM 호출이 발생한다.

비용을 줄이는 방법을 몇 가지 살펴보자.

모델 계층화는 모든 작업에 최상위 모델을 쓰지 않는 것이다. 질문 분류나 엔티티 추출처럼 비교적 단순한 작업에는 경량 모델을 쓰고, 최종 답변 생성처럼 품질이 중요한 작업에만 고성능 모델을 쓴다. 이것만으로도 비용이 상당히 줄어든다.

프롬프트 최적화는 LLM에 보내는 컨텍스트의 크기를 최소화하는 것이다. 그래프에서 가져온 컨텍스트 중 답변에 꼭 필요한 부분만 선별하여 전달한다. 불필요하게 긴 컨텍스트는 토큰 비용을 높이고 답변 품질도 떨어뜨린다.

캐싱은 앞서 다룬 대로 동일 질문의 반복 호출을 막는 것이다. HR 도메인에서는 “연차 사용 방법”, “경조사비 신청”, “재택근무 절차” 같은 빈출 질문이 많으므로, 캐싱 효과가 크다.

배치 처리는 실시간 응답이 필요 없는 작업(예: 온톨로지 자동 업데이트 제안, 품질 평가)을 모아서 처리하는 것이다. 피크 시간을 피해 실행하면 API 비용도 절약하고 응답 시간에 영향을 주지도 않는다.

인프라 비용 계획

Neo4j 인프라 비용은 선택한 배포 방식에 따라 크게 달라진다.

AuraDB를 사용한다면, 예상 사용량(노드 수, 관계 수, 동시 사용자 수)을 기반으로 월 비용을 산정한다. GDS(Graph Data Science) 기능을 사용하려면 Enterprise 티어가 필요하며, 별도 비용이 추가된다.

셀프호스팅이라면, 서버 비용(클라우드 VM 또는 물리 서버), Neo4j Enterprise 라이선스, 운영 인력 비용을 합산한다. 초기 비용은 셀프호스팅이 높을 수 있지만, 규모가 커지면 AuraDB보다 경제적인 경우도 있다.

TCO(Total Cost of Ownership) 산출 시 잊기 쉬운 항목이 있다. 백업 스토리지 비용, 재해 복구 인프라 비용, 그리고 장애 대응에 소요되는 인력 시간이다. 이 숨은 비용까지 포함해야 현실적인 비교가 가능하다.


기술 리더 의사결정 박스: 클라우드 매니지드 vs 셀프호스팅?

기준 AuraDB (매니지드) 셀프호스팅 (Enterprise)
초기 구축 비용 낮음 (바로 시작) 높음 (인프라 + 라이선스)
월 운영 비용 사용량 비례 (예측 가능) 고정 비용 + 변동 비용
운영 인력 최소 (Neo4j가 관리) DBA 또는 DevOps 필요
플러그인 자유도 제한적 완전 자유
데이터 위치 외부 클라우드 자체 인프라
보안 규제 대응 제공자의 인증에 의존 직접 통제 가능
GDS 사용 AuraDS 별도 서비스 Enterprise 라이선스에 포함
확장성 수직 확장 용이 클러스터링으로 수평 확장

판단 기준: 팀에 그래프 DB 운영 경험이 없고, 데이터 규제가 까다롭지 않다면 AuraDB로 시작하는 것이 현실적이다. 데이터 주권이 중요하거나 GDS를 적극 활용할 계획이라면 셀프호스팅 Enterprise를 고려하자. “시작은 AuraDB로, 규모가 커지면 셀프호스팅으로”라는 점진적 전환도 가능하지만, 마이그레이션 비용을 미리 감안해두는 편이 낫다.


운영 체크리스트 — 프로덕션 배포 전 확인 사항

지금까지 다룬 내용을 정리하여, 프로덕션 배포 전 체크리스트를 만들어보자.

아키텍처 - [ ] Neo4j 배포 방식 결정 (AuraDB vs 셀프호스팅) - [ ] 서비스 구성 확정 (모놀리식 vs 마이크로서비스) - [ ] 네트워크 구성 및 방화벽 규칙 설정 - [ ] 백업 및 복구 절차 수립

성능 - [ ] 주요 쿼리에 인덱스 적용 - [ ] 쿼리 실행 계획 분석 완료 - [ ] 캐싱 레이어 구현 및 TTL 설정 - [ ] 부하 테스트 수행 및 결과 문서화

보안 - [ ] 데이터 분류 및 접근 정책 정의 - [ ] RBAC 또는 애플리케이션 레벨 접근 제어 구현 - [ ] LLM 호출 시 민감 데이터 마스킹 처리 - [ ] 감사 로그(Audit Log) 활성화

모니터링 - [ ] 그래프 DB 핵심 지표 대시보드 구성 - [ ] RAG 파이프라인 모니터링 설정 - [ ] 알림 임계값 및 채널 설정 - [ ] 사용자 피드백 수집 메커니즘 구현

비용 - [ ] LLM 모델 계층화 및 비용 추적 설정 - [ ] 월 운영 비용 예측 및 예산 확보 - [ ] 비용 알림 설정 (예산 초과 방지)

이 체크리스트를 모두 통과했다면, 프로토타입은 프로덕션이 될 준비가 된 것이다.

마무리

프로토타입에서 프로덕션으로의 여정은 화려하지 않다. 인덱스를 걸고, 캐시를 설정하고, 접근 제어를 구현하고, 모니터링 대시보드를 만드는 — 지루하지만 결정적인 작업들이다. 이 작업들을 건너뛰면, 서비스는 언젠가 예상치 못한 곳에서 무너진다.

잊지 말자. 아키텍처 결정은 한 번 하면 바꾸기 어렵지만, 성능 최적화와 모니터링은 운영하면서 계속 개선할 수 있다. 처음부터 완벽한 프로덕션을 만들려고 하지 말고, 핵심 안전장치(보안, 백업, 모니터링)를 갖추는 것에 집중하자. 나머지는 운영 데이터가 쌓이면서 자연스럽게 최적화 방향이 보인다.

다음 장에서는 시선을 기술에서 조직으로 옮긴다. “이 시스템을 우리 조직에 도입하려면 어떤 전략이 필요한가?” — 기술 리더로서의 마지막 의사결정을 함께 고민해보자.


이번 장에서 HR안내봇에 추가된 것: - 프로덕션 배포 아키텍처 확정 (API Gateway + Query/Graph/Embedding/LLM Service 구성) - 주요 쿼리 인덱스 적용 및 성능 벤치마크 결과 정리 - 데이터 분류별 접근 제어 정책 및 보안 설계 - Grafana 기반 운영 대시보드 설계 (그래프 DB + RAG 파이프라인 지표) - 프로덕션 배포 전 체크리스트 완성


11장. 기술 리더의 도입 전략 — 조직에 지식그래프를 심는 법

이 책의 여정을 함께 걸어온 당신에게 묻고 싶다. 1장에서 “육아휴직 중 연차는 어떻게 되나요?”라는 질문 하나로 시작한 이 프로젝트가, 지금 당신의 머릿속에서 어떤 그림으로 그려지고 있는가?

온톨로지를 설계하고, Neo4j에 지식그래프를 구축하고, GDS로 숨은 인사이트를 발굴하고, GraphRAG로 지능형 질의응답 파이프라인을 완성했다. 품질 관리 체계를 세우고, 프로덕션 아키텍처까지 설계했다. 기술적으로는 준비가 되었다.

그런데 여기서 한 발짝 물러서서 생각해보자. 이 모든 기술이 실제로 조직에 뿌리를 내리려면, 코드만으로는 부족하다. 경영진을 설득해야 하고, 팀을 꾸려야 하며, 파일럿의 성공을 전사로 확대하는 전략이 필요하다. 기술 리더로서 마지막으로 넘어야 할 관문이다.

도입 타당성 평가 — 우리 조직에 정말 필요한가

모든 조직에 지식그래프가 필요한 것은 아니다. 이 말을 이 책의 마지막 장에서 하는 것이 아이러니하게 느껴질 수 있다. 하지만 도입 의사결정에서 가장 중요한 첫 단계는 “정말 필요한가?”를 냉정하게 묻는 것이다. 필요하지 않은 기술을 도입하는 것만큼 낭비는 없다.

다섯 가지 판단 기준을 제시한다.

기준 1: 데이터 간 관계가 비즈니스의 핵심인가

단순히 데이터를 저장하고 조회하는 것이 전부라면, 관계형 데이터베이스로 충분하다. 하지만 “이 직원은 어떤 부서에 소속되어 있고, 그 부서에 적용되는 정책은 무엇이며, 그 정책의 예외 조건에 해당하는 다른 직원은 누구인가?”처럼 데이터 간의 관계를 따라가며 답을 찾아야 하는 질문이 빈번하다면, 지식그래프가 본질적으로 유리하다.

관계형 DB에서 이런 멀티홉 쿼리를 작성해본 경험이 있다면, JOIN이 네다섯 개 겹치면서 쿼리가 얼마나 난감해지는지 알 것이다. 그래프는 이 문제를 구조적으로 해결한다.

기준 2: 비정형 지식이 흩어져 있는가

정책 문서, 매뉴얼, FAQ, 내부 위키 — 이런 비정형 지식이 여러 곳에 산재해 있고, 직원들이 “이 정보를 어디서 찾아야 하는지 모르겠다”고 불평한다면 지식그래프의 좋은 후보다. 온톨로지로 지식의 구조를 정의하고, 그 위에 실제 데이터를 쌓으면, 흩어진 지식이 하나의 탐색 가능한 네트워크가 된다.

기준 3: AI 기반 질의응답의 정확도가 중요한가

벡터 RAG만으로 충분한 정확도가 나온다면, 지식그래프를 추가할 이유가 약해진다. 하지만 1장에서 살펴본 것처럼, 멀티홉 추론이 필요하거나 관계 기반 맥락이 중요한 질문에서는 GraphRAG가 벡터 RAG 대비 현격한 성능 차이를 보인다. FalkorDB 벤치마크에서 벡터 RAG 56.2% 대비 GraphRAG 90% 이상의 정확도, Microsoft의 계층적 커뮤니티 접근에서 기존 RAG 32% 대비 86%의 정확도가 이를 뒷받침한다.

기준 4: 도메인 지식이 자주 변하는가

도메인이 안정적이고 거의 변하지 않는다면, 한 번 구축한 시스템을 오래 쓸 수 있으므로 어떤 방식이든 무방하다. 하지만 HR처럼 정책이 분기마다 바뀌고, 조직 개편이 잦으며, 새로운 제도가 계속 도입되는 도메인이라면, 온톨로지 기반 접근이 변화 관리에 유리하다. 9장에서 다룬 것처럼, 온톨로지의 구조를 변경하면 지식그래프가 자동으로 따라가고, LLM이 변경 사항을 자동으로 제안할 수도 있다.

기준 5: 설명 가능성이 요구되는가

“왜 이런 답변이 나왔는가?”에 대해 근거를 제시해야 하는 환경이라면, GraphRAG의 추론 경로 제공 기능이 큰 가치를 가진다. 금융 컴플라이언스, 의료, 법률처럼 규제가 강한 도메인에서는 설명 가능성이 필수 요건인 경우가 많다.

다섯 기준 중 세 개 이상에 해당한다면, 지식그래프 도입을 진지하게 검토해볼 만하다. 두 개 이하라면, 다른 접근법(벡터 RAG, 전통적 검색 시스템)으로도 충분할 수 있다.

이 판단을 내리는 과정에서 흔히 빠지는 함정이 있다. “기술이 좋으니까 도입하자”는 기술 주도(Technology-Driven) 사고다. 기술 리더로서 신기술에 대한 열정은 자연스럽지만, 조직의 입장에서 중요한 것은 “어떤 비즈니스 문제를 해결하는가”다. 지식그래프를 도입하고 싶은 마음이 앞서더라도, 먼저 비즈니스 문제를 명확히 정의하고, 그 문제를 풀기에 지식그래프가 최적인지를 검증하는 순서를 지키는 편이 낫다. 순서가 뒤집히면, 화려한 기술 데모는 만들었는데 아무도 쓰지 않는 시스템이 될 위험이 있다.

파일럿 프로젝트 설계 — 작게 시작해서 증명하기

도입 타당성이 확인되었다면, 다음은 파일럿이다. 처음부터 전사 시스템을 구축하겠다고 나서면, 리스크가 크고 성과를 보여주기까지 시간이 너무 오래 걸린다. 작은 프로젝트로 가치를 증명한 후 확대하는 것이 현실적이다.

이 책에서 만든 HR안내봇이 바로 파일럿의 좋은 출발점이다.

파일럿 범위 설정

파일럿의 핵심은 “3개월 안에 측정 가능한 성과를 보여주는 것”이다. 범위를 좁게 잡되, 지식그래프의 장점이 명확히 드러나는 시나리오를 선택해야 한다.

HR안내봇을 파일럿으로 진행한다면, 범위를 다음과 같이 잡을 수 있다.

대상 사용자: 인사팀 + 특정 부서(예: 개발팀) 50~100명 대상 질문: 복리후생 관련 질문 50종 (연차, 경조사, 건강검진, 교육지원 등) 성공 기준: 질문 응답 정확도 85% 이상, 평균 응답 시간 5초 이내, 사용자 만족도 4.0/5.0 이상

범위를 너무 넓게 잡으면 — 예를 들어 처음부터 전체 HR 정책, 급여, 평가, 채용까지 모두 커버하려 하면 — 3개월 안에 완성하기 어렵고, 개별 영역의 품질도 떨어진다. 파일럿에서는 “좁지만 깊게”가 원칙이다.

성공 측정 방법

파일럿의 성공을 측정할 때 주의할 점이 있다. 기술 지표만으로는 경영진을 설득하기 어렵다. “GraphRAG 정확도 90%”라는 숫자보다, “HR 문의 응답 시간이 평균 2시간에서 10초로 단축되었다”거나 “인사팀의 반복 질문 대응 업무가 월 40시간 줄었다”는 비즈니스 지표가 훨씬 설득력이 있다.

파일럿 시작 전에 현재 상태(As-Is)를 측정해두는 것을 잊지 말자. “지금 직원들이 HR 관련 질문에 답을 얻기까지 평균 몇 시간이 걸리는가?” “인사팀이 반복 질문 대응에 주당 몇 시간을 쓰는가?” 이 기저선(Baseline)이 있어야, 파일럿 후 개선 정도를 정량화할 수 있다.

파일럿의 흔한 실패 원인과 대처

파일럿이 실패하는 가장 흔한 원인을 미리 알아두면 함정을 피할 수 있다.

범위 팽창(Scope Creep)은 파일럿 중간에 “이것도 넣으면 좋겠는데?”라는 요청이 계속 들어오는 현상이다. 의도는 좋지만, 범위가 넓어지면 3개월 안에 성과를 보여주기 어려워진다. 파일럿 시작 전에 범위를 문서화하고, 추가 요청은 “Phase 2 백로그”에 넣는 원칙을 세워두자.

도메인 전문가 부재도 치명적이다. 개발팀만으로 온톨로지를 설계하면, 기술적으로는 훌륭하지만 실제 HR 업무 흐름과 동떨어진 구조가 나온다. 인사팀에서 주 2~3시간이라도 시간을 내줄 수 있는 담당자를 확보하는 것이 필수다. 이 사람이 없으면 파일럿의 절반은 “이 용어가 맞나?” “이 관계가 실제로 존재하나?”를 확인하는 데 허비된다.

기대치 관리 실패는 찜찜한 결과를 낳는다. 경영진에게 “AI가 모든 HR 질문에 완벽하게 답합니다”라고 약속하면, 파일럿 결과가 85% 정확도여도 실패로 인식된다. 반면 “복리후생 관련 빈출 질문 50가지에 대해 85% 이상의 정확도를 목표로 합니다”라고 구체적으로 소통하면, 같은 결과가 성공으로 인식된다. 기대치는 낮게, 성과는 높게.

팀 구성과 역량 로드맵

지식그래프 프로젝트를 운영하려면 어떤 역량이 필요할까? 모든 역할을 처음부터 전담 인력으로 채울 필요는 없다. 프로젝트 단계에 따라 필요한 역량이 달라진다.

핵심 역할

온톨로지 엔지니어는 도메인 전문가와 협업하여 온톨로지를 설계하고 관리한다. 파일럿 단계에서는 백엔드 개발자가 겸임할 수 있지만, 규모가 커지면 전문 인력이 필요해진다. 4장에서 다룬 LLM 보조 설계를 활용하면 진입 장벽을 낮출 수 있다.

그래프 DB 엔지니어는 Neo4j 운영, Cypher 최적화, 데이터 모델링을 담당한다. 10장에서 다룬 성능 최적화와 운영 역량이 이 역할에 해당한다. 셀프호스팅을 선택했다면 이 역할의 중요성이 더 커진다.

AI/ML 엔지니어는 GraphRAG 파이프라인, LLM 통합, 임베딩 모델 관리를 담당한다. 7장과 8장의 내용을 실무에 적용하는 역할이다.

도메인 전문가는 기술자가 아니라 해당 도메인(HR, 금융, 제조 등)을 잘 아는 사람이다. 온톨로지 설계의 방향을 잡아주고, 답변 품질을 평가하며, 새로운 요구사항을 전달하는 핵심 연결고리다. 이 역할이 빠지면, 기술적으로는 훌륭하지만 실무와 동떨어진 시스템이 만들어질 위험이 있다.

역량 확보 전략

모든 역량을 내부에서 키울 필요는 없다. 세 가지 경로를 조합하자.

내부 교육으로 기존 개발자의 역량을 확장한다. 이 책이 바로 그 교육 교재가 될 수 있다. 백엔드 개발자가 Neo4j와 Cypher를 익히는 데 2~4주, 온톨로지 기초를 이해하는 데 1~2주면 파일럿에 참여할 수 있는 수준이 된다.

외부 파트너를 활용하면 초기 구축 속도를 높일 수 있다. 온톨로지 설계나 Neo4j 아키텍처 컨설팅을 외부 전문가에게 맡기고, 내부 팀이 이를 학습하며 점차 내재화하는 전략이다. 다만 외부 의존도가 높아지면 장기적으로 비용 부담이 커지므로, 지식 이전(Knowledge Transfer)을 계약에 명시해두는 편이 낫다.

커뮤니티 참여도 중요한 학습 경로다. Neo4j 커뮤니티, 온톨로지 관련 학회나 밋업, 그리고 오픈소스 프로젝트 참여를 통해 실무 노하우를 빠르게 축적할 수 있다.

ROI 산출과 경영진 설득

기술 리더에게 가장 찜찜한 순간 중 하나는, 좋은 기술임을 알지만 경영진에게 투자 대비 효과를 설명해야 할 때다. “지식그래프가 좋습니다”로는 예산이 나오지 않는다. 숫자가 필요하다.

비용 항목 정리

지식그래프 프로젝트의 비용을 투명하게 정리하자.

초기 구축 비용에는 온톨로지 설계(인력 시간), Neo4j 인프라 구축, GraphRAG 파이프라인 개발, 데이터 마이그레이션이 포함된다. 파일럿 규모라면 개발자 2~3명이 3개월 투입하는 수준이다.

운영 비용에는 Neo4j 인프라(AuraDB 구독 또는 서버 비용), LLM API 호출 비용, 온톨로지 유지보수 인력, 모니터링 도구 비용이 포함된다. 10장에서 다룬 비용 최적화 전략을 적용하면 운영 비용을 상당히 절감할 수 있다.

효과 항목 정리

비용만큼 중요한 것이 효과의 정량화다.

직접 효과는 측정이 비교적 쉽다. HR 문의 응답 시간 단축(인사팀 업무 시간 절감), 반복 질문 자동화율(인력 재배치 가능), 신입 직원 온보딩 시간 단축이 대표적이다.

간접 효과는 정량화가 어렵지만 가치가 크다. 정보 접근성 향상으로 인한 직원 만족도 개선, 정책 적용 일관성 향상(잘못된 안내로 인한 민원 감소), 그리고 지식그래프 인프라를 다른 도메인으로 확장할 때의 한계 비용 감소가 여기에 해당한다.

엔터프라이즈 사례의 활용

경영진 설득에서 동종 업계 사례는 강력한 무기다. 리서치 자료에서 확인한 수치를 활용하자.

엔터프라이즈 지식그래프가 300~320% ROI를 달성했다는 보고가 있으며, 금융 서비스 기업이 하이브리드 RAG 아키텍처로 내부 정책 챗봇을 구축하여 컴플라이언스 질문에 92% 정확도를 달성한 사례, 제조 현장에서 온톨로지 기반 대화형 AI로 기술 문서 접근성을 향상시킨 사례, 온보딩 자동화로 신규 입사자 적응 시간을 단축한 사례가 있다.

물론 이 수치들이 우리 조직에 그대로 적용된다고 말하면 안 된다. “이 정도 규모의 기업에서 이런 성과가 보고되고 있으며, 우리 파일럿에서 이를 검증하겠다”는 논리가 더 설득력 있다.

제안서 구조

경영진에게 보내는 제안서의 뼈대를 잡아보자.

  1. 현재 문제 — HR 문의 대응의 현재 비용과 비효율 (기저선 데이터)
  2. 제안 솔루션 — 온톨로지 기반 지식그래프 + GraphRAG 안내봇
  3. 기대 효과 — 정량적 효과 (시간 절감, 정확도 향상) + 정성적 효과 (직원 경험 개선)
  4. 동종 업계 사례 — 위에서 정리한 성공 사례와 수치
  5. 투자 규모 — 파일럿 3개월 비용 + 확장 시 추가 비용
  6. 로드맵 — Phase 1(파일럿) → Phase 2(확장) → Phase 3(플랫폼화)
  7. 리스크와 대응 — 실패 시 매몰 비용 최소화 방안

도입 로드맵 — 세 단계로 나아가기

Phase 1: 파일럿 (3개월)

목표: 지식그래프의 가치를 증명하고, 팀의 핵심 역량을 확보한다.

활동: - HR 복리후생 도메인으로 범위 한정 - 온톨로지 설계 → Neo4j 구축 → GraphRAG 파이프라인 개발 - 50~100명 대상 베타 테스트 - 정확도, 응답 시간, 사용자 만족도 측정

산출물: 동작하는 HR안내봇 프로토타입, 파일럿 성과 보고서, Phase 2 제안서

인력: 개발자 2~3명, 도메인 전문가(HR) 1명 (파트타임)

KPI: 질문 응답 정확도 85% 이상, 인사팀 반복 문의 대응 시간 50% 감소

Phase 2: 확장 (6개월)

목표: 파일럿의 성공을 기반으로 대상 범위를 확대하고, 프로덕션 품질을 확보한다.

활동: - 대상 도메인 확대 (HR 전체: 급여, 평가, 채용, 교육) - 프로덕션 아키텍처 구축 (10장의 체크리스트 적용) - 품질 관리 체계 정식 가동 (9장의 프로세스 적용) - 전사 오픈 (직원 전체 대상) - GDS 기반 분석 기능 추가 (조직 네트워크 분석, 핵심 인물 식별)

산출물: 프로덕션 수준의 HR안내봇, 운영 대시보드, 온톨로지 거버넌스 프로세스

인력: 개발자 3~4명, 온톨로지 엔지니어 1명, 도메인 전문가 2명

KPI: 질문 응답 정확도 90% 이상, 월간 활성 사용자 80% 이상, 인사팀 업무 시간 30% 절감

Phase 3: 플랫폼화 (12개월)

목표: HR안내봇의 성공 경험을 다른 도메인으로 확대하여, 조직 전체의 지식 인프라로 발전시킨다.

활동: - 지식그래프 플랫폼 구축 (온톨로지 관리, 데이터 파이프라인, GraphRAG 템플릿) - 새로운 도메인 적용 (IT 헬프데스크, 규정 준수, 프로젝트 관리 등) - 도메인별 온톨로지 연합(Federation) 구조 설계 - AI 에이전트 통합 (Agentic 패턴으로 진화)

산출물: 멀티 도메인 지식그래프 플랫폼, 도메인별 안내봇 템플릿, 조직 지식 대시보드

인력: 전담 플랫폼 팀 5~7명 + 도메인별 지원 인력

KPI: 3개 이상 도메인 적용, 플랫폼 재사용율 60% 이상, 전사 지식 접근 만족도 향상


기술 리더 의사결정 박스: 도입 로드맵을 어떻게 설계할 것인가?

단계 기간 투자 규모 인력 핵심 KPI
Phase 1 (파일럿) 3개월 낮음 2~3명 정확도 85%, 반복 문의 50% 감소
Phase 2 (확장) 6개월 중간 4~6명 정확도 90%, 업무 시간 30% 절감
Phase 3 (플랫폼화) 12개월 높음 5~7명+ 3개 도메인 적용, 재사용율 60%

판단 기준: Phase 1의 결과가 KPI를 충족하지 못하면, Phase 2 진입 전에 원인을 분석하고 방향을 수정한다. 때로는 “지금은 아니다”라는 결론도 올바른 의사결정이다. 파일럿의 가치는 성공뿐 아니라, 실패에서 배우는 데에도 있다. 각 Phase 사이에 Go/No-Go 게이트를 두고, 데이터 기반으로 결정하는 편이 낫다.


2026년 이후 전망 — 지식그래프가 향하는 곳

기술 리더라면 지금의 투자가 미래에도 유효한지 궁금할 것이다. 지식그래프와 온톨로지 생태계가 어디로 향하고 있는지 살펴보자.

GQL — 그래프 쿼리 언어의 ISO 표준화

GQL(Graph Query Language)은 SQL에 이어 두 번째로 ISO 국제 표준(ISO/IEC 39075)으로 채택된 데이터베이스 쿼리 언어다. 이 표준화가 갖는 의미는 상당하다.

그동안 그래프 데이터베이스마다 서로 다른 쿼리 언어를 사용했다. Neo4j는 Cypher, Amazon Neptune은 Gremlin, TigerGraph는 GSQL이었다. 마치 관계형 DB가 SQL로 통일되기 전, 각 벤더마다 제각각의 언어를 쓰던 시절과 비슷하다. GQL은 이 파편화를 해소할 표준이다.

Neo4j의 Cypher는 GQL의 설계에 크게 기여했고, 양쪽은 높은 호환성을 갖는다. 이 책에서 배운 Cypher 역량은 GQL 시대에도 거의 그대로 활용할 수 있다는 뜻이다. 기술 투자의 지속성 측면에서 안심할 수 있는 부분이다.

GQL 표준화가 가져올 변화를 생각해보자. 벤더 종속(Vendor Lock-in) 우려가 줄어들고, 그래프 DB 간 마이그레이션이 쉬워진다. 그래프 기술을 교육하는 표준 커리큘럼이 만들어지면서 인력 풀이 넓어진다. 더 많은 도구와 라이브러리가 GQL을 지원하면서 생태계가 풍요로워진다.

물론 표준화가 하루아침에 모든 것을 바꾸지는 않는다. SQL이 표준화된 후에도 각 DB의 방언(Dialect)이 존재하듯, GQL도 벤더별 확장이 있을 것이다. 하지만 핵심 문법과 개념이 표준으로 수렴하는 것만으로도 생태계에 긍정적인 영향을 미친다.

기술 리더로서 GQL 표준화에 대해 지금 해야 할 일은 무엇일까? 당장 Cypher에서 GQL로 전환할 필요는 없다. Neo4j가 Cypher와 GQL의 호환성을 적극적으로 관리하고 있기 때문이다. 하지만 팀의 학습 로드맵에 GQL 기초를 포함시키고, 신규 프로젝트의 기술 선택 시 GQL 호환성을 평가 기준에 넣어두는 것은 의미가 있다. 표준이 성숙해지는 시점에 빠르게 전환할 수 있는 준비 자세를 갖추는 것이다.

Agentic AI와 지식그래프의 영속 메모리 패턴

2026년 AI 분야에서 가장 주목받는 키워드 중 하나는 “Agentic AI”다. 단순히 질문에 답하는 것을 넘어, AI 에이전트가 스스로 계획을 세우고, 도구를 사용하며, 작업을 수행하는 패턴이다.

여기서 지식그래프는 AI 에이전트의 영속 메모리(Persistent Memory) 역할을 한다. 에이전트가 작업을 수행하면서 새로 알게 된 사실을 지식그래프에 기록하고, 다음 작업 시 이 그래프를 참조하여 더 나은 의사결정을 내리는 구조다.

이것이 왜 중요한지 생각해보자. 현재의 LLM은 대화가 끝나면 맥락을 잊는다. 매번 처음부터 컨텍스트를 다시 제공해야 한다. 하지만 지식그래프가 영속 메모리로 작동하면, 에이전트는 이전 대화와 작업에서 축적한 지식을 누적적으로 활용할 수 있다.

HR안내봇에 적용하면 이런 시나리오가 가능하다. 직원이 “지난달에 물어본 육아휴직 관련 후속 절차가 궁금합니다”라고 하면, 에이전트가 지식그래프에서 이전 상호작용 이력을 찾아 맥락을 이어받는 것이다. 또는 “올해 경조사비를 이미 사용했나요?”라는 질문에, 에이전트가 사용 이력 노드를 탐색하여 즉시 답변하는 것이다.

이 패턴이 왜 기술 리더에게 중요할까? 지식그래프 인프라를 먼저 갖춘 조직은, Agentic AI 시대에 경쟁 우위를 가진다. 에이전트가 활용할 수 있는 구조화된 지식 기반이 이미 존재하기 때문이다. 지식그래프 없이 에이전트를 구축하면, 에이전트는 매번 비정형 데이터에서 정보를 추출해야 하므로 느리고 부정확하다. 반면 온톨로지로 구조화된 지식그래프가 있으면, 에이전트는 정확한 사실에 빠르게 접근하여 신뢰할 수 있는 의사결정을 내린다.

이 책에서 구축한 HR안내봇의 지식그래프와 GraphRAG 파이프라인은, 향후 Agentic 패턴으로 자연스럽게 진화할 수 있는 기반이다. 지금의 투자가 미래에 두 배, 세 배의 가치를 돌려줄 수 있다는 뜻이다.

온톨로지의 부활

온톨로지는 한때 학술적이고 이론적인 영역으로 여겨졌다. 실무에서의 활용이 제한적이라는 인식도 있었다. 하지만 AI 시대에 온톨로지는 “신뢰할 수 있는 AI 에이전트, 거버넌스된 데이터 시스템, 시맨틱 검색의 기초 인프라”로 재조명되고 있다.

그 이유는 명확하다. LLM이 강력해질수록, “어떤 데이터를 믿을 수 있고, 데이터 간의 관계는 어떻게 정의되어야 하는가”라는 질문이 더 중요해진다. LLM이 생성하는 답변의 신뢰도를 높이려면, 구조화된 지식 기반이 필요하다. 온톨로지가 그 역할을 한다.

이 책에서 배운 온톨로지 설계, 지식그래프 구축, GraphRAG 역량은 앞으로 더 가치가 높아질 것이다. 기술의 유행은 순환하지만, 데이터를 구조화하고 관계를 명시하는 능력은 어떤 시대에서든 유효하다.

HR안내봇 — 전체 회고

1장부터 이 장까지 우리가 함께 만든 HR안내봇의 여정을 되돌아보자.

1장에서 “육아휴직 중 연차는 어떻게 되나요?”라는 질문으로 문제를 정의했다. 키워드 검색과 벡터 RAG의 한계를 확인하고, 데이터 간 관계를 구조화하는 접근이 필요하다는 직관을 얻었다.

2장에서 온톨로지라는 이름을 배웠다. 클래스, 관계, 공리 — 도메인의 설계도를 그리는 법을 익혔다. HR 도메인의 핵심 클래스 5개와 주요 관계를 종이 위에 스케치했다.

3장에서 Neo4j에 첫 데이터를 넣었다. Cypher의 패턴 매칭이 “인사팀 소속 직원 목록”을 직관적으로 표현할 수 있다는 것을 확인했다. 이때의 샘플 데이터가 이후 마이그레이션의 출발점이 되리라는 것은 아직 몰랐다.

4장에서 온톨로지를 본격적으로 설계했다. Protege로 수동 설계하는 경로와 LLM으로 자동 생성하는 경로를 나란히 걸었다. “충분히 좋은 온톨로지가 완벽한 온톨로지보다 낫다”는 교훈을 새겼다.

5장에서 설계한 온톨로지를 Neo4j에 심었다. 3장의 샘플 데이터를 온톨로지 스키마로 마이그레이션하면서, 레거시 전환의 실무 감각을 체득했다. 100건 규모의 지식그래프가 쿼리 가능한 상태가 되었다.

6장에서 GDS로 그래프의 숨은 구조를 파헤쳤다. 공식 조직도에 없는 비공식 커뮤니티를 발견하고, 퇴사 시 정보 흐름이 끊기는 핵심 인물을 식별했다. 분석 결과를 그래프에 기록하여 이후 활용할 수 있게 했다.

7장에서 AI와 지식그래프를 연결하는 네 가지 패턴을 비교하고, HR안내봇에 가장 적합한 Graph-Enhanced RAG 패턴을 선택했다. 하이브리드 RAG 아키텍처의 설계를 완료했다.

8장에서 모든 것을 조합하여 HR안내봇을 완성했다. 20개 시나리오로 검증하고, 멀티홉 추론과 GDS 기반 분석 질문에도 답변하는 프로토타입을 손에 쥐었다.

9장에서 품질 관리 체계를 세웠다. 추론기로 온톨로지를 검증하고, 네 가지 메트릭으로 데이터 품질을 측정하며, 온톨로지 진화를 관리하는 프로세스를 정의했다. LLM이 새 정책 문서에서 온톨로지 변경을 자동 제안하는 기능까지 추가했다.

10장에서 프로덕션 아키텍처를 설계했다. 인덱스, 캐싱, 보안, 모니터링 — 프로토타입과 프로덕션 사이의 간극을 메우는 작업을 하나씩 수행했다.

그리고 이 장에서, 이 기술을 조직에 도입하는 전략을 세웠다.

한 권의 책에서 온톨로지의 개념부터 프로덕션 배포, 조직 도입 전략까지 도달한 것이다. HR안내봇은 단순한 실습 프로젝트가 아니라, 온톨로지 기반 지식그래프 시스템의 전체 생애주기를 경험하는 축소판이었다.

다른 도메인으로의 확장 — HR을 넘어서

HR안내봇에서 배운 패턴은 다른 도메인에도 적용할 수 있다. 몇 가지 가능성을 살펴보자.

금융 컴플라이언스: 규정, 내부 정책, 거래 규칙 간의 관계를 지식그래프로 구조화한다. “이 거래가 어떤 규정에 위반되는가?”라는 질문에 추론 경로와 함께 답변하는 시스템이다. 리서치 자료에서 92% 정확도를 달성한 사례가 이미 있다.

제조 현장 지식: 장비, 공정, 부품, 매뉴얼 간의 관계를 온톨로지로 정의한다. “이 장비의 고장 원인으로 가능한 것은?”이라는 질문에, 관련 매뉴얼과 유사 사례를 그래프 탐색으로 찾아주는 시스템이다.

IT 헬프데스크: 시스템, 서비스, 설정, 장애 이력 간의 관계를 그래프로 관리한다. “VPN 접속이 안 됩니다”라는 신고에, 관련 시스템의 상태와 최근 변경 이력을 자동으로 수집하여 해결 방안을 제시한다.

프로젝트 관리: 프로젝트, 마일스톤, 업무, 인력, 의존성 간의 관계를 지식그래프로 구조화한다. “이 마일스톤이 지연되면 어떤 프로젝트에 영향이 가는가?”라는 질문에 영향 범위를 그래프 탐색으로 즉시 파악한다.

어떤 도메인이든, 핵심 패턴은 동일하다. (1) 도메인 온톨로지 설계, (2) 지식그래프 구축, (3) GraphRAG 파이프라인 연결, (4) 품질 관리와 운영. 이 책에서 HR안내봇을 통해 이 패턴을 한 번 경험했으므로, 두 번째 도메인은 훨씬 빠르게 진행할 수 있다.

여기서 핵심적인 통찰이 있다. Phase 3에서 “플랫폼화”를 말했는데, 그 본질은 이 네 단계 패턴을 재사용 가능하게 만드는 것이다. 온톨로지 설계 도구와 프로세스, GraphRAG 파이프라인 템플릿, 품질 평가 프레임워크, 모니터링 대시보드 — 이런 것들을 공통 플랫폼으로 만들어두면, 새 도메인을 추가할 때마다 처음부터 다시 만들 필요가 없다. 첫 번째 도메인(HR)에서 3개월 걸린 작업이, 세 번째 도메인에서는 1개월로 줄어든다. 이것이 플랫폼의 힘이고, 경영진에게 장기 투자의 가치를 설명할 때 강력한 논거가 된다.

도입 과정에서 만나는 조직적 저항

기술적으로 완벽한 계획도, 조직의 저항 앞에서는 무력해질 수 있다. 미리 알고 대비하면 충격을 줄일 수 있다.

“기존 시스템으로도 충분한데?”라는 반응은 가장 흔하다. 현재 시스템에 익숙한 사람들은 변화의 필요성을 체감하지 못한다. 이때는 구체적인 실패 사례를 보여주는 것이 효과적이다. “지난달에 잘못된 정책 안내로 인해 발생한 민원 건수”, “인사팀이 반복 질문 대응에 소비하는 시간” 같은 현재의 비용을 가시화하자.

“우리 팀에 그래프 DB를 다룰 수 있는 사람이 없다”는 우려도 자주 나온다. 맞는 말이다. 하지만 이 책을 한 권 읽은 당신이 바로 그 첫 번째 사람이 아닌가? 처음에는 소수의 얼리 어답터가 시작하고, 파일럿의 성공이 더 많은 사람을 끌어들인다. 그리고 앞서 다룬 것처럼, 모든 역량을 내부에서 키울 필요는 없다. 외부 파트너와의 협업으로 초기 역량 갭을 메울 수 있다.

“투자 대비 효과가 불확실하다”는 경영진의 우려는, ROI 산출과 파일럿 전략으로 대응한다. “전사 도입이 아니라 3개월 파일럿입니다. 비용은 이 정도이고, 성공 기준은 이것입니다. 기준에 미달하면 중단하겠습니다.” 이렇게 리스크를 한정하면, 의사결정자의 부담이 줄어든다.

마무리 — 당신의 첫 번째 그래프를 응원하며

이 책의 처음으로 돌아가보자. “왜 지식그래프인가?”라는 질문에서 출발했다. 그 답을 이제 당신 자신의 언어로 할 수 있을 것이다.

온톨로지는 도메인의 설계도이고, 지식그래프는 그 설계도 위에 세운 건물이다. Neo4j는 그 건물을 담는 그릇이고, GraphRAG는 건물 안의 지식을 사람에게 전달하는 통로다. GDS는 건물의 숨은 구조를 밝혀주는 조명이다.

기술은 도구일 뿐이다. 중요한 것은 “어떤 문제를 풀 것인가”다. HR안내봇이 해결한 문제 — 흩어진 지식을 구조화하고, 복잡한 관계를 탐색하여, 사람에게 정확한 답을 전달하는 것 — 은 거의 모든 조직이 안고 있는 문제다.

기억해두자. 완벽한 온톨로지보다 “충분히 좋은” 온톨로지가 낫고, 거대한 계획보다 작은 파일럿의 성공이 조직을 움직인다. GQL 표준화와 Agentic AI의 흐름은 지식그래프의 가치를 더 높여줄 것이다.

이 책을 덮고 나서, 당신이 할 일은 하나다. 자신의 도메인에서 “지식 간의 관계가 중요한 문제” 하나를 찾아, 작은 그래프를 그려보는 것이다. 노드 다섯 개, 관계 열 개로 시작해도 좋다. 그 작은 그래프가 당신의 조직에 지식그래프를 심는 첫 번째 씨앗이 될 것이다.

함께 걸어온 이 여정이 당신의 다음 프로젝트에 든든한 지도가 되기를 바란다.


이번 장에서 HR안내봇에 추가된 것: - 도입 타당성 평가 프레임워크 (5가지 판단 기준) - 파일럿 → 확장 → 플랫폼화 3단계 도입 로드맵 - ROI 산출 템플릿과 경영진 설득 제안서 구조 - HR안내봇 전체 프로젝트 회고와 다른 도메인 확장 청사진 - 완성. 1장에서 시작한 HR안내봇이 조직에 도입할 준비를 마쳤다.


에필로그

1장에서 “육아휴직 중 연차는 어떻게 되나요?”라는 한 줄의 질문으로 시작했다. 그리고 11개 챕터를 거쳐 여기까지 왔다. 온톨로지로 도메인의 설계도를 그리고, Neo4j에 지식그래프를 심고, GDS로 숨겨진 패턴을 발견하고, GraphRAG로 AI에게 구조화된 지식을 전달했다. 한 줄의 질문이 하나의 시스템이 되었다.

돌아보면, 이 여정의 핵심은 기술이 아니라 관점의 전환이었다. 데이터를 행과 열이 아니라 노드와 관계로 보기 시작하면, 세상이 다르게 보인다. 조직도가 그래프로 보이고, 정책 문서가 온톨로지로 보이고, 고객 여정이 경로 탐색 문제로 보인다. 이 관점은 HR안내봇을 넘어 어디에든 적용할 수 있다.

이 책이 끝이 아니라 시작이다. 당신의 도메인에서, 당신의 팀과 함께, 첫 번째 지식그래프를 심어보자. 작게 시작하되, 그래프로 생각하자.