포스트

RAG 성능을 좌우하는 Chunking 전략: “Overlap vs Semantic Chunking” 실전 선택 가이드

RAG에서 “검색이 잘 안 된다”는 문제의 상당수는 Retriever나 Embedding이 아니라 document splitting(=chunking)에서 이미 결정됩니다.

RAG 성능을 좌우하는 Chunking 전략: “Overlap vs Semantic Chunking” 실전 선택 가이드

들어가며

RAG에서 “검색이 잘 안 된다”는 문제의 상당수는 Retriever나 Embedding이 아니라 document splitting(=chunking)에서 이미 결정됩니다. 질문에 필요한 근거가 (1) 잘려 나가거나, (2) 너무 많은 문맥에 묻히거나, (3) 표/섹션 구조가 깨지면, 이후에 rerank/prompt를 아무리 튜닝해도 비용만 늘고 정답률은 잘 안 오릅니다.

언제 쓰면 좋나?

  • 사내 위키/매뉴얼/정책/기술 문서처럼 heading/section 구조가 있는 코퍼스
  • “특정 조건/예외/표의 수치” 같은 정밀 QA가 필요할 때
  • PDF → text/markdown 변환을 거치며 레이아웃/표 구조가 흔들리는 파이프라인

언제 쓰면 안 되나?

  • 코퍼스가 매우 짧고(예: 각 문서가 1~2문단) 질문도 단순한 경우: 과한 chunking은 오히려 중복 인덱싱만 만듭니다.
  • 다이어그램/도면(P&ID 등)처럼 정보가 시각적/공간적으로 인코딩된 문서: 텍스트 chunking만으로 한계가 크며 multimodal 접근이 필요합니다.1

🔧 핵심 개념

1) Chunking을 “split”과 “chunk assembly”로 분리해서 생각하기

2026년 실무 관점에서 가장 유용한 프레임은 다음 3단계입니다(특히 overlap/semantic을 섞어 쓸 때 중요).

  1. Document splitting: 어디를 “끊을지” 결정
    • content-independent: token/char/sentence/paragraph 기반
    • content-dependent: speaker turn(콜 transcript), 섹션 경계, 코드 블록/표 경계 등
      Cohere의 가이드도 이 분리를 명시하고, transcript처럼 구조를 보존해야 하는 경우 content-dependent split이 유리하다고 설명합니다.2
  2. Chunk assembly: split 조각들을 “얼마나 묶어서” 최종 chunk로 만들지(=chunk size 정책)
    • 작은 chunk: retrieval precision↑, 문맥↓
    • 큰 chunk: 문맥↑, retrieval dilution↑
      Hugging Face cookbook도 “너무 작으면 아이디어가 잘리고, 너무 크면 의미가 희석된다”고 정리합니다.3
  3. Overlap: 끊긴 경계의 손실을 “중복”으로 복구
    • 장점: 경계에서 핵심 문장/근거가 반으로 잘리는 리스크 완화
    • 단점: 인덱스 중복(비용/저장/latency) + 상위 K 결과가 유사 chunk로 도배될 위험2

2) Overlap vs Semantic chunking의 구조적 차이

Overlap(슬라이딩/윈도우) 기반

  • 원리: “문서 의미를 이해하지 않고도” 경계 손실을 낮추는 확률적 안전장치
  • 흐름: (문장/문단/토큰) 단위로 자른 뒤, 인접 chunk에 N 토큰(또는 문자)을 겹치게 함
  • 강점: 구현 단순, 일관된 latency, 파라미터 튜닝이 비교적 직관적
  • 약점: 중복이 곧 비용. 그리고 동일 문맥이 여러 chunk에 들어가 rerank를 어렵게 할 수 있음

Semantic chunking(브레이크포인트/embedding 기반)

  • 원리: “의미가 바뀌는 지점”을 경계로 잡아 chunk cohesion을 높임
  • 흐름(대표적 패턴)
    1) 문서 → 문장(또는 문단) 리스트
    2) 각 단위 임베딩 계산
    3) 인접 유사도가 임계치 아래로 떨어지는 지점을 breakpoint로 설정
    4) breakpoint 사이를 chunk로 묶고, 최대 길이 초과 시 추가 분할
  • 강점: chunk 내부 응집도가 좋아져 retrieval 시 “필요한 근거가 한 덩어리로” 들어올 확률↑
  • 약점: 임베딩 비용/시간 + 임계치 튜닝이 도메인 의존적. 표/코드/레이아웃 문서는 오히려 구조 기반이 더 낫기도 함(아래 참고).

3) 2026년 트렌드: “한 가지 chunker”가 아니라 “문서별 선택(Adaptive)”

