학습 자료

직접 짠 자르기를 라이브러리로 갈아끼운다


Article

9·10강에서 자르기를 손으로 만들었다. 글자 수로 자르고, 겹치게 하고, 문단으로 나누고, 문장 끝을 찾아 끊고, 짧은 꼬리를 앞에 붙였다. chunk_smart 하나에 그게 다 들어 있다.

그런데 이건 남들도 다 하는 일이다. 이미 만들어져 있다.

이번 강의에서 갈아끼워 본다. 그리고 결과가 뜻밖이다.

이번 강의의 흐름
  1. 라이브러리를 그냥 끼운다우리 것보다 나쁘다
  2. 왜 나쁜지 찾는다우리가 한 걸 안 한다
  3. 설정으로 고친다구분자 · keep_separator
  4. 다시 잰다그래서 이겼나?

파일은 계속 ai-course 폴더에 넣는다. 이번 강의는 131번부터다.

1. 깔고 그냥 돌려본다

python -m pip install langchain-text-splitters

backend.txt 하나만 놓고 우리 것과 나란히 돌려본다.

ai-course/131_그냥돌리기.py
import search
from langchain_text_splitters import RecursiveCharacterTextSplitter

text = search.load_documents()[0]["text"]      # backend.txt

print("=== 우리가 만든 chunk_smart(60, 20) ===")
for i, c in enumerate(search.chunk_smart(text, 60, 20)):
    print(f"{i} ({len(c)}자) {c}")

print()
print("=== 라이브러리 기본값 ===")
splitter = RecursiveCharacterTextSplitter(
    chunk_size=60,       # 조각 하나의 최대 글자 수
    chunk_overlap=0,     # 겹치는 양. 9강에서 만든 그 개념이다
)
for i, c in enumerate(splitter.split_text(text)):
    print(f"{i} ({len(c)}자) {c}")
터미널
  • === 우리가 만든 chunk_smart(60, 20) ===
  • 0 (6자) 수업용 예제
  • 1 (64자) 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다.
  • 2 (56자) 주로 쓰는 언어는 자바, 파이썬, 노드다. 회사마다 다르지만 하나를 깊게 아는 편이 여럿을 얕게 아는
  • 3 (53자) 것보다 낫다. 요즘은 클라우드 지식도 함께 요구된다. 서버를 직접 사는 회사가 줄었기 때문이다.
  • 4 (69자) 신입에게 가장 많이 물어보는 것은 데이터베이스다. 인덱스가 무엇인지, 조인이 왜 느려지는지를 설명할 수 있으면 절반은 됐다.
  • === 라이브러리 기본값 ===
  • 0 (6자) 수업용 예제
  • 1 (57자) 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는
  • 2 (6자) 일을 한다.
  • 3 (56자) 주로 쓰는 언어는 자바, 파이썬, 노드다. 회사마다 다르지만 하나를 깊게 아는 편이 여럿을 얕게 아는
  • 4 (53자) 것보다 낫다. 요즘은 클라우드 지식도 함께 요구된다. 서버를 직접 사는 회사가 줄었기 때문이다.
  • 5 (57자) 신입에게 가장 많이 물어보는 것은 데이터베이스다. 인덱스가 무엇인지, 조인이 왜 느려지는지를 설명할 수
  • 6 (11자) 있으면 절반은 됐다.

5조각이 7조각이 됐고, 늘어난 둘이 문제다.

일을 한다.있으면 절반은 됐다. — 앞 문장에서 떨어져 나온 꼬리다. 이 조각들은 혼자서는 아무 뜻이 없다. 12강에서 본 대로 짧은 조각은 방향도 흐릿하다.

2. 무엇을 안 하고 있나

우리 chunk_smart 가 한 일을 다시 꺼내 본다. 10강에서 만든 그 함수다.

같은 60자인데 왜 다른가

우리 chunk_smart

10강에서 만든 것

  • . 를 찾아 문장 끝에서 끊는다
  • 짧은 조각은 앞에 도로 붙인다 (min_size)
  • 그래서 60자를 조금 넘겨도 문장을 지킨다
  • 우리 문서에 맞춰 만들었다

라이브러리 기본값

