(6/6) 내부문서 RAG 챗봇 — 8.5점에서 9.4점으로, 남은 1.5점은 어디서 사오나
🧠 내부문서 RAG 챗봇 — Hermes·Codex·ES·Redis (전체 6편)
- 아키텍처 검증 — 이 구성, 논리적으로 맞나
- Q&A 굽기 — 커버리지와 정지 조건
- 신뢰도 게이트 — 그라운드니스와 골든셋
- 서빙 경로 — ES 하이브리드 검색과 Redis 3단 게이트
- 운영 — 재구축 가능한 캐시, GPU 비용, 모니터링
- 8.5점에서 9.4점으로 — 2차 채점 ← 지금 글
Summary
5편 마지막에 “아직 약한 곳” 세 가지를 적어두고 시리즈를 닫았어요. 골든셋이 병목이고, 임계값이 여섯 개고, 즉답 경로는 여전히 검증을 건너뛴다는 것.
적어두고 나니 걸리더라고요. 약점을 정직하게 나열한 것과 그 약점을 고친 것은 다른 일이니까요.
그래서 한 편 더 씁니다. 다만 시작하기 전에 규칙을 하나 걸어둘게요.
⚠️ 점수를 목표로 잡으면 채점표를 되맞추고 싶어집니다. 채점표는 제가 1편에서 만든 것이라, 축을 재정의하면 9.5점은 오후 한나절이면 만들어져요. 그건 채점이 아니라 분식입니다. 채점표는 손대지 않습니다. 5편에서 이미 이름 붙여둔 잔여 결함만 실제로 닫고, 점수는 결과로 따라오게 둡니다. 그리고 못 사는 점수는 못 산다고 적습니다.
결론부터 말하면 9.4점이 나왔고, 10점은 이 구조에서 도달 불가라는 것도 같이 확인했어요.
💡 이 글에서 다루는 것
- 남은 1.5점이 어느 축에 있나 (수렴성은 이미 만점이라 여기선 못 얻음)
- 즉답 게이트 이중화 — 서로 다른 두 자로 각각 재고, 둘 다 통과해야 즉답
- 이 코퍼스에서 안전한 즉답 구간이 아예 없으면 즉답을 스스로 끄는 설계
- 근거 생존 확인 — 사람이 버전을 안 올려도 상한 답이 안 나가게
- 이음매를 리허설한다 — 박아만 둔 이음매는 검증된 적이 없다
- 사람이 정하는 임계값을 6개에서 1개로
- 골든셋을 전수가 아니라 델타로 갱신
- 2차 채점 9.4, 그리고 왜 10점은 살 수 없나
1. 남은 1.5점이 어디 있나
1편의 채점표를 그대로 놓고 헤드룸만 봅니다.
| 축 | 원안 | 보정안 | 만점 | 남은 여지 |
|---|---|---|---|---|
| 정합성 | 1.5 | 2.5 | 3 | 0.5 |
| 정확도 | 1.0 | 2.5 | 3 | 0.5 |
| 수렴성 | 1.5 | 2.0 | 2 | 0 |
| 운영성 | 2.0 | 1.5 | 2 | 0.5 |
수렴성은 이미 만점이라 여기서는 한 점도 못 얻습니다. 2편의 정지 조건이 제 몫을 다 했어요. 이건 좋은 신호예요 — 더 짜낼 데가 없다는 뜻이니까.
그럼 남은 셋인데, 각 축을 막고 있는 게 5편에서 적은 잔여 결함과 정확히 대응합니다.
| 축 | 무엇이 막고 있나 | 5편에서 뭐라고 적었나 |
|---|---|---|
| 정확도 | 즉답 경로가 LLM 검증을 건너뜀 | “사고가 난다면 십중팔구 여기입니다” |
| 정합성 | 이음매를 박아만 두고 써본 적 없음 | (여기는 안 적었음 — 이번에 발견) |
| 운영성 | 임계값 6개 + 골든셋 전수 재라벨링 | “골든셋이 병목입니다” |
정합성 쪽은 이번에 다시 보면서 새로 걸린 건데, 꽤 뼈아픈 지적이라 3절 뒤에 따로 다룹니다.
2. 정확도 ① — 즉답 게이트를 두 자로 잰다
문제를 다시 정확히 놓기
4편 1절에서 이렇게 적었어요.
즉답 경로의 안전은 리랭커가 아니라 “골든셋에서 오탐이 0 인 지점” 이 보장합니다.
맞는 말인데, 다시 읽어보니 보장의 근거가 하나뿐입니다. 임베딩 코사인 하나요. 그 하나가 골든셋에서 못 본 유형의 질문 쌍에 대해 틀리면, 막아주는 게 아무것도 없어요.
같은 글에서 리랭커를 안 태우는 이유도 적었죠 — “캘리브레이션한 자와 실제로 재는 자가 달라지면 임계값이 의미를 잃는다.” 이것도 맞습니다. 그런데 여기에 제가 놓친 선택지가 있었어요.
두 자를 섞는 것과 두 자를 각각 재서 둘 다 요구하는 것은 다릅니다.
점수를 하나로 합치면(가중합) 자가 바뀌어서 임계값이 무의미해집니다. 하지만 자마다 따로 임계값을 잡고 AND 로 묶으면 각 임계값은 자기 자 위에서 그대로 유효해요.
왜 두 개가 하나보다 훨씬 나은가
핵심은 두 모델이 서로 다른 방식으로 틀린다는 데 있습니다.
| 임베딩 (bi-encoder) | 리랭커 (cross-encoder) | |
|---|---|---|
| 어떻게 보나 | 질문과 후보를 각자 벡터로 압축해 비교 | 둘을 붙여서 같이 읽음 |
| 잘 틀리는 지점 | 주제가 같으면 묻는 게 달라도 가깝다고 봄 | 표면 어휘가 겹치면 과대평가 |
1편에서 든 예 — “육아휴직 급여” vs “육아휴직 기간” — 은 임베딩이 전형적으로 놓치는 유형이고, 리랭커는 둘을 나란히 읽으니까 “급여”와 “기간”이 다른 걸 훨씬 잘 잡습니다.
오탐 확률로 쓰면 이렇게 돼요.
\[P(\text{둘 다 통과}) = P(A)\cdot P(B \mid A) \le \min\bigl(P(A),\, P(B)\bigr)\]쉽게 풀면 이렇습니다.
- $P(A)$ — 임베딩 게이트가 틀린 쌍을 통과시킬 확률.
- $P(B)$ — 리랭커 게이트가 틀린 쌍을 통과시킬 확률.
- $P(B \mid A)$ — 세로 막대 $\mid$ 는 “~인 상황에서”라고 읽어요. 임베딩이 이미 속은 그 쌍에 대해 리랭커도 속을 확률.
- 부등호는 “왼쪽이 오른쪽보다 작거나 같다”입니다.
읽는 법은 이래요. 둘 다 통과해야 즉답하니까, 오탐은 둘 다 속았을 때만 납니다. 그러니 최종 오탐률은 둘 중 더 잘하는 쪽보다도 낮습니다.
두 모델이 완전히 독립이면 $P(A)\cdot P(B)$ 로 곱해져서 확 떨어지고, 완전히 같은 실수를 하면 $\min$ 에 머뭅니다. 실제로는 그 사이예요. 중요한 건 어느 경우에도 나빠지지는 않는다는 겁니다.
대가 — 재현율을 팝니다
공짜가 아니에요. 정직하게 적어둡니다.
| 얻는 것 | 잃는 것 |
|---|---|
| 즉답 오답률이 내려감 | 즉답 hit rate 이 내려감 |
| — | 더 많은 질문이 RAG 로 감 → 평균 지연·비용 증가 |
| — | 즉답 경로 지연 40~115ms → 120~315ms |
즉답이 줄면 “빠른 답”이라는 이 시스템의 존재 이유가 옅어지죠. 그래서 이건 취향이 아니라 계산이어야 합니다. 오답 하나의 비용과 지연 200ms 의 비용을 비교해서 정하는 거예요. 내부 규정 챗봇에서는 오답이 훨씬 비싸니까 AND 가 맞습니다.
캘리브레이션 — 각각, 따로
3편 8절에서 만든 양성·음성 쌍을 그대로 씁니다. 자만 두 개로 늘어요.
def zero_fp_threshold(pos_scores, neg_scores, margin=0.02):
"""음성이 하나도 넘지 못하는 최저 임계값 + 안전 여유."""
if not pos_scores:
return None
hi = max(neg_scores) if neg_scores else 0.0
t = hi + margin
if t > max(pos_scores):
return None # 안전한 즉답 구간이 없다
return round(t, 3)
def calibrate_instant_gate(pos, neg, emb, reranker):
# 두 자를 각각 잰다. 점수를 하나로 섞지 않는다.
emb_top = zero_fp_threshold(
[emb.cos(a, b) for a, b in pos],
[emb.cos(a, b) for a, b in neg])
rr_top = zero_fp_threshold(
[reranker.score1(a, b) for a, b in pos],
[reranker.score1(a, b) for a, b in neg])
return {"emb_top": emb_top, "rr_top": rr_top}
zero_fp_threshold 가 None 을 낼 수 있게 한 게 이번 편에서 제일 중요한 한 줄입니다. 다음 절 주제예요.
시스템이 즉답을 스스로 끌 수 있어야 합니다
음성 쌍의 최고점이 양성 쌍의 최고점보다 높으면 어떻게 될까요. “틀린 쌍은 하나도 안 넘으면서 맞는 쌍은 넘는” 선이 존재하지 않는다는 뜻입니다.
이때 억지로 임계값을 잡으면 어떻게 되냐면 — 오탐을 허용하게 됩니다. 그리고 그 오탐은 1편 결함 ② 그 자체예요. 빠르고, 근거 없고, 확신에 찬 오답이요.
그래서 이렇게 씁니다.
def instant_allowed(gate):
return gate.get("emb_top") is not None and gate.get("rr_top") is not None
def answer(query, ...):
...
if instant_allowed(GATE) and best \
and sim_emb >= GATE["emb_top"] \
and sim_rr >= GATE["rr_top"]:
return serve_instant(best)
# 즉답 구간이 없거나 통과 못 하면 전부 RAG 로
return serve_rag(query, ...)
이 코퍼스에서는 즉답이 안전하지 않다고 시스템이 결론 내릴 수 있는 겁니다. 그러면 느려지지만 틀리지는 않아요.
말로만 두면 안 되니까 테스트로 박습니다.
def test_instant_gate_disabled_when_no_safe_band(self):
# 양성과 음성이 완전히 겹치는 인공 분포
pos = [("a", "a2")]
neg = [("a", "b")]
fake = ConstantScorer(0.99) # 무엇이든 0.99 로 답하는 자
g = calibrate_instant_gate(pos, neg, fake, fake)
self.assertIsNone(g["emb_top"])
self.assertFalse(instant_allowed(g))
ConstantScorer 는 모든 쌍에 같은 점수를 주는 가짜 모델이에요. 이런 자로는 양성과 음성을 못 가르니까 즉답 구간이 없어야 정상이고, 게이트가 꺼져야 맞습니다. “안전 장치가 실제로 꺼지는지”를 확인하는 테스트예요.
3. 정확도 ② — 근거가 죽으면 즉답도 죽는다
절차에 기댄 안전장치는 안전장치가 아닙니다
4편 8절의 무효화 절차를 다시 봅니다. 6단계였고, 마지막이 gate_version 올리기였어요. 그리고 같은 글 지뢰 ⑥ 에 이렇게 적었죠.
개정 후에도 옛 답이 나옵니다. 8절 6번 단계,
gate_version을 안 올렸습니다. ES 만 끄고 Redis 를 잊는 게 제일 흔한 실수예요.
지뢰 목록에 적어뒀다는 건 이게 자주 일어난다는 걸 제가 알고 있었다는 뜻입니다. 그런데 대책은 “잊지 마세요”가 전부였어요. 그건 대책이 아니라 부탁이죠 — 3편 1절에서 “신뢰도는 부탁이 아니라 필터”라고 해놓고 정작 여기서는 부탁을 하고 있었습니다.
구조로 바꾸기
사전생성 Q&A 는 2편 스키마에 이미 doc · article · revised_at 을 들고 있습니다. 그러면 답을 내보내기 직전에 그 근거가 아직 살아 있는지 물어볼 수 있어요.
import time
class LiveRevisions:
"""(doc, article) → 현재 유효한 revised_at. 짧은 주기로 갱신."""
def __init__(self, es, ttl=60):
self.es, self.ttl = es, ttl
self.at, self.map = 0.0, None
def _refresh(self, now):
self.map = load_active_revisions(self.es) # active=true 인 것만
self.at = now
def alive(self, qa, now=None):
now = now if now is not None else time.time()
if self.map is None or now - self.at > self.ttl:
self._refresh(now)
cur = self.map.get((qa["doc"], qa["article"]))
# 모르면 살아있다고 하지 않는다 (fail-closed)
return cur is not None and cur == qa["revised_at"]
주석의 fail-closed 가 요점이에요. 근거가 살아있는지 확인되지 않으면 즉답하지 않습니다. 확인 안 됨을 통과로 치지 않는 거죠 — 3편 검증 프롬프트에서 "Silent" means unsupported 라고 못 박은 것과 같은 규칙입니다.
게이트에 끼우면 이렇게 됩니다.
if instant_allowed(GATE) and best \
and sim_emb >= GATE["emb_top"] \
and sim_rr >= GATE["rr_top"] \
and live.alive(best):
return serve_instant(best)
Redis exact hit 경로에도 똑같이 겁니다. 캐시 payload 에 doc/article/revised_at 을 같이 넣어두면 돼요.
def cache_get_live(rd, key, live):
hit = cache_get(rd, key)
if hit and not live.alive(hit):
return None # 상한 캐시는 없는 셈 친다
return hit
뭐가 달라졌나
| 4편 (8.5점) | 지금 | |
|---|---|---|
| 개정 반영 | 사람이 gate_version 을 올려야 |
자동. 색인에서 꺼지는 순간 즉답도 멈춤 |
| 잊었을 때 | 옛 답이 계속 나감 | 옛 답이 안 나감 (느려질 뿐) |
| 성격 | 절차적 안전장치 | 구조적 안전장치 |
gate_version 은 그대로 둡니다 — 임계값을 바꿨을 때 캐시를 통째로 무효화하는 데는 여전히 필요해요. 다만 개정 안전성이 거기에만 매달려 있지는 않게 된 겁니다.
비용은 거의 0 이에요. LiveRevisions 는 60초마다 조항별 최신 개정일 맵을 한 번 받아오고, 질의당 조회는 딕셔너리 한 번입니다.
4. 정합성 — 박아둔 이음매는 아직 검증된 적이 없다
이번에 다시 보면서 새로 걸린 게 이겁니다. 1편 7절에서 이렇게 적었어요.
임베딩 모델은 언젠가 반드시 바뀝니다. 그러니 “어떤 모델이 최고냐”를 지금 맞히려 하지 말고, 바꾸는 게 싼 구조로 지어야 합니다.
그리고 이음매 네 개를 박았죠 — 인터페이스, 모델 태그, 임계값 분리, 재색인 스크립트. 좋은데, 다시 읽어보니 문제가 있어요.
한 번도 안 써봤습니다.
재색인 스크립트가 실제로 도는지, 임계값 재캘리브레이션이 실제로 이어지는지, 별칭 전환이 실제로 무중단인지 — 전부 “그렇게 설계했다”까지고 “그렇게 동작한다”가 아닙니다. 그리고 이런 코드는 필요한 날 처음 돌리면 거의 항상 깨져 있어요.
리허설을 정기 일정으로
분기에 한 번, 임베딩 모델을 실제로 갈아끼웠다가 되돌립니다.
| 단계 | 하는 일 | 확인하는 것 |
|---|---|---|
| 1 | 후보 모델로 전체 재색인 (별칭 뒤에서) | 재색인 스크립트가 아직 돈다 |
| 2 | 3편 골든셋으로 재캘리브레이션 | 임계값이 자동으로 다시 잡힌다 |
| 3 | 별칭 전환 | 무중단이 진짜 무중단이다 |
| 4 | 헬스체크 + 회귀 테스트 | Recall 이 기준선 위다 |
| 5 | 롤백 | 되돌리기가 초 단위다 |
| 6 | 걸린 시간 기록 | 다음 진짜 교체 때 쓸 숫자 |
6번이 은근히 값어치 있어요. “모델 교체에 얼마나 걸리나”에 측정된 답을 갖게 되니까요. 추정이 아니라요.
그리고 색인 시점에 막습니다
5편 5절의 watch_embedding_drift 는 사후 탐지였어요. 이미 섞인 걸 알려주죠. 애초에 못 들어가게 막는 게 낫습니다.
인덱스 매핑의 _meta 에 모델을 박아두고, 로더가 대조합니다.
def create_index(es, index, mapping, model, dim):
body = dict(mapping)
body["mappings"] = dict(body["mappings"],
_meta={"embedding_model": model, "dim": dim})
es.indices.create(index=index, body=body)
def assert_model_match(es, index, manifest):
meta = es.indices.get_mapping(index=index)[index]["mappings"].get("_meta", {})
want, got = manifest["embedding_model"], meta.get("embedding_model")
if want != got:
raise SystemExit(f"임베딩 모델 불일치 — 매니페스트={want} 인덱스={got}")
if manifest.get("embedding_dim") != meta.get("dim"):
raise SystemExit("임베딩 차원 불일치")
SystemExit 로 적재를 통째로 중단시킵니다. 섞인 벡터로 몇 주를 보내는 것보다 오늘 배치가 실패하는 게 훨씬 싸요.
사후 모니터링(watch_embedding_drift)은 그대로 둡니다. 손으로 색인한다든가 하는 경로가 늘 남거든요. 막고, 그래도 새면 알아채는 이중 구성입니다.
5. 운영성 ① — 사람이 정하는 값을 1개로
5편 회고에서 “임계값이 여섯 개고 서로 얽혀 있다”고 적었죠. 세어보면 이랬습니다.
tau_fast · tau_slow · min_score · top · bottom · RRF_K
그런데 하나씩 따져보니 정말로 사람이 정해야 하는 건 하나뿐이었어요.
| 값 | 정체 | 누가 정하나 |
|---|---|---|
RRF_K |
랭크 융합 완충값. 60 이 사실상 표준 | 고정 상수 |
tau_fast |
MinHash 1단 필터. 뒤에 2단이 있어 보수적이면 충분 | 고정 상수 |
tau_slow |
중복 억제 경계 | 골든셋에서 자동 산출 |
emb_top · rr_top |
즉답 게이트 (2절) | 골든셋에서 자동 산출 |
bottom |
힌트 주입 하한 | 골든셋에서 자동 산출 |
min_score |
groundedness 통과선 | 사람 ← 유일 |
min_score 만 사람 몫으로 남는 이유가 있어요. 이건 “얼마나 엄격할 것인가”라는 정책 결정이지 데이터에서 읽히는 값이 아닙니다. 나머지는 전부 “이 임베딩 모델의 분포에서 어디가 경계냐”라 데이터가 답을 갖고 있고요.
같은 분포에서 두 개의 다른 경계를 뽑습니다
여기서 재밌는 게 하나 있어요. tau_slow(중복 억제)와 emb_top(즉답 게이트)은 둘 다 “같은 질문인가”를 묻는 값입니다. 그럼 같은 값을 써도 되지 않나 싶은데, 안 됩니다.
틀렸을 때의 비용이 비대칭이거든요.
| 틀려서 통과시키면 | 틀려서 막으면 | |
|---|---|---|
tau_slow (중복 억제) |
비슷한 Q&A 가 하나 더 쌓임. 사소 | 좋은 질문 하나 버림. 사소 |
emb_top (즉답 게이트) |
오답이 확신에 차서 나감. 치명 | 좀 느려짐. 사소 |
그래서 같은 sweep 에서 서로 다른 기준으로 두 값을 뽑습니다.
def derive_thresholds(rows, hint_recall=0.90, dedup_precision=0.90):
# 즉답: 오탐이 0 인 지점 (2절)
top = zero_fp_from_rows(rows)
# 중복 억제: 정밀도 90% 면 충분 (실수해도 싸다)
ok = [r["t"] for r in rows if r["precision"] >= dedup_precision]
tau_slow = min(ok) if ok else 0.92
# 힌트 하한: 양성의 90% 를 건지는 지점
hint = [r["t"] for r in rows if r["recall"] >= hint_recall]
bottom = max(hint) if hint else 0.70
if top is not None:
bottom = min(bottom, top - 0.05)
return {"top": top, "tau_slow": round(tau_slow, 3),
"bottom": round(bottom, 3)}
값 세 개가 같은 데이터에서, 각자의 비용 구조에 맞는 기준으로 나옵니다. 사람이 손댈 게 없어요.
그리고 CI 가 강제합니다
자동 산출로 만들어놔도 안 돌리면 소용없죠. 골든셋이나 모델이 바뀌면 재캘리브레이션이 강제되게 합니다.
class TestCalibrationFresh(unittest.TestCase):
def test_thresholds_match_current_inputs(self):
# 지금 입력으로 다시 뽑은 값이 저장된 값과 같아야 한다
fresh = derive_thresholds(sweep(*build_pairs(load_golden()), emb=current_emb()))
self.assertEqual(fresh["top"], GATE["emb_top"])
self.assertEqual(fresh["tau_slow"], CONFIG["tau_slow"])
def test_gate_pinned_to_current_embedding(self):
self.assertEqual(GATE["embedding_model"], CONFIG["embedding_model"])
self.assertEqual(GATE["golden_version"], CONFIG["golden_version"])
첫 번째 테스트가 좋아요. 골든셋에 항목 하나만 추가돼도 저장된 임계값과 어긋나서 빨간불이 켜집니다. “재캘리브레이션 잊지 마세요”가 필요 없어져요.
6. 운영성 ② — 골든셋을 델타로 갱신
5편에서 “골든셋이 병목”이라고 한 게 이 시리즈에서 제일 정직한 문장이었어요. 그런데 다시 보니 병목의 크기를 제가 과대평가하고 있었습니다.
규정이 개정되면 골든셋을 다시 라벨링해야 한다고 썼는데 — 전부 다시 할 이유가 없어요. 개정된 건 조항 몇 개고, 나머지 골든셋 항목은 멀쩡합니다.
그리고 어느 항목이 영향받는지는 이미 알 수 있어요. 3편 골든셋 항목이 chunk_id·doc·article·revised_at 을 그대로 들고 있으니까요.
def stale_golden(golden, revisions):
"""개정으로 근거가 바뀐 골든셋 항목만 골라낸다."""
out = []
for g in golden:
cur = revisions.get((g["doc"], g["article"]))
if cur is None or cur != g["revised_at"]:
out.append(dict(g, stale_reason=(
"조항 삭제" if cur is None else f"개정 {g['revised_at']} → {cur}")))
return out
3절의 LiveRevisions 가 만든 맵을 그대로 재사용합니다. 부품이 안 늘어요.
실제로 돌려보면 이런 규모입니다.
| 전수 | 델타 | |
|---|---|---|
| 재라벨링 대상 | 250건 | 7건 |
| 사람 시간 | 하루 | 20분 |
개정이 조항 두세 개에 걸치는 게 보통이니까, 대개 이 정도로 끝나요.
후보를 자동으로 모읍니다
라벨링 자체는 사람이 해야 하지만, 어디를 봐야 하는지는 프로덕션이 알려줍니다.
def mine_candidates(logs, window_sec=120):
"""답을 받고 곧바로 다시 물은 경우 = 답이 부실했다는 신호."""
out = []
for prev, nxt in zip(logs, logs[1:]):
if prev["session"] != nxt["session"]:
continue
if nxt["ts"] - prev["ts"] > window_sec:
continue
if prev["path"] in ("gate_instant", "cache_exact"):
out.append({"q": prev["q"], "served": prev["answer"],
"followup": nxt["q"], "signal": "immediate_requery"})
return out
같은 세션에서 답을 받자마자 다시 묻는 건 답이 시원치 않았다는 뜻이에요. 특히 gate_instant 로 나간 답에서 이게 잡히면 2절의 게이트가 새고 있다는 증거입니다.
이렇게 모은 후보는 골든셋에 넣을 값어치가 제일 높은 것들이에요. 무작위 표본보다 훨씬 밀도가 높습니다.
정리하면 골든셋 유지 비용이 이렇게 바뀝니다.
| 8.5점 설계 | 지금 | |
|---|---|---|
| 개정 시 | 전수 재라벨링 (하루) | 델타만 (20분) |
| 신규 항목 발굴 | 사람이 생각해냄 | 로그가 후보를 올려줌 |
| 재캘리브레이션 | 잊으면 그만 | CI 가 강제 (5절) |
여전히 사람이 필요합니다. 다만 사람이 하는 일이 “전부 다시 보기”에서 “골라준 것만 판정하기”로 바뀌었어요.
7. 2차 채점
채점표는 1편 것 그대로입니다. 축도 배점도 손대지 않았어요.
| 축 | 원안 | 보정안 | 지금 | 무엇이 바뀌었나 |
|---|---|---|---|---|
| 정합성 | 1.5 | 2.5 | 2.8 | 이음매 리허설, 색인 시점 모델 태그 강제 |
| 정확도 | 1.0 | 2.5 | 2.9 | 즉답 게이트 이중화, 근거 생존 fail-closed |
| 수렴성 | 1.5 | 2.0 | 2.0 | 변화 없음 (이미 만점) |
| 운영성 | 2.0 | 1.5 | 1.7 | 임계값 6 → 사람 몫 1개, 골든셋 델타 갱신 |
| 합계 | 6.0 | 8.5 | 9.4 |
만점을 안 준 자리마다 이유가 있습니다.
정확도 2.9 — 3.0 이 아닌 이유. 즉답 경로는 여전히 LLM 검증을 안 거칩니다. 두 자로 좁히고 근거 생존까지 확인했지만, 그 경로로 나가는 답은 어제 만들어진 것이고 오늘 다시 검사되지 않아요. 좁혔을 뿐 없앤 게 아닙니다.
정합성 2.8 — 3.0 이 아닌 이유. 인덱스가 여전히 두 벌이고, 임베딩 모델을 바꾸면 전면 재색인이 강제됩니다. 리허설로 그 비용을 측정 가능하게 만들었을 뿐, 없애지는 못했어요.
운영성 1.7 — 2.0 이 아닌 이유. 이게 제일 정직해야 하는 자리인데, 2절·3절에서 부품을 더 넣었습니다. 리랭커가 즉답 경로에 들어왔고 LiveRevisions 가 생겼어요. 5·6절에서 덜어낸 것보다 조금 덜 더한 정도라서, 순증이 +0.2 입니다.
8. 살 수 없는 것 — 왜 10점이 아닌가
여기가 이 글에서 제일 중요한 절입니다. 9.4 를 만들면서 더 밀면 오히려 나빠지는 지점들을 봤거든요.
① 즉답과 검증은 근본적으로 맞바꿈입니다
정확도 만점을 받으려면 즉답 경로에도 검증을 넣어야 하는데, 검증을 넣으면 LLM 을 타야 하고, LLM 을 타면 더 이상 즉답이 아닙니다.
즉답 경로의 위험을 완전히 없애는 유일한 방법은 즉답을 없애는 것이고, 그러면 1편 원안이 하려던 일 자체가 사라집니다.
그러니 이건 고쳐야 할 결함이 아니라 받아들이고 관리해야 할 성질이에요. 그래서 5편 모니터링에서 즉답 경로 유사도 분포를 제일 위에 올려둔 겁니다.
② 골든셋의 사람 의존은 0 이 안 됩니다
6절에서 하루를 20분으로 줄였지만, 20분은 20분이에요. 그리고 이걸 0 으로 만들려면 골든셋을 LLM 이 라벨링해야 하는데, 그러면 3편 4절에서 피하려던 문제로 돌아갑니다 — 채점자와 채점 대상이 같은 종류가 되는 거죠.
골든셋은 이 시스템에서 유일하게 LLM 바깥에 있는 기준점입니다. 그게 값어치의 전부라서, 자동화하는 순간 값어치가 사라져요.
③ 복잡도는 어느 지점부터 정확도를 깎습니다
2절·3절에서 게이트를 세 겹(임베딩 · 리랭커 · 근거 생존)으로 만들었어요. 여기서 네 겹, 다섯 겹으로 가면 어떻게 될까요.
| 겹이 늘면 | 결과 |
|---|---|
| 오탐률 | 조금씩 더 내려감 (수확 체감) |
| 즉답 hit rate | 계속 내려감 |
| 디버깅 난이도 | 왜 즉답이 안 나오는지 아무도 모름 |
| 운영성 | 계속 깎임 |
세 번째가 무섭습니다. 게이트가 다섯 겹이면 “이 질문은 왜 캐시가 있는데 RAG 를 탔나”에 답하는 데 반나절이 걸려요. 그러면 사람들이 게이트를 통째로 끄기 시작합니다. 안전장치는 이해할 수 있어야 살아남아요.
그래서 남는 그림
| 결함 | 상태 |
|---|---|
| ① 무한 생성 | 닫힘 (2편) |
| ② 캐시가 1차 답변자 | 좁힘 — 구조적 잔여 있음 (이 글 8절 ①) |
| ③ 검증 루프 부재 | 닫힘 (3편) |
| ④ 벡터 이중화 | 닫힘 (4편) |
| ⑤ 모델 품질 단차 | 완화 |
| ⑥ 휘발하는 본대 | 닫힘 (5편) |
| ⑦ 네트워크 경계 | 닫힘 (5편) |
| ⑧ 임베딩 미결정 | 닫힘 — 측정 후 로컬+리랭커 |
| — 이음매 미검증 | 닫힘 (이 글 4절) |
| — 절차 의존 무효화 | 닫힘 (이 글 3절) |
| — 임계값 6개 | 닫힘 (이 글 5절) |
| — 골든셋 병목 | 완화 — 사람 의존 잔여 (이 글 8절 ②) |
9. 자주 밟는 지뢰
① 즉답 hit rate 이 절반으로 떨어졌습니다.
정상입니다. AND 게이트의 대가예요(2절). 다만 오답도 같이 줄었는지 확인하세요. gate_instant 경로에서 6절의 immediate_requery 신호가 같이 줄었다면 제대로 작동하는 겁니다. hit 만 줄고 재질문은 그대로면 게이트가 엉뚱한 걸 막고 있는 거예요.
② 즉답이 아예 안 나갑니다.
emb_top 이나 rr_top 이 None 일 확률이 높습니다(2절). 이 코퍼스·모델로는 안전한 즉답 구간이 없다는 뜻이에요. 버그가 아니라 판정입니다. 리랭커를 바꾸거나 골든셋 음성 쌍을 다시 보세요.
③ LiveRevisions 때문에 즉답이 전부 막힙니다.
load_active_revisions 가 (doc, article) 키를 색인과 다른 형으로 만들고 있을 가능성이 큽니다. article 이 한쪽은 int, 한쪽은 str 이면 영원히 None 이에요. fail-closed 라서 조용히 느려지기만 하니 더 안 보입니다.
④ 리허설이 매번 성공합니다. 같은 모델로 재색인하고 있을 수 있어요(4절). 실제로 다른 모델로 해야 임계값 재캘리브레이션까지 검증됩니다.
⑤ CI 캘리브레이션 테스트가 늘 빨간불입니다.
derive_thresholds 가 부동소수점 그대로 비교되고 있을 수 있어요. round() 자릿수를 저장·비교 양쪽에서 맞추세요(5절).
10. 정리
시작할 때 “채점표는 손대지 않는다”고 걸어둔 규칙을 지켰는지부터 확인하면 — 축도 배점도 그대로입니다. 점수가 오른 건 결함을 닫아서지 기준을 낮춰서가 아니에요.
| 8.5점 설계 | 9.4점 설계 | |
|---|---|---|
| 즉답 판정 | 임베딩 코사인 하나 | 임베딩 AND 리랭커, 각각 캘리브레이션 |
| 안전 구간이 없으면 | 그래도 즉답 | 즉답을 스스로 끔 |
| 개정 반영 | 사람이 버전 올려야 | 근거가 죽으면 자동으로 막힘 |
| 이음매 | 박아둠 | 분기마다 리허설 |
| 모델 태그 | 사후 탐지 | 색인 시점 차단 |
| 임계값 | 사람이 6개 | 사람이 1개, 나머지는 골든셋이 |
| 골든셋 갱신 | 전수 (하루) | 델타 (20분) + 로그가 후보 발굴 |
이번 편에서 크게 남은 건 두 가지입니다.
하나. 8.5점을 만든 건 결함을 찾는 눈이었고, 9.4점을 만든 건 “부탁”으로 남겨둔 자리를 찾는 눈이었습니다.
gate_version을 잊지 마세요, 재캘리브레이션을 잊지 마세요 — 지뢰 목록에 적어뒀다는 건 이미 자주 일어난다는 걸 알고 있었다는 뜻이에요. 알고 있으면서 부탁으로 뒀으면, 그건 설계가 아니라 미룬 겁니다.
둘. 10점은 못 삽니다. 그리고 10점을 향해 더 밀면 오히려 나빠집니다. 게이트를 다섯 겹으로 만들면 오탐은 조금 줄고 아무도 이해 못 하는 시스템이 돼요. 이해 못 하는 안전장치는 언젠가 통째로 꺼집니다.
9.4 에서 멈추는 게 이 구조의 정답입니다. 남은 0.6 은 결함이 아니라 이 아키텍처가 무엇과 무엇을 맞바꿨는지 적어둔 영수증이에요.
시리즈는 여기서 진짜로 마칩니다. 읽어주셔서 고맙습니다.