RAG 심화 #2 검색 품질을 좌우하는 청킹 전략

5 분 소요

1편에서 기준선을 만들었습니다. 이제 검색 실패를 고치러 갑니다. 그런데 검색 실패의 뿌리는 검색 알고리즘이 아니라 그 전 단계에 있는 경우가 많습니다. 문서를 어떻게 쪼갰는지, 즉 청킹(chunking, 문서를 검색 단위가 되는 조각으로 나누는 일)입니다. 아무리 좋은 검색도 조각 자체가 나쁘면 좋은 결과를 가져올 수 없습니다.

고정 크기 분할의 한계 #

LLM 앱 개발 실전 8편에서는 300자씩 자르고 50자를 겹치는 고정 크기 분할을 썼습니다. 시작점으로는 충분하지만, 한계가 분명합니다. 문서의 의미 단위와 무관하게 자르기 때문입니다.

예를 들어 환불 정책 문서에서 “환불 수수료” 절의 제목과 수수료율 표가 서로 다른 조각으로 갈라지면, 어느 조각도 혼자서는 질문에 답할 수 없습니다. 검색은 그 어중간한 조각들을 가져오고, 1편의 진단에서 “조각 안에 정답이 없다"로 판정됩니다. 청킹 실패가 검색 실패로 나타나는 전형적인 모습입니다.

구조 기반 청킹 — 문서의 단위를 따라 자르기 #

개선의 기본 방향은 글자 수가 아니라 문서의 구조를 따라 자르는 것입니다. 마크다운이라면 헤딩이, 일반 문서라면 문단이 자연스러운 경계입니다.

structural_chunking.py
import re

def chunk_by_heading(markdown: str, max_chars: int = 1500) -> list:
    """헤딩 단위로 자르되, 너무 큰 섹션만 문단 단위로 다시 나눈다."""
    sections = re.split(r'(?=^#{1,3} )', markdown, flags=re.M)
    chunks = []
    for sec in sections:
        sec = sec.strip()
        if not sec:
            continue
        if len(sec) <= max_chars:
            chunks.append(sec)
        else:
            for para in split_by_paragraph(sec, max_chars):
                chunks.append(para)
    return chunks

핵심은 “섹션 하나가 조각 하나"라는 대응입니다. 제목과 본문과 표가 한 조각에 함께 있으니, 그 조각 하나로 질문에 답할 수 있습니다. 조각 크기는 들쭉날쭉해지지만 괜찮습니다. 균일한 크기보다 온전한 의미가 검색 품질에 훨씬 중요합니다.

크기 상한(max_chars)은 두 가지를 보고 정합니다. 너무 크면 한 조각에 여러 주제가 섞여 임베딩이 흐려지고, 너무 작으면 맥락이 끊깁니다. 문서에서 “질문 하나에 답하는 단위"가 보통 어느 정도 분량인지를 기준으로 잡고, 1편의 골든셋으로 전후를 비교하며 조정합니다.

표와 코드는 통째로 #

고정 크기 분할의 최대 피해자는 표와 코드 블록입니다. 표가 중간에서 잘리면 헤더와 값이 분리되어 양쪽 조각 모두 쓸모가 없어집니다. 규칙은 단순합니다. 표와 코드 블록은 자르지 않고 통째로 한 조각에 둡니다. 구조 기반 청킹에서 문단을 나눌 때, 표와 코드 펜스 안쪽은 분할 경계로 삼지 않으면 됩니다.

표가 너무 커서 상한을 넘는다면, 잘라서 두 조각을 만드는 대신 각 조각에 헤더 행을 복제해 넣습니다. 어느 조각을 가져와도 열 이름과 값이 함께 있게 하는 것입니다.

메타데이터 — 조각에 출신을 적기 #

조각에는 본문 외에 출신 정보를 함께 저장합니다. 어느 문서의 어느 섹션에서 왔는지입니다.

