RAG

[RAG] TextSplitter 비교 및 중요성 (feat. CharacterTextSplitter, RecursiveCharacterText

moonzoo 2025. 2. 6. 13:15
https://js.langchain.com/docs/concepts/text_splitters/

0. TextSplitter

RAG는 대규모 언어 모델(LLM)이 외부 지식을 사용하여 응답을 생성하는 구조입니다. 이때, 검색(retrieval) 단계에서 문서를 효율적으로 검색하기 위해 문서를 적절한 크기로 분할해야 합니다.
이 때, 문서를 적절하게 분할하는 기술을 "Text Splitter"라고 합니다.
 
그럼 효율적인 검색을 위해 문서를 적절한 크기로 분할해야 하는 이유가 뭘까요?
 

0_1. 효율적인 정보 검색

  • 길이가 긴 문서를 LLM 으로 입력하게 되면 비용이 많이 발생하고, 많은 정보속에서 원하는 정보를 찾는 것을 어려워 합니다. 이러한 문제가 할루시네이션으로 이어지기도 하기 때문에 문서를 분할하는 것이 효율적입니다. 

 

0_2. 질문-응답 정확도 향상

  • TextSplitter가 적절히 분할한 텍스트 청크는 검색 결과로 반환될 때 질문과 관련 있는 맥락(Context)을 포함합니다.
  • 물론 의미가 있는 단위로 청크를 나누는 것은 어려운 일이지만, 잘 분할된 텍스트는 RAG의 생성 모델이 더 정확하고 맥락에 맞는 답변을 생성하는 데 큰 영향을 끼칩니다.
  • 그러나, 분할된 청크가 너무 크면 관련이 없는 정보까지 포함될 가능성이 높고, 너무 작으면 문맥을 잃어버릴 위험이 있습니다. TextSplitter는 적절한 크기의 청크를 생성하여 이런 문제를 해결하고자 제안됐습니다.

1. TextSplitter 비교

그렇다면 효율적이고 정확도가 높은 RAG 결과를 위해 문서를 어떠한 방식으로 Chunking하는 것이 좋을까요?
 

가장 이상적인 방식은 의미가 있는 단위로 문단(Paragraph)을 분할한 "Semantic Chunking"입니다.

 
 
그렇다면 현재 공개된 TextSplitter로 Semantic Chunking을 잘할 수 있는지 여러 Chunking 방법론을 비교해보겠습니다.
각 TextSplitter의 파라미터 설정이나 기능은 웹에서 검색하시면 확인해보실 수 있습니다. 여기서는 TextSpliter의 중요성을 알아보기 위한 것이기 때문에 넘어가도록 하겠습니다.
 
 
예시를 위해 좀 어려운 PDF 파일을 가져왔습니다.
(형식이 단순하거나 고정된 pdf파일을 Chunking하는 것은 비교적 간단합니다. 그러나, 복잡한 pdf 파일 역시 중요한 정보이기 때문에 Chunking을 잘하는 것이 중요하여 어려운 예시 pdf를 실험에 사용했습니다.)
 

 
카드 상품에 대한 설명서로 Pymupdf, pdfplumber, llamaindex 등 모든 오픈소스 pdf loader를 사용해도 pdf to text 변환이 어려운 예시입니다. text 변환이 어려운 이유는 다음과 같습니다.
1. 세로선이 없는 표이기 때문에 table extract가 어렵습니다. 이로 인해, 표의 구조를 파악하지 못해 문맥을 살려 텍스트를 변환하지 못합니다.
2. line으로 텍스트를 변환하는 경우에는 좌측의 "제휴 호텔/공항 발레파킹 서비스" 라는 목차와 우측의 본문 내용이 분할되지 않고 병합됩니다.
 
(이러한 pdf파일을 전처리하여 load하는 방법은 추후에 다루도록 하겠습니다.) 
 
이러한 pdf 파일을 공개된 TextSpliter를 통해 Chunking을 잘할 수 있을지 확인해보겠습니다.
 


00. Base Text & Question

 

Text

 

Question

 
"인천공항에서 호텔 공항 발레파킹 서비스 이용하려 하는데 이용방법 알려줘."


01. 문자 텍스트 분할(CharacterTextSplitter)

 
가장 간단한 방식으로 기본적으로 "\n\n" 을 기준으로 문자 단위로 텍스트를 분할하고, 청크의 크기를 문자 수로 측정합니다. 맥락을 보존하기 위해 chunk_overlap을 통해 각 청크끼리 일부 텍스트를 공유합니다. 

from langchain_text_splitters import CharacterTextSplitter

splitter = CharacterTextSplitter(chunk_size=250, chunk_overlap=50,separator='\n')
chunks = splitter.split_text(text)

 

chunk 0
==============================
제휴 호텔/공항 
발레파킹 서비스
프로모션 기간 : 2018.01.01~2018.12.31
프로모션 기간 종료 후 프로모션 내용 변경, 종료 등은 BC카드 
VIP 홈페이지에서 확인 하시기 바랍니다.
BC카드 VIP홈페이지 : vip.bccard.com
프로모션 내용
호텔 발레파킹 : BLISS BC GLOBAL카드로 제휴 호텔 이용 시 
 무료 발레파킹/주차비 지원 
공항 발레파킹 : BLISS BC GLOBAL카드로 제휴 공항 이용 시
==============================
chunk 1
==============================
공항 발레파킹 : BLISS BC GLOBAL카드로 제휴 공항 이용 시 
 무료 발레파킹 제공. 단, 주차비는 고객 부담
이용방법
※
•
•
•
•
구분 내용  
호텔 • 호텔 현관에서 도어맨에게 발렛파킹 요청 
• 차량 인수 시 도어맨에게 주차티켓과 카드 제시 
• 도어맨이 소지한 단말기를 통해 서비스 이용 가능 여부 확인 후 서비스 제공 
인천공항 T1 • 주차 시
1. 인천공항 제1여객터미널 발렛 전용차로 및 발렛 안내표지판을 따라 3층
==============================
chunk 2
==============================
1. 인천공항 제1여객터미널 발렛 전용차로 및 발렛 안내표지판을 따라 3층 
출국층 승용차 도로 진입
2. 안내표지판을 따라서 지상단기주차장 C구역으로 이동
3. 오렌지맨 주차요원에 차량 상태 점검 후 차량 열쇠 인도 및 차량 접수증 수령
• 출차 시
1. 차량보관증에 명기되어 있는 인수장(단기 주차장 지하 3층 동,서편)으로 이동
하여 차량보관증 및 BLISS BC GLOBAL 카드 제시 후, 주차권과 차량열쇠 인수
==============================
chunk 3
==============================
하여 차량보관증 및 BLISS BC GLOBAL 카드 제시 후, 주차권과 차량열쇠 인수
2. 추차장 출차 시 주차요금 정산
인천공항 T2 • 주차 시
1. 인천공항 제2여객터미널 발렛 전용차로 및 발렛 안내표지판을 따라 단
기 주차장 도로로 진입
2. 단기 주차장 지하 1층 서편 주차구역 110~112번으로 이동
3. 오렌지맨 주차요원에 차량 상태 점검 후 차량키 인도 및 차량 접수증(알림톡,
SMS 발송) 수령
• 출차 시
==============================

 
장점:

  • 구현이 쉽습니다.
  • 청크를 겹쳐서 부분적인 맥락을 보존합니다.

단점:

  • 문장의 의미를 무시합니다. 문장을 갑자기 잘라낼 수 있습니다.

Question에 대한 답변 예측 : 

  • Base Question인 "인천공항에서 호텔 공항 발레파킹 서비스 이용하려 하는데 이용방법 알려줘."에 대해 위의 Chunk로 답변을 잘할 수 있을까요? 
    • 해당 정보가 포함된 chunk 1, chunk 2, chunk 3...으로 인천공항 터미널에 대한 이용방법이 분할 되었고,
    • chunk 3와 같이 chunk 1의 과거 정보 '발레파킹 서비스'라는 단어가 존재하지 않아 검색 정확도가 떨어질 수 있습니다.

결론 : 
 
단순 문자 텍스트 분할(CharacterTextSplitter)로는 이상적인 Chunking이 불가능 합니다.
 
 


 

02. 재귀적 문자 텍스트 분할(RecursiveCharacterTextSplitter)

 
RecursiveCharacterTextSplitter는 청크가 충분히 작아질 때까지 주어진 문자 목록의 순서대로 텍스트를 분할합니다.
기본 문자 목록은 ["\n\n", "\n", " ", ""]로 단락 -> 문장 -> 단어 순서로 재귀적으로 분할합니다.
 
이는 단락(그 다음으로 문장, 단어) 단위가 의미적으로 가장 강하게 연관된 텍스트 조각으로 간주되므로, 가능한 한 함께 유지하려는 효과가 있습니다. 단, 단순 문자 텍스트 분할과 동일하게 문자수에 의해 청크의 크기가 측정됩니다.

 

from langchain.text_splitter import RecursiveCharacterTextSplitter 

splitter = RecursiveCharacterTextSplitter(chunk_size= 250 , chunk_overlap= 50 ) 
chunks = splitter.split_text(text)
chunk 0
==============================
제휴 호텔/공항 
발레파킹 서비스
프로모션 기간 : 2018.01.01~2018.12.31
프로모션 기간 종료 후 프로모션 내용 변경, 종료 등은 BC카드 
VIP 홈페이지에서 확인 하시기 바랍니다.
BC카드 VIP홈페이지 : vip.bccard.com
프로모션 내용
호텔 발레파킹 : BLISS BC GLOBAL카드로 제휴 호텔 이용 시 
 무료 발레파킹/주차비 지원 
공항 발레파킹 : BLISS BC GLOBAL카드로 제휴 공항 이용 시
==============================
chunk 1
==============================
공항 발레파킹 : BLISS BC GLOBAL카드로 제휴 공항 이용 시 
 무료 발레파킹 제공. 단, 주차비는 고객 부담
이용방법
※
•
•
•
•
구분 내용  
호텔 • 호텔 현관에서 도어맨에게 발렛파킹 요청 
• 차량 인수 시 도어맨에게 주차티켓과 카드 제시 
• 도어맨이 소지한 단말기를 통해 서비스 이용 가능 여부 확인 후 서비스 제공 
인천공항 T1 • 주차 시
==============================
chunk 2
==============================
인천공항 T1 • 주차 시
1. 인천공항 제1여객터미널 발렛 전용차로 및 발렛 안내표지판을 따라 3층 
출국층 승용차 도로 진입
2. 안내표지판을 따라서 지상단기주차장 C구역으로 이동
3. 오렌지맨 주차요원에 차량 상태 점검 후 차량 열쇠 인도 및 차량 접수증 수령
• 출차 시
1. 차량보관증에 명기되어 있는 인수장(단기 주차장 지하 3층 동,서편)으로 이동
==============================
chunk 3
==============================
1. 차량보관증에 명기되어 있는 인수장(단기 주차장 지하 3층 동,서편)으로 이동
하여 차량보관증 및 BLISS BC GLOBAL 카드 제시 후, 주차권과 차량열쇠 인수
2. 추차장 출차 시 주차요금 정산
인천공항 T2 • 주차 시
1. 인천공항 제2여객터미널 발렛 전용차로 및 발렛 안내표지판을 따라 단
기 주차장 도로로 진입
2. 단기 주차장 지하 1층 서편 주차구역 110~112번으로 이동

장점:

  • 단순 텍스트 분할보다 맥락을 더 잘 보존합니다.
  • 문단이나 섹션과 같은 중첩된 구조를 처리합니다.

단점:

  • 단순 텍스트 분할에 비해 설정이 약간 복잡함.
  • 인덱싱하기 어려운 가변 크기의 청크가 생성될 수 있습니다.

Question에 대한 답변 예측 : 

  • Base Question인 "인천공항에서 호텔 공항 발레파킹 서비스 이용하려 하는데 이용방법 알려줘."에 대해 위의 Chunk로 답변을 잘할 수 있을까요? 
    • 단순 텍스트 분할기와 마찬가지로 인천공항에 대한 정보가 분할 되었고,
    • 과거의 정보 "발레파킹 서비스"에 대한 인천공항에서의 서비스 이용방법인지 파악할 수 있는 방법이 없습니다.

결론 : 
 
재귀적 문자 텍스트 분할(RecursiveCharacterTextSplitter) 로는 이상적인 Chunking이 불가능 합니다.
 


 

03. 마크다운 헤더 텍스트 분할(MarkdownHeaderTextSplitter)

 
마크다운 문서는 #, ##, ### 등 헤더를 사용해 계층 구조를 형성하고 있는데요, MarkdownHeaderTextSplitter는 이 계층 구조의 헤더와 같은 구조를 사용하여 텍스트를 분할합니다.
 
계층 구조가 정확히 형성된 문서라면 의미있는 단위(계층 구조)로 텍스트를 분할하여, 주제가 연관이 있는 문장을 모은 하나의 청크를 만들기 때문에 검색 엔진이 관련된 여러 정보를 참고해 더 정확한 답변을 할 수 있게됩니다.
 
from langchain_text_splitters import MarkdownHeaderTextSplitter

headers_to_split_on = [
    ("#", "Header 1"),  
    ("##", "Header 2"), 
    ("###", "Header 3")  
]

markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_header_splits = markdown_splitter.split_text(markdown_text)
 
(markdown_text는 기존 실험 text에 제가 임의로 #, ##, ### 헤더를 추가해주고 줄바꿈을 진행했습니다.)

 
보시면 #, ##, ### 헤더를 기준으로 분할하고 metadata에 헤더 정보가 추가된 것을 확인하실 수 있습니다.
 
그러나, 마크다운 문서가 아닌 다른 비정형 문서(pdf, word, hwp) 파일은 헤더 정보가 없는 경우가 많아 효과적으로 분할하기 어렵습니다.
 
pdf 파일을 markdown foramt의 text로 변환해주는 PyMUPDF4LLM, Llamaparse 등을 사용해도 pdf 파일에 헤더 정보가 없는 경우가 많아 비정형 문서에 일반화하여 사용하기엔 무리가 있습니다.
 
 

장점:

  • 문서 구조 유지 : 헤더 기반 분할로 논리적인 문서 계층 구조를 보존함.
  • 검색 정확도 향상 : 의미 단위(섹션별)로 분할하여 RAG에서 더 적절한 컨텍스트 제공.
  • 빠른 인덱싱 가능 : 헤더를 메타데이터로 활용하여 검색 효율 증가.
  • 유연한 분할 설정 : 원하는 헤더 수준(#, ##, ###)을 지정하여 세분화 가능.

단점 : 

  • 비정형 문서 처리 어려움 : 헤더 없이 서술된 문서는 효과적으로 분할되지 않음.
  • 헤더 기준이 다를 경우 문제 발생 : 일관되지 않은 헤더 사용 시 원하는 단위로 나누기 어려움.
  • 긴 섹션 문제 : 하나의 헤더 아래 내용이 너무 길면 추가적인 텍스트 분할이 필요함.

Question에 대한 답변 예측 : 

  • Base Question인 "인천공항에서 호텔 공항 발레파킹 서비스 이용하려 하는데 이용방법 알려줘."에 대해 위의 Chunk로 답변을 잘할 수 있을까요? 
    • "네. 충분히 잘 할 수 있습니다." 우선 메타데이터 정보를 통해 검색하는 방법도 있고, 이외에도 여러 방법이 있는데 제가 직접 헤더를 달아주고 줄바꿈 해준 것이긴 하지만 마크다운 구조를 잘 따르는 위와 같은 경우는 인천공항의 호텔 공항 발레파킹 서비스에 대한 청크를 효율적으로 검색할 수 있습니다.

결론:

 
문서가 마크다운 구조를 잘 따를 경우 매우 효과적이지만, 비정형 문서에는 한계가 있음.
 


04. 시멘틱 청커(SemanticChunker)

 
SemanticChunker는 문서를 단순히 길이나 구조가 아닌 의미(semantic)를 기준으로 분할하는 기법입니다. 즉, 문장 간의 연관성을 고려하여 적절한 단위로 나누는 방식입니다.
 
문장간의 의미 연관성을 SemanticChunker가 잘 파악하고 나눈다면 이거만큼 이상적인 Chunking 전략이 없을 것 같습니다. 그럼 바로 확인해보겠습니다.
 
우선 문장 연관성을 계산하기 위한 임베딩 모델을 불러옵니다. (OpenAI 임베딩을 사용해도 됨.)

        
from langchain.embeddings import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(
    model_name='jinaai/jina-embeddings-v3',
    model_kwargs={'device': "cuda:0", 'trust_remote_code': True},
    encode_kwargs={'normalize_embeddings':True},
    multi_process = False
)

 
그리고 임베딩 모델을 사용하여 SemanticChunker를 초기화 시켜줍니다.

from langchain_experimental.text_splitter import SemanticChunker


text_splitter = SemanticChunker(
    embeddings,
    # 분할 기준점 유형을 백분위수로 설정합니다.
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=70
)

docs = text_splitter.create_documents([text])


이제 결과를 확인해보시면 됩니다.

 
어떤가요? 단순 텍스트 분할 처럼 문장을 갑작스럽게 자르는 문제는 발생하지 않지만, 사람이 판단해보면 context based chunk가 아니라고 판단할 것입니다.
 
OpenAI 임베딩을 써도 마찬가지입니다. 현재 SemantiChunker로는 위의 Base text를 의미가 있는 chunk로 묶지 못합니다.
 
SemanticChunker의 장점은 "비정형 문서에 적용해볼 수 있다."인데 위의 예시처럼 청크를 제대로 분할하지 못하면 장점이 될 수없으므로... 장 단점을 정리하는 것보다는 왜 이러한 현상이 발생하는 것인지 간단하게만 설명하겠습니다.


💡 1. 의미적 유사도만으로는 구조적인 연결을 파악하기 어려움

 
SemanticChunker는 보통 연속된 문장 간 의미적 유사도(Semantic Similarity) 를 기반으로 청크를 구분합니다. 하지만 위 텍스트는 목록형 정보(리스트, 절차, 주의사항 등) 가 포함되어 있어, 개별 문장 간 의미 유사도만으로는 적절한 청크 경계를 설정하기 어렵습니다.
예시:

  • "인천공항 T2 주차 시" → "1. 인천공항 제2여객터미널 발렛 전용차로 및 발렛 안내표지판을 따라 단기 주차장 도로로 진입" → "2. 단기 주차장 지하 1층 서편 주차구역 110~112번으로 이동"
    • 각 단계가 의미적으로 연결되지만, 개별적으로 보면 단순한 나열로 보일 수 있음.
    • SemanticChunker는 의미적 유사도가 낮다고 판단하여 잘못된 지점에서 청크를 나눌 가능성이 높음.

💡 2. 리스트와 절차 정보가 많아 의미적 연관성이 낮게 계산됨

SemanticChunker는 문장을 벡터화하여 유사도를 계산하는데, 리스트(•), 번호(1,2,3), 짧은 절차 설명 등은 문맥 없이 단순한 나열처럼 보이기 쉽습니다.
 
결과적으로 청크를 나눌 때 연관성이 높은 문장끼리 묶이지 않고, 절차가 끊겨버리는 문제가 발생하는거죠.
 
예시:

  • "공항 발레파킹: 무료 발레파킹 제공. 단, 주차비는 고객 부담"
  • "이용방법 ※"
  • "출차 시 차량 보관증에 있는 연락처로 차량 출차 요청"
    • 개별적으로 보면 의미가 다소 다르다고 판단될 수 있음 → 문맥을 이해하지 못하고 불필요한 분할 발생

💡 3. 단순한 문맥 연결이 아니라 "논리적 구조"를 파악해야 함

 
위 텍스트는 단순히 의미적으로 유사한 문장끼리 묶는 것(Semantic Similarity) 이 아니라,
논리적인 흐름(예: 이용 방법, 발렛 절차, 주차 정산 과정 등)에 따라 그룹화 되어야 합니다.
SemanticChunker는 문장 간 "의미적 유사도"를 기반으로 하기 때문에 논리적 흐름을 올바르게 파악하지 못합니다.
예시:

  • "출차 시 차량 보관증에 있는 연락처로 차량 출차 요청"
  • "국내선 도착 1층의 주차대행 요금정산소에서 주차요금 정산 후 차량 인수"
    • 의미적 유사도(Semantic Similarity)만 보면 분리될 가능성이 있음.
    • 하지만 이 두 문장은 논리적으로 같은 절차의 일부이므로 하나의 청크로 묶여야 함.

SemanticChunker는 단순 임베딩 유사도만으로 문맥을 유지하는 것이 어렵기 때문에, context based chunk를 잘 생성하기 위해선 다른 방법을 사용해야 한다는 것입니다.
 
그럼 이러한 문제가 발생하는 이유가 무엇일까요?
 
SemanticChunker가 의미 있는 단위로 문서를 분할하지 못하는 주요 원인은 Transformer 구조와 코사인 유사도(Cosine Similarity) 계산 방식 때문입니다.
 
이 주제에 대한 내용은 다음 글에서 다뤄보도록 하겠습니다.


💻결론

RAG 모델의 성능을 극대화하려면 텍스트 분할(Text Splitter)이 핵심입니다. 하지만, 단순히 텍스트를 자르는 것을 넘어 "의미가 있는 덩어리"로 분할하는 것이 중요합니다.
 
본문에서 살펴본 것처럼, 여러 텍스트 분할 방법(단순 문자, 재귀적 문자, 마크다운 헤더, 시멘틱 청커)이 있지만, 각각 장단점이 뚜렷합니다. 특히, 복잡한 비정형 문서(PDF 등)를 의미 있는 단위로 나누는 것은 여전히 쉽지 않은 과제입니다.
 
또한, "chatGPT, Gemini 등 LLM을 사용하면 못하는게 있을까?" 라는 생각이 들 수도 있지만, 아직은 LLM 기반 문서처리 parsing, chunking, bounding box 문제로 성능이 떨어지고 있는 것이 현실입니다.
 
결론적으로, "현재로서는 일반화하여 범용적으로 Text Splitter는 없다!" 입니다.
문서의 특성과 RAG 모델의 목적에 맞게 최적의 분할 전략을 고민하고, 다양한 방법을 조합하는 지혜가 필요합니다.

또는, 기존의 Transformer 구조를 사용하는 SemanticChunker의 문제점을 개선할 수 있는 새로운 모델을 개발하는 방법도 있겠습니다.
 
다음 글에서는 시멘틱 청커(Semantic Chunker)가 왜 완벽하지 않은지, Transformer 구조와 코사인 유사도와 연관지어 작성해보도록 하겠습니다.


🎈깃허브 코드
https://github.com/moonjoo98/RAG_R-D/blob/main/TextSplitter/TextSplitter_Compare.ipynb 

RAG_R-D/TextSplitter/TextSplitter_Compare.ipynb at main · moonjoo98/RAG_R-D

RAG Research Result. Contribute to moonjoo98/RAG_R-D development by creating an account on GitHub.

github.com