학습 자료

문단과 문장 단위로 자른다 — 실무에서 쓰는 방식


Article

앞 강의에서 겹침으로 찢긴 문장을 살렸다. 그런데 겹침은 이미 잘못 자른 걸 덮는 쪽에 가깝다. 조각 수를 늘리는 대가도 치른다.

애초에 문장이 끝나는 자리에서 자르면 찢어질 일이 없다. 이번 강의에서 그렇게 만든다.

자르는 순서
  1. 문단으로 먼저 나눈다빈 줄이 경계다
  2. 짧은 문단은 그대로 둔다이미 한 덩어리다
  3. 긴 문단만 다시 자른다문장이 끝나는 자리에서
  4. 짧은 꼬리는 앞에 붙인다버리지 않는다

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

1. 문단이 있는 문서로 바꾸고, 문단으로 나눠본다

지금까지 쓰던 문서는 한 줄짜리라 문단이 없었다. 실제 문서는 그렇지 않다. docs 폴더를 다시 만들고, 그 자리에서 바로 문단을 나눠본다.

문단 사이에는 빈 줄이 있다. 빈 줄은 줄바꿈 두 개, 즉 "\n\n" 이다.

ai-course/101_문단문서.py
import os

os.makedirs("docs", exist_ok=True)

# """...""" 는 여러 줄 문자열이다. 줄바꿈을 \n 으로 적지 않고 그냥 엔터를 쳐서 넣는다.
files = {
    "backend.txt": """수업용 예제

백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보이지 않는 곳에서 데이터를 저장하고 꺼내는 일을 한다.

주로 쓰는 언어는 자바, 파이썬, 노드다. 회사마다 다르지만 하나를 깊게 아는 편이 여럿을 얕게 아는 것보다 낫다. 요즘은 클라우드 지식도 함께 요구된다. 서버를 직접 사는 회사가 줄었기 때문이다.

신입에게 가장 많이 물어보는 것은 데이터베이스다. 인덱스가 무엇인지, 조인이 왜 느려지는지를 설명할 수 있으면 절반은 됐다.""",

    "frontend.txt": """수업용 예제

프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버튼을 누르면 무슨 일이 일어나는지를 설계한다.

리액트와 뷰가 많이 쓰인다. 요즘은 타입스크립트를 같이 쓰는 곳이 대부분이다.

화면이 느리면 사용자가 바로 떠난다. 그래서 성능을 재는 방법을 아는 것이 중요하다.""",

    "data.txt": """수업용 예제

데이터 엔지니어는 흩어진 데이터를 모아 쓸 수 있는 형태로 만든다. 파이프라인이라고 부르는 흐름을 짠다.

파이썬과 SQL 을 쓴다. 데이터가 커지면 도구가 달라지지만 생각하는 방식은 같다.

수집한 데이터가 틀리면 그 뒤가 전부 틀린다. 그래서 검증에 시간을 많이 쓴다.""",
}

for name, content in files.items():
    with open(f"docs/{name}", "w", encoding="utf-8") as f:
        f.write(content)

# 빈 줄을 기준으로 자르면 문단이 나온다.
# "\n" 은 글자 두 개가 아니라 줄바꿈 하나를 뜻하는 표기다.
doc = files["backend.txt"]
paras = doc.split("\n\n")
print(len(doc), len(paras))

for i, p in enumerate(paras):
    # {p[:30]} - 중괄호 안에서 슬라이싱도 된다. 문단이 길어서 앞 30글자만 본다.
    print(f"{i}  ({len(p)}자)  {p[:30]}")
터미널
  • 255 4
  • 0 (6자) 수업용 예제
  • 1 (64자) 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자
  • 2 (110자) 주로 쓰는 언어는 자바, 파이썬, 노드다. 회사마다 다
  • 3 (69자) 신입에게 가장 많이 물어보는 것은 데이터베이스다. 인덱

2번 문단이 110자로 혼자 길다. 문단이 항상 적당한 크기인 건 아니다. 어떤 건 6자고 어떤 건 110자다.

2. 한 번 자르는 함수

긴 문단을 자를 때, 정확히 60자에서 끊지 말고 60자 근처에서 가장 가까운 문장 끝을 찾는다. rfind() 가 그 일을 한다.

