긴 글을 토막으로 자른다 — TextSplitter
Article
앞에서 글 264편, 132만 자를 받아왔다.
- ①② 웹에서 글을 가져온다끝남
- ③ 글을 토막으로 자른다지금 여기
- ④ 자르기에 딸려오는 기능들다음
- ⑤⑥ 숫자로 바꿔 검색나중
왜 자르나
15,619자짜리 글 하나에는 주제가 여러 개 들어 있다. 통째로 두면 「이 글은 무슨 얘기?」가 흐려져서 질문과 맞춰볼 수가 없다. 그래서 토막마다 한 가지 이야기만 하도록 자른다.
잘라낸 토막 하나를 조각 또는 청크(chunk) 라고 부른다. 자르는 일은 청킹(chunking) 이다.
1. 도구를 깐다
터미널을 새로 열었다면 가상환경부터 다시 켠다. 프롬프트 앞에 (.venv) 가 있어야 한다.
.venv\Scripts\activate # 윈도우 (맥은 source .venv/bin/activate)
python -m pip install langchain-text-splitters
LangChain 은 AI 앱 부품을 모아둔 큰 도구모음인데, 우리는 자르는 부분만 떼어낸 작은 패키지를 쓴다. 몇 MB 면 끝난다.
2. 그냥 돌려본다
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 가 하는 일 그대로다. 큰 경계부터 시도하고, 조각이 여전히 크면 더 작은 경계로 내려간다.
- 빈 줄에서 끊기\n\n — 문단 경계. 1순위
- 줄바꿈에서 끊기\n — 아직도 크면
- 공백에서 끊기" " — 여기서 문장이 갈라진다
- 그냥 글자로"" — 최후 수단. 단어가 쪼개진다
4. 구분자를 추가했더니 사고가 났다
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개로 똑같다. 좋아졌는지 숫자로는 모르겠어서 조각을 직접 훑어봤다.
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 중 글을 다루는 것만 가리키는 좁은 이름이다.'
| 설정 | 조각 수 | 점으로 시작 |
|---|---|---|
| 기본값 | 9,005 | 29개 |
| 구분자 추가 | 9,005 | 117개 — 4배로 늘었다 |
앞 문장의 마침표가 다음 조각 머리에 붙었다. 에러는 안 나고 조각 수도 그대로여서, 직접 세보기 전까지 아무 신호가 없었다.
5. keep_separator 로 고친다
구분자를 어느 쪽 조각에 남길지 정하는 설정이 따로 있다. 기본값은 "start"(뒤 조각 앞에 붙임)이고, 우리가 원하는 건 "end" 다.
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}개")
| 설정 | 조각 수 | 점으로 시작 |
|---|---|---|
| 기본값 | 9,005 | 29개 |
| 구분자 추가 | 9,005 | 117개 |
구분자 + keep_separator="end" | 9,005 | 27개 |
남은 27개는 .env, .gitignore 처럼 진짜 점으로 시작하는 문장들이다. 원문이 그런 거라 고칠 게 없다.
조각 크기를 바꿔가며 같은 걸 세보면 이렇다.
| chunk_size | 기본값 | 구분자 추가 | + keep=end |
|---|---|---|---|
| 1000 | 2개 | 2개 | 2개 |
| 500 | 2개 | 2개 | 2개 |
| 200 | 29개 | 117개 | 27개 |
| 100 | 77개 | 2,068개 | 76개 |
이번 편에 나온 것
| 쓴 것 | 하는 일 |
|---|---|
pip install langchain-text-splitters | 자르기 도구를 깐다 (몇 MB) |
RecursiveCharacterTextSplitter(...) | 자르는 도구 하나를 만든다 |
chunk_size | 조각 최대 글자 수. 절대 안 넘는다 |
chunk_overlap | 조각끼리 겹치는 양. 경계에 걸친 문장을 살린다 |
separators=[...] | 끊을 자리 후보. 앞에서부터 시도한다 |
keep_separator="end" | 구분자를 앞 조각에 남긴다. 기본값은 start |
.split_text(text) | 글 하나를 조각 목록으로 |
repr(문자열) | 따옴표·공백·점이 눈에 보이게 찍는다 |
.lstrip() · .startswith() | 왼쪽 공백 제거 · 그 글자로 시작하나 |
다음 편에서는 라이브러리에 딸려 오는 기능들을 쓴다.