학습 자료

파이프라인도 가른다 — 조절값 · 자르기 · 저장하기


Article

1교시에 app/ 을 나눴습니다. 기준은 "이 파일이 무엇 없이는 못 도나" 였습니다.

그 기준으로 보면 아직 정리가 안 된 폴더가 하나 남아 있습니다. pipeline/ 입니다. 어제 친 02_prepare.py 를 열어 보면 240줄 안에서 이런 일들이 차례로 일어납니다.

  • 데이터베이스를 열고 상품 상세 200건을 읽는다
  • 토큰을 세서 몇 건이 잘리는지 화면에 찍는다
  • 헤더로 자르고, 넘치는 것만 다시 자른다
  • 조각 앞에 접두어를 붙인다
  • 표를 만들고 1,560행을 넣는다

이 시간에 새로 배우는 개념은 없습니다. 1교시에 쓴 기준을 한 번 더 쓰는 것뿐입니다. 대신 어제 친 코드를 잘라서 옮기는 손 작업이 좀 있습니다.


1. 먼저 재 봅니다 — 240줄 중에 진짜 일은 얼마나 되나

"파일이 길어서 나눈다"는 이유로는 약합니다. 무엇이 시간을 쓰는지 재 보고 정하겠습니다. 뿌리에 try_time.py 를 만들어 아래를 칩니다. 다 보고 지울 파일입니다.

실습용 · try_time.py (다 보고 지운다)
import time

t = time.perf_counter()
from transformers import AutoTokenizer
print(f"  ① import (torch · transformers)  {time.perf_counter() - t:6.1f}초")

t = time.perf_counter()
tok = AutoTokenizer.from_pretrained("intfloat/multilingual-e5-small")
print(f"  ② 토크나이저 올리기               {time.perf_counter() - t:6.1f}초")

import sqlite3
con = sqlite3.connect("cosmetic.db")
details = [d for (d,) in con.execute("SELECT detail FROM product_details")]

t = time.perf_counter()
counts = [len(tok.encode(d)) for d in details]
print(f"  ③ 상세 200건 토큰 세기            {time.perf_counter() - t:6.1f}초")

from langchain_text_splitters import MarkdownHeaderTextSplitter

t = time.perf_counter()
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[("##", "section")])
sections = [doc for d in details for doc in splitter.split_text(d)]
print(f"  ④ 실제로 자르기                   {time.perf_counter() - t:6.1f}초  ({len(sections):,}조각)")
python try_time.py
터미널
  • ① import (torch · transformers) 6.3초
  • ② 토크나이저 올리기 3.0초
  • ③ 상세 200건 토큰 세기 0.2초
  • ④ 실제로 자르기 0.4초 (1,560조각)

결과가 좀 뜻밖입니다. 자르는 일 자체는 0.6초입니다. 나머지 9.3초는 전부 "모델과 라이브러리를 메모리에 올리는" 준비 시간입니다.

del try_time.py

2. 지금 모양과 오늘 모양

pipeline/

지금 (1교시가 끝난 상태)

파일 둘, 그중 하나가 다 한다

  • 01_schema.py — 1일차 · CSV 를 표로
  • 02_prepare.py — 어제 · 240줄 안에서 세 가지 일을 한다

2교시가 끝나면

같은 코드가 넷으로 갈린다

  • __init__.py — 새 빈 파일
  • 01_schema.py — 안 건드린다
  • 02_chunk.py — 이름이 바뀐다 · 순서만 정하고 숫자를 보여 준다
  • prep/options.py — 조절값. 실습에서 손대는 건 여기뿐
  • prep/chunking.py — 자르는 일. 데이터베이스를 모른다
  • prep/storage.py — 넣는 일. 자르는 법을 모른다
  • 05_lookup_bench.py — 새 파일 · 재 보는 실습 (읽기 전용)

오후에 prep/embedding.py03_embed.py 가 더 생깁니다. 지금은 자르는 쪽만 정리합니다.


3. 어떤 기준으로 가르나

1교시에 쓴 그 질문을 그대로 씁니다. 02_prepare.py 에 대고 물어보면 답이 셋입니다.

