Skip to content
Published on

RAG 청킹 전략 완전 가이드: 나이브 분할부터 RAPTOR까지

Authors

왜 청킹이 RAG 품질의 70%를 결정하는가

"Garbage in, garbage out."

RAG 시스템을 처음 구축해보면 이 말을 뼈저리게 느끼게 된다. 청킹이 나쁘면 아무리 좋은 임베딩 모델, 아무리 비싼 LLM을 써도 소용없다.

청킹(chunking)이란 긴 문서를 검색 가능한 작은 단위로 나누는 작업이다. 단순하게 들리지만, 여기서 내리는 결정 하나하나가 최종 RAG 성능에 엄청난 영향을 준다.

왜 청킹이 이렇게 중요한가?

임베딩의 정보 밀도 문제: 임베딩 모델은 하나의 벡터로 전체 청크를 표현한다. 청크가 너무 크면 핵심 의미가 희석되고, 청크가 너무 작으면 맥락이 사라진다.

검색 vs. 생성의 트레이드오프: 검색에는 작고 집중된 청크가 유리하고, 생성에는 충분한 컨텍스트가 필요하다. 이 둘을 동시에 만족시키는 게 청킹 전략의 핵심이다.

문서 구조 파괴 문제: 단순히 글자 수로 자르면 문장 중간, 목록 중간이 잘려나가 의미가 완전히 깨진다.

이 글에서는 5가지 청킹 전략을 실제 코드와 함께 살펴보고, 언제 무엇을 써야 하는지 판단 기준을 제시한다.

전략 1: Fixed-size Chunking (가장 단순)

가장 기본적인 방법이다. 일정 토큰/문자 수 단위로 자르되, overlap을 줘서 경계에서 잘리는 문제를 완화한다.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,        # 토큰 수 기준 (대략 문자 수의 75%)
    chunk_overlap=50,      # 겹치는 토큰 수
    separators=["\n\n", "\n", ".", " ", ""]
    # 위에서부터 우선순위: 단락 > 줄 > 문장 > 단어 > 문자
)

chunks = splitter.split_text(document)
print(f"총 {len(chunks)}개 청크 생성됨")
print(f"첫 번째 청크: {chunks[0][:100]}...")

RecursiveCharacterTextSplitterseparators 리스트를 순서대로 시도한다. 먼저 \n\n(단락)으로 자르려 하고, 청크가 너무 크면 \n으로, 그래도 크면 .으로 자른다.

장점: 구현이 단순하고 빠르다. 청크 크기가 예측 가능하다.

단점: 의미 단위를 고려하지 않는다. "이 제품의 가격은 10만원이며" 에서 잘릴 수 있다. overlap이 있어도 완전한 해결책은 아니다.

언제 쓰는가: 빠른 프로토타입, 문서 구조가 일정한 경우, 비용이 중요할 때.

전략 2: Semantic Chunking (의미 기반)

임베딩 유사도를 이용해 의미가 변하는 지점에서 자르는 방법이다. 문장 간 임베딩 거리가 급증하는 지점을 경계로 삼는다.

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings

splitter = SemanticChunker(
    OpenAIEmbeddings(),
    breakpoint_threshold_type="percentile",  # 또는 "standard_deviation", "interquartile"
    breakpoint_threshold_amount=95           # 상위 5% 거리 변화를 경계로 사용
)

# 내부 동작:
# 1. 각 문장을 임베딩
# 2. 인접 문장 간 코사인 거리 계산
# 3. 거리가 임계값 이상인 지점에서 분할

chunks = splitter.create_documents([long_document])

장점: 의미 단위로 나눠져 각 청크의 내용이 일관되다. 검색 정확도가 눈에 띄게 향상된다.

단점: 임베딩 API 호출 비용이 든다. 속도가 느리다. 짧은 문서에는 과도할 수 있다.

언제 쓰는가: 주제가 다양하게 섞인 긴 문서, 의미 경계가 불명확한 문서, 검색 정확도가 중요한 프로덕션 시스템.

실제 성능 차이

경험상 fixed-size vs. semantic chunking을 비교하면, 동일한 문서 코퍼스에서 semantic chunking이 retrieval precision을 약 15-20% 높인다. 특히 "A인데 B는 아닌" 같은 미묘한 질의에서 차이가 크다.

전략 3: Document-Structure-Aware Chunking

문서의 구조(헤딩, 목록, 코드 블록 등)를 보존하면서 자르는 방법이다.

from langchain.text_splitter import MarkdownHeaderTextSplitter

