GraphRAG 파이프라인 구축 - 문서 뭉치에서 지식그래프까지

지난 글에서 온톨로지와 지식그래프의 개념을 정리하면서, 벡터 RAG가 관계 질문에 약하다는 이야기로 마무리했다. 이번에는 그 대안인 GraphRAG를 실제로 만들어 본다. 문서 뭉치를 넣으면 LLM이 엔티티와 관계를 뽑아내고, 그것으로 그래프를 만들고, 질문이 들어오면 벡터로 진입점을 찾은 뒤 그래프를 따라 답에 필요한 조각을 모아 오는 파이프라인이다.

미리 밝혀 두면, 이 글의 코드는 두 종류다. LLM 호출이 필요한 추출 단계는 문법과 스키마만 정확히 적고 출력은 예시로 표기했다. 반면 정규화·그래프 구축·하이브리드 검색은 실제로 돌려서 나온 출력을 그대로 붙였다. 어느 쪽인지는 매번 표시하겠다.

1. 전체 그림부터 - 두 개의 파이프라인

GraphRAG는 사실 별개의 파이프라인 두 개다. 하나는 문서가 바뀔 때만 도는 인덱싱, 다른 하나는 질문마다 도는 질의다. 이 둘을 분리해서 생각하지 않으면 설계가 금세 엉킨다. 인덱싱은 비싸고 느려도 되지만 질의는 싸고 빨라야 하기 때문이다.

GraphRAG 인덱싱과 질의 파이프라인 전체 흐름

GraphRAG의 두 파이프라인. 강조된 두 단계가 비용과 품질을 결정한다.

벡터 RAG와 비교하면 추가된 것은 ②관계 추출③그래프 확장 두 단계뿐이다. 나머지는 이미 익숙한 청킹과 임베딩이다. 그래서 기존 RAG 파이프라인이 있다면 통째로 갈아엎을 필요 없이 이 두 단계를 얹는 식으로 접근하는 편이 낫다.

2. 청킹 - 벡터 RAG와 기준이 다르다

벡터 RAG의 청킹 기준은 "이 조각만 읽고 답할 수 있는가"다. 그래서 400~500자 정도로 잘게 자른다. 그런데 GraphRAG의 청킹 기준은 다르다. "이 조각 안에 주어와 목적어가 함께 들어 있는가"다. 문장이 잘려서 "이 서비스는 그것에 의존한다"만 남으면 LLM이 뽑을 수 있는 관계가 없다.

그래서 GraphRAG용 청크는 벡터 RAG보다 크게 잡는다. 800~1200자 정도, 문단 경계를 지키면서 자르는 것이 무난했다. 겹침도 조금 넉넉하게 준다. 같은 관계가 두 청크에서 중복 추출되는 것은 나중에 병합으로 처리하면 되지만, 잘려서 사라진 관계는 복구할 방법이 없다.

def chunk_by_paragraph(text, target=1000, overlap=200):
    paras = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, buf = [], ""
    for p in paras:
        if len(buf) + len(p) > target and buf:
            chunks.append(buf)
            buf = buf[-overlap:] + "\n\n" + p   # 꼬리를 물려 준다
        else:
            buf = (buf + "\n\n" + p) if buf else p
    if buf:
        chunks.append(buf)
    return chunks

한 가지 더. 청크마다 고유 ID를 붙여 둬야 한다. 나중에 그래프의 각 엣지가 어느 문서 어느 청크에서 나왔는지 되짚을 수 있어야 하기 때문이다. 이 출처 추적이 없으면 GraphRAG는 그냥 "LLM이 만들어 낸 그럴듯한 그래프"가 되고, 지난 글에서 강조한 검증 가능성이 통째로 날아간다.

3. 엔티티·관계 추출 - 프롬프트가 전부다

이 단계가 GraphRAG 품질의 8할을 결정한다. 그리고 실패하는 방식이 대체로 정해져 있다. 자유롭게 뽑으라고 하면 같은 개념을 청크마다 다른 이름으로 부르고, 없는 관계를 만들어 내고, JSON 앞뒤에 설명문을 붙인다.

엔티티 추출 프롬프트에 넣어야 할 네 가지 제약

