학습 자료

긴 글을 토막으로 자른다 — TextSplitter


Article

앞에서 글 264편, 132만 자를 받아왔다.

전체 여섯 편 중 지금
  1. ①② 웹에서 글을 가져온다끝남
  2. ③ 글을 토막으로 자른다지금 여기
  3. ④ 자르기에 딸려오는 기능들다음
  4. ⑤⑥ 숫자로 바꿔 검색나중

왜 자르나

15,619자짜리 글 하나에는 주제가 여러 개 들어 있다. 통째로 두면 「이 글은 무슨 얘기?」가 흐려져서 질문과 맞춰볼 수가 없다. 그래서 토막마다 한 가지 이야기만 하도록 자른다.

잘라낸 토막 하나를 조각 또는 청크(chunk) 라고 부른다. 자르는 일은 청킹(chunking) 이다.

1. 도구를 깐다

터미널을 새로 열었다면 가상환경부터 다시 켠다. 프롬프트 앞에 (.venv) 가 있어야 한다.

.venv\Scripts\activate                        # 윈도우 (맥은 source .venv/bin/activate)
python -m pip install langchain-text-splitters

LangChain 은 AI 앱 부품을 모아둔 큰 도구모음인데, 우리는 자르는 부분만 떼어낸 작은 패키지를 쓴다. 몇 MB 면 끝난다.

2. 그냥 돌려본다

05_split_basic.py
import pathlib
from langchain_text_splitters import RecursiveCharacterTextSplitter

# posts 폴더의 md 파일을 전부 읽어 {글이름: 내용} 으로 모은다
texts = {
    p.stem: p.read_text(encoding="utf-8")
    for p in sorted(pathlib.Path("posts").glob("*.md"))
}
print(f"글 {len(texts)}편, 총 {sum(len(t) for t in texts.values()):,}자")

splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,       # 조각 하나의 최대 글자 수. 이걸 절대 안 넘는다
    chunk_overlap=20,     # 앞 조각 끝 20자를 다음 조각 앞에 다시 넣는다
)

chunks = [
    {"slug": slug, "text": c}
    for slug, text in texts.items()
    for c in splitter.split_text(text)
    if c.strip()
]

lens = [len(c["text"]) for c in chunks]
print(f"조각 {len(chunks)}개")
print(f"길이  최소 {min(lens)}  평균 {sum(lens) // len(lens)}  최대 {max(lens)}")
터미널
  • 글 264편, 총 1,328,418자
  • 조각 9005개
  • 길이 최소 3 평균 147 최대 200

chunk_overlap 은 경계에 걸친 문장을 살리려고 준다. 앞 조각 끝 20자를 다음 조각 앞에 한 번 더 넣으면 어느 한쪽에는 온전히 들어간다. 보통 chunk_size 의 10% 안팎이다.

chunk_size=200 은 실측으로 고른 값이다. LangChain 기본값은 1000/200 이고 튜토리얼에는 500/50 이 많은데, 그건 영어 문서 기준 관행이다.

3. 끊을 자리를 고르는 순서

이름의 Recursive 가 하는 일 그대로다. 큰 경계부터 시도하고, 조각이 여전히 크면 더 작은 경계로 내려간다.

조각이 200자를 넘을 때
  1. 빈 줄에서 끊기\n\n — 문단 경계. 1순위
  2. 줄바꿈에서 끊기\n — 아직도 크면
  3. 공백에서 끊기" " — 여기서 문장이 갈라진다
  4. 그냥 글자로"" — 최후 수단. 단어가 쪼개진다
이 후보 목록을 separators 라고 부른다. 기본 목록에 「마침표」가 없다.

4. 구분자를 추가했더니 사고가 났다

06_add_separators.py
import pathlib
from langchain_text_splitters import RecursiveCharacterTextSplitter

texts = {
    p.stem: p.read_text(encoding="utf-8")
    for p in sorted(pathlib.Path("posts").glob("*.md"))
}

splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=20,
    # 마침표+공백을 3순위로 끼워넣었다. 앞에서부터 시도한다
    separators=["\n\n", "\n", ". ", " ", ""],
)

chunks = [
    {"slug": slug, "text": c}
    for slug, text in texts.items()
    for c in splitter.split_text(text) if c.strip()
]

print(f"조각 {len(chunks)}개")

조각 수는 9,005개로 똑같다. 좋아졌는지 숫자로는 모르겠어서 조각을 직접 훑어봤다.

07_find_dots.py
import pathlib
from langchain_text_splitters import RecursiveCharacterTextSplitter

