학습 자료

겹치게 자른다 — 오버랩으로 찢긴 문장을 살린다


Article

2강에서 문제를 하나 봤다. 글자 수로 자르면 문장이 한가운데서 끊긴다. 그때는 눈으로만 확인하고 넘어갔는데, 이번 강의에서 그걸 푼다.

방법은 허무할 만큼 간단하다. 조각을 조금씩 겹치게 만든다.

겹치면 무슨 일이 생기나
  1. 잘린 대목「사용자 눈에 / 보이지 않는」이 두 조각으로 갈린다
  2. 조각을 겹친다앞 조각 끝을 다음 조각 앞에 한 번 더 넣는다
  3. 온전한 조각이 하나 생긴다찢긴 대목이 통째로 든 조각이 만들어진다

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

1. 문제를 다시 본다

2강에서 쓴 문서를 그대로 쓴다.

ai-course/91_문제확인.py
text = "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다. 요즘은 클라우드 지식도 함께 요구된다."

for start in range(0, len(text), 30):
    print(text[start:start + 30])

print("---")

# 2강의 반복문을 컴프리헨션 한 줄로 줄인 것이다. 하는 일은 같다.
chunks = [text[s:s + 30] for s in range(0, len(text), 30)]

# enumerate 로 번호를 같이 받고, if 로 거른 뒤, 담는 것은 값이 아니라 번호(i)다.
#   [ i    for i, c in enumerate(chunks)   if "..." in c ]
#     번호   번호와 값을 같이 꺼내서         이 말이 들어 있는 것만
print([i for i, c in enumerate(chunks) if "사용자 눈에 보이지 않는" in c])
터미널
  • 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자
  • 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을
  • 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다. 요
  • 즘은 클라우드 지식도 함께 요구된다.
  • ---
  • []

「사용자 눈에 보이지 않는」이 1번과 2번 조각에 반씩 걸쳐 있다. 마지막 줄의 빈 리스트가 그 증거다 — 그 표현이 온전히 든 조각이 하나도 없다. 누가 「사용자 눈에 안 보이는 일이 뭔가요」라고 물으면 이 조각들로는 제대로 답할 수 없다.

2. 겹치게 자른다

지금 코드는 30씩 건너뛰며 30글자를 가져온다. 그래서 조각끼리 딱 붙어 있다.

건너뛰는 폭만 줄이면 겹친다. 30글자를 가져오되 20씩만 건너뛰면, 매번 앞 조각의 끝 10글자가 다시 딸려온다.

건너뛰는 폭이 겹침을 만든다

size = 30 · overlap = 10 이면 step = 20

1번 조각
text[0:30]
0번부터 29번
2번 조각
text[20:50]
20~29번이 앞 조각과 겹친다
3번 조각
text[40:70]
40~49번이 겹친다

3강에서 만든 chunk_text 에 overlap 을 붙인다.

ai-course/92_오버랩.py
text = "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다. 요즘은 클라우드 지식도 함께 요구된다."


# 인자가 하나 늘었다. overlap=0 이 기본값이라 예전처럼 불러도 그대로 동작한다.
def chunk_text(text, size=50, overlap=0):
    """긴 글을 size 글자씩 자른다. overlap 만큼 앞 조각과 겹친다."""
    result = []

    # 여기가 이번 강의의 전부다.
    #   가져오는 길이(size)는 그대로 두고, 건너뛰는 폭만 줄인다.
    #   30 을 가져오면서 20 씩만 건너뛰면 매번 앞의 10 글자가 다시 딸려온다.
    step = size - overlap

    for start in range(0, len(text), step):
        piece = text[start:start + size]     # 길이는 여전히 size 만큼 가져온다
        if piece:                            # 빈 문자열은 거짓이다. 끝에서 나올 수 있다
            result.append(piece)
    return result


without = chunk_text(text, 30, 0)      # 겹침 없음
with_ov = chunk_text(text, 30, 10)     # 10글자 겹침

# 쉼표로 값을 나열하면 튜플이 된다. 두 값을 한 번에 보려고 쓴다.
print(len(without), len(with_ov))

for i, c in enumerate(with_ov):
    # {len(c)}자 처럼 중괄호 바로 뒤에 글자를 붙여도 된다. 중괄호 안만 값으로 바뀐다.
    print(f"{i}  ({len(c)}자)  {c}")

print([i for i, c in enumerate(with_ov) if "사용자 눈에 보이지 않는" in c])
터미널
  • 4 6
  • 0 (30자) 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자
  • 1 (30자) 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저
  • 2 (30자) 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는
  • 3 (30자) 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다. 요
  • 4 (30자) 이썬, 노드다. 요즘은 클라우드 지식도 함께 요구된다.
  • 5 (10자) 도 함께 요구된다.
  • [1]