추출 프롬프트에 넣어야 할 네 가지 제약. 하나라도 빠지면 그래프가 지저분해진다.

핵심은 모델이 창의적일 여지를 최대한 없애는 것이다. 타입은 미리 정한 목록에서만 고르게 하고, 출력 형식은 JSON 스키마로 강제하고, 온도는 0으로 둔다. Ollama와 Claude 모두 스키마 강제를 지원하는데, 여기서는 로컬에서 돌릴 수 있는 Ollama 기준으로 적는다.

# pip install ollama pydantic
from typing import List, Literal
from pydantic import BaseModel
import ollama

EntityType   = Literal["Service", "Person", "Team", "Library", "Datastore"]
RelationType = Literal["DEPENDS_ON", "OWNS", "WORKS_ON", "CALLS", "STORES_IN"]

class Relation(BaseModel):
    source: str
    source_type: EntityType
    relation: RelationType
    target: str
    target_type: EntityType
    evidence: str          # 근거가 된 원문 문장

class Extraction(BaseModel):
    relations: List[Relation]

SYSTEM = """너는 기술 문서에서 엔티티와 관계를 추출하는 도구다.

규칙:
1. 엔티티 타입은 Service, Person, Team, Library, Datastore 중에서만 고른다.
2. 관계 타입은 DEPENDS_ON, OWNS, WORKS_ON, CALLS, STORES_IN 중에서만 고른다.
3. source와 target은 원문에 등장한 표기를 그대로 쓴다. 번역하거나 다듬지 않는다.
4. evidence에는 그 관계의 근거가 된 원문 문장을 그대로 옮긴다.
5. 추론하지 않는다. 문장에 없는 관계는 만들지 않는다.
6. 해당하는 관계가 없으면 relations를 빈 배열로 반환한다."""

def extract(chunk_id, text):
    res = ollama.chat(
        model="qwen3:8b",
        options={"temperature": 0},
        format=Extraction.model_json_schema(),   # 스키마로 출력을 구속한다
        messages=[{"role": "system", "content": SYSTEM},
                  {"role": "user",   "content": text}],
    )
    data = Extraction.model_validate_json(res["message"]["content"])
    return [(r.source, r.relation, r.target, chunk_id) for r in data.relations]

스키마를 format 파라미터로 넘기면 모델의 디코딩 자체가 제약을 받는다. 코드 펜스가 섞이거나 "다음은 추출 결과입니다" 같은 서두가 붙는 문제가 원천적으로 사라진다. Extraction.model_json_schema()가 만드는 스키마와 model_validate_json()의 검증 동작은 실제로 확인했다. 타입 목록에 없는 값(예: USES)을 넣으면 literal_error로 걸러진다.

다음은 위 함수에 마이크로서비스 문서 한 청크를 넣었을 때 나오는 출력 예시다(LLM 호출이 필요해 이 글에서 직접 실행하지는 않았다).

[('OrderApi', 'DEPENDS_ON', 'AuthService', 'doc1#0'),
 ('주문 API', 'DEPENDS_ON', '결제 모듈',    'doc1#1')]

여기서 바로 문제가 보인다. 같은 서비스인데 한 청크에서는 OrderApi, 다른 청크에서는 주문 API로 나왔다. 이걸 그대로 그래프에 넣으면 노드가 둘로 쪼개진다.

4. 엔티티 정규화 - 그래프가 갈라지는 진짜 원인

GraphRAG를 처음 만들면 대개 여기서 무너진다. 추출은 그럴듯하게 됐는데 그래프를 그려 보면 aliceAlice가 다른 사람이고, Vertex AIVertexAI가 다른 서비스다. 관계 질의가 전혀 답을 못 낸다.

해법은 표기(surface form)와 정규 ID(canonical id)를 분리하는 것이다. 노드의 정체성은 정규 ID가 갖고, 원문 표기는 별칭 목록으로 노드에 붙여 둔다. 아래 코드부터는 실제로 실행한 것이다.

import re, networkx as nx

