RAG 시스템을 만들다 보면 한 가지 의문이 생긴다.
“왜 AI가 내 문서를 제대로 찾지 못할까?”
처음에는 보통 임베딩 모델이나 LLM 성능을 의심한다. 하지만 실제로는 전혀 다른 곳에서 문제가 발생하는 경우가 많다. 바로 문서를 어떻게 잘라서 저장했는가이다. 이를 해결하기 위해 구현한 HierarchicalParser에 대해 정리해보려고 한다.
대부분의 RAG 튜토리얼에서는 문서를 다음 방식으로 분할한다.
문서
↓
텍스트 분할
↓
Chunk
↓
Embedding
↓
Vector DB
여기서 사용하는 분할 방식은 보통 다음 기준이다.
대표적인 예가 이런 방식이다.
RecursiveCharacterTextSplitter
문제는 문서가 계층 구조를 가지고 있을 때 발생한다. 예를 들어 다음과 같은 문서가 있다고 가정해보자.
# 보라색 북극곰
## 서식지
남극의 지하 300미터에 산다.
이 문서를 단순히 길이 기준으로 분할하면 다음과 같은 조각이 만들어진다.
서식지
남극의 지하 300미터에 산다.
여기서 중요한 정보가 하나 사라졌다.
보라색 북극곰
즉 이 문장이 무엇에 대한 설명인지 알 수 없게 된다.
이 상태에서 사용자가 다음 질문을 한다고 가정해보자.
보라색 북극곰 어디 살아?
Vector Search는 보통 문장 유사도를 기준으로 검색한다. 하지만 저장된 데이터는 이렇게 되어 있다.
서식지
남극의 지하 300미터에 산다
즉 “북극곰”이라는 키워드가 존재하지 않는다. 결과적으로 검색 흐름은 이렇게 된다.
Question
↓
Vector Search
↓
관련 문서 없음
↓
LLM
↓
부정확한 답변
이 문제가 바로 Naive Chunking의 한계이다.
이 문제를 해결하기 위해 HierarchicalParser를 구현했다. 아이디어는 단순하다.
문서의 계층 구조를 유지한 채로 텍스트를 분할한다.
HierarchicalParser는 문서를 파싱하면서 현재 문장이 어떤 제목 아래에 있는지 계속 추적한다. 예를 들어 이런 구조를 인식한다.
# h1
## h2
### h3
파서는 다음과 같은 상태를 유지한다.
currentH1
currentH2
currentH3
그리고 실제 텍스트 조각을 만들 때 계층 정보를 함께 붙인다.
예를 들어 원본 문장이 다음과 같다고 가정해보자.
서식지: 남극의 지하 300미터
HierarchicalParser는 이를 다음과 같이 변환한다.
[세상의 모든 이상한 것들 사전 > 보라색 북극곰]
서식지: 남극의 지하 300미터
즉 텍스트 앞에 문서의 계보(context)를 추가한다. 이 구조는 벡터 DB에 다음 형태로 저장된다.
Document Chunk
├ text
├ h1
├ h2
└ metadata
또한 계층 정보는 단순히 텍스트에만 붙이는 것이 아니라 메타데이터에도 저장했다. 예를 들어 이런 구조다.
metadata:
h1: 세상의 모든 이상한 것들 사전
h2: 보라색 북극곰
이렇게 하면 나중에 다음 기능도 가능해진다.
일반적으로는 다음과 같은 라이브러리를 많이 사용한다.
RecursiveCharacterTextSplitter
하지만 이번 프로젝트에서는 커스텀 파서를 구현했다. 이유는 세 가지였다.
Chunk 내부에 제목 키워드가 포함되기 때문이다. 예를 들어
보라색 북극곰
이 키워드가 텍스트에 포함되면 벡터 유사도 점수가 크게 올라간다.
메타데이터에 계층 정보가 있기 때문에 다음과 같은 답변이 가능하다.
"세상의 모든 이상한 것들 사전 > 보라색 북극곰" 섹션에 따르면
즉 답변의 출처를 명확하게 표시할 수 있다.
이번 프로젝트에서 사용한 문서들은 다음 특징이 있었다.
sample-odd.txt
sample-markdown.txt
이 문서들은 대부분 명확한 제목-내용 구조를 가지고 있었다. 그래서 계층 파싱 방식이 가장 잘 맞는 구조였다.
HierarchicalParser는 문서의 계층 구조를 유지한 채 텍스트를 분할한다는 점에서 RAG 시스템의 검색 품질을 높여준다. 하지만 몇 가지 장단점도 존재한다.
장점
(1) 문맥(Context) 유지
단순 분할 방식에서는 텍스트 조각이 자신의 상위 문맥을 잃어버리는 경우가 많다. 예를 들어 다음 문장이 있다고 가정해보자.
서식지: 남극의 지하 300미터
Naive Chunking에서는 이 문장이 무엇에 대한 설명인지 알 수 없다. 하지만 HierarchicalParser는 다음과 같이 계층 정보를 함께 저장한다.
[세상의 모든 이상한 것들 사전 > 보라색 북극곰]
서식지: 남극의 지하 300미터
이 구조 덕분에 텍스트 조각이 자신의 주제를 잃지 않는다.
(2) 검색 정확도 향상
Vector Search는 텍스트 유사도를 기반으로 동작한다. HierarchicalParser는 제목 정보를 함께 주입하기 때문에 핵심 키워드가 Chunk 내부에 포함될 확률이 높아진다. 결과적으로 검색 재현율(Recall)이 올라가고, RAG가 더 정확한 문서를 찾아올 가능성이 높아진다.
(3) 출처 제공이 쉬움
계층 정보를 메타데이터로 저장하기 때문에 AI가 답변을 생성할 때 구체적인 출처를 함께 제공할 수 있다.
예를 들어 다음과 같은 방식이다.
"세상의 모든 이상한 것들 사전 > 보라색 북극곰" 섹션에 따르면
이는 RAG 시스템의 신뢰도를 높이는 데 도움이 된다.
단점
(1) 토큰 사용량 증가
각 Chunk에 제목 정보가 반복 삽입되기 때문에
이 모두가 소폭 증가하는 단점이 있다.
(2) 문서 구조 의존성
HierarchicalParser는 다음과 같은 문서에서 가장 효과적이다.
반대로 로그 데이터나 대화 기록처럼 비정형 데이터에서는 효과가 제한적일 수 있다.
RAG 시스템을 처음 만들 때 가장 많이 하는 질문이 있다.
왜 AI가 내 문서를 못 찾지?
이 문제의 원인은 생각보다 단순하다. 문서를 잘못 저장했기 때문이다. HierarchicalParser는 데이터를 단순히 분할하는 것이 아니라 각 문서 조각에 이름표를 붙이는 작업이다.
문서의 계층 구조를 이해하고 이를 벡터 DB에 반영하는 것만으로도 RAG 시스템의 검색 품질은 크게 올라간다. LLM 성능을 올리는 것만큼이나 중요한 것이 데이터 구조 설계라는 점을 다시 한번 느낀 경험이었다.