학습 자료

자르기에 딸려 오는 것들 — 원본 위치와 제목


Article

앞 편에서 TextSplitter 로 132만 자를 9,005조각으로 잘랐다. 이번엔 라이브러리에 딸려 오는 기능 두 가지를 쓴다.

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

1. 조각이 원본 어디서 왔나

09_start_index.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"))
}

slug = max(texts, key=lambda s: len(texts[s]))     # 가장 긴 글 하나로 해본다
text = texts[slug]
print("고른 글:", slug)

splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=20,
    separators=["\n\n", "\n", ". ", " ", ""],
    keep_separator="end",
    add_start_index=True,     # 원본 몇 번째 글자에서 시작했는지 기록한다
)

docs = splitter.create_documents(
    [text],
    metadatas=[{"source": slug}],
)

for d in docs[:4]:
    print(d.metadata, "|", d.page_content[:34].replace("\n", " "))
터미널
  • 고른 글: ai-eng-06-measuring-chunks
  • {'source': 'ai-eng-06-measuring-chunks', 'start_index': 0} | # 어느 자르기가 나은가 — 말이 아니라
  • {'source': 'ai-eng-06-measuring-chunks', 'start_index': 188} | 재는 순서 1. 정답을 아는 질문을 모은다
  • {'source': 'ai-eng-06-measuring-chunks', 'start_index': 364} | ## 1. 먼저 `search.py` 를 마무리한다
  • {'source': 'ai-eng-06-measuring-chunks', 'start_index': 549} | ai-course/search.py ``` # search.

start_index 가 붙었다. 「이 글의 364번째 글자부터」를 알 수 있다는 뜻이다.

결과에 나온 Document 는 본문과 정보를 함께 담은 객체다. 본문은 .page_content, 정보는 .metadata 로 꺼낸다.

2. 마크다운은 제목이 곧 경계

10_markdown_headers.py
import pathlib
from langchain_text_splitters import MarkdownHeaderTextSplitter

texts = {
    p.stem: p.read_text(encoding="utf-8")
    for p in sorted(pathlib.Path("posts").glob("*.md"))
}
slug = max(texts, key=lambda s: len(texts[s]))

splitter = MarkdownHeaderTextSplitter(
    # (어느 제목 기호에서 끊을지, 그 제목을 무슨 이름으로 저장할지)
    headers_to_split_on=[("#", "글"), ("##", "섹션")],
)

sections = splitter.split_text(texts[slug])

print(f"{slug} -> 섹션 {len(sections)}개")
for d in sections[:5]:
    print(" ", d.metadata, "|", len(d.page_content), "자")
터미널
  • ai-eng-06-measuring-chunks -> 섹션 11개
  • {'글': '어느 자르기가 나은가…'} | 335 자
  • {'글': '어느 자르기가 나은가…', '섹션': '1. 먼저 `search.py` 를 마무리한다'} | 2648 자
  • {'글': '어느 자르기가 나은가…', '섹션': '2. 정답을 아는 질문을 만든다'} | 934 자
  • {'글': '어느 자르기가 나은가…', '섹션': '3. 정답이 몇 위에 오는지 센다'} | 1312 자
  • {'글': '어느 자르기가 나은가…', '섹션': '4. 세 방식을 나란히 잰다'} | 1832 자

주제별로 나뉘었고 섹션 이름이 자동으로 따라왔다. 첫 항목에 섹션 이 없는 것은 ## 가 나오기 전의 도입부라서다.

3. 두 단계로 붙인다

섹션 길이가 165자부터 4,041자까지 제각각이다. 제목으로 먼저 나누고, 긴 것만 크기로 다시 자른다.

11_two_stage.py
import pathlib
from langchain_text_splitters import (
    MarkdownHeaderTextSplitter,
    RecursiveCharacterTextSplitter,
)

texts = {
    p.stem: p.read_text(encoding="utf-8")
    for p in sorted(pathlib.Path("posts").glob("*.md"))
}
md = texts[max(texts, key=lambda s: len(texts[s]))]

sections = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "글"), ("##", "섹션")],
).split_text(md)

sizer = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=20,
    separators=["\n\n", "\n", ". ", " ", ""],
    keep_separator="end",
)

chunks = sizer.split_documents(sections)      # split_text 가 아니다

print(f"섹션 {len(sections)}개 -> 조각 {len(chunks)}개")
for d in chunks[:5]:
    print(d.metadata.get("섹션"), "|", d.page_content[:30].replace("\n", " "))

split_documents 를 써야 위에서 붙은 섹션 이름이 최종 조각까지 따라온다. 첫 조각에는 섹션 이 없으므로 대괄호 대신 .get() 으로 꺼낸다.

이번 편에 나온 것

정리
쓴 것하는 일
add_start_index=True원본 몇 번째 글자에서 시작했나
.create_documents([본문], metadatas=[정보])본문 + 정보를 묶어서
.split_documents(docs)Document 를 받아 정보를 물려준다
MarkdownHeaderTextSplitter# · ## 로 나누고 제목을 정보로 남긴다
headers_to_split_on=[("##", "섹션")]어느 기호에서 끊고 무슨 이름으로 저장할지
.page_content · .metadataDocument 에서 본문과 정보를 꺼낸다
.get("열쇠")없으면 None. 대괄호는 없으면 에러

다음 편에서는 조각을 숫자로 바꾼다.