찾기 함수 셋
s.find(x)
앞에서부터 x 의 위치
없으면 -1
s.rfind(x)
뒤에서부터 x 의 위치
r 은 right. 없으면 -1
s[:n].rfind(x)
앞 n 글자 안에서만 뒤에서부터
구간을 좁히는 법

-1 을 조심해야 한다. 못 찾으면 -1 이 나오는데, 그걸 그대로 자르는 위치로 쓰면 「끝에서 한 글자 앞」이 되어 엉뚱하게 잘린다. 아래 함수가 -1 을 세 번 걸러내는 이유다.

ai-course/102_한번자르기.py
with open("docs/backend.txt", "r", encoding="utf-8") as f:
    doc = f.read()

paras = doc.split("\n\n")


def cut_once(text, size):
    """size 근처에서 문장이 끝나는 자리를 찾아 한 번 자른다. (앞, 뒤) 로 돌려준다."""
    if len(text) <= size:
        return text, ""                  # 자를 필요가 없다. 빈 문자열을 뒤로 준다

    window = text[:size]                 # 자를 후보 구간. 이 안에서만 찾는다
    cut = window.rfind(". ")             # 1. 문장 끝을 먼저 찾는다 - 제일 좋은 자리

    # or 는 "둘 중 하나라도 참이면".
    #   cut == -1        문장 끝이 아예 없다
    #   cut < size // 2  있긴 한데 너무 앞이다. // 는 몫만 남기는 나눗셈(60//2=30).
    if cut == -1 or cut < size // 2:
        cut = window.rfind(" ")          # 2. 단어 경계로 물러선다

    if cut == -1:                        # 3. 공백조차 없으면 (URL·코드 같은 것)
        cut = size                       #    어쩔 수 없이 글자 수로 자른다
    else:
        # rfind 는 찾은 것의 시작 위치를 준다. ". " 의 시작이 22면 마침표가 22번이다.
        # 23부터 잘라야 마침표가 앞 조각에 남는다.
        cut += 1

    # .strip() 은 앞뒤 공백을 없앤다. ". " 뒤에서 자르면 다음 조각이 공백으로 시작한다.
    return text[:cut].strip(), text[cut:].strip()


# 함수가 값 두 개를 돌려주므로 받는 이름도 두 개다. 순서대로 들어간다.
head, rest = cut_once(paras[2], 60)

print(f"앞  ({len(head)}자)  {head}")
print(f"뒤  ({len(rest)}자)  {rest}")
터미널
  • 앞 (56자) 주로 쓰는 언어는 자바, 파이썬, 노드다. 회사마다 다르지만 하나를 깊게 아는 편이 여럿을 얕게 아는
  • 뒤 (53자) 것보다 낫다. 요즘은 클라우드 지식도 함께 요구된다. 서버를 직접 사는 회사가 줄었기 때문이다.
세 단계로 물러선다
  1. 01

    ① 문장 끝을 찾는다 — rfind(". ")

    제일 좋은 자리다. 여기서 자르면 문장이 통째로 남는다.

  2. 02

    ② 없으면 단어 경계로 — rfind(" ")

    cut < size // 2 는 「문장 끝이 너무 앞에 있으면」이라는 뜻이다. 60자로 자르려는데 문장 끝이 5번째면, 55자를 버리는 셈이라 차라리 단어 경계가 낫다.

  3. 03

    ③ 그것도 없으면 글자 수로

    공백 없이 이어진 긴 문자열(URL, 코드 등)이면 별수 없다. 최후의 수단이 있어야 함수가 어떤 입력에도 안 멈춘다.

  4. 04

    cut += 1 을 잊지 않는다

    rfind 는 찾은 것의 시작 위치를 준다. ". " 의 시작이 22면 마침표가 22번이므로, 23부터 잘라야 마침표가 앞 조각에 남는다.

3. 문서 전체에 적용한다

문단마다 cut_once 를 반복해서 부른다. 자르고 남은 뒤쪽을 다시 자르는 식이다.

ai-course/103_전체적용.py
with open("docs/backend.txt", "r", encoding="utf-8") as f:
    doc = f.read()


