15 분 소요

Summary

2편에서 Q&A 를 뽑아냈는데, 마지막 줄에 이렇게 적어뒀어요.

여기서 나온 Q&A 가 맞는지는 아무도 확인하지 않았습니다.

verified 필드가 null 인 채로 JSONL 에 쌓여 있죠. 이 상태로 4편의 캐시에 넣으면 어떻게 되냐면, 1편에서 진단한 결함 ②가 그대로 터집니다. 틀린 답이 50ms 만에 영원히 반복되는 거예요.

이번 글은 그 사이에 문을 하나 세웁니다. 그리고 문을 세우면서, 1편 7절에서 “재야 안다”고 미뤄둔 임베딩 측정도 같이 해요. 둘을 한 글에 묶은 건 우연이 아니라 둘 다 골든셋 하나에서 나오기 때문입니다.

💡 이 글에서 다루는 것

  • 신뢰도는 왜 부탁이 아니라 필터인가
  • groundedness — 답을 주장 단위로 쪼개서 근거와 대조하기
  • 검증 프롬프트 — 생성 맥락을 지우고 채점하기
  • 자기가 만든 걸 자기가 채점하는 편향(self-preference)을 피하는 법
  • 질문 쪽 게이트 — answerability
  • 골든셋 만들기 — 사람 손이 들어가는 유일한 자리
  • 골든셋으로 임베딩 후보 재기 (Recall@k · MRR)
  • 임계값을 실제로 정하는 절차 — 1편의 0.95 / 0.80 은 어디가 틀렸나
  • 회귀 테스트로 굳히기



1. 신뢰도는 필터에서 나옵니다

1편 5절에서 이렇게 적었어요.

신뢰도를 얻는 방법 실제 효과
프롬프트에 “정확히 답해” 약간 도움. 보장 없음
좋은 모델 쓰기 도움. 여전히 보장 없음
생성물을 검사해서 떨어뜨리기 이것만이 보장

세 번째만이 측정 가능한 보장을 줍니다. 앞의 둘은 “확률을 좀 낮췄다”까지밖에 말할 수 없어요. 필터는 다릅니다 — “통과한 것들의 groundedness 는 전부 0.9 이상” 이라고 말할 수 있어요.

그리고 여기서 중요한 결정이 하나 있습니다.

떨어진 건 고치지 말고 버립니다.

고치려 들면 모델은 또 지어냅니다. “근거가 부족하다”는 피드백을 받은 LLM 은 대개 근거를 더 그럴듯하게 쓰는 쪽으로 반응해요. 문서에 답이 없는 질문은 애초에 있으면 안 되는 질문입니다. 버리는 게 맞아요.

2편에서 이미 넉넉히 뽑아뒀으니 버릴 여유도 있습니다. 실제로 돌려보면 10~20% 정도가 떨어집니다.



2. Groundedness — 답을 쪼개서 대조하기

“이 답이 근거에 붙어 있나?” 를 통째로 물으면 LLM 은 대충 “네”라고 합니다. 답이 다섯 문장인데 네 번째만 틀렸으면, 전체를 보고 판단할 때 그 하나가 묻혀요.

그래서 주장 단위로 쪼갭니다.

단계 하는 일
1 답변을 독립적으로 참/거짓 판정 가능한 주장(claim) 들로 분해
2 주장마다 근거 청크로 지지되는지 개별 판정
3 지지된 주장 비율로 점수 산출

식으로 쓰면 이렇습니다.

\[G(a, e) = \frac{\lvert\{\, m \in M(a) \;:\; \text{supported}(m, e) \,\}\rvert}{\lvert M(a) \rvert}\]

쉽게 풀면 이렇습니다. 분모부터 읽을게요.

  • $a$ — 우리가 만든 답변, $e$ — 근거 청크(2편의 evidence).
  • $M(a)$ — 답변 $a$ 를 쪼개서 나온 주장들의 집합. $\lvert M(a) \rvert$ 는 그 개수예요.
  • $\text{supported}(m, e)$ — 주장 $m$ 이 근거 $e$ 로 뒷받침되면 참.
  • 분자는 “뒷받침되는 주장의 개수”.

그러니까 $G$ 는 “답변 속 주장 중 몇 %가 근거로 확인되나” 입니다. 주장이 5개인데 4개만 확인되면 0.8 이에요.

