- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 왜 청킹이 RAG 품질의 70%를 결정하는가
- 전략 1: Fixed-size Chunking (가장 단순)
- 전략 2: Semantic Chunking (의미 기반)
- 전략 3: Document-Structure-Aware Chunking
- 전략 4: Hierarchical (Parent-Child) Chunking
- 전략 5: RAPTOR (재귀적 추상 처리)
- 청크 크기 선택 가이드
- 실전 청킹 평가 방법
- 전략 선택 요약
- 마지막으로
왜 청킹이 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]}...")
RecursiveCharacterTextSplitter는 separators 리스트를 순서대로 시도한다. 먼저 \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 + 벡터 검색)를 다룬다. 아무리 좋은 청킹을 해도 검색이 나쁘면 말짱 도루묵이다.