# Markdown 헤딩 기반 분할
headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]

md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False  # 헤딩을 청크 내에 유지
)

md_header_splits = md_splitter.split_text(markdown_document)

# 각 청크에 메타데이터로 헤딩 정보가 포함됨
# md_header_splits[0].metadata = {'h1': '제품 소개', 'h2': '주요 기능'}
# 이 메타데이터를 필터링에 활용 가능

# 헤딩으로 나눈 후 크기가 너무 크면 추가로 분할
from langchain.text_splitter import RecursiveCharacterTextSplitter

secondary_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512, chunk_overlap=50
)

final_chunks = secondary_splitter.split_documents(md_header_splits)

HTML 문서의 경우:

from langchain.document_loaders import BSHTMLLoader
from bs4 import BeautifulSoup

# HTML 태그 구조를 보존하면서 파싱
# article, section, div.content 등의 의미 있는 태그 단위로 분할

핵심 인사이트: 문서 구조 정보를 메타데이터로 보존해두면, 나중에 "제품 소개 섹션에서만 검색"하는 필터링이 가능해진다. 이게 단순 fixed-size 청킹과의 결정적 차이다.

언제 쓰는가: Markdown 문서, 기술 문서, 잘 구조화된 HTML 페이지, 섹션 기반 필터링이 필요한 경우.

전략 4: Hierarchical (Parent-Child) Chunking

이 전략이 내가 가장 좋아하는 방법이다. 정확한 검색과 충분한 컨텍스트, 두 마리 토끼를 잡는 접근이다.

핵심 아이디어: 작은 청크로 검색하고, 그 청크의 부모(더 큰 청크)를 LLM에게 전달한다.

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 부모 청크: 큰 컨텍스트 (LLM에게 전달)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)

# 자식 청크: 작고 정확한 단위 (임베딩 및 검색에 사용)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)

# 저장소: 부모 문서 보관
docstore = InMemoryStore()  # 프로덕션에서는 Redis나 DB 사용

# 벡터 스토어: 자식 청크의 임베딩 저장
vectorstore = FAISS.from_documents([], embedding_model)

retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=docstore,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 문서 추가 시 자동으로 부모-자식 계층 생성
retriever.add_documents(documents)

# 검색 시:
# 1. 쿼리와 유사한 자식 청크 찾기 (작고 정확한 검색)
# 2. 해당 자식의 부모 ID 조회
# 3. 부모 청크 반환 (풍부한 컨텍스트)
results = retriever.get_relevant_documents("검색 쿼리")

왜 이게 효과적인가:

검색 단계에서는 200토큰짜리 작은 청크를 사용하므로 임베딩이 집중되어 있다. "A 제품의 출시 날짜는?"이라는 질문에 정확히 그 정보가 들어있는 작은 청크를 찾는다.

생성 단계에서는 그 청크의 부모인 2000토큰 청크를 LLM에게 넘긴다. LLM은 답변 주변의 맥락을 충분히 가지고 답한다.

실전 팁: InMemoryStore는 프로토타입에만 쓰자. 프로덕션에서는 Redis나 PostgreSQL을 docstore로 사용해야 서버 재시작 시에도 데이터가 유지된다.

전략 5: RAPTOR (재귀적 추상 처리)

RAPTOR는 "Recursive Abstractive Processing for Tree-Organized Retrieval"의 약자다. 2024년 Stanford에서 발표한 방법으로, 문서의 트리 계층 구조를 자동으로 만들어낸다.

핵심 아이디어: 비슷한 청크들을 클러스터링하고, 각 클러스터를 요약해서 상위 노드로 만든다. 이것을 재귀적으로 반복한다.

원본 청크들 (Leaf Nodes):
[제품A 기능1] [제품A 기능2] [제품B 기능1] [제품B 비교]

클러스터링 + 요약 (Level 1):
[제품A 개요: 기능1, 기능2 요약] [제품B 개요: 기능1, 비교 요약]

더 높은 수준 요약 (Level 2):
[전체 제품군 비교 요약]
# RAPTOR 구현 (개념 코드)
from sklearn.mixture import GaussianMixture
import numpy as np

