(2/5) 내부문서 RAG 챗봇 — Q&A 굽기, 커버리지와 정지 조건
🧠 내부문서 RAG 챗봇 — Hermes·Codex·ES·Redis (전체 6편)
- 아키텍처 검증 — 이 구성, 논리적으로 맞나
- Q&A 굽기 — 커버리지와 정지 조건 ← 지금 글
- 신뢰도 게이트 — 그라운드니스와 골든셋
- 서빙 경로 — ES 하이브리드 검색과 Redis 3단 게이트
- 운영 — 재구축 가능한 캐시, GPU 비용, 모니터링
- 8.5점에서 9.4점으로 — 2차 채점
Summary
1편에서 원안을 6점으로 채점하고, 세 군데를 고쳐 8.5점을 만들었어요. 그중 첫 번째 결함 — “무한 생성은 성립하지 않는다” — 를 실제 파이프라인으로 옮기는 게 이번 글입니다.
핵심은 이거예요.
“많이 만들기”를 목표로 두면 이 파이프라인은 반드시 망가집니다. 목표는 “문서를 빠짐없이 덮기” 여야 하고, 그러면 언제 멈출지가 저절로 정해집니다.
같은 질문을 백만 개 만드는 것과, 문서의 모든 조항을 한 번씩 덮는 것은 완전히 다른 일이에요. 전자는 디스크만 먹고 후자는 자산이 됩니다. 그런데 개수만 세면 이 둘이 구분이 안 돼요.
그래서 이번 글은 세는 방법을 바꾸는 이야기입니다.
💡 이 글에서 다루는 것
- 문서 → 청크 — 규정 문서는 왜 조항 단위로 잘라야 하나
- 청크 → 질문 — “이 청크만 보고 답할 수 있는 질문”만 만들게 하는 프롬프트
- 질문 유형을 강제로 흩뜨리기 (안 하면 전부 “~는 무엇인가요”가 됨)
- 2단 중복 억제 — MinHash 로 싸게 거르고 임베딩으로 정밀하게
- 커버리지 — 진행률을 개수 대신 이걸로 보기
- 신규율 기반 정지 — 광맥이 끝난 걸 알아채는 법
- Hermes 배치 러너 — 예산 상한과 재개
- 산출물 JSONL 스키마와, 자주 밟는 지뢰
1. 전체 흐름
이번 편에서 만들 파이프라인은 다섯 단계예요.
| 단계 | 입력 | 출력 | 어디서 |
|---|---|---|---|
| 1. 청킹 | 원문 문서 | 청크 + 메타데이터 | 로컬 (CPU) |
| 2. 질문 생성 | 청크 | 질문 후보 | Hermes + Codex |
| 3. 중복 억제 | 질문 후보 | 신규 질문만 | 로컬 (MinHash + 임베딩) |
| 4. 답변 생성 | 신규 질문 + 청크 | Q&A 쌍 | Hermes + Codex |
| 5. 적재 | Q&A 쌍 | JSONL | 로컬 디스크 |
3편의 검증 게이트는 5단계 앞에 들어갑니다. 여기서는 아직 안 붙여요 — 먼저 뽑는 것부터.
그리고 1편에서 정한 원칙 하나를 다시 확인하고 갑니다.
배치에는 외부 좋은 모델을 쓴다.
질문 생성(2단계)과 답변 생성(4단계)은 전부 야간 배치예요. 지연이 중요하지 않고, 여기서 나온 품질이 그대로 자산으로 굳습니다. Codex 를 쓰기 딱 좋은 자리예요. 반면 3단계의 임베딩은 로컬 모델로 갑니다 — 이건 나중에 질의 경로에서도 똑같이 써야 하니까요.
2. 청킹 — 규정 문서는 조항 단위로
일반적인 RAG 튜토리얼은 “500자씩 자르고 50자 겹쳐라” 같은 걸 알려줘요. 내부 규정 문서에는 잘 안 맞습니다.
이유가 있어요. 규정 문서는 이미 사람이 의미 단위로 잘라놓은 문서거든요.
| 고정 길이 청킹 | 조항 단위 청킹 |
|---|---|
| 조항 중간에서 잘림 | 조항이 온전히 보존됨 |
| “제3항은 예외로 한다”가 앞뒤 잘려 무의미 | 항까지 같이 붙어 의미 유지 |
| 출처 표기가 “문서 42번째 조각” | 출처가 “취업규칙 제15조 2항” |
마지막이 특히 큽니다. 챗봇이 “취업규칙 제15조 2항에 따르면” 이라고 답할 수 있느냐 없느냐는 신뢰도에서 아주 큰 차이예요.
import re
# 조 단위로 먼저 자르고, 조가 너무 길면 항 단위로 한 번 더
ARTICLE = re.compile(r"^제\s*(\d+)\s*조(?:\s*\(([^)]*)\))?", re.M)
CLAUSE = re.compile(r"^\s*[①-⑳]|^\s*\d+\.", re.M)
MAX_CHARS = 1200
def split_articles(text, doc_name, revised_at):
hits = list(ARTICLE.finditer(text))
out = []
for i, m in enumerate(hits):
end = hits[i + 1].start() if i + 1 < len(hits) else len(text)
body = text[m.start():end].strip()
head = {
"doc": doc_name,
"article": int(m.group(1)),
"title": (m.group(2) or "").strip(),
"revised_at": revised_at,
}
out.extend(_split_long(body, head))
return out
def _split_long(body, head):
if len(body) <= MAX_CHARS:
return [dict(head, clause=None, text=body)]
parts, marks = [], list(CLAUSE.finditer(body))
if not marks:
return [dict(head, clause=None, text=body[:MAX_CHARS])]
for j, m in enumerate(marks):
end = marks[j + 1].start() if j + 1 < len(marks) else len(body)
parts.append(dict(head, clause=j + 1, text=body[m.start():end].strip()))
return parts
revised_at(개정일)을 메타데이터에 꼭 넣으세요. 규정은 개정되고, 개정 전 조항으로 만든 Q&A 는 어느 날 갑자기 오답이 됩니다. 나중에 무효화할 때 이 필드가 유일한 단서예요. 5편에서 다시 씁니다.
청크에는 안정적인 ID 를 붙여둡니다.
import hashlib
def chunk_id(c):
key = f"{c['doc']}|{c['article']}|{c.get('clause')}|{c['revised_at']}"
return hashlib.sha1(key.encode("utf-8")).hexdigest()[:16]
내용이 아니라 위치로 ID 를 만든 게 포인트예요. 오탈자 수정으로 본문이 살짝 바뀌어도 ID 가 유지돼서 커버리지 집계가 안 깨집니다.
3. 질문 생성 — “이 청크만 보고 답할 수 있는” 것만
여기서 프롬프트 한 줄이 파이프라인 전체의 운명을 가릅니다.
나쁜 프롬프트
“다음 문서를 읽고 예상 질문 10개를 만들어줘.”
이렇게 하면 이런 게 나와요.
| 나온 질문 | 문제 |
|---|---|
| 우리 회사 연차 제도는 다른 회사와 비교해 어떤가요 | 문서에 답이 없음 |
| 연차를 안 쓰면 어떻게 되나요 | 이 청크엔 없고 다른 조항에 있음 |
| 연차는 좋은 제도인가요 | 답이 존재하지 않음 |
전부 1편에서 말한 오염이에요. 답을 만들려면 지어내야 하는 질문들입니다.
좋은 프롬프트
제약을 명시적으로 겁니다.
You are given ONE clause from an internal policy document.
Generate questions that satisfy ALL of the following:
1. The answer MUST be fully contained in the given clause.
If answering requires any other clause, external knowledge,
or assumption, DO NOT generate that question.
2. Write questions the way an employee would actually ask them
in a chat window - casual, short, colloquial Korean.
3. Do NOT reference the document structure.
Bad: "제15조에 따르면 연차는?"
Good: "연차 며칠 쓸 수 있어?"
4. If this clause cannot support any self-contained question,
return an empty array. An empty result is a valid, good answer.
Return JSON: [{"q": "...", "type": "...", "evidence": "..."}]
- type: one of fact | condition | procedure | exception | amount
- evidence: the exact substring of the clause that answers it
네 번째 규칙이 특히 중요해요. “만들 게 없으면 빈 배열을 내라” 고 명시적으로 허락해줘야 합니다. 안 그러면 LLM 은 목차나 부칙 같은 청크에서도 억지로 뭔가를 만들어내요.
세 번째도 실무에서 크게 갈립니다. 사용자는 “제15조에 따르면” 이라고 안 물어요. 사용자가 실제로 칠 법한 문장으로 만들어둬야 나중에 질문↔질문 매칭이 맞습니다.
그리고 evidence — 답의 근거가 되는 원문 그대로의 부분 문자열을 같이 뱉게 합니다. 3편의 검증 게이트가 이걸 씁니다.
유형을 강제로 흩뜨리기
그냥 두면 생성된 질문의 대부분이 fact 유형이 됩니다. 실제 사용자 질문 분포와 안 맞아요.
TYPES = ["fact", "condition", "procedure", "exception", "amount"]
def build_prompt(chunk, want_types):
types = ", ".join(want_types)
return (
f"{SYSTEM_RULES}\n\n"
f"Focus on these question types only: {types}\n\n"
f"<clause>\n{chunk['text']}\n</clause>"
)
def rotate_types(round_no, k=2):
# 라운드마다 다른 유형 조합을 요구해 한쪽으로 쏠리는 걸 막는다
start = (round_no * k) % len(TYPES)
return [TYPES[(start + i) % len(TYPES)] for i in range(k)]
같은 청크를 여러 라운드에 걸쳐 다른 유형으로 다시 방문하는 거예요. 청크 하나에서 뽑을 수 있는 각도가 여러 개니까요. 그리고 이게 뒤에서 볼 신규율을 자연스럽게 유지시켜 줍니다.
4. 중복 억제 — 싸게 먼저, 정밀하게 나중에
생성한 질문 중 상당수가 기존과 겹칩니다. 전부 임베딩으로 비교하면 비싸요. 그래서 2단으로 갑니다.
| 단 | 방법 | 비용 | 걸러내는 것 |
|---|---|---|---|
| 1단 | MinHash (문자 n-gram) | 아주 쌈 | 표현까지 거의 같은 것 |
| 2단 | 임베딩 코사인 | 비쌈 | 표현은 다른데 뜻이 같은 것 |
1단에서 대부분이 걸러지고, 2단은 살아남은 것만 봅니다.
import hashlib
SHINGLE = 4
NUM_PERM = 64
MASK = (1 << 32) - 1
def _shingles(s):
s = "".join(s.split())
return {s[i:i + SHINGLE] for i in range(max(1, len(s) - SHINGLE + 1))}
def minhash(s):
sig = []
for seed in range(NUM_PERM):
best = MASK
for sh in _shingles(s):
h = hashlib.blake2b(sh.encode(), digest_size=8,
key=seed.to_bytes(8, "big")).digest()
v = int.from_bytes(h[:4], "big")
if v < best:
best = v
sig.append(best)
return tuple(sig)
def jaccard(a, b):
return sum(1 for x, y in zip(a, b) if x == y) / len(a)
표준 라이브러리만 씁니다. 정확한 Jaccard 가 아니라 추정치지만, 1단 필터로는 충분해요.
⚠️ 아래
dedupe()는 후보마다 저장된 시그니처를 전부 훑습니다. 읽기 쉬우라고 그렇게 뒀는데, 15,000개 규모가 되면 비교 횟수가 $O(n^2)$ 로 늡니다. 실제로는 시그니처를 몇 개 구간(band)으로 잘라 같은 구간 값을 가진 것끼리만 버킷에 모아 비교해요(LSH). 그래야 “아주 쌈”이 실제로 성립합니다.
2단은 임베딩입니다. 1편 7절에서 정한 로컬 모델을 여기서 처음 씁니다.
def dedupe(candidates, store, emb, tau_fast=0.85, tau_slow=0.92):
fresh = []
for q in candidates:
sig = minhash(q["q"])
# 1단: 표현이 거의 같은 게 이미 있으면 즉시 탈락
if any(jaccard(sig, s) >= tau_fast for s in store.signatures()):
continue
# 2단: 뜻이 같은 게 있는지 임베딩으로
v = emb.encode(q["q"])
if store.max_cosine(v) >= tau_slow:
continue
q["_sig"], q["_vec"] = sig, v
fresh.append(q)
return fresh
⚠️
tau_slow=0.92를 그대로 베끼지 마세요. 1편 3절에서 짚었듯 이 숫자는 임베딩 모델에 붙어 있는 값입니다. 모델을 정한 뒤 실제 분포를 재서 잡아야 해요. 재는 법은 3편에서 골든셋과 함께 다룹니다.
임계값을 설정 파일로 빼두는 것도 1편에서 말한 이음매 중 하나예요.
CONFIG = {
"embedding_model": "local-multilingual-v1",
"embedding_dim": 1024,
"tau_fast": 0.85,
"tau_slow": 0.92,
}
embedding_model 을 여기 적어두고, 만드는 벡터마다 이 값을 태그로 붙입니다. 나중에 모델을 바꿨을 때 섞인 벡터를 검출할 수 있어야 하거든요.
5. 커버리지 — 진행률을 이걸로 봅니다
이제 세는 방법을 바꿉니다. 1편에서 말한 그 지점이에요.
\[\text{Coverage} = \frac{\lvert\{\, c \in C \;:\; n(c) \ge 1 \,\}\rvert}{\lvert C \rvert}\]쉽게 풀면 이렇습니다. 분수니까 분모부터 읽을게요.
- $C$ — 전체 청크 집합. $\lvert C \rvert$ 는 그 개수예요. 세로 막대 두 개는 “개수를 세라”는 뜻.
- $n(c)$ — 청크 $c$ 를 근거로 만들어진 Q&A 가 몇 개인지.
- 분자는 “Q&A 가 하나라도 붙은 청크의 개수”.
그러니까 커버리지는 “문서 조각 중 몇 %가 최소 한 번은 다뤄졌나” 예요. 0.87 이면 조각의 87% 는 물어볼 거리가 하나라도 생겼고, 13% 는 아직 아무도 안 물어본 상태라는 뜻입니다.
개수와 뭐가 다른지 보면 명확해요.
| 상황 | Q&A 개수 | 커버리지 | 실제 쓸모 |
|---|---|---|---|
| 인기 조항 10개에서 10만 개 | 100,000 | 0.02 | 거의 없음 |
| 모든 조항에서 3개씩 | 15,000 | 1.00 | 큼 |
개수만 보면 첫 번째가 6배 좋아 보입니다. 실제로는 반대예요.
from collections import Counter
def coverage(qa_rows, all_chunk_ids):
hit = Counter(r["chunk_id"] for r in qa_rows)
covered = sum(1 for cid in all_chunk_ids if hit[cid] >= 1)
return covered / len(all_chunk_ids)
def uncovered(qa_rows, all_chunk_ids, min_n=1):
hit = Counter(r["chunk_id"] for r in qa_rows)
return [cid for cid in all_chunk_ids if hit[cid] < min_n]
uncovered() 가 진짜 쓸모 있어요. 다음 배치를 어디에 쓸지 알려줍니다.
def next_batch(all_chunks, qa_rows, size=200):
gaps = set(uncovered(qa_rows, [c["id"] for c in all_chunks]))
todo = [c for c in all_chunks if c["id"] in gaps]
return todo[:size] if todo else _round_robin(all_chunks, qa_rows, size)
빈 곳부터 채우고, 다 채워졌으면 골고루 두 번째 바퀴를 돕니다. 무작위로 청크를 뽑아 돌리는 것보다 훨씬 빨리 커버리지가 올라가요.
커버리지가 안 올라가는 청크
돌리다 보면 아무리 해도 Q&A 가 안 붙는 청크가 남습니다. 이건 버그가 아니라 정상이에요.
| 청크 종류 | 왜 질문이 안 나오나 |
|---|---|
| 목차 · 별표 제목 | 내용이 없음 |
| 부칙 (“이 규정은 ~부터 시행한다”) | 물어볼 사람이 없음 |
| 정의 조항의 나열 항목 | 단독으로는 질문이 안 됨 |
이런 건 명시적으로 제외 표시를 해두고 커버리지 분모에서 빼세요. 안 그러면 100% 가 영원히 안 나와서 정지 조건이 작동하지 않습니다.
def mark_barren(chunk_id, store, reason):
store.set_flag(chunk_id, "barren", reason)
def effective_chunks(all_chunks, store):
return [c for c in all_chunks if not store.get_flag(c["id"], "barren")]
두 번 연속 빈 배열이 나온 청크는 자동으로 barren 처리하면 편해요.
6. 정지 조건 — 광맥이 끝난 걸 알아채기
1편에서 정의한 신규율 $r_k$ 를 코드로 옮깁니다. 배치에서 나온 질문 중 중복 억제를 통과한 비율이 그대로 신규율이에요.
def run_round(round_no, all_chunks, store, emb, llm):
chunks = next_batch(effective_chunks(all_chunks, store),
store.qa_rows(), size=200)
if not chunks:
return None
want = rotate_types(round_no)
raw = []
for c in chunks:
got = llm.generate(build_prompt(c, want))
if not got:
store.bump_empty(c["id"])
raw.extend(dict(g, chunk_id=c["id"]) for g in got)
fresh = dedupe(raw, store, emb)
store.add(fresh)
novelty = len(fresh) / len(raw) if raw else 0.0
return {
"round": round_no,
"asked": len(raw),
"fresh": len(fresh),
"novelty": novelty,
"coverage": coverage(store.qa_rows(),
[c["id"] for c in effective_chunks(all_chunks, store)]),
}
정지 판정은 두 조건을 함께 봅니다.
MIN_NOVELTY = 0.10
MIN_COVERAGE = 0.95
PATIENCE = 3
def should_stop(history):
if not history:
return False, ""
last = history[-1]
if last["coverage"] >= MIN_COVERAGE:
recent = history[-PATIENCE:]
if len(recent) == PATIENCE and all(h["novelty"] < MIN_NOVELTY for h in recent):
return True, "saturated"
if len(history) >= PATIENCE * 2:
window = history[-PATIENCE:]
if all(h["fresh"] == 0 for h in window):
return True, "dry"
return False, ""
두 조건을 같이 보는 이유가 있어요. 커버리지만 보면 너무 일찍 멈추고, 신규율만 보면 안 멈춥니다.
| 조건 | 단독으로 쓰면 |
|---|---|
| 커버리지 ≥ 0.95 만 | 조항마다 딱 1개씩만 있고 각도가 빈약한 채로 종료 |
| 신규율 < 0.10 만 | 유형 로테이션 때문에 신규율이 계속 튀어서 안 멈춤 |
| 둘 다 | 넓게 덮었고 + 더 캐도 새 게 안 나올 때만 종료 |
PATIENCE=3 은 연속 3라운드를 요구한다는 뜻이에요. 한 라운드 신규율이 낮은 건 그냥 그 배치의 청크가 빈약했던 걸 수도 있으니까요.
돌리면 로그가 이렇게 찍힙니다.
round=1 asked=1840 fresh=1791 novelty=0.973 coverage=0.212
round=2 asked=1802 fresh=1604 novelty=0.890 coverage=0.401
round=5 asked=1795 fresh=1013 novelty=0.564 coverage=0.798
round=9 asked=1781 fresh= 402 novelty=0.226 coverage=0.947
round=12 asked=1774 fresh= 151 novelty=0.085 coverage=0.961
round=13 asked=1769 fresh= 133 novelty=0.075 coverage=0.963
round=14 asked=1772 fresh= 118 novelty=0.067 coverage=0.964
stop: saturated (coverage=0.964, novelty<0.10 for 3 rounds)
14라운드에서 끝났어요. 여기서 더 돌리면? 신규율이 0.05, 0.03 으로 떨어지면서 “새롭다”고 판정된 것들의 품질이 급격히 나빠집니다. 임베딩상 안 겹칠 뿐 실제로는 억지 질문이거든요. 그 지점이 바로 1편에서 말한 오염 구간이에요.
7. Hermes 배치 러너 — 예산과 재개
Hermes 셋업에서 만든 컨테이너 위에 이 배치를 얹습니다. 두 가지가 꼭 필요해요.
| 필요한 것 | 왜 |
|---|---|
| 예산 상한 | Codex OAuth 는 구독 기반. 무제한 배치는 레이트리밋에 걸리고 약관상도 회색 |
| 재개 | 배치가 몇 시간짜리라 중간에 끊기면 처음부터는 곤란 |
이 저장소의 부동산 수집기와 같은 패턴이에요 — --max-calls 로 하루 예산을 끊고 다음 실행이 이어받습니다.
import json
import os
class Budget:
def __init__(self, max_calls):
self.max_calls, self.used = max_calls, 0
def take(self, n=1):
if self.used + n > self.max_calls:
raise BudgetExhausted(f"{self.used}/{self.max_calls}")
self.used += n
def load_state(path):
if not os.path.exists(path):
return {"round": 0, "history": []}
with open(path, encoding="utf-8") as f:
return json.load(f)
def save_state(path, state):
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False)
os.replace(tmp, path)
os.replace 로 원자적 교체를 하는 게 포인트예요. 상태 파일을 쓰는 도중에 죽으면 다음 실행이 깨진 JSON 을 읽게 됩니다.
메인 루프는 이렇게 됩니다.
def main(max_calls, state_path):
state = load_state(state_path)
budget = Budget(max_calls)
try:
while True:
stop, why = should_stop(state["history"])
if stop:
print(f"stop: {why}")
break
budget.take(1)
state["round"] += 1
stat = run_round(state["round"], all_chunks, store, emb, llm)
if stat is None:
print("stop: no chunks left")
break
state["history"].append(stat)
save_state(state_path, state)
print(fmt(stat))
except BudgetExhausted as e:
print(f"budget exhausted: {e} - resuming next run")
finally:
save_state(state_path, state)
finally 에서 한 번 더 저장하는 게 중요해요. 예산이 떨어지든 예외가 나든 진행 상황은 남습니다.
크론에 물릴 때는 이 저장소의 기존 스크립트들처럼 flock 을 씌우세요.
flock -w 7200 ~/.cache/qagen.lock \
python3 scripts/generate_qa.py --max-calls 40 --state ~/.cache/qagen.json
배치가 겹쳐 돌면 같은 청크를 두 번 처리하고 중복 억제가 꼬입니다.
8. 산출물 — JSONL 스키마
1편 결함 ⑥·⑦에서 정한 대로, 원본(source of truth)은 이 JSONL 파일입니다. Redis 도 ES 도 여기서 재구축돼요.
{
"id": "qa_8f3a1c2b",
"q": "연차 며칠 쓸 수 있어?",
"a": "입사 1년이 지나면 15일의 연차 유급휴가가 부여됩니다.",
"type": "fact",
"chunk_id": "3e9d7a1f22b04c85",
"evidence": "연차 유급휴가는 입사 1년 후 15일이 부여된다",
"doc": "취업규칙",
"article": 15,
"clause": 2,
"revised_at": "2026-03-01",
"gen_model": "codex-oauth",
"embedding_model": "local-multilingual-v1",
"created_at": "2026-08-20T02:31:00+09:00",
"verified": null
}
몇 개 필드는 이유가 있어서 넣었어요.
| 필드 | 왜 필요한가 |
|---|---|
chunk_id |
커버리지 집계의 열쇠. 원문 개정 시 무효화 대상 찾기 |
evidence |
3편 검증 게이트의 입력. 없으면 채점을 못 함 |
revised_at |
개정 전 Q&A 를 골라내는 유일한 단서 (5편) |
embedding_model |
벡터가 섞였는지 검출. 1편의 이음매 |
verified |
3편에서 채워짐. 지금은 null |
벡터는 여기 안 넣습니다. 별도 파일로 빼세요.
def dump_jsonl(rows, path):
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for r in rows:
r = {k: v for k, v in r.items() if not k.startswith("_")}
f.write(json.dumps(r, ensure_ascii=False) + "\n")
os.replace(tmp, path)
_sig, _vec 같은 임시 필드를 떨어내고 씁니다. 벡터를 JSONL 에 섞으면 파일이 수십 배로 불고, 임베딩 모델을 바꿀 때마다 이 원본을 다시 써야 해서 원본의 자격을 잃어요. 원본은 모델이 바뀌어도 그대로여야 합니다.
9. 자주 밟는 지뢰
실제로 돌려보면 걸리는 것들이에요.
① 같은 청크를 여러 번 방문했는데 매번 같은 질문이 나옵니다. 유형 로테이션(3절)을 안 걸었을 가능성이 큽니다. 프롬프트에 온도를 올리는 걸로는 잘 안 풀려요 — 온도를 올리면 다양해지는 게 아니라 부정확해집니다. 각도를 명시적으로 지정하는 쪽이 맞아요.
② 커버리지가 0.7 근처에서 안 올라갑니다.
barren 청크를 안 빼고 있을 확률이 높아요(5절). 목차·부칙이 분모에 남아 있으면 영원히 안 채워집니다.
③ 신규율이 계속 0.9 이상입니다. 중복 억제가 작동을 안 하는 겁니다. 임베딩 모델 태그를 확인하세요 — 중간에 모델이 바뀌었으면 옛 벡터와 새 벡터의 코사인이 의미 없는 값이 됩니다. 1편에서 말한 섞인 벡터 사고예요.
④ 답변이 청크 밖 내용을 참조합니다. “자세한 내용은 별표 3 참조” 같은 답이 나오면, 그 청크는 애초에 자족적이지 않은 거예요. 3편 검증 게이트에서 떨어질 겁니다. 여기서 미리 막으려면 프롬프트 규칙 1번을 더 강하게 거세요.
⑤ JSONL 이 갑자기 반토막 났습니다.
os.replace 없이 직접 쓰다가 죽은 경우예요. 임시 파일 후 원자적 교체는 선택이 아니라 필수입니다.
10. 정리
이번 편의 요점을 한 줄로 줄이면 이렇습니다.
개수를 세지 말고 커버리지를 세세요. 그러면 “언제 멈출지”가 목표에서 저절로 따라 나옵니다.
그리고 1편의 결함 ① 은 이걸로 닫혔어요.
| 원안 | 지금 |
|---|---|
| 무한 생성 | 커버리지 ≥ 0.95 그리고 신규율 < 0.10 이 3라운드 연속일 때 정지 |
| 진행률 = 개수 | 진행률 = 커버리지, 다음 배치는 빈 곳부터 |
| 중복 대책 없음 | MinHash 1단 + 임베딩 2단 |
다만 아직 큰 게 하나 남았습니다. 여기서 나온 Q&A 가 맞는지는 아무도 확인하지 않았어요. verified 필드가 null 인 채로 있죠.
3편에서 그 게이트를 만듭니다. 그리고 같은 글에서 골든셋을 만들면서, 1편 7절에서 미뤄둔 임베딩 모델을 실제로 재는 일도 같이 합니다.