실무 Multi-Agent 오케스트레이션 12장 · 문서 로딩과 청킹 6 / 8 ← 이전목차다음 → TechLead Cro

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() 입니다.
← 이전 절메타데이터 — 조각에 출처를 적어 둔다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com