$G = 1.0$ 만 통과시킬 거냐고요? 아뇨. 실무에서는 그러면 너무 많이 떨어집니다. “입사 1년이 지나면” 같은 자연스러운 부연이 근거에 그대로 없다고 떨어지거든요. 대신 치명적 주장을 따로 봅니다 — 숫자, 기간, 금액, 조건은 하나라도 틀리면 즉시 폐기예요.

CRITICAL = {"amount", "duration", "count", "date", "condition"}


def verdict(claims, min_score=0.85):
    if not claims:
        return "reject", 0.0, "no_claims"

    ok = [c for c in claims if c["supported"]]
    score = len(ok) / len(claims)

    bad_critical = [c for c in claims
                    if not c["supported"] and c["kind"] in CRITICAL]
    if bad_critical:
        return "reject", score, f"critical:{bad_critical[0]['kind']}"

    if score < min_score:
        return "reject", score, "low_groundedness"

    return "accept", score, ""



3. 검증 프롬프트 — 생성 맥락을 지우는 게 핵심

여기서 제일 흔한 실수는 생성할 때 쓰던 대화를 그대로 이어서 채점을 시키는 것이에요. 그러면 모델이 자기가 방금 쓴 답을 변호합니다.

검증은 완전히 새 세션에서, 최소한의 정보만 주고 합니다.

주는 것 안 주는 것
근거 청크 원문 원본 문서 전체
채점할 답변 질문을 만든 프롬프트
“이건 네가 만든 거야” 같은 맥락
다른 청크들
You are a strict fact-checker. You will see EVIDENCE and an ANSWER.

Step 1. Decompose ANSWER into atomic claims.
  An atomic claim states exactly one verifiable fact.
  Split compound sentences. Keep numbers with what they measure.

Step 2. For EACH claim, decide independently:
  - supported: EVIDENCE explicitly states it
  - contradicted: EVIDENCE states otherwise
  - unsupported: EVIDENCE is silent on it

  "Silent" means unsupported, NOT supported.
  Do NOT use outside knowledge. Do NOT infer beyond EVIDENCE.
  If you are unsure, answer unsupported.

Step 3. Tag each claim's kind:
  amount | duration | count | date | condition | procedure | other

Return JSON:
[{"claim": "...", "status": "...", "kind": "...", "span": "..."}]
- span: exact substring of EVIDENCE that supports it, or null

몇 줄이 특히 일합니다.

규칙 없으면 벌어지는 일
"Silent" means unsupported 모델이 상식으로 채워 넣고 supported 판정
Do NOT use outside knowledge 일반 노동법 지식으로 사규를 채점함
If unsure, answer unsupported 애매한 걸 통과시키는 쪽으로 기움
span 요구 근거를 못 짚으면 supported 라고 못 함

마지막이 좋아요. 원문의 어느 부분인지 정확히 인용하게 만들면, 근거 없는 supported 판정이 크게 줄어듭니다. 그리고 그 span 을 프로그램으로 검증할 수 있어요.

def audit_spans(claims, evidence):
    # 모델이 인용했다는 span 이
    # 실제 근거에 있는지 기계적으로 확인
    for c in claims:
        if c["status"] == "supported":
            span = (c.get("span") or "").strip()
            if not span or span not in evidence:
                c["status"] = "unsupported"
                c["span"] = None
        c["supported"] = c["status"] == "supported"
    return claims

모델의 판정을 그대로 믿지 않고 한 번 되짚는 겁니다. 이 열 줄이 생각보다 많이 잡아요.



4. 자기가 만든 걸 자기가 채점하는 문제

LLM 은 자기 출력에 후한 점수를 줍니다. self-preference bias 라고 해요. 생성도 Codex, 검증도 Codex 로 하면 이 편향이 그대로 들어옵니다.

완전히 없앨 방법은 없고, 줄이는 방법은 여럿입니다.

방법 효과 비용
다른 모델로 검증 모델 하나 더
맥락 제거 (3절) 중간 없음
span 기계 검증 (3절) 중간 없음
반증 지시 — “틀렸다고 가정하고 시작” 중간 없음
2표 이상 요구 호출 2~3배

이 구성에서는 첫 번째가 자연스럽게 됩니다. 1편 7절에서 정한 원칙이 “배치에는 외부 좋은 모델을 쓴다” 였잖아요. 검증도 배치니까, 생성과 검증에 서로 다른 모델을 배정하면 됩니다.

