
지난 글에이어서 이제 본격적으로 RAG를 구성하는 요소를 하나하나 뜯어볼 차례다.
첫 번째는 바로 Chunking이다.
RAG 파이프라인에서 가장 앞단에 있고, 개인적으로는
“Garbage In, Garbage Out을 가장 먼저 결정짓는 요소”
라고 생각한다.
아무리 모델이 좋아도, 아무리 검색 성능이 좋다고 해도, 문서가 이상하게 잘려 있으면 답은 없다.
이번 글에서는 가장 단순한 chunking 기법부터 비교적 최신(?)에 공개된 기법까지 다양하게 비교하고 분석해 보고자 한다.
Chunking, 왜 중요할까?
RAG 파이프라인에서 Chunking을 비유하자면 요리 재료 손질에 가깝다.
- 감잣국을 끓이는데 감자를 통째로 넣으면→ 속도 안 익고, 먹기도 불편하다 (Too Large Chunk)
- 반대로 감자를 믹서기에 갈아서 넣으면 → 감자인지 뭔지 정체를 잃는다 (Too Small Chunk)
RAG도 딱 이 느낌이다.
- 너무 작게 자르면
- 문맥이 사라진다
"그는 그것을 싫어했다"같은 문장만 남는다- LLM은 그가 누구고, 그것이 뭔지 모른다
- 너무 크게 자르면
- 노이즈가 늘어난다
- 검색 정확도가 떨어진다
- 쓸데없는 정보에 현혹될 가능성도 커진다
결국 해결해야 할 문제는 '어디까지를 하나의 의미 단위로 볼 것인가?'이다.
평가 방법
각각의 chunking 기법에 대한 평가를 위하여 LLM을 활용하는 방법인, "LLM-as-a-judge" 방식으로 평가를 진행했다.
검색된 결과를 Ground Truth와 비교하는 대신, "검색된 이 문맥(Context)만 보고 질문에 답할 수 있는가?"를 LLM이 판단하도록 했다.
평가 파이프라인
평가 모델은 가성비가 좋은 Gemini 2.5 Flash를 사용했다. 비용과 속도를 고려해 Gemini Batch API를 활용했다.
1. 평가 프롬프트 (Prompt Engineering)
가장 중요한 건 프롬프트다. LLM이 모호하게 답하지 않도록 RELEVANT와 IRRELEVANT 두 가지로만 분류하게 강제했다.
또한 평가 성능을 높이기 위해 평가 근거도 함께 출력하도록 했다.
# Role
전문 RAG 컨텍스트 적합성 평가자 (RAG Context Relevance Judge)
# Task
<question>과 <context>를 보고, **오직 context만으로 질문에 답할 수 있는지**를 분류하십시오.
# Evaluation Logic
1. **IRRELEVANT**: 핵심 정보가 없어 답변이 불가능한 경우.
2. **RELEVANT**: 외부 지식 없이 context만으로 정확하게 답변 가능한 경우.
# Output Format
{{
"brief_reason": "판단 근거 요약"
"category": "RELEVANT" | "IRRELEVANT",
}}
2. 평가 코드
google의 Gemini SDK를 이용해 검색된 청크들을 평가했다.
# 실제 사용한 평가 코드 (일부분)
def evaluate_context_relevance_batch(inputs):
"""
질문과 검색된 청크를 입력받아, 답변 가능 여부를 배치로 평가
"""
batch_requests = []
for item in inputs:
prompt = render_prompt("eval_prompt.txt", **item)
batch_requests.append({
'contents': [{'parts': [{'text': prompt}], 'role': 'user'}]
})
# Gemini Batch API로 대량 처리 요청
batch_job = client.batches.create(
model="models/gemini-2.5-flash",
src=batch_requests,
config={'display_name': "rag_eval_run"}
)
return batch_job.name
사용한 메트릭 (Metrics)
각각의 chunking 전략으로 문서를 나누고, 각 chunki에 대해 LLM이 생성한 RELEVANT(1) / IRRELEVANT(0) 판정 결과를 바탕으로 다음 두 가지 지표를 계산했다.
- MRR (Mean Reciprocal Rank)
- 정답 문서를 얼마나 높은 순위/점수로 검색하였는가?
- 첫 번째로 등장한 정답 청크가 1등이면 1.0, 2등이면 0.5, 3등이면 0.33... 식의 점수다.
- Precision@K (K=5)
- 상위 5개 문서 중 정답 문서가 몇 개나 포함되어 있는가?
- LLM에게 풍부한 문맥을 주기 위해서는 관련된 문서가 여러 개 잡히는 것이 유리하다.
실험 결과 요약
테스트는 18개의 문서와 질문 50개를 대상으로 진행되었다.
테스트 결과 가장 단순한 접근 중 하나인 Page Chunking이 검색 정확도(MRR) 1위를 차지했고, Contextual Retrieval은 역시나 Precision 면에서 압도적인 성능을 보였다.
| Chunking 전략 | MRR | Precision@K | 비고 |
|---|---|---|---|
| Page Chunking | 0.810 (1st) | 0.284 | PDF 페이지 단위 분리. |
| Recursive Chunking | 0.768 | 0.296 | |
| Contextual Retrieval | 0.787 (2nd) | 0.272 | 검색 인덱싱에 context 강화 |
| Fixed Size | 0.723 | 0.256 | |
| Small-to-Big | 0.756 | 0.272 | 검색(512) -> 활용(1024) |
| Semantic Chunking | 0.712 | 0.234 | Qwen Embedding 사용 |
(※ MRR이 높다는 건 "첫 번째로 검색된 문서가 정답일 확률"이 높다는 뜻이고, Precision@K가 높다는 건 "검색된 5개 문서 전반에 정답 관련 정보가 풍부하다"는 뜻으로 해석될 수 있다.)
chunking 기법 별 세부 분석
① Fixed Size Chunking
가장 단순한 방법이다. 문맥이나 문장 부호를 고려하지 않고, 정해진 토큰 수(또는 글자 수)대로 뚝뚝 자르는 방식이다.
구현 코드 일부
from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
chunk_size=512,
chunk_overlap=50
)
docs = text_splitter.split_documents(original_docs)
평가 결과
- 점수: MRR 0.723 / Precision@K 0.256
- 평가 분석: 예상대로 하위권의 점수를 얻었지만, 생각보다 점수가 낮지는 않았다. 그러나 어쨌든 검색된 chunk를 보면 문장이 "대한민국의 수도는 서..."에서 끝나고 다음 청크가 "울이다."로 시작되는 등 문맥 단절이 빈번했다. 검색된 청크만 보고 답변해야 하는 LLM 입장에서 이런 데이터는 노이즈로 취급될 것이다.
② Recursive Chunking
현재 RAG 시스템의 국밥(Baseline) 전략이다. 문단(\n\n) → 문장(\n) → 단어 순으로 내려가며 최대한 의미 단위를 유지하려고 시도한다.
구현 코드 일부
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", " ", ""]
)
docs = text_splitter.split_documents(original_docs)
평가 결과
- 점수: MRR 0.768 / Precision@K 0.296
- 평가 분석: 역시 안정적이다. MRR과 Precision 모두 높은 점수를 얻었다. 문장 단위가 보존되니 LLM이 내용을 이해하기 수월할 것이고, 검색 정확도도 준수했다. Precision의 경우 전체 chunking 방법 중 가장 높은 점수를 휙득하였다.
③ Semantic Chunking
임베딩 모델을 사용하여 문장 간의 의미적 유사도가 급격히 변하는 지점(주제가 바뀌는 지점)을 찾아 자르는 방식이다.
구현 코드 일부
from sentence_transformers import SentenceTransformer, util
# Qwen 임베딩 모델 로드
model = SentenceTransformer("Qwen/Qwen3-Embedding-0.6B", device='cuda')
def chunk_semantically(text, sim_threshold=0.7):
# 1. 문장 단위 분리
sentences = split_into_sentences(text)
embeddings = model.encode(sentences, convert_to_tensor=True)
chunks = []
current_chunk = [sentences[0]]
# 2. 인접 문장 간 유사도 계산 Loop
for i in range(1, len(sentences)):
sim = util.cos_sim(embeddings[i-1], embeddings[i]).item()
# 3. 유사도가 임계값보다 낮으면(주제 전환), 청크를 자름
if sim < sim_threshold:
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i]]
else:
current_chunk.append(sentences[i])
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
평가 결과
- 점수: MRR 0.712 / Precision@K 0.234
- 평가 분석: 이론상 높은 성능을 기대했으나, 실험 결과 상 최하위를 기록했다.
- 원인 1: 임베딩 모델로 Qwen을 사용했는데, 한국어 데이터셋의 미묘한 뉘앙스를 완벽히 잡아내지 못했을 가능성이 있다.
- 원인 2: 청크 크기가 들쭉날쭉하다. 어떤 건 50 토큰, 어떤 건 1000 토큰이 되다 보니 검색 스코어링에서 불리하게 작용했을 수 있다.
Semantic Chunking방식이 이론적으로 나쁘다고는 생각하지 않는다. 하지만 이전 문장과 현재 문장의 유사도를 구하려면 단순히 문장의 유사도를 비교하는 게 아니라 전체 글의 맥락을 바탕으로 판단해야 하지 않을까 싶다.🤔
④ Page Chunking
PDF의 페이지 하나를 그대로 하나의 청크로 사용하는 방식이다.
구현 코드 일부
import re
def page_chunk_text(text):
"""
=== Page N === 형태의 구분자를 기준으로 물리적 페이지 단위 분리
"""
# 1. 정규식으로 페이지 구분자 기준 Split
raw_chunks = re.split(r"===\s*Page\s*\d+\s*===", text)
# 2. 전처리 (앞뒤 개행 제거)
chunks = []
for chunk in raw_chunks:
cleaned = re.sub(r"^\n+|\n+$", "", chunk)
if cleaned:
chunks.append(cleaned)
return chunks
평가 결과
- 점수: MRR 0.810 (전체 1위) / Precision@K 0.284
- 평가 분석: MRR이 가장 높게 측정되었다.
- 실험 데이터가 PPT나 보고서 형태가 많았는데, 이런 문서는 보통 "한 페이지 = 하나의 주제"로 작성된다. 억지로 문장을 쪼개는 것보다, 작성자가 의도한 시각적/논리적 단위인 '페이지'를 통째로 가져오는 것이 훨씬 명확한 게 아니었나 한다.
⑤ Small-to-Big Chunking
검색은 작은 청크(512 토큰)로 수행하되, LLM에게는 그 청크가 포함된 더 큰 범위(1024 토큰)의 텍스트를 넘겨주는 방식이다.
구현 코드 일부
import uuid
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 1. Parent Splitter (LLM 참조용 큰 청크)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1024)
# 2. Child Splitter (벡터 검색용 작은 청크)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=512)
def process_small_to_big(text):
all_child_chunks = []
metadata = []
# Step 1: 문서를 먼저 큰 덩어리(Parent)로 분할
parent_chunks = parent_splitter.split_text(text)
for parent_text in parent_chunks:
parent_id = str(uuid.uuid4())
# Step 2: 각 Parent를 다시 작은 덩어리(Child)로 분할
child_chunks = child_splitter.split_text(parent_text)
for child_text in child_chunks:
all_child_chunks.append(child_text)
# 메타데이터에 '검색은 Child, 참조는 Parent'로 매핑
metadata.append({
"child_content": child_text, # 검색용
"parent_content": parent_text, # LLM Context용
"parent_id": parent_id
})
return all_child_chunks, metadata
평가 결과
- 점수: MRR 0.757 / Precision@K 0.272
- 평가 분석: 기대만큼의 성능이 나오지 않았다. Recrusive_chunking 방법보다 점수가 다소 낮은게 의아하긴한데, 불필요한 정보들이 chunk에 너무 많이 포함되어서 그런가 싶다.
⑥ Contextual Retrieval Chunking
Anthropic에서 제안한 방식이다. 각 청크마다 "이 문서는 2025년도 재무 보고서의 요약본입니다"와 같은 전체적인 맥락(Context)을 LLM으로 생성하여 텍스트 앞단에 붙여준다.
나는 문서를 Recursive 방식으로 자른 뒤, 각 청크가 전체 문서에서 어떤 맥락을 갖는지 Gemini 2.5-lite로 요약하여 청크 앞단에 붙여주었다.
구현 코드 일부
def generate_context(full_document: str, chunk: str) -> str:
"""Gemini를 사용해 해당 청크의 전역적 문맥(Global Context)을 생성"""
prompt = f"""
<document>{full_document}</document>
위 문서를 바탕으로, 아래 [chunk]가 어떤 맥락을 가지는지 1~2문장으로 설명해줘.
[chunk]{chunk}
"""
response = model.generate_content(prompt) # Gemini 2.5-Lite 사용
return response.text.strip()
def process_contextual_retrieval(documents):
text_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)
all_contextual_chunks = []
for doc in documents:
# 1. 일단 자른다
chunks = text_splitter.split_text(doc['text'])
for chunk in chunks:
# 2. 문맥을 생성한다
context = generate_context(doc['text'], chunk)
# 3. [문맥 + 원본]을 합친다 -> 이 텍스트가 임베딩됨
combined = f"[Context: {context}]\n\n[Content: {chunk}]"
all_contextual_chunks.append(combined)
return all_contextual_chunks
평가 결과
- 점수: MRR 0.787 / Precision@K 0.272
- 평가 분석: MRR과 Precision 모두 높은 점수를 기록하였다.
- 기본적으로 준수한 성적을 보였다.
- 단점은 생성 비용과 시간이 꽤 오래 들기 때문에, 사실 실제 서비스하는 환경에서 사용하기는 현실적으로 힘들어 보인다.
결론 : "문서를 먼저 보자"
이번 Chunking 실험의 결론은 꽤 명확하다.
- PPT나 슬라이드 위주라면? 고민하지 말고 Page Chunking부터 해라. 그게 제일 빠르고 정확하다.
- 일반적인 줄글이라면? Recursive가 가장 안전한 선택이다.
- 성능을 극한으로 끌어올리고 싶다면? Contextual Retrieval을 도입해라. 단, 생성 비용과 시간은 감수해야 한다.
- Semantic은? 아직은… 잘 모르겠다🤷
결국 "어떻게 자를까"는 "문서가 어떻게 생겼나"에 달려 있는 거 같다.
Reference
'AI' 카테고리의 다른 글
| [RAG] #4 - Reranking, Query Expand, etc .. (0) | 2026.01.03 |
|---|---|
| [RAG] #3 - Embedding (0) | 2025.12.28 |
| RAG #1 (0) | 2025.12.28 |
| Google file search (0) | 2025.11.18 |
| Agile is Out, Architecture is Back (0) | 2025.11.09 |