LangChain 챗봇6장 · 문서 기반 챗봇15 / 21← 이전목차다음 →MetaContents

6장. 문서 기반 챗봇

실습 2 — 청킹 견주기와 조항 단위로 나누기

실습 1의 약관을 RecursiveCharacterTextSplitter 로 크기를 바꿔 가며 나누고, 조각 수와 평균 길이, 머리글이 섞인 조각, 조항이 둘 이상 섞인 조각을 표와 그래프로 견줍니다. 그다음 머리글을 지우고 "제N조" 앞에서 잘라, 조항 하나가 조각 하나가 되도록 나눠 out_ch06_chunks.csv 로 저장합니다. 이 파일은 실습 3, 4, 5와 프로젝트가 씁니다. 모델은 부르지 않습니다.

1단계. 크기 200 으로 나눠 보기

Python
import re
import warnings

warnings.filterwarnings("ignore", category=DeprecationWarning)

import matplotlib.pyplot as plt
import pandas as pd
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 그래프의 한글 글꼴과 마이너스 기호
plt.rcParams["font.family"] = "Malgun Gothic"
plt.rcParams["axes.unicode_minus"] = False

pages = PyPDFLoader("travel_terms.pdf").load()
HEADER = "구름다리여행 여행 약관 (연습용 가상 문서)"    # 실습 1에서 찾은 머리글

splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,      # 조각 하나의 최대 글자 수
    chunk_overlap=30,    # 이웃 조각과 겹칠 최대 글자 수
)
# split_documents: Document 목록을 조각 Document 목록으로. 쪽의 metadata 를 조각마다 물려준다
chunks = splitter.split_documents(pages)
print("쪽 4개 → 조각", len(chunks), "개")
for c in chunks[:3]:
    print(f"[{c.metadata['page'] + 1}쪽, {len(c.page_content)}자]", c.page_content.replace("\n", " ")[:60], "…")
출력
쪽 4개 → 조각 12 개
[1쪽, 171자] 구름다리여행 여행 약관 (연습용 가상 문서) 제1장 총칙과 예약 제1조 목적 이  약관은  구름다리여행(이하 …
[1쪽, 186자] 제2조 예약의 성립 손님이 상품을 고르고 예약금을 낸 때 예약이 성립한다. 예약금은 상품 금액의 10%이며, …
[1쪽, 128자] 전부터의 변경은 취소 후 다시 예약하는 것으로 본다. 제4조 결제 신용카드, 계좌 이체, 여행 포인트로 낼  …

코드가 의미하는 것 split_documents(pages) 는 쪽을 조각으로 나누면서 쪽의 metadata 를 조각마다 물려주므로, 잘게 나눠도 "몇 쪽에서 왔는지"를 잃지 않습니다. 다만 분할기는 글자 수만 보고 자르므로 머리글이 첫 조각에 붙고, 조항이 조각 경계에서 잘립니다. 이 단계는 "아무 손질 없이 나누면 어떤 조각이 생기는가"를 보는 기준선입니다.

2단계. 크기를 바꿔 가며 견주기

Python
def describe(chunks):
    """조각 목록의 조각 수, 평균 길이, 머리글이 든 조각 수, 조항이 둘 이상 섞인 조각 수"""
    return {
        "조각 수": len(chunks),
        "평균 글자 수": round(sum(len(c.page_content) for c in chunks) / len(chunks), 1),
        "머리글 든 조각": sum(HEADER in c.page_content for c in chunks),
        "조항 섞인 조각": sum(len(re.findall(r"제\d+조 ", c.page_content)) >= 2 for c in chunks),
    }


rows = []
for size in [100, 200, 300, 500, 1000]:
    sp = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=30)
    rows.append({"chunk_size": size, **describe(sp.split_documents(pages))})

sizes = pd.DataFrame(rows).set_index("chunk_size")
print(sizes.to_string())
출력
            조각 수  평균 글자 수  머리글 든 조각  조항 섞인 조각
chunk_size
100           28     71.4         4         0
200           12    158.6         4         8
300            8    229.5         4         7
500            5    363.4         4         4
1000           4    454.5         4         4

