온톨로지 빌드 2단계 분리: LLM 0회 기본 빌드와, 검색을 해치지 않을 때까지 다시 설계한 관계 형성
업로드마다 LLM 추출을 실행하면 비용을 감당할 수 없어, 개체 추출은 LLM 0회 기본 빌드로, 관계 추출은 사용자가 켤 때만 실행하는 관계 형성으로 나눴습니다. 기본 빌드가 표를 행 단위 사실로 읽게 하고, 관계 형성은 원문 단위 위의 번호 분류와 어휘 우선 구조로 다시 설계한 뒤 같은 검색 코드로 번갈아 측정했습니다.
Key Results
- 0.3~1.6초 · LLM 0회
- 기본 빌드 소요문서 6개 테스트 컬렉션. 같은 컬렉션 관계 확장은 6청크 10.0초·LLM 7회·15,194토큰
- 23분 · 약 8달러
- 관계 형성 1회규정 문서 평가 컬렉션, gpt-4.1-mini 2,495콜·입력 15.85M·출력 1.26M 토큰. 직전 게이트웨이 Qwen 27B 실행은 1,744콜에 2시간 52분
- 2,495콜 중 2건
- 관계 형성 빈 응답응답 스키마에 관계가 있는 단위 번호 목록을 먼저 쓰게 한 뒤. 이전 구조는 첫 묶음 배치의 34%가 빈 응답
온톨로지가 문서를 올릴 때마다 자동으로 만들어지려면 기본 빌드에는 호출 비용이 없어야 했습니다. 업로드마다 LLM 추출을 실행하면 컬렉션이 커질수록 비용과 시간이 선형으로 늘어 자동으로 켜 둘 수 없습니다. 그래서 개체 추출은 결정론적으로 내리고, 비싼 관계 추출은 사용자가 켤 때만 실행하기로 했습니다. 그런데 막상 코드를 열어 보니 "기본 빌드 + 마지막 LLM 관계 형성"이라는 경계가 어디에도 없었습니다.
이 글은 그 경계를 만든 방법, 기본 빌드만으로 표의 값에 답할 수 있게 만든 과정, 관계 형성을 원문 근거 위의 분류로 다시 설계하며 만난 실패들, 그리고 관계 형성이 검색에 실제로 도움이 되는지를 같은 검색 코드로 번갈아 잰 방식과 결과를 다룹니다. 결론부터 말하면 관계 형성은 처음에 검색을 해쳤고, 다시 설계한 뒤에는 해치지 않게 됐지만 이득은 검색이 관계의 술어를 직접 쓰게 한 뒤에야 나왔습니다.
착수 전에 뒤집힌 전제
첫째, 관계는 마지막 단계가 아니었습니다. 추출기는 그룹마다 한 번의 LLM 호출로 클래스·객체 속성·데이터 속성·개체·관계·값의 6개 배열을 닫힌 JSON 스키마(additionalProperties=False)로 받고 있었습니다. 관계는 클래스·개체와 같은 호출에서 동시에 만들어졌고, 추출 이후의 LLM 호출은 OWL 용어 번역, 요약, 동의어 통합뿐이었습니다.
둘째, 그래프가 검색에 기여한다는 전제도 그 시점에는 성립하지 않았습니다. 같은 주에 같은 질문 세트로 그래프를 비운 상태, 기본 빌드, 관계 형성을 비교했는데 청크 회수 지표(R@k, MRR)는 소수점까지 같았습니다. 서로 다른 두 문서를 모두 봐야 답이 나오는 19문항을 상태당 3회씩 답변 채점해 부호 검정을 하니, 기본 빌드에서 관계 형성으로 갈 때 개선 6·악화 4·동일 9(p=0.754)였습니다. 엣지가 6,524개에서 12,144개로 86% 늘었는데 답변 품질이 움직이지 않았습니다. 검색 엔진의 우위는 동적 절단·MMR·적응형 top-k와 합성 프롬프트에서 나오고 있었고, 빌드의 LLM 비용은 검색으로 회수되지 않았습니다. 기본 빌드를 무료로 만드는 것이 우선이라는 근거가 됐습니다.
경계를 부탁하지 않고 표현할 수 없게 만들었습니다
관계를 빼 달라고 프롬프트로 부탁하는 대신 스키마에서 relations 키 자체를 없앴습니다. 관계를 끄면 스키마 속성과 required 목록, 프롬프트의 관계 원칙 두 개, 출력 예시의 관계 블록을 모두 제거해 모델이 관계를 출력할 자리가 없게 했습니다. 처음에는 함수 시그니처를 건드리지 않으려고 모드를 추출기 인스턴스 속성으로 넣었는데, 추출기가 오케스트레이터마다 하나뿐이라 여러 컬렉션이 동시에 빌드되면 서로의 모드를 덮어썼습니다. 인자로 세 함수를 관통시키는 방식으로 바꿨습니다.
| 모드 | 하는 일 | LLM |
|---|---|---|
| full | 클래스·속성·개체·값·관계를 한 번에 추출(기존 동작) | 있음 |
| basic | 관계 없는 기본 온톨로지 | 0회(결정론적 추출기) |
| enrich | 기본 빌드가 만든 개체 목록을 주고 관계만 추출해 얹음 | 있음 |
첫 basic은 관계 키만 뺀 LLM 추출이었고, 48청크 컬렉션에서 엣지 174 → 139, 인스턴스 75 → 56으로 줄었습니다. 클래스는 32 → 33으로 유지됐는데 스키마 행은 46 → 63으로 늘었습니다. 관계에 쓰던 출력 예산을 클래스·속성이 채운 것이라, basic이 full의 부분집합은 아니었습니다. 이후 basic의 추출기를 결정론적 추출기(표 구조, Kiwi 형태소 경계, Hearst 패턴)로 바꿔 추출 LLM을 0으로 만들었고, 문서 6개 테스트 컬렉션에서 기본 빌드는 0.3~1.6초(LLM 0회·0토큰), 같은 컬렉션의 관계 확장은 6청크에 10.0초(LLM 7회·15,194토큰)였습니다.
자동 실행은 컬렉션별 설정 테이블(컬렉션당 1행)로 관리했습니다. 빌드 잡 이력은 "무슨 일이 있었나"를 담는 자리라 지속 설정을 두지 않았습니다. 업로드, 데이터 소스 동기화, 캔버스 인덱싱 세 경로가 모두 임베딩 직후의 한 함수로 모이므로 그 한 곳에 트리거를 두었습니다. 실제로 API를 호출하고 빌드를 실행하자 코드 정독으로는 보이지 않던 결함이 나왔습니다. 설정 저장은 레코드 API가 없는 DB 핸들을 써서 500이 났는데, 조회만 테스트했다면 통과했을 결함이었습니다. 이미 full로 끝난 컬렉션에서 자동 빌드는 이어서 빌드로 걸려 새 청크 0건을 처리했고, 그래프 수치가 그대로라 "차이 없음 = 정상"으로 보고할 뻔했습니다. 현재 그래프가 걸려는 모드로 만든 것이 아니면 처음부터 다시 빌드하게 고쳤습니다. 기존 컬렉션 소급 빌드는 기동 90초 뒤 미빌드 컬렉션을 순차로 훑는 백필(상한 20개, 킬 스위치)로 넣었고, 백필 완료 로그가 실제보다 먼저 찍히는 것(태스크를 띄우고 바로 반환)은 잡이 종료 단계에 이를 때까지 폴링하게 고쳤습니다.
자동화는 숨어 있던 결함도 드러냈습니다. SDK 조회 기본값 limit=500 때문에 빌드가 컬렉션 문서를 500개까지만 읽어, 문서 1,186개 중 686개(57.8%, 청크 기준 19,450/29,506 = 66%)가 그래프에 들어가지 않았고, 기준선이 자랄 수 없어 백필이 11.6분마다 같은 빌드를 반복했습니다. 상한을 명시하고 상한에 닿으면 경고를 남기게 했으며, 빌드 경로가 SDK를 직접 부르지 않는지를 소스에서 검사하는 회귀 테스트를 넣었습니다.
기본 빌드가 표를 사실로 읽게 했습니다
LLM 없는 기본 빌드 초기판은 표의 열 이름을 셀의 클래스로만 써서 그래프에 "서울특별시 → 유형 → 구분"만 남고 값(700만원)은 어디에도 없었습니다. 관계 형성을 켜기 전에는 검색의 1홉 관계 블록이 답을 싣지 못했고, 표가 있는 청크의 산문은 통째로 버려졌습니다. 목표는 "무료로 실행되는 기본 빌드만으로 표의 사실은 답할 수 있어야 한다"였습니다.
표를 rowspan·colspan을 펼친 격자로 읽고, 행을 개체로, (행, 열 이름, 셀)을 속성·관계로 적재했습니다. 행의 클래스는 표 제목(캡션)에서 가져오고, 표 밖 산문은 형태소 경계로 명사구를 뽑아 함께 넣습니다. 머리글 판정도 보강했습니다. 두 줄 머리글은 합치고, 연번 같은 식별 열은 데이터로 취급하고, 괄호 주석 제목은 캡션에서 빼고, 청크 경계를 넘는 머리글은 이월합니다. 소액임차보증금 표 하나에서 행 36·속성 72·관계 70이 생성됐고 (지역, 최우선변제금액, 700만원) 같은 트리플이 검색 관계 블록에 실렸습니다. 규정 문서 QA 세트(20문항)에서 관계 블록에 정답이 그대로 들어간 비율은 0%에서 15%(문자열 엄격), 단위를 무시하고 주어가 겹치면 50%가 됐고, cov@3은 0.92에서 0.97이 됐습니다.
관계 형성을 다시 설계한 과정
관계 형성이 검색을 해친다는 실측이 먼저 있었습니다. 같은 문서의 쌍둥이 컬렉션에서 관계를 40,360개에서 77,300개로 늘리자 정답 규정 회수가 125문항 중 67에서 64로, 답변 정답률이 77.0%에서 71.5%로 떨어지고 검색은 4.2초에서 43.6초가 됐습니다. 코드를 추적하니 관계 확장 프롬프트가 "이미 추출된 개체" 300개를 주면서 청크 안 개체가 모자라면 컬렉션 전체의 연결 차수 상위 개체로 채웠고, 다른 문서의 개체끼리 잇는 근거 없는 관계가 48%였습니다. 미지의 끝점은 자리표시 개체로 만들어졌고(미타입 27,129개), 엣지 가중치는 전부 1.0이었으며, 엣지에 출처 청크 컬럼이 없어 관계에서 근거로 되돌아갈 수 없었습니다.
구조를 바꿨습니다. 문장·조항·표 행을 단위로 나누고 단위 안의 노드 언급을 결정론적으로 찾은 뒤, 모델에는 "단위 번호, 주어 번호, 술어, 목적어 번호" 한 줄의 분류만 맡겼습니다. 옛 전체 추출 경로 1,100줄을 지웠고, 관계마다 출처 청크를 별도 테이블에 적재하고 근거가 쌓일 때마다 가중치를 1씩 더하며, 구체 술어가 생기면 그 쌍의 일반 관계(relatedTo)를 대체합니다. LLM 없이 미리 세어 보니 후보 단위 125,005개, 호출 1,584회, 입력 약 810만 토큰으로 비용을 실행 전에 확정할 수 있었습니다.
첫 실행들은 매번 다른 이유로 실패했습니다. 모든 관계가 "ooo"로 나왔는데, 표 머리글 "OOO"가 ASCII라 그대로 술어가 된 것을 모델이 허용 목록으로 읽은 것이었습니다. 결정론 층이 한글 열 이름은 relatedTo + 라벨로, ASCII 열 이름은 술어로 내던 비대칭을 없앴습니다. 어휘가 비자 모델이 숫자나 한글을 술어로 써서 파서가 전부 버렸고, 응답 원소를 객체로 바꿔 번호 칸은 정수, 술어는 ^[A-Za-z][A-Za-z0-9]*$ 패턴으로 디코더가 강제하게 했습니다. 옛 추출의 출력/입력 비 가정 때문에 한 묶음이 325콜로 쪼개졌고, 반대로 입력 예산만 보자 한 콜에 단위 249개가 들어가 응답 배열 상한 60에 잘렸습니다. 출력 예산을 단위 수 × 단위당 관계(실측 이동 평균) × 관계당 토큰으로 계산하고 배치 상한을 응답 원소 수로 잡았습니다.
어휘를 고정하는 시도도 실패했습니다. 어휘 75개를 응답 스키마 enum으로 고정하고 "맞는 이름이 없으면 적지 말라"고 하자 첫 11콜이 전부 빈 relations였습니다. 같은 요청을 게이트웨이에 직접 보내도 비었고, "가장 가까운 이름을 고르라"로 바꾸면 60건 중 37건이 involves로 몰렸습니다. 어휘 없이 자유 이름을 허용하면 구체적인 이름이 나왔습니다. 그래서 어휘를 우선하되 고정하지 않았습니다. 같은 뜻이면 어휘 이름을, 아니면 가장 구체적인 새 camelCase 동사를 쓰고, 함께 나왔다는 것 외에 뜻이 없는 이름은 금지합니다. 묶음마다 자주 쓰인 새 이름을 natural cut으로 골라 정의를 붙여 어휘에 더하고, 빌드 끝에 같은 뜻의 이름을 어휘로 정규화합니다.
배치 하나의 빈 응답도 문제였습니다. 잘림을 관측할 때마다 단위당 관계 추정을 1.5배씩 곱하던 래칫이 첫 묶음 77콜을 두 번째 묶음 526콜로 키웠고, 이동 평균(0.7/0.3)으로 바꿨습니다. 그래도 첫 응답의 37~45%가 비었습니다. 같은 배치를 12회씩 다시 보내 보니 두 번 연속 빈 배치는 표 행 조각처럼 관계가 정말 없는 내용이었고, 보통 배치의 빈 답은 확률적이었습니다. 응답 구조 변형을 12회씩 비교했습니다.
| 응답 구조 | 빈 응답(12회 중) | 관계 수 | 판단 |
|---|---|---|---|
| 관계 배열만 | 1~3 | 45~60, 상한 60에 자주 도달 | 기존 |
| 관계 수를 먼저 쓰기 | 0 | 12~18, 쓴 수에 맞춰 적게 냄 | 기각 |
| 단위마다 항목 쓰기 | 해당 없음 | 2~36, 단위별 기권 | 기각 |
| 관계가 있는 단위 번호 목록을 먼저 쓰기 | 0 | 28~48 | 채택 |
모델이 단위를 훑지 않고 관계 목록부터 쓰다 포기하는 패턴이라, 훑기를 먼저 강제한 구조가 효과가 있었습니다. 첫 묶음의 빈 응답이 34%에서 0이 됐고, 최종 실행은 2,495콜 중 2건이었습니다.
마지막 결함은 빌드 끝에 있었습니다. 관계 형성이 만든 영어 어휘가 빌드 끝에 한글로 바뀌고 사라졌는데, 옛 동의어 통합 단계가 관계 어휘까지 "한글을 대표 이름으로" 병합했고, 술어 정규화가 (주어, 목적어) 한 쌍만 겹쳐도 술어를 합쳤기 때문입니다. 동의어 통합은 클래스만 다루게 하고 술어 후처리를 삭제했습니다(352줄 감소). 그런데 삭제하면서 남은 데코레이터 한 줄이 다른 메서드에 붙어 마무리 단계 전체가 건너뛰어졌고, 바깥 예외 처리가 이를 삼켜 술어가 3,363종으로 남았습니다. 고친 뒤 마무리를 다시 실행하자 술어 223종, 어휘 222개(한글 0)가 됐습니다.
모델 선택은 비용 실측으로 정했습니다. 사내 게이트웨이의 Qwen 27B는 유효 출력 약 300 tok/s로 1,744콜에 2시간 52분이 걸렸고, 다른 사내 서버는 더 느리거나 컨텍스트가 8k라 잘렸습니다. 재설계 전 구조로 Claude Sonnet을 시험했을 때는 실제 배치 하나(청크 4개)를 재현하니 입력 10,021·출력 13,233토큰, 관계 173개(청크당 43개)로 콜당 약 0.23달러, 전체 약 1,000달러·7시간이 예상돼 중단했습니다. 처음 들고 간 200달러 추정은 청크당 관계 수를 Qwen 실측(6.8개)으로 잡은 것이라 틀렸습니다. 비용은 관계 형성 자체가 아니라 모델 단가 × 추출 밀도였습니다. gpt-4.1-mini로는 23분, 2,495콜, 약 8달러였습니다.
효과를 어떻게 쟀나
관계 형성이 기본 빌드보다 검색을 낫게 하는지 보려면 검색 코드는 같고 그래프만 다른 조건이 필요했습니다. 기본 빌드 그래프와 관계 형성 그래프를 테이블 스냅샷으로 떠 두고, 같은 검색 코드로 번갈아 복원하며 하나씩 측정했습니다. 두 측정을 한 컨테이너에서 동시에 실행하면 검색이 느려져 그래프 확장 시간 예산(30초)에 걸리고 수치가 깎일 수 있기 때문입니다. 같은 그래프를 두 번 재면 165문항의 근거 순서까지 같아 재현성은 확보됐습니다.
세트는 셋이었습니다. 규정 165문항, 내규 청크에서 생성해 정답 표현과 근거 문장이 원문에 그대로 있는지 검증한 구조 100문항(승인자·한도·위임보고·적용대상·필요서류), 두 규정을 함께 묻는 다리 문항 50개와 질문 어휘를 바꾼 문항 50개로 된 난이도 100문항입니다. 다리 문항의 오답 8건이 전부 "두 번째 규정의 청크가 근거에 없음"이어서, 한 파일이 근거 자리의 절반까지만 차지하고 질문 노드가 잇는 다른 문서에 자리를 주는 문서 다양성 보장을 먼저 넣었습니다(165에서 기본 137 → 141, 관계 형성 135 → 139).
| 세트 · 지표 | 기본 빌드 | 관계 형성 뒤 | 조건 |
|---|---|---|---|
| 165 · 정답 규정 전부 회수 | 141 | 139 | 같은 검색 코드, 하나씩 측정 |
| 구조 100 · 정답 근거 전체 / R@1 | 99 / 86 | 99 / 86 | 같음 |
| 구조 100 · 관계 줄에 정답 표현 | 13 | 17 | 같음 |
| 난이도 100 · 골드 전부 / 하나라도 | 87 / 94 | 88 / 96 | 같음 |
| 검색 시간 중앙값(165 / 난이도 100) | 9.5 / 10.0초 | 11.0 / 8.4초 | 같음 |
세 세트 모두 차이는 ±2문항 안이었습니다. 문항별로 대조하니 165에서 근거 집합이 바뀐 문항은 98개, 그래프 채널 근거가 바뀐 문항은 86개였지만 정답이 뒤집힌 것은 잃음 3·얻음 1이었습니다. 그래프 채널이 넣은 근거 중 정답 파일 비율은 11%(264개 중 29개)로 벡터 채널 52%보다 낮았습니다. 원인은 검색 쪽에 있었습니다. 그래프 후보 생성과 로컬 PPR이 간선을 이름 없는 1홉 인접으로만 쓰고 술어와 가중치를 보지 않았고, 술어는 답변 프롬프트의 관계 줄에서만 쓰였습니다(관계 줄 정답 13 → 17만 움직인 이유). 다리 오답 5쌍 중 1쌍은 순위에서 밀렸고, 1쌍은 정답 청크에 기본 빌드가 개체를 하나도 뽑지 못했으며, 3쌍은 질문이 규정 이름으로만 가리켜 청크 노드와 라벨이 겹치지 않았습니다.
검색이 관계를 쓰게 한 뒤의 결과
후속으로 검색에 관계 근거 채널을 넣었습니다. 질문 시드에 닿는 이름 있는 관계의 출처 청크를 후보로 올리는 방식입니다. 오프라인으로 보니 이 출처 청크가 정답 청크에 닿는 문항이 165에서 79개 중 61개였고, 기본 빌드의 일반 관계로는 0~5개였습니다. 일반어 시드가 희귀해 보이는 문제(기본 빌드가 흔한 이름의 링크를 빼 두기 때문)는 시드 가중치를 링크 청크 수와 그 이름이 등장하는 청크 수 중 큰 쪽으로 나눠 보정했고, 여러 순위 방식을 오프라인으로 비교해 "빈도 보정 관계 점수 × 질문 유사도"를 골랐습니다.
| 지표 | 기본 빌드 | 관계 형성 + 관계 근거 채널 | 조건 |
|---|---|---|---|
| 165 · 정답 규정 전부 회수 | 143 | 145(얻음 4 · 잃음 2) | 같은 검색 코드, 하나씩 측정 |
| 구조 100 · R@1 / 관계 줄 정답 | 85 / 11 | 87 / 15(잃음 0) | 같음 |
| 난이도 100 · 골드 전부 / R@5 | 88 / 85 | 90 / 89(잃음 0) | 같음 |
| 처음 보는 다리 50문항 | 41 | 43(잃음 0) | 새로 만든 세트, 기존 다리 노드 제외 |
순위 방식을 165의 놓친 23문항을 보며 골랐기 때문에 165의 향상은 표본 안 수치이고, 그래서 기존 다리 노드를 뺀 50문항을 새로 만들어 확인했습니다. 다만 끼워 넣은 관계 근거 중 정답 문서의 비율은 여전히 낮고(165에서 44개 중 2개), 관계 형성을 한 번 더 실행한 판에서는 165가 143 → 142로 나오며 뒤집히는 문항이 바뀌었습니다. 판마다 관계 집합이 달라지는 편차가 있고, 답변 정확도는 아직 재지 않았습니다.
회고와 남은 과제
배운 것은 네 가지입니다. 원하지 않는 출력은 프롬프트로 부탁하지 말고 스키마에서 표현할 수 없게 만듭니다. 반대로 디코더에 닫힌 어휘를 강제하면 모델은 기권하거나 가장 넓은 이름으로 몰리므로, 어휘는 우선하되 고정하지 않습니다. 그래프의 효과는 같은 검색 코드와 그래프 스냅샷 교대, 하나씩 측정으로만 판정할 수 있습니다. 그리고 관계 형성의 값은 관계를 많이 만드는 데서가 아니라 검색이 술어를 소비할 때 나옵니다.
남은 과제도 분명합니다. 어휘 안의 동의어와 양상 변형(includes와 includesDetail, isRequiredToProvideTo와 mustProvideTo 등)이 합쳐지지 않아 "한 뜻 한 이름"이 지켜지지 않고, isRelatedTo나 isAssociatedWith 같은 일반 이름이 프롬프트 규칙을 우회해 어휘에 들어옵니다. 새 어휘 항목을 그래프의 기본 관계 4개에 대응시켜 거르는 방법은 시험해 봤지만 manages나 uses 같은 뜻 있는 이름까지 relatedTo로 빼서 버렸습니다. 원리 있는 어휘 정규화와 답변 정확도 측정이 다음 단계입니다.