240줄 안에 섞여 있는 것
무슨 일무엇 없이는 못 하나어느 파일로
조절값 (CHUNK_SIZE 등 여섯 줄)아무것도prep/options.py
자르기 · 접두어 붙이기스플리터 · 토크나이저prep/chunking.py (순수)
표 만들고 넣기데이터베이스prep/storage.py (바깥)
순서를 정하고 숫자를 찍기위 셋 전부02_chunk.py (조립)

app/ 을 나눌 때 쓴 네 칸과 같은 모양입니다. 새 규칙이 아니라 같은 규칙을 다른 폴더에 한 번 더 적용하는 것입니다.

같은 규칙, 다른 폴더

app/ — 1교시에 나눈 것

이미 이렇게 되어 있다

  • 순수한 규칙은 아무것에도 안 기댄다
  • 바깥에 닿는 코드는 따로 둔다
  • 조립하는 자리가 그 둘을 엮는다

pipeline/ — 지금 나눌 것

같은 세 칸

  • prep/chunking.py 순수
  • prep/storage.py 데이터베이스에 닿는다
  • 02_chunk.py 순서만 정한다

여기서 클래스를 만들지 않는 이유

구조를 나눈다고 하면 클래스부터 떠올리기 쉽습니다. 자바나 C# 을 보셨다면 더 그렇습니다. 그런데 우리는 함수만 만듭니다. 파이썬에서는 그게 기본값입니다.

파이썬은 모듈(파일) 자체가 이미 이름 공간입니다. chunking.split_details() 라고 쓰면 "chunking 이라는 묶음 안의 split_details" 라는 뜻이 됩니다. 클래스로 한 번 더 감쌀 이유가 없습니다.

함수인가 클래스인가
상황무엇으로우리 프로젝트에서
입력을 받아 결과를 돌려주기만 한다함수chunking.py · storage.py · 오늘 오후 embedding.py
구현이 둘 이상이고 갈아끼워야 한다클래스 + 약속(Protocol)5일차 벡터 저장소 (SQLite / Supabase)
데이터의 모양을 정한다클래스 (Pydantic)4일차 LLM 응답 검증

지금 자르는 방법은 하나뿐입니다. 갈아끼울 것이 없으니 약속을 만들 이유도 없습니다. 필요해지는 날 만들면 됩니다. 1교시에 빈 폴더를 미리 안 만든 것과 같은 판단입니다.


4. 폴더와 빈 파일부터 만듭니다

  1. 01

    pipeline/prep/ 폴더를 만든다

    자르는 일과 넣는 일이 여기로 들어갑니다

  2. 02

    pipeline/__init__.py 를 만든다

    이게 있어야 pipeline.prep.chunking 처럼 점으로 이어 부를 수 있습니다

  3. 03

    pipeline/prep/__init__.py 를 만든다

    폴더를 열기 전에 읽을 자리로 씁니다

mkdir pipeline\prep
pipeline/__init__.py
"""파이프라인 ― CSV 를 읽어서 검색할 수 있는 DB 로 만드는 자리."""
pipeline/prep/__init__.py
"""자르는 일과 넣는 일을 가른 자리.

    options.py    조절값. 실습에서 손대는 건 여기뿐이다
    chunking.py   순수. 글을 조각으로.     데이터베이스를 모른다
    storage.py    바깥. 자료구조를 DB 로.  자르는 법을 모른다

위 둘은 서로도 모른다. 셋을 순서대로 부르는 일은 pipeline/02_chunk.py 가 한다.
오늘 오후에 embedding.py 가 하나 더 들어온다.
"""

1교시에 만든 app/core/__init__.py 는 완전히 빈 파일이었는데 여기는 설명을 적었습니다. 규칙은 아닙니다. 폴더 이름만 봐서는 안에 뭐가 있는지 모르니 폴더를 열기 전에 읽을 자리가 하나 있으면 편해서 그렇게 했습니다. 적을 말이 없으면 비워 두면 됩니다.


5. pipeline/prep/options.py

어제 02_prepare.py 맨 위에 친 「설정」 여섯 줄이 그대로 옮겨 갑니다. 값은 하나도 안 바꿉니다.

pipeline/prep/options.py
"""조각을 어떻게 자를지 정하는 값들 ― 실습에서 손대는 건 여기뿐이다.

이 파일을 따로 뗀 이유
    값을 바꿔 보는 실습을 할 때 240줄짜리 파일에서 여섯 줄을 찾아 헤매지 않는다.
    그리고 chunking.py 는 값이 바뀌어도 한 글자도 안 바뀐다 ― 그게 목적이다.
"""