설정을 안 준 상태

  • 빈 줄 → 줄바꿈 → 공백 순으로 끊는다
  • 짧아도 그대로 둔다 — 붙이는 기능이 없다
  • 60자를 절대 안 넘긴다. 대신 문장이 갈라진다
  • 어느 문서에나 무난한 값이다
우리 것이 64자·69자 조각을 만든 건 버그가 아니다. 「문장을 지키는 쪽이 낫다」고 우리가 정한 것이다.

라이브러리가 끊을 자리를 고르는 순서는 이렇다. 큰 경계부터 시도하고, 그래도 크면 더 작은 경계로 내려간다. 이름에 「재귀」가 붙은 이유다.

조각이 60자를 넘을 때 벌어지는 일
  1. 빈 줄로 끊기\n\n — 문단 경계. 1순위
  2. 줄바꿈으로 끊기\n — 아직도 크면
  3. 공백으로 끊기" " — 여기서 문장이 갈라진다
  4. 그냥 글자로 끊기"" — 최후 수단. 단어가 쪼개진다
기본 목록에 「마침표」가 없다. 그래서 3단계까지 내려가 공백에서 끊긴 것이다.

빠진 것이 보인다. 목록에 . 가 없다. 우리 cut_oncerfind(". ") 로 마침표를 먼저 찾았는데, 라이브러리는 그 단계를 아예 안 갖고 있다.

3. 끊을 자리를 알려준다

목록은 우리가 넘길 수 있다.

ai-course/132_구분자.py
import search
from langchain_text_splitters import RecursiveCharacterTextSplitter

text = search.load_documents()[0]["text"]

splitter = RecursiveCharacterTextSplitter(
    chunk_size=60,
    chunk_overlap=0,
    separators=["\n\n", "\n", ". ", " ", ""],
    # ↑ 마침표+공백을 3순위로 끼워넣었다. 우리 cut_once 가 하던 일이다
)

for i, c in enumerate(splitter.split_text(text)):
    print(f"{i} ({len(c)}자) {c}")
터미널
  • 0 (6자) 수업용 예제
  • 1 (24자) 백엔드 개발자는 서버와 데이터베이스를 다룬다
  • 2 (40자) . 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다.
  • 3 (22자) 주로 쓰는 언어는 자바, 파이썬, 노드다
  • 4 (41자) . 회사마다 다르지만 하나를 깊게 아는 편이 여럿을 얕게 아는 것보다 낫다
  • 5 (47자) . 요즘은 클라우드 지식도 함께 요구된다. 서버를 직접 사는 회사가 줄었기 때문이다.
  • 6 (26자) 신입에게 가장 많이 물어보는 것은 데이터베이스다
  • 7 (43자) . 인덱스가 무엇인지, 조인이 왜 느려지는지를 설명할 수 있으면 절반은 됐다.

꼬리 조각은 사라졌다. 그런데 새 문제가 생겼다.

조각이 . 시작한다. 앞 문장의 마침표가 다음 조각 앞에 붙었다. 문장 끝의 마침표는 사라지고, 다음 조각 머리에 뜬금없는 점이 하나 붙어 있다.

4. 마침표를 앞 조각에 남긴다

구분자를 어느 쪽에 붙일지 정하는 설정이 따로 있다.

ai-course/133_고치기.py
import search
from langchain_text_splitters import RecursiveCharacterTextSplitter

text = search.load_documents()[0]["text"]

splitter = RecursiveCharacterTextSplitter(
    chunk_size=60,
    chunk_overlap=0,
    separators=["\n\n", "\n", ". ", " ", ""],
    keep_separator="end",
    # ↑ 구분자를 앞 조각의 끝에 남긴다. 기본값은 "start" 라 다음 조각 앞에 붙는다
)

for i, c in enumerate(splitter.split_text(text)):
    print(f"{i} ({len(c)}자) {c}")
터미널
  • 0 (6자) 수업용 예제
  • 1 (25자) 백엔드 개발자는 서버와 데이터베이스를 다룬다.
  • 2 (38자) 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다.
  • 3 (23자) 주로 쓰는 언어는 자바, 파이썬, 노드다.
  • 4 (40자) 회사마다 다르지만 하나를 깊게 아는 편이 여럿을 얕게 아는 것보다 낫다.
  • 5 (45자) 요즘은 클라우드 지식도 함께 요구된다. 서버를 직접 사는 회사가 줄었기 때문이다.
  • 6 (27자) 신입에게 가장 많이 물어보는 것은 데이터베이스다.
  • 7 (41자) 인덱스가 무엇인지, 조인이 왜 느려지는지를 설명할 수 있으면 절반은 됐다.