import hashlib


def stable_pick(key, pool):
    # 내장 hash() 는 문자열에 무작위 시드가 붙어
    # 프로세스마다 값이 달라진다. 재현성이 필요하면 쓰면 안 된다.
    h = hashlib.sha1(key.encode("utf-8")).hexdigest()
    return pool[int(h[:8], 16) % len(pool)]


def verify_batch(rows, judges):
    out = []
    for r in rows:
        # 생성한 모델은 채점에서 제외
        pool = [j for j in judges if j.name != r["gen_model"]]
        judge = stable_pick(r["id"], pool)

        claims = judge.check(r["a"], r["evidence"])
        claims = audit_spans(claims, r["evidence"])
        v, score, why = verdict(claims)

        r["verified"] = v == "accept"
        r["ground_score"] = round(score, 3)
        r["reject_reason"] = why or None
        r["judge_model"] = judge.name
        out.append(r)
    return out

ID 로 심사자를 돌리는 건 한 심사자의 버릇이 전체에 배는 걸 막기 위해서예요. 무작위가 아니라 ID 기반이라 재실행해도 같은 심사자가 배정됩니다.

여기서 내장 hash() 를 쓰면 안 됩니다. 파이썬은 문자열 해시에 프로세스마다 다른 시드를 붙여요.

python3 -c 'print(hash("qa_8f3a1c2b"))'   # -2396063841278293852
python3 -c 'print(hash("qa_8f3a1c2b"))'   # -7680596639865108030

같은 입력인데 값이 다르죠. 이걸로 심사자를 고르면 어제 통과한 Q&A 가 오늘 다른 심사자에게 가서 다른 판정을 받습니다. 재현이 안 되면 회귀 테스트(9절)가 성립하지 않아요. 그래서 sha1 처럼 고정된 해시를 씁니다.

그리고 검증도 배치니까 2편의 예산·재개 패턴을 그대로 씁니다.



5. 질문 쪽 게이트 — answerability

지금까지는 을 검사했어요. 그런데 질문 자체가 나쁜 경우도 있습니다.

나쁜 질문 문제
이 규정은 언제부터인가요 부칙 청크에서 나옴. 물어볼 사람이 없음
연차와 병가 중 뭐가 유리한가요 여러 조항이 필요. 청크 하나로 답 못 함
제15조 2항의 내용은 무엇인가요 사용자가 이렇게 안 물음

이런 건 답이 groundedness 를 통과할 수도 있어요. 근거를 그대로 베끼면 되니까요. 그래서 질문 쪽 게이트를 따로 둡니다.

def check_question(q, chunk, judge):
    res = judge.ask(ANSWERABILITY_PROMPT, question=q["q"], clause=chunk["text"])
    # self_contained: 이 청크만으로 답이 되나
    # natural: 사람이 실제로 이렇게 묻나
    # has_asker: 물어볼 사람이 있나
    q["self_contained"] = res["self_contained"]
    q["natural"] = res["natural"]
    q["has_asker"] = res["has_asker"]
    q["q_ok"] = all([q["self_contained"], q["natural"], q["has_asker"]])
    return q

natural 이 은근히 중요해요. 부자연스러운 질문이 캐시에 있으면 아무도 그 질문을 안 하니 영원히 hit 이 안 됩니다. 디스크만 먹고 커버리지 숫자만 부풀리는 거예요.

두 게이트를 합치면 최종 통과 조건은 이렇게 됩니다.

게이트 검사 대상 떨어지면
answerability 질문 폐기
groundedness 답변 폐기
critical claim 답변의 숫자·기간·금액 즉시 폐기

실제 통과율은 대략 이런 분포로 나옵니다.

input          18,442
q_ok           16,109  (87.3%)
ground pass    15,027  (81.5%)
  reject: low_groundedness      681
  reject: critical:amount       214
  reject: critical:duration     122
  reject: no_claims              65
accepted       15,027  (81.5%)

18% 를 버렸어요. 아깝지 않습니다. 저 3,400개가 캐시에 들어갔으면 그만큼의 오답이 즉답으로 나갔을 거예요.



6. 골든셋 — 사람 손이 들어가는 유일한 자리

여기까지는 전부 자동이었어요. 그런데 자동 게이트가 제대로 작동하는지는 누가 확인하나요?