코드가 의미하는 것 describe 는 조각 목록을 네 가지 숫자로 채점하는 작은 평가표이고, for size in [...] 는 같은 문서에 chunk_size 만 바꿔 그 채점을 되풀이합니다. 조각의 크기는 감으로 정하지 않고 이렇게 몇 가지 값을 견줘 고르는 설정값이라는 뜻입니다. "머리글 든 조각"과 "조항 섞인 조각"은 검색을 흐리는 두 원인을 따로 세는 칸입니다.

3단계. 그래프로 보기

Python
fig, axes = plt.subplots(1, 2, figsize=(10, 3.8))

# 왼쪽: 크기마다 조각 수
sizes["조각 수"].plot.bar(ax=axes[0], rot=0)
axes[0].set_title("chunk_size 와 조각 수")
axes[0].set_xlabel("chunk_size (글자)")
axes[0].set_ylabel("조각 수")

# 오른쪽: 크기마다 "조항 섞인 조각"이 전체에서 차지하는 비율
(sizes["조항 섞인 조각"] / sizes["조각 수"]).plot(ax=axes[1], marker="o")
axes[1].set_title("조항이 둘 이상 섞인 조각의 비율")
axes[1].set_xlabel("chunk_size (글자)")
axes[1].set_ylabel("비율")
axes[1].set_ylim(0, 1.05)

plt.tight_layout()
plt.show()
chunk_size 를 100 에서 1000 까지 바꿀 때의 조각 수 막대그래프와 조항이 섞인 조각의 비율 꺾은선그래프

코드가 의미하는 것 왼쪽 막대는 색인의 크기(조각 수)를, 오른쪽 꺾은선은 조각의 품질(조항이 섞인 비율)을 그립니다. sizes["조항 섞인 조각"] / sizes["조각 수"] 처럼 개수를 비율로 바꾼 것은 조각 수가 다른 크기끼리 공정하게 견주려는 것입니다. 두 그래프를 나란히 두면 "작게 자르면 조각이 많아지고, 크게 자르면 섞인다"는 맞바꿈이 한눈에 보입니다.

4단계. 머리글 지우고 조항 단위로 나누기

Python
from langchain_core.documents import Document

articles = []
for p in pages:
    # 머리글을 지우고, 줄바꿈과 겹친 빈칸을 빈칸 하나로 바꾼다
    text = p.page_content.replace(HEADER, "")
    text = re.sub(r"\s+", " ", text).strip()
    # (?=…): 그 자리 "앞"에서 자르되 "제N조 " 글자는 남긴다(앞을 내다보기, lookahead)
    parts = re.split(r"(?=제\d+조 )", text)
    chapter = parts[0].strip()                      # 첫 토막은 "제1장 총칙과 예약" 같은 장 제목
    for part in parts[1:]:
        number = re.match(r"제\d+조", part).group()      # 조각 맨 앞의 조항 번호
        articles.append(Document(
            page_content=part.strip(),
            metadata={"article": number, "chapter": chapter, "page": p.metadata["page"] + 1},
        ))

print("조항 단위 조각:", len(articles), "개")
print(describe(articles))
for d in articles[4:6]:
    print(d.metadata, "|", d.page_content[:50], "…")

saved = pd.DataFrame([{**d.metadata, "글자 수": len(d.page_content), "내용": d.page_content} for d in articles])
saved.to_csv("out_ch06_chunks.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch06_chunks.csv")
출력
조항 단위 조각: 16 개
{'조각 수': 16, '평균 글자 수': 101.6, '머리글 든 조각': 0, '조항 섞인 조각': 0}
{'article': '제5조', 'chapter': '제2장 취소와 환불', 'page': 2} | 제5조 손님이 취소할 때 출발 10일 전까지 취소하면 낸 돈을 전액 돌려준다. 출발 7일  …
{'article': '제6조', 'chapter': '제2장 취소와 환불', 'page': 2} | 제6조 회사가 취소할 때 모인 손님이 최소 인원에 못 미치면 회사는 출발 7일 전까지 알리 …
저장: out_ch06_chunks.csv