전부 문장 단위로 깔끔하게 끊겼다. 모든 조각이 마침표로 끝난다.

세 문서 전체로 보면 이렇다.

세 문서를 다 잘랐을 때
방식조각 수20자 미만
우리 chunk_smart(60, 20)13개3개
라이브러리 기본값15개5개
라이브러리 + 마침표 + keep_separator16개3개
조각 수는 라이브러리 쪽이 더 많다. 60자를 안 넘기려고 더 잘게 자르기 때문이다.

20자 미만 조각이 3개로 내려왔다. 우리 것과 같다. 다만 아직 0은 아니다.

5. 라이브러리에 없는 기능 하나

우리 chunk_smart 에는 있고 라이브러리에는 없는 것이 하나 남았다. 10강에서 만든 그것이다.

if len(head) < min_size and out:
    out[-1] = out[-1] + " " + head      # 짧은 꼬리를 앞 조각에 붙인다

라이브러리에는 이런 설정이 없다. 그러니 그 부분만 우리 코드로 감싼다. search.py 맨 아래에 덧붙인다.

ai-course/search.py
# ... 12강까지의 내용 아래에 덧붙인다

from langchain_text_splitters import RecursiveCharacterTextSplitter


def chunk_lib(text, size=60, min_size=20):
    """라이브러리로 자르되, 짧은 꼬리 붙이기는 우리가 한다.

    10강의 chunk_smart 와 결과가 거의 같다. 자르는 일만 남에게 맡긴 것이다.
    """
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=size,
        chunk_overlap=0,
        separators=["\n\n", "\n", ". ", " ", ""],
        keep_separator="end",
    )

    out = []
    for piece in splitter.split_text(text):
        if out and len(piece) < min_size:
            out[-1] = out[-1] + " " + piece     # 10강에서 만든 그 줄 그대로다
        else:
            out.append(piece)
    return out


def build_chunks_lib(folder=None, size=60, min_size=20):
    """chunk_lib 판 build_chunks. 부르는 함수 하나만 다르다."""
    chunks = []
    for doc in load_documents(folder):
        for piece in chunk_lib(doc["text"], size, min_size):
            if piece.strip():
                chunks.append({"source": doc["source"], "text": piece})
    return chunks

11강에서 build_chunks_plain 을 남겨둔 것과 같은 이유로 새 함수를 따로 만든다. 옛 방식을 지우면 비교할 대상이 사라진다.

그런데 이 문서로 돌려보면 꼬리 붙이기가 한 번도 발동하지 않는다. 20자 미만 조각 셋이 전부 각 문서의 첫 줄 수업용 예제 인데, 그 자리에서는 out 이 비어 있어 앞에 붙일 조각이 없기 때문이다.

6. 출처를 공짜로 얻는다

여기부터가 라이브러리로 갈아탈 진짜 이유다. 우리 코드에는 없던 기능이 딸려 온다.

지금 우리 조각은 {"source": ..., "text": ...} 딕셔너리다. 어느 파일에서 왔는지는 알지만 그 파일의 어디쯤인지는 모른다.

ai-course/134_출처.py
import search
from langchain_text_splitters import RecursiveCharacterTextSplitter

docs = search.load_documents()

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

out = splitter.create_documents(
    [d["text"] for d in docs],                          # 본문 목록
    metadatas=[{"source": d["source"]} for d in docs],  # 짝이 되는 정보 목록
)
# ↑ split_text 는 문자열만 준다. create_documents 는 정보를 같이 묶어 준다

print("총", len(out), "조각")
for d in out[:4]:
    print(d.metadata, "|", d.page_content[:28])
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 총 16 조각
  • {'source': 'backend.txt', 'start_index': 0} | 수업용 예제
  • {'source': 'backend.txt', 'start_index': 8} | 백엔드 개발자는 서버와 데이터베이스를 다룬다.
  • {'source': 'backend.txt', 'start_index': 34} | 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고
  • {'source': 'backend.txt', 'start_index': 74} | 주로 쓰는 언어는 자바, 파이썬, 노드다.

