GraphRAG 시스템을 직접 구축하며 발견한 것들

Tasker_Jang·2026년 5월 16일
post-thumbnail

일반적인 VectorRAG 구조와 동일한 chunker 정책을 유지하면서 GraphRAG 레이어를 추가했을 때, 어떤 새로운 질의 패턴이 가능한지 — 그리고 production 적용 시 마주한 본질적 trade-off 에 대한 기록.

한 페이지 분량의 PDF 문서에서 회사 / 리스크 / 지표 / 전망 4가지 entity 타입을 추출하고, 100여 개 raw entity 가 NED(Named Entity Disambiguation) 를 거쳐 절반으로 압축되는 과정 (compression 0.49) 을 직접 측정했다.

한편 LangChain SemanticChunker 가 CPU + bge-m3 조합에서 820자를 분할하는 데 31.3초 가 걸리는 본질적 비효율도 발견했다. 이 발견 자체가 GraphRAG 의 production 적용 가능성에 대한 정량적 인사이트가 되었다.


들어가며

요즘 RAG(Retrieval-Augmented Generation) 시스템은 거의 표준이 되었다. 비정형 문서를 청크로 쪼개고, 임베딩으로 변환해서 vector store 에 적재한 뒤, 사용자 질의가 들어오면 코사인 유사도 기반으로 가장 가까운 청크 N개를 가져와서 LLM 에 넣는다. 이게 우리가 흔히 말하는 VectorRAG 다.

잘 동작한다. 그런데 어느 순간 한계를 마주한다.

"특정 회사와 함께 언급된 리스크가 뭐가 있지?"
"같은 회사가 여러 보고서에서 일관되게 강조하는 메트릭은?"
"특정 거시 요인이 영향을 미친 회사들은?"

이런 질의는 VectorRAG 가 잘 못한다. 청크 유사도 검색은 "비슷한 텍스트" 를 찾아줄 뿐, "entity 간의 관계" 를 추출하지 못하기 때문이다. 청크 5개를 가져와서 LLM 에게 후처리시킬 수는 있지만, 이건 검색이 아니라 재추론 이다. 비용과 시간이 든다.

그래서 GraphRAG 다. 청크에서 직접 entity 와 관계를 추출해서 그래프 DB 에 적재하면, "회사 X → FACES_RISK → ?" 같은 쿼리를 한 번에 답할 수 있다.

이 글은 일반적인 VectorRAG 시스템 위에 GraphRAG 레이어를 plug-in 형태로 추가해보면서 발견한 것들을 정리한 기록이다.


1. VectorRAG 의 한계 — 왜 그래프가 필요한가

먼저 VectorRAG 의 구조를 잠깐 짚자. 일반적인 흐름은 다음과 같다.

원본 문서 (PDF/HWP/DOC 등)
    ↓ (chunker)
청크 (size=700, overlap=200)
    ↓ (embedding)
vector store
    ↓ (사용자 질의)
유사도 top-K 검색
    ↓ (LLM)
답변 생성

이 구조의 강점은 명확하다. 빠르고, 단순하고, 새 문서 추가가 trivial 하다. 청크 → 임베딩 → 저장이 끝. 검색도 cosine similarity 한 번이면 끝.

문제는 답변할 수 있는 질의의 형태가 제한적이라는 점이다. 유사도 검색은 본질적으로 "이 청크와 비슷한 청크" 를 찾는 연산이다. 그래서 잘 답하는 질의는 이런 류다.

  • "회사 X 의 영업이익은?" → 매출/실적 언급된 청크 5개 → LLM 이 추출 → 답
  • "시장 전망은?" → 전망 언급된 청크 → 답

반면 못 답하는 질의:

  • "회사 X 와 함께 언급된 리스크가 뭐가 있지?"
    → 청크 5개 가져와도 "리스크" 자체가 entity 가 아니라 텍스트라 LLM 이 매번 재추출해야 함
  • "같은 entity 가 분기별 보고서에서 어떻게 변화했지?"
    → 청크 간 entity 동일성을 모르니까 비교 못 함
  • "회사 X 와 회사 Y 의 공통 리스크는?"
    → 두 회사 청크 따로 가져와서 LLM 이 합쳐서 비교 — 매번 비싼 호출

이런 질의에서 VectorRAG 는 항상 LLM 후처리에 의존한다. "검색"이 아니라 "검색 + 재추론" 이 되어버린다. 비용이 청크 수에 선형 비례하고, 결과의 일관성도 떨어진다.

핵심은 이거다. 유사도 검색은 텍스트의 의미 거리만 안다. Entity 간의 관계는 모른다.

여기서 GraphRAG 가 들어온다.


2. GraphRAG 실측 사례 — 1쪽 PDF 의 그래프 변환

말로만 하면 추상적이니까 실제 데이터로 보자. 평가용으로 한 페이지 분량의 시황분석 형식 PDF 를 골랐다. 약 4400자, 6개 섹션, 52개 청크로 분할된다.

이 1쪽 PDF 를 GraphRAG 파이프라인에 통과시킨 결과는 다음과 같다.

추출 결과

단계결과
Layer A 적재 (구조)Document 1 + Section 6 + Chunk 52 + NEXT 46 = 59 노드 + 104 관계
Entity 추출 (raw)113개
NED 그룹화113 → 55 group (compression 0.49)
Layer B 관계28개 (HAS_METRIC 13 + FACES_RISK 7 + ...)
MENTIONS 적재108개 (Chunk → Entity)
총 처리 시간227.5초 (약 4분)

특히 entity 타입별로 들여다보면 흥미롭다.

MATCH (c:Chunk)-[:MENTIONS]->(e:Entity)
RETURN labels(e)[1] AS type, count(DISTINCT c) AS chunks, count(e) AS mentions
ORDER BY mentions DESC;
Entity TypeDISTINCT ChunksTotal Mentions
Company1468
Risk621
Metric414
Outlook35

1쪽짜리 시황분석에서 14개 회사 + 6개 리스크 + 4개 지표 + 3개 전망이 추출됐다. 그리고 Company entity 1개당 평균 4.86개 청크에서 언급됐다.

이 숫자가 GraphRAG 의 정량적 가치다. VectorRAG 였다면 "회사 X" 키워드로 검색해서 청크 5개 가져오는 데 그쳤을 텐데, GraphRAG 는 다음과 같은 사실들을 그래프 구조로 직접 표현한다.

  • 회사 X (Company) → FACES_RISK → 거시 리스크 A (Risk)
  • 회사 X (Company) → HAS_METRIC → 영업이익 지표 (Metric)
  • 회사 Y (Company) ← MENTIONS ← Chunk023 ← Section_2 ← Document보고서3

이러면 "회사 X 와 함께 언급된 리스크는?" 같은 질의가 Cypher 한 줄로 답해진다.

MATCH (c:Company {name: $company})-[:FACES_RISK]->(r:Risk)
RETURN r.name;

LLM 호출 0번. 응답 시간 100ms 미만. 검색이 곧 답 이다.


3. Layer A + B + MENTIONS — 문서 구조와 의미 그래프를 동시에

그래프를 어떻게 설계했느냐가 GraphRAG 의 거의 모든 것이다. 직접 정의한 ontology 스키마를 따라 3-Layer 구조로 적재한다.

Layer A — 문서 구조 (Document Structure)

원본 문서의 물리적 구조를 그대로 보존한다. 출처 추적과 evidence 제공이 목적이다.

(:Document {id, filename, doc_type, publisher, fiscal_year, ...})
  -[:HAS_SECTION]->
(:Section {id, label, heading_level, page_start, ...})
  -[:CONTAINS_CHUNK]->
(:Chunk {id, text, char_count, page, order_index})
  -[:NEXT]->
(:Chunk)  # 같은 Section 내 순서

(:Section) -[:CONTAINS_TABLE]-> (:Table {raw_markdown, row_count, ...})

이 레이어만 있어도 "이 답변이 어느 문서 어느 페이지 어느 청크에서 왔는지" 를 정확히 추적할 수 있다. 도메인에 따라 다르지만, 출처가 중요한 분야에서는 생명이다.

Layer B — Entity 의미 그래프 (Entity Interaction)

청크에서 LLM 으로 추출한 entity 와 관계를 적재한다. 의미 기반 쿼리의 주체다.

(:Entity:Company {group_id, name, aliases, ...})
(:Entity:Risk    {group_id, name, ...})
(:Entity:Metric  {group_id, name, ...})
(:Entity:Outlook {group_id, name, ...})

(:Entity:Company) -[:FACES_RISK]-> (:Entity:Risk)
(:Entity:Company) -[:HAS_METRIC]-> (:Entity:Metric)
(:Entity:Company) -[:HAS_OUTLOOK]-> (:Entity:Outlook)

멀티 라벨:Entity (전역 검색용) 과 구체 타입 (:Company, :Risk...) 을 동시에 부착한다. MATCH (e:Entity) 로 전체 entity 검색이 가능하면서, MATCH (e:Company) 로 타입별 검색도 가능하다.

MENTIONS — Layer A 와 Layer B 의 다리

가장 중요한 연결고리다. "어느 청크가 어느 entity 를 언급했는가" 를 표현한다.

(:Chunk) -[:MENTIONS]-> (:Entity)

이 관계 덕분에 다음이 가능하다.

  • Entity → 청크 역추적: 회사 X 를 언급한 모든 청크 찾기 → evidence 제공
  • 청크 → Entity 그래프: 청크가 어떤 entity 들 사이의 관계를 다루는지
  • 다중 문서 간 entity 공유: 같은 entity 가 여러 문서에서 어떻게 등장하는지

마지막 부분이 진짜 핵심이다. 분기별 보고서 4개에서 같은 회사명이 등장하면, NED 가 이들을 같은 group_id 로 묶는다. 그러면 단일 Entity 노드를 4개 Chunk 가 MENTIONS 하는 구조가 된다. 시계열 분석이 그래프 traversal 한 번으로 끝난다.

MATCH (e:Entity:Company {name: $company})
      <-[:MENTIONS]-(c:Chunk)
      <-[:CONTAINS_CHUNK]-(s:Section)
      <-[:HAS_SECTION]-(d:Document)
RETURN DISTINCT d.filename;

→ 해당 회사가 등장하는 모든 보고서가 한 번에 나온다.


4. NED Compression — 같은 entity 를 인식하는 핵심 기술

여기서 짚어야 하는 게 NED (Named Entity Disambiguation) 다. LLM 으로 청크별로 entity 를 뽑으면 같은 회사가 다른 표기로 나온다.

청크 1:  "회사명 정식 표기"
청크 5:  "회사명 약칭"
청크 12: "회사명(주)"
청크 23: "회사명 영문"

NED 가 없으면 이 4개를 다른 entity 4개 로 본다. 그러면 그래프가 사방으로 fragmenting 된다. "회사 X 의 리스크" 같은 쿼리가 4번 검색이 되어버리고, 결과도 4개로 쪼개진다.

다음 단계로 NED 를 한다.

  1. Canonical 정규화: 괄호 제거, 공백 정리, 영문 소문자화
  2. 임베딩 기반 유사도: bge-m3 로 entity 명을 임베딩, cosine similarity 계산
  3. 임계값 그룹화: 0.92 이상이면 같은 group 으로 묶음
  4. Representative 선정: group 내에서 가장 많이 등장한 표기를 대표 이름으로

1쪽 PDF 의 실측 결과는 다음과 같았다.

단계개수
Raw entity (청크별 추출 합산)113
임계값 0.92 그룹화 후55
Compression ratio0.49 (51% 중복 제거)

평균적으로 같은 entity 가 약 2번씩 다른 표기로 등장 했다는 뜻이다. 113개 모두 별도 노드로 만들었으면 그래프가 의미 없어졌을 것이다. NED 가 GraphRAG 를 실질적으로 valuable 하게 만든다.

여러 문서 풀 적재 후에는 이 compression 이 더 극적으로 나타날 것으로 예상된다. 같은 회사가 여러 분기 보고서에 등장하면 단일 group_id 로 묶이고, 다중 문서를 동시 traversal 하는 쿼리가 trivial 해진다.


5. 운영 환경의 trade-off — production 의 함정

여기까지 보면 "GraphRAG 좋네, 적용하자" 라고 결론 내고 싶다. 그런데 실제로 다수의 문서를 batch 적재하려고 했을 때 본질적 trade-off 를 마주했다.