def cut_once(text, size):
    """size 근처에서 문장이 끝나는 자리를 찾아 한 번 자른다."""
    if len(text) <= size:
        return text, ""
    window = text[:size]
    cut = window.rfind(". ")
    if cut == -1 or cut < size // 2:
        cut = window.rfind(" ")
    if cut == -1:
        cut = size
    else:
        cut += 1
    return text[:cut].strip(), text[cut:].strip()


def chunk_smart(text, size=60):
    """문단으로 나누고, 긴 문단은 문장 끝에서 자른다."""
    out = []
    # 컴프리헨션 안에서 두 가지를 한다 - 앞뒤 공백을 없애고(.strip()),
    # 그러고도 내용이 남는 것만 담는다(if p.strip()). 빈 문단이 걸러진다.
    for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
        rest = para                      # 아직 안 자른 나머지
        # while 은 "조건이 참인 동안 반복". 문자열이 비면 거짓이 되어 멈춘다.
        while rest:
            head, rest = cut_once(rest, size)
            if not head:
                break                    # 안전장치 - 못 자르면 무한 반복이 된다
            out.append(head)
    return out


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

문장은 안 찢어졌다. 대신 짧은 꼬리가 셋 생겼다. 「일을 한다.」 6자, 「있으면 절반은 됐다.」 11자.

이 조각들은 혼자서는 아무 뜻이 없다. 「일을 한다」만 들어 있는 조각이 검색에 걸려봐야 쓸모가 없다.

4. 짧은 꼬리를 앞에 붙인다

3강에서 짧은 조각을 걸러내며 「버리는 대신 앞 조각에 붙인다」 고 적어뒀다. 이제 그걸 만든다.

ai-course/104_꼬리붙이기.py
with open("docs/backend.txt", "r", encoding="utf-8") as f:
    doc = f.read()


def cut_once(text, size):
    """size 근처에서 문장이 끝나는 자리를 찾아 한 번 자른다."""
    if len(text) <= size:
        return text, ""
    window = text[:size]
    cut = window.rfind(". ")
    if cut == -1 or cut < size // 2:
        cut = window.rfind(" ")
    if cut == -1:
        cut = size
    else:
        cut += 1
    return text[:cut].strip(), text[cut:].strip()


def chunk_smart(text, size=60, min_size=20):
    """문단으로 나누고, 긴 문단은 문장 끝에서 자른다. 짧은 꼬리는 앞 조각에 붙인다."""
    out = []
    for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
        rest = para
        while rest:
            head, rest = cut_once(rest, size)
            if not head:
                break
            # and 는 "둘 다 참이어야".
            #   len(head) < min_size   너무 짧고
            #   and out                앞에 붙일 조각이 있어야 한다
            if len(head) < min_size and out:
                # out[-1] 은 마지막 항목이다 (2강의 음수 번호).
                # 사이에 공백 하나를 넣어야 단어가 붙어버리지 않는다.
                out[-1] = out[-1] + " " + head
            else:
                out.append(head)
    return out


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

7조각에서 5조각이 됐다. 6자짜리 「일을 한다.」와 11자짜리 「있으면 절반은 됐다.」가 각각 앞 조각에 붙어 1번과 4번이 문단 전체를 담은 조각이 됐다.

and out 이 중요하다. 앞에 붙일 조각이 있어야 붙일 수 있다. 그래서 0번 「수업용 예제」 6자는 첫 조각이라 앞에 붙일 데가 없어 그대로 남았다.

고치는 방법은 있다. 첫 조각이 짧으면 다음 조각에 붙이거나, 제목처럼 보이는 줄을 아예 건너뛰면 된다. 다만 예외를 하나씩 막다 보면 함수가 금방 읽기 어려워진다. 어디까지 막을지 정하는 것도 설계다. 지금은 「20자 미만이 하나 남는다」를 알고 넘어가는 편이 낫다 — 모르고 남는 것과 알고 남기는 것은 다르다. 3번 문제에서 이걸 직접 고쳐본다.

5. search.py 에 반영한다

