LLMGraphTransformer: 문서 → 지식 그래프

텍스트에서 엔티티와 관계를 자동 추출하여 Neo4j에 저장하기

LangChain의 LLMGraphTransformer를 사용해 비구조화 텍스트에서 엔티티(노드)와 관계(엣지)를 LLM으로 자동 추출하고 Neo4j에 저장하는 파이프라인을 구축한다. 스키마 정의, 엔티티 해결(Entity Resolution), 대규모 문서 배치 처리 방법을 포함한다.

AI
RAG
GraphRAG
Neo4j
저자

Kwangmin Kim

공개

2026년 03월 08일

1 LLMGraphTransformer: 문서 → 지식 그래프

1.1 핵심 아이디어

"일론 머스크는 2003년 테슬라를 설립했고, 테슬라는 텍사스 오스틴에 본사를 두고 있다."
    │
    ▼ LLMGraphTransformer
    │
├── 노드: Person(Elon Musk), Company(Tesla), Location(Austin)
└── 관계: FOUNDED(year=2003), LOCATED_IN

LLM이 텍스트를 읽고 누가, 무엇을, 어떤 관계로 연결되어 있는지 추출한다.


1.2 기본 사용법

from langchain_experimental.graph_transformers import LLMGraphTransformer
from langchain_openai import ChatOpenAI
from langchain_core.documents import Document
from langchain_neo4j import Neo4jGraph

# LLM 설정 (GPT-4o 권장: 관계 추출 정확도 높음)
llm = ChatOpenAI(model="gpt-4o", temperature=0)

# Transformer 초기화
transformer = LLMGraphTransformer(llm=llm)

# 문서 준비
docs = [
    Document(page_content="""
        Elon Musk founded Tesla in 2003. Tesla is headquartered in Austin, Texas.
        Musk also founded SpaceX in 2002, which is located in Hawthorne, California.
        Before that, Musk co-founded PayPal with Peter Thiel in 1998.
    """),
]

# 그래프 문서로 변환
graph_docs = transformer.convert_to_graph_documents(docs)

# 결과 확인
for gd in graph_docs:
    print("=== 노드 ===")
    for node in gd.nodes:
        print(f"  {node.id} ({node.type}): {node.properties}")

    print("=== 관계 ===")
    for rel in gd.relationships:
        print(f"  ({rel.source.id})-[:{rel.type}]->({rel.target.id})")
        if rel.properties:
            print(f"    properties: {rel.properties}")

출력:

=== 노드 ===
  Elon Musk (Person): {}
  Tesla (Company): {}
  Austin (Location): {}
  SpaceX (Company): {}
  Hawthorne (Location): {}
  Peter Thiel (Person): {}
  PayPal (Company): {}

=== 관계 ===
  (Elon Musk)-[:FOUNDED]->(Tesla)
  (Tesla)-[:HEADQUARTERED_IN]->(Austin)
  (Elon Musk)-[:FOUNDED]->(SpaceX)
  (SpaceX)-[:LOCATED_IN]->(Hawthorne)
  (Elon Musk)-[:CO_FOUNDED]->(PayPal)
  (Peter Thiel)-[:CO_FOUNDED]->(PayPal)

1.3 Neo4j에 저장

graph = Neo4jGraph(
    url="bolt://localhost:7687",
    username="neo4j",
    password="password"
)

# 그래프 문서를 Neo4j에 저장
graph.add_graph_documents(
    graph_docs,
    baseEntityLabel=True,  # 모든 노드에 __Entity__ 레이블 추가 (검색 편의)
    include_source=True,   # 원본 문서 노드도 함께 저장
)

# 저장된 데이터 확인
result = graph.query("""
MATCH (n)-[r]->(m)
RETURN labels(n)[0] AS from_type, n.id AS from,
       type(r) AS relation,
       labels(m)[0] AS to_type, m.id AS to
LIMIT 20
""")
for row in result:
    print(f"({row['from_type']}:{row['from']})-[:{row['relation']}]->({row['to_type']}:{row['to']})")

1.4 스키마 정의: 추출할 타입 제한

스키마를 정의하지 않으면 LLM이 임의의 타입을 생성하여 일관성이 떨어진다. 도메인에 맞는 타입을 미리 정의하는 것이 권장된다.

transformer = LLMGraphTransformer(
    llm=llm,
    # 허용할 노드 타입
    allowed_nodes=["Person", "Company", "Location", "Product", "Technology"],

    # 허용할 관계 타입
    allowed_relationships=[
        "FOUNDED",
        "WORKS_AT",
        "LOCATED_IN",
        "ACQUIRED",
        "INVESTED_IN",
        "DEVELOPED",
        "COMPETES_WITH",
    ],

    # 노드 속성 추출 여부
    node_properties=True,

    # 관계 속성 추출 여부
    relationship_properties=True,
)

스키마 정의의 중요성:

스키마 없음:
  TESLA_FOUNDED_BY, IS_CEO_OF, HEADQUARTERED_IN, BASED_AT
  → 같은 의미가 다른 타입으로 저장 → 탐색 불가