def build_raptor_tree(chunks, embeddings, levels=3):
    """
    RAPTOR 트리를 재귀적으로 구성.
    각 레벨에서 클러스터링 후 요약 생성.
    """
    tree = {'level_0': chunks}

    current_chunks = chunks
    current_embeddings = embeddings

    for level in range(1, levels + 1):
        # 1. GMM으로 클러스터링 (BIC로 최적 k 선택)
        n_clusters = max(1, len(current_chunks) // 5)
        gm = GaussianMixture(n_components=n_clusters, random_state=42)
        gm.fit(current_embeddings)
        labels = gm.predict(current_embeddings)

        # 2. 각 클러스터 내 청크들을 합쳐서 요약
        summaries = []
        for cluster_id in range(n_clusters):
            cluster_chunks = [
                current_chunks[i]
                for i in range(len(current_chunks))
                if labels[i] == cluster_id
            ]
            combined_text = "\n\n".join(cluster_chunks)
            summary = llm.summarize(combined_text)  # LLM으로 요약
            summaries.append(summary)

        tree[f'level_{level}'] = summaries

        # 다음 레벨 입력으로 현재 레벨 요약 사용
        current_chunks = summaries
        current_embeddings = embed(summaries)

    return tree

장점:

  • 구체적인 질문("제품A의 가격은?")과 추상적인 질문("어떤 제품군이 있나요?") 모두 잘 처리한다
  • 계층적 검색이 가능해서 쿼리 수준에 맞는 계층에서 검색할 수 있다

단점:

  • 구축 시간이 길다 (클러스터링 + LLM 요약 반복)
  • 비용이 높다 (요약을 위한 LLM 호출이 많음)
  • 구현 복잡도가 높다

언제 쓰는가: 매우 큰 문서 코퍼스, 다양한 추상화 수준의 질문이 예상되는 경우, 비용보다 품질이 중요한 엔터프라이즈 프로덕션.

청크 크기 선택 가이드

청크 크기적합한 상황주요 위험
128-256 토큰정확한 사실 질의, 단답형컨텍스트 부족으로 LLM이 오답 생성
512-1024 토큰일반적인 QA, 설명형 질의노이즈 증가, 관련성 희석
2048+ 토큰긴 추론 필요, 분석형 질의임베딩 품질 저하, 핵심 정보 희석

경험칙: 대부분의 경우 512토큰이 가장 무난하다. 특수한 이유가 없으면 여기서 시작하자.

실전 청킹 평가 방법

청킹 전략을 바꿀 때마다 "느낌"으로 판단하지 말고 정량적으로 측정하자.

from ragas.metrics import context_precision, context_recall
from ragas import evaluate
from datasets import Dataset

# 평가 데이터셋: 질문 + 정답 쌍
eval_questions = [
    "제품의 반품 기간은?",
    "배송비는 얼마인가?",
    # ...
]
ground_truths = [
    "30일 이내 반품 가능",
    "5만원 이상 무료 배송",
    # ...
]

# 각 청킹 전략으로 RAG 실행 후 결과 수집
def evaluate_chunking_strategy(strategy_name, retriever):
    results = []
    for q, gt in zip(eval_questions, ground_truths):
        retrieved_docs = retriever.get_relevant_documents(q)
        contexts = [doc.page_content for doc in retrieved_docs]
        results.append({
            "question": q,
            "contexts": contexts,
            "ground_truth": gt
        })

    dataset = Dataset.from_list(results)
    scores = evaluate(dataset, metrics=[context_precision, context_recall])

    print(f"\n=== {strategy_name} ===")
    print(f"Context Precision: {scores['context_precision']:.3f}")
    print(f"Context Recall:    {scores['context_recall']:.3f}")
    return scores

# 각 전략 비교
evaluate_chunking_strategy("Fixed-size 512", fixed_retriever)
evaluate_chunking_strategy("Semantic", semantic_retriever)
evaluate_chunking_strategy("Parent-Child", parent_child_retriever)

전략 선택 요약

상황권장 전략
빠른 프로토타입Fixed-size (512 토큰)
구조화된 Markdown/HTML 문서Document-Structure-Aware
주제가 다양한 긴 문서Semantic Chunking
정확한 검색 + 풍부한 컨텍스트 필요Parent-Child
대규모 코퍼스, 다양한 질문 유형RAPTOR
예산이 많고 최고 품질 필요RAPTOR + Parent-Child 혼합

마지막으로

청킹은 "설정하고 잊어버리는" 게 아니다. 서비스가 성장하고 문서 유형이 다양해지면 청킹 전략도 같이 진화해야 한다. 정기적으로 평가 데이터셋을 만들고, 스코어를 측정하고, 개선하는 사이클을 만들자.

다음 포스트에서는 청킹 이후의 핵심인 Hybrid Search (BM25 + 벡터 검색)를 다룬다. 아무리 좋은 청킹을 해도 검색이 나쁘면 말짱 도루묵이다.