본문 바로가기
AI

온톨로지 기반 LLM 구축 실습하기! [Neo4j 지식그래프와 Text2Cypher, 그리고 로컬 소형 모델의 성적표]

by 클수저 2026. 7. 22.
728x90
반응형

앞선 내용에서는 WSL + Ollama 완전 무료 환경으로 LangChain 기초와 벡터 RAG를 실습했다. 
마지막 실험에서 벡터 RAG가 멀티홉 질문("김철수가 다니는 회사의 파트너사는?")에 "근거가 없어서 모른다" 고 답하는 구조적 한계를 재현했다.
이번 편에서는 그 한계를 해결하는 지식그래프(Neo4j) 를 구축하고, 자연어로 그래프에 질문하는 Text2Cypher 까지 실습한다. 결론을 미리 말하면, 파이프라인은 완주했고, 로컬 소형 모델의 한계를 실측 데이터로 확보했다. 이번에도 실제 에러 로그와 해결 과정을 그대로 담았다.

 


 

1편 마지막 실험의 실패 원인은 명확했다:
"김철수 → 에이컴퍼니 소속"이라는 관계가 어디에도 명시적으로 저장되어 있지 않았다.
해법은 관계를 데이터로 저장하는 것이다.

(김철수) -[근무한다]-> (에이컴퍼니) -[파트너십]-> (비테크)

이렇게 저장하면 답은 유사도 '검색'이 아니라 화살표를 따라가는 '탐색'으로 확정적으로 나온다. 

  1. Docker로 Neo4j(그래프 DB) 실행
  2. Cypher 질의를 손으로 작성해 그래프 생성·조회 (개념 체득)
  3. 1편에서 실패한 질문을 그래프 탐색으로 해결
  4. 문서 → 그래프 변환을 LLM으로 자동화 (온톨로지 제약 추출)
  5. 자연어 → Cypher 번역(Text2Cypher)으로 그래프에 질문

1. Neo4j (그래프 데이터베이스란??)

 

Neo4j Graph Intelligence Platform

Connect data as it's stored with Neo4j. Perform powerful, complex queries at scale and speed with our graph data platform.

neo4j.com

MySQL이 데이터를 표(테이블)로 저장하는 DB라면, Neo4j는 데이터를 그래프(동그라미와 화살표)로 저장하는 DB다.

저장 단위는 세 가지뿐이다.

요소 표기 예시
노드 (개체) ( ) (p:Person {name:'김철수'})
관계 (방향+타입 보유) [ ] -[:WORKS_AT]->
속성 (키-값) { } {name:'에이컴퍼니', founded:2015}
  • 이 구조는 온톨로지의 구조(개념과 관계)와 정확히 일치한다.
    그래서 온톨로지/지식그래프의 저장소로 그래프 DB를 쓴다. 그리고 SQL에 해당하는 전용 질의 언어가 Cypher다.
  • 또 하나의 비유: Docker로 띄우는 것이 Neo4j 서버(DB 본체, 7687 포트) 이고, 웹으로 접속하는 화면은 Neo4j Browser(관리 도구, 7474 포트) 다.
    • DBeaver가 MySQL에 SQL을 날리는 클라이언트이듯, Neo4j Browser는 Cypher를 날리는 클라이언트다. 차이는 결과를 표가 아니라 실제 그래프 그림으로 그려준다는 것. 나중에 만들 파이썬 코드도 이 Browser와 똑같은 자격으로 7687 포트에 접속하는 또 하나의 클라이언트일 뿐이다.

2. Docker로 Neo4j 실행

  • 전제: Windows에 Docker Desktop 설치 + WSL Integration 활성화.
  • WSL 터미널에서:

  • 옵션 해석: -d 백그라운드 실행,
  • --name 컨테이너 이름 지정,
  • -p 7474/7687 웹 화면·데이터 통신 포트 연결,
  • -e NEO4J_AUTH 초기 계정 지정.

실행하면 Unable to find image 'neo4j:5' locally가 뜨는데 에러가 아니다  ❌❌

"로컬에 이미지가 없으니 받아오겠다"는 정상 동작이고, Pull complete 후 출력되는 긴 문자열이 컨테이너 ID다.
docker ps로 STATUS가 Up인지 확인하고, 브라우저에서 http://localhost:7474 접속 → neo4j / password123 로그인. 접속 화면의 Nodes (0), Relationships (0)은 "연결됐고 아직 비어 있다"는 뜻이다.


3. 손으로 Cypher 실습 (그리고 SyntaxError 2연발)

3-1. 그래프 생성 

