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

12장. 문서 로딩과 청킹

실습문제와 해답

잘라 낸 조각의 내용을 직접 살펴봅니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다.

세 문제 모두 lesson12_rag_chunking.py의 함수를 가져다 씁니다. 그 파일은 화면 출력을 if __name__ == "__main__": 아래에 두었으므로, 가져와도 「따라하기」의 출력이 다시 찍히지 않습니다.


문제 1 — 200자 청크를 열어 보기

「따라하기」에서 200자로 자르면 청크의 절반에 조항 제목이 없다는 것을 숫자로 봤습니다. 이번에는 조각을 직접 엽니다. 새 파일 ex12_peek.py를 만들고, 두 문서를 이어 붙인 합본을 200자, 겹침 0으로 잘라 3번, 5번, 11번 청크의 전문을 출력하세요.

  • 힌트: 합본은 "\n".join(text for _, _, text in load_pdf_texts())로 만듭니다. load_pdf_texts()는 (파일 이름, 쪽수, 글)을 돌려줍니다.

각 청크에 대해 답해 보세요. 이 조각만 검색되었다면 모델은 무엇을 모르게 될까요?

해답 보기
from langchain_text_splitters import RecursiveCharacterTextSplitter

from lesson12_rag_chunking import SEPARATORS, load_pdf_texts

# 두 문서를 이어 붙인 합본 (따라하기의 청킹 실험과 같은 글)
full_text = "\n".join(text for _, _, text in load_pdf_texts())

splitter = RecursiveCharacterTextSplitter(
    chunk_size=200, chunk_overlap=0, separators=SEPARATORS)
chunks = splitter.split_text(full_text)

print(f"200자 청크 {len(chunks)}개 중 3, 5, 11번")
for i in [3, 5, 11]:
    print(f"\n--- 청크 {i} ({len(chunks[i])}자) ---")
    print(chunks[i])
$ python ex12_peek.py

실행 결과

200자 청크 26개 중 3, 5, 11번

--- 청크 3 (132자) ---
혜택 일반 실버 골드 VIP
하루포인트 적립률 구매액의 1% 2% 3% 5%
무료배송 쿠폰 - 월 1장 월 2장 월 4장
단순 변심 반품 배송비 면제 - - - 월 1회
생일 쿠폰 5% 5% 10% 15%
전용 상담 라인 - - - 제공

--- 청크 5 (140자) ---
① 하루포인트는 구매확정 다음 날 등급별 적립률에 따라 자동 적립된다. ② 적립금·쿠폰으로 결제한 금액에는 적립되지 않는
다. ③ 리뷰 작성 시 일반 리뷰 100포인트, 포토 리뷰 500포인트가 추가 적립된다(상품당 1회).
제4조 (하루포인트 사용)

--- 청크 11 (166자) ---
v1.2 2026-01-02 포토 리뷰 적립 상향(300→500P)
v1.0 2025-06-01 하루클럽 제도 도입
하루마켓 HARU MARKET CS-POL-2026-007 · 고객경험팀
반품·교환·환불 운영 정책
Return, Exchange & Refund Policy — 하루마켓 고객경험팀

해설 — 세 청크가 200자라는 크기의 세 가지 모습을 보여 줍니다.

청크 3 — 제목 없는 표. 등급별 혜택 표의 내용만 있습니다. "제2조 (등급별 혜택)"과 "[표 2] 등급별 혜택 요약"이라는 제목 두 줄은 바로 앞 청크의 끝에 있습니다. 표의 줄은 읽을 수 있지만 이 조각만으로는 몇 조인지 알 수 없습니다. 「따라하기」에서 센 "조항 제목이 없는 청크 13개" 가운데 하나입니다.

