clausegraph
by JAEHUN-AN
README.md
# clausegraph
보험 표준약관을 지식그래프로 만들고, 그 위에서 지급심사를 보조하는 시스템.
청구 건을 넣으면 **판정과 근거 약관 조항**을 함께 낸다.
```
청구: "충치가 심해 임플란트를 했습니다. 비급여 1,200,000원 청구합니다."
가입: 2026-07-01, 실손의료보험 특별약관1(중증 비급여)
판정 DENIED
사유 진단코드 K02가 약관이 정한 면책 범위에 든다
근거 20260506/실손의료보험 특별약관1(중증 비급여 실손의료비)/제4조#(3)3대비급여#4-1
제4조(보상하지 않는 사항) 치과치료(K00∼K08, 다만, 안면부 골절로 발생한
의료비는 치아관련 치료를 제외하고 보상합니다)ㆍ한방치료(…)
```
## 왜 그래프인가
벡터 검색으로 구조적으로 안 되는 것이 셋 있다. 셋 다 숫자로 확인했다.
### 1. 면책은 부정 조건이라 벡터가 놓친다
청구자 말투 18문항으로 재 봤다.
| 전략 | hit@10 | recall@10 | recall@30 | recall@100 | 후보 수 |
|---|---|---|---|---|---|
| **vector** | **50.0%** | **32.9%** | 55.1% | 88.0% | k |
| lexical | 5.6% | 5.6% | — | — | k |
| graph | 100% | **100%** | 100% | 100% | 106 |
| vec+graph | 88.9% | 86.1% | **100%** | 100% | 285~388 |
**벡터는 k를 열 배로 늘려야 88%에 닿는다.** 후보 100개는 색인 1,689청크의
6%다. 그래프는 k와 무관하게 100%인데, 순위를 매기는 대신 **그 상품·그
판본의 면책 사유를 빠짐없이 세우기** 때문이다. vec+graph는 두 후보를 섞느라
k=10에서는 오히려 그래프보다 낮고 k=30부터 100%가 된다.
이유는 어휘가 겹치지 않기 때문이다. *"충치가 심해 임플란트를 했습니다"* 와
의미상 가장 가까운 글은 **보장** 조항이다. 지급을 뒤집는 `치과치료(K00~K08)`는
질문의 어떤 표현과도 닮지 않았다 — 임플란트라는 낱말이 약관에 없으니까.
**어휘 검색을 붙여도 안 된다(5.6%).** 조사를 떼는 문제가 아니었다 —
청구인의 말과 약관의 말이 뒤쪽 형태소에서 갈린다(`간병인`/`간병비`,
`발급비`/`발급비용`, `경주`/`경기`). 벡터와 RRF로 섞으면 약한 다리가 상위
10칸의 절반을 가져가 **벡터 단독보다 나빠진다**(38.9%/21.3%).
### 그런데 랭킹으로 어디까지 되는지도 재 봤다
벡터의 recall@100이 88%라는 것은 **정답이 후보 안에 이미 있다**는 뜻이다.
교차 인코더(`bge-reranker-v2-m3`)로 그 100개를 다시 세웠다.
| | recall@10 | 후보 수 | p50 |
|---|---:|---:|---:|
| vector | 32.9% | 10 | 186ms |
| vector 100개 → 리랭킹 → 10개 | **55.1%** | 10 | 134,232ms |
| graph | **100%** | 106 | 15ms |
| graph → 리랭킹 → 10개 | **59.3%** | 9.3 | 123,675ms |
**랭킹 문제인 부분이 실제로 있었다** — 순서만 바꿔 32.9%가 55.1%가 된다.
22.2%p는 새로 찾아온 것이 아니라 후보 안에서 눌려 있던 것이다.
**그런데 완전한 열거에 리랭킹을 걸면 40.7%p를 잃는다.** 충치·임플란트는
6/6에서 **0/6**이 된다. 교차 인코더가 질문과 조항을 함께 읽어도, 임플란트가
약관에 없다는 사실은 모델을 바꿔도 바뀌지 않는다.
> **면책 조회에서 랭킹은 열거를 대체하지 못한다.** 랭킹을 잘하면 벡터 경로가
> 32.9% → 55.1%까지 오르지만, 열거의 100%에는 닿지 못하고, 열거된 결과에
> 랭킹을 걸면 오히려 40.7%p를 잃는다.
그리고 그 55.1%는 **10만 배** 비싸다 — 열거가 15ms에 100%를 내는 자리에서
리랭킹은 134초를 쓴다. 코드는 남기되 기본 경로에 넣지 않는다.
→ [notes/008](notes/008-exclusion-recall.md) · [notes/038](notes/038-hybrid-rerank.md)
### 2. 약관에는 시점 축이 있다
보험은 **가입 당시** 약관이 적용된다. 같은 조문 번호가 시점에 따라 다른 내용이다.
수집한 4개 버전에서 상품이 **16개** 나온다. 최신본은 14개다. 2026-05-06
5세대 실손 개편으로 `실손의료보험 특별약관`이 중증/비중증 둘로 갈리면서
원래 상품이 **사라졌다.**
> 2025년 가입자가 "실손 특별약관"이라고 말하면 지금 문서에 없는 상품을
> 가리킨다. 현행 약관만 벡터로 색인하면 특별약관1이나 2가 걸려 나오지만
> 그 계약에는 둘 다 적용되지 않는다.
더 미묘한 것도 있다. **세칙 시행일과 약관 적용일이 다르다.**
| 세칙 시행일 | 약관 적용일 |
|---|---|
| 20260506 | **20260606** |
부칙이 *"[별표15] 표준약관(개인실손의료보험은 제외한다) 개정내용은 2026년
6월 6일 이후 체결되는 보험계약부터 적용한다"* 고 정했다.
괄호가 결정적이다. **적용일이 상품마다 다르다.** 같은 날 가입해도 실손은
새 약관, 생명·질병상해는 옛 약관이다.
| 가입일 | 기본형 실손(급여) | 생명보험 | 질병·상해보험 |
|---|---|---|---|
| **2026-05-20** | **20260506** | **20250901** | **20250901** |
| 2026-06-10 | 20260506 | 20260506 | 20260506 |
그리고 **부칙은 조문마다 시행일을 따로 정한다.**
```
다만 [별표 15]의 <자동차보험> 제9조, 제11조의 개정사항은 2020년 10월 22일부터
시행하고, … 제1조, 제20조, 제27조 … 의 개정사항은 2020년 11월 10일부터 시행한다.
```
부칙 26건을 **적용 단위 30개**로 쪼갰다. 19개는 상품 전체에 걸리고 **11개는
조문·별표 일부만** 바꾼다. 일부만 바꾼 부칙으로는 **버전을 옮기지 않는다** —
옮기면 그 상품의 나머지 조문까지 새 판본으로 끌려간다. 대신 그 사실을 답에
실어, 조문을 단정해서 인용하지 못하게 한다.
→ [notes/006](notes/006-graph-load.md) · [notes/015](notes/015-appendix-provisions.md) · [notes/030](notes/030-appendix-scopes.md)
· [notes/016](notes/016-product-scoped-provisions.md)
### 3. 면책의 예외는 다른 조문에 있다
면책 조항의 상당수가 예외를 달고 있고, **그 예외가 다른 조문을 가리킨다.**
> 산재보험에서 보상받는 의료비. **다만**, 본인부담의료비(…)는
> **제3조(보장종목별 보상내용) … 에 따라 보상합니다.**
면책이면서 동시에 보상의 근거를 가리킨다. 참조를 따라가지 않으면 "면책에
걸렸다"까지만 말하고 끝난다 — 청구인에게는 그 뒤가 중요하다.
`제N조` 표기의 42%는 약관 **밖**(「국민건강보험법」 제42조, 동법 제3조의3,
민법 제768조)을 가리켜서, 내부로 잘못 읽으면 번호만 같은 엉뚱한 조문에
엣지가 생긴다. 앞을 보고 가르고, 가르지 못하면 엣지를 만들지 않는다.
내부 참조 해석률 **98.3%**, 못 만든 엣지는 적재 로그에 수로 남긴다.
**참조 해석률이 파서 검사기이기도 했다.** 처음 80.5%가 나와 남은 20%를
들여다보다가, 조문 파서가 **제목을 대괄호로 묶은 조문 60개를 통째로
버리고** 있던 것을 찾았다(`제27조[… 납입최고(독촉)와 계약의 해지]` —
제목 안에 괄호가 겹치면 바깥을 대괄호로 바꿔 쓴다). 조문이 사라진 것은
조문 수를 세도 보이지 않는다. **약관이 자기 자신을 가리키는 것을 이용해서야
빈칸이 드러났다.** 고친 뒤 조문 1,774 → 1,798, 해석률 → 98.3%.
→ [notes/022](notes/022-reference-edges.md)
## 어디까지 되는가
앞의 셋은 **이 구조가 무엇을 할 수 있는지**다. 그것만 쓰면 절반이다.
금감원 분쟁조정사례 중 **청구 분쟁이면서 표준약관 범위인 68건**의 쟁점을
유형별로 세었다.
| | 유형 | 건수 |
|---|---|---:|
| ✗ | **DEFINITION** — 약관 용어(수술·입원·장해)의 범위 해석 | **21 (30.9%)** |
| ✓ | EXCLUSION — 보상하지 않는 사항에 걸리는가 | 13 (19.1%) |
| ✓ | TIMING — 보장개시·면책기간·적용 판본 | 10 (14.7%) |
| ✓ | DUPLICATE — 건보 상한액·산재에서 받은 돈 | 5 (7.4%) |
| ✗ | 나머지(고지·입증·절차·계약·미상) | 17 (25.0%) |
| ✓ | SCOPE — 그 특약에 가입했는가 | 2 (2.9%) |
> **답할 수 있는 유형 30/68 (44.1%). 가장 큰 덩어리는 못 푼다.**
*"IPL시술이 약관이 정하는 수술의 정의와 범위에 포함되는지"* 는 **조문을
찾아 주는 문제가 아니라 조문을 해석하는 문제다.** 그래프는 조문을 정확히
가져다주지만 그 판단을 대신하지 않는다.
**LLM으로 덮으려다 21건을 전부 읽고 그만뒀다.** 병목이 모델이 아니었다.
| | |
|---|---|
| 판정 쏠림 | OUT 19 / IN 2 — **"전부 OUT"이 90.5%를 받는다** |
| 상품 약관의 정의 문구만으로 갈린 사례 | **5건 (24%)** |
| 나머지가 요구한 것 | 의무기록 38% · 판례 33% · 상품분류표 29% |
| 표준약관에 `수술`·`입원`의 정의 | **없다** (제2조는 계약 용어만 정의) |
| `장해분류표` | 조문이 **32번 가리키는데 표가 없다** |
없는 문서는 모델 급을 올려도 읽을 수 없다. 붙였다면 모델이 표준약관의 다른
조문으로 답을 지어냈을 것이고, 채점하면 쏠림 덕에 90%가 나왔을 것이다.
**대신 판정하지 않고 "무엇이 있어야 하는지"를 답한다.** 용어를 가리고
(21/21), 표준약관에 그 정의가 있는지를 **조회로** 확인하고, 없으면 무엇을
가져와야 하는지 이름으로 돌려준다. 금액에서 쓴 것과 같은 방법이다.
정의를 셋으로 나눠 센다 — **있다 / 비었다 / 없다**. 가운데가 중요하다.
`장해`는 표준약관이 정의하지만 그 정의가 `<부표 3>`을 가리키고 그 표가
수집본에 없다. "정의가 있으니 그 조문을 보라"고 답하면 빈 곳으로 보낸다.
**판례가 필요한지는 말하지 않는다.** 쟁점 문장만으로는 알 수 없고, 예측하는
척하면 사후 지식을 쓰는 것이 된다. 넷 중 셋만 답하고 하나를 비워 두는 쪽이
낫다. → [notes/034](notes/034-definition-anatomy.md) · [notes/035](notes/035-definition-triage.md)
이 표가 있어야 위의 "면책 recall 100%"가 무엇의 100%인지 말이 된다.
**실제 분쟁의 19%가 면책 다툼이고, 그 19%에서 열거 방식이 100%를 낸다.**
→ [notes/024](notes/024-dispute-types.md)
## 구조
```
┌─ 국가법령정보 API ──→ 표준약관 조문·부칙
데이터 수집 ─────────┼─ 금감원 분쟁조정사례 ─→ 평가셋
└─ 심평원 상병마스터 ──→ KCD 코드 사전
지식그래프 (Neo4j) Product · Version · Article · Item · Coverage · Provision
조문 간 참조 1,592엣지 (면책의 예외가 보상 조문을 가리킨다)
벡터 색인 (pgvector) 조문·호 1,689청크, bge-m3 ONNX INT8 CPU
심사 에이전트 사실추출 → 보장탐색 → 면책검증 → 금액산정 → 검증/심판
(종료 경로 넷이 전부 검증/심판 한 곳을 지난다)
가드레일 grounding · 금액 미계산 · 금액 상한 · 금액 하한 · 불확실 면책
· PII 마스킹
접근 제어 상품 단위 스코프. 지목은 거절하고 검색은 조용히 뺀다
관측 스텝 p50/p95 집계 + 한 건 단위 트레이스(trace_id)
회귀 게이트 eval_baselines.json — 통과/실패/**못 잼**을 갈라 답한다
노출 MCP 서버 도구 10개 (심사 7 + 후속 질문·재심사 2 + 용어 트리아지 1)
```
## 설계에서 갈렸던 지점
**LLM을 어디에 쓰는가.** 용어→코드 변환 하나에만 쓸 수 있고, 그것도
**기본 경로에서는 뺐다.** 4B(llama.cpp CPU)는 코드 생성 3/10에 틀리는 방식도
나빴다 — 치핵에 `G00.1`(뇌수막염), 발목 골절에 `L84.0`(티눈)처럼 **형식은
맞고 챕터가 틀린** 코드를 냈다.
그래서 "4B라서 그런 것 아닌가"를 재 봤다. **맞다.** 모델 급만 바꾸면 달라진다.
| | 생성 적중 | 선택 헛짚음 | p50 | 온프렘 |
|---|---|---|---|---|
| 규칙 표 | 7/10 | — | **0.8ms** | 예 |
| 로컬 4B | 3/10 | 3/3 | 2,438ms | 예 |
| 오픈웨이트 120B | **8/10** | **0/3** | 2,524ms | 예 (80GB급 필요) |
| 프론티어 flash | **8/10** | *측정 못 함* | 9,138ms | **아니오** |
**그래도 안 넣는다. 근거가 바뀌었을 뿐이다** — 규칙 표와 한 건 차이인데
**3,000배** 느리고 가속기가 필요하다. 대신 바뀌는 조건이 확정됐다: 폐쇄망에
80GB급 가속기가 있고 심사 한 건에 2~3초를 쓸 수 있다면, 선택 방식의 120B를
불확실 버킷으로 넣는 것이 낫다. 프론티어가 오픈웨이트를 못 이겼으므로
**망분리 제약은 이 과제에서 비용을 물리지 않는다.**
→ [notes/012](notes/012-local-llm.md) · [notes/031](notes/031-model-tier.md)
**multi-turn에서 위험한 것은 잊는 것이 아니라 낡은 것을 말하는 것이다.**
```
턴1 "충치 임플란트 120만원" -> DENIED (근거: 치과치료 K00~K08)
턴2 "아 작년에 이미 300만원 받았어요"
```
턴2는 판정의 **입력**을 바꾼다. 기억해 둔 DENIED로 답하면 이미 틀린 결론에
근거 조항까지 붙여 설명하게 된다 — 틀린 답 중 제일 나쁜 종류다. 그래서 새
사실 검사가 다른 모든 분류보다 **먼저** 온다. *"작년에 300만원 받았는데 왜
부지급이죠"* 는 `왜`가 들어 있어도 `WHY`가 아니라 `NEW_FACTS`이고, 답하지
않고 재심사로 보낸다.
숫자가 있다고 새 사실은 아니다. *"120만원이 왜 안 나와요"* 의 120만원은 이미
청구에 있는 값이다. **지금 아는 값과 달라야** 새 사실로 센다 — 아니면 같은
답을 두 번 주면서 매번 "다시 심사했다"고 말하게 된다.
그리고 후속 답변은 **앞 턴의 답변 문장이 아니라 저장된 `Adjudication`에서**
낸다. 답변을 이어 쓰면 두 턴 만에 인용이 원본에서 떠내려간다.
→ [notes/032](notes/032-multi-turn.md) · [notes/033](notes/033-followup-eval.md)
**금액산정은 자동 파싱하지 않는다.** 산식은 약관에 적혀 있지만 값이 표 안의
표에 중첩되고 보장종목·입원/통원·의료기관마다 다르다. 정규식으로 긁으면
조용히 틀린 숫자가 나오고, 틀린 숫자는 곧 틀린 지급액이다. 값을 손으로 적되
**조항 uid를 함께** 적고, 확인 못 한 값은 비워 사람에게 넘긴다.
→ [notes/014](notes/014-amount.md) · [notes/017](notes/017-amount-parameters.md)
**모르는 값이 있으면 지급액이 아니라 상한을 말한다.** 급여 통원 공제는
`max(정액, 의료비의 20%, 의료비 × 건강보험 본인부담률)`인데, 마지막 항의
비율은 **영수증에서 오는 값이라 약관에 없다.** 빠진 항은 공제를 키우는
쪽으로만 작용하므로, 그 항 없이 계산한 값은 실제 지급액의 **상한**이다.
법정 요율로 추정해 채우지 않고 상한이라고 말하고, 가드레일이 상한의 지급을
막는다. 무엇이 없어서 못 정했는지 이름까지 돌려준다 — 심사자가 할 일이
"판단"이 아니라 "값 두 개 확인"이 된다. → [notes/018](notes/018-deductible-terms.md)
**표에서 숫자 하나를 찾은 순간이 가장 위험하다.** 금액 오류 셋을 내가
직접 만들고 직접 잡았다. 한 칸만 읽어 공제 항을 빼먹어 상급종합병원 통원을
**2배 과다지급**했고, 한 행만 읽어 입원 비율을 통원에 돌려써 특약2 통원을
**정확히 반**만 줬고, 표준약관에 없는 감액기간(730일 50%)을 하드코딩해 가입
2년 미만 청구를 전부 반으로 깎았다. 셋 다 "틀릴 때만 조용히" 틀렸다.
그래서 규칙마다 `source_articles`를 달아 근거 조항을 되짚을 수 있게 했다 —
감액기간은 규칙 밖의 모듈 상수여서 그 검사를 통째로 빠져나갔다.
→ [notes/019](notes/019-rate-and-reduction.md)
**평가셋을 내가 쓰면 내 말투가 배어 있다.** 위 면책 recall 18문항은 내가
약관을 읽고 만든 것이라 약관의 어휘가 그대로 들어가 있다. 그래서 Tier B가
`EXCLUSION`으로 분류한 **실제 분쟁 13건의 쟁점 문장을 그대로** 넣어 봤다.
처음 나온 숫자는 **13/13**이었다. 무엇을 짚었는지 읽어 보니 이랬다.
| 쟁점 | 걸린 조항 | 걸린 낱말 |
|---|---|---|
| **보조기** 구입비용이 보장 대상인지 | **HIV 감염 치료비** | `대상에` |
| **치과치료** 비급여가 지급사유인지 | **정신 및 행동장애** | `치료에서` |
**변별어 4,528개의 46.6%가 조사 붙은 어절**이었다. `대상`은 흔해서 걸러지는데
`대상에`는 희귀어로 통과한다. 어절이 아니라 어간으로 세도록 고쳤다.
고치고 나니 **확실(certain) 히트**가 하나 남았다 — 부담보 해제를 묻는 쟁점에
정신질환 면책이 걸렸다. 확실 히트는 가드레일이 막지 않는다. 따라가 보니
KCD 사전에 `'특정'`이라는 열쇠가 있었고, 그건 `특정(개별) 공포증`을
**첫 괄호에서 잘라** 만든 조각이었다. `만성 (소아기) 육아종성 질환` → `만성`,
`급성, 재발성 또는 …` → `급성`도 같았다. 괄호를 자르지 않고 덜어 내도록
고쳤다.
| | 어간화 이전 | 이후 |
|---|---:|---:|
| 정답 조항을 짚은 사례 | 9/10 | **8/10** |
| **총 히트** | **178** | **74** |
| 사례당 평균 히트 | 13.7 | **5.7** |
**정답률만 보면 나빠 보인다.** 이전의 9/10은 아무 데나 다 걸려서 그 안에
정답이 섞여 있던 것이다. 사례 하나에 "걸릴 수도 있다"를 열네 건 내놓으면
심사자에게 준 것이 없다. 그래서 이 평가는 **정답률과 히트 수를 함께 낸다.**
그리고 어간화가 **원래 있던 논리 오류를 드러냈다.** 발목 골절 청구가
`치과치료` 면책에 걸렸는데, `골절`이 그 조항의 **`다만` 뒤**에 있었기
때문이다 — "안면부 골절로 발생한 의료비는 보상합니다", 즉 예외를 적은
자리다. `다만`이 든 괄호를 표현 일치에서 빼자 합성 청구 195건의
`PARTIAL`이 18 → **28**로 늘고 오발동 가드레일이 0이 됐다.
→ [notes/025](notes/025-real-disputes.md) · [notes/026](notes/026-proviso-tokens.md)
**인용문이 곧 산출물이다.** 근거로 보여 줄 문장의 앞머리를
`^.*?제\d+조\([^)]*\)\s*`로 다듬고 있었다. 조문 제목을 떼려던 것인데,
조문도 호도 제목으로 시작하지 않는다 — 파서가 이미 뗀다. 그래서 이 정규식이
실제로 지운 것은 **본문 안의 조문 참조와 그 앞의 모든 글자**, 즉 면책의
주어였다. 면책 항목 1,446개 중 116개(8.0%)가 잘렸고 최악은 11자
`"에 따라 보상합니다."`였다 — **부지급의 근거로 "보상합니다"를 인용했다.**
지우고 나니 표현 일치도 옳은 쪽으로 붙었다(`치과치료`, `치료관계비`를 새로
잡는다). → [notes/020](notes/020-citation-integrity.md)
같은 문제가 보장 쪽에 더 심하게 있었다 — 보장 조문 88개 중 40개(45.5%)의
인용문이 `┏━━━━┳`로 채워져 있었고 심한 것은 127자 중 59자가 테두리였다.
자르는 코드가 세 군데(보장·면책·MCP)에 각자 다르게 있어서, 020에서 면책만
고친 것이 고친 게 아니었다. 한 함수로 모으고 **표가 시작되는 곳에서 끊어
`…(표)`로 표시한다** — 괘선을 지우고 이어 붙이면 셀이 뒤엉켜 읽을 수 없고,
읽을 수 없는 인용문은 근거가 아니다. → [notes/021](notes/021-prose-quotes.md)
**권한은 한 곳에 걸면 막은 것처럼 보인다.** 상품 단위 스코프를 넣고
주체 4 × 문항 18로 스코프 밖 조항이 새는지 전수로 셌다. 게이트 전 23,605건,
게이트 후 0건. 그런데 중요한 것은 **어디에 걸었느냐**였다.
| 게이트를 건 곳 | 남은 누수 |
|---|---:|
| 없음 | 23,542 |
| **에이전트(심사) 층만** | **22,246 (94.5%가 그대로)** |
| 벡터만 | 7,586 |
| 검색 경로 전부 | 1,296 |
| 전부 | **0** |
심사 경로에만 걸면 `adjudicate`가 남의 상품을 거절하므로 **막은 것처럼
보이는데**, 스무 배가 넘는 조항이 검색 도구로 그대로 나간다. notes/023에서
"감싸는 줄 알았던 것이 두 번째 구현이었다"를 겪은 자리가 그대로 재현됐다.
가장 크게 샌 것은 `search_hybrid` 하나로 **15,410건, 전체의 65%**다. 벡터
단독(546)의 28배인데, 상품 목록이 호출부가 아니라 **벡터 결과에서** 나오기
때문이다 — 스코프 밖 씨앗 한 건이 잡히면 그 상품의 면책이 그래프로 통째로
끌려 올라온다. **recall을 만든 구조가 누수도 만든다.**
그래서 게이트를 호출부가 아니라 **질의 자체**에 걸고(`LIMIT`보다 앞에),
`principal`을 키워드 필수로 만들었다 — 기본값이 있으면 잊어도 통과하고
잊었다는 것이 드러나지 않는다. 다만 이것은 **인증이 아니라 스코프**다.
MCP stdio는 호출자를 확인하지 않는다. → [notes/039](notes/039-access-control.md)
**노출 계층도 로직이다.** MCP 도구가 버전을 고를 때 **상품을 무시하고**
세칙 시행일만 보고 있었다. 심사 에이전트는 부칙의 상품별 적용일을 반영하는데
도구는 그 함수를 지나쳐 갔다 — 같은 청구에 두 답이 나왔다. 가입일×상품
1,472쌍 중 32쌍(2.2%)이 어긋났고, 어긋나는 구간이 하필 **개정 직후 한 달**
이었다. 감싸는 줄 알았던 것이 두 번째 구현이었다.
→ [notes/023](notes/023-mcp-drift.md)
**LangGraph를 쓸지 재 봤고, 안 쓰기로 했다 — 대신 하나를 배워 왔다.**
같은 5단계를 LangGraph로 다시 짜서 같은 청구 195건에 둘을 걸었다. 스텝
함수는 두 벌 만들지 않았으므로(notes/036의 규칙) 다른 답이 나오면 그것은
정의상 오케스트레이션의 차이다.
| 축 | 자체 | LangGraph |
|---|---:|---:|
| 판정 일치 | **195/195 = 100%** | |
| p50 지연 | **0.30ms** | 2.27ms |
| p95 지연 | **4.03ms** | 5.25ms |
| 의존성 트리 | neo4j 4 | **79** |
**부담은 배수가 아니라 상수다.** p50은 7.6배지만 차이는 +1.97ms로 거의
고정이고, p95에서는 1.3배로 줄어든다. 7.6배라는 수는 이 시스템이 유난히
빠르기 때문에 나온다 — LLM을 한 번이라도 부르는 에이전트라면 측정 한계
아래다. 안 쓰는 근거는 속도가 아니라 **이 흐름에 순환·합류·재개가 하나도
없다**는 것이다. 폐쇄망에 패키지 15개(그중 `langsmith`)를 들일 값어치가 없다.
**그런데 하나는 가져왔다.** LangGraph에서는 종료 노드 넷이 초안만 만들고
엣지가 전부 `검증심판` 하나로 모인다. 자체 구현은 종료 지점 넷이 각자
가드레일을 불렀는데, 그건 **약속이지 구조가 아니다** — 다섯 번째 `return`에서
빠뜨리면 그 경로만 가드레일 없이 나가고, 가드레일은 대부분의 청구에서
발동하지 않으므로 결과를 봐서는 안 보인다. notes/039에서 "게이트를 한 곳에만
걸면 94.5%가 샌다"를 본 직후라 이 성질이 값어치 있어 보였다.
그래서 자체 오케스트레이터를 단일 종료로 고쳤다. `_finalize` 호출 지점
**4 → 1**, 판정은 **195/195 그대로**. 비교 하네스가 리팩터링의 안전망이
됐다. → [notes/040](notes/040-langgraph-compare.md)
**기본값은 조용한 가정이다.** 연간한도를 깎을 `already_paid_this_year`가
매개변수로 있었는데 **오케스트레이터가 한 번도 넘기지 않아** 언제나 0이
들어갔다. `0`은 "모른다"가 아니라 "올해 아무것도 안 받았다"는 주장이고,
한도를 다 쓴 계약에도 한도가 통째로 남은 것처럼 계산했다. 계약의 올해
누적은 약관에도 청구서에도 없는 **보험사 시스템의 값**이라, 저장소를 만드는
대신 청구가 들고 오게 하고 **없으면 지급액 대신 상한**을 말한다.
→ [notes/027](notes/027-claim-history.md)
**"보수적"은 누구에게 안전한지를 정해야 뜻이 생긴다.** 지금까지 찾은 금액
누락은 전부 한 방향이었다 — 공제 항을 빼먹거나 한도를 모르면 **더** 주게
된다. 그래서 "모르면 상한"이라는 한 문장으로 덮였다. 그런데 제5조 ④의
자기부담 연 200만원 상한은 **알수록 더 주는** 조항이라, 모르면 우리 계산이
실제보다 **작다**. 깃발을 나눠 `amount_lower_bound` 가드레일을 더했다 —
다섯이 보험사가 더 주는 위험을 막는다면, 이건 **청구인이 덜 받는 위험**을
막는다. 부지급도 사고지만 과소지급도 사고다.
→ [notes/028](notes/028-lower-bound.md)
**가드레일은 한 방향으로만 움직인다.** 여섯 다 판정을 보수적인 쪽으로만
바꾼다. 방향을 한 번 뒤집었다 — "불확실한 면책으로 부지급을 내지 않는다"로
걸었더니 코드로 확정된 부지급까지 사람에게 넘어갔다. 위험한 방향은 반대다.
→ [notes/009](notes/009-agent-orchestration.md)
**집계로 답할 수 있는 질문과 없는 질문이 갈렸다.** p50/p95와 카운터는
처음부터 있었는데, 그것이 답하는 것은 *"HUMAN_REVIEW가 몇 건인가"*였다.
심사자가 묻는 것은 *"**이** 건이 왜 HUMAN_REVIEW인가"*다. 그래서 한 건마다
트레이스를 열고 `Adjudication`에 `trace_id`를 실었다.
```
trace dce9cb39bda9 T-1 기본형 실손의료보험(급여 실손의료비) 총 16.29ms
보장탐색:버전확정 0.04ms 가입일 2026-07-01 -> 약관 20260506
면책검증 6.26ms 면책 31건 전부 검토 -> 확실 0, 불확실 0
금액산정 0.04ms … 올해 기지급액을 확인하지 못해 상한이다
검증/심판 0.00ms before=PARTIAL after=HUMAN_REVIEW
guardrails=amount_upper_bound
```
마지막 줄이 없던 것이다 — 판정을 바꾼 것이 무엇인지 한 줄로 나온다.
**그리고 재 놓은 수치를 지키게 했다.** 테스트 474개가 CI에서 도는데 면책
recall이 32.9%에서 20%로 떨어져도 초록이었다. 기준선을
`eval_baselines.json`에 적고 게이트가 비교한다.
게이트의 설계 문제는 정확도가 아니라 **정직함**이었다. 측정 대부분이
그래프·색인을 요구하는데 CI에는 없으므로, 검사가 조용히 0건 돌고 초록이
되기 쉽다. notes/033에서 이미 밟은 함정이다. 그래서 게이트가 셋으로
답한다 — **통과 / 실패 / 못 잼**.
```
**못 잰 항목 6개가 있다.** 건너뛴 것은 통과한 것이 아니다.
...
잰 것 안에서는 회귀가 없다. 못 잰 것은 여전히 모른다.
```
잡는지도 확인했다. 권한 필터 한 줄을 일부러 지우니 `leaks_after_gate`가
**0 → 515**로 실패하고 나머지 여덟 지표는 흔들리지 않았다.
**게이트를 만들다 실제 버그 둘을 잡았다.** `principal`을 키워드 필수로 바꾼
뒤 `rag/evaluate.py`의 호출 아홉 곳을 전부 빠뜨렸는데, 그 파일은 단위
테스트가 없어 게이트를 돌리기 전까지 아무도 몰랐다. 시그니처 검사는
**정의만** 보므로 호출부를 AST로 훑는 테스트를 더했고, 그게 돌자마자 둘째를
잡았다. → [notes/041](notes/041-tracing-and-regression.md)
**지연은 캐시를 비싼 호출에 걸어 잡았다.** 청구 1건당 15.8ms → 1.3ms(-92%).
변별어 사전 계산은 이미 캐시돼 있었는데 그 앞의 조회가 캐시 밖이라, 청구마다
조문 452개를 Neo4j에서 다시 긁어 왔다. → [notes/011](notes/011-latency.md)
**조문 파서는 라벨 대신 같은 문서를 두 번 읽어 채점했다.** 458개에 손으로
라벨을 다는 대신, 같은 별표15의 **PDF 조판본 492쪽**을 레이아웃으로 읽어
평문 경로와 맞댔다. 두 결과가 다르면 둘 중 하나가 틀린 것이라, 라벨 없이
전수 채점이 된다.
| | |
|---|---|
| 조문 재현율 | 458/458 = **100%** |
| 제목 불일치 | **0건** |
| 본문 일치(공백 무시) | 377/458 = 82.3% |
| 표 안의 면책 사유 일치 | 332/351 = **94.6%** |
| 쪽 번호 확보 | **458/458** |
**개수로는 안 드러나는 결함이 표에서 나왔다.** 표의 행 구분선으로 `┣╋┫`만
보고 있었는데, 이 문서가 더 자주 쓰는 건 굵기만 다른 `┠╂┨`(1,708+1,448회
대 444회)다. 거기서 행을 안 끊으니 아래위 두 행이 합쳐져, **면책 사유 11건이
자기 것이 아닌 보장에 붙어 있었다** — 해외 전용 면책이 국내(급여)에도 달렸다.
사유의 수는 351개로 그대로라 개수를 세는 검사로는 영영 안 보인다.
본문 불일치 81건 중 **66건은 파서가 아니라 평문 원천의 문자 손실**이었다.
XML 평문에는 `?`가 1,088개 있고 그중 줄머리 12개가 항 번호 자리다(`➄`→`?`).
잃은 글자를 PDF에서 베껴 오지 않고 **순서로 되살린 뒤**(④ 다음의 깨진 자리는
⑤다) 조판본으로 검산했다 — **12개 전부 확인**. 추론은 평문 안에서, 검산만
PDF로 한다.
**스캔본으로 오면 얼마나 잃는지도 같은 방법으로 쟀다.** 있는 PDF를 이미지로
구워 글자층을 버리고 OCR로 되읽으면 원래 글자층이 정답이 된다 — CER **9.1%**,
조문 머리글 5/6(300dpi, EasyOCR CPU). 머리글은 살아남지만 글자 100개에 9개가
틀려서, 근거 조항을 인용하는 시스템에는 교정 단계 없이 못 쓴다.
→ [notes/036](notes/036-pdf-layout.md)
## 규모
| | |
|---|---|
| 약관 조문 | 1,798 (버전 4 × 상품 16) |
| 면책 조문 / 사유 | 68 / 1,446 |
| 조문 간 참조 | 1,592 (해석률 98.3%) |
| 벡터 청크 | 1,689 |
| KCD 코드 / 용어 열쇠 | 21,299 / 27,113 |
| 평가셋 | 면책 recall 18문항, 실제 분쟁 13건, Tier A 라벨 50건, Tier B 분쟁 유형 68건 |
| 테스트 | 474 |
## 돌려 보기
Docker와 [uv](https://docs.astral.sh/uv/)가 필요하다.
```bash
docker compose up -d # Neo4j 7475, pgvector 5434
cp .env.example .env # LAW_API_OC를 채운다
uv run python -m clausegraph.law.collect --limit 10 # 표준약관 수집
uv run --extra graph python -m clausegraph.graph.load_cli # 그래프 적재
uv run --extra graph python -m clausegraph.agents.cli # 심사 데모
```
`LAW_API_OC`는 [국가법령정보 공동활용](https://open.law.go.kr)에서 무료로
발급한다(승인 1~2일). 자세한 실행 방법은 `docs/`를 본다.
- [docs/mcp-setup.md](docs/mcp-setup.md) — MCP 서버를 Claude Desktop에 붙이기
- [docs/local-llm.md](docs/local-llm.md) — 로컬 LLM 서빙
- [docs/data-sources.md](docs/data-sources.md) — 데이터 출처와 이용조건
## 데이터 출처
**수집한 데이터는 리포에 담지 않는다.** `data/`와 `models/`는 git에서
제외돼 있고, 아래 출처에서 직접 받는 수집기만 들어 있다. 리포에 있는 것은
평가에 쓴 **라벨 파일**(직접 판독한 것)과 사례 목록뿐이다.
| 데이터 | 출처 | 이용조건 |
|---|---|---|
| 보험 표준약관 | [국가법령정보 공동활용](https://open.law.go.kr) — 보험업감독업무시행세칙 별표15 | 비상업적 이용, 출처표시 |
| 분쟁조정사례 | [금융감독원](https://www.fss.or.kr/fss/job/fncCnflCase/list.do?menuNo=201195) | 출처표시 |
| KCD 코드표 | [공공데이터포털](https://www.data.go.kr/data/15067467/fileData.do) — 건강보험심사평가원 상병마스터 | 공공저작물 제1유형(출처표시) |
임베딩은 [BAAI/bge-m3](https://huggingface.co/BAAI/bge-m3), 로컬 LLM은
[Qwen3-4B](https://huggingface.co/Qwen/Qwen3-4B-GGUF)(Apache-2.0)를 쓴다.
모델 파일도 리포에 담지 않는다.
## 한계
포트폴리오로 만든 것이고, 실제 지급심사에 쓸 수 있는 상태가 아니다.
- **판정은 보조다.** 근거 조항을 특정하지 못하면 결론을 내지 않고, 확인 못 한
면책이 남으면 지급하지 않는다. 최종 결정은 사람이 한다.
- 약관을 **최근 4개 버전**만 수집했다. 그 전 가입자는 판정하지 않고 거절한다.
- 금액산정은 급여 실손과 비급여 특약1·2(3대비급여·MRI 포함)까지 채웠다.
올해 기지급액과 통원 횟수는 **청구가 들고 와야** 하고, 없으면 지급액이
아니라 상한만 답한다. 약관 값이 비어 있는 자리는 이제 없고, 남은 것은
**계약별 청구 이력을 시계열로 들고 있어야 판정되는 한도**다 — 3대비급여
연 50회, 통원 "1회"의 정의, 제5조 ⑥의 계속 통원 180일·90회.
- **조문 단위 판본이 없다.** 부칙이 조문마다 시행일을 따로 정하는 경우
(수집한 26건 중 11개 적용 단위)에는 한 계약에 두 판본이 걸리는데, 조문을
판본에 매어 둔 구조로는 "제11조만 옛 것"을 표현할 수 없다. 버전을 옮기지
않고 그 사실을 답에 실어 사람에게 넘긴다.
- 실제 분쟁 13건에서 정답 조항을 짚은 것은 8/10이고, 면책이 아닌 3건 중
2건에 오발동한다. 못 잡는 둘은 청구서의 말(`한방병원`)과 약관의 말
(`한방치료`)이 달라서, 그리고 면책 세부 항목이 조문 본문이 아니라 `목`에
있어서다.
- 용어→코드 변환이 면책 적중 5/8이다. 못 잡는 셋은 청구서의 말과 KCD 공식
표기가 달라서다.
- Tier A 라벨 50건은 세션 안에서 판독한 것이며 재검수 전이다. Tier B는
검수하면서 규칙을 고쳤으므로 "규칙 정확도"를 주장하지 않는다 — 무엇을
고쳤는지만 남겼다.
- 표준약관은 **표준**이다. 실제 상품 약관은 회사마다 다르다.
전체 설계 근거와 측정 기록은 `notes/`에 042번까지 있다.
## 라이선스
코드는 [MIT](LICENSE). 위 데이터 출처의 이용조건은 각 기관의 것을 따른다.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues