일반적인 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 형태로 추가해보면서 발견한 것들을 정리한 기록이다.
먼저 VectorRAG 의 구조를 잠깐 짚자. 일반적인 흐름은 다음과 같다.
원본 문서 (PDF/HWP/DOC 등)
↓ (chunker)
청크 (size=700, overlap=200)
↓ (embedding)
vector store
↓ (사용자 질의)
유사도 top-K 검색
↓ (LLM)
답변 생성
이 구조의 강점은 명확하다. 빠르고, 단순하고, 새 문서 추가가 trivial 하다. 청크 → 임베딩 → 저장이 끝. 검색도 cosine similarity 한 번이면 끝.
문제는 답변할 수 있는 질의의 형태가 제한적이라는 점이다. 유사도 검색은 본질적으로 "이 청크와 비슷한 청크" 를 찾는 연산이다. 그래서 잘 답하는 질의는 이런 류다.
반면 못 답하는 질의:
이런 질의에서 VectorRAG 는 항상 LLM 후처리에 의존한다. "검색"이 아니라 "검색 + 재추론" 이 되어버린다. 비용이 청크 수에 선형 비례하고, 결과의 일관성도 떨어진다.
핵심은 이거다. 유사도 검색은 텍스트의 의미 거리만 안다. Entity 간의 관계는 모른다.
여기서 GraphRAG 가 들어온다.
말로만 하면 추상적이니까 실제 데이터로 보자. 평가용으로 한 페이지 분량의 시황분석 형식 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 Type | DISTINCT Chunks | Total Mentions |
|---|---|---|
| Company | 14 | 68 |
| Risk | 6 | 21 |
| Metric | 4 | 14 |
| Outlook | 3 | 5 |
1쪽짜리 시황분석에서 14개 회사 + 6개 리스크 + 4개 지표 + 3개 전망이 추출됐다. 그리고 Company entity 1개당 평균 4.86개 청크에서 언급됐다.
이 숫자가 GraphRAG 의 정량적 가치다. VectorRAG 였다면 "회사 X" 키워드로 검색해서 청크 5개 가져오는 데 그쳤을 텐데, GraphRAG 는 다음과 같은 사실들을 그래프 구조로 직접 표현한다.
이러면 "회사 X 와 함께 언급된 리스크는?" 같은 질의가 Cypher 한 줄로 답해진다.
MATCH (c:Company {name: $company})-[:FACES_RISK]->(r:Risk)
RETURN r.name;
LLM 호출 0번. 응답 시간 100ms 미만. 검색이 곧 답 이다.
그래프를 어떻게 설계했느냐가 GraphRAG 의 거의 모든 것이다. 직접 정의한 ontology 스키마를 따라 3-Layer 구조로 적재한다.
원본 문서의 물리적 구조를 그대로 보존한다. 출처 추적과 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, ...})
이 레이어만 있어도 "이 답변이 어느 문서 어느 페이지 어느 청크에서 왔는지" 를 정확히 추적할 수 있다. 도메인에 따라 다르지만, 출처가 중요한 분야에서는 생명이다.
청크에서 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) 로 타입별 검색도 가능하다.
가장 중요한 연결고리다. "어느 청크가 어느 entity 를 언급했는가" 를 표현한다.
(:Chunk) -[:MENTIONS]-> (: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;
→ 해당 회사가 등장하는 모든 보고서가 한 번에 나온다.
여기서 짚어야 하는 게 NED (Named Entity Disambiguation) 다. LLM 으로 청크별로 entity 를 뽑으면 같은 회사가 다른 표기로 나온다.
청크 1: "회사명 정식 표기"
청크 5: "회사명 약칭"
청크 12: "회사명(주)"
청크 23: "회사명 영문"
NED 가 없으면 이 4개를 다른 entity 4개 로 본다. 그러면 그래프가 사방으로 fragmenting 된다. "회사 X 의 리스크" 같은 쿼리가 4번 검색이 되어버리고, 결과도 4개로 쪼개진다.
다음 단계로 NED 를 한다.
1쪽 PDF 의 실측 결과는 다음과 같았다.
| 단계 | 개수 |
|---|---|
| Raw entity (청크별 추출 합산) | 113 |
| 임계값 0.92 그룹화 후 | 55 |
| Compression ratio | 0.49 (51% 중복 제거) |
평균적으로 같은 entity 가 약 2번씩 다른 표기로 등장 했다는 뜻이다. 113개 모두 별도 노드로 만들었으면 그래프가 의미 없어졌을 것이다. NED 가 GraphRAG 를 실질적으로 valuable 하게 만든다.
여러 문서 풀 적재 후에는 이 compression 이 더 극적으로 나타날 것으로 예상된다. 같은 회사가 여러 분기 보고서에 등장하면 단일 group_id 로 묶이고, 다중 문서를 동시 traversal 하는 쿼리가 trivial 해진다.
여기까지 보면 "GraphRAG 좋네, 적용하자" 라고 결론 내고 싶다. 그런데 실제로 다수의 문서를 batch 적재하려고 했을 때 본질적 trade-off 를 마주했다.
일반적으로 쓰이는 chunker 정책을 따랐다.
DEFAULT_CHUNK_SIZE = 700
DEFAULT_CHUNK_OVERLAP = 200
chunker 의 핵심 컴포넌트는 LangChain SemanticChunker 다. 문장들의 임베딩 거리를 보고 의미 단위 경계에서 자른다. production 환경에서는 잘 동작하는 모듈로 알려져 있다.
그런데 개인 환경 (CPU + 노트북) 에서 돌려보면 멈춘다. 한참 멈춘다. 디버깅 1.5시간 끝에 원인이 좁혀졌다.
단독 벤치:
SemanticChunker.split_text(820자) → 31.3초 ❌ 비정상원인: LangChain SemanticChunker 는 임베딩을 문장 단위 sequential 호출 한다 (batch 미사용). bge-m3 (568M params) + CPU 조합에서 문장당 ~400ms × 수십 문장 = 분 단위 소요.
추정 — 8개 문서 전체 분할만 40분 ~ 1시간. LLM 호출과 Neo4j 적재까지 더하면 production 으로 못 쓴다.
| 항목 | SemanticChunker (의미 분할) | RecursiveCharacterTextSplitter (단순 분할) |
|---|---|---|
| 분할 품질 | ◎ 문장 의미 경계 | △ 문자 수 기반 |
| 1문서 분할 시간 (CPU) | ~3분 | ~수십 ms |
| 다수 문서 추정 | 분당 처리량 매우 낮음 | 즉시 |
| 임베딩 호출 | sequential per sentence | 없음 |
| Production 적용 | GPU 또는 API 임베딩 필수 | CPU 만으로 OK |
코드에 환경변수 분기를 추가했다.
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분 안에 끝난다.
이 발견 자체가 가장 큰 인사이트였다. production 환경 (GPU 또는 API 임베딩) 을 가정한 컴포넌트가 개인 환경 (CPU + 오픈소스 모델) 에서는 완전히 다른 비용 프로파일을 가진다. SemanticChunker 는 OpenAI API 임베딩 같은 빠른 임베딩과 짝궁이지, CPU + 무거운 모델 조합으로는 못 쓴다.
GraphRAG vs VectorRAG 를 진짜로 비교하려면 두 시스템이 같은 chunker 를 써야 한다. CPU 환경에서 SemanticChunker 가 비현실적이면, 진짜 비교는 GPU 또는 API 환경을 마련한 다음에야 가능하다.
이게 한 줄로 정리되는 결론이다.
"RAG 시스템의 비교는 chunker 동일성이 전제. CPU 한계가 메타-비교 차원의 변수다."