청크 5 — 본문은 제3조, 제목은 제4조. 본문 세 문장은 제3조(하루포인트 적립)의 내용입니다. 제3조라는 제목은 앞 청크에 있고, 이 청크의 끝에는 다음 조항의 제목 "제4조 (하루포인트 사용)"이 걸쳐 있습니다. 「메타데이터」 절의 find_articles가 "제목 없이 시작하면 직전 조항을 물려받고, 제목 줄에서 끝나면 그 조항은 뺀다"는 두 가지를 하는 이유가 바로 이런 조각 때문입니다. 그 두 가지를 적용하면 이 청크의 꼬리표는 본문과 맞는 제3조가 됩니다.

청크 11 — 두 문서가 한 조각에. 앞의 두 줄은 멤버십 정책의 개정 이력, 뒤의 세 줄은 반품 정책의 머리말입니다. 합본을 잘랐기 때문입니다. 표준 청킹에서 문서마다 따로 자르는 이유를 여기서 봅니다.

500자, 겹침 100자로 자른 표준 청크에서는 표 2가 제목과 함께 policy-001에 들어 있습니다. 크기를 키우고 겹침을 준 효과입니다.


문제 2 — 조항 단위로 잘라 비교하기

우리 문서는 "제N조"로 나뉘어 있습니다. 그렇다면 조항이 시작하는 자리에서 자르면 어떨까요. 새 파일 ex12_articles.py를 만들고, 문서마다 줄 맨 앞의 "제N조" 앞에서 잘라 청크를 만든 뒤, 표준 청크(load_policy_chunks())와 개수, 평균·최소·최대 길이를 비교하세요. 가장 긴 조항 단위 청크의 첫 줄과 마지막 줄도 출력합니다.

  • 힌트: re.split(r"(?m)^(?=제\d+조)", text). (?m)은 ^가 각 줄의 맨 앞을 뜻하게 하고, (?=…)는 그 자리를 소비하지 않고 자르는 위치로만 씁니다.

실행하기 전에 짐작해 보세요. 조항 단위로 자르면 모든 청크가 500자보다 짧을까요?

해답 보기
import re

from lesson12_rag_chunking import load_pdf_texts, load_policy_chunks


def report(label: str, texts: list[str]) -> None:
    lengths = [len(t) for t in texts]
    print(f"{label:<12} 청크 {len(texts):>2}개 · 평균 {sum(lengths) // len(lengths):>3}자"
          f" · 최소 {min(lengths):>3}자 · 최대 {max(lengths):>3}자")


# 방식 A: 줄 맨 앞의 "제N조" 앞에서 자른다 (문서별로 따로)
by_article = []
for _, _, text in load_pdf_texts():
    by_article += [p.strip() for p in re.split(r"(?m)^(?=제\d+조)", text) if p.strip()]

report("조항 단위", by_article)
report("재귀적 분할", [d["text"] for d in load_policy_chunks()])

longest = max(by_article, key=len)
print(f"\n가장 긴 조항 단위 청크의 첫 줄: {longest.splitlines()[0]}")
print(f"그 청크의 마지막 줄      : {longest.splitlines()[-1]}")
$ python ex12_articles.py

실행 결과

조항 단위        청크 17개 · 평균 258자 · 최소 125자 · 최대 611자
재귀적 분할       청크 12개 · 평균 432자 · 최소 183자 · 최대 497자

가장 긴 조항 단위 청크의 첫 줄: 제6조 (등급 조정과 제외)
그 청크의 마지막 줄      : v1.0 2025-06-01 하루클럽 제도 도입

해설 — 조항 단위로 자르면 모든 청크가 "제N조 (…)"로 시작하므로 청크 하나가 조항 하나입니다. 청크는 17개로, 조항 15개에 두 문서의 머리말 2개를 더한 수입니다.

두 방식은 서로 다른 것을 얻습니다.

조항 단위 재귀적 분할 (이 과정의 표준)
청크와 조항 일대일 한 청크에 조항 두세 개. 꼬리표에 모두 적는다
크기 125~611자. 상한이 없다 183~497자. 상한을 지킨다
조항 번호가 없는 부분 마지막 조항에 붙는다 따로 이름이 붙는다 (자주 묻는 질문, 부칙)
다른 문서에 "제N조" 형식이어야 쓸 수 있다 어떤 글에나 쓸 수 있다

가장 긴 조항 단위 청크는 멤버십 정책의 제6조로 시작해 개정 이력의 마지막 줄로 끝납니다. 제6조 뒤에 오는 "자주 묻는 질문"과 "부칙"에는 "제N조"가 없어서 제6조에 이어 붙었습니다.

이 과정은 어떤 문서에나 쓸 수 있는 재귀적 분할을 표준으로 삼고, 조항은 꼬리표로 챙깁니다. 두 방법을 합쳐 조항에서 먼저 자르고 너무 긴 것만 다시 나누는 방법도 있습니다. 어느 방법이 더 잘 찾는지는 짐작이 아니라 검색 결과로 재어서 정합니다(15장).


문제 3 — 통째로 넣으면 몇 토큰인가

"문서를 통째로 프롬프트에 넣으면 안 되나"라는 물음을 숫자로 확인합니다. 새 파일 ex12_tokens.py를 만들고 다음을 출력하세요. 이 문제는 토큰 수를 세기 위해 API를 호출하므로 API 키가 필요합니다.

  • 두 정책 문서 전문(문제 1의 합본)의 글자 수와 토큰 수
  • 표준 청크 12개의 토큰 수 가운데 최소, 최대, 평균
  • 가장 큰 청크 3개를 합친 토큰 수와, 그것이 전문의 몇 %인지

  • 힌트: 토큰 수는 client.models.count_tokens(model=MODEL, contents=글).total_tokens로 셉니다. 답을 생성하지 않고 세기만 합니다.

해답 보기
from config import MODEL, get_client
from lesson12_rag_chunking import load_pdf_texts, load_policy_chunks

client = get_client()
full_text = "\n".join(text for _, _, text in load_pdf_texts())
documents = load_policy_chunks()


def count(text: str) -> int:
    return client.models.count_tokens(model=MODEL, contents=text).total_tokens


whole = count(full_text)
print(f"정책 2종 전문: {len(full_text):,}자 = {whole:,}토큰")

sizes = [count(d["text"]) for d in documents]
print(f"청크 12개    : 최소 {min(sizes)} · 최대 {max(sizes)} · 평균 {sum(sizes) // len(sizes)}토큰")

top3 = sum(sorted(sizes, reverse=True)[:3])
print(f"가장 큰 청크 3개를 넣어도 {top3:,}토큰 — 전문의 {top3 / whole:.0%}")
$ python ex12_tokens.py

실행 결과

정책 2종 전문: 4,412자 = 2,889토큰
청크 12개    : 최소 135 · 최대 336 · 평균 284토큰
가장 큰 청크 3개를 넣어도 989토큰 — 전문의 34%

해설 — 전문은 2,889토큰입니다. 통째로 넣으면 고객이 무엇을 묻든 매 호출에 이만큼이 더 갑니다. 10장에서 본 정상 문의 한 건의 누적이 1,894토큰이었습니다. 그 문의 전체보다 큰 덩어리가 호출마다 붙는 셈입니다.

질문과 관련된 청크 세 개만 넣으면 많아야 989토큰, 전문의 3분의 1입니다. 문서가 두 개뿐인 지금도 차이가 이만큼이고, 문서가 늘면 통째로 넣는 쪽은 그만큼 불어나지만 청크 세 개를 넣는 쪽은 그대로입니다.

한글 500자 청크는 대략 300토큰 안팎이라는 것도 알았습니다. 4,412자가 2,889토큰이니 글자당 0.65토큰쯤입니다. chunk_size는 글자 수로 정하지만 비용은 토큰으로 나가므로, 이 환산을 알아 두면 "청크 몇 개를 넣을 것인가"를 정할 때 쓸 수 있습니다.

← 이전 절정리와 체크리스트13장 →왜 임베딩이 필요한가 — 청크는 있는데, 찾을 방법이 없다
오명운 · macro@prag-ai.com