from app.core.config import EMBED_MAX_TOKENS

CHUNK_SIZE = 384            # 조각 하나의 토큰 상한
CHUNK_OVERLAP = 48          # 경계에서 겹치는 몫
PREFIX_BUDGET = 32          # 접두어("[상품명 > 섹션]")가 쓸 토큰 자리

# 계산해서 잡는다. 상한이 바뀌면 문턱도 따라 움직인다 ― 손으로 두 번 적으면 한쪽만 고치게 된다
RESPLIT_OVER = EMBED_MAX_TOKENS - PREFIX_BUDGET

HEADERS = [("##", "section")]

SEPARATORS = ["\n\n", "\n", "다. ", "요. ", ". ", " ", ""]

이 파일에서 기억할 것

  • 바뀌는 것과 안 바뀌는 것을 갈라 놓는 것이 목적입니다. CHUNK_SIZE 는 실습할 때마다 바뀌고, 자르는 코드는 안 바뀝니다. 둘이 한 파일에 있으면 값을 고치려고 로직 파일을 열게 되고, 그러다 옆줄을 건드립니다.
  • RESPLIT_OVER 만 계산식입니다. 상한(512)에서 접두어 자리(32)를 뺀 값이라 한쪽만 고치는 사고가 안 납니다.
  • SEPARATORS"다. " · "요. " 가 들어간 건 2일차에 정한 그대로입니다. 구분자는 남의 설정을 베끼는 게 아니라 데이터를 보고 고릅니다.

6. pipeline/prep/chunking.py

이 파일이 오늘의 핵심입니다. 어제 친 토막이 거의 그대로 오는데 두 가지가 달라집니다.

pipeline/prep/chunking.py
"""긴 글을 조각으로 자른다. **이 파일은 데이터베이스를 모른다.**

    들어오는 것   [(상품번호, 상품명, 상세 원문), ...]   ― 그냥 파이썬 목록
    나가는 것     (섹션 목록, 조각 목록)                 ― 그냥 딕셔너리 목록

🔴 `import sqlite3` 이 없다. 커넥션도 안 받는다. 일부러 그렇다.
   그래서 이 파일은 DB 없이 시험할 수 있다.

자르는 순서는 어제와 같은 2단이다.
    1단  '## 주의사항' 같은 사람이 만든 경계로 자른다
    2단  그래도 상한을 넘는 것만 다시 자른다

조절값은 전부 options.py 에 있다. 이 파일에는 숫자가 없다.
"""

from langchain_text_splitters import (MarkdownHeaderTextSplitter,
                                      RecursiveCharacterTextSplitter)
from transformers import AutoTokenizer

from app.core.config import EMBED_TOKENIZER
from pipeline.prep.options import (CHUNK_OVERLAP, CHUNK_SIZE, HEADERS,
                                   RESPLIT_OVER, SEPARATORS)

_tokenizer = None


def get_tokenizer():
    """토큰을 세는 자. 무거우니 한 번만 올리고 계속 쓴다."""
    global _tokenizer
    if _tokenizer is None:
        _tokenizer = AutoTokenizer.from_pretrained(EMBED_TOKENIZER)
    return _tokenizer


def count_tokens(text):
    """토큰 수 (2일차 1교시)."""
    return len(get_tokenizer().encode(text))


def with_context(product_name, section, body):
    """[상품명 > 섹션] 본문 ― 접두어가 곧 문맥이다 (2일차 3교시)."""
    return f"[{product_name} > {section}] {body}"


def split_sections(details):
    """1단 ― 사람이 만든 경계로 자른다.

    🔴 어제와 달라진 것: 튜플이 아니라 **딕셔너리**를 돌려준다.
       storage.py 가 chunking.py 를 안 읽고도 무엇이 오는지 알아야 하기 때문이다.
    """
    splitter = MarkdownHeaderTextSplitter(headers_to_split_on=HEADERS)

    sections = []
    for product_id, product_name, detail in details:
        for doc in splitter.split_text(detail):
            text = doc.page_content.strip()
            if not text:
                continue
            sections.append({
                "product_id": product_id,
                "product_name": product_name,
                "section": doc.metadata.get("section", "(머리말)"),
                "text": text,
                "n_tokens": count_tokens(text),
            })
    return sections


