Field Log · Entry
RAG Chunking: 크기·Overlap·Semantic·Late Chunking 선택법 (2/10)
오늘의 결론
- chunk는 단순 저장 조각이 아니라 검색 점수와 답변 근거의 최소 단위입니다.
- 글자 수보다 embedding model의 tokenizer 기준 token 수를 사용합니다.
- 기본값은 구조 경계를 먼저 지키고, 너무 긴 구간만 token window로 나누는 방식입니다.
- overlap은 경계 손실을 줄이지만 index 크기와 중복 context를 동시에 늘립니다.
- semantic·late chunking은 자동 upgrade가 아니라 같은 평가셋에서 증명해야 할 가설입니다.
앞 글에서 PDF와 HTML을 page, heading, table, source가 보존된 element로 만들었습니다. 이제 이 element를 retrieval이 비교할 단위로 묶어야 합니다.
chunk가 너무 작으면 정답에 필요한 조건이 여러 조각으로 흩어집니다. 너무 크면 한 vector에 여러 주제가 압축되고, 관련 없는 text가 LLM context를 차지합니다. 이 사이의 균형이 chunking입니다.
그림 1. 같은 문서도 경계를 어디에서 결정하고 언제 embedding하는지에 따라 검색 표현이 달라진다. 실무 baseline은 구조를 보존한 child와 더 넓은 parent를 함께 관리하는 방식이다.
Chunk에는 두 역할이 있다
RAG framework는 흔히 하나의 Document 객체를 검색하고 그대로 prompt에 넣습니다. 하지만 두 역할을 구분하면 설계가 선명해집니다.
- retrieval unit: 질문과 점수를 계산하는 작은 단위
- context unit: 답을 만들기에 충분한 근거를 제공하는 단위
두 단위가 반드시 같을 필요는 없습니다.
작은 child chunk로 정확히 검색
↓ parent_id
같은 section 또는 앞뒤 문단을 context로 확장
예를 들어 “Pro 요금제의 보존 기간”은 표의 한 row로 검색하기 쉽지만, 답변에는 표 제목과 단위 설명도 필요할 수 있습니다. 검색용 child는 row이고 context용 parent는 table 전체가 될 수 있습니다.
먼저 정답의 크기를 생각한다
chunk size를 256이나 512로 정하기 전에 실제 질문의 evidence span을 봅니다.
| 질문 유형 | 필요한 근거 범위 | chunk 위험 |
|---|---|---|
| 이름·코드 조회 | 한 문장·한 row | 큰 chunk에서 신호 희석 |
| 절차 설명 | 연속된 list·여러 문단 | 작은 chunk에서 단계 분리 |
| 조건 비교 | 표의 여러 row·section | 단일 top-1로 불충분 |
| 요약 | 문서·chapter 전체 | chunk retrieval만으로 범위 누락 |
| 관계 추론 | 떨어진 여러 근거 | query 분해·multi-hop 필요 |
따라서 목표는 “최고의 chunk size”가 아니라 내 query 분포에서 필요한 evidence가 검색 가능한 단위에 들어가게 하는 것입니다.
글자 수 대신 token 수를 쓴다
같은 1,000자라도 한글, 영문, code, 숫자의 token 수는 다릅니다. embedding model마다 tokenizer도 다를 수 있습니다.
model의 최대 입력이 512 token이라고 해서 chunk text를 정확히 512로 만들면 안 됩니다. query/passage prefix와 special token이 추가될 수 있고, model에 따라 긴 입력이 잘릴 수 있습니다.
from dataclasses import dataclass
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(
"intfloat/multilingual-e5-small",
use_fast=True,
)
@dataclass(frozen=True)
class TextSpan:
text: str
start_char: int
end_char: int
start_token: int
end_token: int
def token_windows(text: str, size: int, overlap: int) -> list[TextSpan]:
if size <= 0 or overlap < 0 or overlap >= size:
raise ValueError("require size > overlap >= 0")
encoded = tokenizer(
text,
add_special_tokens=False,
return_offsets_mapping=True,
truncation=False,
)
offsets = encoded["offset_mapping"]
spans = []
step = size - overlap
for start in range(0, len(offsets), step):
end = min(start + size, len(offsets))
if start >= end:
break
start_char = offsets[start][0]
end_char = offsets[end - 1][1]
spans.append(TextSpan(
text=text[start_char:end_char],
start_char=start_char,
end_char=end_char,
start_token=start,
end_token=end,
))
if end == len(offsets):
break
return spans
fast tokenizer의 offset_mapping을 쓰면 decode 과정에서 공백이나 punctuation이 달라지는 문제를 피하고 원문 위치를 유지할 수 있습니다.
Fixed-token chunking: 가장 중요한 baseline
고정 token window는 단순하지만 다음 장점이 있습니다.
- 구현과 재현이 쉽습니다.
- chunk token 분포가 예측 가능합니다.
- 다른 방법의 개선 효과를 비교할 baseline이 됩니다.
- ingestion latency가 작습니다.
단점은 heading, 문단, 표 중간을 자를 수 있다는 점입니다.
전체 token이 T, chunk size가 C, overlap이 O라면 chunk 수는 대략 다음과 같습니다.
step = C - O
chunk_count ≈ ceil((T - O) / (C - O))
긴 문서에서 overlap으로 인한 저장 배수는 대략 다음에 가까워집니다.
duplication factor ≈ C / (C - O)
C=400, O=0 → 1.00배
C=400, O=80 → 1.25배
C=400, O=200 → 2.00배
overlap 50%는 경계를 지켜 주는 무료 옵션이 아니라 embedding 비용, vector 수, 검색 중복을 거의 두 배로 만드는 선택입니다.
Structure-aware chunking: 경계를 먼저 지킨다
문서 parser가 만든 heading, paragraph, list, table 정보를 사용합니다.
기본 알고리즘은 다음처럼 구성할 수 있습니다.
1. heading path가 바뀌면 새 section 시작
2. paragraph/list/table row를 원자 element로 유지
3. element를 token budget까지 합치기
4. element 하나가 budget보다 길 때만 token window 적용
5. child에 heading path와 parent section ID 복사
from dataclasses import dataclass
@dataclass(frozen=True)
class Chunk:
chunk_id: str
parent_id: str
text: str
heading_path: tuple[str, ...]
element_ids: tuple[str, ...]
token_count: int
def render_element(element: Element) -> str:
prefix = " > ".join(element.heading_path)
if element.kind == "table":
return f"[TABLE] {prefix}\n{element.text}"
return f"{prefix}\n{element.text}" if prefix else element.text
heading을 metadata에만 넣을지 text 앞에도 붙일지는 실험 대상입니다. 짧은 대명사나 “이 정책” 같은 표현은 heading을 붙였을 때 더 잘 검색될 수 있지만, 반복 prefix가 모든 vector를 비슷하게 만들 수도 있습니다.
Recursive chunking은 무엇인가
recursive splitter는 큰 separator부터 시도합니다.
chapter 경계
→ heading
→ paragraph blank line
→ sentence
→ whitespace
→ 마지막에 token window
단순 fixed window보다 자연스러운 경계를 만들기 쉽지만, separator list가 문서 언어와 형식에 맞아야 합니다. Markdown의 ##를 PDF text에 그대로 적용하는 식으로 형식 정보를 잃은 뒤 추측하지 않는 편이 좋습니다.
Semantic chunking은 의미 변화로 경계를 찾는다
semantic chunking의 한 방식은 연속 sentence embedding 사이의 유사도를 계산해 급격히 달라지는 지점에서 자르는 것입니다.
s₁ ─0.91─ s₂ ─0.88─ s₃ ─0.34─ s₄ ─0.90─ s₅
↑ topic boundary 후보
간단한 구현은 다음 형태입니다.
import numpy as np
def cosine_rows(a: np.ndarray, b: np.ndarray) -> np.ndarray:
a = a / np.linalg.norm(a, axis=1, keepdims=True)
b = b / np.linalg.norm(b, axis=1, keepdims=True)
return np.sum(a * b, axis=1)
def semantic_boundaries(
sentence_vectors: np.ndarray,
percentile: float = 15.0,
) -> list[int]:
adjacent = cosine_rows(sentence_vectors[:-1], sentence_vectors[1:])
threshold = np.percentile(adjacent, percentile)
return [i + 1 for i, score in enumerate(adjacent) if score <= threshold]
하지만 다음 질문이 남습니다.
- sentence splitter가 code와 table을 올바르게 처리하는가?
- embedding model이 domain의 주제 변화를 표현하는가?
- threshold는 document마다 달라져야 하는가?
- 아주 긴 section과 아주 짧은 section을 어떻게 제한하는가?
- sentence마다 embedding하는 ingestion 비용이 가치가 있는가?
2024년의 비교 연구는 semantic chunking의 추가 계산 비용이 여러 retrieval 과제에서 일관된 성능 향상으로 이어지지 않았다고 보고했습니다. 2026년의 재현·분류 연구도 최적 방식이 in-document와 in-corpus retrieval에 따라 달라진다고 정리합니다. 따라서 이름이 고급스러워 보인다는 이유로 baseline을 건너뛰면 안 됩니다.
Late chunking은 경계를 늦게 적용한다
일반적인 chunk-then-embed는 각 chunk를 독립적으로 model에 넣습니다.
[문서] → chunk A, B, C → 각각 Transformer → mean pooling → vA, vB, vC
late chunking은 긴 문서를 먼저 Transformer에 통과시켜 각 token이 넓은 문맥을 반영하게 한 뒤, chunk span별로 pooling합니다.
[긴 문서] → long-context Transformer → contextualized token vectors
→ span A pooling → vA
→ span B pooling → vB
→ span C pooling → vC
“그 회사”, “이 제한”처럼 주변 문맥 없이는 모호한 표현을 더 잘 표현할 가능성이 있습니다. 반면 다음 제약이 있습니다.
- long-context embedding model이 필요합니다.
- 문서 전체 token encoding 비용과 memory가 커집니다.
- model 최대 길이보다 긴 문서는 다시 windowing해야 합니다.
- boundary 자체를 자동으로 찾아 주는 방법은 아닙니다.
- 일반 chunking과 동일한 serving API로 구현되지 않을 수 있습니다.
late chunking과 contextual retrieval도 구분해야 합니다. 전자는 token representation을 넓은 문맥에서 만든 뒤 pooling하고, 후자는 각 chunk 앞에 문서·section 설명을 text로 추가하는 계열입니다.
Parent-child retrieval로 검색 단위와 답변 단위를 분리한다
실용적인 baseline은 작은 child를 검색하고 큰 parent를 가져오는 것입니다.
@dataclass(frozen=True)
class SearchHit:
child_id: str
parent_id: str
score: float
def expand_parents(
hits: list[SearchHit],
parent_store: dict[str, str],
max_parents: int,
) -> list[str]:
seen = set()
contexts = []
for hit in hits:
if hit.parent_id in seen:
continue
seen.add(hit.parent_id)
contexts.append(parent_store[hit.parent_id])
if len(contexts) == max_parents:
break
return contexts
여기서 child top-k가 같은 parent에 몰릴 수 있으므로 parent dedup을 적용하고, 원래 가장 높은 child score를 parent score로 보존합니다.
parent를 너무 크게 만들면 다시 context 비용과 lost-in-the-middle 문제가 생깁니다. section, page, window-expanded paragraph 중 무엇을 parent로 쓸지 평가합니다.
Chunk metadata는 text만큼 중요하다
최소한 다음을 남깁니다.
{
"chunk_id": "chk_7d8...",
"parent_id": "sec_3a1...",
"revision_id": "rev_91b...",
"element_ids": ["el_14", "el_15"],
"heading_path": ["요금제", "보존 정책"],
"page_start": 8,
"page_end": 8,
"start_char": 1240,
"end_char": 2094,
"token_count": 286,
"chunker_version": "structure-token-v3",
"acl": ["employee"]
}
overlap 때문에 같은 문장이 여러 chunk에 들어갔을 때도 원문 span으로 중복을 찾을 수 있습니다.
실험은 한 숫자가 아니라 grid로 한다
작은 query set으로 다음 조합부터 비교합니다.
method ∈ {fixed-token, structure-token, semantic}
size ∈ {128, 256, 512}
overlap∈ {0, 32, 64}
parent ∈ {none, section}
모든 조합을 무작정 실행하기보다 baseline에서 시작해 한 축씩 좁힙니다.
함께 기록할 metric
| 관점 | metric |
|---|---|
| 검색 품질 | Recall@k, MRR, nDCG@k |
| 근거 완전성 | answer evidence span coverage |
| 중복 | top-k 간 동일 원문 span 비율 |
| 비용 | chunk 수, embedding tokens, index bytes |
| latency | ingestion time, query time |
| 생성 | context tokens, faithfulness, answer correctness |
검색 Recall만 오르고 top-k가 같은 문장 복제본으로 가득 차면 LLM에 필요한 근거 다양성은 오히려 나빠질 수 있습니다.
평가 label도 chunker에 종속되지 않게 만든다
정답을 chunk_id로만 label하면 chunking을 바꿀 때 모든 label이 깨집니다. 가능한 한 원문 기준으로 label합니다.
{
"question_id": "q-17",
"source_id": "policy:storage",
"revision_id": "rev_91b...",
"evidence_spans": [
{"page": 8, "start_char": 1510, "end_char": 1588}
]
}
candidate chunk의 원문 span이 evidence span과 겹치는지 계산하면 chunker를 바꿔도 같은 질문으로 비교할 수 있습니다.
def overlaps(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
return max(a_start, b_start) < min(a_end, b_end)
완전 포함만 정답으로 볼지 일부 overlap도 인정할지는 evidence 성격에 맞춰 명시합니다.
흔한 실패와 먼저 볼 값
| 증상 | 가능한 원인 | 먼저 볼 값 |
|---|---|---|
| 정답 문장 앞 조건이 없음 | chunk가 너무 작음 | evidence span coverage |
| 모든 hit가 같은 section | overlap·child 중복 | unique parent count |
| 짧은 코드 질문이 안 잡힘 | 큰 chunk에 exact term 희석 | chunk token 분포, BM25 |
| 표 질문이 틀림 | row-header 관계 소실 | parsed table element |
| ingestion이 매우 느림 | sentence embedding·LLM chunking | tokens/sec, API calls |
| context가 길지만 답이 나쁨 | parent가 너무 큼·순서 문제 | context tokens, position |
chunking은 retrieval 앞단의 문제이지만 생성 결과까지 영향을 줍니다. 그래서 단계 metric과 end-to-end metric을 함께 봅니다.
추천 baseline
처음에는 다음 구성이 충분합니다.
- parser가 제공한 heading·paragraph·table 경계를 보존합니다.
- embedding tokenizer 기준 256–512 token 범위에서 시작합니다.
- overlap은 10–20%보다 작은 값부터 비교합니다.
- 긴 element만 token window로 나눕니다.
- child에 heading path를 metadata로 저장합니다.
- section parent를 별도 store에 둡니다.
- fixed-token baseline과 같은 query set으로 비교합니다.
이 숫자는 정답이 아니라 실험의 시작점입니다. corpus와 질문별 분포를 본 뒤 바꿉니다.
Production 체크리스트
- embedding model tokenizer로 chunk token 수를 계산한다.
- parser의 heading·list·table 경계를 잃지 않는다.
- child와 parent의 역할이 분리돼 있다.
- overlap의 저장·검색 중복 비용을 측정한다.
- chunk에 원문 span과 element ID가 있다.
- chunker version과 index version을 기록한다.
- query label이 특정 chunk ID에만 묶여 있지 않다.
- 방법별 Recall, 중복, 비용, context token을 함께 비교한다.
- semantic·late chunking은 baseline 대비 개선을 증명한 뒤 채택한다.
스스로 확인하기
- retrieval unit과 context unit을 분리하면 어떤 장점이 있는가?
- 400-token chunk에 200-token overlap을 쓰면 저장량이 왜 약 두 배가 되는가?
- semantic chunking과 late chunking은 각각 경계를 언제, 어떻게 다루는가?
- chunker를 바꿔도 유지되는 evaluation label은 어떤 좌표를 가져야 하는가?
- Recall@k가 올랐는데 답변이 나빠졌다면 어떤 중복과 context metric을 볼 것인가?
다음 글에서는 embedding 없이도 exact term과 희귀 식별자를 강하게 찾는 역색인·TF-IDF·BM25를 직접 계산합니다.
참고자료
- Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models
- Is Semantic Chunking Worth the Computational Cost?
- Beyond Chunk-Then-Embed: A Comprehensive Taxonomy and Evaluation of Document Chunking Strategies for Information Retrieval
- Reconstructing Context: Evaluating Advanced Chunking Strategies for RAG
- DAPR: A Benchmark on Document-Aware Passage Retrieval
- Lost in the Middle: How Language Models Use Long Contexts