RAG 파이프라인의 품질을 좌우하는 것은 화려한 임베딩 모델이나 재랭커가 아니라, 그 앞단에서 원문을 어떻게 잘라 넣었는가입니다. 문서를 조각(chunk)으로 나누는 방식이 검색 리콜과 답변 정확성을 사실상 결정합니다. 청킹이 잘못되면 아무리 좋은 리트리버도 “정답이 반쪽만 담긴 조각”이나 “문맥이 잘려 의미가 훼손된 조각”밖에 가져올 수 없습니다.

이 글에서는 청킹 전략을 고정 크기 분할, 오버랩, 재귀적 분할, 시맨틱 분할의 순서로 비교하며, 각각이 어떤 실패 모드를 해결하고 어떤 비용을 치르는지 코드와 함께 정리합니다. 핵심 원칙은 경계는 토큰 수가 아니라 의미 단위에 맞추고, 오버랩은 경계 손실을 메우는 보험이라는 것입니다.

왜 청킹이 검색 품질을 결정하는가

임베딩 검색은 “질문 벡터와 가장 가까운 조각 벡터”를 찾는 일입니다. 한 조각에 서로 다른 주제가 섞이면 임베딩은 주제들의 평균 위치로 뭉개져, 어느 질문에도 어중간하게 가까운 벡터가 됩니다. 이것이 첫 번째 실패 모드인 주제 희석(topic dilution)입니다. 반대로 완결된 설명이 두 조각으로 쪼개지면 각 조각이 절반의 정보만 담는 경계 절단(boundary truncation)이 일어납니다.

  • 주제 희석: 조각이 크거나 경계가 의미와 무관 → 임베딩 뭉개짐 → 정밀도 하락.
  • 경계 절단: 조각이 작거나 경계가 문맥 중간 → 정보 반토막 → 리콜 하락.
  • 고아 조각: 표·코드·리스트가 잘려 머리말 없는 조각 → 검색돼도 해석 불가.

좋은 청킹은 이 셋을 동시에 억누르는 균형점입니다. 조각을 키우면 희석되고 줄이면 절단되는 긴장이 모든 전략의 출발점입니다.

고정 크기 분할: 단순하지만 위험한 기준선

가장 흔한 시작점은 토큰 수를 기준으로 기계적으로 자르는 것입니다. 구현이 단순하지만 의미를 전혀 고려하지 않아 문장 한가운데, 심지어 단어 한가운데를 잘라 버립니다. 이때는 임베딩 모델과 동일한 토크나이저로 세야 합니다. 문자 수로 어림하면 한국어·코드처럼 토큰 밀도가 높은 텍스트에서 입력 한계를 넘겨 뒷부분이 조용히 잘립니다.

# 고정 크기(토큰 기준) 분할 — 모델과 같은 토크나이저로 세는 것이 핵심
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")  # 임베딩 모델과 동일 인코딩 사용

def fixed_token_chunks(text, size=512, overlap=0):
    tokens = enc.encode(text)
    step = size - overlap          # 오버랩만큼 겹쳐서 전진
    chunks = []
    for start in range(0, len(tokens), step):
        window = tokens[start:start + size]
        if not window:
            break
        chunks.append(enc.decode(window))  # 문장·단어 경계 무시하고 절단
    return chunks

고정 크기 분할은 “기준선”으로만 쓰는 것이 정직합니다. 프로토타입에는 유용하지만, 프로덕션에서는 경계 절단 때문에 거의 항상 더 나은 전략으로 교체하게 됩니다.

오버랩: 경계 손실을 메우는 보험

경계 절단을 완화하는 가장 값싼 방법이 오버랩(overlap)입니다. 인접한 조각이 일정 토큰만큼 겹치게 만들어, 경계에 걸친 문장이 최소한 한쪽 조각에는 온전히 담기도록 합니다. 위 코드의 step = size - overlap이 그 역할입니다. 오버랩 크기는 조각 크기의 10~20%가 출발점입니다. 너무 작으면 경계를 못 메우고, 너무 크면 같은 내용이 중복 저장되어 비용이 늘고 중복 결과가 상위를 차지합니다.

  • 이득: 경계에 걸친 정의·수식·목록이 한쪽 조각에 온전히 남아 리콜이 오릅니다.
  • 비용: 저장 벡터 수 증가(오버랩 20%면 약 25%)와 검색 결과 중복.
  • 주의: 오버랩은 절단을 완화할 뿐 없애지 못합니다. 근본 해법은 경계를 의미에 맞추는 것입니다.