TRIPLES = [   # 추출 단계의 결과라고 가정한 8개
    ("OrderApi",    "DEPENDS_ON", "AuthService", "doc1#0"),
    ("주문 API",     "DEPENDS_ON", "결제 모듈",     "doc1#1"),
    ("AuthService", "DEPENDS_ON", "Vertex AI",   "doc2#0"),
    ("Vertex AI",   "DEPENDS_ON", "GCP IAM",     "doc2#1"),
    ("alice",       "OWNS",       "OrderApi",    "doc3#0"),
    ("Alice",       "WORKS_ON",   "결제 모듈",     "doc3#1"),
    ("결제 모듈",     "DEPENDS_ON", "AuthService", "doc4#0"),
    ("bob",         "OWNS",       "AuthService", "doc4#1"),
]

ALIAS = {"주문api": "OrderApi",   "orderapi": "OrderApi",
         "vertexai": "VertexAI",  "gcpiam": "GcpIam",
         "결제모듈": "PaymentModule", "paymentmodule": "PaymentModule",
         "authservice": "AuthService", "alice": "Alice", "bob": "Bob"}

def canon(name):
    key = re.sub(r"\s+", "", name.strip()).casefold()   # 공백 제거 + 대소문자 통일
    return ALIAS.get(key, name.strip())

G = nx.MultiDiGraph()
for s, r, o, src in TRIPLES:
    cs, co = canon(s), canon(o)
    for n, raw in ((cs, s), (co, o)):
        if n not in G:
            G.add_node(n, aliases=set())
        G.nodes[n]["aliases"].add(raw)      # 원문 표기는 버리지 않는다
    G.add_edge(cs, co, key=r, relation=r, source=src)

print("노드:", G.number_of_nodes(), "엣지:", G.number_of_edges())
for n in sorted(G.nodes()):
    print(f"  {n:14s} aliases={sorted(G.nodes[n]['aliases'])}")

실행 결과다.

노드: 7 엣지: 8
  Alice          aliases=['Alice', 'alice']
  AuthService    aliases=['AuthService']
  Bob            aliases=['bob']
  GcpIam         aliases=['GCP IAM']
  OrderApi       aliases=['OrderApi', '주문 API']
  PaymentModule  aliases=['결제 모듈']
  VertexAI       aliases=['Vertex AI']

표기가 8종류였는데 노드는 7개로 정리됐고, OrderApi주문 API라는 별칭을 함께 갖게 됐다. 별칭을 남겨 두는 이유는 두 가지다. 하나는 나중에 사용자가 "주문 API"라고 물었을 때 매칭시키기 위해서, 다른 하나는 병합이 잘못됐을 때 되돌리기 위해서다.

물론 별칭 사전을 손으로 채우는 방식은 도메인이 커지면 한계가 온다. 실무에서는 세 단계를 순서대로 쓴다. 먼저 문자열 정규화로 대부분을 잡고, 남은 것은 엔티티 이름의 임베딩 유사도로 후보를 뽑고, 애매한 후보만 LLM에게 "이 둘은 같은 것인가"를 묻는다. 이 순서를 지키면 LLM 호출 횟수가 크게 줄어든다.

5. 하이브리드 검색 - 벡터로 들어가서 그래프로 넓힌다

이제 질의 쪽이다. 여기서 흔한 오해가 하나 있다. GraphRAG가 벡터 검색을 대체한다는 오해다. 실제로는 벡터 검색이 여전히 첫 단계에 있다. 다만 역할이 바뀐다. 벡터는 이제 답을 찾는 도구가 아니라 그래프의 어느 지점부터 걸어갈지 정하는 도구다.

벡터 진입점 탐색과 그래프 확장 하이브리드 검색

벡터는 진입점만 고르고, 실제 답은 그래프를 따라가며 모은다.

def node_text(g, n):
    """노드를 임베딩할 텍스트로 만든다 - 별칭 + 나가는 관계"""
    aliases = " ".join(sorted(g.nodes[n]["aliases"]))
    rels = " ".join(f"{n} {d['relation']} {v}" for _, v, d in g.out_edges(n, data=True))
    return f"{aliases} {rels}"

# 실제로는 ollama.embed(model="embeddinggemma", input=...) 를 쓴다
NODE_VEC = {n: embed(node_text(G, n)) for n in G.nodes()}