코드가 의미하는 것 replace(HEADER, "") 와 re.sub(r"\s+", " ", text) 는 검색에 방해가 되는 군더더기를 덜어 내는 전처리이고, re.split(r"(?=제\d+조 )", text) 는 분할기 대신 문서의 짜임대로 칼질하는 일입니다. 조각마다 Document(..., metadata={"article": …, "chapter": …, "page": …}) 로 이름표를 달아 두었기 때문에, 이 조각이 검색되면 챗봇이 "제5조(2쪽)"처럼 출처를 붙일 수 있습니다. out_ch06_chunks.csv 로 저장해 두는 것은 색인 단계의 중간 결과를 한 번만 만들고 실습 3부터 다시 쓰려는 것입니다.

이 실습이 뜻하는 것

이 실습은 오픈북 시험을 앞두고 책에 쪽지를 붙이는 방법을 몇 가지 시험해 보는 일과 같습니다. 일정한 분량마다 쪽지를 붙여 보고(1단계), 분량을 바꿔 가며 견주고(2·3단계), 마지막에는 쪽 위의 머리글은 무시하고 조항마다 쪽지를 하나씩 붙입니다(4단계). 여기서 쪽지 하나가 조각, 쪽지 사이의 분량이 chunk_size, 조항마다 붙인 쪽지가 조항 단위 조각입니다. 비유와 다른 점은, 사람은 쪽지를 단원의 경계에 붙이지만 분할기는 글자 수만 보고 빈칸이나 줄바꿈에서 자른다는 것입니다.

단계출력뜻
1. 크기 200조각 12개첫 조각에 머리글이 들어 있고, 셋째 조각은 제3조의 뒷부분("전부터의 변경은…")으로 시작합니다
2. 크기 견주기28·12·8·5·4개크기를 키울수록 조각이 줄고 평균 길이가 늘어납니다. 머리글은 어느 크기에서나 4개 조각에 섞입니다
3. 그래프비율 0 → 0.67 → 0.88 → 0.8 → 1.0크기 100 은 조항이 섞이지 않는 대신 71자 안팎으로 잘게 쪼개고, 200 부터는 조각 대부분에 조항이 둘 이상 섞입니다
4. 조항 단위16개, 머리글 0, 섞임 0조항 하나가 조각 하나가 되어 평균 101.6자이고, 머리글과 섞인 조항이 모두 사라졌습니다

한 줄로 줄이면, 글자 수로만 자르면 "작으면 뜻이 잘리고 크면 규정이 섞인다"는 사이에서 고를 수밖에 없지만, 문서의 짜임(조항)대로 자르면 둘 다 피할 수 있다는 것을 숫자로 확인한 실습입니다. 크기 200 의 조각 12개 가운데 8개에 조항이 둘 이상 섞였고 4개에 머리글이 들었습니다. 조항 단위로 나눈 16개는 둘 다 0 입니다. 조각의 metadata 에 조항 번호와 쪽을 넣어 둔 것도 나중에 근거를 보여 줄 때 쓰입니다.

코드 설명

  • RecursiveCharacterTextSplitter(chunk_size=…, chunk_overlap=…) 는 분할기를 만들고, split_documents(pages) 가 쪽마다 나눠 조각 Document 목록을 돌려줍니다. 조각의 metadata 에는 원래 쪽의 page 가 그대로 남습니다.
  • describe 의 sum(조건 for c in chunks) 는 조건이 True 인 조각의 수입니다. True 는 1, False 는 0 으로 더해집니다.
  • re.sub(r"\s+", " ", text) 는 줄바꿈과 겹친 빈칸(\s+, 빈칸 같은 글자 하나 이상)을 빈칸 하나로 바꿉니다. PDF 가 문장 가운데에서 줄을 바꾼 곳이 다시 이어집니다.
  • re.split(r"(?=제\d+조 )", text) 는 "제N조 " 바로 앞에서 글을 자릅니다. (?=…) 는 그 글자를 지우지 않고 자리만 찾으므로 조항 번호가 조각 맨 앞에 남습니다.
  • 조항 조각의 metadata 에 article(조항), chapter(장), page(쪽)를 넣었습니다. 나중에 답의 근거를 "제5조(2쪽)"처럼 보여 줄 때 이 값을 씁니다.
← 이전 절6장 · 실습 1 — 여행 약관 PDF 읽어 보기다음 절 →6장 · 실습 3 — 연습용 임베딩과 FAISS 로 검색 적중 재기