실패는 열쇠에서 났다 — 사실탐색기 v2를 실제 공시 2,851건과 부산 기록 362건에 돌려 보니
합성 코퍼스에서 모든 질문을 맞힌 사실탐색기 v2를 실제 기록에 넣었다. 2026년 DART 배당 공시 2,742건과 뉴스 109건, 부산시의회 회의록·고시공고·조례 공포문·현행 조례 362건이다. 깨진 곳은 권위를 따지는 AGS가 아니라 그 앞의 '열쇠'였다. DART에서 정정이 아닌 대체 305건 가운데 304건이 열쇠 오류였다. 분기배당 네 번이 한 열쇠가 되었고, 지주사가 자회사 배당을 다시 낸 공시가 주체를 바꿨고, 서로 다른 조례의 '이 조례'가 시행일을 서로 덮었다. 원칙 하나 — 기록 밖에서 뜻이 정해지지 않는 주어는 그 기록의 이름으로 좁힌다 — 와 측정으로 고른 회차 간격 45일·연도 오기 문턱 180일, 그리고 '설명되지 않는 대체'라는 건강 신호로 부산의 숨은 버그 94건을 6건까지 줄인 과정을 위젯 4개로 따라간다.
코어닷투데이2026-10-2544분
프롤로그 — 합성 코퍼스에서는 다 맞았다
1편에서 설명한 사실탐색기 v2는 합성 코퍼스에서 흠잡을 데가 없었다. 부산 시나리오 18문항, 기업 공시 8문항을 모두 맞혔다. 최신 우선 기억이 틀린 낡은 사실을 하나도 답하지 않았고, 기권해야 할 때 정확히 기권했다. 회귀 테스트는 218건이었다.
합성 코퍼스는 설계자가 상상한 실패만 담는다. 그래서 10월 22일부터 24일까지 실제 기록을 모아 넣었다. 부산광역시의회와 부산시의 공적 기록 362건, 그리고 2026년 한 해(1~10월) DART 배당 공시 2,742건과 관련 뉴스 109건이다.
결과부터 말하면, 권위 격자와 발화행위를 따지는 AGS 자체는 거의 깨지지 않았다. 깨진 곳은 그 앞이었다. AGS의 첫 번째 조건 C1, "같은 열쇠인가"다.
열쇠는 (주어, 술어)다. "지누스 결산배당의 배당기준일", "청년 월세 지원 조례의 시행일"이 열쇠다. 열쇠가 너무 넓으면 서로 다른 사실이 한 열쇠에 모여 서로를 덮는다. 너무 좁으면 같은 사실이 흩어져 정정이 원본을 닫지 못한다. 어느 쪽이든 C2~C5가 아무리 정확해도 소용이 없다. 엉뚱한 상대와 비교하고 있기 때문이다.
이 글은 그 열쇠를 바로잡은 기록이다. 무엇을 모았는지, 추출에서 무엇을 배웠는지, 열쇠가 어떻게 깨졌는지, 그리고 그 깨짐을 찾아내는 지표를 어떻게 만들었는지를 차례로 다룬다.
1. 무엇을 모았나
부산 — 한 도시의 공적 기록 362건
부산 코퍼스는 지방의회의 네 장르를 모두 담도록 모았다. 출처마다 함정이 있었다.
장르
건수
권위
수집의 함정
본회의 회의록
13건 (발언 약 1,280개)
1인칭
의원은 링크, 시장·국장은 링크 없는 태그 — 둘 다 같은 블록을 순서대로 갈라야 발언 교대가 나온다
입법예고
59
1인칭
2026년 4월 이후만 HTML, 그 전은 시보 PDF뿐
고시·공고
106
공식
공시송달·과태료 공고는 제목으로 제외
보도자료
36
1인칭
본문은 첨부 hwp에만 있고 페이지에는 요점만
뉴스
24
전언
Google News 링크를 원문 URL로 되돌리는 단계가 자주 실패
조례 공포문
68
공식
시보 PDF의 글자가 벡터 그림이라 텍스트 층이 비어 있음 (2절)
현행 조례 전문
56건 (조문 1,248개)
공식
붙여 쓴 검색어는 못 찾음, 제명이 바뀐 조례 12건 미발견
DART — 2026년 배당 공시 전부
기업 공시는 별도 수집기 dartkit을 만들어 모았다. OpenDART 키 없이 DART 공개 화면을 읽는 표준 라이브러리 수집기다. 2026년 1월 2일부터 10월 22일까지 영업일 210일의 공시 목록 175,553건을 색인했고(요청 1,862회, 거부 0회), 그중 배당결정 공시 1,962건과 배당 기준일 결정(주주명부폐쇄) 공시 762건을 본문과 함께 받았다.
수집 원칙
공개 화면의 자동 수집은 이용약관상 회색 지대다. 0.7초 간격, 백오프, 캐시를 두고 연구 규모로만 돌렸다. 상시 운영이라면 OpenDART API 키로 바꿔야 한다. 수집기는 이 전환을 염두에 두고 목록·본문 층을 나눠 두었다.
수집기를 따로 만든 데에는 이유가 있다. 처음에는 사실탐색기 안에 간단한 DART 수집 함수를 두었는데, 목록 정규식이 표의 행 경계를 넘어 옆 행의 회사명을 붙이고 있었다. 첫 수집분 70건 가운데 12건(17%)의 회사명이 틀렸다. 하이비젼시스템의 공시가 '하나머티리얼즈'로 들어와 있었다. 사실을 유지하는 시스템에 들어가는 기록이 이 정도로 틀리면 그 뒤의 모든 단계가 의미가 없다. 별도 수집기 dartkit은 목록·본문·관련 공시를 층으로 나누고 픽스처 테스트를 붙였다. 1차 기록은 모두 이 판으로 덮어썼다.
2. 추출 — 기록의 모양이 추출기를 고른다
설계는 "LLM이 모든 기록을 추출하고 코드가 수리한다"였다. 실측 뒤에는 "기록의 모양에 따라 추출기를 고른다"로 바뀌었다.
로컬 27B와 GPU 클러스터
처음 계획은 사내 게이트웨이로 Claude를 부르는 것이었지만 테스트 지갑이 바닥나 있었다. 로컬 모델로 바꿨다. 사내 쿠버네티스 클러스터의 A100을 MIG 조각 3개(80GB 1개, 40GB 2개)로 나눠 Ollama에 qwen3.8:27b를 올렸다. 이 모델은 A100에서도 단일 스트림이 초당 7.5토큰에 그쳤다. 그래서 처리량은 병렬 슬롯으로 얻었다. A100 세 조각에 8+6+6, V100에 4, 맥에 1, 모두 25 스트림이다. 부산 1,919단위를 약 1시간에 끝냈다.
그런데 결과가 거칠었다. 이벤트를 1.4배쯤 잘게 쪼갰고, 인용 스팬(근거 위치)이 원문과 맞지 않아 강등된 것이 많았다.
서브에이전트로 Claude 쓰기
다음으로 Claude Code의 서브에이전트에게 1단계 추출을 맡겼다. 추출할 단위를 25개씩 파일로 쓰고, 서브에이전트가 같은 프롬프트로 결과 파일을 쓰면 코드가 합친다. 에이전트마다 다른 파일을 쓰므로 경쟁이 없다. 같은 회의록 25단위로 비교한 결과는 이랬다.
Opus
78 이벤트 · 25/25
Haiku
69 · 25/25
Sonnet
57 · 25/25
qwen3.8 27B
31 · 16/25, 스팬 강등 27
바는 최댓값 78 기준이다. '25/25'는 이벤트를 하나 이상 낸 단위 수다. Claude 세 모델은 스팬 강등이 0이었고, Opus와 Sonnet은 국장의 "수요가 생기면 다시 편성하겠다"는 약속까지 잡았다. 회의록 1,283단위를 Sonnet 52배치로 다시 뽑았다(배치당 약 1분, 전체 약 25분). 회의록 명제는 214개에서 478개로, 약속 명제는 17개에서 36개로 늘었다.
추출기끼리 얼마나 일치하나
논문의 예비 실험은 사람이 주석한 금본위와 비교했지만, 이번 기록에는 금본위가 없었다. 대신 같은 25단위를 독립으로 뽑은 네 추출기 사이의 일치도(Cohen κ)를 쟀다. "이 단위의 이 슬롯(누가·언제·무엇을…)을 채웠는가"의 일치다.
Haiku – Sonnet
κ 0.95
Opus – Haiku
0.77
Opus – Sonnet
0.72
Claude – 27B
0.38~0.46
슬롯을 채우는지는 Claude끼리 잘 맞았다. 그러나 채운 값의 표현은 추출기마다 달랐다. 값이 겹치는지(음절 bigram 0.5 이상)로 재면 κ는 0 근처였고, (주어, 술어, 값)이 완전히 같은 경우는 쌍마다 1~14%였다. 회의록 발언처럼 긴 값은 어디서 자르느냐가 추출기마다 다르다. 이것이 뒤에서 열쇠 문제로 이어진다. 열쇠의 주어는 결국 추출기가 쓴 문자열이기 때문이다.
표는 표로 읽는다
DART 공시는 사정이 다르다. 배당결정 공시는 "1. 배당구분 / 결산배당", "3. 1주당 배당금(원) / 보통주식 / 1,000" 같은 번호 항목 표다. 표를 그대로 읽는 결정론 파서를 만들어 이미 Sonnet으로 뽑아 둔 551건과 비교했다. 사실 술어(주당배당금·시가배당률·배당금총액·기준일·지급일·주총일·이사회결의일·배당구분·정정사유)의 일치율이 97.0%였다. 차이는 대부분 Sonnet이 배당구분을 주어에 녹여 따로 쓰지 않은 경우였다. AGS 결과도 같았다. 그래서 새로 들어온 2,191건은 LLM 없이 처리했다.
표 파서에도 버그가 있었다. 유니코드 정규화(NFKC)가 공시 제목의 'ㆍ'(U+318D)를 옛한글 아래아 'ᆞ'(U+119E)로 바꿔 놓아, "현금ㆍ현물배당결정" 표 제목을 찾지 못했다. 그 바람에 정정 머리 파서가 표 끝까지 먹어 들어갔다. 사건 수가 10,408에서 6,960으로 줄고 나서야 알아챘다.
글자가 그림인 PDF — 벡터 글리프 복원
가장 손이 많이 간 것은 부산시보의 조례 공포문이었다. 공포문 PDF에는 글꼴이 하나도 없었다. 글자 하나하나가 채워진 벡터 경로로 그려져 있었다. pdftotext로 뽑으면 빈 문자열이 나온다.
처음에는 300dpi로 렌더해 tesseract로 OCR했다. 제목처럼 큰 글자는 깨끗했지만, 본문 작은 글자에서는 '날부터'가 'GHEE'로, '일부'가 '얼부'로 바뀌었다. 1,000자당 라틴·기호 잡음이 7~12개였고, 이 잡음이 값에 그대로 스며들었다.
그런데 같은 글자는 PDF 안에서 같은 윤곽으로 그려진다. 이 성질을 이용했다.
윤곽 모으기한 줄은 PDF 연산 한 덩어리, 글자 하나는 서브패스 한두 개다. 좌표가 위치마다 ±0.001pt씩 흔들려 좌표 해시는 깨진다(84,416가지). 그래서 연산 순서열(m l c c … h)로 먼저 통을 나누고, 좌표 벡터를 0.03pt 허용오차로 비교한다.
투표로 배우기OCR이 준 글자 상자 안의 윤곽 = 그 글자. 68건 265쪽에서 상자 106,904개를 모아 윤곽 4,704가지에 글자 614종을 투표로 붙였다. OCR이 어떤 자리에서 틀려도 다른 자리의 다수표가 이긴다.
OCR 없이 읽기이제 윤곽 순서열을 표에 맞춰 가며 읽는다. 표에 없는 윤곽은 '�'로 남긴다. 추측하지 않는다.
결과잡음 1,000자당 11.89 → 3.55, 미지 글자 2.9%. '이 조례는 공포한 날부터 시행한다'가 68건 모두 글자 그대로 복원됐다.
한 번 미끄러진 일도 있었다. 처음에는 "투표가 둘 이상이면 동률"로 처리했더니, 4표짜리 '7년' 윤곽이 822표짜리 '7'을 이겨 "2027 1 1일부터"가 나왔다. 투표 수를 5에서 포화시켜 비교하도록 바꾸고서야 "2027년 1월 1일부터"가 돌아왔다. 질문 회귀 세트가 이것을 잡았다.
3. 첫 번째 경고 — 가짜 정정 쌍
열쇠 문제의 첫 신호는 DART에서 왔다. 정정 공시가 원 공시를 닫은 사례를 세어 보니 33건이었다. 그중 BNK금융지주의 주당배당금이 621.02원에서 858.59원으로 "정정"된 것이 눈에 띄었다. 정정 폭이 너무 컸다.
원문을 열어 보니 정정이 아니었다. 2월 3일 공시는 자회사 부산은행의 배당(621.02원)이었고, 2월 4일 공시는 자회사 경남은행의 배당(858.59원)이었다. 둘 다 BNK금융지주가 "자회사의 주요경영사항"으로 낸 공시라 추출기가 주어를 모회사로 썼고, 그래서 한 열쇠가 되었다. 첫 수집기는 목록에서 "같은 회사, 같은 보고서명"으로 정정 쌍을 짝지었기 때문에 이것을 정정 쌍으로 묶어 버렸다.
고친 것은 두 가지다.
주어는 실제 주체로. 자회사 공시는 본문의 "자회사인 X의"에서 X를 주어로 쓴다. 주어를 "회사 + 배당구분 + 배당" 한 꼴로 맞추자 진짜 정정만 남았다(33 → 21).
연결은 추측하지 않는다. DART 공시 화면에는 원본과 정정을 나란히 보여 주는 '관련 공시' 목록이 있다. dartkit은 이 목록만 쓴다. 정정 공시 250건 모두가 이 목록으로 원본에 연결됐고, 추측으로 만든 쌍은 0이다.
"출처가 준 연결만 쓴다"는 이 원칙은 설계 문서에 없었다. 설계에는 "정정 쌍을 찾는다"고만 적혀 있었다.
4. '이 조례'는 어느 조례인가
부산에서는 더 노골적인 꼴로 나타났다. 조례 공포문 68건을 처음 넣었을 때, 서로 다른 조례의 시행일이 서로를 대체하고 있었다.
공포문은 거의 모두 "이 조례는 공포한 날부터 시행한다"로 끝난다. 추출기는 성실하게 주어를 '이 조례'로 썼다. 그 결과 68개 조례의 시행일이 모두 ('이 조례', 시행일)이라는 한 열쇠에 모였다. 모두 공식 기록이니 C4가 성립했고, 뒤에 공포된 조례가 앞 조례의 시행일을 닫았다. 현행 조례 전문 56건을 넣었을 때도 같은 일이 났다. 조문의 '이 조례'가 공통 엔티티가 되어 시행일 92개가 서로를 대체했고, 그때까지 맞히던 질문 23문항 가운데 3문항이 틀렸다.
'이 조례'만의 문제가 아니었다. 나중에 건강 신호(6절)로 찾아낸 것까지 모으면 이렇다.
꼴
예
처리
지시어
'이 조례', '본 규칙'
기록 제목으로 바꾼다
조문 번호
공포문의 '제7조', '별표'가 서로 다른 조례끼리 대체
제목 + 번호
'이 조례의 X'
'이 조례의 용어' 정의가 조례끼리 대체
제목 + X
시 행위자·짧은 역할
'부산광역시장 책무', '위촉위원 임기', '소위원회 수량'
조례·공포문에서는 제목 + 주어 (이름 있는 '규제합리화위원회'는 그대로)
행위 명사
공고의 '신청'이 끝 글자 '청' 때문에 기관(○○청)으로 취급되어 다른 공고끼리 섞임
행위 명사 사전(신청·접수·모집·선정…) → 제목 + 주어
속성 이름
'지원금액'이 주어 엔티티로 들어와 질문의 술어와 겹쳐 가짜 열쇠를 만듦
속성 이름 엔티티는 주어 후보에서 뺀다
자회사·중계 공시
BNK금융지주 아래 부산은행·경남은행, 진양홀딩스 아래 진양AMC
실제 주체를 주어로 (5절)
규칙은 일곱 줄이지만 원칙은 하나다.
!
열쇠의 원칙
기록 밖에서 뜻이 정해지지 않는 주어는 그 기록의 이름으로 좁힌다. '이 조례', '제7조', '신청', '부산광역시장의 책무'는 그 기록 안에서만 무엇을 가리키는지 정해진다. 그런 주어를 기록 밖으로 꺼내 열쇠로 쓰면, 같은 글자를 쓴 남의 사실과 만난다.
좁히다 보면 반대 방향의 실패가 생긴다. '신청'을 공고 제목으로 좁히자 재공고가 원 공고를 닫지 못했다. 원 공고는 "… 모집 공고"이고 재공고는 "… 모집 재공고"라서, 제목을 붙인 열쇠가 서로 달라졌기 때문이다. 재공고가 신청 기간을 바꾸는 것은 AGS가 잡아야 할 진짜 대체다. 이 단계에서 재공고 닫기가 14건에서 8건으로 줄었다. 그래서 제목을 붙이기 전에 '재공고', '(변경)', 'N차 변경 공고' 같은 표지를 떼어 줄기만 쓰도록 했고, 14건을 되찾았다. 아래 위젯에서 규칙을 하나씩 켜 보면 이 두 방향의 실패가 모두 보인다.
5. 어느 배당인가 — 회차
DART 2,851건을 한 번에 넣자 정정도 아니고 기준일 결정도 아닌 대체가 305건 나왔다. 하나씩 열어 보니 304건이 열쇠 오류였다. 원인은 네 가지였다.
분기배당 — 같은 열쇠, 다른 사건
신한지주는 분기마다 배당한다. 1분기 배당의 기준일은 4월 30일, 2분기는 7월 30일이다. 열쇠가 ('신한지주 분기배당', 배당기준일)이면 2분기 기준일이 1분기 기준일을 '대체'한다. 7월에 "1분기 배당 기준일이 언제였지?"라고 물으면 7월 30일이라고 답하게 된다. 리츠도 같았다. 분기 말이 지나고 한 달 남짓 뒤에 배당을 결정한다.
열쇠에 회차가 필요했다. 그런데 공시에는 "몇 회차"라는 칸이 없다. 보고서명에 "(2026년 1분기)"를 붙이는 회사도 있지만 일관되지 않다. 그래서 날짜로 가른다.
같은 (회사, 갈래)의 기준일을 날짜순으로 늘어놓고, 간격 G보다 멀면 다른 배당, 가까우면 같은 배당의 기준일 변경으로 본다. 같은 배당 안의 변경은 대체가 맞다. G를 얼마로 둘지는 측정으로 정했다.
같은 배당 안에서 기준일이 바뀐 폭: 최대 11일(인피니트헬스케어가 정정 공시로 10월 23일을 10월 12일로 당겼다)
같은 회사의 서로 다른 배당 사이 간격: 최소 61일
11과 61 사이면 어느 값이든 이 코퍼스를 바르게 가른다. 가운데쯤인 45일을 골랐다. 기준일이 '-'로 비어 있는 배당결정은 결정일 뒤에 오는 가장 가까운 회차에 붙인다. 정정 공시는 원본(정정 사슬의 첫 공시)의 회차를 물려받는다.
연도 오기 — 회차를 가로막는 한 글자
회차를 나누자 새 문제가 보였다. 지누스는 2026년 2월 6일 배당결정 공시에 기준일을 2025-04-03으로 적었다. 같은 날 낸 기준일 결정 공시와 같은 날의 정정 공시는 2026-04-03이다. 연도를 잘못 쓴 것이다. 그런데 2025-04-03과 2026-04-03은 365일 떨어져 있으니 회차 규칙은 이것을 두 개의 배당으로 갈랐다. 정정 공시는 원본의 열쇠를 물려받는데, 원본이 오기 쪽 회차에 있으니 정정이 엉뚱한 회차에 붙었다.
처음 떠올린 규칙은 "기준일이 결정일보다 앞서면 오기"였다. 틀린 규칙이었다. 리츠는 기준일이 지난 뒤에 배당을 결정하는 것이 정상이다(SK리츠: 3월 31일 기준일, 5월 6일 결정). 그래서 분포를 쟀다.
정상 기준일이 결정일보다 앞선 최대: 75일(리츠)
연도 오기가 결정일보다 앞선 폭: 309~365일(지누스, JW중외제약, 미원에스씨, 동화약품, 젝시믹스)
그 사이의 180일을 문턱 S로 골랐다. 결정일보다 180일 넘게 앞선 기준일은 오기로 본다. 처리는 신중하게 했다.
값은 고치지 않는다. 2025-04-03을 2026-04-03으로 바꾸는 것은 추측이다. 원문 그대로 둔다.
회차 판단에서만 뺀다. 오기는 무리를 만들지 않고, 결정일 뒤 가장 가까운 회차에 붙는다.
명제를 만들지 않는다. 그 값은 클레임으로만 남고, 리뷰 큐에 implausible_anchor 항목을 연다.
정정의 결정일은 원본의 공시일이다. 2024년 배당의 정정이 2026년에 들어와도(인바이츠바이오코아의 정정 사슬) 오기로 보지 않는다.
오기는 13건이 잡혔다(미원에스씨 4, JW중외제약 4, 지누스 2, 동화약품 2, 젝시믹스 1). 모두 같은 회사의 기준일 결정이나 정정 공시가 옳은 날짜를 갖고 있었다. 새 질문 두 개를 더했다. "지누스 결산배당 기준일은?"에는 2026-04-03을, "JW중외제약 결산배당 기준일은?"에는 2026-04-01을 답한다.
아래 실험실에서 실제 공시 여섯 묶음으로 G와 S를 움직여 보자. 두 상수의 안전 구간이 눈에 보인다.
지주사 중계 — 같은 표, 다른 제출인
세 번째 원인은 지주사였다. 진양AMC의 배당을 모회사 진양홀딩스가 "자회사인 진양AMC의 배당"으로 다시 공시하고, 그 모회사 KPX홀딩스가 같은 표를 "자회사인 진양홀딩스"라고 적어 또 낸다. 주어 정규화가 꼬이자 진양AMC의 2,004원과 진양물산의 511원이 진양홀딩스의 150원을 대체했다.
처리 규칙은 이렇다. 같은 날, 같은 표 지문(배당구분·보통주 주당배당금·총액·기준일)을 가진, 제출인이 다른 공시는 하나의 배당이다. 주어는 회사 자신의 공시가 있으면 그 회사로, 없으면 첫 접수의 회사로 쓴다.
감액배당과 '결산'
네 번째는 작은 것들이다. 자본준비금을 감액해 하는 배당은 같은 날 같은 구분이어도 다른 배당이다(와토스코리아). 기준일 결정의 배당구분 '결산'은 배당결정의 '결산배당'과 같은 것으로 맞췄다. 같은 배당을 배당결정은 '분기'로, 기준일 결정은 '중간(분기)'로 적는 경우가 많아 중간과 분기는 한 갈래로 묶었다.
회차 규칙은 수단 표의 열이다
G=45와 S=180은 처음에 배당 처리 코드 안의 상수였다. 이것을 일반 모듈로 빼고, 값은 수단 표의 회차 규칙으로 옮겼다. 1편에서 수단 표가 장르와 권위·효력일 규칙을 잇는다고 했는데, 여기에 '어떤 날짜로 회차를 가르고, 간격과 오기 문턱은 얼마인가'라는 열이 하나 더 생긴 것이다. 측정 근거도 그 행에 문장으로 적혀 있고, 생성 문서에 그대로 나온다. 의회 회기, 추경 예산 차수, 공고 회차도 규칙 한 줄만 더하면 같은 함수가 처리한다.
6. 체온계 — 설명되지 않는 대체
여기까지 고치고 나니 질문이 하나 남았다. 이런 버그를 어떻게 미리 찾을 것인가. DART의 305건은 운이 좋아 눈에 띄었다. 회귀 질문은 우리가 만든 질문만 확인한다. 질문이 닿지 않는 곳에서 사실이 서로를 덮고 있으면 알 길이 없다.
답은 AGS의 성질에 있었다. AGS에서 대체는 드문 사건이고, 대체에는 이유가 있어야 한다. 그래서 대체된 명제마다 닫은 쪽 기록을 보고 이유를 붙이는 분류기를 만들었다.
cross_instrument다른 수단이다. 예: 기준일 결정이 배당결정의 기준일을 바꿈.
renotice제목에 재공고·변경·연장·수정·정정·추가가 있다.
unexplained어느 것도 아니다. 열쇠 버그일 가능성이 높다.
same_record와 unexplained의 합이 건강 신호unexplained_supersessions다. 이 값이 문턱을 넘으면 실패하는 CI 게이트(--max-unexplained N)도 붙였다.
신호를 처음 켰을 때 DART는 1이었다. 앞에서 정정으로 설명되지 않는 대체 305건을 하나씩 찾아 고친 결과다(305 → 113 → 36 → 4 → 1). 이 마지막 1건도 회차·오기 규칙을 정리한 뒤 0이 됐다. 놀란 것은 부산이었다. 부산은 94였다. 그때까지 부산 질문 25문항을 모두 맞히고 있었는데도 그랬다. 4절 표의 아래쪽 절반(조문 번호, '이 조례의 X', 시 행위자, 행위 명사, 재공고 줄기)이 모두 이 신호로 찾아낸 것이다. 원인을 하나 고칠 때마다 신호는 94 → 54 → 27 → 13 → 6으로 내려갔다. 목록 값 버그도 하나 있었다. 변경 공고가 같은 제출서류 목록을 되풀이하자 목록 항목끼리 차례로 앞 항목을 닫았다. 공존 판정을 "근거 기록 집합이 같으면 공존"에서 "두 값을 함께 말한 기록이 하나라도 있으면 공존"으로 바꿨다.
부산 전
부산 후
DART 전
DART 후
대체 전체
119
31
242
238
설명되지 않음 (+같은 기록)
82 + 12
6 + 0
1
0
정정 / 다른 수단 / 재공고
– / 11 / 14
– / 11 / 14
241 / – / –
237 / 1 / –
반박 큐
7
4
46
32
연도 오기 리뷰
–
–
–
13
명제
2,711
2,727
16,368
16,355
질문
25/25
25/25
14/14
16/16
부산에 남은 6건은 열쇠 버그가 아니다. 표지 없이 같은 제목으로 다시 낸 공고 4건(반여·엄궁 중도매인 모집, 수산가공단지)은 진짜 재공고지만 제목만으로는 알 수 없다. 나머지 2건은 C3가 같은 값을 다르다고 본 경우다('가결'과 '원안대로 가결', '이 조례는 2024년 7월 1일부터 시행한다'와 '2024년 7월 1일부터'). 그래서 게이트 값은 코퍼스마다 다르게 둔다. DART는 0, 부산은 6이다. 새 코퍼스를 넣을 때 이 숫자가 오르면, 질문을 하나도 틀리지 않았더라도 열쇠부터 의심한다.
7. 엄격한 열쇠층, 느슨한 사안층
열쇠를 좁히는 이야기만 하면 오해가 생긴다. 회의록에는 반대 문제가 있다. 같은 사안을 서른 가지로 부른다. '사직야구장 재건축', '사직야구장 재건축 시비', '사직 재건축 문제', '사직야구장사업', '사직야구장 국비 299억 반납'이 모두 같은 사안이다.
처음 떠오르는 해법은 비슷한 주어를 하나의 엔티티로 합치는 것이다. 하지만 그렇게 하면 AGS가 깨진다. '재건축 방향'의 상태와 '재건축'의 상태는 다른 사실이다. 한 엔티티로 합치면 서로를 대체한다. 그래서 층을 둘로 나눴다.
열쇠층(엄격): AGS가 비교하는 (주어, 술어, 회차). 합치지 않는다. 병합 후보는 사람이 결정한다.
사안층(느슨): 사람이 읽는 이슈 카드. 주어의 첫 토큰이 닻(셋 이상의 서로 다른 주어가 공유하는 머리 토큰)이면 같은 카드에 모은다.
사안층에서 사직야구장 관련 주어 22개가 한 카드(7/24~9/11, 링크 48)로 모였다. 이 층도 두 번 조였다. '해양'(해양수도특별법·해양농수산국·해양정책)이나 '교육' 같은 분야어가 닻이 되어 서로 다른 사안을 묶었기 때문이다. 또 접두 규칙이 '어린이 통학로 종합안전대책'을 '어린이복합문화공간' 조례에 붙여 가짜 이행 사례를 하나 만들었다. 꼬리가 장소 접미어(야구장·동·청)일 때만 접두 규칙을 쓰도록 바꿨다.
약속 추적의 정직한 0
사안층이 필요한 이유는 약속 추적이다. 회의록의 약속 75건은 "사직야구장 재건축 9월 안에", "건립 후보지 9월 내지 10월 중 선정"처럼 대부분 일정이다. 뒤따르는 기록은 조문이나 사업명이라 (주어, 술어, 값)이 같을 일이 없다. 값 일치로 이행을 판정하면 언제나 0이다. 그래서 두 갈래로 추적한다. 기한은 약속 값을 발화일 기준으로 풀어 구간의 끝을 기한으로 삼는다. 후속은 같은 사안 카드에서 발화 뒤에 나온 집행·제정 기록을 모은다.
결과는 대기 12, 기한 지남 5(사직야구장 재건축 '9월 안에' 등), 기한 없음 58, 후속 0이었다. 0은 정직한 결과다. 코퍼스에 9월 11일 이후 사직야구장 관련 공고나 보도자료가 없다. 앞에서 말한 가짜 이행 1건을 그대로 두었다면 "1건 이행"이라는 그럴듯한 숫자가 나왔을 것이다.
8. 측정이 꺼 버린 것들
실제 코퍼스는 설계에서 기본으로 켜 두려던 장치 몇 개를 껐다.
판정 모델의 과신
텍스트 값이 같은지 다른지(C3)는 로컬 qwen3.5:9b가 판정한다. 확신도 0.8 아래는 '모름'으로 돌리고 리뷰 큐에 올린다. 부산에서 622쌍을 판정했다. 그런데 프롬프트에 "한쪽이 다른 쪽을 포함하면 모름"이라고 적었는데도, 모델은 '부산시청 1층 대회의실'과 '부산시청 1층 대회의실 및 1층 로비'를 다름, 확신도 0.95로 답했다. 문턱 아래로 떨어지지 않는 과신이다. 그래서 판정 모델 앞에 코드 가드를 넣었다. 정규형이 한쪽을 포함하면 모델에게 묻지 않고 '모름'으로 처리한다. "규칙은 프롬프트가 아니라 코드에"라는 원칙 1을 다시 확인한 셈이다.
반박 큐가 많이 쌓이면 문턱을 자동으로 올리는 장치도 만들었는데, 한 번도 움직이지 않았다. 실제 적체율이 부산 0.27%, DART 2% 정도였다. 논문 예비 실험의 15%와는 거리가 멀었다. 큐가 쌓이지 않으니 같은 권위 안의 타이브레이커(1편 12절)도 급하지 않다.
RRF 상수와 재정렬기
슬롯 조회가 못 찾는 바꿔 말한 질문("사직 구장을 다시 짓는 데 드는 국비는?")을 위해 하이브리드 경로를 붙였다. 등록부 정규형과 술어 사전을 로컬 bge-m3로 임베딩해 두고, 슬롯 조회가 실패할 때만 벡터 이웃을 RRF로 섞는다. 부산 23문항에서 슬롯 조회만으로는 18문항, 하이브리드로는 23문항을 맞혔다.
그런데 RRF 상수 k를 10, 60, 200으로 바꿔도 결과는 모두 23이었다. 후보가 몇 개 안 되면 k는 의미가 없다. 영향이 있었던 것은 유사도 문턱이었다(0.75면 20문항, 0.6이면 23문항). 재정렬기는 더 나빴다. 크로스인코더 대신 생성 모델로 후보에 점수를 매기게 했더니, 모델이 네 후보 모두에 1.00을 줘 변별을 하지 못했다. 동점이면 순서만 흔들려 23문항이 22문항으로 떨어졌다. 기본 OFF로 두고, 고른 값과 그 근거를 설정 표에 적었다.
9. 개정 알고리즘 — 일곱 단계
실측 뒤 파이프라인을 다시 썼다. 가장 큰 변화는 네 번째 단계다. 처음 설계에는 "열쇠 만들기"라는 단계가 따로 없었다.
실측이 바꾼 판단을 한 표로 모으면 이렇다.
처음 설계
실측
바뀐 판단
모든 기록을 LLM이 추출, 코드가 수리
정형 공시는 표 파서가 Sonnet과 97.0% 일치, 비용 0
기록의 모양으로 추출기를 고른다
열쇠 = (주어, 술어)
정정이 아닌 대체 305건 중 304건이 열쇠 오류
열쇠 = (주어, 술어, 회차), 열쇠 만들기를 독립 단계로
정정 쌍은 목록에서 찾는다
추측은 자회사끼리 가짜 쌍, 출처의 관련 공시 목록은 250/250
출처가 준 연결만 쓴다
비슷한 주어는 엔티티로 병합
병합하면 '재건축'과 '재건축 방향'이 서로를 대체
엄격한 열쇠층과 느슨한 사안층을 가른다
판정 모델 + 확신도 문턱
포함 관계를 '다름 0.95'로 과신
판정 앞에 코드 가드
RRF 융합·재정렬 기본 ON
k ∈ {10, 60, 200} 결과 같음, 재정렬은 순위만 흔듦
슬롯 조회 먼저, 벡터는 실패 때만, 재정렬 OFF
로컬 27B 추출 + 코드 수리
슬롯 채움 κ: Claude 0.72~0.95, 27B 0.38~0.46
자유 문장은 Sonnet급
(없음)
부산 질문 25/25인데 숨은 대체 94건
건강 신호를 CI 게이트로
10. 지금의 숫자
부산
DART
기록
362
2,851 (공시 2,742 + 뉴스 109)
명제
2,727
16,355
대체 (그중 설명되지 않음)
31 (6)
238 (0)
반박 큐
4
32 (모두 뉴스 대 공시)
교차 지지
입법예고·공포문·현행 조문이 한 카드에 모인 조례 18
배당결정과 기준일 결정이 함께 지지하는 기준일 616
질문 회귀 세트
25/25
16/16
코드 쪽은 회귀 테스트 275건(회차 7건, 열쇠 5건, 목록 공존 1건 추가)이고, 두 코퍼스가 Postgres 한 데이터베이스(pgvector 포함)에 함께 들어 있다. 기록 3,213건, 판본을 포함한 명제 24,555개, 임베딩 6,830개다. 사람이 볼 리뷰 큐는 부산 5건, DART 51건이다.
질문 하나의 운명이 코퍼스에 따라 바뀐 사례도 있다. "명인제약 중간배당은?"은 1차 수집 때는 뉴스만 있어서 기권이 정답이었다. 2차 수집에서 공식 공시가 들어오자 500원으로 답한다. 같은 질문이 기권에서 답으로 바뀌는 것, 이것이 1편에서 말한 허용성의 뜻이다.
11. 남은 것, 그리고 교훈
남은 것
같은 권위 안의 타이브레이커. 근거 수와 취재원 독립성으로 매기는 2차 순서. 적체율이 0.2~2%라 급하지 않다.
사안층 보강. 회의록 주어를 임베딩으로 묶기, 공포문에서 자치법규 ID를 뽑아 제명이 바뀐 조례 추적하기(법령 검색에서 못 찾은 12건).
회차 규칙 확장. 의회 회기, 추경 예산 차수, 공고 회차에 같은 함수를 적용해 실제로 도는지 확인하기.
OpenDART 키. 상시 수집은 공식 API로 옮긴다.
사실을 유지하는 시스템을 만든다면
비교 연산자보다 열쇠를 먼저 의심하라. 대체·병합·무효화 로직이 틀린 것처럼 보이면 대개 열쇠가 틀렸다. 우리 경우 305건 중 304건이 그랬다.
대체마다 이유를 붙이고 이유 없는 대체를 세라. 질문 회귀 세트가 만점이어도 숨은 충돌은 있다. 이 숫자 하나로 부산의 숨은 버그 94건을 찾았다.
기록 안에서만 뜻이 정해지는 말을 찾아라. '이 조례', '제7조', '신청', '자회사인 X'. 이런 말은 그 기록의 이름으로 좁혀야 한다. 좁힌 다음에는 재공고처럼 진짜로 이어져야 할 것이 끊기지 않았는지 확인하라.
상수는 분포에서 골라라. 회차 간격 45일은 11일과 61일 사이에서, 오기 문턱 180일은 75일과 309일 사이에서 나왔다. 골랐으면 근거를 같은 자리에 적어라.
출처가 준 구조를 추측으로 대신하지 마라. 정정 쌍, 재공고, 자회사 관계는 출처가 이미 알려 준다. 추측은 그럴듯한 가짜를 만든다.
기록의 모양이 추출기를 정하게 하라. 표는 파서로, 자유 문장은 좋은 LLM으로. 97%가 맞는 일에 LLM을 부를 이유는 없다.
정직한 0을 지켜라. 약속 이행 0건은 실패가 아니라 코퍼스가 말하는 사실이다. 0을 1로 만드는 규칙은 대개 가짜를 만든다.
1편의 제목은 "약속은 사실이 아니다"였다. 2편에서 배운 것을 한 줄로 덧붙이면 이렇다. 같은 글자라고 같은 사실은 아니다. '이 조례'는 68개의 서로 다른 조례였고, '신한지주 분기배당'은 네 번의 서로 다른 배당이었다. 무엇이 무엇을 대체할 수 있는지 따지려면, 먼저 둘이 같은 것에 대한 말인지부터 정해야 한다.