19장. MLOps 와 LLMOps, 워크플로 자동화
프로젝트 1 — 매일 밤 도는 RAG 갱신
하늘숲 도서관의 안내 챗봇은 도서관 안내 문서를 검색해 답합니다. 원본 시스템은 밤마다 문서 전체를 library/dayN/ 로 내보냅니다. 밤마다 색인을 갱신하되, 내보내기가 망가졌거나 검색 품질이 떨어진 밤에는 색인을 바꾸지 않는 밤 작업을 만들어 보세요. 다섯 밤(0~4)을 차례로 돌리고 운영 기록을 남깁니다. 도서관, 문서, 질문은 모두 지어낸 연습용 데이터입니다.
| 파일 | 생김새 |
|---|---|
library/day0/ ~ library/day4/ | 그 밤에 내보낸 안내 문서 전체. 문서마다 첫 문단이 제목, 빈 줄로 나눈 문단이 조각이 된다. 10~12개 |
library_qa.jsonl | 질문, 근거(맞는 조각에 든 글), 부터·까지(그 질문이 맞는 밤의 범위). 24줄 |
요구사항은 여섯 가지입니다.
- 밤 작업을 DAG 로 짭니다. 읽기 → 검사 → 조각 → 임베딩 → 평가 → 게이트 → 저장 → 교체 차례입니다.
- 검사는 문서 수의 급감, 빈 파일, 깨진 글자를 봅니다.
- 바뀐 조각만 임베딩합니다(실습 3의 캐시).
- 평가는 그 밤에 맞는 질문으로 hit@3(근거가 든 조각이 검색 상위 3개 안에 있는 질문의 비율)을 잽니다. 지금 배포된 판도 같은 질문으로 잽니다.
- 게이트: 후보의 hit@3 이 0.9 이상이고, 지금 판보다 0.05 넘게 떨어지지 않을 때만 교체합니다.
- 밤마다 MLflow 에 결과를 기록하고, 다섯 밤의 운영 기록을 표와 그래프로 봅니다. Airflow 로 옮긴 꼴도 적습니다.
단계별 힌트입니다.
- 실습 1의
run_flow()와Stop을 씁니다. 검사와 게이트에서 막히면Stop을 냅니다. - 실습 3의
snapshot(),split_chunks(), 임베딩 캐시를 그대로 가져옵니다. 판 번호는 저장할 때만 늘립니다. - 그 밤의 질문은
부터 <= 밤 <= 까지인 줄입니다. 문서가 바뀌면 정답도 바뀌기 때문입니다. - 지금 판의 점수는 지금 판의 벡터로, 후보의 점수는 후보의 벡터로 잽니다. 질문 벡터는 같은 것을 씁니다.
- MLflow 에는 밤 하나를 실행 하나로, 작업마다의 상태를 파라미터로, 점수를 지표로 남깁니다.
- 막힌 밤은 기록을 읽어 무엇이 막았는지 적습니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
공개된 문서 묶음을 날짜별로 나눠 같은 밤 작업을 돌려 볼 수 있습니다. 내려받기 전에 라이선스와 약관을 확인합니다.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| 위키백과 덤프 | dumps.wikimedia.org (CC BY-SA) | 날짜마다 나오는 전체 덤프. 두 날짜의 같은 문서를 견주면 바뀐 문단을 찾을 수 있다 |
| 공공데이터포털 개방 문서 | data.go.kr | 기관의 안내문, 고시 같은 문서 파일. 개정 전후 판으로 갱신을 시험한다 |
| KorQuAD 1.0 | korquad.github.io (CC BY-ND 2.0 KR) | 위키백과 문단과 질문. 문단을 조각으로, 질문을 평가 묶음으로 쓴다 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례입니다. 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)
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 줄 · 지금 판: None2단계. 밤 작업 DAG
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단계. 다섯 밤 돌리기
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 성공 v0034단계. 막힌 밤 들여다보기
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 → v0035단계. 운영 기록 보기
# 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
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. Airflow | Skipping downstream tasks: publish | 실제 실행이 아니라 실행하지 않고 유추한 예상 출력입니다. 밤 2라면 게이트가 False 를 돌려 publish 를 건너뛸 것이라는 예시입니다 |
한 줄로 줄이면, 밤마다 색인을 바꾸되 "형식이 망가진 밤"은 검사가, "형식은 멀쩡한데 내용이 틀린 밤"은 평가 게이트가 막아, 서비스가 늘 검증된 판을 쓰게 한 프로젝트입니다. 가장 눈여겨볼 곳은 밤 2입니다. 새로 임베딩한 조각이 0개라 비용이 들지 않았는데도, 게이트가 없었다면 옛 안내가 배포됐을 밤입니다. 다만 hit@3 은 맞는 조각을 찾았는지만 재므로, RAG(Retrieval-Augmented Generation, 검색 증강 생성) 챗봇의 답 글이 옳은지는 아직 모릅니다. 그것은 20장에서 잽니다. 질문이 밤마다 17~21개라 질문 하나가 점수를 0.05 안팎 움직인다는 점도 함께 기억해 둡니다.