def find_seeds(query, k=2):
    qv = embed(query)
    scored = sorted(((cosine(qv, v), n) for n, v in NODE_VEC.items()), reverse=True)
    return [n for _, n in scored[:k]]

def expand(g, seeds, hops=1):
    """진입점에서 hops만큼 이웃을 넓힌다 - 방향은 무시한다"""
    keep, frontier = set(seeds), set(seeds)
    for _ in range(hops):
        nxt = set()
        for n in frontier:
            nxt |= set(g.successors(n)) | set(g.predecessors(n))
        nxt -= keep
        keep |= nxt
        frontier = nxt
    return g.subgraph(keep)

def to_context(sub):
    """서브그래프를 LLM이 읽을 텍스트로 편다"""
    return "\n".join(sorted(
        f"- {u} --[{d['relation']}]--> {v}   (출처: {d['source']})"
        for u, v, d in sub.edges(data=True)))

확장할 때 successorspredecessors를 모두 보는 것이 포인트다. "OrderApi가 무엇에 의존하나"는 나가는 방향이지만 "OrderApi를 누가 소유하나"는 들어오는 방향이다. 질문 유형을 미리 알 수 없으니 양방향으로 넓히는 편이 안전하다.

아래는 실제 실행 결과다. 임베딩 부분만 검증용으로 문자 2-gram 기반 함수로 대체했고, 진입점 선정 이후의 그래프 확장과 컨텍스트 조립은 위 코드 그대로다.