발견: SemanticChunker + bge-m3 + CPU = 비현실적

일반적으로 쓰이는 chunker 정책을 따랐다.

DEFAULT_CHUNK_SIZE = 700
DEFAULT_CHUNK_OVERLAP = 200

chunker 의 핵심 컴포넌트는 LangChain SemanticChunker 다. 문장들의 임베딩 거리를 보고 의미 단위 경계에서 자른다. production 환경에서는 잘 동작하는 모듈로 알려져 있다.

그런데 개인 환경 (CPU + 노트북) 에서 돌려보면 멈춘다. 한참 멈춘다. 디버깅 1.5시간 끝에 원인이 좁혀졌다.

단독 벤치:

  • bge-m3 임베딩 50문장 (batch_size=32) → 5.4초 ✅ 정상
  • PDF 파싱 (PyMuPDF) → 0.1초 ✅ 정상
  • Neo4j connectivity → OK ✅ 정상
  • SemanticChunker 초기화 → OK ✅ 정상
  • SemanticChunker.split_text(820자) → 31.3초 ❌ 비정상

원인: LangChain SemanticChunker 는 임베딩을 문장 단위 sequential 호출 한다 (batch 미사용). bge-m3 (568M params) + CPU 조합에서 문장당 ~400ms × 수십 문장 = 분 단위 소요.

추정 — 8개 문서 전체 분할만 40분 ~ 1시간. LLM 호출과 Neo4j 적재까지 더하면 production 으로 못 쓴다.

Trade-off 정량화

항목SemanticChunker (의미 분할)RecursiveCharacterTextSplitter (단순 분할)
분할 품질◎ 문장 의미 경계△ 문자 수 기반
1문서 분할 시간 (CPU)~3분~수십 ms
다수 문서 추정분당 처리량 매우 낮음즉시
임베딩 호출sequential per sentence없음
Production 적용GPU 또는 API 임베딩 필수CPU 만으로 OK

해결: 환경변수 fallback

코드에 환경변수 분기를 추가했다.

def _semantic_split(text: str) -> list[str]:
    # 발견: SemanticChunker + bge-m3 + CPU = 820자/31초 (비현실적)
    # 옵션: 환경변수로 단순 분할 fallback
    if os.getenv("USE_SIMPLE_CHUNKER") == "1":
        return RecursiveCharacterTextSplitter(
            chunk_size=DEFAULT_CHUNK_SIZE,
            chunk_overlap=DEFAULT_CHUNK_OVERLAP,
            separators=["\n\n", "\n", ". ", "。", "? ", "! ", " ", ""],
        ).split_text(text)
    # ... 기존 SemanticChunker 코드

기본값은 OFF. 일반적인 동작이 유지된다. USE_SIMPLE_CHUNKER=1 을 켜는 환경에서만 fallback 이 작동한다. chunk 정책 (size=700, overlap=200, min=50) 자체는 동일 하므로 GraphRAG vs VectorRAG 비교의 chunk 경계 변수가 통제된다.

이 fallback 으로 다수 문서 적재가 30분 안에 끝난다.

인사이트: "RAG 비교 실험은 동일 chunker 가 전제"

이 발견 자체가 가장 큰 인사이트였다. production 환경 (GPU 또는 API 임베딩) 을 가정한 컴포넌트가 개인 환경 (CPU + 오픈소스 모델) 에서는 완전히 다른 비용 프로파일을 가진다. SemanticChunker 는 OpenAI API 임베딩 같은 빠른 임베딩과 짝궁이지, CPU + 무거운 모델 조합으로는 못 쓴다.

GraphRAG vs VectorRAG 를 진짜로 비교하려면 두 시스템이 같은 chunker 를 써야 한다. CPU 환경에서 SemanticChunker 가 비현실적이면, 진짜 비교는 GPU 또는 API 환경을 마련한 다음에야 가능하다.

이게 한 줄로 정리되는 결론이다.

"RAG 시스템의 비교는 chunker 동일성이 전제. CPU 한계가 메타-비교 차원의 변수다."


profile
ML Engineer 🧠 | AI 모델 개발과 최적화 경험을 기록하며 성장하는 개발자 🚀 The light that burns twice as bright burns half as long ✨

0개의 댓글