포스트

RAG 기초 (2) - Chunking: 문서를 검색 단위로 나누기

문서를 chunk로 나누는 이유와 고정 길이 분할, 구분자 기반 분할, overlap의 효과를 직접 비교하고 메타데이터를 붙이는 방법을 다룹니다.

RAG 기초 (2) - Chunking: 문서를 검색 단위로 나누기

RAG 기초 시리즈의 2편입니다. 전체 목차는 0편에 있습니다.

왜 자르나

문서를 통째로 검색 단위로 쓰면 두 가지가 어긋납니다. 질문이 “제주도 배송비”인데 배송 정책 문서 전체가 딸려 오면 출고 시각과 송장 조회까지 프롬프트에 들어갑니다. 그리고 문서 하나가 수만 자면 검색의 의미가 사라집니다. 문서 세 개 중 하나를 고르는 것은 검색이 아니라 분류입니다.

그래서 문서를 몇백 자 단위 조각으로 자릅니다. 이 조각을 chunk라고 부릅니다. chunk는 embedding의 단위이자 검색의 단위이고, 프롬프트에 들어가는 단위입니다.

가장 단순한 방법

글자 수로 자릅니다.

1
2
3
4
5
6
7
8
9
10
11
from pathlib import Path

def split_fixed(text: str, size: int = 300) -> list[str]:
    return [text[i:i + size] for i in range(0, len(text), size)]

doc = Path("docs/shipping.md").read_text(encoding="utf-8")
chunks = split_fixed(doc, 300)

for i, c in enumerate(chunks):
    print(f"--- chunk {i} ({len(c)}자) ---")
    print(c)

출력을 보면 문제가 바로 보입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
--- chunk 0 (300자) ---
# 배송 정책

## 배송비
기본 배송비는 3,000원입니다.
주문 금액이 30,000원 이상이면 배송비가 무료입니다.
제주 및 도서산간 지역은 3,000원이 추가됩니다.

## 출고와 도착
평일 오후 2시 이전에 결제된 주문은 당일 출고됩니다.
출고 후 영업일 기준 2일에서 3일 이내에 도착합니다.
주말과 공휴일에는 출고하지 않습
--- chunk 1 (78자) ---
니다.

## 송장 조회
송장번호는 출고 다음 날 오전부터...

“출고하지 않습” 에서 잘렸습니다. 문장 중간, 심지어 단어 중간에서 끊기면 그 chunk의 embedding은 의미를 제대로 담지 못합니다.

구분자를 기준으로 자르기

글자 수 대신 문단과 문장 경계를 먼저 존중합니다. 큰 구분자부터 시도하고, 조각이 여전히 크면 더 작은 구분자로 내려갑니다.

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
def split_text(text: str, size: int = 400, overlap: int = 50) -> list[str]:
    """문단 -> 줄 -> 문장 순으로 경계를 지키며 자른다."""
    parts, buf = [], ""
    for para in text.split("\n\n"):          # ① 문단 단위로 먼저 나눈다
        para = para.strip()
        if not para:
            continue
        if len(buf) + len(para) + 2 <= size:  # ② 상한에 여유가 있으면 이어 붙인다
            buf = f"{buf}\n\n{para}" if buf else para
        else:
            if buf:
                parts.append(buf)
            if len(para) <= size:
                buf = para
            else:                             # ③ 문단 하나가 상한을 넘으면 문장으로 쪼갠다
                sentences, cur = para.split("\n"), ""
                for s in sentences:
                    if len(cur) + len(s) + 1 <= size:
                        cur = f"{cur}\n{s}" if cur else s
                    else:
                        parts.append(cur)
                        cur = s
                buf = cur
    if buf:
        parts.append(buf)

    if overlap <= 0:                          # ④ 앞 chunk의 꼬리를 다음 chunk 앞에 덧댄다
        return parts
    merged = [parts[0]]
    for prev, cur in zip(parts, parts[1:]):
        merged.append(prev[-overlap:] + "\n" + cur)
    return merged
  • ① 문단 경계(\n\n)를 최우선으로 지킵니다. 정책 문서에서는 문단이 곧 의미 단위입니다
  • ② 짧은 문단은 상한에 닿을 때까지 합칩니다. chunk가 너무 잘게 쪼개지면 문맥이 사라집니다
  • ③ 문단 하나가 상한보다 크면 줄 단위로 내려가 자릅니다
  • ④ overlap은 앞 chunk의 마지막 몇 글자를 다음 chunk 앞에 붙이는 것입니다

