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

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

프로젝트 1 — 매일 밤 도는 RAG 갱신

하늘숲 도서관의 안내 챗봇은 도서관 안내 문서를 검색해 답합니다. 원본 시스템은 밤마다 문서 전체를 library/dayN/ 로 내보냅니다. 밤마다 색인을 갱신하되, 내보내기가 망가졌거나 검색 품질이 떨어진 밤에는 색인을 바꾸지 않는 밤 작업을 만들어 보세요. 다섯 밤(0~4)을 차례로 돌리고 운영 기록을 남깁니다. 도서관, 문서, 질문은 모두 지어낸 연습용 데이터입니다.

파일생김새
library/day0/ ~ library/day4/그 밤에 내보낸 안내 문서 전체. 문서마다 첫 문단이 제목, 빈 줄로 나눈 문단이 조각이 된다. 10~12개
library_qa.jsonl질문, 근거(맞는 조각에 든 글), 부터·까지(그 질문이 맞는 밤의 범위). 24줄

요구사항은 여섯 가지입니다.

  1. 밤 작업을 DAG 로 짭니다. 읽기 → 검사 → 조각 → 임베딩 → 평가 → 게이트 → 저장 → 교체 차례입니다.
  2. 검사는 문서 수의 급감, 빈 파일, 깨진 글자를 봅니다.
  3. 바뀐 조각만 임베딩합니다(실습 3의 캐시).
  4. 평가는 그 밤에 맞는 질문으로 hit@3(근거가 든 조각이 검색 상위 3개 안에 있는 질문의 비율)을 잽니다. 지금 배포된 판도 같은 질문으로 잽니다.
  5. 게이트: 후보의 hit@3 이 0.9 이상이고, 지금 판보다 0.05 넘게 떨어지지 않을 때만 교체합니다.
  6. 밤마다 MLflow 에 결과를 기록하고, 다섯 밤의 운영 기록을 표와 그래프로 봅니다. Airflow 로 옮긴 꼴도 적습니다.

단계별 힌트입니다.

  1. 실습 1의 run_flow() 와 Stop 을 씁니다. 검사와 게이트에서 막히면 Stop 을 냅니다.
  2. 실습 3의 snapshot(), split_chunks(), 임베딩 캐시를 그대로 가져옵니다. 판 번호는 저장할 때만 늘립니다.
  3. 그 밤의 질문은 부터 <= 밤 <= 까지 인 줄입니다. 문서가 바뀌면 정답도 바뀌기 때문입니다.
  4. 지금 판의 점수는 지금 판의 벡터로, 후보의 점수는 후보의 벡터로 잽니다. 질문 벡터는 같은 것을 씁니다.
  5. MLflow 에는 밤 하나를 실행 하나로, 작업마다의 상태를 파라미터로, 점수를 지표로 남깁니다.
  6. 막힌 밤은 기록을 읽어 무엇이 막았는지 적습니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

실제 데이터로 해 보려면

공개된 문서 묶음을 날짜별로 나눠 같은 밤 작업을 돌려 볼 수 있습니다. 내려받기 전에 라이선스와 약관을 확인합니다.

자료구하는 곳내용
위키백과 덤프dumps.wikimedia.org (CC BY-SA)날짜마다 나오는 전체 덤프. 두 날짜의 같은 문서를 견주면 바뀐 문단을 찾을 수 있다
공공데이터포털 개방 문서data.go.kr기관의 안내문, 고시 같은 문서 파일. 개정 전후 판으로 갱신을 시험한다
KorQuAD 1.0korquad.github.io (CC BY-ND 2.0 KR)위키백과 문단과 질문. 문단을 조각으로, 질문을 평가 묶음으로 쓴다

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

준비, 밤 작업, 다섯 밤, 기록 보기, Airflow 의 다섯 상자를 화살표로 이은 RAG 갱신 프로젝트의 코드 흐름도

주황 화살표가 실행 차례입니다. 1~3번 상자는 1~3단계, 4번 상자는 4~5단계, 5번 상자는 6단계를 묶었습니다. 1단계(청록)는 hit_at_3() 같은 함수와 평가 질문을 준비하고 MLflow 실험을 정하며, 2단계(마젠타)는 읽기부터 교체까지 8개 작업 DAG 를 nightly() 안에 짜서 run_flow() 로 돌리게 합니다. 3단계(다홍)는 nightly(night) 로 다섯 밤을 돌리고 밤마다 실행 기록을 MLflow 에 남깁니다. 4~5단계(빨강)는 막힌 밤의 막은 까닭을 보고 mlflow.search_runs 로 운영 기록을 표로 뽑으며, 6단계(짙은 청록)는 같은 DAG 를 Airflow 의 @dag·@task 로 옮긴 꼴을 실행하지 않은 안내 코드로 보입니다.

결과 보기 (예시 풀이)

1단계. 준비(실습 3과 같은 함수, 평가 질문, MLflow)

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

import matplotlib.pyplot as plt
import mlflow
import numpy as np
import pandas as pd
from mlflow import MlflowClient
from sentence_transformers import SentenceTransformer

sys.path.insert(0, ".")
from out_flow import Stop, run_flow

plt.rcParams["font.family"] = "Malgun Gothic"        # 한글 글꼴(5단계의 그래프)
plt.rcParams["axes.unicode_minus"] = False

shutil.rmtree("out_rag_index", ignore_errors=True)   # 다시 돌려도 같게: 지난 색인을 지운다
ROOT = Path("out_rag_index")
ROOT.mkdir()
CACHE = ROOT / "embed_cache.npz"
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
QA = [json.loads(line) for line in Path("library_qa.jsonl").read_text(encoding="utf-8").splitlines()]

# 밤마다 실행 하나를 남길 실험. 다시 돌릴 때를 생각해 지난 실행은 지운다
mlflow.set_tracking_uri("sqlite:///out_mlflow.db")
if mlflow.get_experiment_by_name("도서관-색인") is None:
    mlflow.create_experiment("도서관-색인", artifact_location=Path("out_mlruns").resolve().as_uri())
mlflow.set_experiment("도서관-색인")
client = MlflowClient()
for r in mlflow.search_runs(experiment_names=["도서관-색인"]).itertuples():
    client.delete_run(r.run_id)


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():
    """지금 판의 (정보, 조각, 벡터). 판이 없으면 None"""
    if not (ROOT / "CURRENT").exists():
        return None
    folder = ROOT / (ROOT / "CURRENT").read_text(encoding="utf-8")
    return (json.loads((folder / "meta.json").read_text(encoding="utf-8")),
            json.loads((folder / "chunks.json").read_text(encoding="utf-8")), np.load(folder / "vectors.npy"))


def hit_at_3(questions, q_vecs, chunks, vectors):
    """근거가 든 조각이 상위 3개 안에 든 질문의 비율, 그리고 놓친 질문"""
    top = np.argsort(-(q_vecs @ vectors.T), axis=1)[:, :3]
    hits = [any(q["근거"] in chunks[j] for j in top[i]) for i, q in enumerate(questions)]
    return float(np.mean(hits)), [q["질문"] for q, h in zip(questions, hits) if not h]


print("질문", len(QA), "줄 · 지금 판:", current())
출력
질문 24 줄 · 지금 판: None

2단계. 밤 작업 DAG

Python
def nightly(night):
    """밤 하나의 갱신 작업. (작업 기록, ctx)"""
    def read(ctx):
        return snapshot(night)

    def check(ctx):
        docs, now = ctx["읽기"], current()
        problems = []
        if now and len(docs) < now[0]["문서"] * 0.8:
            problems.append(f"문서 수 {now[0]['문서']}→{len(docs)}")
        empty = [n for n, t in docs.items() if not t.strip()]
        broken = [n for n, t in docs.items() if t and t.count("�") / len(t) > 0.01]
        if empty:
            problems.append(f"빈 파일 {empty}")
        if broken:
            problems.append(f"깨진 글자 {broken}")
        if problems:
            raise Stop("; ".join(problems))
        return len(docs)

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

    def embed(ctx):
        chunks, cache = ctx["조각"], {}
        if CACHE.exists():
            with np.load(CACHE) as z:
                cache = {k: z[k] for k in z.files}
        todo = [c for c in chunks if fingerprint(c) 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[fingerprint(c)] for c in chunks]), "새로": len(todo)}

    def evaluate(ctx):
        qs = [q for q in QA if q["부터"] <= night <= q["까지"]]       # 그 밤에 맞는 질문만
        q_vecs = emb.encode(["query: " + q["질문"] for q in qs], normalize_embeddings=True)
        cand, missed = hit_at_3(qs, q_vecs, ctx["조각"], ctx["임베딩"]["벡터"])
        now = current()
        live = hit_at_3(qs, q_vecs, now[1], now[2])[0] if now else None   # 지금 판을 같은 질문으로
        return {"질문": len(qs), "후보": cand, "지금 판": live, "놓친 질문": missed}

    def gate(ctx):
        r = ctx["평가"]
        if r["후보"] < 0.9:
            raise Stop(f"hit@3 {r['후보']:.3f} < 0.9")
        if r["지금 판"] is not None and r["후보"] < r["지금 판"] - 0.05:
            raise Stop(f"지금 판 {r['지금 판']:.3f} 보다 {r['지금 판'] - r['후보']:.3f} 떨어짐")
        return "통과"

    def save(ctx):
        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(ctx["조각"], ensure_ascii=False), encoding="utf-8")
        meta = {"판": version, "밤": night, "문서": ctx["검사"], "조각": len(ctx["조각"])}
        (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["저장"]

    tasks = {"읽기": (read, []), "검사": (check, ["읽기"]), "조각": (chunk, ["검사"]),
             "임베딩": (embed, ["조각"]), "평가": (evaluate, ["임베딩"]), "게이트": (gate, ["평가"]),
             "저장": (save, ["게이트"]), "교체": (swap, ["저장"])}
    return run_flow(tasks, retries=1, log=lambda m: None)


print("밤 작업 준비 끝: 읽기 → 검사 → 조각 → 임베딩 → 평가 → 게이트 → 저장 → 교체")
출력
밤 작업 준비 끝: 읽기 → 검사 → 조각 → 임베딩 → 평가 → 게이트 → 저장 → 교체

3단계. 다섯 밤 돌리기

Python
log = []
for night in range(5):
    records, ctx = nightly(night)
    state = {r["작업"]: r for r in records}
    ev = ctx.get("평가", {})
    stopped = next((r for r in records if r["상태"] == "멈춤"), None)
    row = {"밤": night, "검사": state["검사"]["상태"], "새로 임베딩": ctx.get("임베딩", {}).get("새로"),
           "질문": ev.get("질문"), "후보 hit@3": ev.get("후보"), "지금 판 hit@3": ev.get("지금 판"),
           "게이트": state["게이트"]["상태"], "지금 판": current()[0]["판"] if current() else None,
           "막은 까닭": stopped["메모"] if stopped else ""}
    log.append(dict(row, 놓친질문=ev.get("놓친 질문", [])))
    # MLflow: 밤 하나 = 실행 하나. 작업마다의 상태는 파라미터, 점수는 지표로 남긴다
    with mlflow.start_run(run_name=f"night-{night}"):
        mlflow.log_params({f"task_{r['작업']}": r["상태"] for r in records})
        mlflow.log_params({"night": night, "live_version": row["지금 판"]})
        mlflow.log_metrics({k: v for k, v in {"candidate_hit3": ev.get("후보"), "live_hit3": ev.get("지금 판"),
                                              "new_chunks": row["새로 임베딩"]}.items() if v is not None})
table = pd.DataFrame(log).drop(columns="놓친질문")
table[["새로 임베딩", "질문"]] = table[["새로 임베딩", "질문"]].astype("Int64")   # 빈칸(검사에서 멈춘 밤)이 있어도 정수로 보인다
# 결과: 다섯 밤의 운영 기록
print(table.round(3).to_string(index=False))
출력
 밤 검사  새로 임베딩   질문  후보 hit@3  지금 판 hit@3 게이트 지금 판                                           막은 까닭
 0 성공      29   17      1.00         NaN  성공 v001
 1 성공       7   21      1.00       0.714  성공 v002
 2 성공       0   21      0.81       1.000  멈춤 v002                               hit@3 0.810 < 0.9
 3 멈춤    <NA> <NA>       NaN         NaN 건너뜀 v002 빈 파일 ['분실물']; 깨진 글자 ['연체', '예약', '프로그램', '회원증']
 4 성공       4   21      1.00       0.952  성공 v003

4단계. 막힌 밤 들여다보기

Python
for row in log:
    if row["게이트"] != "성공":
        print(f"밤 {row['밤']}: 검사 {row['검사']}, 게이트 {row['게이트']} · {row['막은 까닭']}")
        for q in row["놓친질문"]:
            print("   놓친 질문:", q)

# 밤 2에 덮인 문서를 밤 1과 문단 단위로 견준다
d1, d2 = snapshot(1), snapshot(2)
for name in d1:
    if d1[name] != d2.get(name):
        p1 = {x.strip() for x in d1[name].split("\n\n")}
        p2 = {x.strip() for x in d2[name].split("\n\n")}
        print(f"{name}: 사라진 문단 {sorted(p1 - p2)} · 되살아난 문단 {sorted(p2 - p1)}")
print("판 폴더:", sorted(p.name for p in ROOT.glob("v*")), "· CURRENT →", (ROOT / "CURRENT").read_text(encoding="utf-8"))
출력
밤 2: 검사 성공, 게이트 멈춤 · hit@3 0.810 < 0.9
   놓친 질문: 한 번에 최대 몇 권까지 대출돼요?
   놓친 질문: 저 혼자 책을 몇 권 빌려 갈 수 있어요?
   놓친 질문: 연말에 책 정리하느라 문 닫는 날이 있나요?
   놓친 질문: 12월에 도서관이 쉬는 날이 따로 있어요?
밤 3: 검사 멈춤, 게이트 건너뜀 · 빈 파일 ['분실물']; 깨진 글자 ['연체', '예약', '프로그램', '회원증']
대출과반납: 사라진 문단 ['책은 한 사람이 7권까지 빌릴 수 있습니다. 대출 기간은 14일입니다.'] · 되살아난 문단 ['책은 한 사람이 5권까지 빌릴 수 있습니다. 대출 기간은 14일입니다.']
휴관일: 사라진 문단 ['해마다 12월 첫째 주 목요일과 금요일은 장서 점검으로 쉽니다. 책을 하나하나 세고 제자리에 꽂는 날입니다.'] · 되살아난 문단 []
판 폴더: ['v001', 'v002', 'v003'] · CURRENT → v003

5단계. 운영 기록 보기

Python
# MLflow 에 남은 밤마다의 실행을 표로 뽑는다(웹 화면 없이 코드로)
runs = mlflow.search_runs(experiment_names=["도서관-색인"], order_by=["params.night ASC"])
print(runs[["tags.mlflow.runName", "params.task_검사", "params.task_게이트", "params.live_version",
            "metrics.candidate_hit3"]].round(3).to_string(index=False))

# 밤마다 후보의 hit@3 과 게이트의 바닥선
fig, ax = plt.subplots(figsize=(7, 3.5))
cand = table["후보 hit@3"].astype(float)
ax.plot(table["밤"], cand, marker="o", label="후보 hit@3")
ax.axhline(0.9, linestyle="--", color="gray", label="바닥 0.9")
for _, r in table.iterrows():
    ax.annotate(f"{r['게이트']}\n{r['지금 판']}", (r["밤"], 0.55), ha="center", fontsize=9)
ax.set_ylim(0.5, 1.05)
ax.set_xticks(table["밤"])
ax.set_xlabel("밤")
ax.set_ylabel("hit@3")
ax.set_title("밤마다의 색인 후보 점수와 게이트")
ax.legend(loc="center left")
plt.tight_layout()
plt.show()
출력
tags.mlflow.runName params.task_검사 params.task_게이트 params.live_version  metrics.candidate_hit3
            night-0             성공              성공                v001                    1.00
            night-1             성공              성공                v002                    1.00
            night-2             성공              멈춤                v002                    0.81
            night-3             멈춤             건너뜀                v002                     NaN
            night-4             성공              성공                v003                    1.00
밤 0부터 4까지 색인 후보의 hit@3 을 선으로 잇고, 바닥선 0.9 와 밤마다 게이트 결과와 지금 판을 적은 그래프

6단계. Airflow 로 옮긴 꼴

아래는 이 PC 에서 실행하지 않은 안내 코드입니다. 2단계의 작업 함수를 그대로 두고, 차례와 일정, 다시 시도만 Airflow 에 맡긴 꼴입니다. 실제로 옮길 때는 작업 사이에 벡터 같은 큰 값을 넘기지 않고, 파일 경로만 넘기도록 함수를 고칩니다.

출력
from datetime import timedelta
import pendulum
from airflow.sdk import dag, task


@dag(schedule="0 2 * * *", start_date=pendulum.datetime(2026, 11, 1, tz="Asia/Seoul"), catchup=False,
     default_args={"retries": 1, "retry_delay": timedelta(minutes=10)}, tags=["llmops"])
def library_rag_refresh():
    @task
    def read(ds=None):                   # 그 날짜의 내보내기 폴더 경로만 넘긴다
        return f"/exports/library/{ds}"

    @task.short_circuit
    def check(path):                     # 검사에 걸리면 False → 뒤 작업을 모두 건너뛴다
        return validate(path) == []

    @task
    def build(path):                     # 조각 + 증분 임베딩, 후보 판을 임시 폴더에 쓴다
        return build_candidate(path)

    @task
    def evaluate(candidate):
        return {"candidate": candidate, **score(candidate)}

    @task.short_circuit
    def gate(result):
        return result["후보"] >= 0.9 and (result["지금 판"] is None or result["후보"] >= result["지금 판"] - 0.05)

    @task
    def publish(result):                 # 새 판 폴더로 옮기고 CURRENT 를 바꾼다
        return swap(result["candidate"])

    path = read()
    ok = check(path)
    result = evaluate(build(path))
    ok >> result
    gate(result) >> publish(result)


library_rag_refresh()

밤 2를 시험으로 돌렸을 때의 예상 출력(실행하지 않고 유추한 예시)입니다. 게이트가 False 를 돌려 publish 가 건너뛰어집니다.

출력
airflow dags test library_rag_refresh 2026-11-03
... Running task read ... Marking task as SUCCESS
... Running task check ... Marking task as SUCCESS
... Running task build ... Marking task as SUCCESS
... Running task evaluate ... Marking task as SUCCESS
... Running task gate ... Condition result is False ... Marking task as SUCCESS
... Skipping downstream tasks: publish
... DagRun Finished: state=success

다섯 밤 가운데 세 밤만 색인이 바뀌었습니다. 밤 0은 조각 29개를 모두 임베딩해 첫 판 v001 을 만들었습니다. 밤 1은 바뀐 조각 7개만 임베딩하고 28개는 캐시에서 다시 써서 v002 로 바꿨습니다. 이때 지금 판(v001)은 밤 1의 질문에 0.714 만 맞혔습니다. 늘어난 대출 권수와 새 문서를 몰라서이니, 갱신하지 않으면 챗봇이 옛 안내를 한다는 뜻입니다.

밤 2는 형식 검사를 모두 통과했습니다. 그런데 후보의 hit@3 이 0.810 으로 바닥 0.9 에 못 미쳐 게이트가 막았습니다. 지금 판(1.000)보다도 0.19 떨어졌습니다. 놓친 네 질문은 대출 권수와 장서 점검에 관한 것이었고, 문서를 견주니 "7권까지" 문단이 "5권까지"로 되살아나고 장서 점검 문단이 사라져 있었습니다. 수정한 문서 둘이 옛 판으로 덮인 것입니다. 이 밤은 새로 임베딩한 조각이 0개였습니다. 옛 문단의 벡터가 밤 0의 캐시에 남아 있었기 때문입니다. 비용이 들지 않은 밤이 가장 위험한 밤이었고, 막은 것은 형식 검사가 아니라 평가 게이트였습니다.

밤 3은 검사가 빈 파일 하나와 글자가 깨진 문서 넷을 찾아 멈췄고, 임베딩부터 뒤의 작업은 돌지 않았습니다. 밤 2와 3 동안 서비스는 v002 를 그대로 썼습니다. 밤 4는 열람실 시간이 바뀌고 주차 문서가 오시는 길 문서로 합쳐진 정상 변경입니다. 조각 4개를 새로 임베딩했습니다. 합쳐진 주차 문단 둘은 글이 같아도 앞에 붙는 문서 이름이 바뀌어 새 조각이 되었습니다. 후보 1.000, 지금 판 0.952 로 게이트를 넘어 v003 이 되었습니다. 이 프로젝트의 평가는 검색만 잽니다. 답의 글까지 재는 법은 20장에서 다룹니다. Airflow 로 옮긴 DAG 와 그 출력은 실행하지 않은 안내 코드와 예상 출력입니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 도서관 안내 책자를 밤마다 바꾸는 야간 당번을 세우는 것과 같습니다. 도구와 시험 문제지, 당번 일지를 챙기고(1단계), 받기 → 훑어보기 → 쪽 나누기 → 숫자로 옮기기 → 시험 → 합격 심사 → 보관 → 진열의 순서표를 짭니다(2단계). 다섯 밤 동안 당번을 서며 일지를 쓰고(3단계), 책자를 바꾸지 못한 밤은 까닭을 들여다보고(4단계), 일지를 표와 그래프로 모아 봅니다(5단계). 끝으로 같은 순서표를 자동 당번 시스템(Airflow)에 옮긴 꼴을 적습니다(6단계). 여기서 시험 문제지가 library_qa.jsonl 의 그 밤 질문, 시험 점수가 hit@3, 합격 심사가 게이트, 진열이 CURRENT 교체, 당번 일지가 MLflow 기록입니다. 비유와 다른 점은, 이 시험은 책자에서 맞는 쪽을 펴는지만 볼 뿐, 안내원이 그 쪽을 읽고 바르게 말하는지는 보지 않는다는 것입니다.

단계출력뜻
1. 준비질문 24 줄 · 지금 판: None시험 질문은 모두 24줄이고, 아직 배포된 판이 없습니다
2. 작업읽기 → … → 교체실습 3의 작업 사이에 평가와 게이트가 들어간 여덟 작업입니다
3. 밤 0새로 임베딩 29 · 질문 17 · 후보 1.00 · v001첫 판입니다. 그 밤 질문 17개 모두 근거 조각을 상위 3개 안에서 찾았습니다
3. 밤 1새로 임베딩 7 · 후보 1.00 · 지금 판 0.714 · v002옛 판(v001)은 밤 1의 질문 21개 가운데 15개만 찾았습니다. 갱신하지 않으면 챗봇이 옛 안내를 한다는 뜻입니다
3. 밤 2새로 임베딩 0 · 후보 0.81 · 게이트 멈춤형식 검사는 통과했지만 21개 가운데 17개만 찾아 바닥 0.9 에 못 미쳤습니다. v002 를 그대로 둡니다
3. 밤 3검사 멈춤 · 게이트 건너뜀 · 후보 NaN빈 파일 하나와 글자가 깨진 문서 넷 때문에 임베딩부터 돌지 않아 점수가 비어 있습니다
3. 밤 4새로 임베딩 4 · 후보 1.00 · 지금 판 0.952 · v003정상 변경이라 게이트를 넘어 v003 이 되었습니다. 옛 판 v002 는 21개 가운데 20개를 찾았습니다
4. 막힌 밤놓친 질문 넷 · 7권까지 사라짐 · 5권까지 되살아남밤 2에는 대출과반납과 휴관일 문서가 옛 판으로 덮여, 대출 권수와 장서 점검에 관한 질문 넷을 놓쳤습니다
4. 판 폴더['v001', 'v002', 'v003'] · CURRENT → v003다섯 밤 가운데 세 밤만 새 판이 생겼고, 지금은 v003 을 씁니다
5. MLflow 표night-0 ~ night-4 의 검사, 게이트, 판, 점수3단계 표와 같은 내용이 MLflow 에 밤 하나당 실행 하나로 남았습니다
5. 그래프후보 hit@3 선과 바닥선 0.9밤마다의 후보 점수를 바닥선과 함께 보여 줍니다. 3단계 표대로라면 밤 2(0.81)만 바닥선 아래입니다
6. AirflowSkipping downstream tasks: publish실제 실행이 아니라 실행하지 않고 유추한 예상 출력입니다. 밤 2라면 게이트가 False 를 돌려 publish 를 건너뛸 것이라는 예시입니다

한 줄로 줄이면, 밤마다 색인을 바꾸되 "형식이 망가진 밤"은 검사가, "형식은 멀쩡한데 내용이 틀린 밤"은 평가 게이트가 막아, 서비스가 늘 검증된 판을 쓰게 한 프로젝트입니다. 가장 눈여겨볼 곳은 밤 2입니다. 새로 임베딩한 조각이 0개라 비용이 들지 않았는데도, 게이트가 없었다면 옛 안내가 배포됐을 밤입니다. 다만 hit@3 은 맞는 조각을 찾았는지만 재므로, RAG(Retrieval-Augmented Generation, 검색 증강 생성) 챗봇의 답 글이 옳은지는 아직 모릅니다. 그것은 20장에서 잽니다. 질문이 밤마다 17~21개라 질문 하나가 점수를 0.05 안팎 움직인다는 점도 함께 기억해 둡니다.

← 이전 절19장 · 실습 6 — 학습과 배포 파이프라인다음 절 →19장 · 정리