1번 조각에 「사용자 눈에 보이지 않는」이 통째로 들어 있다. 앞 조각 끝의 「다룬다. 사용자」가 한 번 더 딸려오면서 찢겼던 대목이 이어졌다. 맨 아래 [1] 이 조금 전의 [] 와 대비되는 결과다.

바뀐 곳은 두 줄뿐이다. step = size - overlap 으로 덜 건너뛰게 하고, range 의 세 번째 자리를 step 으로 바꿨다. 가져오는 길이(size)는 건드리지 않았다 — 이 둘을 헷갈리면 결과가 이상해진다. 그리고 overlap 의 기본값이 0 이라 기존 코드는 그대로 돌아간다. 3강에서 배운 기본값 인자가 여기서 쓰인다.

겹침 하나로 갈린다

겹침 없음

chunk_text(text, 30, 0)

  • 조각 4개
  • 「사용자 눈에 보이지 않는」이 든 조각 — 없음
  • 경계에 걸친 표현은 검색에서 사라진다

겹침 10

chunk_text(text, 30, 10)

  • 조각 6개
  • 「사용자 눈에 보이지 않는」이 든 조각 — 1번
  • 경계가 두 번 나오므로 어느 한쪽에는 온전히 들어간다

3. 공짜가 아니다

조각이 4개에서 6개로 50% 늘었다. 겹치는 만큼 같은 내용을 두 번 저장하는 셈이다.

ai-course/93_겹침폭.py
text = "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다. 요즘은 클라우드 지식도 함께 요구된다."


def chunk_text(text, size=50, overlap=0):
    """긴 글을 size 글자씩 자른다. overlap 만큼 앞 조각과 겹친다."""
    result = []
    step = size - overlap
    for start in range(0, len(text), step):
        piece = text[start:start + size]
        if piece:
            result.append(piece)
    return result


# 리스트를 그냥 돌면 값이 하나씩 나온다. range 처럼 숫자를 만들 필요가 없을 때 쓴다.
for ov in [0, 10, 20, 25]:
    # 콜론 뒤 :2d 는 "정수를 2칸에 오른쪽 맞춤으로". 숫자가 세로로 줄 맞는다.
    print(f"겹침 {ov:2d}  ->  {len(chunk_text(text, 30, ov)):2d}조각")
터미널
  • 겹침 0 -> 4조각
  • 겹침 10 -> 6조각
  • 겹침 20 -> 11조각
  • 겹침 25 -> 22조각

겹침을 조각 크기에 가깝게 키우면 조각 수가 폭발한다. 25로 하면 step 이 5밖에 안 되어 다섯 글자마다 새 조각이 생긴다.

조각이 많아지면 뒤에서 임베딩을 그만큼 더 계산해야 하고, 검색할 때 비교할 대상도 그만큼 늘어난다. 겹침은 안전장치이지 많을수록 좋은 게 아니다.

실무에서 흔히 쓰는 값은 조각 크기의 10~20% 다. 300자로 자른다면 30~60자쯤. 근거는 「한 문장이 대체로 그 안에 들어가는 길이」다 — 겹침이 문장 하나보다 짧으면 찢긴 문장을 못 살린다. 정답은 없고 문서마다 다르므로 11강에서 이걸 숫자로 재는 법을 배운다.

4. 검사를 직접 붙인다

겹침을 조각 크기와 같게 주면 step = 30 - 30 = 0 이 되고, 0씩 건너뛰는 반복문은 영원히 제자리다. 파이썬이 그걸 알고 ValueError: range() arg 3 must not be zero 로 미리 막는다.

에러가 나는 것 자체는 다행이다. 조용히 이상한 결과를 돌려줬다면 나중에 「검색 결과가 왜 이러지」를 한참 찾게 된다. 그런데 저 메시지는 우리 함수를 쓰는 사람에게 아무 도움이 안 된다. range 를 부른 적도 없는데 그런 말을 들으니까.

ai-course/94_검사추가.py
text = "백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다. 주로 쓰는 언어는 자바, 파이썬, 노드다. 요즘은 클라우드 지식도 함께 요구된다."


