Field Log · Entry

RAG Chunking: 크기·Overlap·Semantic·Late Chunking 선택법 (2/10)

하나의 문서를 fixed token, structure-aware, semantic, late chunking으로 나누고 검색용 child와 답변용 parent를 연결하는 비교

오늘의 결론

  • 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입니다.

Fixed, structure-aware, semantic, late chunking과 parent-child retrieval 비교

그림 1. 같은 문서도 경계를 어디에서 결정하고 언제 embedding하는지에 따라 검색 표현이 달라진다. 실무 baseline은 구조를 보존한 child와 더 넓은 parent를 함께 관리하는 방식이다.


Chunk에는 두 역할이 있다

RAG framework는 흔히 하나의 Document 객체를 검색하고 그대로 prompt에 넣습니다. 하지만 두 역할을 구분하면 설계가 선명해집니다.

  1. retrieval unit: 질문과 점수를 계산하는 작은 단위
  2. 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
latencyingestion 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가 같은 sectionoverlap·child 중복unique parent count
짧은 코드 질문이 안 잡힘큰 chunk에 exact term 희석chunk token 분포, BM25
표 질문이 틀림row-header 관계 소실parsed table element
ingestion이 매우 느림sentence embedding·LLM chunkingtokens/sec, API calls
context가 길지만 답이 나쁨parent가 너무 큼·순서 문제context tokens, position

chunking은 retrieval 앞단의 문제이지만 생성 결과까지 영향을 줍니다. 그래서 단계 metric과 end-to-end metric을 함께 봅니다.

추천 baseline

처음에는 다음 구성이 충분합니다.

  1. parser가 제공한 heading·paragraph·table 경계를 보존합니다.
  2. embedding tokenizer 기준 256–512 token 범위에서 시작합니다.
  3. overlap은 10–20%보다 작은 값부터 비교합니다.
  4. 긴 element만 token window로 나눕니다.
  5. child에 heading path를 metadata로 저장합니다.
  6. section parent를 별도 store에 둡니다.
  7. 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 대비 개선을 증명한 뒤 채택한다.

스스로 확인하기

  1. retrieval unit과 context unit을 분리하면 어떤 장점이 있는가?
  2. 400-token chunk에 200-token overlap을 쓰면 저장량이 왜 약 두 배가 되는가?
  3. semantic chunking과 late chunking은 각각 경계를 언제, 어떻게 다루는가?
  4. chunker를 바꿔도 유지되는 evaluation label은 어떤 좌표를 가져야 하는가?
  5. Recall@k가 올랐는데 답변이 나빠졌다면 어떤 중복과 context metric을 볼 것인가?

다음 글에서는 embedding 없이도 exact term과 희귀 식별자를 강하게 찾는 역색인·TF-IDF·BM25를 직접 계산합니다.

참고자료