search.py 를 아래로 통째로 덮어쓴다. 9강의 chunk_text 도 지우지 않고 남겨둔다 — 11강에서 두 방식을 비교할 때 필요하다.

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):
    """글자 수로만 자른다. 9강에서 만든 것 - 11강에서 비교 대상으로 쓴다."""
    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 cut_once(text, size):
    """size 근처에서 문장이 끝나는 자리를 찾아 한 번 자른다."""
    if len(text) <= size:
        return text, ""
    window = text[:size]
    cut = window.rfind(". ")
    if cut == -1 or cut < size // 2:
        cut = window.rfind(" ")
    if cut == -1:
        cut = size
    else:
        cut += 1
    return text[:cut].strip(), text[cut:].strip()


def chunk_smart(text, size=200, min_size=60):
    """문단으로 나누고, 긴 문단은 문장 끝에서 자른다. 짧은 꼬리는 앞에 붙인다."""
    out = []
    for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
        rest = para
        while rest:
            head, rest = cut_once(rest, size)
            if not head:
                break
            if len(head) < min_size and out:
                out[-1] = out[-1] + " " + head
            else:
                out.append(head)
    return out


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

chunks = search.build_chunks(size=60, min_size=20)
print(len(chunks))

for c in chunks[:4]:
    print(f'{c["source"]:14s} ({len(c["text"])}자)  {c["text"][:34]}')
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 13
  • backend.txt (6자) 수업용 예제
  • backend.txt (64자) 백엔드 개발자는 서버와 데이터베이스를 다룬다. 사용자 눈에 보
  • backend.txt (56자) 주로 쓰는 언어는 자바, 파이썬, 노드다. 회사마다 다르지만
  • backend.txt (53자) 것보다 낫다. 요즘은 클라우드 지식도 함께 요구된다. 서버를

f'{c["source"]:14s}' 는 파일명을 14칸에 왼쪽 맞춤으로 찍는다. 길이가 제각각인 값을 세로로 줄 맞출 때 쓰는 표기다.

이번 강의에 나온 것

정리
쓴 것하는 일
"""여러 줄"""줄바꿈이 든 문자열을 그대로 적는다
text.split("\n\n")빈 줄을 경계로 문단을 나눈다
s.find(x) · s.rfind(x)앞에서 / 뒤에서 찾는다. 없으면 -1
s[:n].rfind(x)구간을 좁혀 찾는다
s.strip()앞뒤 공백을 없앤다
return a, b값 두 개를 한 번에 돌려준다
head, rest = f(...)돌려받은 두 값을 각각 받는다
out[-1] = out[-1] + x마지막 항목에 이어붙인다
while rest:남은 게 없을 때까지 반복
size // 2몫만 남기는 나눗셈 (정수)
f"{x:14s}"14칸에 왼쪽 맞춤. 세로 줄이 맞는다

스스로 해보기

확인 문제 셋
  1. 01

    min_size 를 0 · 40 · 60 으로 바꿔본다

    ai-course/106_연습1.py. 조각 수가 어떻게 달라지나. 60에서 이상한 일이 생긴다. 왜 그런지 생각해본다.

  2. 02

    물음표로 끝나는 문장을 넣어본다

    ai-course/107_연습2.py. 지금 cut_once 는 ". " 만 찾는다. 물음표로만 끝나는 문단은 어디서 잘리나. 고치려면 어디를 손대야 하나.

  3. 03

    첫 조각이 짧을 때 다음 조각에 붙여본다

    ai-course/108_연습3.py. 「수업용 예제」가 남는 문제다. 힌트는 「일단 다 만든 뒤에 앞에서부터 다시 훑는다」.

1번 — min_size 를 올리면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

실측하면 0 → 7조각 · 20 → 5 · 40 → 5 · 60 → 1 이다.

60에서 갑자기 1이 되는 이유는 모든 조각이 60자 미만이라 전부 앞으로 붙어버리기 때문이다. size 가 60이니 조각이 60자를 넘을 수가 없다.

for m in [0, 20, 40, 60]:
    print(f"min_size {m:2d}  ->  {len(chunk_smart(doc, 60, m))}조각")

`min_size` 는 `size` 에 가까우면 안 된다. 가까울수록 조각이 하나로 뭉치고, 같아지면 문서 전체가 조각 하나가 된다.

