완료로 기록된 빈 그래프: 구조 복원, 부분 커밋, 출력 예산 기반 배치 사이징
빌드는 완료로 표시되는데 그래프가 비어 있거나 구조가 무너진 문제를 석 달에 걸쳐 추적했습니다. 리팩토링이 지운 중복 제거·상속 로직을 복원하고, 오진을 세 번 정정하며 예외를 삼키던 배치 처리와 all-or-nothing 저장을 찾아냈고, 배치 크기를 모델 출력 한도에서 산출해 절단 시 반분 재시도하게 바꿨습니다.
Key Results
- 194초 → 17초
- 추출 배치 1개 처리 시간35B 모델·단일 GPU, 추론(thinking) 출력 비활성화 전후. 빈 그래프의 근본 원인은 아니었음
- 202초 → 31초
- 27B 추론 모델의 같은 추출추론 비활성화가 실제로 적용됐는지 협상해 확인한 뒤 채택, 모델명 목록 하드코딩 없음
- 94.2분
- 2만 6,934청크 컬렉션 첫 전체 빌드 완주클라우드 LLM 추출, 트리플 16만 1,287개. 60초 실측 요청 41건 중 12건이 429로, 병목은 계정 단위 레이트리밋
XGEN 온톨로지 빌드는 문서 청크를 LLM에 보내 클래스·속성·개체·관계를 JSON으로 받아 그래프에 적재합니다. 5월부터 8월까지 이 빌드에 대해 비슷한 보고가 반복됐습니다. 빌드는 "완료"인데 그래프가 비어 있다, 그래프가 있긴 한데 계층이 없다, 추출 배치 하나에 수십 분이 걸린다는 것이었습니다. 원인은 매번 달랐지만 공통점이 있었습니다. 실패가 로그 없이 성공으로 보고되고 있었습니다.
이 글은 그 조용한 실패를 걷어낸 세 번의 추적을 시간순으로 따라갑니다. 첫째는 리팩토링이 지운 구조 로직을 git 이력으로 찾아 복원한 일, 둘째는 빈 그래프의 원인을 세 번 잘못 짚고 정정한 일, 셋째는 배치 크기를 모델의 출력 한도에서 산출하고 절단된 배치를 반분 재시도하게 바꾼 일입니다. 마지막으로 대형 빌드에서 확인한 실제 병목과 이후 작업에 남은 원칙을 정리합니다.
파이프라인과 실패가 숨는 자리
빌드는 청크를 그룹으로 나누고, 그룹마다 추출 게이트를 통과한 청크를 배치로 묶어 동시에 LLM을 호출합니다. 1차 추출 뒤 빠진 개체를 보충하는 gleaning 단계가 있고, 그룹마다 스키마를 저장하고 그래프를 적재합니다. 모든 그룹이 끝나면 세션 끝에 한 번 라벨 병합과 형태소 정규화, LLM 동의어 통합을 하고, 결과를 트리플스토어에 업로드합니다(8월 이후에는 PostgreSQL 네이티브 저장소).
청크 → 그룹 분할 → 추출 게이트 → LLM 추출(배치 × 동시성) → gleaning
│ JSON: 클래스 · 속성 · 개체 · 관계 · 값
▼
그룹마다 스키마 저장 · 그래프 적재
▼
세션 끝 1회: 라벨 병합 → Kiwi 정규화 → LLM 동의어 통합
▼
그래프 저장소 업로드이 흐름에는 실패가 조용히 사라질 자리가 여럿 있었습니다. 게이트가 청크를 LLM 호출 전에 버리는 자리, 배치 결과를 asyncio.gather(return_exceptions=True)로 모으는 자리, 그룹 저장, 업로드, SDK 저장 호출의 반환값이었습니다. 모델 환경도 다양했습니다. 클라우드 GPT-4o 계열, 단일 GPU의 35B 로컬 모델, 추론 출력을 내는 27B 모델, gemma 계열이 같은 코드를 지나갔습니다.
첫 번째: 완료된 그래프의 구조가 무너져 있었습니다
5월 말 공공기관 문서 컬렉션의 그래프를 감사하니 클래스 999개, subClassOf 엣지 5개, 고아 클래스 993개, 관계 421개(인스턴스 1,693개)였습니다. 같은 개념이 다른 URI로 갈라져 클래스 URI 1,070개에 라벨은 732개, 약 338개가 중복이었습니다. 정형(CSV) 컬렉션은 잡이 "완료"인데 그래프가 비어 있었고 빌드는 700초가 걸렸습니다.
git 이력을 추적하니 일주일 전 리팩토링이 출발점이었습니다. 대형 컬렉션의 메모리 사용을 줄이려고 전체 문서를 한 번에 처리하던 구조를 그룹 단위 증분 적재로 바꿨는데, 이때 빌드 말미의 최종 중복 제거 호출이 사라지고 그룹마다 기존 개념 목록을 LLM에 느슨하게 넘기는 방식으로 대체됐습니다. 그 결과 중복이 누적되고, 별개의 개념이 기존 클래스로 합쳐지고, 상속이 만들어지지 않았습니다. 같은 시점의 다른 결함도 겹쳐 있었습니다. 그래프 빌더는 추출기가 준 부모 클래스 정보를 그래프에 넣지 않고 버렸고(subClassOf 생성 코드 자체가 없었음), 현재 그룹이 모르는 개체를 가리키는 관계는 통째로 버렸습니다. 그룹마다 한글 라벨을 영문 URI로 번역하는 결과가 달라 "감사"가 Audit과 Inspection으로 갈린 것이 중복의 정체였습니다.
비어 있는 그룹의 원인은 추출 게이트였습니다. 게이트가 영어 산문 기준(50자 미만, 공백 비율 5% 미만이면 제외)이라 공백이 적은 한글, 숫자, 표 청크를 비자연어로 보고 LLM 호출 전에 버렸습니다. 빈 그룹이 세 번 연속되면 빌드를 실패로 끝내는 조기 종료 로직까지 있었습니다. CSV는 37.8만 트리플 TTL을 한 번에 POST하다 60초 타임아웃에 걸렸는데 업로드 함수의 except: pass가 이를 삼켰고, 잡은 메모리의 카운트만 보고 완료를 기록했습니다.
설계 판단은 전면 재작성이 아니라 복원이었습니다. 원인이 특정 리팩토링의 호출 제거로 좁혀졌고, 스트리밍·그룹·중단·재개라는 빌드 골격 자체는 정상이었습니다. 세션 끝 1회 마무리 단계(같은 라벨 병합 → Kiwi 인스턴스 정규화 → LLM 동의어 통합)를 되살리고 subClassOf 구축을 복원했습니다. 그룹 경계를 넘는 관계는 대상 노드를 만들어 연결했는데, 인스턴스 URI가 라벨에서 결정적으로 만들어지므로 그 개체가 속한 그룹을 처리할 때 자연스럽게 병합됩니다. 게이트는 한중일 문자가 있으면 통과시키고, 실제 base64(공백 없는 단일 토큰, 길이가 4의 배수, 디코딩 성공)만 제외하게 바꿨습니다. TTL은 2만~4만 트리플 단위로 나눠 세마포어 4개로 병렬 업로드하고, CSV만 있는 컬렉션은 인스턴스 URI가 결정적이라 형태소 정규화를 건너뛰게 했습니다. 이 판정은 임계값이 아니라 데이터 종류로 합니다. 게이트 단위 테스트 16/16과 6만·12만 트리플 업로드는 확인했지만, 그날은 인증 문제로 전체 빌드를 브라우저에서 끝까지 확인하지 못했다고 기록했습니다.
두 번째: 빈 그래프의 진범은 세 번 바뀌었습니다
6월 말에는 컬렉션에 따라 노드 0개로 끝나거나 배치 하나에 수십 분이 걸리는 문제가 반복됐습니다. 닷새 동안 가설을 세우고 고치고 다시 뒤집었습니다.
| 순서 | 가설 | 조치 | 결과 |
|---|---|---|---|
| 1 | 긴 응답이 기본 타임아웃에 걸려 추출 결과가 None | 출력 상한·타임아웃 지정 | 증상 완화, 원인 아님 |
| 2 | 35B 단일 GPU에 동시 요청 30개, KV 캐시 경쟁으로 요청당 45분 | 동시성 30 → 3 | 지연 완화(동시 1개는 90초 정상) |
| 3 | 추론 모델이 추출에도 thinking 출력 | thinking 비활성화 | 배치당 194초 → 17초, 이때 "최종 원인"으로 판단 |
| 4 | 그룹(100청크) 완주 후 all-or-nothing 저장 | 그룹 5청크, 그룹마다 즉시 커밋 | 첫 번째 정정: 작은 컬렉션은 한 그룹이 끝나기 전에 취소되면 저장 0 |
| 5 | 출력 상한 32,000 하드코딩 | 설정 주입 + 보수적 기본값 | 모델 교체 시 400 오류(gemma 실측) 방지 |
| 6 | gleaning이 개체를 dict가 아닌 문자열 배열로 반환 | 형식 방어 + 유실 배치 로깅 | 두 번째 정정: 개체 61개가 든 배치가 로그 없이 사라지던 경로 |
| 7 | 추출 모듈에 logger 정의 없음 | import 추가 | 세 번째 정정: 잠복 NameError |
3번에서 멈출 뻔했습니다. 11배 빨라졌으니 원인처럼 보였지만, 빈 그래프는 계속 생겼습니다. 4번에서 LLM은 개체를 정상적으로 추출하고 있었고 저장에 도달하지 못한 것이 원인이었다는 것이 드러났습니다. 그래도 끝이 아니었습니다. gleaning 단계가 가끔 개체를 dict가 아닌 문자열 배열로 돌려줬고, 후처리의 ent.get()이 AttributeError를 냈고, gather(return_exceptions=True) 뒤의 pass가 그 예외를 삼켜 1차 추출 결과까지 포함한 배치 전체가 로그 한 줄 없이 사라졌습니다. LLM 응답 원문을 다시 실행해 재현한 뒤에야 확정했습니다. 원인 확인용으로 넣은 로깅은 즉시 NameError를 냈는데, 모듈에 logger가 정의돼 있지 않았기 때문입니다. 기존의 gleaning 로그도 같은 결함으로 배치를 죽이던 잠복 결함이었고, py_compile은 통과하는 종류의 오류였습니다.
같은 기간에 저장 쪽 조용한 실패도 하나 고쳤습니다. 스키마 저장 함수가 SDK의 {"success": False} 반환을 무시해서, 저장이 실패해도 빌드가 완료로 기록됐습니다. 이를 예외로 올려 실패가 표면에 드러나게 했습니다.
세 번째: 배치 크기를 출력 한도에서 산출합니다
7월 초에는 로컬 소형 모델로 빌드하면 추출 배치 하나에 15분이 걸렸습니다. 원인은 여러 축이었습니다. 배치 크기가 모델과 무관하게 10,000자로 고정돼 있어 작은 컨텍스트 모델에서는 프롬프트와 출력이 윈도우를 꽉 채웠습니다. 추출이 쓰는 JSON 호출 함수에는 타임아웃 파라미터가 아예 없어 느린 배치가 무한정 매달렸습니다. 출력 상한은 배치 크기와 무관하게 고정(vLLM 1,280토큰)이라 내용이 많은 배치의 JSON이 잘리고 파싱에 실패한 뒤 오류 없이 사라졌습니다. 배치 텍스트를 16,000자에서 자르는 두 번째 하드코딩도 있었습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 고정 문자 수(10,000자) | 단순함 | 작은 모델은 과부하, 큰 모델은 낭비, 출력 절단은 그대로 | 폐기 |
| 공급자별 윈도우 가정(클라우드 128,000토큰) | 감지가 필요 없음 | 모델 가정을 코드에 박는 하드코딩, 모델이 바뀌면 틀림 | 1차 구현에 넣었다가 리뷰 지적으로 원복 |
| 런타임 감지 + 입력·출력 중 작은 쪽 + 절단 시 반분 | 모델과 무관하게 배치마다 자가 보정 | 감지 실패 시 폴백 필요 | 채택 |
핵심은 소형 모델의 병목이 입력이 아니라 출력이라는 판단이었습니다. 입력이 작아도 개체가 많으면 JSON이 커지고 출력 상한에서 잘립니다.
문자 예산 = min(입력 컨텍스트 기반 예산, 출력 max_tokens 기반 예산)
예) 윈도우 128k 토큰, 출력 상한 1,280토큰 → 배치 4,571자
배치가 절단·타임아웃·파싱 실패 → 청크(또는 텍스트)를 반으로 나눠 스택에 다시 넣음
세마포어는 배치당 한 번만 획득, 재귀 없이 명시적 스택, 최대 깊이 3컨텍스트 윈도우는 OpenAI 호환 서버(vLLM·Ollama·LM Studio·TGI·LiteLLM)의 모델 목록 응답에서 서버마다 다른 키(max_model_len, context_length, n_ctx 등)와 경로를 모두 시도해 런타임에 감지하고, 순수 클라우드 API는 운영자 설정이 있을 때만 쓰며, 둘 다 없으면 검증된 폴백 10,000자를 씁니다. 표 하나가 예산을 넘으면 행 경계로 나누되 청크 ID를 유지해 출처가 어긋나지 않게 했습니다. JSON 호출에는 타임아웃(하드 취소), 호출 단위 출력 상한, 절단 신호를 추가했고, 절단은 공급자와 무관하게 응답의 종료 사유(length, max_tokens 등)로 판정합니다. 반분 재시도를 재귀로 짜고 재시도마다 세마포어를 다시 잡으면 교착 위험이 있어 명시적 스택으로 바꿨습니다. 이 검증은 분할·병합·절단 판정·재시도 스택 종료를 실제 클래스로 확인한 로직 테스트까지였습니다. 당시 vLLM 엔드포인트에 도달할 수 없어 실빌드 확인은 미완으로 기록했습니다.
8월에는 같은 원칙을 모델 간 품질 격차로 넓혔습니다. 27B 추론 모델은 출력 예산을 사고 과정에 다 써서 추출 결과가 0이었고, 엔드포인트가 thinking 비활성화 인자를 무시했습니다. 추론 수준 인자를 바꾸자 같은 추출이 202초에서 31초가 됐는데, 모델명 목록을 하드코딩하지 않으려고 실제로 꺼졌는지 협상해 확인한 뒤 채택하게 했습니다. 속도 프로브가 실패하면 가장 공격적인 기본값으로 떨어지던 것도 반대로 고쳤습니다. 프로브 실패는 느리다는 신호로 보고 보수적 동시성(2)으로 전환합니다. 직전 빌드 꼬리와 겹친 최악의 프로브 결과가 캐시돼 동시성 1에 고착되던 것(113청크가 12시간짜리 작업이 되던 것), 유휴 속도로 정한 180초 타임아웃이 부하 시 72콜을 실패시켜 추출량이 1/3이 되던 것도 실제 호출의 이동 평균을 쓰게 해 해소했습니다. 그룹 추출의 예외 처리가 추출기 고장(AttributeError)을 빈 그룹으로 흡수해 "완료 100%, LLM 0회"로 표시되던 것은 경고를 남기고 모든 그룹이 예외면 잡을 실패로 처리하게 바꿨습니다.
대형 빌드에서 확인한 병목은 모델이 아니었습니다
6월 초 2만 6,934청크 컬렉션의 첫 대형 빌드가 94.2분에 완주했습니다. 결과는 클래스 500, 인스턴스 9,998, 관계 3,422, 트리플 16만 1,287개였습니다. 시간을 분해하니 추출 약 40분(표가 많은 HWP 문서에서 호출당 22~26초), 중복 제거 약 26분(인스턴스 1만 개를 하나씩 질의), 나머지 정리·검증 약 28분이었습니다.
추출 시간을 줄이려고 모델을 GPT-4o에서 mini로 바꾸는 실험을 했습니다. 60초 동안 실측하니 API 요청 41건 중 12건이 429(레이트리밋)였고, 클라이언트 라이브러리가 이를 조용히 백오프 재시도하고 있었습니다. 어느 모델이든 분당 처리량은 계정의 토큰·요청 한도에 똑같이 묶여 있었고, mini로 바꿔도 시간 효과는 거의 없었습니다. 실제 레버는 여러 API 키의 라운드로빈, 요금제 상향, 요청 수 감소라는 결론을 남기고 실험 변경은 모두 원복했습니다.
결과
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 추출 배치 1개 | 194초 | 17초 | 35B·단일 GPU, thinking 비활성화(빈 그래프의 원인은 아님) |
| 27B 추론 모델의 같은 추출 | 202초, 결과 0 | 31초 | 추론 비활성화 협상 후 |
| 대용량 TTL 적재 | 37.8만 트리플 단일 POST 실패를 삼킴 | 6만·12만 트리플 배치·병렬 적재 확인 | 로컬 직접 테스트(60,007 / 120,007 트리플) |
| 추출 게이트 | 한글·숫자·표 청크를 LLM 호출 전에 제외 | 단위 테스트 16/16 | 로컬 |
| 2만 6,934청크 첫 전체 빌드 | 완주 기록 없음 | 94.2분, 트리플 16만 1,287개 | 클라우드 LLM, 병목은 계정 레이트리밋(60초 41건 중 429 12건) |
| 배치 자동 사이징·반분 재시도 | 고정 10,000자, 타임아웃 없음 | 로직 테스트 통과, 실빌드 미검증 | vLLM 엔드포인트 도달 불가 |
회고: 남은 원칙과 과제
세 번의 추적에서 남은 원칙은 다섯 가지입니다. 예외를 조용히 삼키지 않고, 실패는 반드시 로그와 잡 상태로 드러냅니다. 첫 번째로 그럴듯한 원인에서 멈추지 않고, 응답 원문을 다시 실행해 재현한 뒤에만 원인으로 선언합니다. 출력 상한·동시성·컨텍스트 윈도우 같은 용량 값은 코드에 박지 않고 런타임 감지, 운영자 설정, 보수적 폴백에서만 얻습니다. 정의되지 않은 이름을 부르는 코드처럼 py_compile을 통과하는 잠복 결함은 정적 분석으로 확인합니다. 저장은 all-or-nothing을 피하고 작은 단위로 커밋해 중단 시 손실 범위를 제한합니다.
고치기 전에 호출 경로를 전부 보는 것도 배웠습니다. 15분 배치 문제에서 처음에는 입력 축만 고치고 끝낼 뻔했는데, 직접 원인은 JSON 호출의 타임아웃 부재였고 두 번째 하드코딩과 출력 축 누락도 있었습니다. 라이브 검증을 할 수 없을 때는 로직 검증만 했다는 사실을 그대로 기록했습니다.
남은 과제도 있습니다. 대형 빌드의 품질 점검은 원문 근거 비율 53.1%(환각 의심 1,242건), 도메인 위반 5만 9,331건, 스키마 밖 술어 197종을 경고했고, 이 작업 범위에서는 개선하지 못했습니다. 마무리 단계가 진행 메시지를 갱신하지 않아 UI가 멈춘 듯 보이는 문제와, 중복 제거·품질 수정을 한 건씩 질의하는 비용도 후속 과제로 남겼습니다. 이후 기본 빌드를 LLM 없는 결정론적 추출로 분리하면서, 업로드마다 LLM 추출의 실패 지점을 지나야 하는 구조 자체가 바뀌었습니다.