def split_chunks(sections):
    """2단 ― 상한을 넘는 섹션만 다시 자르고, 접두어를 붙인다.

    body  접두어를 뺀 원문 조각   ― 화면에 띄울 때 쓴다
    text  접두어를 붙인 것        ― 임베딩에 넣는다
    """
    resplitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
        get_tokenizer(), chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP,
        separators=SEPARATORS, keep_separator="end")

    chunks, n_resplit = [], 0
    for section in sections:
        if section["n_tokens"] > RESPLIT_OVER:
            n_resplit += 1
            parts = resplitter.split_text(section["text"])
        else:
            parts = [section["text"]]

        for chunk_index, body in enumerate(parts):
            text = with_context(section["product_name"], section["section"], body)
            chunks.append({
                "product_id": section["product_id"],
                "product_name": section["product_name"],
                "section": section["section"],
                "chunk_index": chunk_index,
                "body": body,
                "text": text,
                "n_tokens": count_tokens(text),
            })
    return chunks, n_resplit


def split_details(details):
    """1단과 2단을 순서대로. 부르는 쪽은 이 하나만 알면 된다."""
    sections = split_sections(details)
    chunks, n_resplit = split_chunks(sections)
    return sections, chunks, n_resplit

이 파일에서 기억할 것

  • 첫째, import sqlite3 가 없습니다. 커넥션도 인자로 안 받습니다. 어제는 같은 파일 안에 con 이 있어서 마음만 먹으면 언제든 데이터베이스를 건드릴 수 있었는데, 이제는 건드릴 방법이 없습니다. 1교시에 말한 순수 함수가 이 모양입니다.
  • 둘째, 튜플이 딕셔너리가 됐습니다. 어제는 rows.append((pid, pname, section, i, part)) 라 꺼낼 때 example[4] 였고, 그래서 # rows 는 (pid, pname, ...) 이라는 주석이 필요했습니다. 주석으로 설명해야 하는 자료구조는 대개 자료구조 쪽이 잘못된 것입니다. 오늘부터는 chunk["body"] 라고 씁니다.
  • 부르는 쪽은 split_details() 하나만 알면 됩니다. 나머지는 안을 들여다볼 때 씁니다. n_tokens 를 여기서 세는 이유는 토크나이저를 들고 있는 것이 이 파일이기 때문입니다 — storage.py 가 센다면 저장하는 파일이 토크나이저를 알아야 합니다.

import sqlite3 이 없어서 이 파일은 데이터베이스 없이 세 줄로 불러 볼 수 있습니다.

예시 코드
>>> from pipeline.prep import chunking
>>> detail = "## 주의사항\n민감성 피부에는 사용을 권하지 않습니다."
>>> sections, chunks, n = chunking.split_details([("P001", "수분 크림", detail)])
>>> chunks[0]["text"]
'[수분 크림 > 주의사항] 민감성 피부에는 사용을 권하지 않습니다.'

어제 02_prepare.py 로는 이게 불가능했습니다. import 하는 순간 데이터베이스를 열고 SQL 을 날리기 때문입니다. 오늘 9교시에 이 성질로 시험 파일을 만듭니다.


7. pipeline/prep/storage.py

SQL 은 어제와 한 글자도 다르지 않습니다. CREATE TABLE 도, 인덱스 세 줄도, INSERT 문도 그대로입니다. 달라진 것은 함수로 감쌌고, 커넥션을 밖에서 받는다는 점입니다.

pipeline/prep/storage.py
"""자료구조를 데이터베이스에 넣는다. **이 파일은 자르는 법을 모른다.**

스플리터를 부르지 않는다. 이미 만들어진 것을 받아서 넣기만 한다.
그래서 「조각이 이상하다」와 「저장이 이상하다」를 따로 볼 수 있다.

오늘 오후에 벡터 표를 만드는 함수가 여기 더 붙는다.
"""