결과 메시지가 기대값과 정확히 일치했다:

Created 3 nodes, created 2 relationships, set 4 properties, added 3 labels
  • 노드 3(김철수·에이컴퍼니·비테크), 관계 2(근무한다·파트너십), 속성 4(name×3 + founded×1), 레이블 3(Person×1 + Company×2). 사이드바에도 반영됐다. 첫 지식그래프가 DB에 저장된 것.
  • MATCH (n) RETURN n을 실행하면 동그라미 3개가 화살표로 연결된 그래프 그림이 나온다(드래그로 움직일 수도 있다).
    참고로 사이드바의 관계 뱃지를 클릭하면 MATCH p=()-[:근무한다]->() RETURN p 같은 질의가 자동 생성되는데, 이건 해당 관계가 있는 경로만 보여주는 것이라 일부 노드만 나오는 게 정상이다.

에러 8: Cypher SyntaxError 2건 (원인은 줄바꿈)

멀티홉 검증 질의를 두 줄로 붙여넣자 에러 두 개가 떴다:

  • 원인: 첫 번째는 "RETURN partner.name이 단독 실행됐는데 partner가 정의된 적 없다",
    두 번째는 "MATCH ... 줄이 단독 실행됐는데 MATCH로 끝나면 안 된다".
    즉 두 줄짜리 질의가 줄마다 별개의 명령으로 쪼개져 실행된 것이다. Neo4j Browser 입력창에서 Enter는 "실행"이라, 붙여넣기 중 줄바꿈 시점에 첫 줄이 먼저 실행돼 버렸다.
  • 해결: 한 줄로 붙여서 실행. (팁: 입력창에서 줄바꿈이 필요하면 Shift+Enter)
MATCH (p:Person {name:'김철수'})-[:근무한다]->(c:Company)-[:파트너십]->(partner) RETURN partner.name

이 에러들은 조회 시도였을 뿐이라 데이터는 전혀 건드리지 않았다. 그리고 값진 예습이기도 했다 — 나중에 LLM이 Text2Cypher로 잘못된 Cypher를 만들면 우리가 받게 될 에러가 정확히 이 종류(SyntaxError)다.

3-2. 하이라이트 : "비테크"

  • 같은 데이터, 같은 질문에 대해 : 벡터 RAG는 "근거가 없어서 모른다"였고, 지식그래프는 105ms 만에 확정 답을 냈다.
  • 차이는 모델 성능이 아니라 저장 구조다. 벡터 쪽은 관계가 청크 분할 때 소실됐고, 그래프 쪽은 관계가 명시적 화살표로 저장돼 있어 따라가기만 하면 됐다.
    • 질의 읽는 법: "김철수 Person 노드에서 → 근무한다 화살표를 따라가고 → 파트너십 화살표를 한 번 더 따라가서 → 도착 노드의 이름을 반환하라." 멀티홉이 그래프에서는 그냥 화살표 두 번 따라가기다.

4. 구축 자동화 : 문서를 LLM이 읽고 그래프로 변환

방금 손으로 한 일(문서를 읽고 → 개체·관계를 파악해 → CREATE 작성)을 실무에서는 문서 수천 건에 대해 해야 하므로 LLM에게 시킨다. 이때 "어떤 노드/관계 타입만 허용할지" 제한하는 것이 온톨로지의 역할이다.

pip install langchain-neo4j langchain-experimental

전체 코드 (step3_graph_build.py)

"""[3단계] 문서 -> LLM 추출(온톨로지 제약) -> Neo4j 자동 적재"""
from langchain_ollama import ChatOllama
from langchain_core.documents import Document
from langchain_neo4j import Neo4jGraph
from langchain_experimental.graph_transformers import LLMGraphTransformer

# (0) Neo4j 접속
graph = Neo4jGraph(url="bolt://localhost:7687",
                   username="neo4j", password="password123")
graph.query("MATCH (n) DETACH DELETE n")   # 기존 그래프 삭제 후 새로 구축
print("기존 그래프 삭제 완료")

# (1) 온톨로지 정의 = 허용할 노드/관계 타입 (실무 관례상 영문 대문자)
ALLOWED_NODES = ["Person", "Company", "Product", "Technology"]
ALLOWED_RELATIONSHIPS = ["WORKS_AT", "DEVELOPS", "USES", "PARTNERS_WITH"]

