LLM 엔지니어링19장 · MLOps 와 LLMOps, 워크플로 자동화17 / 23← 이전목차다음 →MetaContents

19장. MLOps 와 LLMOps, 워크플로 자동화

실습 3 — 색인 갱신 작업 자동화

실습 2의 지문과 실습 1의 실행기로, 밤마다 도는 색인 갱신 작업을 만듭니다. 바뀐 조각만 임베딩하고(증분), 새 판 폴더에 쓴 뒤 가리킴(CURRENT)을 바꿉니다. 같은 밤을 다시 돌려도 판이 늘지 않는지 확인합니다. 임베딩은 7장의 intfloat/multilingual-e5-small 을 CPU 로 씁니다.

1단계. 준비

Python
import hashlib
import json
import os
import shutil
import sys
from pathlib import Path

import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer

sys.path.insert(0, ".")                        # 실습 1이 만든 out_flow.py 를 찾게 한다
from out_flow import Stop, run_flow

shutil.rmtree("out_index", ignore_errors=True)   # 다시 돌려도 같은 결과가 나오게 지난 색인을 지운다
ROOT = Path("out_index")
ROOT.mkdir()
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")


# 실습 2와 같은 함수들
def fingerprint(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]


def snapshot(day):
    return {f.stem: f.read_text(encoding="utf-8") for f in sorted(Path(f"library/day{day}").glob("*.txt"))}


def split_chunks(docs):
    out = []
    for name, text in docs.items():
        paras = [p.strip() for p in text.split("\n\n") if p.strip()]
        out += [f"{name}: {p}" for p in paras[1:]]
    return out


def current():
    """지금 판의 정보(meta.json). 아직 판이 없으면 None"""
    pointer = ROOT / "CURRENT"
    if not pointer.exists():
        return None
    version = pointer.read_text(encoding="utf-8")
    return json.loads((ROOT / version / "meta.json").read_text(encoding="utf-8"))


print("준비 끝 · 지금 판:", current())
출력
준비 끝 · 지금 판: None

2단계. 갱신 작업 만들기

Python
CACHE = ROOT / "embed_cache.npz"               # 조각 지문 → 벡터. 판이 바뀌어도 남겨 다시 쓴다


def make_tasks(day):
    """그 밤의 갱신 작업 DAG. 작업마다 ctx 를 받아 결과를 돌려준다"""
    def read(ctx):
        return snapshot(day)

    def check(ctx):
        docs = ctx["읽기"]
        bad = [n for n, t in docs.items() if not t.strip() or t.count("�") / len(t) > 0.01]
        if bad:
            raise Stop(f"망가진 문서 {bad}")
        return len(docs)

    def chunk(ctx):
        return split_chunks(ctx["읽기"])

    def embed(ctx):
        chunks = ctx["조각"]
        cache = {}
        if CACHE.exists():
            with np.load(CACHE) as z:          # with: 다 읽으면 파일을 닫는다
                cache = {k: z[k] for k in z.files}
        keys = [fingerprint(c) for c in chunks]
        todo = [c for c, k in zip(chunks, keys) if k not in cache]
        if todo:                               # 캐시에 없는 조각만 임베딩한다(증분)
            vecs = emb.encode(["passage: " + c for c in todo], normalize_embeddings=True)
            cache.update({fingerprint(c): v for c, v in zip(todo, vecs)})
            np.savez(CACHE, **cache)
        return {"벡터": np.stack([cache[k] for k in keys]), "새로": len(todo), "다시 씀": len(chunks) - len(todo)}

    def save(ctx):
        chunks = ctx["조각"]
        # 판의 지문: 조각 지문을 정렬해 이은 것의 지문. 내용이 같으면 같은 지문이 된다
        content = fingerprint("\n".join(sorted(fingerprint(c) for c in chunks)))
        now = current()
        if now and now["내용 지문"] == content:
            raise Stop("바뀐 것이 없어 새 판을 만들지 않습니다")
        version = f"v{len(list(ROOT.glob('v*'))) + 1:03d}"
        folder = ROOT / version
        folder.mkdir()
        np.save(folder / "vectors.npy", ctx["임베딩"]["벡터"])
        (folder / "chunks.json").write_text(json.dumps(chunks, ensure_ascii=False), encoding="utf-8")
        meta = {"판": version, "밤": day, "조각": len(chunks), "내용 지문": content}
        (folder / "meta.json").write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
        return version

    def swap(ctx):
        tmp = ROOT / "CURRENT.tmp"
        tmp.write_text(ctx["저장"], encoding="utf-8")
        os.replace(tmp, ROOT / "CURRENT")      # 가리킴을 한 번에 바꾼다
        return ctx["저장"]

    return {"읽기": (read, []), "검사": (check, ["읽기"]), "조각": (chunk, ["검사"]),
            "임베딩": (embed, ["조각"]), "저장": (save, ["임베딩"]), "교체": (swap, ["저장"])}


def refresh(day):
    """한 밤의 갱신을 돌리고 한 줄로 요약한다. 작업마다의 로그는 끈다"""
    records, ctx = run_flow(make_tasks(day), log=lambda m: None)
    state = {r["작업"]: r for r in records}
    e = ctx.get("임베딩", {})
    return {"밤": day, "검사": state["검사"]["상태"], "조각": len(ctx.get("조각", [])), "새로 임베딩": e.get("새로", 0),
            "다시 씀": e.get("다시 씀", 0), "저장": state["저장"]["상태"], "메모": state["저장"]["메모"] or state["검사"]["메모"],
            "지금 판": (current() or {}).get("판")}


# 결과: 함수가 만든 DAG 의 작업 이름
print(list(make_tasks(0)))
출력
['읽기', '검사', '조각', '임베딩', '저장', '교체']

3단계. 첫 밤과 다음 밤 돌리기

Python
rows = [refresh(0), refresh(1)]
# 결과: 첫 밤은 모든 조각을, 다음 밤은 바뀐 조각만 임베딩한다
print(pd.DataFrame(rows).to_string(index=False))
print("판 폴더:", sorted(p.name for p in ROOT.glob("v*")))
출력
 밤 검사  조각  새로 임베딩  다시 씀 저장 메모 지금 판
 0 성공  29      29     0 성공    v001
 1 성공  35       7    28 성공    v002
판 폴더: ['v001', 'v002']

4단계. 다시 돌려도 같은지 보고, 지금 판으로 검색하기

Python
# 같은 밤(day1)을 한 번 더 돌린다. 서버가 멈춰 다시 돌린 상황이다
print(pd.DataFrame([refresh(1)]).to_string(index=False))
print("판 폴더:", sorted(p.name for p in ROOT.glob("v*")))

# 서비스 쪽: 가리킴이 가리키는 판을 읽어 검색한다
version = (ROOT / "CURRENT").read_text(encoding="utf-8")
vectors = np.load(ROOT / version / "vectors.npy")
chunks = json.loads((ROOT / version / "chunks.json").read_text(encoding="utf-8"))
q = emb.encode(["query: 한 번에 최대 몇 권까지 대출돼요?"], normalize_embeddings=True)
best = int(np.argmax(vectors @ q[0]))          # 정규화한 벡터의 곱 = 코사인 유사도
# 결과: 지금 판과 가장 가까운 조각
print(version, "→", chunks[best])
출력
 밤 검사  조각  새로 임베딩  다시 씀 저장                     메모 지금 판
 1 성공  35       0    35 멈춤 바뀐 것이 없어 새 판을 만들지 않습니다 v002
판 폴더: ['v001', 'v002']
v002 → 대출과반납: 책은 한 사람이 7권까지 빌릴 수 있습니다. 대출 기간은 14일입니다.

이 실습이 뜻하는 것

이 실습은 도서관 안내 책자를 밤마다 새로 엮어 진열대에 꽂는 일과 같습니다. 준비물을 챙기고 진열대가 비어 있는지 확인합니다(1단계). 받기부터 진열까지 여섯 단계 작업표를 만들되, 이미 숫자로 옮겨 둔 쪽은 다시 옮기지 않도록 옮김 공책을 둡니다(2단계). 첫 밤과 다음 밤에 책자를 엮고(3단계), 같은 밤을 한 번 더 엮어도 같은 책자가 두 권 꽂히지 않는지, 진열대의 책자로 질문의 답이 찾아지는지 봅니다(4단계). 여기서 옮김 공책이 임베딩 캐시(embed_cache.npz), 새로 엮은 책자가 판 폴더(v001, v002), 진열대의 "지금 이 책" 표지가 CURRENT 입니다. 비유와 다른 점은, 사람이 진열대의 책을 바꿀 때는 잠깐 빈자리가 생기지만 os.replace 는 가리킴을 한 번에 바꿔 빈 순간이 없다는 것입니다.

단계출력뜻
1. 준비지금 판: None지난 색인을 지웠으므로 아직 배포된 판이 없습니다
2. 작업['읽기', '검사', '조각', '임베딩', '저장', '교체']밤 작업이 여섯 작업으로 짜였습니다
3. 밤 0조각 29 · 새로 임베딩 29 · v001첫 밤에는 캐시가 비어 있어 조각 29개를 모두 임베딩했습니다
3. 밤 1조각 35 · 새로 임베딩 7 · 다시 씀 28 · v002바뀐 조각 7개만 새로 임베딩하고 28개는 캐시에서 꺼냈습니다. 실습 2에서 센 7개와 같습니다
4. 밤 1 다시새로 임베딩 0 · 저장 멈춤 · 지금 판 v002같은 밤을 또 돌려도 내용 지문이 같아 새 판을 만들지 않았습니다. 판 폴더는 여전히 둘입니다
4. 검색v002 → 대출과반납: 책은 한 사람이 7권까지 …서비스 쪽은 CURRENT 가 가리키는 v002 를 읽어, 대출 권수 질문에 새 안내(7권)를 찾았습니다

한 줄로 줄이면, "바뀐 것만 임베딩하고, 새 판을 다 쓴 뒤 가리킴만 바꾸며, 다시 돌려도 판이 늘지 않는" 밤 작업을 만든 실습입니다. 서버가 멈춰 같은 밤을 다시 돌려도 안전하다는 것을 확인했습니다. 다만 이 작업은 새 판의 검색 품질을 재지 않습니다. 형식은 멀쩡한데 내용이 옛 판으로 돌아간 밤을 막으려면 평가와 게이트가 필요하고, 그것은 프로젝트에서 더합니다.

코드 설명

  • embed_cache.npz 는 조각 지문을 열쇠로 벡터를 모아 둔 캐시입니다. 판이 바뀌어도 남기므로, 바뀌지 않은 조각은 다시 임베딩하지 않습니다.
  • np.savez(파일, **사전) 은 이름이 붙은 배열 여러 개를 파일 하나에 쓰고, with np.load(...) as z 로 읽은 뒤 파일을 닫습니다.
  • 판의 내용 지문이 지금 판과 같으면 Stop 으로 저장과 교체를 건너뜁니다. 그래서 같은 밤을 다시 돌려도 판이 늘지 않습니다.
  • 새 판은 out_index/vNNN/ 에 다 쓴 뒤, os.replace 로 CURRENT 만 바꿉니다. 서비스는 늘 CURRENT 가 가리키는 판을 읽습니다.
  • 쓰다 실패하면 새 판 폴더만 반쯤 남고 CURRENT 는 그대로라, 서비스는 앞 판을 계속 씁니다.
← 이전 절19장 · 실습 2 — 바뀐 문서만 찾기다음 절 →19장 · 실습 4 — MLflow 로 실험 기록하기