2026년 논문 흐름에서 눈에 띄는 건 Adaptive Chunking입니다. 핵심은 “어떤 chunker가 최고냐”가 아니라, 문서의 특징(구조/참조/블록 무결성 등)에 따라 문서별로 chunking 전략을 고르는 것. intrinsic metric(RC, ICC, DCC, BI, SC)을 정의해 chunk 품질을 직접 평가하고, 모델/프롬프트를 바꾸지 않고도 정답률을 올렸다고 보고합니다.4

또한 2026년 산업 문서(오일&가스) 평가에서는 structure-aware chunking이 semantic이나 baseline 대비 top-K retrieval에서 유리하고 비용도 낮았다고 보고합니다. 특히 표/도면류는 텍스트-only RAG의 한계를 분명히 지적합니다.1
PDF 파싱/변환 쪽에서도 “hierarchical splitting + metadata enrichment”가 정확도에 크게 기여한다는 결과가 반복됩니다.5


💻 실전 코드

아래 예제는 “실무에서 흔한” Markdown/정책 문서(heading 구조 + 표/코드 블록 혼재)를 대상으로, 1) 구조 기반(heading/코드블록 보호) 1차 split
2) 섹션 내부에서 semantic chunking(문장 임베딩) 2차 split
3) 마지막에 소량 overlap을 옵션으로 주는 하이브리드 ingestion 파이프라인입니다.

  • Vector DB: FAISS (로컬에서 재현 쉬움)
  • Embedding: sentence-transformers (로컬/온프레 가정)
  • Retriever는 BM25+Vector 하이브리드까지 가면 더 좋지만, 여기서는 chunking에 집중합니다.

0) 의존성/실행

1
2
3
4
python -m venv .venv
source .venv/bin/activate

pip install -U faiss-cpu sentence-transformers numpy regex rank-bm25

1) 구조 보존 + semantic chunking + (선택) overlap

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
import re
from dataclasses import dataclass
from typing import List, Dict, Tuple, Iterable, Optional

import numpy as np
import faiss
from sentence_transformers import SentenceTransformer

# --------- 데이터 모델 ---------
@dataclass
class Chunk:
    chunk_id: str
    text: str
    meta: Dict

# --------- 유틸: 코드블록 보호 + heading 기반 섹션 분해 ---------
CODE_FENCE_RE = re.compile(r"```.*?\n.*?\n```", re.DOTALL)

def split_markdown_by_headings(md: str) -> List[Tuple[str, str]]:
    """
    returns list of (section_path, section_text)
    - code fences는 통째로 보존(중간 분해 방지)
    - heading(#..######) 기준으로 섹션을 쪼갬
    """
    # 1) code fence를 placeholder로 치환
    fences = []
    def _stash(m):
        fences.append(m.group(0))
        return f"__CODE_FENCE_{len(fences)-1}__"
    protected = CODE_FENCE_RE.sub(_stash, md)

    # 2) heading split
    lines = protected.splitlines()
    sections = []
    cur_title = "ROOT"
    buf = []

    heading_re = re.compile(r"^(#{1,6})\s+(.*)$")
    for line in lines:
        m = heading_re.match(line)
        if m:
            # flush
            if buf:
                sections.append((cur_title, "\n".join(buf).strip()))
                buf = []
            level = len(m.group(1))
            title = m.group(2).strip()
            cur_title = f"{'#'*level} {title}"
        buf.append(line)

    if buf:
        sections.append((cur_title, "\n".join(buf).strip()))

    # 3) placeholder 복원
    def _restore(text: str) -> str:
        for i, fence in enumerate(fences):
            text = text.replace(f"__CODE_FENCE_{i}__", fence)
        return text

    return [(title, _restore(text)) for title, text in sections if text]

# --------- 유틸: 문장 단위 분해(간단 버전) ---------
SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+|\n+")

def to_sentences(text: str) -> List[str]:
    sents = [s.strip() for s in SENT_SPLIT_RE.split(text) if s.strip()]
    # 너무 짧은 조각은 앞에 붙이는 등 후처리 가능(실무에서는 권장)
    return sents

