RAG 기초 (2) - Chunking: 문서를 검색 단위로 나누기
문서를 chunk로 나누는 이유와 고정 길이 분할, 구분자 기반 분할, overlap의 효과를 직접 비교하고 메타데이터를 붙이는 방법을 다룹니다.
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편). 색인 스크립트를 언제든 다시 돌릴 수 있게 짜두는 편이 좋습니다.