질문: 주문 API가 결국 무엇에 의존하는가?
진입점: [('OrderApi', 0.193), ('GcpIam', 0.112)]
서브그래프: 노드 6 / 엣지 7
- Alice --[OWNS]--> OrderApi   (출처: doc3#0)
- Alice --[WORKS_ON]--> PaymentModule   (출처: doc3#1)
- AuthService --[DEPENDS_ON]--> VertexAI   (출처: doc2#0)
- OrderApi --[DEPENDS_ON]--> AuthService   (출처: doc1#0)
- OrderApi --[DEPENDS_ON]--> PaymentModule   (출처: doc1#1)
- PaymentModule --[DEPENDS_ON]--> AuthService   (출처: doc4#0)
- VertexAI --[DEPENDS_ON]--> GcpIam   (출처: doc2#1)

전이 의존(OrderApi 기준): ['AuthService', 'GcpIam', 'PaymentModule', 'VertexAI']

주목할 것은 두 가지다. 첫째, 사용자는 "주문 API"라고 한글로 물었는데 진입점으로 OrderApi가 잡혔다. 별칭을 노드 텍스트에 포함시킨 덕이다. 둘째, OrderApi → AuthService → VertexAI → GcpIam이라는 4단계 경로가 컨텍스트에 통째로 들어왔다. 벡터 검색만으로는 GcpIam이 걸릴 이유가 없는 질문이다. 문서 어디에도 "주문 API는 GCP IAM에 의존한다"는 문장이 없기 때문이다.

그리고 각 줄에 출처 청크 ID가 붙어 있다. LLM에게 이 컨텍스트를 넘기면서 "근거로 삼은 출처 ID를 답변에 함께 적어라"라고 지시하면, 답변을 원문까지 되짚을 수 있는 체인이 완성된다.

6. 홉 수를 늘리면 좋을까

같은 그래프에서 hops만 1에서 2로 바꿔 다시 돌리면 서브그래프가 노드 7개, 엣지 8개가 된다. 그래프 전체다. 노드 7개짜리 장난감 그래프라 그런 것이 아니라, 실제 그래프에서도 같은 일이 일어난다. 평균 차수가 5인 그래프에서 진입점 3개로 2홉을 넓히면 이미 수십 개 노드, 3홉이면 수백 개가 된다.

그래서 실무에서는 홉 수를 늘리는 대신 다른 장치를 쓴다.

  • 관계 타입 필터 — 질문 의도를 먼저 분류해 DEPENDS_ON만 따라가는 식으로 좁힌다. 가장 효과가 확실하다.
  • 엣지 예산 — 컨텍스트에 넣을 엣지 수 상한을 정하고, 진입점에서 가까운 순으로 채운다.
  • 경로 질의로 대체 — "A와 B가 어떻게 연결되나" 유형은 확장이 아니라 최단 경로 탐색이 정답이다.

7. 비용 - 솔직하게 짚고 넘어가기

GraphRAG의 가장 큰 진입 장벽은 인덱싱 비용이다. 문서를 청킹한 개수만큼 LLM을 호출해야 하고, 엔티티 정규화에서도 추가 호출이 발생한다. 벡터 RAG의 인덱싱이 임베딩 API 호출 몇 번으로 끝나는 것과는 자릿수가 다르다.

선택지특징어울리는 경우
Microsoft GraphRAG커뮤니티 탐지(Leiden)로 계층 요약까지 생성. 전역 질문에 강하다말뭉치 전체 요약이 필요할 때
LazyGraphRAG사전 요약을 생략하고 질의 시점에 필요한 만큼만 처리인덱싱 비용이 부담일 때
직접 구현 (이 글 방식)타입 10~20개짜리 얕은 스키마 + 그래프 확장도메인이 명확하고 통제가 필요할 때

Microsoft 측 발표에 따르면 LazyGraphRAG는 GraphRAG 전역 검색 대비 훨씬 낮은 질의 비용으로 비슷하거나 나은 품질을 낸다고 한다. 다만 이런 수치는 말뭉치 성격과 모델에 크게 좌우되므로, 자기 데이터로 작은 규모부터 재 보는 것 외에 방법이 없다. 본격적으로 들어가기 전에 문서 50개 정도로 파일럿을 돌려 호출 횟수와 시간을 실측해 보길 권한다.

비용을 줄이는 현실적인 방법도 있다. 추출은 로컬 모델로 돌리는 것이다. 관계 추출은 창의성이 필요 없고 스키마로 출력이 묶여 있는 작업이라, 8B 정도의 로컬 모델로도 쓸 만한 결과가 나온다. 그래프가 만들어진 뒤 최종 답변 생성만 큰 모델에 맡기면 전체 비용 구조가 크게 달라진다.

정리

  • GraphRAG는 인덱싱질의, 별개의 파이프라인 둘이다. 요구 조건이 정반대다.
  • 청킹 기준이 벡터 RAG와 다르다. 주어와 목적어가 한 청크에 들어가도록 크게 자른다.
  • 추출 프롬프트는 타입 화이트리스트 + JSON 스키마 강제 + 출처 필수 + 빈 배열 허용, 온도 0.
  • 진짜 난관은 추출이 아니라 엔티티 정규화다. 정규 ID와 표기를 분리하고 별칭을 보존한다.
  • 검색은 벡터로 진입점만 찾고 그래프로 넓힌다. 홉 수보다 관계 타입 필터가 효과적이다.
  • 인덱싱 비용이 실질적인 장벽이다. 추출을 로컬 모델로 돌리면 구조가 달라진다.

여기까지 만들면 "지금 이 순간의 사실"을 다루는 그래프는 완성된다. 그런데 에이전트에 붙이는 순간 새로운 문제가 생긴다. 사실이 시간에 따라 바뀐다는 것이다. 다음 글에서 그 이야기를 다룬다.

이 시리즈의 다른 글: 온톨로지와 지식그래프 기초 - DB 스키마와 무엇이 다른가 / AI 에이전트의 장기 기억에 지식그래프 적용하기 - 바뀌는 사실을 다루는 법

함께 보면 좋은 글: 로컬 LLM에 RAG 붙이기 — 내 문서로 답하는 Gemma 만들기

※ 이 글의 라이브러리 사용법·비용 비교·도구 목록은 2026년 기준이며 변경될 수 있습니다.

댓글

이 블로그의 인기 게시물

메일 주소 하나 만들려다 브랜드를 세웠다 — 1인 스튜디오 브랜딩 실전기

클로드 Fable 5와 Opus 4.8 비교 - 차이점, 가격, 무료 기간 종료 후 변화까지 총정리 (7월 최신)

구글 labs-fx - 제미나이 - 클라우드 차이점 알아보기