chunk_with_metadata.py
{
    "text": "환불 수수료는 결제 금액의 10%입니다. ...",
    "metadata": {
        "source": "refund-policy.md",
        "section": "환불 수수료",
        "updated": "2026-05-01",
    },
}

메타데이터의 쓸모는 세 가지입니다. 첫째, 임베딩할 때 본문 앞에 섹션 경로를 붙이면(“환불 정책 > 환불 수수료: …”) 짧은 조각의 검색 품질이 올라갑니다. 둘째, 검색 단계에서 “최신 문서만”, “인사 규정만” 같은 조건으로 필터링할 수 있습니다. 셋째, 답변에 출처를 표시할 때 그대로 씁니다. 5편의 인용에서 다시 다룹니다.

부모-자식 청킹 — 작게 찾고 크게 넣기 #

검색과 생성은 조각 크기에 대한 요구가 서로 다릅니다. 검색은 주제가 하나로 또렷한 작은 조각에서 정확하고, 생성은 앞뒤 맥락이 담긴 큰 조각을 받을 때 좋은 답을 만듭니다. 이 긴장을 푸는 방법이 부모-자식 청킹입니다.

  • 문서를 큰 단위(부모, 섹션 전체)로 나누고, 각 부모를 다시 작은 단위(자식, 문단)로 나눕니다.
  • 임베딩과 검색은 자식으로 하고, 자식이 검색되면 그 부모를 컨텍스트로 넣습니다.
parent_child.py
def search_with_parent(question: str, top_k: int = 5) -> list:
    children = vector_search(question, top_k=top_k)   # 작은 조각으로 검색
    parent_ids = {c.metadata["parent_id"] for c in children}
    return [parents[pid] for pid in parent_ids]        # 큰 조각을 반환

구현 부담이 조금 늘지만, “검색은 맞았는데 조각이 너무 짧아 답을 못 만드는” 유형의 실패에 직접적인 효과가 있습니다. 1편 진단에서 그 패턴이 보였다면 우선순위를 올릴 만합니다.

다시 자르고, 다시 잽니다 #

청킹을 바꾸면 임베딩을 다시 만들어야 하므로 인덱스 전체를 다시 빌드합니다. 그리고 반드시 1편의 골든셋으로 전후를 비교합니다. 청킹 변경은 효과가 큰 만큼 방향도 양쪽입니다. 어떤 질문군은 좋아지고 다른 질문군은 나빠질 수 있어서, 숫자 없이 바꾸면 좋아졌다는 착각만 남습니다.

흔히 걸려 넘어지는 곳 #

  • 균일한 크기에 집착한다 — 깔끔하게 N자씩 잘린 조각은 보기에는 좋지만 의미가 끊겨 있습니다. 들쭉날쭉해도 의미 단위가 우선입니다.
  • 제목을 버린다 — 문단만 잘라 담으면 “그것”, “이 정책” 같은 대명사의 대상이 사라집니다. 섹션 제목을 조각에 포함하거나 메타데이터로 앞에 붙입니다.
  • 인덱스 재빌드를 미룬다 — 청킹 코드만 바꾸고 기존 인덱스로 테스트하면 아무것도 달라지지 않습니다. 청킹 변경은 항상 재인덱싱과 한 묶음입니다.

마무리 #

이번 글에서는 검색 품질의 토대인 청킹을 다뤘습니다.

  • 고정 크기 분할은 의미 단위를 끊습니다. 헤딩과 문단 같은 문서 구조를 따라 자르고, 표와 코드는 통째로 둡니다.
  • 조각에는 출처와 섹션 같은 메타데이터를 붙여 임베딩 보강, 필터, 출처 표시에 씁니다.
  • 검색은 작은 조각, 생성은 큰 조각이 유리합니다. 부모-자식 청킹이 둘을 같이 잡습니다.

조각이 좋아졌으니 다음은 찾는 방법입니다. 의미 검색만으로는 제품 코드나 고유 명사 질문을 놓칩니다. 다음 글인 “RAG 심화 #3 하이브리드 검색 — 벡터와 키워드 결합"에서 두 검색을 합칩니다.

X