File size: 14,055 Bytes
46d572c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 | # -*- coding: utf-8 -*-
"""배치 평가: 골든셋으로 Context Recall/Precision (기계 채점, LLM 비용 0).
검색 대상: 각 질의회신의 **기준서 컬렉션만**(질의회신 컬렉션 제외).
정답(expected_ref_keys)은 100% 기준서 문단이므로, 질의회신을 검색 대상에 섞으면
유사 질의회신이 상위를 독식해 정답을 밀어냄(실측 recall 3.7%→26%로 7배 왜곡).
→ 질의회신 컬렉션을 원천 제외해 self-leakage도 동시 차단(자기 자신이 결과에 안 나옴).
지표(RAGAS 근사) — recall을 3단으로 병기(정직성):
· 문단 recall (exact) = 정확한 문단까지 회수한 비율 — 가장 엄격
· 문단 recall (인접완화) = exact 1.0 + 인접 문단 0.5점 — 실용 하한
· 호 recall = 올바른 기준서(제NNNN호) 회수 비율 — 실질 성능 상한
· 문단 precision(exact) = 검색 상위 k 중 정확 문단 비율
인접 판정(자의성 배제): 같은 호 · 같은 상위경로/접두 · 같은 꼬리 · 마지막 정수 차이 ±1.
(예: 제1102호 B45 ↔ B46 인접 0.5점 / B45 ↔ B53(차이8) 오답 / 5.7.5 ↔ 5.8.1 오답)
결과: 전체 평균 + 게시판별 + top-k(5/10). batch_YYYYMMDD.json + summary.md.
사용: python3 -m rag.eval.run_batch [--sample 30] [--per-coll 50] [--date YYYYMMDD]
"""
import argparse
import json
import re
import time
import unicodedata
from collections import defaultdict
from rag import common as C
from rag.search import Index
GOLD = C.ROOT / "eval" / "goldenset.jsonl"
RESULTS = C.ROOT / "eval" / "results"
# 게시판 → 검색 대상 기준서 컬렉션 (질의회신 컬렉션은 검색 안 함)
STD_COLL = {"016001": "kifrs_standards", "016002": "kifrs_standards",
"016005": "kifrs_standards", "016003": "kgaap_standards",
"016006": "kgaap_standards"}
# 호(kifrs: 제1102호) + 장(kgaap 일반기업기준: 제5장) 모두 — 두 체계 혼재(질의회신 정답에 섞임)
_HO = re.compile(r"(제\d+[호장])")
_LABEL = re.compile(r"문단\s*(.+)$")
_INT = re.compile(r"\d+")
def ho_of(ref_key):
"""ref_key/section_key에서 기준서 단위(제NNNN호/제N장) 추출. kifrs=호, kgaap=장."""
m = _HO.match(ref_key)
return m.group(1) if m else None
def parse_para(ref_key):
"""문단키를 (호, 상위경로prefix, 마지막정수, 꼬리)로 분해. 용어섹션/파싱불가 → None."""
hom = _HO.match(ref_key)
if not hom:
return None
lm = _LABEL.search(ref_key) # '문단 ' 뒤 라벨. '용어의 정의' 등은 매칭 안 됨
if not lm:
return None
label = lm.group(1).strip()
ints = list(_INT.finditer(label))
if not ints:
return None
last = ints[-1]
return (hom.group(1), label[:last.start()], int(last.group()), label[last.end():])
def adjacent(a, b, n=1):
"""두 문단키가 인접한가: 같은 호·상위경로·꼬리 & 마지막 정수 차이 0<Δ≤n."""
pa, pb = parse_para(a), parse_para(b)
if not pa or not pb:
return False
return (pa[0] == pb[0] and pa[1] == pb[1] and pa[3] == pb[3]
and pa[2] != pb[2] and abs(pa[2] - pb[2]) <= n)
def got_keys(metas):
"""검색 결과 메타에서 회수한 ref_key/section_key 집합."""
got = set()
for m in metas:
if m.get("ref_key"):
got.add(m["ref_key"])
if m.get("section_key"):
got.add(m["section_key"])
return got
# 표기 정규화: NFKC로 원문자·전각을 표준형으로 '풀어씀'(제거 아님) + 공백만 제거.
# 'B96⑷'→'B96(4)', 전각'()'→'()'. 따라서 'B96⑷'↔'B96(4)'는 같게, 'B96⑷'↔'B96⑴'은
# **다르게** 취급(하위항목 ⑴⑵⑷ 구분 보존). ← 원문자를 '제거'하면 다른 하위문단이 합쳐져
# recall이 부당하게 부풀려짐(검증: 제거식은 기준서 고유키 3,131그룹 충돌 → false-positive).
# NFKC식은 충돌 0. 실제 골든셋엔 괄호숫자↔원문자 같은 진짜 표기차가 거의 없어 효과 +0.0%p.
_WS = re.compile(r"\s+")
def _nk(k):
return _WS.sub("", unicodedata.normalize("NFKC", k))
def score(expected, got):
"""정답 대비 3단 점수 반환: (exact_recall, relaxed_recall, ho_recall, exact_hit수).
exact는 표기 정규화(_nk) 후 매칭 — 원문자·괄호·공백 차이는 같은 문단으로 인정.
"""
if not expected:
return 0.0, 0.0, 0.0, 0
gotnorm = {_nk(g) for g in got}
def hit(e):
return _nk(e) in gotnorm
exact_hits = sum(1 for e in expected if hit(e))
relaxed = 0.0
for e in expected:
if hit(e):
relaxed += 1.0
elif any(adjacent(e, g) for g in got):
relaxed += 0.5
exp_ho = {ho_of(e) for e in expected if ho_of(e)}
got_ho = {ho_of(g) for g in got if ho_of(g)}
ho_recall = len(exp_ho & got_ho) / len(exp_ho) if exp_ho else 0.0
return exact_hits / len(expected), relaxed / len(expected), ho_recall, exact_hits
def evaluate(index, golden, ks=(5, 10), per_coll=50, progress_every=50):
agg = {k: defaultdict(lambda: {"exact": [], "relaxed": [], "ho": [], "prec": []})
for k in ks}
self_excluded = 0
kmax = max(ks)
n = len(golden)
dump = [] # 원시 검색결과 — 채점 로직 바뀌면 재실행 없이 rescore.py로 재계산
t_start = time.time()
for qi, g in enumerate(golden, 1):
scoll = STD_COLL[g["board"]]
tq = time.time()
# 기준서 컬렉션만 검색(질의회신 원천 제외). 넉넉히 kmax+3.
hits = index.retrieve_routed(g["question"], [scoll], k=kmax + 3,
min_standards=0, per_coll=per_coll)
dt = time.time() - tq
if qi == 1 or qi % progress_every == 0 or qi == n:
elapsed = time.time() - t_start
eta = elapsed / qi * (n - qi)
print(f" [{qi}/{n}] 쿼리 {dt:.1f}s · 누적 {elapsed:.0f}s · 예상잔여 {eta:.0f}s",
flush=True)
# 안전망: 혹시 남을 self(doc_no 일치) 제외 — 기준서만 검색이라 사실상 0
before = len(hits)
hits = [h for h in hits if h.get("doc_no") != g["doc_no"]]
self_excluded += before - len(hits)
exp = g["expected_ref_keys"]
for k in ks:
got = got_keys([h["meta"] for h in hits[:k]])
ex, rel, ho, hit = score(exp, got)
for scope in (g["board"], "ALL"):
agg[k][scope]["exact"].append(ex)
agg[k][scope]["relaxed"].append(rel)
agg[k][scope]["ho"].append(ho)
agg[k][scope]["prec"].append(hit / k)
dump.append({"id": g["id"], "board": g["board"], "expected": exp,
"hits": [{"ref_key": h["meta"].get("ref_key", ""),
"section_key": h["meta"].get("section_key", "")}
for h in hits[:kmax]]})
return agg, self_excluded, dump
def summarize(agg, ks):
def avg(x):
return sum(x) / len(x) if x else 0.0
out = {}
for k in ks:
out[k] = {b: {"exact": round(avg(v["exact"]), 4),
"relaxed": round(avg(v["relaxed"]), 4),
"ho": round(avg(v["ho"]), 4),
"precision": round(avg(v["prec"]), 4),
"n": len(v["exact"])}
for b, v in agg[k].items()}
return out
def write_markdown(summary, ks, meta, path):
a5, a10 = summary[5]["ALL"], summary[10]["ALL"]
L = ["# 배치 평가 — Context Recall/Precision (기계 채점, LLM 비용 0)", "",
f"- 골든셋 **{meta['n']}건** (질의회신 질문 → 정답=인용 기준서 문단, 조인 성공분만).",
f"- 검색 대상: **각 질의회신의 기준서 컬렉션만**(질의회신 컬렉션 제외) · "
f"per_coll={meta['per_coll']} · dense+리랭킹(bge-reranker-v2-m3, fp16).",
f"- **self-leakage 차단**: 질의회신 컬렉션을 검색 대상에서 원천 제외 → 자기 글이 "
f"결과에 안 나옴(추가 doc_no 필터로 제외된 잔여 {meta['self_excluded']}건).",
f"- 소요 {meta['elapsed_s']}s ({meta['elapsed_s']/max(meta['n'],1):.1f}s/건).", "",
"## 성능 (전체 평균)", "",
"| 지표 | top-5 | top-10 | 무엇을 재나 / 무엇을 못 재나 |",
"|---|---|---|---|",
f"| 문단 recall — exact | {a5['exact']:.3f} | {a10['exact']:.3f} | "
"정확한 문단까지 회수. **가장 엄격**(표기 정규화 NFKC 적용하나 현재 데이터엔 효과 +0.0%p) |",
f"| 문단 recall — 인접완화 | {a5['relaxed']:.3f} | {a10['relaxed']:.3f} | "
"정답 문단은 1.0, **±1 인접 문단은 0.5**점. exact가 놓치는 '옆 문단' 회수를 반영(실용 하한) |",
f"| 호 recall — 기준서 단위 | {a5['ho']:.3f} | {a10['ho']:.3f} | "
"올바른 **기준서(제NNNN호)**를 찾았는가. **실질 성능에 가까움**. 단, 문단 정밀도는 못 잼 |",
f"| 문단 precision — exact | {a5['precision']:.3f} | {a10['precision']:.3f} | "
"상위 k 중 정확 문단 비율 |", "",
"### 인접완화 채점 기준 (자의적 후함 배제)",
"- 인접 = **같은 호 · 같은 상위경로(예: `5.7.`)/접두(예: `B`,`AG`) · 같은 꼬리 · "
"마지막 정수 차이 정확히 ±1**. 이때만 0.5점.",
"- 예: `제1102호 B45`↔`B46` 인접(0.5) / `B45`↔`B53`(차이 8) 오답 / "
"`5.7.5`↔`5.8.1`(상위경로 다름) 오답 / `3A`↔`3`(꼬리 다름) 오답.",
"- exact를 **대체하지 않고 별도 병기**. 인접완화 ≥ exact는 구조상 보장(exact 점수를 낮추지 않음).", "",
"### 왜 문단 exact가 낮은가 (정직한 해석)",
"- 질의회신 1건당 인용 문단 **평균 2.1개·최대 14개** — 여러 문단을 모두 top-k에 넣어야 만점.",
"- 인용이 **번호를 축약/범위로** 표기하는 경우가 있어 레코드 단위 exact와 어긋남.",
"- 임베딩·리랭킹은 **올바른 기준서(호)는 잘 찾지만**(위 호 recall) 인용된 정확한 문단 "
"번호까지 pinpoint하는 것은 본질적으로 어려움 → exact는 하한, 호는 상한, 인접완화는 그 사이.",
"- 수치를 부풀리지 않음: 세 지표를 모두 공개하고 각자의 한계를 명시.", "",
"### 문단 exact 실패 원인 분류 (dump 1,956건 자동 분류)",
"- 표기 정규화(NFKC)로 원문자·괄호·전각 차이는 exact에 이미 흡수 → 남은 실패는 "
"**진짜 검색 실패 100%**. 골든셋엔 `괄호숫자↔원문자` 같은 진짜 표기차가 거의 없어 "
"**정규화 효과 +0.0%p**(원문자 '제거'식은 `B96⑴/⑵/⑷`를 합쳐 +3.4%p 부풀렸으나 "
"false-positive라 폐기 → 문단 exact 27%는 채점 아티팩트가 아닌 실제 검색 성능).",
"- 실패 내역: **62.6% 호는 맞고 정확 문단만 놓침**(문단 pinpoint 한계) · "
"30.0% 기준서(호)조차 못 찾음(질의회신이 여러 기준서에 광범위 인용) · 7.4% ±1 인접까지 감.",
"- **답변 품질 영향은 제한적**: 실패의 63%는 올바른 기준서를 이미 회수(호 recall "
f"{a10['ho']:.1%})했고 인접 문단을 근거로 제시 → 사용자가 정답 문단 부근 원문을 받게 됨.", "",
"## 게시판별 (top-10)", "",
"| 게시판 | 문단 exact | 인접완화 | 호 recall | n |", "|---|---|---|---|---|"]
for b in ("016001", "016002", "016003", "016005", "016006"):
if b in summary[10]:
s = summary[10][b]
L.append(f"| {b} | {s['exact']:.3f} | {s['relaxed']:.3f} | {s['ho']:.3f} | {s['n']} |")
path.write_text("\n".join(L) + "\n", encoding="utf-8")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sample", type=int, default=0, help="상위 N건만(0=전체)")
ap.add_argument("--per-coll", type=int, default=50)
ap.add_argument("--date", default="test")
args = ap.parse_args()
golden = [json.loads(l) for l in GOLD.open(encoding="utf-8")]
if args.sample:
golden = golden[:args.sample]
prog = 5 if len(golden) <= 60 else 50
print(f"평가 대상 {len(golden)}건, Index 로드...", flush=True)
idx = Index()
ks = (5, 10)
t0 = time.time()
agg, self_excluded, dump = evaluate(idx, golden, ks=ks, per_coll=args.per_coll,
progress_every=prog)
summary = summarize(agg, ks)
meta = {"n": len(golden), "self_excluded": self_excluded,
"per_coll": args.per_coll, "elapsed_s": round(time.time() - t0, 1)}
RESULTS.mkdir(parents=True, exist_ok=True)
(RESULTS / f"batch_{args.date}.json").write_text(
json.dumps({"meta": meta, "summary": summary}, ensure_ascii=False, indent=2),
encoding="utf-8")
# 원시 검색결과 저장 → 채점 로직 변경 시 rescore.py로 재실행 없이 재계산(원시 로그, gitignore)
with (RESULTS / f"retrieval_{args.date}.jsonl").open("w", encoding="utf-8") as f:
for d in dump:
f.write(json.dumps(d, ensure_ascii=False) + "\n")
write_markdown(summary, ks, meta, RESULTS / "summary.md")
a5, a10 = summary[5]["ALL"], summary[10]["ALL"]
print(f"\n=== 결과 (n={meta['n']}, {meta['elapsed_s']}s, self제외 {self_excluded}) ===")
print(f" 문단 recall exact top5={a5['exact']:.3f} top10={a10['exact']:.3f}")
print(f" 문단 recall 인접완화 top5={a5['relaxed']:.3f} top10={a10['relaxed']:.3f}")
print(f" 호 recall top5={a5['ho']:.3f} top10={a10['ho']:.3f}")
print(f" 문단 precision exact top5={a5['precision']:.3f} top10={a10['precision']:.3f}")
if __name__ == "__main__":
main()
|