def save_sections_and_chunks(con, sections, chunks):
    """섹션과 조각을 넣는다.

    🔴 커넥션을 밖에서 받는다. 이 함수는 sqlite3.connect() 를 부르지 않는다.
    """
    con.execute("DROP TABLE IF EXISTS chunk_vectors")
    con.execute("DROP TABLE IF EXISTS chunks")
    con.execute("DROP TABLE IF EXISTS sections")
    con.execute("""
        CREATE TABLE sections (
            section_id INTEGER PRIMARY KEY,
            product_id TEXT NOT NULL,
            section    TEXT NOT NULL,
            text       TEXT NOT NULL,
            n_tokens   INTEGER NOT NULL,
            FOREIGN KEY (product_id) REFERENCES products(product_id)
        )
    """)
    con.execute("""
        CREATE TABLE chunks (
            chunk_id    INTEGER PRIMARY KEY,
            section_id  INTEGER NOT NULL,
            product_id  TEXT NOT NULL,
            section     TEXT NOT NULL,
            chunk_index INTEGER NOT NULL,
            text        TEXT NOT NULL,
            body        TEXT NOT NULL,
            n_tokens    INTEGER NOT NULL,
            FOREIGN KEY (section_id) REFERENCES sections(section_id),
            FOREIGN KEY (product_id) REFERENCES products(product_id)
        )
    """)
    con.execute("CREATE INDEX idx_chunks_product_id ON chunks(product_id)")
    con.execute("CREATE INDEX idx_chunks_section ON chunks(section)")
    con.execute("CREATE INDEX idx_sections_product_id ON sections(product_id)")

    # 🔴 방금 붙은 번호를 담아 두는 보관함.
    #    cur.lastrowid 는 다음 INSERT 를 하면 사라진다. 나오는 그 자리에서 받아 둔다
    section_id_lookup = {}
    for section in sections:
        cur = con.execute(
            "INSERT INTO sections (product_id, section, text, n_tokens) VALUES (?, ?, ?, ?)",
            (section["product_id"], section["section"], section["text"], section["n_tokens"]),
        )
        section_id_lookup[(section["product_id"], section["section"])] = cur.lastrowid

    for chunk in chunks:
        con.execute("""
            INSERT INTO chunks (section_id, product_id, section, chunk_index, text, body, n_tokens)
            VALUES (?, ?, ?, ?, ?, ?, ?)
        """, (section_id_lookup[(chunk["product_id"], chunk["section"])],
              chunk["product_id"], chunk["section"], chunk["chunk_index"],
              chunk["text"], chunk["body"], chunk["n_tokens"]))

    con.commit()

이 파일에서 기억할 것

  • 커넥션을 밖에서 받습니다. 1교시에 말한 의존성 주입이 이 모양입니다. 함수 안에서 sqlite3.connect() 를 부르지 않으니, 시험할 때 메모리 데이터베이스를 넣어 줄 수도 있고 파일 이름이 바뀌어도 이 함수는 안 바뀝니다.
  • chunking.pysection_id 를 모릅니다. 알 수가 없습니다. 그 번호는 INSERT 하는 순간 데이터베이스가 만들어 주는 것이기 때문입니다. 그래서 조각은 (product_id, section) 이라는 자연키만 들고 이 파일에 도착합니다.
  • section_id_lookup 은 그래서 있습니다. cur.lastrowid 는 다음 INSERT 를 하면 사라지므로 나오는 그 자리에서 받아 둡니다. 이름의 lookup 은 "담아 뒀다가 찾아 쓴다"는 뜻입니다.
이게 왜 결합도 이야기인가

청커가 section_id 를 안다면

데이터베이스에 매인 설계

  • 자르려면 먼저 DB 에 넣어야 한다
  • DB 없이는 시험도 못 한다
  • SQLite 를 바꾸면 자르는 코드도 바뀐다

자연키만 들고 간다

오늘 한 것

  • 자르는 코드는 DB 가 있든 없든 돈다
  • 다른 데이터베이스로 옮겨도 안 바뀐다
  • 바뀌는 것은 lastrowid 한 줄뿐이다

8. pipeline/02_chunk.py

먼저 파일 이름을 바꿉니다.

ren pipeline\02_prepare.py 02_chunk.py

prepare 는 "준비한다"인데 무엇을 준비하는지가 안 적혀 있습니다. 어제는 이 파일이 다 했으니 그 이름이 맞았습니다. 오늘부터 이 파일은 자르기만 합니다. 그리고 번호가 뜻을 갖게 됩니다 — 01 스키마, 02 자르기, 03 벡터, 04 점검.