# (2) 원본 문서 (1편 step2와 동일 텍스트)
company_doc = """
에이컴퍼니는 2015년 설립된 스마트팩토리 솔루션 기업이다.
주력 제품은 설비 예지보전 시스템 '팩토리아이'로, 센서 데이터를 분석해 고장을 예측한다.
에이컴퍼니는 2023년부터 비테크와 파트너십을 맺고 온톨로지 기술을 도입했다.
온톨로지 도입 이후 설비-부품-공급사 관계를 지식그래프로 관리하고 있다.
직원 김철수는 데이터플랫폼팀 소속으로 지식그래프 구축을 담당한다.
"""

# (3) LLM 추출기: 온톨로지 제약을 걸고 노드/관계 추출
llm = ChatOllama(model="llama3.2", temperature=0)
transformer = LLMGraphTransformer(llm=llm,
    allowed_nodes=ALLOWED_NODES,
    allowed_relationships=ALLOWED_RELATIONSHIPS)
graph_docs = transformer.convert_to_graph_documents(
    [Document(page_content=company_doc)])

# (4) 추출 결과 확인 후 적재
for gd in graph_docs:
    print("노드:", [(n.id, n.type) for n in gd.nodes])
    print("관계:", [(r.source.id, r.type, r.target.id) for r in gd.relationships])
graph.add_graph_documents(graph_docs)
graph.refresh_schema()   # 적재 후 스키마 갱신 (Text2Cypher 정확도에 필수)

DETACH DELETE는 "관계까지 끊고 노드 삭제"라는 Cypher다. 그런데 첫 실행에서 곧바로 에러를 만났다.

에러 9: APOC 플러그인 부재 (ProcedureNotFound)

  • 원인: APOC(Awesome Procedures On Cypher)은 Neo4j의 확장 함수 모음 플러그인이다.
    그중 apoc.meta.data()가 "DB 스키마를 요약해 주는" 함수인데, langchain-neo4j의 Neo4jGraph는 접속하자마자 이 함수로 스키마를 읽는다(나중에 Text2Cypher 프롬프트에 넣기 위해).
    우리가 띄운 순정 Neo4j에는 이 플러그인이 없어서, 파이썬 코드가 사실상 첫 줄(Neo4jGraph(...) 생성)에서 실패했다.
  • 해결: 컨테이너를 APOC 포함 옵션으로 재생성. Neo4j 공식 이미지는 환경변수 하나로 플러그인을 자동 설치해 준다.

  • 🚫주의: 볼륨 없이 띄운 컨테이너라 삭제 시 안의 데이터(손으로 만든 그래프)도 함께 사라진다. 스크립트가 어차피 전부 지우고 새로 만들 예정이라 문제없었다.

출력 맨 위의 DeprecationWarning: langchain-experimental is being sunset 은 에러가 아니라 "이 패키지는 더 이상 활발히 유지보수되지 않는다"는 경고다. 학습용으로는 문제없지만, 실무 구축 시에는 Neo4j가 직접 유지보수하는 공식 대안(neo4j-graphrag)을 검토할 신호로 기억해 둘 것.

에러 10: ServiceUnavailable :  서버가 뜨기 전에 접속 시도

컨테이너 재생성 직후 바로 스크립트를 실행하자:

neo4j.exceptions.ServiceUnavailable: Couldn't connect to localhost:7687
(ConnectionResetError: [Errno 104] Connection reset by peer)
  • 원인: docker run이 끝났다는 건 "시작 버튼을 눌렀다"까지다. 이번 기동은 APOC 플러그인 다운로드 때문에 1~2분이 걸리는데, 그 사이에 접속을 시도해서 서버가 응답을 못 한 것. 타이밍 문제일 뿐 설정은 올바랐다.
  • 해결: 기동 완료를 로그로 확인한 뒤 실행.

원칙으로 일반화: "서버를 새로 띄웠으면, 클라이언트 실행 전에 서버 로그로 준비 상태를 확인한다."
Ollama든 Neo4j든 어떤 서버든 동일하다. (실무에서는 재시도 로직이나 Docker healthcheck로 자동화한다.)

실험 결과 1 — llama3.2(3B): 품질 낙제

  • 정답지는 노드 5개(김철수·에이컴퍼니·비테크·팩토리아이·온톨로지 기술)와 관계 4개(WORKS_AT, PARTNERS_WITH, DEVELOPS, USES)였다.
  • llama3.2의 답안: 노드 2개, 관계 0개. 관계 없는 지식그래프는 화살표 없는 지도다
    • 이 상태로는 그래프도 멀티홉 질문에 답할 수 없다. 문서를 읽고 정해진 스키마에 맞는 구조화 출력을 만드는 것은 3B 모델에게 가장 어려운 과업 중 하나라는 것이 실측으로 확인됐다.
      (파이프라인 자체 — 접속→삭제→추출→적재 — 는 처음으로 에러 없이 완주했다.)

