[LLMOps] Day 2: 문서 처리와 청킹 전략 - 검색 품질을 결정하는 첫 단계
이 글은 AI(Claude)의 도움을 받아 작성하고, 작성자가 검토·편집했습니다.
서론: “Garbage In, Garbage Out”
RAG 시스템에서 가장 간과되기 쉬운 단계가 청킹(Chunking)이다. 많은 개발자가 LLM 선택이나 벡터 DB 성능에만 집중하지만, 실제로는 문서를 어떻게 분할하느냐가 검색 품질의 70%를 결정한다.
잘못된 청킹의 결과는 치명적이다.
- 너무 작은 청크: 문맥이 부족해서 검색 정확도가 떨어진다.
- 너무 큰 청크: LLM 컨텍스트 창을 낭비하고, 관련 없는 정보가 섞인다.
- 의미 단절: 문장 중간에서 잘려서 검색 시 매칭이 안 된다.
이번 글에서는 엔지니어링 관점에서 실전에서 검증된 청킹 전략을 다룬다.
1. 청킹의 핵심 파라미터
1.1 Chunk Size
임베딩 모델과 LLM의 특성에 따라 최적값이 다르다.
| 모델 | 권장 청크 크기 | 이유 |
|---|---|---|
| OpenAI ada-002 | 400~600 토큰 | 8191 토큰 컨텍스트, 중간 크기가 정확도 최고 |
| Cohere embed-v3 | 256~512 토큰 | 긴 문서는 자동 압축되어 정보 손실 |
| OpenAI text-embedding-3 | 512~1024 토큰 | 8191 토큰 컨텍스트, 긴 청크 처리 개선 |
| BAAI/bge-large | 256~512 토큰 | 512 토큰 최대 입력 |
경험칙: 임베딩 모델의 최대 토큰 길이의 50~70%를 사용한다.
1.2 Chunk Overlap
청크 간 중복은 문맥 손실을 방지한다.
1
2
3
4
5
6
7
8
9
# 예시: 중복이 없을 때
chunk_1 = "...Rust는 메모리 안전성을 보장한다."
chunk_2 = "PyO3를 사용하면 Python 확장을 만들 수 있다."
# "Rust로 Python 확장을 만들 수 있나요?" 검색 시 매칭 실패
# 중복이 있을 때
chunk_1 = "...Rust는 메모리 안전성을 보장한다. PyO3를 사용하면"
chunk_2 = "PyO3를 사용하면 Python 확장을 만들 수 있다."
# 두 청크 모두 검색됨
권장값: 청크 크기의 10~20% (50~100 토큰)
2. 청킹 전략의 종류
2.1 Fixed Size Chunking (고정 크기)
가장 단순하지만, 의외로 많은 경우에 충분하다.
1
2
3
4
5
6
7
8
9
10
from langchain_text_splitters import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separator="\n",
length_function=len
)
chunks = splitter.split_text(document)
장점: 빠르고(1M 문자 처리에 2초), 예측 가능한 청크 개수 단점: 문장 중간에서 잘릴 수 있음 적합한 경우: 로그 파일, 정형화된 문서, 채팅 기록
2.2 Recursive Character Splitting (재귀적 분할)
실전에서 가장 많이 사용되는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=[
"\n\n", # 문단 우선
"\n", # 줄바꿈
". ", # 문장
" ", # 단어
"" # 문자 (최후의 수단)
]
)
chunks = splitter.split_text(document)
핵심 아이디어: 큰 구분자부터 시도하고, 안 되면 작은 구분자로 fallback한다.
성능 벤치마크:
1
2
3
4
5
6
7
8
9
10
11
12
13
import time
# 1MB 문서 처리 시간
text = load_large_document() # 1,000,000 characters
start = time.time()
chunks = splitter.split_text(text)
print(f"처리 시간: {time.time() - start:.2f}초")
print(f"청크 개수: {len(chunks)}")
# 출력:
# 처리 시간: 1.23초
# 청크 개수: 2,150
2.3 Semantic Chunking (의미 기반)
문장 간 유사도를 계산하여 의미적으로 응집력 있는 단위로 분할한다.
1
2
3
4
5
6
7
8
9
10
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
splitter = SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="percentile", # 상위 N% 유사도에서 분할
breakpoint_threshold_amount=90
)
chunks = splitter.split_text(document)
작동 원리:
- 문장 단위로 임베딩 생성
- 인접 문장 간 코사인 유사도 계산
- 유사도가 급격히 떨어지는 지점에서 분할
성능 비교:
1
2
Recursive Splitting: 1.23초 (1MB 문서)
Semantic Chunking: 14.7초 (1MB 문서)
비용 비교:
- OpenAI Embedding API: $0.0001 / 1K 토큰
- 1MB 문서 ≈ 250K 토큰
- Semantic Chunking 비용: $0.025 per document
Trade-off: 정확도는 5~10% 향상되지만, 속도는 10배 느리고 비용도 발생한다. 적합한 경우: 법률 문서, 연구 논문 등 문맥이 매우 중요한 경우
3. 실전 청킹 파이프라인
3.1 문서 타입별 전처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import re
from typing import List
class DocumentProcessor:
def __init__(self):
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50
)
def process_markdown(self, text: str) -> List[str]:
# 코드 블록 보존
code_blocks = re.findall(r'```.*?```', text, re.DOTALL)
for i, block in enumerate(code_blocks):
text = text.replace(block, f"<<CODE_BLOCK_{i}>>")
# 청킹
chunks = self.splitter.split_text(text)
# 코드 블록 복원
for i, block in enumerate(code_blocks):
chunks = [c.replace(f"<<CODE_BLOCK_{i}>>", block) for c in chunks]
return chunks
def process_pdf(self, text: str) -> List[str]:
# PDF의 불필요한 줄바꿈 제거
text = re.sub(r'(?<!\n)\n(?!\n)', ' ', text)
# 페이지 번호 제거
text = re.sub(r'\n\d+\n', '\n', text)
return self.splitter.split_text(text)
def process_code(self, code: str, language: str) -> List[str]:
# 코드는 함수/클래스 단위로 분할
if language == "python":
# 함수와 클래스 정의 기준으로 분할
chunks = re.split(r'\n(?=def |class )', code)
else:
# 기본 전략 사용
chunks = self.splitter.split_text(code)
return [c for c in chunks if len(c.strip()) > 0]
3.2 메타데이터 추가
청크만 저장하지 말고, 검색 품질 향상을 위한 메타데이터를 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from datetime import datetime
def create_chunk_with_metadata(chunk: str, doc_metadata: dict) -> dict:
return {
"content": chunk,
"source": doc_metadata.get("source"),
"created_at": datetime.now().isoformat(),
"chunk_index": doc_metadata.get("chunk_index"),
"total_chunks": doc_metadata.get("total_chunks"),
# 검색 필터링에 사용
"category": doc_metadata.get("category"),
"language": doc_metadata.get("language"),
# 디버깅용
"char_count": len(chunk),
"word_count": len(chunk.split())
}
4. 청킹 품질 측정
4.1 정량적 메트릭
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import numpy as np
def evaluate_chunking(chunks: List[str]):
sizes = [len(c) for c in chunks]
return {
"total_chunks": len(chunks),
"avg_size": np.mean(sizes),
"std_size": np.std(sizes),
"min_size": min(sizes),
"max_size": max(sizes),
# 균일성 점수 (0~1, 1이 이상적)
"uniformity": 1 - (np.std(sizes) / np.mean(sizes))
}
# 사용 예시
stats = evaluate_chunking(chunks)
print(f"균일성 점수: {stats['uniformity']:.2f}")
# 0.9 이상이면 양호, 0.7 이하면 재검토 필요
4.2 정성적 평가
1
2
3
4
5
6
7
8
9
def sample_chunks(chunks: List[str], n: int = 5):
"""무작위로 N개 청크를 추출하여 육안 검사"""
import random
samples = random.sample(chunks, min(n, len(chunks)))
for i, chunk in enumerate(samples):
print(f"\n--- Sample {i+1} ---")
print(chunk[:200] + "...")
print(f"Length: {len(chunk)} chars")
5. 언제 어떤 전략을 사용할 것인가?
| 문서 타입 | 권장 전략 | 청크 크기 | 이유 |
|---|---|---|---|
| 기술 문서, 블로그 | Recursive | 512 토큰 | 문단 구조 보존 필요 |
| 채팅 로그, 로그 파일 | Fixed Size | 300 토큰 | 속도 우선, 구조 단순 |
| 법률, 연구 논문 | Semantic | 700 토큰 | 정확도 최우선 |
| 코드 파일 | Custom (함수 단위) | 가변 | 논리적 단위 보존 |
| FAQ, Q&A | 문서 단위 (분할 안 함) | - | 질문-답변 쌍 보존 |
다음 단계
청킹이 완료되면, 다음 단계는 임베딩 생성과 벡터 DB 선택이다. Day 3에서는 임베딩 모델 벤치마크와 프로덕션 벡터 DB 비교를 다룰 것이다.