학습 자료

어느 자르기가 나은가 — 말이 아니라 숫자로 정한다


Article

자르는 방식이 셋이 됐다. 글자 수, 겹침, 문단+문장. 마지막 것이 제일 정교해 보이지만 그건 느낌이다.

이번 강의에서 재본다. 그리고 결과가 예상과 반대로 나온다.

재는 순서
  1. 정답을 아는 질문을 모은다이 질문의 답은 저 문서에 있다
  2. 방식마다 조각을 만든다글자수 · 겹침 · 문단+문장
  3. 정답이 몇 위에 오는지 센다3강의 검색기로
  4. 숫자로 비교한다1위 적중 · 평균 순위

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

1. 먼저 search.py 를 마무리한다

10강에서 build_chunks 를 문단+문장 방식으로 바꿨다. 비교하려면 옛 방식으로 조각을 만드는 길도 있어야 한다. search.py 를 아래로 통째로 덮어쓴다. 10강 것과 달라진 곳은 맨 아래 build_chunks_plain 하나뿐이다.

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강에서 만든 것."""
    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):
    """문단으로 나누고, 긴 문단은 문장 끝에서 자른다. 10강에서 만든 것."""
    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


def build_chunks_plain(folder=None, size=200, overlap=30):
    """글자 수로만 자른다. 문단+문장 방식과 비교하려고 남겨둔다."""
    chunks = []
    for doc in load_documents(folder):
        # 9강에서 만든 chunk_text 를 쓴다. build_chunks 는 10강의 chunk_smart 를 쓴다.
        # 부르는 함수 하나만 다르고 나머지는 같다.
        for piece in chunk_text(doc["text"], size, overlap):
            if piece.strip():           # 공백뿐인 조각은 버린다
                chunks.append({"source": doc["source"], "text": piece})
    return chunks

build_chunks 와 build_chunks_plain 이 나란히 있다. 안쪽에서 부르는 함수 하나만 다르고 나머지는 같다. 이렇게 두면 같은 문서를 두 방식으로 잘라 옆에 놓고 잴 수 있다.

새 방식을 만들었다고 옛 방식을 지우면 비교할 대상이 사라진다. 「좋아졌다」고 말하려면 무엇보다 좋아졌는지 옆에 있어야 한다 — 10강에서 chunk_text 를 안 지우고 남겨둔 것도 이걸 위해서였다. 실무에서 옛 구현을 한동안 남겨두는 이유가 같다.

2. 정답을 아는 질문을 만든다

품질을 재려면 정답이 있어야 한다. 문서를 우리가 만들었으니 어느 질문의 답이 어디 있는지 안다.

ai-course/111_질문표.py
# 소괄호 ( ) 로 묶은 것이 튜플이다. 리스트와 거의 같은데 한 번 만들면 못 바꾼다.
# 이름을 대문자로 쓴 것은 "이건 고정값"이라는 관례다. 문법이 아니라 약속이다.
QUESTIONS = [
    ("인덱스가 뭔지 물어본다",        "backend.txt"),   # (질문, 답이 있는 파일)
    ("화면이 느리면 어떻게 되나",      "frontend.txt"),
    ("데이터가 틀리면 어떻게 되나",    "data.txt"),
    ("타입스크립트를 쓰나",           "frontend.txt"),
    ("파이프라인을 짜는 일",          "data.txt"),
    ("서버와 데이터베이스를 다루는 일", "backend.txt"),
]

print(len(QUESTIONS))
print(QUESTIONS[0])
print(QUESTIONS[0][0], "->", QUESTIONS[0][1])
터미널
  • 6
  • ('인덱스가 뭔지 물어본다', 'backend.txt')
  • 인덱스가 뭔지 물어본다 -> backend.txt

리스트 안에 튜플이 들어 있다. QUESTIONS[0] 이 튜플 하나, QUESTIONS[0][0] 이 그 안의 첫 값이다. 대괄호를 두 번 쓰는 것은 3강의 docs[0][:20] 과 같은 요령이다.

정답 6개로 충분한가? 아니다. 실무에서는 수십~수백 개를 만든다. 다만 0개와 6개의 차이가 6개와 60개의 차이보다 훨씬 크다. 하나도 없으면 「좋아진 것 같다」밖에 말할 수 없고, 여섯 개만 있어도 「4/6에서 6/6이 됐다」고 말할 수 있다. 먼저 재기 시작하는 것이 정확히 재는 것보다 앞선다.

3. 정답이 몇 위에 오는지 센다

3강의 점수 함수를 그대로 쓴다.

ai-course/112_순위재기.py
import search

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


def score(query, text):
    """질문 글자 중 몇 %가 조각 안에 들어 있는지 센다."""
    hit = 0
    for ch in query:
        if ch in text:
            hit += 1
    return hit / len(query)


def rank_of_answer(chunks, query, answer_file):
    """정답 문서에서 나온 조각이 몇 위에 오는지 돌려준다."""
    # 3강의 정렬 그대로다. 달라진 건 text 대신 source 를 담는다는 것뿐이다.
    ranked = sorted(
        [{"score": score(query, c["text"]), "source": c["source"]} for c in chunks],
        key=lambda r: r["score"],
        reverse=True,
    )
    # enumerate 는 0부터 세는데 순위는 1부터다. start=1 하나로 해결된다.
    for i, r in enumerate(ranked, start=1):
        if r["source"] == answer_file:
            return i                    # return 을 만나면 함수가 그 자리에서 끝난다
    # 정답 문서 조각이 하나도 없는 경우다.
    # 없는 걸 0이나 -1로 돌려주면 평균을 낼 때 조용히 결과를 왜곡한다.
    return len(ranked) + 1


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

for q, a in QUESTIONS:
    print(rank_of_answer(chunks, q, a), q)
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 13
  • 1 인덱스가 뭔지 물어본다
  • 2 화면이 느리면 어떻게 되나
  • 2 데이터가 틀리면 어떻게 되나
  • 1 타입스크립트를 쓰나
  • 1 파이프라인을 짜는 일
  • 1 서버와 데이터베이스를 다루는 일

여섯 중 넷만 1위다. 두 질문은 정답이 2위로 밀렸다.

4. 세 방식을 나란히 잰다

ai-course/113_세방식비교.py
import search

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


def score(query, text):
    hit = 0
    for ch in query:
        if ch in text:
            hit += 1
    return hit / len(query)


def rank_of_answer(chunks, query, answer_file):
    ranked = sorted(
        [{"score": score(query, c["text"]), "source": c["source"]} for c in chunks],
        key=lambda r: r["score"],
        reverse=True,
    )
    for i, r in enumerate(ranked, start=1):
        if r["source"] == answer_file:
            return i
    return len(ranked) + 1


# 튜플 (이름, 조각목록) 세 개를 리스트에 담는다.
cases = [
    ("글자수 60 · 겹침 0",  search.build_chunks_plain(size=60, overlap=0)),
    ("글자수 60 · 겹침 15", search.build_chunks_plain(size=60, overlap=15)),
    ("문단+문장 60",        search.build_chunks(size=60, min_size=20)),
]

print("---")

# 튜플이 들어 있으니 받는 이름도 두 개다 - name 에 이름, chunks 에 조각 목록.
for name, chunks in cases:
    ranks = [rank_of_answer(chunks, q, a) for q, a in QUESTIONS]

    # sum(1 for ... if 조건) 은 "조건에 맞는 것이 몇 개인가"를 세는 흔한 표현이다.
    top1 = sum(1 for r in ranks if r == 1)
    avg = sum(ranks) / len(ranks)

    # {name:18s} 는 "문자열을 18칸에 왼쪽 맞춤". 세로로 줄이 맞는다.
    print(f"{name:18s} 조각 {len(chunks):2d}개  1위 {top1}/{len(QUESTIONS)}  평균 {avg:.2f}  {ranks}")
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 문서 3건을 읽었다 (폴더: docs)
  • 문서 3건을 읽었다 (폴더: docs)
  • ---
  • 글자수 60 · 겹침 0 조각 11개 1위 6/6 평균 1.00 [1, 1, 1, 1, 1, 1]
  • 글자수 60 · 겹침 15 조각 14개 1위 6/6 평균 1.00 [1, 1, 1, 1, 1, 1]
  • 문단+문장 60 조각 13개 1위 4/6 평균 1.33 [1, 2, 2, 1, 1, 1]

5. 왜 이렇게 나왔나

2위로 밀린 질문을 다시 던져보되, 이번엔 점수와 길이까지 같이 본다.

ai-course/114_왜이런가.py
import search


def score(query, text):
    hit = 0
    for ch in query:
        if ch in text:
            hit += 1
    return hit / len(query)


chunks = search.build_chunks(size=60, min_size=20)
query = "화면이 느리면 어떻게 되나"      # 정답은 frontend.txt

# 이번엔 순위만이 아니라 점수·길이·원문까지 봐야 해서 딕셔너리에 셋을 담는다.
ranked = sorted(
    [{"s": score(query, c["text"]), "src": c["source"], "t": c["text"]} for c in chunks],
    key=lambda r: r["s"],
    reverse=True,
)

for i, r in enumerate(ranked[:4], start=1):     # 위에서 4개만
    print(f'{i}위 {r["s"]:.2f}  {len(r["t"]):2d}자  {r["src"]:14s} {r["t"][:32]}')
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 1위 0.64 69자 backend.txt 신입에게 가장 많이 물어보는 것은 데이터베이스다. 인덱스가
  • 2위 0.64 57자 frontend.txt 프론트엔드 개발자는 사용자가 직접 보는 화면을 만든다. 버
  • 3위 0.64 47자 frontend.txt 화면이 느리면 사용자가 바로 떠난다. 그래서 성능을 재는
  • 4위 0.50 56자 backend.txt 주로 쓰는 언어는 자바, 파이썬, 노드다. 회사마다 다르지

두 가지가 보인다.

① 진짜 정답이 3위다. 「화면이 느리면 사용자가 바로 떠난다」가 질문에 정확히 답하는 조각인데 3위에 있다.

② 1·2·3위가 전부 0.64로 동점이다. 점수가 같으니 순서는 원래 있던 순서일 뿐이다. 사실상 아무것도 못 고른 것이다.

6. 점수가 길이를 따라간다

1위에 오른 조각은 69자로 가장 길다. 우연일까. 조각 길이와 점수를 나란히 놓아본다.

ai-course/115_길이와점수.py
import search


def score(query, text):
    hit = 0
    for ch in query:
        if ch in text:
            hit += 1
    return hit / len(query)


chunks = search.build_chunks(size=60, min_size=20)
query = "화면이 느리면 어떻게 되나"

# (길이, 점수) 튜플을 만들어 정렬한다.
# 튜플을 정렬하면 앞 항목부터 비교하므로, 길이 순으로 줄이 선다.
# key= 를 안 줘도 되는 경우다 - 무엇으로 비교할지가 이미 정해져 있다.
rows = sorted([(len(c["text"]), score(query, c["text"])) for c in chunks])

for n, s in rows:       # 튜플이 두 칸이니 받는 이름도 두 개
    print(f"{n:2d}자  ->  {s:.2f}")
터미널
  • 문서 3건을 읽었다 (폴더: docs)
  • 6자 -> 0.21
  • 6자 -> 0.21
  • 6자 -> 0.21
  • 43자 -> 0.36
  • 44자 -> 0.50
  • 46자 -> 0.43
  • 47자 -> 0.64
  • 53자 -> 0.29
  • 56자 -> 0.50
  • 57자 -> 0.64
  • 58자 -> 0.36
  • 64자 -> 0.29
  • 69자 -> 0.64
길이가 늘면 점수도 는다 (같은 질문 기준)
6자0.21점
43자0.36점
47자0.64점
57자0.64점
69자0.64점
6자 조각은 무슨 내용이든 0.21 을 넘지 못한다

원인이 여기 있다. 우리 점수는 「질문 글자가 조각 안에 있나」를 센다. 조각이 길수록 아무 글자나 들어 있을 확률이 올라간다.

6자짜리 조각은 내용이 무엇이든 0.21이 한계다. 69자짜리는 내용이 무관해도 0.64가 나온다. 점수가 내용이 아니라 길이를 재고 있다.

글자수로 자르면 조각이 전부 60자 근처라 길이 효과가 모두에게 똑같이 걸려 편향이 안 드러난다. 문단+문장으로 자르면 6자부터 69자까지 제각각이라 긴 조각이 무조건 유리해진다. 즉 이 실험이 보여준 것은 「문단+문장 방식이 나쁘다」가 아니다. **「우리 점수가 길이에 속는다」**다.

그럼 어떻게 고치나

고치는 두 방향
무엇을어떻게언제
점수를 고친다길이로 나누거나, 글자가 아니라 단어를 세거나아래 미션에서 직접
점수를 갈아치운다글자 세기를 버리고 임베딩으로 뜻을 비교한다다음 강의부터

이번 강의에 나온 것

정리
쓴 것하는 일
("질문", "정답파일")튜플. 리스트와 비슷하나 못 바꾼다
QUESTIONS[0][1]튜플 안의 값. 대괄호를 두 번
enumerate(x, start=1)1부터 센다
sum(1 for r in x if 조건)조건에 맞는 개수를 센다
sorted([(a, b), ...])튜플을 정렬하면 앞 항목부터 비교
f"{name:18s}"18칸에 왼쪽 맞춤
f"{avg:.2f}"소수점 둘째 자리
query.split()공백으로 단어를 나눈다
def f(..., score_fn=score)함수를 인자로 받는다. 괄호를 안 붙인다

미션

여기까지가 「문서를 조각으로 만드는 일」 전부다. 이제 스스로 판단할 차례다.

미션
  1. 01

    [필수] 내 문서를 넣고 질문 두 개를 만든다

    ai-course/116_미션필수.py. docs 폴더에 txt 를 하나 더 넣고, 그 문서에만 답이 있는 질문 두 개를 QUESTIONS 에 추가해 다시 재본다. 내 문서에서도 같은 경향이 나오나.

  2. 02

    [응용] 글자 대신 단어를 세본다

    ai-course/117_미션응용.py. 점수 함수를 query.split() 으로 단어를 세도록 바꿔 다시 잰다. 문단+문장 방식이 올라오나. 올라온다면 문제는 자르기가 아니라 점수였다는 뜻이다.

  3. 03

    [도전] 길이 편향을 직접 없애본다

    ai-course/118_미션도전.py. 점수를 조각 길이로 나눠보거나, 아주 짧은 조각을 아예 후보에서 빼보거나, 동점일 때 짧은 쪽을 앞세워본다. 셋 중 무엇이 가장 효과가 컸나.

[필수] 내 문서로 질문 만들기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

QUESTIONS 에 튜플 두 개를 더하기만 하면 된다. 재는 코드는 손대지 않는다.

# 파일을 하나 더 넣고
with open("docs/mine.txt", "w", encoding="utf-8") as f:
    f.write("수업용 예제\n\n내가 좋아하는 것은 영화와 산책이다. 주말에는 주로 걷는다.")

# 그 문서에만 답이 있는 질문을 더한다
QUESTIONS = QUESTIONS + [
    ("주말에 주로 뭘 하나", "mine.txt"),
    ("좋아하는 것이 뭔가",   "mine.txt"),
]

cases = [
    ("글자수 60 · 겹침 0",  search.build_chunks_plain(size=60, overlap=0)),
    ("문단+문장 60",        search.build_chunks(size=60, min_size=20)),
]

for name, chunks in cases:
    ranks = [rank_of_answer(chunks, q, a) for q, a in QUESTIONS]
    print(f"{name:18s} 1위 {sum(1 for r in ranks if r == 1)}/{len(QUESTIONS)}  {ranks}")

글자수 60 · 겹침 0 → 8/8 · 문단+문장 60 → 6/8 이 나온다. 경향이 그대로다 — 내 문서를 넣어도 길이 편향은 사라지지 않는다.

문서 4건을 읽었다 로 바뀌는 것도 확인한다. 안 바뀌었으면 파일이 다른 폴더에 만들어진 것이다.

질문을 늘릴수록 결과가 흔들린다. 그게 정상이고, 여섯 개가 적다는 증거다.

[응용] 글자 대신 단어 세기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

점수 함수를 하나 더 만들고 인자로 끼워 넣는다. 파이썬에서 함수는 값이라 변수에 담고 넘길 수 있다.

score_fn=score 는 「안 주면 기존 score 를 쓴다」는 뜻이다. 기존 호출은 그대로 두고 새 방법만 끼울 수 있다. 3강의 sorted(key=lambda ...) 도 같은 원리다.

score 와 score() 는 다르다. 괄호가 없으면 함수 자체, 있으면 「지금 실행한 결과」다.

def score_words(query, text):
    """질문의 단어 중 몇 개가 조각에 들어 있나."""
    # .split() 을 기준 없이 부르면 공백으로 나눈다. 단어 목록이 된다.
    words = query.split()
    return sum(1 for w in words if w in text) / len(words)


# 인자가 하나 늘었다. 괄호 없이 score 라고만 쓴 것에 주의한다.
def rank_of_answer(chunks, query, answer_file, score_fn=score):
    ranked = sorted(
        [{"score": score_fn(query, c["text"]), "source": c["source"]} for c in chunks],
        key=lambda r: r["score"],
        reverse=True,
    )
    for i, r in enumerate(ranked, start=1):
        if r["source"] == answer_file:
            return i
    return len(ranked) + 1


for name, chunks in cases:
    ranks = [rank_of_answer(chunks, q, a, score_words) for q, a in QUESTIONS]
    print(f"{name:18s} 1위 {sum(1 for r in ranks if r == 1)}/{len(QUESTIONS)}  {ranks}")

기본 질문 여섯 개로는 세 방식이 전부 5/6 으로 같아진다. 글자 세기일 때 6/6 이던 것이 내려오고 4/6 이던 것이 올라온다. 점수 함수 하나를 바꿨을 뿐인데 순위가 뒤집혔다 — 아까의 4/6 은 자르기의 문제가 아니었다.

「나빠진 쪽」도 봐야 한다. 글자수 방식이 6/6 에서 5/6 으로 내려간 것은 손해가 아니라, 원래 6/6 이 과대평가였다는 뜻이다.

다만 다섯 번째 질문(「파이프라인을 짜는 일」)이 세 방식 모두 7~9위로 밀린다. 단어 세기는 단어가 통째로 일치해야 해서 「짜는」 같은 활용형을 못 잡는다.

한 편향을 지우면 다른 편향이 나온다. 글자 세기는 길이에 속고, 단어 세기는 활용형에 약하다. 한국어는 조사와 어미가 붙어 이 문제가 특히 심하다. 근본은 그대로다 — 글자든 단어든 「같은 글자가 있나」를 볼 뿐 뜻을 모른다.

[도전] 길이 편향 없애기충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

셋 다 해볼 만하다. 어느 것이 가장 효과가 컸는지가 이 문제의 답이다.

아래는 세 번째(동점이면 짧은 쪽) 코드다. 가장 짧고 효과가 확실하다.

# 동점이면 짧은 쪽을 앞세운다 - 튜플로 정렬 기준을 둘 준다
ranked = sorted(
    [{"s": score(query, c["text"]), "n": len(c["text"]), "src": c["source"], "t": c["text"]}
     for c in chunks],
    key=lambda r: (-r["s"], r["n"]),      # 점수는 큰 것부터, 길이는 짧은 것부터
)

for i, r in enumerate(ranked[:4], start=1):
    print(f'{i}위 {r["s"]:.2f}  {r["n"]:2d}자  {r["src"]:14s} {r["t"][:32]}')

47자짜리 정답 조각이 3위에서 1위로 올라온다. 0.64 동점 셋의 순서가 길이 짧은 순으로 바뀌기 때문이다.

key=lambda r: (-r["s"], r["n"]) 가 요령이다. 튜플을 기준으로 주면 앞 항목부터 차례로 비교한다. 점수에 마이너스를 붙인 건 「큰 것부터」로 뒤집으려는 것이다 — reverse=True 를 쓰면 길이까지 같이 뒤집혀서 안 된다.

나머지 둘도 해볼 만하다. 길이로 나누는 방식(score / len(text) ** 0.5)은 나눗셈 정도를 잘못 잡으면 이번엔 짧은 조각이 과하게 유리해진다. 한 편향을 지우려다 반대 편향을 만든다 — 지표를 손볼 때 늘 따라오는 문제다.

정리하면

자르는 방식을 셋 만들었고, 어느 게 나은지 정답을 아는 질문 여섯 개로 재봤다. 결과는 예상과 반대였다 — 가장 공들인 방식이 가장 나쁘게 나왔다.

원인은 자르기가 아니라 점수였다. 우리 점수는 조각이 길수록 높아져서, 길이가 균일한 방식에 유리하게 기울어 있었다. 지표가 틀리면 개선을 개악으로 판정한다.

그래서 다음에 할 일은 정해졌다. 글자를 세는 방식을 버리고, 글자를 숫자로 바꿔 뜻을 비교하는 것 — 임베딩이다. 문장 하나가 숫자 384개가 되고, 그 숫자로 「비슷하다」를 잰다.

Share
  • 파이썬
  • AI
  • RAG
  • 청킹
  • 평가
  • 검색 품질