# --------- Semantic chunking (breakpoint based) ---------
def semantic_chunk_sentences(
    sentences: List[str],
    embed: SentenceTransformer,
    max_chars: int = 1800,
    breakpoint_sim: float = 0.75,   # 낮을수록 더 잘게 쪼개짐(도메인 튜닝 필요)
) -> List[str]:
    if not sentences:
        return []

    embs = embed.encode(sentences, normalize_embeddings=True, batch_size=64)
    # 인접 cosine similarity
    sims = (embs[:-1] * embs[1:]).sum(axis=1)

    chunks = []
    cur = [sentences[0]]

    def cur_len():
        return sum(len(x) for x in cur) + max(0, len(cur)-1)

    for i in range(1, len(sentences)):
        # 규칙 1: 의미가 크게 바뀌는 지점이면 끊기
        if sims[i-1] < breakpoint_sim:
            chunks.append(" ".join(cur).strip())
            cur = [sentences[i]]
            continue

        # 규칙 2: 길이 제한
        if cur_len() + 1 + len(sentences[i]) > max_chars:
            chunks.append(" ".join(cur).strip())
            cur = [sentences[i]]
            continue

        cur.append(sentences[i])

    if cur:
        chunks.append(" ".join(cur).strip())

    return [c for c in chunks if c]

# --------- Overlap (선택): chunk 경계 완충 ---------
def apply_overlap(chunks: List[str], overlap_chars: int = 200) -> List[str]:
    if overlap_chars <= 0 or len(chunks) <= 1:
        return chunks

    out = [chunks[0]]
    for i in range(1, len(chunks)):
        prev = chunks[i-1]
        cur = chunks[i]
        prefix = prev[-overlap_chars:] if len(prev) > overlap_chars else prev
        out.append((prefix + "\n" + cur).strip())
    return out

# --------- 인덱싱: FAISS ---------
class FaissIndex:
    def __init__(self, embed: SentenceTransformer):
        self.embed = embed
        self.index = None
        self.chunks: List[Chunk] = []

    def add(self, chunks: List[Chunk]):
        vecs = self.embed.encode([c.text for c in chunks], normalize_embeddings=True)
        vecs = np.asarray(vecs, dtype="float32")

        if self.index is None:
            dim = vecs.shape[1]
            self.index = faiss.IndexFlatIP(dim)  # cosine ~ inner product (normalized)
        self.index.add(vecs)
        self.chunks.extend(chunks)

    def search(self, query: str, top_k: int = 5) -> List[Tuple[Chunk, float]]:
        qv = self.embed.encode([query], normalize_embeddings=True).astype("float32")
        scores, ids = self.index.search(qv, top_k)
        results = []
        for idx, score in zip(ids[0], scores[0]):
            if idx == -1:
                continue
            results.append((self.chunks[idx], float(score)))
        return results

# --------- 현실적 시나리오: 정책/런북 문서 ingest ---------
def ingest_markdown_docs(docs: List[Tuple[str, str]]) -> List[Chunk]:
    """
    docs: list of (doc_id, markdown_text)
    """
    embed = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

    all_chunks: List[Chunk] = []
    for doc_id, md in docs:
        sections = split_markdown_by_headings(md)

        for sec_i, (sec_title, sec_text) in enumerate(sections):
            sents = to_sentences(sec_text)

            sem_chunks = semantic_chunk_sentences(
                sents,
                embed=embed,
                max_chars=1800,
                breakpoint_sim=0.75,
            )

            # overlap은 "조금만" (semantic chunking에서도 경계 손실은 생김)
            final_chunks = apply_overlap(sem_chunks, overlap_chars=180)

            for j, ch in enumerate(final_chunks):
                all_chunks.append(
                    Chunk(
                        chunk_id=f"{doc_id}::sec{sec_i}::ch{j}",
                        text=ch,
                        meta={
                            "doc_id": doc_id,
                            "section": sec_title,
                            "sec_index": sec_i,
                            "chunk_index": j,
                        },
                    )
                )
    return all_chunks

if __name__ == "__main__":
    # 예시: SRE 런북 + 보안 정책 같은 “현실 문서”를 가정(텍스트는 짧게 축약)
    runbook = """
# Incident Runbook

## Mitigation steps
If error rate increases above 5% for 10 minutes, do:
1. Check deploy history
2. Rollback if needed
3. Verify DB connections

## Rollback procedure
```bash
kubectl rollout undo deploy/api -n prod
kubectl rollout status deploy/api -n prod

Postmortem template

Write: timeline, impact, root cause, actions. “””

1
policy = """ # Security Policy

Password requirements

Minimum length is 14 characters. Disallow common passwords. Rotate privileged credentials every 90 days.

Exception process

Exceptions require approval from Security and are time-boxed. “””

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
chunks = ingest_markdown_docs([
    ("runbook.md", runbook),
    ("security_policy.md", policy),
])

embed = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
ix = FaissIndex(embed)
ix.add(chunks)

query = "kubectl로 롤백하는 방법이 뭐야?"
results = ix.search(query, top_k=3)