재귀적 분할: 구조적 경계를 존중하기

재귀적 분할(recursive splitting)은 문서에 이미 존재하는 구조적 경계를 우선순위대로 활용합니다. 먼저 문단으로 나눠 보고, 목표보다 크면 문장으로, 그래도 크면 단어로 내려가며 재귀적으로 쪼갭니다. 핵심은 “가능한 한 큰 의미 단위를 유지하되 크기 한계는 지킨다”입니다.

# 재귀적 분할 — 구분자를 우선순위대로 시도해 의미 단위를 최대한 보존
SEPARATORS = ["nn", "n", ". ", " ", ""]  # 문단 → 줄 → 문장 → 단어 → 문자

def recursive_split(text, max_tokens, seps=SEPARATORS):
    if len(enc.encode(text)) <= max_tokens:
        return [text]                      # 이미 충분히 작으면 그대로
    sep = seps[0]
    parts = text.split(sep) if sep else list(text)
    chunks, buf = [], ""
    for part in parts:
        candidate = (buf + sep + part) if buf else part
        if len(enc.encode(candidate))  max_tokens:  # 아직 크면 세밀한 구분자로 재귀
                chunks.extend(recursive_split(part, max_tokens, seps[1:]))
                buf = ""
            else:
                buf = part
    if buf:
        chunks.append(buf)
    return chunks

재귀적 분할은 문단·문장 경계에서 우선적으로 끊으므로 고정 크기보다 경계 절단이 훨씬 적습니다. 대부분의 산문에서 “충분히 좋은” 기본값이며, 시맨틱 분할의 비용을 들이기 전에 먼저 시도해 볼 만합니다.

다만 구조가 빈약한 문서(줄바꿈 없는 긴 텍스트, OCR 결과)에서는 구분자가 제 역할을 못 해 결국 고정 크기와 비슷해집니다. 또한 구조적 경계가 항상 의미적 경계와 일치하지는 않아, 한 문단 안에서 주제가 바뀌면 그 전환을 감지하지 못합니다. 바로 이 지점에서 시맨틱 분할이 등장합니다.

시맨틱 분할: 의미가 바뀌는 지점을 찾기

시맨틱 분할(semantic chunking)은 조각 경계를 글자가 아니라 의미로 정합니다. 문장 단위로 쪼갠 뒤 인접 문장의 임베딩 유사도를 계산하고, 유사도가 급락하는 지점, 즉 주제가 전환되는 경계에서 조각을 끊습니다.

# 시맨틱 분할 — 인접 문장 임베딩의 유사도가 급락하는 지점에서 끊는다
import numpy as np