overlap이 필요한 이유는 경계에 걸친 정보 때문입니다. “골드 등급부터는” 이 앞 chunk 끝에 있고 “배송비가 무료입니다” 가 다음 chunk 앞에 있으면 어느 쪽도 질문에 답하지 못합니다. 앞부분을 조금 겹쳐두면 뒤 chunk만으로도 문맥이 통합니다.

크기를 바꿔가며 비교하기

정답은 없고 문서에 따라 다릅니다. 실제로 잘라보고 정합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pathlib import Path

docs = {p.name: p.read_text(encoding="utf-8") for p in Path("docs").glob("*.md")}
full = "\n\n".join(docs.values())

for size in (200, 400, 800):
    chunks = split_text(full, size=size, overlap=50)
    lengths = [len(c) for c in chunks]
    print(f"size={size}: chunk {len(chunks)}개, 평균 {sum(lengths)//len(lengths)}자, "
          f"최소 {min(lengths)}자, 최대 {max(lengths)}")

# size=200: chunk 12개, 평균 178자, 최소 96자, 최대 249자
# size=400: chunk 7개, 평균 281자, 최소 142자, 최대 448자
# size=800: chunk 4개, 평균 447자, 최소 210자, 최대 802자
chunk 크기장점단점
작다 (200자 내외)검색이 정밀하고 관련 없는 내용이 덜 딸려옵니다문맥이 끊겨 답에 필요한 정보가 조각나 있습니다
크다 (800자 이상)문맥이 온전합니다한 chunk에 여러 주제가 섞여 검색 정확도가 떨어집니다

정책 문서나 FAQ처럼 항목이 짧고 독립적인 문서는 작은 편이 낫고, 서술형 매뉴얼이나 논문은 큰 편이 낫습니다. 이 시리즈에서는 400자에 overlap 50으로 진행합니다. 이 선택이 실제로 나은지는 7편에서 hit rate로 확인합니다.

메타데이터를 함께 붙인다

chunk를 문자열로만 다루면 나중에 출처를 표시할 수 없습니다. 자를 때 어디서 왔는지 같이 기록합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def build_chunks(doc_dir: str = "docs") -> list[dict]:
    result = []
    for path in sorted(Path(doc_dir).glob("*.md")):
        text = path.read_text(encoding="utf-8")
        title = text.splitlines()[0].lstrip("# ").strip()
        for i, chunk in enumerate(split_text(text, size=400, overlap=50)):
            result.append({
                "id": f"{path.stem}-{i}",
                "text": chunk,
                "source": path.name,
                "title": title,
            })
    return result

chunks = build_chunks()
print(len(chunks), chunks[0]["id"], chunks[0]["source"])
# 7 membership-0 membership.md

메타데이터는 세 곳에서 쓰입니다. 4편에서 특정 문서로 검색 범위를 좁힐 때, 5편에서 답변에 출처를 표시할 때, 7편에서 검색 결과가 정답 문서에서 왔는지 채점할 때입니다.

함정

제목이 chunk에서 떨어집니다. 위 예제에서 ## 환불 처리 기간 이라는 제목과 그 아래 본문이 다른 chunk로 갈라지면, 본문 chunk만 봐서는 무슨 이야기인지 알 수 없습니다. 실무에서는 각 chunk 앞에 문서 제목과 상위 제목을 덧붙이는 방식을 씁니다.

1
enriched = f"[{title} > {section}]\n{chunk}"

표와 코드가 잘립니다. 마크다운 표나 코드 블록이 중간에서 끊기면 의미를 잃습니다. 문서에 표가 많다면 표 단위로 자르는 별도 처리가 필요합니다.

한 번 정한 chunk는 바꾸기 번거롭습니다. chunk를 다시 나누면 embedding을 전부 다시 만들어야 합니다(4편). 색인 스크립트를 언제든 다시 돌릴 수 있게 짜두는 편이 좋습니다.

다음 글: RAG 기초 (3) - Embeddings: 의미를 벡터로 바꾸기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.