경험적으로는 size 의 3분의 1 정도가 무난하다. 60으로 자른다면 20 안팎.

에러도 안 나고 결과도 그럴듯해서 알아채기 어려운 종류의 실수다. 조각 수를 찍어보는 습관이 이런 걸 잡는다.

2번 — 물음표로 끝나는 문장충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

지금 cut_once 는 ". " 만 찾는다. 물음표로만 끝나는 문단은 문장 끝을 못 찾아 단어 경계로 물러선다.

찾을 기호를 늘리면 된다. 여러 후보 중 가장 뒤에 있는 것을 골라야 한다.

물음문단 = "인덱스가 무엇인지, 조인이 왜 느려지는지를 설명할 수 있을까? 그 둘만 알아도 신입 면접의 절반은 통과한 셈이라고들 한다"


def cut_once(text, size, marks=(". ",)):
    """marks 에 든 기호 중 가장 뒤에 있는 자리에서 자른다."""
    if len(text) <= size:
        return text, ""
    window = text[:size]
    # 후보마다 위치를 구해 가장 뒤에 있는 것을 고른다.
    # 못 찾은 것은 -1 이라 max 가 알아서 걸러준다.
    cut = max(window.rfind(m) for m in marks)
    if cut == -1 or cut < size // 2:
        cut = window.rfind(" ")
    if cut == -1:
        cut = size
    else:
        cut += 1
    return text[:cut].strip(), text[cut:].strip()


a, b = cut_once(물음문단, 60)                      # 마침표만 찾을 때
print(f"[{len(a)}자] {a}")
print(f"[{len(b)}자] {b}")

a, b = cut_once(물음문단, 60, (". ", "? ", "! "))   # 물음표까지 찾을 때
print(f"[{len(a)}자] {a}")
print(f"[{len(b)}자] {b}")

마침표만 찾으면 58자 + 8자 로 잘린다. 문장 한가운데인 「통과한 / 셈이라고들 한다」에서 끊겼다.

물음표까지 찾으면 34자 + 32자 다. 물음표 자리에서 깔끔하게 갈렸다.

max(window.rfind(m) for m in marks) 가 요령이다. 못 찾은 것은 -1 이라 자연히 밀려난다 — 따로 걸러낼 필요가 없다.

3번 — 첫 조각이 짧을 때충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

다 만든 뒤에 앞에서 한 번 훑는 것이 제일 간단하다. 자르는 도중에 처리하려 들면 조건이 복잡해진다.

def chunk_smart(text, size=60, min_size=20):
    out = []
    for para in [p.strip() for p in text.split("\n\n") if p.strip()]:
        rest = para
        while rest:
            head, rest = cut_once(rest, size)
            if not head:
                break
            if len(head) < min_size and out:
                out[-1] = out[-1] + " " + head
            else:
                out.append(head)

    # 첫 조각은 붙일 앞이 없었다. 다 만든 뒤 뒤쪽에 붙인다.
    if len(out) > 1 and len(out[0]) < min_size:
        out[1] = out[0] + " " + out[1]
        out = out[1:]

    return out


cs = chunk_smart(doc)
print(len(cs))
for i, c in enumerate(cs):
    print(f"{i}  ({len(c)}자)  {c[:40]}")

5조각 → 4조각이 되고 첫 조각이 6자에서 71자가 된다.

len(out) > 1 검사가 없으면 조각이 하나뿐일 때 그것마저 지워진다. 리스트를 자르는 코드에는 거의 항상 이런 검사가 필요하다.

정리하면

문단으로 먼저 나누면 문장이 안 찢어지고, 긴 문단만 문장이 끝나는 자리에서 다시 자르면 길이도 통제된다. 짧게 남은 꼬리는 버리지 않고 앞 조각에 붙인다.

여기까지가 자르는 이야기다. 그런데 방식이 셋이 됐다 — 글자 수, 겹침, 문단+문장. 어느 게 제일 나은가? 다음 강의에서 그걸 말이 아니라 숫자로 정한다.

Share
  • 파이썬
  • AI
  • RAG
  • 청킹
  • 문자열 처리