파일 안을 통째로 지우고 아래를 넣습니다.

pipeline/02_chunk.py
"""긴 상품 상세를 조각으로 자르고 저장한다.

🔴 이 파일에는 자르는 코드가 없다. prep/chunking.py 가 자르고, prep/storage.py 가
   넣는다. 이 파일은 둘을 순서대로 부르고 **숫자를 보여 준다.**
   조절값은 prep/options.py 한 곳에 있다.

🔴 벡터는 여기서 안 만든다. 03_embed.py 가 한다. 가른 이유는 값이다 ―
   조각을 다시 자르는 건 몇 초지만 벡터를 다시 만드는 건 로컬에서 30초쯤 걸리고,
   상용 API 를 켜 두면 그대로 요금이 된다.

앞:    python pipeline/01_schema.py
실행:  python pipeline/02_chunk.py
"""

import sqlite3
import statistics
import sys
from pathlib import Path

# pipeline/ 안에서 돌리는데 app/ 과 pipeline.prep 을 불러와야 한다. 그래서 뿌리를 경로에 넣는다
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.stdout.reconfigure(errors="replace")

from huggingface_hub.utils import disable_progress_bars
from huggingface_hub.utils import logging as hub_logging
from transformers import logging as hf_logging

hf_logging.set_verbosity_error()
hf_logging.disable_progress_bar()
hub_logging.set_verbosity_error()
disable_progress_bars()

from app.core.config import DB_PATH, EMBED_MAX_TOKENS, EMBED_TOKENIZER
from pipeline.prep import chunking, storage
from pipeline.prep.options import (CHUNK_OVERLAP, CHUNK_SIZE, PREFIX_BUDGET,
                                   RESPLIT_OVER)

con = sqlite3.connect(DB_PATH)
con.execute("PRAGMA foreign_keys = ON")

ntok = chunking.count_tokens


def dist(values):
    """분포 한 줄 ― 최소 / 중앙 / 최대"""
    return (f"최소 {min(values):>5,} · 중앙 {int(statistics.median(values)):>5,} · "
            f"최대 {max(values):>5,}")


details = con.execute("""
    SELECT product_details.product_id, products.name, product_details.detail
    FROM product_details
    JOIN products ON product_details.product_id = products.product_id
    ORDER BY product_details.product_id
""").fetchall()

print(f"상품 상세 {len(details)}건을 읽었다\n")


# ═══════════════════════════════════════════════ ① 왜 자르나
print("=" * 74)
print("① 왜 자르나 ― 통째로 넣으면 뒤가 잘린다")
print("=" * 74)

full_tokens = [ntok(detail) for _, _, detail in details]
over = [n for n in full_tokens if n > EMBED_MAX_TOKENS]

print(f"  임베딩 모델 상한 : {EMBED_MAX_TOKENS} 토큰 ({EMBED_TOKENIZER})")
print(f"  상세 토큰 분포   : {dist(full_tokens)}")
print(f"  상한 초과        : {len(over)}/{len(full_tokens)}건 "
      f"({len(over) / len(full_tokens) * 100:.0f}%)\n")


# ═══════════════════════════════════════════════ ②③ 자른다
print("=" * 74)
print("② 2단 구조 ― 사람이 만든 경계로 자르고, 넘치는 것만 다시 자른다")
print("=" * 74)
print(f"  설정: CHUNK_SIZE {CHUNK_SIZE} · OVERLAP {CHUNK_OVERLAP} · "
      f"PREFIX_BUDGET {PREFIX_BUDGET} -> 다시 자를 문턱 {RESPLIT_OVER}")
print("  (prep/options.py 에 있다. 이 파일에는 숫자가 없다)\n")

# 🔴 이 한 줄이 자르기의 전부다. 어떻게 자르는지는 prep/chunking.py 가 안다
sections, chunks, n_resplit = chunking.split_details(details)