def chunk_text(text, size=50, overlap=0):
    """긴 글을 size 글자씩 자른다. overlap 만큼 앞 조각과 겹친다."""
    # raise 는 "여기서 일부러 멈춘다"는 뜻이다. 4강의 try/except 의 반대편이다.
    #   raise 는 에러를 내고, except 는 받는다.
    #   에러 메시지에 f-string 으로 실제 숫자를 넣는 게 중요하다 - 무엇이 얼마여서
    #   틀렸는지가 화면에 그대로 나오고, 그 한 줄이 질문을 몇 개 줄인다.
    if overlap >= size:
        raise ValueError(f"overlap({overlap})은 size({size})보다 작아야 한다")

    result = []
    step = size - overlap
    for start in range(0, len(text), step):
        piece = text[start:start + size]
        if piece:
            result.append(piece)
    return result


print(len(chunk_text(text, 30, 10)))    # 정상은 그대로 돈다
print(chunk_text(text, 30, 30))         # 여기서 우리가 만든 에러가 난다
터미널
  • 6
  • Traceback (most recent call last):
  • File "...\94_검사추가.py", line 23, in <module>
  • print(chunk_text(text, 30, 30)) # 여기서 우리가 만든 에러가 난다
  • ~~~~~~~~~~^^^^^^^^^^^^^^
  • File "...\94_검사추가.py", line 11, in chunk_text
  • raise ValueError(f"overlap({overlap})은 size({size})보다 작아야 한다")
  • ValueError: overlap(30)은 size(30)보다 작아야 한다

트레이스백이 두 겹인 것도 봐둔다. 위쪽이 「내가 부른 곳」, 아래쪽이 「실제로 터진 곳」이다. 함수 안에서 터지면 이렇게 경로가 쌓인다.

5. search.py 에 반영한다

4강에서 만든 search.py 를 아래로 통째로 덮어쓴다. chunk_text 에 overlap 이 붙었고 build_chunks 가 그걸 넘긴다.

ai-course/search.py
# search.py - 문서를 읽고 겹치게 잘라 조각으로 만든다
import os
import glob


def load_documents(folder=None):
    """폴더 안 txt 를 전부 읽는다."""
    folder = folder or os.getenv("DOCS_DIR", "docs")
    paths = sorted(glob.glob(f"{folder}/*.txt"))

    if not paths:
        print(f"[경고] '{folder}' 에서 txt 파일을 못 찾았다.")
        return []

    result = []
    for path in paths:
        try:
            with open(path, "r", encoding="utf-8") as f:
                result.append({"source": os.path.basename(path), "text": f.read()})
        except UnicodeDecodeError:
            print(f"[건너뜀] {path} - utf-8 이 아니다")

    print(f"문서 {len(result)}건을 읽었다 (폴더: {folder})")
    return result


def chunk_text(text, size=200, overlap=30):
    """긴 글을 size 글자씩 자른다. overlap 만큼 앞 조각과 겹친다."""
    if overlap >= size:
        raise ValueError(f"overlap({overlap})은 size({size})보다 작아야 한다")

    result = []
    step = size - overlap
    for start in range(0, len(text), step):
        piece = text[start:start + size]
        if piece:
            result.append(piece)
    return result


def build_chunks(folder=None, size=200, overlap=30):
    """문서를 읽어 조각 목록으로 만든다. 조각마다 출처를 붙인다."""
    chunks = []
    for doc in load_documents(folder):
        for piece in chunk_text(doc["text"], size, overlap):
            if piece.strip():
                chunks.append({"source": doc["source"], "text": piece})
    return chunks
ai-course/95_search갱신.py
import search

# 이름을 붙여 넘기면(size=50) 순서를 안 지켜도 되고, 무엇을 주는지도 읽힌다.
a = search.build_chunks(size=50, overlap=0)
b = search.build_chunks(size=50, overlap=15)

print(len(a), len(b))
print(a[0]["source"], "|", a[0]["text"][:20])
print(b[1]["source"], "|", b[1]["text"][:20])
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 문서 3건을 읽었다 (폴더: docs)
  • 6 9
  • backend.txt | 수업용 예제
  • 백엔드 개발자는 서버와
  • backend.txt | 자 눈에 보이지 않는 곳에서 데이터를

조각이 6개에서 9개로 늘었다. 겹침이 실제 문서에도 그대로 적용된다는 뜻이다. 그리고 문서 3건을 읽었다 가 두 번 찍힌 것도 봐둔다 — build_chunks 를 두 번 불렀으니 파일도 두 번 읽었다. 지금은 문서가 셋뿐이라 괜찮지만, 문서가 많아지면 이게 낭비가 된다.

이번 강의에 나온 것

정리
쓴 것하는 일
step = size - overlap건너뛰는 폭. 겹칠수록 좁아진다
"찾을 말" in 조각이 문자열이 안에 있나 — 참/거짓
[i for i, c in enumerate(x) if 조건]조건에 맞는 번호만 모은다
if piece:빈 문자열은 거짓이다
raise ValueError(f"...")잘못된 값이면 일부러 멈춘다
f"{ov:2d}"숫자를 2칸에 오른쪽 맞춤으로 찍는다
f(size=50, overlap=15)이름을 붙여 넘기면 순서를 안 지켜도 된다