texts = {
    p.stem: p.read_text(encoding="utf-8")
    for p in sorted(pathlib.Path("posts").glob("*.md"))
}


def cut(**kw):
    """설정만 바꿔가며 조각을 만든다."""
    sp = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20, **kw)
    return [
        {"slug": slug, "text": c}
        for slug, text in texts.items()
        for c in sp.split_text(text) if c.strip()
    ]


def count_dots(chunks):
    """조각이 마침표로 '시작'하면 이상한 것이다."""
    return [c for c in chunks if c["text"].lstrip().startswith(".")]


기본 = cut()
구분자 = cut(separators=["\n\n", "\n", ". ", " ", ""])

print(f"기본값      조각 {len(기본):5d}개   점으로 시작 {len(count_dots(기본)):4d}개")
print(f"구분자 추가  조각 {len(구분자):5d}개   점으로 시작 {len(count_dots(구분자)):4d}개")

# repr() 로 찍어야 따옴표와 앞의 점이 눈에 보인다
print()
print(repr(count_dots(구분자)[0]["text"][:60]))
터미널
  • 점으로 시작하는 조각 117개
  • '. LLM은 생성형 AI 중 글을 다루는 것만 가리키는 좁은 이름이다.'
점으로 시작하는 조각 (264편 전체)
설정조각 수점으로 시작
기본값9,00529개
구분자 추가9,005117개 — 4배로 늘었다
좋아지라고 넣은 설정이 문제를 4배로 만들었다.

앞 문장의 마침표가 다음 조각 머리에 붙었다. 에러는 안 나고 조각 수도 그대로여서, 직접 세보기 전까지 아무 신호가 없었다.

5. keep_separator 로 고친다

구분자를 어느 쪽 조각에 남길지 정하는 설정이 따로 있다. 기본값은 "start"(뒤 조각 앞에 붙임)이고, 우리가 원하는 건 "end" 다.

08_fix.py
import pathlib
from langchain_text_splitters import RecursiveCharacterTextSplitter

texts = {
    p.stem: p.read_text(encoding="utf-8")
    for p in sorted(pathlib.Path("posts").glob("*.md"))
}

SEPS = ["\n\n", "\n", ". ", " ", ""]


def cut(**kw):
    sp = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20, **kw)
    return [c for text in texts.values() for c in sp.split_text(text) if c.strip()]


def dots(chunks):
    return sum(1 for c in chunks if c.lstrip().startswith("."))


세팅 = {
    "기본값": cut(),
    "구분자 추가": cut(separators=SEPS),
    # keep_separator="end" — 구분자를 앞 조각 끝에 남긴다
    "구분자+keep=end": cut(separators=SEPS, keep_separator="end"),
}

for name, chunks in 세팅.items():
    print(f"{name:16s} 조각 {len(chunks):5d}개   점으로 시작 {dots(chunks):4d}개")
세 설정을 나란히 (264편 전체)
설정조각 수점으로 시작
기본값9,00529개
구분자 추가9,005117개
구분자 + keep_separator="end"9,00527개
27개로 내려왔다. 기본값(29개)보다도 적다.

남은 27개는 .env, .gitignore 처럼 진짜 점으로 시작하는 문장들이다. 원문이 그런 거라 고칠 게 없다.

조각 크기를 바꿔가며 같은 걸 세보면 이렇다.

조각 크기별 — 점으로 시작하는 조각
chunk_size기본값구분자 추가+ keep=end
10002개2개2개
5002개2개2개
20029개117개27개
10077개2,068개76개
조각이 클수록 문제가 안 보인다. 1000자로 자르면 차이가 아예 0이다.

이번 편에 나온 것

정리
쓴 것하는 일
pip install langchain-text-splitters자르기 도구를 깐다 (몇 MB)
RecursiveCharacterTextSplitter(...)자르는 도구 하나를 만든다
chunk_size조각 최대 글자 수. 절대 안 넘는다
chunk_overlap조각끼리 겹치는 양. 경계에 걸친 문장을 살린다
separators=[...]끊을 자리 후보. 앞에서부터 시도한다
keep_separator="end"구분자를 앞 조각에 남긴다. 기본값은 start
.split_text(text)글 하나를 조각 목록으로
repr(문자열)따옴표·공백·점이 눈에 보이게 찍는다
.lstrip() · .startswith()왼쪽 공백 제거 · 그 글자로 시작하나

다음 편에서는 라이브러리에 딸려 오는 기능들을 쓴다.

Share
  • 파이썬
  • AI
  • RAG
  • 청킹
  • LangChain
긴 글을 토막으로 자른다 — TextSplitter — 디코드랩(DCODELAB)