section_tokens = [section["n_tokens"] for section in sections]
chunk_tokens = [ntok(chunk["body"]) for chunk in chunks]
print(f"  {'':14s}{'개수':>8s}   분포")
print(f"  0단 (통째로)  {len(full_tokens):>8,}   {dist(full_tokens)}")
print(f"  1단 (섹션)    {len(sections):>8,}   {dist(section_tokens)}")
print(f"  2단 (조각)    {len(chunks):>8,}   {dist(chunk_tokens)}")
print(f"  다시 자른 섹션: {n_resplit}개 / {len(sections):,}개\n")

example = next(chunk for chunk in chunks if chunk["section"] == "주의사항")
print("③ 문맥 유지 ― 조각 앞에 제목과 섹션명을 붙인다")
print(f"  붙이기 전: {example['body'][:46]}...")
print(f"  붙인 뒤  : {example['text'][:46]}...\n")


# ═══════════════════════════════════════════════ ④ 저장
# 🔴 이 한 줄이 저장의 전부다. 어떻게 넣는지는 prep/storage.py 가 안다
storage.save_sections_and_chunks(con, sections, chunks)

stored = [n for (n,) in con.execute("SELECT n_tokens FROM chunks")]
print("=" * 74)
print("④ 저장 ― sections · chunks")
print("=" * 74)
print(f"  sections {len(sections):>6,}행")
print(f"  chunks   {len(chunks):>6,}행  ·  접두어까지 넣은 토큰 {dist(stored)}")
print(f"  상한({EMBED_MAX_TOKENS}) 초과: {sum(n > EMBED_MAX_TOKENS for n in stored)}개")
con.close()

이 파일에서 기억할 것

  • 실제로 일하는 코드는 두 줄입니다. chunking.split_details(details)storage.save_sections_and_chunks(con, sections, chunks). 나머지는 전부 숫자를 보여 주는 코드입니다.
  • 그게 이 파일이 하는 일의 전부이기도 합니다. 순서를 정하고, 중간에 무슨 일이 일어났는지 사람에게 알려 줍니다. app/features/ 와 같은 성격입니다.
  • sys.path.insert(...) 한 줄은 pipeline/ 안에서 돌리면서 app/ 을 불러와야 해서 필요합니다.

돌려 봅니다

python pipeline/02_chunk.py
터미널
  • 상품 상세 200건을 읽었다
  • ① 왜 자르나 ― 통째로 넣으면 뒤가 잘린다
  • 임베딩 모델 상한 : 512 토큰 (intfloat/multilingual-e5-small)
  • 상세 토큰 분포 : 최소 435 · 중앙 707 · 최대 1,201
  • 상한 초과 : 184/200건 (92%)
  • ② 2단 구조 ― 사람이 만든 경계로 자르고, 넘치는 것만 다시 자른다
  • 설정: CHUNK_SIZE 384 · OVERLAP 48 · PREFIX_BUDGET 32 -> 다시 자를 문턱 480
  • 개수 분포
  • 0단 (통째로) 200 최소 435 · 중앙 707 · 최대 1,201
  • 1단 (섹션) 1,560 최소 24 · 중앙 96 · 최대 319
  • 2단 (조각) 1,560 최소 24 · 중앙 96 · 최대 319
  • 다시 자른 섹션: 0개 / 1,560개
  • ④ 저장 ― sections · chunks
  • sections 1,560행
  • chunks 1,560행 · 접두어까지 넣은 토큰 최소 35 · 중앙 110 · 최대 338
  • 상한(512) 초과: 0개

어제와 숫자가 하나도 다르지 않아야 합니다. sections 1,560 · chunks 1,560 · 최소 35 · 중앙 110 · 최대 338 · 초과 0개. 어제 3교시 화면과 같은지 확인하십시오. 숫자가 하나라도 다르면 옮기다 무언가를 흘린 것입니다.


9. pipeline/05_lookup_bench.py — 담아 두는 게 정말 나은지 재 봅니다

storage.pysection_id_lookup 을 보고 이런 생각이 들 수 있습니다.

그냥 필요할 때 SELECT section_id FROM sections WHERE ... 하면 되지 않나?

재 보면 됩니다. 읽기만 하는 파일이라 돌려도 데이터베이스가 안 바뀝니다.