start_index 가 붙었다. 「backend.txt 의 34번째 글자부터」 를 알 수 있다는 뜻이다. 검색 결과에 「이 답은 여기서 나왔습니다」를 붙이고, 원문의 그 자리로 보내줄 수 있다.

세 가지 메서드
메서드넣는 것나오는 것
split_text문자열 하나문자열 목록
create_documents문자열 목록 + 정보 목록Document 목록
split_documentsDocument 목록Document 목록 (정보 유지)
Document 는 .page_content 에 본문, .metadata 에 정보를 담은 객체다. 우리가 쓰던 딕셔너리와 모양만 다르고 하는 일은 같다.

7. 마크다운은 제목이 공짜 구분선

우리 문서는 .txt 라 빈 줄밖에 단서가 없었다. 마크다운이라면 ## 가 이미 완벽한 경계다.

ai-course/135_마크다운.py
from langchain_text_splitters import MarkdownHeaderTextSplitter

md = """# 사내 규정

## 환불 정책

주문 후 7일 이내에 환불 신청이 가능하다.
단순 변심이면 왕복 배송비는 고객 부담이다.

## 배송 안내

배송은 결제일 기준 2~3일 걸린다.
5만원 이상이면 배송비가 없다.
"""

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

for d in splitter.split_text(md):
    print(d.metadata)
    print(" ", d.page_content.replace("\n", " / "))
터미널
  • {'문서': '사내 규정', '섹션': '환불 정책'}
  • 주문 후 7일 이내에 환불 신청이 가능하다. / 단순 변심이면 왕복 배송비는 고객 부담이다.
  • {'문서': '사내 규정', '섹션': '배송 안내'}
  • 배송은 결제일 기준 2~3일 걸린다. / 5만원 이상이면 배송비가 없다.

주제가 정확히 주제별로 나뉘었고, 섹션 이름이 자동으로 따라왔다. 나중에 「사내 규정 > 환불 정책」이라고 출처를 찍을 수 있다.

실무 문서 상당수가 마크다운이다. 위키, 사내 문서, README 가 전부 그렇다. 글자 수를 세기 전에 제목부터 보는 게 순서다.

실무에서는 두 단계로 쓴다
  1. 01

    제목으로 먼저 나눈다

    주제가 섞이지 않게 하는 게 1순위다. 환불 이야기와 배송 이야기는 아무리 짧아도 한 조각에 들어가면 안 된다. 12강에서 본 대로 주제가 섞이면 방향이 뭉개진다.

    sections = MarkdownHeaderTextSplitter(
        headers_to_split_on=[("#", "문서"), ("##", "섹션")]
    ).split_text(md)
  2. 02

    긴 섹션만 크기로 다시 자른다

    섹션 하나가 A4 세 장이면 그때만 다시 자른다. 짧은 섹션은 그대로 통과한다. split_documents 를 쓰면 위에서 붙은 섹션 이름이 그대로 따라온다.

    sizer = RecursiveCharacterTextSplitter(chunk_size=60, chunk_overlap=0)
    chunks = sizer.split_documents(sections)

8. 그래서 이겼나 — 다시 잰다

11강에서 만든 질문 여섯 개, 12강에서 만든 임베딩 점수. 재는 방법을 그대로 두고 자르는 방식만 바꿔 잰다.

ai-course/136_재기.py
import search
from langchain_text_splitters import RecursiveCharacterTextSplitter

QUESTIONS = [
    ("인덱스가 뭔지 물어본다",        "backend.txt"),
    ("화면이 느리면 어떻게 되나",      "frontend.txt"),
    ("데이터가 틀리면 어떻게 되나",    "data.txt"),
    ("타입스크립트를 쓰나",           "frontend.txt"),
    ("파이프라인을 짜는 일",          "data.txt"),
    ("서버와 데이터베이스를 다루는 일", "backend.txt"),
]

def build_plain_lib(size=60):
    """설정을 하나도 안 준 라이브러리 기본값. 비교용이라 여기서만 쓴다."""
    splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=0)
    return [
        {"source": doc["source"], "text": piece}
        for doc in search.load_documents()
        for piece in splitter.split_text(doc["text"])
    ]


cases = [
    ("우리 chunk_smart",  search.build_chunks(size=60, min_size=20)),
    ("lib 기본값",        build_plain_lib(size=60)),
    ("lib 설정 맞춤",     search.build_chunks_lib(size=60, min_size=0)),
    ("lib + 꼬리붙이기",  search.build_chunks_lib(size=60, min_size=20)),
]