여기서만 사람이 필요합니다. 그리고 이 골든셋은 두 가지 일을 동시에 해요.

쓰임 어디서
검증 게이트의 정확도 측정 이 절
임베딩 후보 비교 7절
임계값 확정 8절
회귀 테스트 9절

하나 만들어서 네 번 씁니다. 그래서 공들일 값어치가 있어요.

규모와 표본

전수 검사는 불가능하고 무작위 표본은 비효율적이에요. 층화 표본(stratified) 으로 갑니다.

뽑는 수
게이트 통과 + 높은 점수 60 통과시킨 게 진짜 맞나
게이트 통과 + 경계 점수 80 여기가 제일 중요. 임계값 근처
게이트 탈락 60 억울하게 떨어진 게 있나
무작위 50 층화가 놓친 것

250개 정도면 충분합니다. 사람이 하루에 검토 가능한 규모예요.

import random


def sample_golden(rows, seed=20260820):
    rnd = random.Random(seed)
    passed = [r for r in rows if r["verified"]]
    failed = [r for r in rows if not r["verified"]]

    high = [r for r in passed if r["ground_score"] >= 0.95]
    edge = [r for r in passed if r["ground_score"] < 0.95]

    picks = (rnd.sample(high, min(60, len(high)))
             + rnd.sample(edge, min(80, len(edge)))
             + rnd.sample(failed, min(60, len(failed)))
             + rnd.sample(rows, min(50, len(rows))))

    seen, out = set(), []
    for r in picks:
        if r["id"] not in seen:
            seen.add(r["id"])
            out.append(r)
    return out

seed 를 고정하는 게 중요해요. 나중에 재현이 안 되면 회귀 테스트가 성립하지 않습니다.

사람이 매기는 라벨

라벨은 최소한으로 둡니다. 항목이 많으면 검토가 안 끝나요.

라벨
human_correct 답이 근거에 비추어 맞나 (예/아니오)
human_askable 사람이 실제로 이렇게 물을 법한가 (예/아니오)
intent_group 같은 의도의 질문끼리 묶은 그룹 ID

세 번째가 7·8절의 열쇠입니다. “연차 며칠?” 과 “연차 몇 일 쓸 수 있나요?” 는 같은 그룹, “연차 며칠?” 과 “연차 언제까지 써야 해?” 는 다른 그룹이에요. 이 라벨이 있어야 임베딩을 잴 수 있습니다.

의도 그룹당 3~5개의 표현 변형을 사람이 직접 써넣게 하세요. 이게 골든셋 제작에서 제일 손이 많이 가는 부분인데, 가장 값어치 있는 부분이기도 합니다.

게이트 채점

이제 자동 게이트와 사람 라벨을 대조합니다.

def score_gate(golden):
    tp = sum(1 for r in golden if r["verified"] and r["human_correct"])
    fp = sum(1 for r in golden if r["verified"] and not r["human_correct"])
    fn = sum(1 for r in golden if not r["verified"] and r["human_correct"])
    tn = sum(1 for r in golden if not r["verified"] and not r["human_correct"])

    precision = tp / (tp + fp) if tp + fp else 0.0
    recall = tp / (tp + fn) if tp + fn else 0.0
    return {"precision": precision, "recall": recall, "tp": tp,
            "fp": fp, "fn": fn, "tn": tn}

여기서 precision 이 recall 보다 훨씬 중요합니다.

지표 이 시스템에서
precision 통과시킨 것 중 실제로 맞는 비율 낮으면 오답이 캐시로 들어감
recall 맞는 것 중 통과시킨 비율 낮으면 좀 아깝게 버릴 뿐

precision 목표를 0.98 이상으로 잡고, 미달이면 min_score 를 올려 게이트를 조입니다. recall 이 떨어지는 건 감수해요 — 2편에서 넉넉히 뽑아뒀으니까요.



7. 골든셋으로 임베딩 재기

이제 1편 7절에서 “재야 안다”고 미뤄둔 걸 잽니다. 6절에서 만든 intent_group 라벨이 여기 쓰여요.

무엇을 재나

두 가지를 봅니다.

재는 것 어디에 쓰이나
질문 → 청크 회수 성능 ES dense 검색 (4편)
질문 → 질문 매칭 성능 Redis 3단 게이트 (4편), 중복 억제 (2편)

