12장. 문서 로딩과 청킹
따라하기 — 정책 문서 로딩과 청킹
출력 안내: 실행 코드는 답변·도구 결과·청크 본문을 글자 수로 자르지 않고 출력합니다. 아래의 기존 실행 예시는 일부 축약된 기록이며, 실제 실행 화면에서 전체 내용을 확인하세요.
목표
data/ 폴더의 정책 PDF 두 개에서 글을 꺼내고, 세 가지 크기로 잘라 비교한 뒤, 500자 청크 열두 개에 출처 메타데이터를 붙입니다. 이 파일은 LLM을 호출하지 않습니다.
0. 실습 준비
VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.
$ conda activate myenv
이번 장은 새로 설치할 것이 없습니다. pypdf와 langchain-text-splitters는 2장에서 requirements.txt로 함께 설치했습니다.
data 폴더에 PDF 두 개가 있는지 확인합니다.
data/
├── 하루마켓_멤버십정책.pdf
└── 하루마켓_반품교환환불정책.pdf
VS Code에서 PDF를 한 번 열어 눈으로 훑어보세요. 조항이 어떻게 나뉘어 있는지, 표가 어디에 있는지 알고 시작하면 결과를 읽기 쉽습니다.
1. 파일 만들기
haru-market 폴더 맨 위에 새 파일을 만듭니다.
lesson12_rag_chunking.py
아래 코드 전체를 복사해 붙여 넣고 저장합니다.
# -*- coding: utf-8 -*-
"""[12장] RAG 1 — 정책 문서 로딩과 청킹
문제 상황: "단순 변심인데 반품 배송비 얼마예요?"
정책은 data/하루마켓_반품교환환불정책.pdf 에만 있다.
- 매번 PDF 전체를 프롬프트에 넣으면? 토큰 낭비 + 문서가 커지면 불가능.
- 그래서 문서를 '검색 가능한 조각(청크)'으로 잘라 두고,
질문과 관련된 조각만 찾아 프롬프트에 넣는다 = RAG.
오늘은 파이프라인의 앞부분: PDF 로딩 → 청킹. (LLM 호출 없음 — API 키 불필요)
실행: python lesson12_rag_chunking.py
"""
import re
from langchain_text_splitters import RecursiveCharacterTextSplitter
from pypdf import PdfReader
from config import DATA_DIR
# 정책 문서는 앞으로 늘어난다(반품·교환·환불, 멤버십, 배송…).
# 특정 파일이 아니라 data/ 의 PDF 전부를 지식으로 삼는다.
PDF_PATHS = sorted(DATA_DIR.glob("*.pdf"))
SEPARATORS = ["\n\n", "\n", ". ", " ", ""] # 문단 → 줄 → 문장 순으로 자르기 시도
# 조항 제목 줄: "제N조 (…)" 와, 조항 번호가 없는 "자주 묻는 질문", "부칙"
HEADING = re.compile(r"^(?:제\d+조 |자주 묻는 질문|부칙).*$", re.MULTILINE)
# ── 1. PDF 로딩 (문서별로 출처를 기억한다) ────────────────────────────
def load_pdf_texts() -> list[tuple[str, int, str]]:
"""data/ 의 PDF 를 모두 읽어 (파일명, 쪽수, 전체 텍스트) 목록으로 돌려준다."""
docs_text = []
for pdf_path in PDF_PATHS:
reader = PdfReader(pdf_path)
text = "\n".join(page.extract_text() for page in reader.pages)
docs_text.append((pdf_path.name, len(reader.pages), text))
return docs_text
# ── 2. 메타데이터 — 청크에 내용이 담긴 조항을 모두 찾는다 ─────────────
def find_articles(text: str, chunk: str) -> str:
"""청크에 등장하는 조항 제목을 모두 모아 ', ' 로 이은 문자열로 돌려준다."""
names = HEADING.findall(chunk) # 청크 안에 있는 조항 제목 전부
# 청크가 조항 제목으로 시작하지 않으면, 앞머리는 직전 조항에서 이어진 내용이다.
# 원문에서 이 청크보다 앞에 나온 마지막 조항 제목을 물려받는다.
before = HEADING.findall(text[:text.find(chunk)])
if before and not HEADING.match(chunk):
names.insert(0, before[-1])
# 청크가 조항 제목 줄에서 끝나면 그 조항은 제목만 있고 내용이 없다 → 뺀다.
if names and chunk.endswith(names[-1]):
names.pop()
return ", ".join(dict.fromkeys(names)) # 중복 제거 (순서 유지)
# ── 3. 수업 표준 청킹 + 메타데이터 부여 ──────────────────────────────
# 메타데이터(출처 문서·조항)는 14장 '출처 표기'의 재료가 된다.
# 문서별로 따로 청킹한다 — 두 정책이 한 청크에 섞이면 안 된다.
def load_policy_chunks():
"""정책 PDF → 청크 목록. 13장(인덱싱)에서 import 해서 사용."""
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100,
separators=SEPARATORS)
documents = []
for source_name, _, text in load_pdf_texts():
# 파일명에서 문서 제목 추출: 하루마켓_멤버십정책.pdf → 멤버십정책
doc_title = source_name.replace("하루마켓_", "").replace(".pdf", "")
for chunk in splitter.split_text(text):
i = len(documents)
documents.append({
"id": f"policy-{i:03d}",
"text": chunk,
"metadata": {"source": source_name, "doc_title": doc_title,
"chunk_no": i, "article": find_articles(text, chunk)},
})
return documents
# 로딩·실험·샘플 출력은 이 파일을 직접 실행할 때만 한다.
# (13·15장이 load_policy_chunks 를 import 할 때는 아무것도 출력하지 않는다)
if __name__ == "__main__":
docs_text = load_pdf_texts()
for source_name, pages, text in docs_text:
print(f"■ 로딩: {source_name} — {pages}쪽, {len(text):,}자")
full_text = "\n".join(text for _, _, text in docs_text) # 청킹 실험용 합본
# ── 청킹 실험 — 크기에 따라 무엇이 달라지는가 ─────────────────────
# 너무 작으면: 문맥이 잘려 조항 제목 없이 내용만 있는 조각이 생긴다
# 너무 크면: 관련 없는 조항까지 딸려와 검색 정밀도가 떨어지고 토큰 낭비
for size, overlap in [(200, 0), (500, 100), (1500, 200)]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=size,
chunk_overlap=overlap, # 겹침: 조각 경계에서 문맥이 끊기는 것을 완화
separators=SEPARATORS,
)
chunks = splitter.split_text(full_text)
lengths = [len(c) for c in chunks]
# 청크마다 "제N조" 제목 줄이 몇 개 들어 있는지 센다
titles = [len(re.findall(r"^제\d+조 ", c, re.MULTILINE)) for c in chunks]
print(f"\n chunk_size={size}, overlap={overlap} → {len(chunks)}개 청크"
f" (평균 {sum(lengths) // len(lengths)}자,"
f" 최소 {min(lengths)}자, 최대 {max(lengths)}자)")
print(f" 조항 제목이 없는 청크 {titles.count(0)}개"
f" · 조항 제목이 3개 이상 든 청크 {sum(n >= 3 for n in titles)}개")
documents = load_policy_chunks()
print(f"\n■ 최종: {len(documents)}개 청크 (chunk_size=500/overlap=100)")
print("\n■ 청크 샘플 (메타데이터 포함)")
for doc in documents[2:4]:
print(f" [{doc['id']}] article={doc['metadata']['article']!r}")
print(f" {doc['text']}\n")
print("""
──────────────────────────────────────────────────────────
청킹 체크리스트
- 표·조항이 중간에 잘리지 않는가? (separators 순서로 조정)
- 청크만 읽어도 무슨 얘긴지 아는가? (overlap 으로 문맥 보강)
- 출처를 답변에 표기할 재료(메타데이터)를 남겼는가?
다음 장: 이 청크들을 '의미로 검색되는' 벡터로 바꾼다 (임베딩 + Chroma)
──────────────────────────────────────────────────────────""")
2. 코드에서 볼 곳
파일의 위쪽은 함수 세 개이고, 아래쪽 if __name__ == "__main__": 부분이 그 함수들을 불러 화면에 찍습니다.
| 함수 | 하는 일 | 돌려주는 것 |
|---|---|---|
load_pdf_texts |
data/의 PDF를 모두 읽어 글을 꺼낸다 |
(파일 이름, 쪽수, 글) 목록 |
find_articles |
청크에 내용이 담긴 조항 제목을 모두 찾는다 | 쉼표로 이은 문자열 |
load_policy_chunks |
문서마다 500자/겹침 100자로 자르고 메타데이터를 붙인다 | 청크 딕셔너리 목록 |
읽을 파일을 이름으로 적지 않고 폴더의 PDF 전부를 가져옵니다.
PDF_PATHS = sorted(DATA_DIR.glob("*.pdf"))
glob("*.pdf")는 "이름이 .pdf로 끝나는 파일 전부"입니다. 정책 문서가 하나 더 생기면 data/에 넣기만 하면 됩니다.
아래쪽 실행 부분은 세 가지를 차례로 합니다.
| 순서 | 하는 일 | 자르는 대상 |
|---|---|---|
| 1 | 문서마다 쪽수와 글자 수를 찍는다 | — |
| 2 | 200자, 500자, 1,500자로 잘라 비교한다 (청킹 실험) | 두 문서를 이어 붙인 합본 |
| 3 | 표준 청크 12개를 만들고 샘플 두 개를 찍는다 | 문서마다 따로 |
청킹 실험은 크기마다 두 줄을 찍습니다.
lengths = [len(c) for c in chunks]
# 청크마다 "제N조" 제목 줄이 몇 개 들어 있는지 센다
titles = [len(re.findall(r"^제\d+조 ", c, re.MULTILINE)) for c in chunks]
첫 줄은 청크 수와 길이(평균, 최소, 최대)입니다. 둘째 줄은 조항 제목이 하나도 없는 청크와 조항 제목이 세 개 이상 든 청크의 수입니다. 앞의 것은 "무엇에 대한 내용인지 알 수 없는 조각", 뒤의 것은 "여러 조항이 섞인 조각"을 뜻합니다.
3. 실행하기
실행하기 전에 짐작해 보세요.
- 두 문서를 합치면 4,412자입니다. 200자로 자르면 청크가 몇 개쯤 나올까요? 4,412 ÷ 200 = 22개보다 많을까요, 적을까요?
- 200자로 잘랐을 때 조항 제목이 없는 청크는 전체의 얼마쯤일까요?
$ python lesson12_rag_chunking.py
LLM을 호출하지 않으므로 바로 끝납니다.
실행 결과
■ 로딩: 하루마켓_멤버십정책.pdf — 2쪽, 1,844자
■ 로딩: 하루마켓_반품교환환불정책.pdf — 3쪽, 2,567자
chunk_size=200, overlap=0 → 26개 청크 (평균 168자, 최소 62자, 최대 199자)
조항 제목이 없는 청크 13개 · 조항 제목이 3개 이상 든 청크 0개
chunk_size=500, overlap=100 → 12개 청크 (평균 441자, 최소 183자, 최대 497자)
조항 제목이 없는 청크 2개 · 조항 제목이 3개 이상 든 청크 3개
chunk_size=1500, overlap=200 → 4개 청크 (평균 1221자, 최소 488자, 최대 1483자)
조항 제목이 없는 청크 1개 · 조항 제목이 3개 이상 든 청크 3개
■ 최종: 12개 청크 (chunk_size=500/overlap=100)
■ 청크 샘플 (메타데이터 포함)
[policy-002] article='제3조 (하루포인트 적립), 제4조 (하루포인트 사용), 제5조 (하루포인트 유효기간과 소멸)'
제3조 (하루포인트 적립)
① 하루포인트는 구매확정 다음 날 등급별 적립률에 따라 자동 적립된다. ② 적립금·쿠폰으로 결제한 금액에는 적립되지 않는
다. ③ 리뷰 작성 시 일반 리...
[policy-003] article='제5조 (하루포인트 유효기간과 소멸), 제6조 (등급 조정과 제외), 자주 묻는 질문 (FAQ)'
내한다. ③ 유효기간이 지나 소멸된 포인트는 복구되지 않는다. 단, 회사 귀책 사유로 소멸된 경우는 예외로 한다.
제6조 (등급 조정과 제외)
① 반품·취소로 실결제액이 산정 기준...
(이하 생략)
이 파일은 글을 읽고 자르기만 하므로 누가 실행해도 같은 결과가 나옵니다.
| 이렇게 나오면 | 원인과 조치 |
|---|---|
■ 로딩: 줄이 하나도 없고 ZeroDivisionError가 난다 |
data 폴더에 PDF가 없습니다. 폴더 위치와 파일을 확인합니다 |
ModuleNotFoundError: No module named 'pypdf' |
(myenv)가 꺼져 있거나 라이브러리를 설치하지 않았습니다. conda activate myenv 후 pip install -r requirements.txt |
4. 무엇을 관찰했나
로딩 — 쪽수와 글자 수가 상식에 맞는가
두 쪽에서 1,844자, 세 쪽에서 2,567자가 나왔습니다. 한 쪽에 800~900자쯤입니다. 표가 섞인 문서로는 자연스러운 숫자입니다.
청크 수 — 나눗셈보다 많이 나온다
| 설정 | 단순 나눗셈 | 실제 | 평균 길이 |
|---|---|---|---|
| 200자, 겹침 0 | 4,412 ÷ 200 ≈ 22개 | 26개 | 168자 |
| 500자, 겹침 100 | 4,412 ÷ 500 ≈ 9개 | 12개 | 441자 |
| 1,500자, 겹침 200 | 4,412 ÷ 1,500 ≈ 3개 | 4개 | 1,221자 |
chunk_size는 목표가 아니라 상한입니다. 분할기는 줄바꿈에서만 자르므로, 상한을 넘기지 않는 선에서 줄 단위로 끊습니다. 그래서 평균 길이는 상한보다 짧고 개수는 나눗셈보다 많습니다. 겹침이 있으면 같은 내용이 두 번 들어가므로 더 늘어납니다.
크기에 따라 조각의 속이 달라진다
둘째 줄의 숫자가 「청킹」 절에서 말한 "작아도 탈, 커도 탈"을 보여 줍니다.
| 설정 | 조항 제목이 없는 청크 | 조항 제목이 3개 이상 든 청크 |
|---|---|---|
| 200자 | 26개 중 13개 | 0개 |
| 500자 | 12개 중 2개 | 12개 중 3개 |
| 1,500자 | 4개 중 1개 | 4개 중 3개 |
- 200자에서는 청크의 절반에 조항 제목이 없습니다. 그 조각만 검색되면 무엇에 대한 내용인지 알 수 없습니다.
- 1,500자에서는 네 청크 가운데 셋에 조항이 세 개 이상 섞입니다. 질문과 상관없는 조항이 함께 딸려 옵니다.
- 500자는 그 사이입니다. 제목이 없는 청크는 둘뿐이고(둘 다 자주 묻는 질문과 부칙 부분입니다), 조항이 세 개 이상 든 청크는 셋입니다.
500자가 정답이어서가 아니라, 두 문제가 모두 작은 크기여서 골랐습니다.
청크 샘플 — 겹침의 흔적과 조항 꼬리표
policy-003은 "내한다."로 시작합니다. 문장의 한가운데입니다. 앞 청크 policy-002의 끝부분이 겹침으로 다시 들어온 것이고, 내용은 제5조의 마지막 문장입니다.
article을 봅니다. policy-003에는 "제5조"라는 제목 줄이 없는데도 꼬리표 맨 앞에 제5조가 있습니다. 직전 조항을 물려받았기 때문입니다. 그 뒤로 본문에 제목이 나오는 제6조와 자주 묻는 질문이 이어집니다.
열두 개 청크의 article입니다(조항 제목의 괄호 부분은 줄였습니다).
| 청크 | 문서 | article |
|---|---|---|
| policy-000 | 멤버십정책 | 제1조 |
| policy-001 | 멤버십정책 | 제1조, 제2조, 제3조 |
| policy-002 | 멤버십정책 | 제3조, 제4조, 제5조 |
| policy-003 | 멤버십정책 | 제5조, 제6조, 자주 묻는 질문 |
| policy-004 | 멤버십정책 | 자주 묻는 질문, 부칙 |
| policy-005 | 반품교환환불정책 | 제1조, 제2조 |
| policy-006 | 반품교환환불정책 | 제1조, 제2조, 제3조, 제4조 |
| policy-007 | 반품교환환불정책 | 제3조, 제4조, 제5조 |
| policy-008 | 반품교환환불정책 | 제5조, 제6조, 제7조 |
| policy-009 | 반품교환환불정책 | 제7조, 제8조, 제9조 |
| policy-010 | 반품교환환불정책 | 제8조, 제9조, 자주 묻는 질문 |
| policy-011 | 반품교환환불정책 | 자주 묻는 질문, 부칙 |
세 가지가 보입니다.
- 빈 값인 청크가 없습니다. 조항 번호가 없는 자주 묻는 질문과 부칙에도 이름이 붙었습니다.
- 모든 조항이 어딘가에 나타납니다. 두 문서의 조항 15개와 자주 묻는 질문, 부칙이 모두 최소 한 청크의
article에 들어 있습니다. - 같은 조항이 이웃한 두 청크에 나타납니다. 제5조는
policy-002와policy-003에 모두 있습니다. 조항이 두 청크에 걸쳐 있기 때문입니다.
꼬리표에 조항이 여럿이므로, 답변에 근거를 적을 때는 모델이 본문을 읽고 그중 어느 조항인지 골라 적습니다. 14장에서 확인합니다.
문서는 섞이지 않았다
policy-004까지가 멤버십정책, policy-005부터가 반품교환환불정책입니다. 문서마다 따로 잘랐으므로 두 문서가 한 청크에 섞인 경우는 없습니다.
이번 장에서 누가 무엇을 했는가
이번 장에는 모델이 한 일이 없습니다. 크기를 500자로, 겹침을 100자로, 자를 자리의 우선순위를, 조항 제목을 찾는 규칙을 전부 사람이 정했습니다. 그 결정이 좋은지는 15장에서 검색 결과로 재어 봅니다.
5. 지금 폴더의 모습
haru-market/
├── config.py
├── haru_tools.py
├── (lesson02 ~ lesson10 파일)
├── lesson11_langchain_agent.py
├── lesson12_rag_chunking.py ← 이번 장
├── data/
│ ├── 하루마켓_멤버십정책.pdf
│ ├── 하루마켓_반품교환환불정책.pdf
│ └── (CSV 파일)
└── memory_store/
핵심 정리
- PDF 두 개에서 1,844자와 2,567자를 꺼냈습니다.
- 같은 글이 크기에 따라 26개, 12개, 4개로 잘렸습니다.
chunk_size는 상한이라 나눗셈보다 많이 나옵니다. - 200자에서는 청크의 절반에 조항 제목이 없고, 1,500자에서는 넷 중 셋에 조항이 세 개 이상 섞였습니다.
- 표준 청킹은 500자/겹침 100자, 문서마다 따로이고 결과는 12개입니다.
article에는 청크에 담긴 조항이 모두 적혀 있습니다. 빈 값인 청크는 없습니다.- 이번 장의 산출물은
load_policy_chunks()입니다.