pipeline/05_lookup_bench.py
"""같은 것을 두 번 찾지 않는다 ― 딕셔너리에 담아 두기 vs 매번 SELECT.

「왜 딕셔너리에 담아 두나. 그냥 필요할 때 SELECT 하면 되지 않나」
그 물음을 **재서** 답한다.

    (A) 조각마다 SELECT section_id FROM sections WHERE product_id=? AND section=?
    (B) 딕셔너리에 담아 두고 꺼내 쓴다

🔴 이 파일은 DB 를 **읽기만 한다.** 지워도 파이프라인이 돈다.

앞:    python pipeline/02_chunk.py
실행:  python pipeline/05_lookup_bench.py
"""

import sqlite3
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.stdout.reconfigure(errors="replace")

from app.core.config import DB_PATH

con = sqlite3.connect(DB_PATH)

# 조각이 저장 모듈에 도착했을 때 들고 있는 것과 같은 모양 ― 자연키뿐이다
keys = con.execute("SELECT product_id, section FROM chunks ORDER BY chunk_id").fetchall()

print(f"조각 {len(keys):,}건에 대해 section_id 를 찾는다\n")

# ─────────────────────────────────────────────── (A) 매번 SELECT
started = time.perf_counter()
a_result = []
for product_id, section in keys:
    row = con.execute(
        "SELECT section_id FROM sections WHERE product_id = ? AND section = ?",
        (product_id, section)).fetchone()
    a_result.append(row[0])
a_elapsed = time.perf_counter() - started

# ─────────────────────────────────────────────── (B) 담아 두고 꺼내 쓰기
started = time.perf_counter()
section_id_lookup = {(product_id, section): section_id for section_id, product_id, section
                     in con.execute("SELECT section_id, product_id, section FROM sections")}
b_result = [section_id_lookup[key] for key in keys]
b_elapsed = time.perf_counter() - started

assert a_result == b_result, "두 방식의 답이 다르다. 재기 전에 이것부터 고쳐야 한다"

print(f"  (A) 조각마다 SELECT      {a_elapsed:>8.4f}초   (질의 {len(keys):,}번)")
print(f"  (B) 담아 두고 꺼내 쓰기   {b_elapsed:>8.4f}초   (질의 1번 + 딕셔너리 조회)")
print(f"  {a_elapsed / b_elapsed:>26.1f}배   ― 답은 {len(a_result):,}건 모두 같다")
con.close()
python pipeline/05_lookup_bench.py
터미널
  • 조각 1,560건에 대해 section_id 를 찾는다
  • (A) 조각마다 SELECT 0.0665초 (질의 1,560번)
  • (B) 담아 두고 꺼내 쓰기 0.0013초 (질의 1번 + 딕셔너리 조회)
  • 51.8배 ― 답은 1,560건 모두 같다

이 시간에 한 것

2교시 요점
주제핵심
재 보고 나눈다자르는 일은 0.6초, 준비가 9.3초. 나눠도 안 빨라진다는 걸 알고 시작합니다
무슨 기준으로1교시와 같은 질문. 조절값 / 순수 / 바깥 / 조립
options.py바뀌는 값과 안 바뀌는 코드를 갈라 놓습니다
chunking.py`import sqlite3` 이 없습니다. 그래서 DB 없이 시험할 수 있습니다
storage.py커넥션을 밖에서 받습니다(의존성 주입). 자르는 법은 모릅니다
02_chunk.py실제로 일하는 코드는 두 줄. 나머지는 숫자를 보여 주는 코드입니다
자료구조튜플 → 딕셔너리. 주석으로 설명해야 하는 자료구조는 자료구조가 잘못된 것입니다
합격선1교시와 같습니다 — 「어제와 똑같다」

다음 시간에 할 것

자르는 쪽은 정리됐습니다. 그런데 우리는 아직 상품만 다뤘습니다.

chunks 1,560행은 전부 상품 상세에서 나온 것입니다. 그럼 고객은요? 고객에게 무언가를 추천하려면 고객도 무언가로 표현되어 있어야 합니다.

다음 시간에는 코드를 치기 전에 그 질문을 먼저 답합니다 — 고객을 무엇으로 표현할 것인가. 정형 데이터로? 후기 글로? 아니면 둘 다?

Share
  • 파이썬
  • 프로젝트 구조
  • 리팩터링
  • 청킹
  • RAG
파이프라인도 가른다 — 조절값 · 자르기 · 저장하기 — 디코드랩(DCODELAB)