지표는 Recall@k 와 MRR 두 개면 충분해요.

\[\text{Recall@}k = \frac{1}{\lvert Q \rvert}\sum_{q \in Q} \mathbb{1}\!\left[\, \text{정답} \in \text{top-}k(q) \,\right]\]

쉽게 풀면 이렇습니다.

  • $\sum_{q \in Q}$ — “시그마”. 골든셋의 질문 $q$ 를 하나씩 다 훑으면서 더하라는 뜻이에요.
  • $\text{top-}k(q)$ — 질문 $q$ 로 검색했을 때 나온 상위 $k$ 개.
  • $\mathbb{1}[\cdots]$ — “지시함수”라고 읽어요. 안의 조건이 참이면 1, 거짓이면 0 을 내놓는 스위치입니다.
  • $\frac{1}{\lvert Q \rvert}$ — 질문 개수로 나눠서 평균을 냄.

그래서 Recall@10 은 “질문 100개를 던졌을 때, 정답이 상위 10개 안에 들어온 게 몇 %냐” 예요. 0.92 면 92번은 들어왔고 8번은 10등 밖으로 밀렸다는 뜻입니다.

\[\text{MRR} = \frac{1}{\lvert Q \rvert}\sum_{q \in Q} \frac{1}{\text{rank}(q)}\]

쉽게 풀면, $\text{rank}(q)$ 는 정답이 몇 등으로 나왔는지예요. 1등이면 $\frac{1}{1}=1$, 4등이면 $\frac{1}{4}=0.25$. 그걸 평균낸 게 MRR 입니다. 분수는 분모부터 읽으니 “랭크 분의 1”이에요.

Recall 은 들어왔냐 아니냐만 보고, MRR 은 몇 등으로 들어왔냐까지 봅니다. 리랭커를 붙일 거면 Recall 이 더 중요해요 — 일단 후보 안에만 들어오면 리랭커가 끌어올릴 수 있으니까요.

def recall_at_k(queries, search, k=10):
    hit = 0
    for q in queries:
        got = [d["id"] for d in search(q["text"], top_k=k)]
        if q["gold_id"] in got:
            hit += 1
    return hit / len(queries)


def mrr(queries, search, k=10):
    total = 0.0
    for q in queries:
        got = [d["id"] for d in search(q["text"], top_k=k)]
        if q["gold_id"] in got:
            total += 1.0 / (got.index(q["gold_id"]) + 1)
    return total / len(queries)

후보 비교

이제 후보들을 같은 자로 잽니다. 1편에서 정한 선택지 A(로컬) · B(외부) · C(로컬+리랭커) 예요.

def compare(candidates, golden_queries):
    rows = []
    for name, backend in candidates.items():
        rows.append({
            "name": name,
            "recall@10": round(recall_at_k(golden_queries, backend.search, 10), 4),
            "recall@50": round(recall_at_k(golden_queries, backend.search, 50), 4),
            "mrr@10": round(mrr(golden_queries, backend.search, 10), 4),
        })
    return sorted(rows, key=lambda r: -r["recall@10"])

돌리면 이런 표가 나옵니다. 숫자는 문서·모델에 따라 달라지니 모양만 보세요.

후보 Recall@10 Recall@50 MRR@10
A. 로컬 임베딩 단독 0.841 0.951 0.612
A+BM25 하이브리드 0.887 0.968 0.664
C. 하이브리드 + 리랭커 0.934 0.968 0.811
B. 외부 임베딩 단독 0.912 0.972 0.735

읽는 법이 중요해요.

  • Recall@50 은 후보들이 다 비슷합니다. 넉넉히 뽑으면 정답은 대체로 들어와 있어요. 1편에서 리랭커를 추천한 근거가 이겁니다 — 회수는 이미 되고 있고, 문제는 순서예요.
  • MRR 격차가 Recall 격차보다 큽니다. 리랭커가 0.664 → 0.811 로 끌어올린 게 그 증거고요.
  • C 가 B 를 +2.2%p 앞섰습니다. 1편의 판단 기준표는 “-3%p 이내면 로컬 유지” 였는데 뒤진 게 아니라 아예 앞섰으니, 고민할 것도 없어요. 문서를 밖으로 안 내보내고 재색인이 공짜인 쪽이 이깁니다.