for name, chunks in cases:
    ranks = [search.embed_rank(chunks, q, a) for q, a in QUESTIONS]
    top1 = sum(1 for r in ranks if r == 1)
    print(f"{name:18s} 조각 {len(chunks):2d}개  1위 {top1}/6  평균 {sum(ranks) / 6:.2f}  {ranks}")
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 우리 chunk_smart 조각 13개 1위 6/6 평균 1.00 [1, 1, 1, 1, 1, 1]
  • lib 기본값 조각 15개 1위 5/6 평균 1.17 [1, 1, 1, 1, 2, 1]
  • lib 설정 맞춤 조각 16개 1위 6/6 평균 1.00 [1, 1, 1, 1, 1, 1]
  • lib + 꼬리붙이기 조각 16개 1위 6/6 평균 1.00 [1, 1, 1, 1, 1, 1]

결론이 싱겁다. 우리가 만든 것과 똑같다.

라이브러리 기본값은 오히려 5/6 으로 더 나쁘다. 제대로 설정해야 겨우 동점이다. 며칠 걸려 만든 chunk_smart 를 라이브러리로 갈아끼웠는데 숫자가 하나도 안 움직였다.

마지막 두 줄은 조각 수까지 같다. 5절에서 본 대로 이 문서에서는 꼬리 붙이기가 발동할 자리가 없기 때문이다.

9. 그럼 왜 쓰나

바꿔서 얻는 것 · 잃는 것
항목우리 코드라이브러리
검색 품질6/66/6 — 차이 없음
설정을 안 줬을 때우리 문서에 맞음더 나쁨
출처 위치(start_index)없다. 직접 만들어야설정 한 줄
마크다운 제목 자르기없다. 직접 만들어야전용 도구가 있다
코드 줄 수cut_once + chunk_smart 28줄설정 6줄 + 꼬리 5줄
새 형식이 필요할 때직접 짠다HTML · 코드 · 표 전용이 이미 있다
이해도내가 다 안다안 보이는 동작이 있다

정리하면 이렇다. 라이브러리는 지금 있는 것을 더 잘하게 해주지 않는다. 다음에 필요할 것을 미리 갖고 있다.

start_index 와 마크다운 제목은 우리 코드에 없었고, 직접 만들려면 또 며칠이다. 앞으로 HTML 이나 소스코드를 자를 일이 생기면 그때도 이미 있다.

이번 강의에 나온 것

정리
쓴 것하는 일
pip install langchain-text-splitters자르기 도구를 깐다 (몇 MB)
RecursiveCharacterTextSplitter(...)자르는 도구 하나를 만든다
chunk_size · chunk_overlap9강에서 만든 그 두 값
separators=[...]끊을 자리 후보. 앞에서부터 시도한다
keep_separator="end"구분자를 앞 조각에 남긴다. 기본값은 start
add_start_index=True원본 몇 번째 글자에서 시작했나
.split_text(text)문자열 목록을 돌려준다
.create_documents([본문], metadatas=[정보])본문과 정보를 묶어 돌려준다
MarkdownHeaderTextSplitter# · ## 로 나누고 제목을 정보로 남긴다
.page_content · .metadataDocument 에서 본문과 정보를 꺼낸다

미션

미션
  1. 01

    [필수] keep_separator 를 빼고 눈으로 확인한다

    ai-course/137_미션필수.py. keep_separator="end" 만 지우고 돌려서 조각 앞에 점이 붙는 것을 직접 본다. 그 상태로 136_재기.py 를 돌려 점수가 떨어지는지도 확인한다.

  2. 02

    [응용] 내 마크다운 문서를 잘라본다

    ai-course/138_미션응용.py. 아무 README 나 사내 문서를 docs 에 넣고 MarkdownHeaderTextSplitter 로 잘라본다. 섹션이 몇 개 나오고, 그중 제일 긴 것이 몇 자인가.

  3. 03

    [도전] 제목 + 크기 두 단계를 붙인다

    ai-course/139_미션도전.py. 제목으로 나눈 뒤 긴 섹션만 split_documents 로 다시 자른다. 섹션 이름이 최종 조각까지 따라오는지 확인한다.