스키마 있음:
  FOUNDED, LOCATED_IN
  → 일관된 관계 타입 → 예측 가능한 탐색

1.5 대규모 문서 배치 처리

import time
from tqdm import tqdm

def process_documents_batch(docs, transformer, graph, batch_size=10):
    """대규모 문서를 배치로 처리."""
    all_graph_docs = []

    for i in tqdm(range(0, len(docs), batch_size)):
        batch = docs[i:i + batch_size]

        try:
            graph_docs = transformer.convert_to_graph_documents(batch)
            graph.add_graph_documents(
                graph_docs,
                baseEntityLabel=True,
                include_source=True,
            )
            all_graph_docs.extend(graph_docs)

        except Exception as e:
            print(f"배치 {i//batch_size} 처리 실패: {e}")
            continue

        # API 레이트 리밋 방지
        time.sleep(1)

    return all_graph_docs

# 실행
from langchain_community.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 문서 로드 & 청크 분할
loader = TextLoader("data/company_news.txt")
raw_docs = loader.load()

splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
chunks = splitter.split_documents(raw_docs)

print(f"총 {len(chunks)}개 청크 처리 예정")
graph_docs = process_documents_batch(chunks, transformer, graph)

1.6 비용 추정

LLMGraphTransformer는 문서당 LLM 호출이 1~2번 발생한다.

GPT-4o 기준:
  Input:  $5 / 1M tokens
  Output: $15 / 1M tokens

문서 1개 (1000 tokens 기준):
  ≈ $0.005 ~ $0.015

문서 1000개:
  ≈ $5 ~ $15

문서 100만 개 (Wikipedia):
  ≈ $5,000 ~ $15,000

비용 절감 방안: - gpt-4o-mini 사용 (약 10배 저렴, 정확도 약간 낮음) - allowed_nodes, allowed_relationships 로 추출 범위 제한 - 청크 크기 최적화 (너무 작으면 컨텍스트 손실, 너무 크면 비용 증가)


1.7 엔티티 해결 (Entity Resolution)

같은 엔티티가 다른 이름으로 등장하는 문제를 해결한다.

"Elon Musk" = "Elon R. Musk" = "머스크" = "musk"
"Tesla"     = "Tesla Motors" = "TSLA"

1.7.1 방법 1: MERGE로 중복 방지

# add_graph_documents 내부적으로 MERGE 사용
# → 같은 id를 가진 노드는 새로 생성하지 않고 기존 노드 재사용
graph.add_graph_documents(graph_docs, baseEntityLabel=True)

1.7.2 방법 2: LLM에게 표준화 요청

transformer = LLMGraphTransformer(
    llm=llm,
    prompt="""
    엔티티 이름은 다음 규칙으로 표준화하세요:
    - 사람: 영문 풀네임 사용 (예: Elon Musk)
    - 회사: 공식 명칭 사용 (예: Tesla, Inc.)
    - 장소: 도시명 + 국가명 (예: Austin, USA)
    """
)

1.7.3 방법 3: 사후 APOC 처리

// 유사한 노드 병합 (APOC 필요)
MATCH (n:Person)
WITH n.name AS name, collect(n) AS nodes
WHERE size(nodes) > 1
CALL apoc.refactor.mergeNodes(nodes, {properties: 'combine'})
YIELD node
RETURN node

1.8 구축된 그래프 검증

# 전체 통계
stats = graph.query("""
MATCH (n)
RETURN labels(n)[0] AS type, count(n) AS count
ORDER BY count DESC
""")
print("=== 노드 통계 ===")
for s in stats: print(f"  {s['type']}: {s['count']}개")

rel_stats = graph.query("""
MATCH ()-[r]->()
RETURN type(r) AS type, count(r) AS count
ORDER BY count DESC
""")
print("\n=== 관계 통계 ===")
for s in rel_stats: print(f"  {s['type']}: {s['count']}개")

# 연결도 높은 노드 (허브 노드)
hubs = graph.query("""
MATCH (n)
WITH n, size((n)-[]-()) AS degree
ORDER BY degree DESC
LIMIT 10
RETURN labels(n)[0] AS type, n.id AS name, degree
""")
print("\n=== 허브 노드 TOP 10 ===")
for h in hubs: print(f"  {h['type']}:{h['name']} - {h['degree']}개 연결")

1.9 정리

LLMGraphTransformer 파이프라인:
  문서 → convert_to_graph_documents() → GraphDocument
       ↓
  graph.add_graph_documents()
       ↓
  Neo4j: 노드 + 관계 저장

핵심 파라미터:
  allowed_nodes        = ["Person", "Company", ...]
  allowed_relationships = ["FOUNDED", "WORKS_AT", ...]
  node_properties      = True
  relationship_properties = True

주의사항:
  - 스키마 정의 필수 (없으면 일관성 깨짐)
  - 대규모 처리 시 배치 + 레이트 리밋 처리
  - Entity Resolution 전략 수립 필요

다음 파일에서는 Neo4j에 벡터 인덱스를 추가하여 의미적 검색을 구현한다.

Subscribe

Enjoy this blog? Get notified of new posts by email: