19장. MLOps 와 LLMOps, 워크플로 자동화
실습 3 — 색인 갱신 작업 자동화
실습 2의 지문과 실습 1의 실행기로, 밤마다 도는 색인 갱신 작업을 만듭니다. 바뀐 조각만 임베딩하고(증분), 새 판 폴더에 쓴 뒤 가리킴(CURRENT)을 바꿉니다. 같은 밤을 다시 돌려도 판이 늘지 않는지 확인합니다. 임베딩은 7장의 intfloat/multilingual-e5-small 을 CPU 로 씁니다.
1단계. 준비
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())준비 끝 · 지금 판: None2단계. 갱신 작업 만들기
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단계. 첫 밤과 다음 밤 돌리기
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단계. 다시 돌려도 같은지 보고, 지금 판으로 검색하기
# 같은 밤(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는 그대로라, 서비스는 앞 판을 계속 씁니다.