print("=== Top hits ===")
for c, s in results:
    print(f"\nscore={s:.3f} id={c.chunk_id} section={c.meta['section']}")
    print(c.text[:400]) ```

예상 출력(예)

  • “Rollback procedure” 섹션(코드블록 포함) chunk가 1~2순위로 뜨고,
  • overlap 덕분에 바로 앞 문장(맥락)까지 일부 같이 들어오는 형태가 됩니다.

이 파이프라인이 실무적으로 중요한 이유:

  • Hugging Face가 말한 것처럼 recursive/구조 보존이 문서 전체 구조를 유지하는 데 유리하고3
  • Cohere가 강조한 overlap의 “경계 완충”을 과도한 중복 없이 최소로만 적용하며2
  • 최근 연구들이 반복해서 보여주는 “structure-aware가 종종 semantic보다 싸고 잘 먹힌다”는 결과를 반영하기 때문입니다.1

⚡ 실전 팁 & 함정

Best Practice (바로 적용 가능)

1) heading path / metadata를 chunk에 넣어라

  • “섹션 제목 + 문서 경로”는 retrieval precision을 올리는 가장 싼 방법입니다.
  • PDF→Markdown 파이프라인 평가에서도 hierarchical splitting과 metadata enrichment가 정확도에 크게 기여한다고 보고됩니다.5

2) semantic chunking은 ‘전체 문서’가 아니라 ‘구조로 잘라낸 섹션 내부’에서만

  • 전체 문서에 semantic을 돌리면 비용이 크고, 표/코드/목차 등 잡음이 breakpoint를 오염시킵니다.
  • 먼저 structure-aware split → 그 내부에서 semantic은 “미세 조정” 역할이 가장 안정적입니다(실무 체감 + 최근 구조 기반 우세 결과와도 부합).1

3) Overlap은 기본값이 아니라 “보험료”로 취급

  • overlap은 중복 인덱싱 비용을 영구히 발생시키므로 “필요 최소”로.
  • 특히 semantic chunking까지 쓰면 overlap을 크게 줄이거나(예: 10% 미만) 아예 꺼도 되는 케이스가 많습니다.2

흔한 함정 / 안티패턴

  • 고정 token chunk + 큰 overlap만으로 모든 문서를 해결하려는 접근: 빠르게 만들 수는 있지만, 섹션/표/코드 경계를 무시하면 top-K가 “비슷한 중복 chunk”로 채워져 실제 근거가 묻힙니다.
  • semantic chunking 임계치(breakpoint_sim)를 한 번 정하고 영원히 고정: 도메인/문체가 바뀌면 바로 성능이 흔들립니다. 2026년에는 문서별 전략 선택(Adaptive)이 유효하다는 근거가 쌓이는 중입니다.4
  • 시각적 문서(P&ID/복잡한 표)를 텍스트로만 해결하려 함: 연구에서도 순수 텍스트 chunking은 한계가 명확합니다.1

비용/성능/안정성 트레이드오프(의사결정 기준)

  • 비용이 가장 큰 축: semantic chunking(임베딩 비용) + overlap(저장/검색 중복)
  • 안정성이 높은 축: structure-aware split(heading/문단/코드블록 보호)
  • 권장 우선순위(대부분의 팀에 맞음): 1) 구조 기반 split + metadata 2) chunk size 재조정(Reader context window 고려) 3) overlap 최소 적용 4) 그래도 부족하면 섹션 내부 semantic chunking 5) 코퍼스가 다양한 대규모면 Adaptive(문서별 선택)로 확장4

🚀 마무리

2026년 8월 기준 chunking의 결론은 “semantic이냐 overlap이냐”의 이분법이 아니라, 구조 보존(structure-aware) → 필요 시 semantic → overlap은 최소 보험의 레이어드 전략입니다. 최근 연구/가이드에서도 구조 보존과 문서별 전략 선택(Adaptive), 그리고 overlap의 장단점(완충 vs 중복)이 반복해서 강조됩니다.1

도입 판단 기준(빠른 체크리스트):

  • 문서에 heading/섹션이 있다 → structure-aware 먼저
  • 질문이 “정의/조건/예외/표 수치” 중심이다 → semantic(섹션 내부) 고려
  • “근거가 반쯤 잘린다” 이슈가 있다 → overlap을 최소로만 추가
  • 문서 타입이 다양하고 한 전략이 자주 깨진다 → Adaptive chunking(문서별 선택) 실험4

다음 학습 추천:

  • chunk 품질을 intrinsic metric으로 평가하고 문서별로 chunker를 고르는 Adaptive Chunking 흐름4
  • PDF → Markdown 변환과 hierarchical splitting/metadata enrichment가 QA 정확도에 미치는 영향(“chunking 이전 단계”까지 포함)5
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.