실험 결과 2 : qwen3:4b: 속도 낙제

이제는 모델만 바꿔 재실행했다.

ollama pull qwen3:4b 후 sed -i 's/llama3.2/qwen3:4b/' step3_graph_build.py.


그런데 20분이 지나도 결과가 나오지 않았다. 새 터미널에서 진단:

$ ollama ps
NAME        SIZE      PROCESSOR    CONTEXT
qwen3:4b    3.2 GB    100% CPU     4096

$ free -h
Mem:  total 15Gi  ...  available 10Gi
Swap: 4.0Gi  used 32Ki
  • 진단: 메모리는 넉넉(available 10Gi, 스왑 사실상 0)하므로 메모리 가설은 기각.
  • 원인은 100% CPU : GPU 없이 순수 CPU 연산인데, qwen3는 답하기 전에 내부 추론(thinking)을 길게 생성하는 모델이라 "긴 추출 프롬프트 처리 + 긴 내부 추론 + 구조화 출력"이 전부 CPU에서 곱해져 시간이 폭발한 것.
    reasoning=False 옵션(내부 추론 끄기)으로 재시도했지만 역시 오래 걸려 Ctrl+C로 중단했다.

Ctrl+C 후의 긴 Traceback은 오류가 아니다.
핵심이 전부 KeyboardInterrupt인데, 이는 "사용자가 중단시켰다"는 사실을 보고하는 파이썬의 정상 반응이다.
프로그램이 실패한 게 아니라 우리가 멈춘 것이고, 데이터도 안전하다.

Step 3 구축 파트의 결론

모델 추출/품질 추출속도 판정
llama3.2 (3B) 노드 2/5, 관계 0/4 수 분 품질 낙제
qwen3:4b 미완료 CPU 20분+ 속도 낙제
"GPU 없는 로컬 소형 모델은 그래프 추출(구축) 작업에 부적합하다."
이 실측이 곧 실무 아키텍처의 근거가 된다: 구축(추출)은 품질이 생명이므로 대형 모델(유료 API 등)로 1회성 비용을 쓰고, 질의는 경량 모델로 운영하는 하이브리드 — 실제 GraphRAG 실무의 흔한 패턴이다. 문서 1건에 CPU 수십 분이면 실무 문서 수천 건은 계산이 서지 않는다는 것도 같은 결론을 가리킨다.

5. 우회: 정답 그래프를 직접 적재 그리고 온톨로지의 "실물"

학습을 계속하기 위해 "대형 모델이 추출했다면 나왔을 정답 그래프"를 Cypher로 직접 적재했다.

전체 코드 (step3_graph_load.py)

"""[3단계-대안] 정답 그래프를 Cypher로 직접 적재"""
from langchain_neo4j import Neo4jGraph

graph = Neo4jGraph(url="bolt://localhost:7687",
                   username="neo4j", password="password123")
graph.query("MATCH (n) DETACH DELETE n")
graph.query("""
CREATE (p:Person {name:'김철수'})
CREATE (a:Company {name:'에이컴퍼니', founded:2015})
CREATE (b:Company {name:'비테크'})
CREATE (f:Product {name:'팩토리아이'})
CREATE (t:Technology {name:'온톨로지'})
CREATE (p)-[:WORKS_AT]->(a)
CREATE (a)-[:PARTNERS_WITH]->(b)
CREATE (a)-[:DEVELOPS]->(f)
CREATE (a)-[:USES]->(t)
""")
graph.refresh_schema()
print(graph.schema)

실행 결과 : 이번 학습 전체에서 개념적으로 가장 중요한 출력물:

이것이 온톨로지의 실물이다.

이 세계에 어떤 개념이 존재하고, 각 개념이 어떤 타입의 속성을 가지며, 개념 사이에 어떤 관계가 어느 방향으로 허용되는지의 명세. 그리고 다음 단계에서 이 텍스트가 글자 그대로 LLM 프롬프트에 주입된다. 
"이 스키마를 가진 그래프에 질의하라. 여기 없는 노드/관계는 쓰지 마라." LLM이 존재하지 않는 관계로 엉터리 질의를 만들지 못하게 막는 가드레일이다. 온톨로지 설계 업무란 결국 이 텍스트의 품질(개념 구분, 관계 방향, 명명)을 책임지는 일이다.


6. 질의 파트 ,Text2Cypher: 자연어로 그래프에 질문하기

전체 코드 (step4_graph_query.py)