[필수] keep_separator 를 빼면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

설정 한 줄만 지우면 된다. 눈으로 보는 것이 이 문제의 전부다.

splitter = RecursiveCharacterTextSplitter(
    chunk_size=60,
    chunk_overlap=0,
    separators=["\n\n", "\n", ". ", " ", ""],
    # keep_separator="end",      <- 이 줄만 지운다
)

for c in splitter.split_text(text):
    print(repr(c[:20]))      # repr 로 찍으면 앞의 공백과 점이 눈에 들어온다

'. 사용자 눈에 보이지 않는 곳에서 ' 처럼 점으로 시작하는 조각이 보인다. `repr` 로 찍는 게 요령이다 — 그냥 print 하면 앞의 점을 그러려니 하고 넘긴다.

조각 16개에 1위 6/6, 평균 1.00. 점수가 하나도 안 변한다. 그래서 위험하다 — 지표가 안 잡아주니 눈으로 안 보면 그대로 지나간다.

문서가 늘고 조각이 수만 개가 되면 이런 것들이 쌓인다. 하나하나는 미미하고, 합치면 원인을 못 찾는 품질 저하가 된다.

[응용] 내 마크다운 자르기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

5강에서 배운 파일 읽기를 그대로 쓴다. 인코딩을 빼먹지 않는다.

from pathlib import Path
from langchain_text_splitters import MarkdownHeaderTextSplitter

md = Path("docs/README.md").read_text(encoding="utf-8")

sections = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "문서"), ("##", "섹션"), ("###", "소제목")]
).split_text(md)

print(f"섹션 {len(sections)}개")
for d in sorted(sections, key=lambda d: -len(d.page_content))[:3]:
    print(f'{len(d.page_content):5d}{d.metadata}')

제일 긴 섹션 세 개만 보는 게 요령이다. 그것들만 다시 자르면 되기 때문이다.

제목 단계를 ### 까지 넣을지는 문서마다 다르다. 소제목이 많은 문서면 넣는 게 낫고, 소제목이 한두 줄짜리면 조각이 너무 잘게 난다.

섹션이 1개만 나왔다면 그 문서에 ## 가 없는 것이다. 모든 마크다운이 제목을 잘 쓰는 건 아니다 — 그래서 크기 자르기가 뒤에 또 필요하다.

[도전] 두 단계로 붙이기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

split_text 가 아니라 `split_documents` 를 쓰는 것이 핵심이다. 이름이 비슷해서 헷갈리기 쉽다.

split_text 는 문자열을 받으니 metadata 를 받을 자리가 없다. split_documents 는 Document 를 받아 정보를 그대로 물려준다.

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

sizer = RecursiveCharacterTextSplitter(
    chunk_size=60,
    chunk_overlap=0,
    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, "|", d.page_content[:26])

조각마다 섹션 이름이 그대로 붙어 있어야 한다. 안 붙어 있으면 split_text 를 쓴 것이다.

짧은 섹션은 조각 수가 안 늘어난다. 「섹션 4개 → 조각 6개」처럼 긴 섹션만 쪼개진 결과가 나온다.

여기에 5절의 꼬리 붙이기까지 얹으면 완성이다. 다만 Document 목록이라 out[-1].page_content 를 고쳐야 한다 — 딕셔너리를 다룰 때와 점 하나 차이다.

정리하면

손으로 만든 자르기를 라이브러리로 갈아끼웠다. 기본값 그대로는 우리 것보다 나빴고(5/6), 구분자와 keep_separator 를 맞춰주고 나서야 같아졌다(6/6).

검색 품질은 안 변했다. 그런데도 갈아끼울 값어치가 있는 이유는 딸려 오는 것들 때문이다 — 원본 위치를 기록하는 add_start_index, 마크다운 제목으로 나누는 전용 도구, 그리고 앞으로 만날 형식들.

기억할 것 두 가지다. 가져다 쓴다고 저절로 좋아지지 않는다. 그리고 직접 만들어 본 사람만 설정값의 뜻을 안다.

이제 자르기는 됐고 점수도 됐다. 남은 문제는 12강 도전 문제에서 이미 만난 그것이다 — 질문 하나에 조각을 전부 다시 인코딩하고 있다. 한 번 만든 벡터를 어디에 남겨둘 것인가.