🤖 AI/ML

Chunking

Text Chunking / Document Splitting

긴 문서를 작은 조각(청크)으로 나누는 기법입니다. RAG(검색 증강 생성) 시스템에서 임베딩 생성 전 필수적으로 수행되며, 청크 크기와 오버랩 설정이 검색 품질에 결정적인 영향을 미칩니다.

📖 상세 설명

Chunking(청킹)은 긴 텍스트 문서를 더 작은 단위인 "청크(chunk)"로 분할하는 전처리 기법입니다. LLM(대규모 언어 모델)과 임베딩 모델은 처리할 수 있는 토큰 수에 제한이 있고, 긴 문서를 통째로 임베딩하면 의미가 희석되기 때문에, 적절한 크기로 나누는 것이 필수입니다. RAG 시스템의 검색 품질은 청킹 전략에 크게 좌우됩니다.

Chunking의 필요성은 LLM의 컨텍스트 윈도우 제한과 임베딩의 특성에서 비롯됩니다. 예를 들어, OpenAI의 text-embedding-3-small 모델은 8191 토큰까지만 처리할 수 있고, 너무 긴 텍스트를 하나의 임베딩으로 압축하면 세부 정보가 손실됩니다. 적절한 크기의 청크로 나누면 각 청크가 특정 주제에 집중하게 되어 검색 정확도가 향상됩니다.

청킹 방법은 여러 가지가 있습니다. 가장 기본적인 것은 고정 크기 분할로, 일정 문자 수나 토큰 수로 자르는 방식입니다. 하지만 이는 문장 중간에서 잘릴 수 있어 의미가 깨질 수 있습니다. 이를 보완하기 위해 문장 단위, 문단 단위, 또는 마크다운 헤더 기준 분할 등 구조 기반 청킹이 사용됩니다. 최근에는 시맨틱 청킹(의미 단위 분할)도 주목받고 있으며, 문맥의 연속성을 위해 청크 간 오버랩(overlap)을 두는 것도 중요합니다.

실무에서 청킹 전략은 데이터 특성에 따라 달라집니다. 기술 문서나 법률 문서는 섹션/조항 단위로, 대화 로그는 발화 단위로, 코드는 함수/클래스 단위로 나누는 것이 효과적입니다. 일반적으로 청크 크기 500-1500 토큰, 오버랩 10-20% 정도가 권장되지만, 실제 검색 성능을 측정하며 최적값을 찾아야 합니다. LangChain, LlamaIndex 등의 프레임워크는 다양한 청킹 전략을 쉽게 적용할 수 있는 도구를 제공합니다.

💻 코드 예제

다양한 청킹 방법을 구현하는 Python 코드입니다.

Python
# pip install langchain langchain-text-splitters tiktoken
from langchain_text_splitters import (
    RecursiveCharacterTextSplitter,
    CharacterTextSplitter,
    TokenTextSplitter,
    MarkdownHeaderTextSplitter
)
import tiktoken

# 예시 문서
document = """
# 인공지능 개요

인공지능(AI)은 인간의 학습능력, 추론능력, 언어이해능력 등을
컴퓨터 프로그램으로 실현한 기술입니다.

## 머신러닝

머신러닝은 데이터로부터 패턴을 학습하여 예측하는 AI의 하위 분야입니다.
지도학습, 비지도학습, 강화학습으로 나뉩니다.

### 딥러닝

딥러닝은 인공신경망을 여러 층으로 쌓아 복잡한 패턴을 학습하는 기법입니다.
CNN, RNN, Transformer 등의 아키텍처가 있습니다.

## 자연어 처리

자연어 처리(NLP)는 컴퓨터가 인간의 언어를 이해하고 생성하는 기술입니다.
텍스트 분류, 번역, 요약, 질문 응답 등에 활용됩니다.
"""

# 1. 재귀적 문자 분할 (가장 추천되는 방법)
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,           # 청크 최대 크기 (문자 수)
    chunk_overlap=50,         # 청크 간 오버랩 (문맥 유지)
    length_function=len,
    separators=["\n\n", "\n", ".", " ", ""]  # 분할 우선순위
)

chunks = recursive_splitter.split_text(document)
print("=== 재귀적 문자 분할 ===")
for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1} ({len(chunk)}자): {chunk[:50]}...")

# 2. 토큰 기반 분할 (토큰 제한 고려 시)
token_splitter = TokenTextSplitter(
    encoding_name="cl100k_base",  # GPT-4/3.5용 토크나이저
    chunk_size=100,               # 최대 토큰 수
    chunk_overlap=20              # 오버랩 토큰 수
)

token_chunks = token_splitter.split_text(document)
print("\n=== 토큰 기반 분할 ===")
for i, chunk in enumerate(token_chunks):
    # 토큰 수 확인
    enc = tiktoken.get_encoding("cl100k_base")
    tokens = len(enc.encode(chunk))
    print(f"Chunk {i+1} ({tokens} tokens): {chunk[:40]}...")