"""[4단계] 자연어 질문 -> LLM이 Cypher 생성 -> 그래프 조회 -> 답변"""
from langchain_ollama import ChatOllama
from langchain_neo4j import Neo4jGraph, GraphCypherQAChain

graph = Neo4jGraph(url="bolt://localhost:7687",
                   username="neo4j", password="password123")
llm = ChatOllama(model="llama3.2", temperature=0)

chain = GraphCypherQAChain.from_llm(
    llm=llm,
    graph=graph,              # graph.schema(온톨로지)가 프롬프트에 자동 주입됨
    verbose=True,             # LLM이 생성한 Cypher를 그대로 출력
    allow_dangerous_requests=True,
)

questions = ["김철수가 다니는 회사의 파트너사는 어디야?",
             "에이컴퍼니가 개발한 제품은 뭐야?"]
for q in questions:
    result = chain.invoke({"query": q})
    print("답변:", result["result"])

allow_dangerous_requests=True에 대한 정직한 주석: 이 체인은 LLM이 만든 Cypher를 검증 없이 실행하므로 이론상 파괴적 질의도 그대로 실행된다. 그래서 라이브러리가 위험 인지 플래그를 요구하며, 실무에서는 읽기 전용 DB 계정 등으로 방어한다. 학습용 장난감 데이터라 허용하고 진행.

실험 결과 3 한국어 질문: 두 가지 실패 유형이 한 번에

  • 질문 1 해부 : Cypher 생성 단계 실패. 오류가 두 겹이다:
    ① 김철수(Person)를 Company의 이름으로 취급 — (Person)-[:WORKS_AT]->(Company) 홉을 통째로 누락.
    ② DB에는 '김철수'(한글)로 저장돼 있는데 "Kim Cheol-su"로 멋대로 로마자 변환. 문법은 유효해서 실행은 됐지만 매칭 0건(Full Context: []) → 빈 근거로 "모른다".
  • 질문 2 해부 : Cypher 성공, 답변 생성 실패. 생성된 질의는 유효했고 DB가 정답('팩토리아이')을 반환했다.
    그런데 그 결과를 문장으로 만드는 마지막 LLM 호출이 눈앞의 정답을 못 읽고 "정보가 부족하다"고 답했다.

여기서 GraphRAG의 중요한 구조가 드러난다: 한 번의 질의에 LLM이 두 번 개입하며, 각각이 독립적인 실패 지점이다.

질문 1은 ①에서, 질문 2는 ②에서 실패했다.
답변이 영어로 나온 것도 같은 뿌리다 :  체인 내부 프롬프트가 영어라서 약한 모델일수록 영어에 끌려간다.

실험 결과 4  변인 통제: 같은 질문을 영어로

한국어 이해가 병목인지 분리 확인하기 위해 질문만 영어로 바꿨다(고유명사는 DB 값과 일치해야 하므로 한글 유지):

  1. Text2Cypher는 완치됐다.
    누락됐던 WORKS_AT 홉이 정확히 들어갔고, 고유명사를 한글 그대로 보존했고, 멀티홉 패턴을 제대로 조립했다.
    DB가 정답 '비테크'를 반환. 진단 확정: llama3.2의 Cypher 번역 능력 자체는 있으나 한국어 질문 이해가 병목이었다.
  2. 그러나 답변 생성은 여전히 전패. 정답이 Full Context에 뻔히 있는데 "정보가 부족하다".
    눈앞에 '비테크'를 쥐여줘도 문장으로 못 만드는 상태 -> 내부 QA 프롬프트의 "컨텍스트에 기반해서만 답하라"는 지시를 과잉 적용해 정답마저 거부하는, 소형 모델의 전형적 오작동 패턴으로 보인다.

7. 이번 편의 결론 : 로컬 3B 모델의 최종 성적표

작업  llama3.2 성적 비고
그래프 추출 (구축) 낙제 (관계 0개) qwen3:4b는 속도로 낙제
Text2Cypher (질문→Cypher) 영어 한정 합격 한국어는 홉 누락·고유명사 변조
답변 생성 (결과→문장) 낙제 정답을 쥐여줘도 거부
파이프라인 (배관) 전 구간 정상 스키마 주입→질의 실행→결과 전달
핵심 통찰: 시스템(배관)은 완벽하게 설계대로 굴러갔고, 양쪽 끝의 LLM(두뇌)이 부족했다.
그리고 이 부족함을 verbose 출력 덕분에 단계별로 정확히 해부할 수 있었다.
Generated Cypher와 Full Context를 보는 습관이 GraphRAG 디버깅의 기본기다.

 

728x90
반응형