RAG 청킹 (Chunking, 문서 분할)
2026.10.03 · 20분
1. 청킹이란?
청킹(Chunking)은 RAG의 인덱싱 단계에서 긴 문서를 검색 단위가 되는 작은 조각(청크, chunk)으로 자르는 일입니다. 검색은 문서가 아닌 청크 단위로 일어나고, LLM에 전달되는 것도 청크입니다.
원본 문서 (40페이지 PDF)
┌──────────────────────────────────────────┐
│ 1. 개요 ... │
│ 2. 출장 규정 ... │
│ 2-1. 교통비 ... │
│ 2-2. 숙박비 ... │
│ 3. 경비 정산 ... │
└──────────────────────────────────────────┘
│ 청킹
▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│청크 1│ │청크 2│ │청크 3│ │청크 4│ │ ... │ ← 각각 임베딩되어 저장
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘도서관에 비유하면, 책 한 권을 통째로 꽂아 두면 "숙박비 상한"을 찾기 위해 책 전체를 뒤져야 합니다. 그렇다고 문장 하나하나를 카드로 만들면 "단, 서울은 예외"라는 카드만 보고는 무슨 이야기인지 알 수 없습니다. 청킹은 "찾기 좋은 크기"와 "읽고 이해할 수 있는 크기" 사이에서 균형을 잡는 일입니다.
2. 왜 중요할까?
2-1. 청크가 검색과 생성 양쪽의 단위다
| 단계 | 청크의 역할 | 청크가 나쁘면 |
|---|---|---|
| 임베딩 | 청크 하나 = 벡터 하나 | 여러 주제가 섞인 청크는 벡터가 "평균"이 되어 어느 질문과도 애매하게 가깝다 |
| 검색 | 청크 단위로 순위를 매긴다 | 정답 문장이 있어도 주변 잡음 때문에 순위가 밀린다 |
| 생성 | 검색된 청크만 LLM이 본다 | 청크에 앞뒤 문맥이 없으면 LLM이 잘못 해석한다 |
2-2. 같은 모델, 다른 청킹
질문: "서울 출장 숙박비 상한은?"
원문: "숙박비 상한은 1박 15만원이다. 단, 서울·제주 지역은 예외로 1박 20만원까지 인정한다."
[잘린 청킹]
청크 A: "...숙박비 상한은 1박 15만원이다. 단, 서울·제주 지역은"
청크 B: "예외로 1박 20만원까지 인정한다. 출장 중 식비는..."
→ 청크 A가 검색됨 → "15만원입니다" (오답)
[문단 단위 청킹]
청크: "숙박비 상한은 1박 15만원이다. 단, 서울·제주 지역은 예외로 1박 20만원까지 인정한다."
→ "서울은 예외로 20만원입니다" (정답)임베딩 모델, 벡터 DB, LLM은 그대로 두고 청킹만 바꿔도 정확도가 크게 달라지는 경우가 흔합니다. 그래서 RAG를 개선할 때 청킹부터 손대는 경우가 많습니다.
3. 청크 크기와 overlap
3-1. 크기의 트레이드오프
| 작은 청크 (예: 100~200 토큰) | 큰 청크 (예: 1000 토큰 이상) | |
|---|---|---|
| 검색 정밀도 | 높다. 벡터가 한 가지 의미를 선명하게 담는다 | 낮다. 여러 주제가 섞여 벡터가 흐려진다 |
| 문맥 | 부족하다. "이것", "위 조항" 같은 지시어가 끊긴다 | 충분하다 |
| LLM 입력 | 적은 토큰, 여러 개를 넣어야 한다 | 많은 토큰, 몇 개만 넣어도 꽉 찬다 |
| 잘 맞는 질문 | 사실 하나를 묻는 질문 ("상한이 얼마?") | 설명·요약 질문 ("절차를 설명해 줘") |
출발점으로는 흔히 수백 토큰(예: 256~512)을 쓰고, 평가 세트로 측정하며 조정합니다. 알맞은 크기는 문서와 질문 유형에 따라 다르므로 측정해서 정해야 합니다.
3-2. 임베딩 모델의 입력 한도
임베딩 모델마다 최대 입력 토큰 수가 정해져 있습니다. 이를 넘는 텍스트는 보통 뒷부분이 잘린(truncate) 채 임베딩됩니다. 그러면 청크 뒷부분이 검색에 전혀 반영되지 않는 조용한 버그가 생깁니다. 청크 크기는 사용하는 임베딩 모델의 한도 안으로 잡아야 합니다.
3-3. Overlap
Overlap은 인접한 청크가 앞뒤로 일부 내용을 겹치게 하는 것입니다.
overlap 없음:
[────── 청크 1 ──────][────── 청크 2 ──────][────── 청크 3 ──────]
↑ 여기서 문장이 끊기면 양쪽 모두 불완전
overlap 있음 (예: 10~20%):
[────── 청크 1 ──────]
[────── 청크 2 ──────]
[────── 청크 3 ──────]
└겹침┘ └겹침┘4. 청킹 방법
4-1. 한눈에 보기
| 방법 | 자르는 기준 | 장점 | 단점 |
|---|---|---|---|
| 고정 길이 | 문자/토큰 수 | 구현이 가장 쉽고 예측 가능 | 문장, 단어 중간에서 잘린다 |
| 재귀 문자 분할 | 문단 → 줄 → 문장 → 단어 순으로 시도 | 자연스러운 경계를 최대한 지킨다 | 문서의 의미 구조는 모른다 |
| 문서 구조 기반 | 마크다운 헤더, HTML 태그, 조항 번호 | 섹션 단위로 의미가 온전하다 | 섹션 길이가 들쭉날쭉하다 |
| 시맨틱 청킹 | 문장 간 임베딩 유사도가 떨어지는 지점 | 주제 전환에서 자른다 | 임베딩 비용, 임계값 튜닝 필요 |
4-2. 고정 길이 (Fixed-size)
정해진 길이마다 기계적으로 자릅니다. 문자 수보다 토큰 수 기준이 임베딩 모델 한도와 맞추기 쉽습니다.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
def fixed_chunks(text: str, size: int = 400, overlap: int = 50) -> list[str]:
tokens = enc.encode(text)
step = size - overlap
return [enc.decode(tokens[i:i + size]) for i in range(0, len(tokens), step)]5. 검색 단위와 전달 단위 분리하기
작은 청크는 검색이 정확하고, 큰 청크는 문맥이 풍부합니다. 둘 다 얻으려면 검색할 때와 LLM에 줄 때 다른 크기를 쓰면 됩니다.
5-1. Parent-Child (Small-to-Big)
부모 청크 (섹션, ~1500 토큰) ← LLM에 전달
┌─────────────────────────────────────────────┐
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 자식 1 │ │ 자식 2 │ │ 자식 3 │ ← 임베딩·검색 (~200 토큰)
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────┘
1. 자식 청크로 검색한다 (정밀)
2. 매칭된 자식의 부모 ID를 찾는다
3. 부모 청크를 LLM에 넘긴다 (문맥 풍부)parents = {} # parent_id -> 부모 텍스트
children = [] # {"text": ..., "parent_id": ...}
for pid, section in enumerate(split_markdown(md)):
parents[pid] = section["text"]
for child in recursive_split(section["text"], size=300):
children.append({"text": child, "parent_id": pid})
child_vecs = model.encode([c["text"] for c in children], normalize_embeddings=True)
def retrieve_parents(question: str, k: int = 5) -> list[str]:
q = model.encode([question], normalize_embeddings=True)[0]
top = np.argsort(-(child_vecs @ q))[:k]
seen, result = set(), []
for i in top: # 같은 부모는 한 번만
pid = children[i]["parent_id"]
if pid not in seen:
seen.add(pid)
result.append(parents[pid])
return result5-2. 변형들
| 기법 | 검색 단위 | 전달 단위 |
|---|---|---|
| Parent-Child | 작은 자식 청크 | 부모 섹션 전체 |
| Sentence Window | 문장 1개 | 앞뒤 N문장을 붙인 창 |
| 요약 인덱스 | 문서·섹션 요약문 |
6. 문맥 보강 (Contextual Retrieval)
6-1. 문제: 청크가 자기 문맥을 잃는다
원문: "ACME 주식회사 2024년 2분기 실적 보고서"
...
"매출은 전 분기 대비 3% 증가했다." ← 이 문장만 청크가 됨
질문: "ACME의 2024년 2분기 매출 성장률은?"
청크: "매출은 전 분기 대비 3% 증가했다."
→ 어느 회사? 어느 분기? 청크에 정보가 없어서 검색이 안 된다6-2. 해결: 청크마다 문서 맥락을 붙인다
Anthropic이 2024년 9월 공개한 글 Introducing Contextual Retrieval에서 제안한 방법입니다. 인덱싱할 때 LLM에게 문서 전체와 청크를 주고 이 청크가 문서에서 어떤 위치·맥락인지 짧게 설명하게 한 뒤, 그 설명을 청크 앞에 붙여서 임베딩과 BM25 색인을 모두 만듭니다.
[원래 청크]
"매출은 전 분기 대비 3% 증가했다."
[문맥 보강된 청크]
"이 청크는 ACME 주식회사의 2024년 2분기 실적 보고서 중 매출 실적 부분이다.
매출은 전 분기 대비 3% 증가했다."import anthropic
client = anthropic.Anthropic()
def situate(document: str, chunk: str) -> str:
resp = client.messages.create(
model="claude-haiku-4-5",
max_tokens=150,
messages=[{
"role": "user",
"content": [
{ # 같은 문서의 청크들이 문서 부분을 공유하므로 프롬프트 캐싱
"type": "text",
"text": f"<document>\n{document}\n</document>",
"cache_control": {"type": "ephemeral"},
},
{
"type": "text",
"text": f"<chunk>\n{chunk}\n</chunk>\n"
"이 청크가 위 문서 전체에서 어떤 맥락인지, 검색에 도움이 되도록 "
"한두 문장으로 짧게 설명하세요. 설명만 답하세요.",
},
],
}],
)
return resp.content[0].text.strip()
contextual_chunks = [f"{situate(doc, c)}\n{c}" for c in chunks]
# 이후 contextual_chunks로 임베딩과 BM25 색인을 만든다6-3. 효과와 비용
Anthropic의 글에서는 자체 실험에서 검색 실패율(top-20 기준)이 줄었다고 보고했습니다.
| 구성 | 보고된 검색 실패율 감소 |
|---|---|
| 문맥 보강 임베딩 | 약 35% |
| 문맥 보강 임베딩 + 문맥 보강 BM25 |
7. 표와 코드 처리
7-1. 표
표를 단순 텍스트로 추출하면 행과 열의 관계가 사라집니다.
[원본 표]
| 직급 | 숙박비 상한 | 식비 상한 |
|------|-------------|-----------|
| L3 | 12만원 | 3만원 |
| L5 | 18만원 | 5만원 |
[텍스트 추출 결과]
"직급 숙박비 상한 식비 상한 L3 12만원 3만원 L5 18만원 5만원"
→ "L5 식비 상한은?" 에 답하기 어렵다| 방법 | 설명 | 적합한 경우 |
|---|---|---|
| 마크다운/HTML 표로 보존 | 표 구조를 그대로 유지해 하나의 청크로 둔다 | 작은 표 |
| 행 단위 직렬화 | 각 행을 "직급 L5의 숙박비 상한은 18만원, 식비 상한은 5만원이다"처럼 문장으로 | 행마다 독립적인 사실인 표 |
| 헤더 반복 | 큰 표를 나눌 때 각 청크에 열 머리글을 반복해 넣는다 | 행이 많은 표 |
| 표 요약 + 원본 | 요약문으로 검색하고, 원본 표를 LLM에 전달 | 복잡한 표 (5장의 분리 원리) |
| 정형 데이터로 분리 | 표를 DB에 넣고 SQL로 질의 | 숫자 집계가 필요한 표 |
최소한 표가 헤더 없이 중간에서 잘리지는 않게 해야 합니다. PDF에서는 표를 제대로 추출하는 것부터 어려우므로 파서 품질을 먼저 확인해야 합니다.
7-2. 코드
| 원칙 |
|---|
8. 청킹 전략 고르기
문서에 구조(헤더, 조항, 섹션)가 있는가?
├─ 예 → 구조 기반 분할 + 너무 긴 섹션은 재귀 분할
│ + 헤더 경로를 청크 앞에 붙인다
└─ 아니오 → 재귀 문자 분할로 시작
└─ 주제가 자주 바뀌는 긴 글이면 시맨틱 청킹을 비교해 본다
검색은 되는데 답에 문맥이 부족한가? → Parent-Child / Sentence Window
청크만 보고는 무슨 이야기인지 모르는가? → 문맥 보강 (메타데이터 → LLM 맥락)
표·코드가 많은가? → 7장의 전용 처리| 상황 | 추천 출발점 |
|---|---|
| 사내 위키, 마크다운 문서 | 헤더 기반 + 헤더 경로 붙이기 |
| 법률, 약관, 규정 | 조항 단위 |
| PDF 보고서 | 레이아웃 인식 파서 → 섹션 기반 + 표 별도 처리 |
| 회의록, 채팅 로그 | 재귀 분할 또는 시맨틱, 화자·날짜 메타데이터 |
| 소스 코드 | 함수·클래스 단위(AST) |
| FAQ | 질문-답변 쌍 하나가 하나의 청크 |
9. 장점과 단점, 흔한 실수
9-1. 방법별 비용과 효과
| 방법 | 구현 난이도 | 인덱싱 비용 | 기대 효과 |
|---|---|---|---|
| 고정 길이 | 매우 낮음 | 낮음 | 기준선 |
| 재귀 분할 | 낮음 | 낮음 | 경계 문제 완화 |
| 구조 기반 | 중간 (파서 필요) | 낮음 | 구조가 있는 문서에서 큰 개선 |
| 시맨틱 | 중간 | 중간 (문장 임베딩) | 데이터에 따라 다름 |
| Parent-Child | 중간 | 중간 (저장 2배) | 정밀도와 문맥 동시 확보 |
| 문맥 보강 (LLM) | 중간 | 높음 (청크당 LLM 호출) | 문맥 손실이 큰 문서에서 큰 개선 |
10. 핵심 정리
청킹은 문서를 검색 단위로 자르는 일이며, 청크는 임베딩·검색·생성 모두의 단위라서 RAG 품질에 직접 영향을 준다. 작은 청크는 검색이 정밀하지만 문맥이 부족하고, 큰 청크는 문맥은 풍부하지만 벡터가 흐려진다. 고정 길이 → 재귀 문자 분할 → 문서 구조 기반 → 시맨틱 순으로 정교해지지만, 구조가 있는 문서라면 헤더·조항 기반 분할이 가장 싸고 효과적인 경우가 많다. Parent-Child처럼 검색 단위와 전달 단위를 분리하면 정밀도와 문맥을 함께 얻을 수 있고, Contextual Retrieval처럼 청크에 문서 맥락을 붙이면 "이 청크가 무엇에 대한 이야기인지" 모르는 문제를 줄일 수 있다. 표와 코드는 구조를 보존하는 전용 처리가 필요하며, 어떤 전략이든 평가 세트로 측정해서 골라야 한다.