# 3. 마크다운 헤더 기반 분할 (구조화된 문서에 적합)
headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3"),
]

markdown_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on
)

md_chunks = markdown_splitter.split_text(document)
print("\n=== 마크다운 헤더 기반 분할 ===")
for i, chunk in enumerate(md_chunks):
    print(f"Chunk {i+1}: {chunk.metadata} - {chunk.page_content[:40]}...")

# 4. 시맨틱 청킹 (의미 단위 분할) - 고급 방법
from langchain_openai import OpenAIEmbeddings
from langchain_experimental.text_splitter import SemanticChunker

# 의미적 유사도 기반 분할
semantic_splitter = SemanticChunker(
    embeddings=OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",  # 분할 기준
    breakpoint_threshold_amount=95           # 임계값
)

# 5. 커스텀 청킹 함수
def custom_chunker(
    text: str,
    chunk_size: int = 500,
    chunk_overlap: int = 100,
    min_chunk_size: int = 50
) -> list:
    """
    문장 경계를 존중하는 커스텀 청커

    Args:
        text: 분할할 텍스트
        chunk_size: 목표 청크 크기
        chunk_overlap: 오버랩 크기
        min_chunk_size: 최소 청크 크기

    Returns:
        청크 리스트
    """
    import re

    # 문장 단위로 분리
    sentences = re.split(r'(?<=[.!?])\s+', text)
    chunks = []
    current_chunk = ""

    for sentence in sentences:
        if len(current_chunk) + len(sentence) <= chunk_size:
            current_chunk += " " + sentence if current_chunk else sentence
        else:
            if len(current_chunk) >= min_chunk_size:
                chunks.append(current_chunk.strip())
            # 오버랩 적용
            overlap_text = current_chunk[-chunk_overlap:] if len(current_chunk) > chunk_overlap else current_chunk
            current_chunk = overlap_text + " " + sentence

    if len(current_chunk) >= min_chunk_size:
        chunks.append(current_chunk.strip())

    return chunks

custom_chunks = custom_chunker(document, chunk_size=300, chunk_overlap=50)
print("\n=== 커스텀 문장 기반 분할 ===")
for i, chunk in enumerate(custom_chunks):
    print(f"Chunk {i+1} ({len(chunk)}자): {chunk[:50]}...")

🗣️ 실무 대화 예시

팀장 "RAG 검색 품질이 좋지 않아요. 관련 없는 내용이 검색되는 경우가 많은데, 어떻게 개선할 수 있을까요?"
개발자 "현재 청크 사이즈가 2000자인데, 너무 큰 것 같아요. 하나의 청크에 여러 주제가 섞여서 임베딩이 희석되고 있습니다. 500-800자로 줄이고 오버랩을 20% 정도 주면 좋을 것 같아요."
팀장 "청크가 작아지면 문맥이 끊기지 않을까요?"
개발자 "오버랩이 그 역할을 해줍니다. 또한 검색 시 상위 3-5개 청크를 가져와서 합치면 충분한 문맥을 제공할 수 있어요. 아니면 Parent Document Retriever 패턴을 써서 작은 청크로 검색하고 큰 원본 문서를 반환하는 방법도 있습니다."
면접관 "RAG에서 청킹이 왜 중요한지 설명해주세요."
지원자 "세 가지 이유가 있습니다. 첫째, 임베딩 모델의 토큰 제한이 있어요. 둘째, 너무 긴 텍스트를 하나의 벡터로 압축하면 세부 정보가 손실됩니다. 셋째, 적절한 크기의 청크가 있어야 검색 시 관련성 높은 정보만 정확하게 가져올 수 있습니다."
면접관 "청크 크기는 어떻게 결정하나요?"
지원자 "데이터 특성과 사용 사례에 따라 다릅니다. 일반적으로 500-1500 토큰이 권장되지만, 실제로는 평가 세트를 만들어서 다양한 청크 사이즈로 검색 성능을 측정해야 합니다. 또한 문서 구조가 있다면 섹션/헤더 기준으로 나누는 것이 효과적입니다."
시니어 "CharacterTextSplitter를 separator='\n'으로만 쓰고 있네요. 이건 문제가 있어요."
주니어 "어떤 문제가 있죠? 줄바꿈 기준으로 잘 나뉘는 것 같은데요."
시니어 "줄바꿈 없이 긴 문단이 있으면 chunk_size를 초과해도 그냥 통과시켜요. RecursiveCharacterTextSplitter를 쓰면 여러 구분자를 순차적으로 시도해서 더 안전합니다. 그리고 오버랩이 0인데, 청크 경계에서 문맥이 끊깁니다."
주니어 "RecursiveCharacterTextSplitter로 바꾸고, chunk_overlap을 chunk_size의 10-20%로 설정하겠습니다. 감사합니다!"

⚠️ 주의사항

🔗 관련 용어

📚 더 배우기