C 가 B 에 -10%p 넘게 뒤졌다면? 그때는 외부 임베딩을 진지하게 검토하되, 1편에서 짚은 함정 두 개(질의마다 외부 왕복 · 모델 은퇴 시 전체 재색인)를 비용에 반드시 넣으세요.



8. 임계값을 실제로 정하기 — 1편의 0.80 은 어디가 틀렸나

1편에서 이 숫자들에 계속 경고를 붙였죠. 이제 정합니다. intent_group 라벨로 정답 쌍과 오답 쌍을 만드는 게 출발점이에요.

쌍 종류 만드는 법 이상적인 유사도
양성(positive) 같은 intent_group 안의 서로 다른 표현 높아야 함
음성(negative) 다른 group 인데 같은 청크에서 나온 질문 낮아야 함

음성 쌍을 “같은 청크에서” 뽑는 게 핵심이에요. 완전히 딴 주제끼리 비교하면 유사도가 당연히 낮게 나와서 아무 정보가 없습니다. 우리가 두려운 건 1편 4절에서 든 예 — “육아휴직 급여” vs “육아휴직 기간” 같은 가까운데 다른 쌍이거든요.

from itertools import combinations


def build_pairs(golden):
    by_group, by_chunk = {}, {}
    for r in golden:
        by_group.setdefault(r["intent_group"], []).append(r)
        by_chunk.setdefault(r["chunk_id"], []).append(r)

    pos = [(a["q"], b["q"])
           for members in by_group.values()
           for a, b in combinations(members, 2)]

    neg = [(a["q"], b["q"])
           for members in by_chunk.values()
           for a, b in combinations(members, 2)
           if a["intent_group"] != b["intent_group"]]

    return pos, neg

이제 두 분포를 훑으면서 임계값을 찾습니다.

def sweep(pos, neg, emb, lo=0.60, hi=0.99, step=0.01):
    ps = [emb.cos(a, b) for a, b in pos]
    ns = [emb.cos(a, b) for a, b in neg]

    out = []
    t = lo
    while t <= hi + 1e-9:
        tp = sum(1 for s in ps if s >= t)
        fp = sum(1 for s in ns if s >= t)
        out.append({
            "t": round(t, 2),
            "precision": tp / (tp + fp) if tp + fp else 1.0,
            "recall": tp / len(ps) if ps else 0.0,
            "fp": fp,
        })
        t += step
    return out

두 경계선은 서로 다른 기준으로 고릅니다. 목적이 다르기 때문이에요.

경계 무엇을 지키나 고르는 기준
상단 (즉답) 오답 즉답 방지 음성 쌍이 하나도 안 넘는 지점 + 여유
하단 (힌트 주입) 쓸 만한 힌트 확보 양성 쌍의 90% 를 잡는 지점

상단은 타협하지 않습니다. 여기를 넘으면 검증 없이 나가니까요.

def pick_thresholds(rows, margin=0.02, hint_recall=0.90):
    # 상단: 오탐이 0 이 되는 최저 임계값 + 안전 여유
    zero_fp = [r["t"] for r in rows if r["fp"] == 0]
    top = min(zero_fp) + margin if zero_fp else 0.99

    # 하단: 양성의 90% 를 건지는 지점
    ok = [r["t"] for r in rows if r["recall"] >= hint_recall]
    bottom = max(ok) if ok else 0.70

    return round(min(top, 0.99), 2), round(min(bottom, top - 0.05), 2)

실제로 돌리면 이런 게 나와요.

model=local-multilingual-v1  pos=612  neg=1893
  t=0.80  precision=0.712  recall=0.968  fp=214
  t=0.86  precision=0.891  recall=0.921  fp= 61
  t=0.90  precision=0.972  recall=0.847  fp= 14
  t=0.93  precision=1.000  recall=0.771  fp=  0
picked: top=0.95  bottom=0.86

0.95 와 0.80 이 아니라 0.95 와 0.86 이 나왔습니다. 1편에서 적었던 0.80 은 이 모델에서는 너무 헐거워요 — 오탐이 214개나 넘어옵니다. 이래서 재봐야 하는 거예요.

이 값들은 설정으로 나갑니다.

GATE = {
    "embedding_model": "local-multilingual-v1",
    "top": 0.95,
    "bottom": 0.86,
    "calibrated_at": "2026-08-20",
    "golden_version": "g1",
}

embedding_model 을 같이 적어두는 게 중요해요. 모델이 바뀌면 이 값들은 전부 무효입니다. 4편에서 이 설정을 그대로 씁니다.



9. 회귀 테스트로 굳히기

골든셋을 만들어놨으니 테스트로 박아둡니다. 이 저장소 관행대로 표준 unittest 만 씁니다.

import json
import unittest

GOLDEN = "tests/fixtures/golden_v1.jsonl"
BASELINE = {"gate_precision": 0.98, "recall@10": 0.90, "top_fp": 0}


class TestTrustGate(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        with open(GOLDEN, encoding="utf-8") as f:
            cls.golden = [json.loads(line) for line in f]

    def test_gate_precision_not_regressed(self):
        got = score_gate(self.golden)["precision"]
        self.assertGreaterEqual(got, BASELINE["gate_precision"])

    def test_retrieval_recall_not_regressed(self):
        got = recall_at_k(load_queries(self.golden), current_backend().search, 10)
        self.assertGreaterEqual(got, BASELINE["recall@10"])

    def test_top_gate_has_no_false_positives(self):
        pos, neg = build_pairs(self.golden)
        fp = sum(1 for a, b in neg if current_emb().cos(a, b) >= GATE["top"])
        self.assertEqual(fp, BASELINE["top_fp"])

    def test_thresholds_match_current_embedding(self):
        self.assertEqual(GATE["embedding_model"], CONFIG["embedding_model"])

마지막 테스트가 제일 값어치 있어요. 임베딩 모델을 바꿨는데 임계값을 안 다시 잡으면 즉시 빨간불이 켜집니다. 1편에서 말한 이음매가 여기서 실제로 작동하는 거예요.

python3 -m unittest discover -s tests



10. 자주 밟는 지뢰

① 검증 통과율이 99% 입니다. 게이트가 일을 안 하고 있어요. 십중팔구 생성 모델과 검증 모델이 같습니다(4절). 아니면 evidence 를 안 주고 원문 전체를 주고 있거나요 — 그러면 뭐든 어딘가에서 지지됩니다.

② 통과율이 40% 입니다. min_score 가 너무 높거나, 청킹이 너무 잘게 되어 있어요. 답이 두 청크에 걸쳐 있으면 근거 하나로는 절대 통과 못 합니다. 2편의 조항 단위 청킹으로 돌아가서 보세요.

③ 임계값을 재봤더니 상단이 0.99 로 나옵니다. 음성 쌍을 “완전히 다른 주제”로 만들었을 가능성이 큽니다(8절). 같은 청크에서 나온 다른 의도끼리 비교해야 의미가 있어요.

④ 골든셋을 다시 만들 때마다 결과가 달라집니다. seed 를 고정 안 했거나(6절), 골든셋 파일에 버전이 없어서예요. golden_version 을 박고 파일을 tests/fixtures/ 에 고정하세요.

⑤ 사람 라벨과 게이트가 자꾸 어긋납니다. intent_group 기준이 사람마다 다를 수 있어요. “같은 답이 나와야 하면 같은 그룹” 이라는 기준 하나로 통일하면 대체로 정리됩니다.



11. 정리

이번 편으로 1편의 결함 ③이 닫혔고, 미뤄뒀던 ⑧ 도 절반 닫혔습니다.

원안 지금
신뢰도를 프롬프트로 부탁 주장 단위 groundedness + critical claim 즉시 폐기
검증 없음 통과율 81.5%, 나머지는 버림
채점자 = 생성자 심사자 모델 분리 + span 기계 검증
임계값이 임의의 상수 골든셋으로 캘리브레이션 → 0.95 / 0.86
임베딩이 미정 로컬+리랭커가 외부보다 나음을 측정으로 확인

특히 마지막 두 줄이 이번 편의 진짜 성과예요. 1편에서 “그대로 베끼지 마세요”라고 세 번쯤 경고했던 숫자들이, 이제 근거 있는 값이 됐습니다.

이제 재료가 다 준비됐어요. 검증된 Q&A, 확정된 임베딩 모델, 캘리브레이션된 임계값. 4편에서 이걸 실제 서빙 경로로 조립합니다 — ES 하이브리드 검색, 리랭커, 그리고 1편 결함 ②의 처방인 Redis 3단 게이트요.