스스로 해보기

확인 문제 셋
  1. 01

    겹침을 5 · 15 로 바꿔가며 조각 수를 본다

    ai-course/96_연습1.py. 몇부터 「사용자 눈에 보이지 않는」이 온전히 들어가는지 찾아본다.

  2. 02

    겹침이 음수면 어떻게 되는지 본다

    ai-course/97_연습2.py. chunk_text(text, 30, -10) 을 돌려본다. 에러가 날까, 아니면 조용히 이상한 결과가 나올까. 조용히 이상한 쪽이 더 위험하다.

  3. 03

    겹치는 부분을 눈에 보이게 찍어본다

    ai-course/98_연습3.py. 조각마다 앞 10글자만 따로 출력하면 앞 조각의 끝과 같은지 바로 보인다.

1번 — 겹침을 바꿔가며충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

「사용자 눈에 보이지 않는」은 13글자다. 겹침이 그보다 짧으면 못 살릴 것 같은데, 실제로는 5부터 살아난다.

잘린 자리가 어디였느냐에 달렸기 때문이다. 경계가 그 표현의 앞쪽을 지나가면 조금만 겹쳐도 온전한 조각이 생긴다.

for ov in [0, 5, 10, 15]:
    cs = chunk_text(text, 30, ov)
    hit = [i for i, c in enumerate(cs) if "사용자 눈에 보이지 않는" in c]
    print(f"겹침 {ov:2d}  조각 {len(cs):2d}개  온전한 조각 {hit}")

겹침 0 → 4조각 [] · 겹침 5 → 5조각 [1] · 겹침 10 → 6조각 [1] · 겹침 15 → 8조각 [1]

겹침을 더 키워도 살아나는 조각은 그대로인데 조각 수만 는다. 5에서 이미 살아났으니 10·15는 비용만 늘린 셈이다.

다만 이건 이 문서, 이 표현에서만 그렇다. 다른 문서에서는 다르다. 그래서 눈으로 정하지 말고 재야 한다 — 11강에서 한다.

2번 — 겹침이 음수면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

에러가 안 난다. 이게 더 위험하다.

step 이 size 보다 커져서 조각 사이에 틈이 생긴다. 그 틈에 있던 글자는 어느 조각에도 안 들어가고 조용히 사라진다.

r = chunk_text(text, 30, -10)       # step 이 40 이 된다

print(len(r))                       # 3
print(len("".join(r)), len(text))   # 90 110 - 20글자가 사라졌다

"".join(r) 은 조각을 전부 이어붙인 것이다. 원문이 110자인데 90자밖에 안 나온다 — 20자가 어디에도 없다.

막으려면 검사에 if overlap < 0: raise ValueError(...) 한 줄을 더한다.

「검색이 이상한데 에러는 안 난다」의 원인 대부분이 이런 종류다. 값이 조용히 사라지는 자리를 미리 막는다.

3번 — 겹치는 부분 보기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

앞 10글자만 따로 찍으면 그게 앞 조각의 끝과 같다는 게 바로 보인다.

cs = chunk_text(text, 30, 10)
for i, c in enumerate(cs):
    print(f"{i}  겹친부분[{c[:10]}]  나머지[{c[10:]}]")

0번의 나머지 끝과 1번의 겹친부분 이 정확히 같다 — ' 다룬다. 사용자 '.

겹침은 앞 조각의 꼬리를 한 번 더 가져오는 것뿐이다. 어려운 장치가 아니다.

마지막 5번은 나머지 가 비어 있다. 조각이 10자밖에 안 되어 겹친 부분이 전부이기 때문이다.

정리하면

글자 수로만 자르면 경계에 걸친 표현이 사라진다. 건너뛰는 폭을 조각 크기보다 좁게 하면 조각끼리 겹치고, 찢겼던 대목이 어느 한 조각에는 온전히 들어간다.

대신 조각 수가 늘어 저장과 계산이 그만큼 늘고, 겹침을 조각 크기까지 키우면 아예 멈춘다. 겹침은 안전장치이지 많을수록 좋은 값이 아니다.

그런데 겹침은 증상을 덮는 쪽에 가깝다. 애초에 문장이 끝나는 자리에서 자르면 찢어질 일이 없다. 다음 강의에서 그렇게 만든다.

Share
  • 파이썬
  • AI
  • RAG
  • 청킹
  • 오버랩