def semantic_split(sentences, embed, drop_percentile=90):
    vecs = embed(sentences)                        # 각 문장 임베딩 (N, d)
    # 인접 문장 간 코사인 거리(1 - 유사도)
    dists = []
    for i in range(len(vecs) - 1):
        a, b = vecs[i], vecs[i + 1]
        dists.append(1 - np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
    # 거리가 상위 percentile 이상인 곳을 "주제 전환 경계"로 판단
    threshold = np.percentile(dists, drop_percentile)
    boundaries = [i + 1 for i, d in enumerate(dists) if d > threshold] + [len(sentences)]
    chunks, start = [], 0
    for b in boundaries:
        chunks.append(" ".join(sentences[start:b]))
        start = b
    return chunks

강점은 조각이 하나의 일관된 주제를 담아 주제 희석이 줄고 임베딩이 선명해진다는 것입니다. 특히 한 문서에 이질적 주제가 섞여 있을 때 효과가 큽니다. 대신 분할 단계에서 모든 문장을 임베딩해야 해 인덱싱 비용이 늡니다. 임계치(drop_percentile)는 절대값 대신 백분위 기반으로 잡으면 문서마다 스케일이 자동으로 맞아 견고합니다.

임베딩 모델 한계와 조각 크기 정합

어떤 전략을 쓰든 조각 크기는 임베딩 모델의 제약과 맞아야 합니다. 최대 입력 토큰과 실질적으로 의미가 유지되는 유효 컨텍스트 길이를 함께 봐야 합니다. 많은 모델은 최대 입력이 512나 8192여도 한계까지 꽉 채운 텍스트는 임베딩 품질이 떨어집니다.

  • 최대 토큰 초과: 한계를 넘으면 뒷부분이 조용히 잘려 임베딩에 반영되지 않습니다.
  • 너무 짧은 조각: 문장 하나짜리는 문맥이 부족해 임베딩이 모호해지고 인덱스만 불립니다.
  • 스위트스팟: 대체로 256~512토큰 구간이 정밀도와 문맥 보존의 균형점입니다.
# 인덱싱 전 안전장치: 모델 한계 초과 조각을 강제로 로깅·재분할
MODEL_MAX = 512  # 임베딩 모델의 최대 입력 토큰

def enforce_limit(chunks):
    safe = []
    for c in chunks:
        n = len(enc.encode(c))
        if n > MODEL_MAX:                       # 조용한 절단 방지
            print(f"[warn] {n}토큰 조각 재분할 (한계 {MODEL_MAX})")
            safe.extend(recursive_split(c, MODEL_MAX))
        else:
            safe.append(c)
    return safe

실무에서 가장 흔한 버그가 이 조용한 절단입니다. 분할은 문자 수 기준인데 임베딩은 토큰 기준이라, 한글이 많은 조각이 소리 없이 잘려 인덱스에 절반만 들어가곤 합니다. 파이프라인에 한계 강제 검증을 넣어 두는 것이 안전합니다.

부모-자식 청킹: 검색은 작게, 문맥은 크게

정밀도와 문맥이 조각 크기에서 상충하는 딜레마를 우회하는 패턴이 부모-자식 청킹입니다. 검색은 주제가 선명한 작은 자식 조각으로 하되, LLM에 넘기는 컨텍스트는 그것이 속한 큰 부모 조각으로 확장합니다.

# 부모-자식 청킹: 작은 자식으로 검색 → 부모로 확장해 LLM에 전달
def build_parent_child(doc):
    index, parent_store = [], {}   # index: (자식텍스트, 부모ID)
    for pid, parent in enumerate(recursive_split(doc, 1024)):  # 큰 부모
        parent_store[pid] = parent
        for child in recursive_split(parent, 256):            # 정밀한 자식
            index.append((child, pid))                        # 자식만 임베딩
    return index, parent_store        # 검색 후 pid 집합으로 부모 원문 반환

히트한 자식의 부모 ID를 집합으로 중복 제거해 부모 원문을 반환하면, 같은 부모에 여러 자식이 걸려도 컨텍스트가 부풀지 않습니다. 자식은 시맨틱, 부모는 재귀 분할로 두는 조합이 견고합니다.

전략 선택과 검증: 무엇을 언제 쓰는가

상황별 기본 매핑은 다음과 같습니다.

  • 산문·긴 문서: 재귀적 분할(256~512토큰) + 오버랩 10~20%. 무난한 기본값.
  • 이질적 주제 혼재: 시맨틱 분할로 주제 희석 억제(인덱싱 비용은 감수).
  • 정밀도·문맥 동시: 부모-자식 청킹.
  • 표·코드·마크다운: 구조 인식 분할 + 헤딩 머리말.

어떤 전략도 감으로 고르지 말아야 합니다. 청킹은 측정 가능한 하이퍼파라미터입니다. 대표 질문 세트로 조각 크기·오버랩·전략을 바꿔 가며 아래처럼 리콜을 비교하면 선택이 정량화됩니다.

# 청킹 전략 평가: 정답 텍스트가 top_k 조각 안에 담겼는가(리콜)
def eval_recall(qa_pairs, chunker, embed, top_k=4):
    hit = 0
    for question, answer_span in qa_pairs:
        chunks = chunker(load_doc(question))
        retrieved = search(embed(question), embed_all(chunks), top_k)
        if any(answer_span in c for c in retrieved):  # 정답 구절 포함
            hit += 1
    return hit / len(qa_pairs)   # 전략별로 이 값을 비교해 선택

마무리

청킹은 RAG에서 가장 과소평가되지만 가장 큰 레버입니다. 원칙은 세 가지입니다. 첫째, 경계는 토큰 수가 아니라 의미 단위에 맞춥니다. 재귀적 분할이 값싼 기본값이고 시맨틱 분할이 정밀한 상위 버전입니다. 둘째, 오버랩은 절단을 완화하는 보험이니 10~20%면 충분합니다. 셋째, 조각 크기를 임베딩 모델의 토큰 한계와 정합시키고 조용한 절단을 로깅으로 잡아냅니다. 무엇보다 전략은 감이 아니라 대표 질문 세트로 측정해서 골라야 합니다. 재귀적 분할이라는 기본값에서 출발해 필요한 만큼만 시맨틱 분할과 부모-자식 구조를 얹는 것이 가장 안전합니다.