Engineering · Knowledge Graph

LLM 없는 개체 추출의 이름 품질: 우연에 걸린 표 판정, 탈락한 어근, 적재 계층 이름 게이트

기본 빌드가 LLM 없이 개체를 뽑게 되자 그래프 품질은 곧 이름 품질이 됐습니다. 산문을 표로 오인하던 판정, 어근과 접미사를 버리던 형태소 결합, 경로마다 흩어진 정제 로직을 원인별로 고치고, 이름 정규화와 모양 검사를 추출기가 아닌 그래프 적재 계층 한 곳으로 내렸습니다.

기간
2026.09 – 2026.10
소속
플래티어 · XGEN
역할
원인 분석·구현(백엔드)

Key Results

62종 중 61종
잘린 개체 이름 복원실컬렉션 3곳 청크 120개, 형태소 어근·접미사 결합 규칙 적용 후. 나머지 1종은 기존 이름으로 통합
23,865 → 0
스키마 엣지 수열 이름 속성을 클래스별이 아니라 술어당 하나로 선언한 전후, 그래프 뷰 페이로드 23MB → 1.8MB
163 → 5
산문·레코드를 표로 오판정한 청크규정 문서 표본 1,235청크(무작위 900 + 잡음 출처 400), 공백 표 판정 강화 전후

XGEN 온톨로지는 9월 초 빌드를 두 갈래로 나눴습니다. 업로드마다 자동으로 실행되는 기본 빌드는 LLM을 한 번도 호출하지 않고 표 구조와 형태소 분석만으로 개체를 뽑고, 관계 추출은 사용자가 켤 때만 LLM으로 실행합니다. 검색은 별도로 검증한 omnifuse 라이브러리를 그대로 쓰기로 해서, 이번 작업의 대상은 검색 알고리즘이 아니라 검색에 들어가는 개체·관계·청크 본문의 품질이었습니다. 검색이 질문 단어를 그래프 노드 라벨에 연결하고 그 1홉 이웃을 근거로 삼는 구조라, 개체 이름이 "현물환거래"가 아니라 "현물"이면 검색이 아무리 좋아도 답이 틀립니다.

LLM이 빠지자 이름을 정하는 일은 전부 규칙의 몫이 됐고, 규칙의 빈틈이 그대로 그래프에 드러났습니다. 이 글은 배포된 제품 기본 라인에 실제 문서를 올려 사용자 동선대로 검수하며 찾은 이름 결함을 원인별로 추적하고, 정제 로직을 한 모듈로 모은 뒤 이름 게이트를 추출기가 아닌 그래프 적재 계층에 둔 과정을 다룹니다. 넣었다가 실측으로 걷어낸 규칙도 함께 적습니다.

이름이 만들어지는 경로

기본 추출기는 청크 하나를 받아 세 갈래로 이름 후보를 만듭니다. HTML 표가 있으면 rowspan과 colspan을 펼친 격자로 읽고 머리글을 판정해 행을 개체로, (행, 열 이름, 셀)을 사실로 올립니다. 공백으로 칸을 맞춘 텍스트가 표처럼 보이면 행 덤프로 판정해 칸 단위로 쪼갭니다. 나머지 산문은 Kiwi 형태소 분석으로 명사구 경계를 잡아 개체 후보를 뽑고, "A, B 등의 C" 같은 Hearst 패턴으로 상하위 관계를 유도합니다.

같은 그래프에 이름을 넣는 경로는 이것만이 아닙니다. LLM 관계 형성, CSV·xlsx 정형 적재, DB 연동 적재도 노드를 만듭니다. 그래서 이름 정제를 어디에 두느냐가 처음부터 설계 문제였습니다. 최종 구조는 다음과 같습니다.

청크 텍스트
  ├─ <table> 있음 ──▶ 격자 펼치기 → 머리글 판정 → 캡션 = 행의 클래스 → 행마다 개체 1 + 사실 N
  ├─ 공백 정렬 텍스트 ──▶ 행 덤프 판정(문장·목록 줄 비율, 열 수 최빈값) → 칸
  └─ 산문 ──▶ Kiwi 명사구 · Hearst 패턴
                     │
LLM 관계 형성 · CSV/xlsx · DB 연동 ──┤
                     ▼
        적재 게이트 (그래프 저장 계층): 이름 정규화 · 모양 검사, 경로와 무관하게 같은 함수
                     ▼
        PostgreSQL 네이티브 그래프 (노드 · 엣지 · 노드-청크)

사용자 동선대로 검수하며 본 이름들

첫 신호는 같은 업로드인데 문서마다 품질이 갈린다는 것이었습니다. A 문서는 개체 32개가 전부 "여신심사위원회는", "신청을", "정한다.", "document_type" 같은 조사·마침표가 붙은 조각이었고, B 문서는 깨끗했습니다. 이름이 잘리는 사례도 있었습니다. "현물환거래"가 "현물"과 "거래"로, "준법감시인"이 "준법감시"로 잘렸는데 같은 문장의 "선물환거래"는 멀쩡했습니다. 이름이 틀리자 관계도 틀려서 "준법감시 → 감시 → 법규준수"처럼 주어가 바뀐 관계가 만들어져 있었습니다.

업로드할 때 청크 앞에 붙는 검색용 머리말(컬렉션명·파일명과 메타데이터)도 개체가 되고 있었고, LLM 프롬프트에도 그대로 실렸습니다. 머리말은 183자로, 400자 청크 기준 31%의 오버헤드였습니다. 실측한 225자 청크 하나는 본문 9자에 머리말 216자였습니다. 줄바꿈을 넘어 이름이 붙는 경우("내부감사규정"과 "감사위원회"가 "내부감사규정감사위원회"로)도 있었습니다.

그래프 뷰에서도 보였습니다. 클러스터 이름이 옛 빌드의 쓰레기 클래스인 "S"(인스턴스 687개), "29", "'18년", "내 용"으로 잡혔습니다. 9월 말 규정 문서 코퍼스에서는 "5."가 개체 574개를 거느린 클래스로, "of"와 "mm"이 독립 이름으로 나왔습니다. 10월 초 구조 패널에 보인 "3.", "-연", "│년" 같은 행은 확인해 보니 기각된 옛 실험 컬렉션의 산물이었지만, 출처를 추적하는 김에 현재 코드로 만든 기본 빌드 그래프를 같은 방법으로 보니 개체의 약 1%와 클래스 수십 개가 여전히 쓰레기 꼴이었습니다.

원인을 좁혀 간 과정

첫 번째 원인은 표 판정이었습니다. 행 덤프 판정 규칙은 "줄마다 단어 수가 일정하면 표"였는데, 실제로는 4단어 이상인 줄이 4개를 넘고 그중 둘만 단어 수가 같으면 통과했습니다. 산문도 쉽게 통과했고, 표로 판정되면 줄을 공백으로 쪼갠 토큰이 그대로 개체가 되어 조사와 마침표가 붙었습니다. B 문서는 우연히 단어 수가 같은 줄이 없어서 산문 경로로 갔을 뿐이었습니다. 품질이 "줄의 단어 수"라는 우연에 걸려 있었습니다. 표 칸은 문장으로 끝나지 않는다는 점에 착안해 마침표로 끝나는 줄이 과반이면 글로 보게 바꿨습니다. 실컬렉션 3곳의 청크 120개로 대조하니 추출 경로가 바뀐 청크는 0개였고(표·HTML 회귀 없음), 개체가 바뀐 43개 청크는 전부 산문 경로였습니다. 머리말은 추출 전에 걷어내고, 줄바꿈 접합은 형태소 위치로 줄바꿈을 보고 끊어 사라진 이름 175종 중 149종이 접합 해소분이었습니다.

이 판정은 이후에도 두 번 더 조였습니다. 9월 말에는 마침표 없이 항 번호로 이어지는 조문체 문단이 행 덤프로 오판돼, 후보 행의 어절을 품사로 판정하게 바꿨습니다(활용 용언이 있거나 어절의 1/3이 조사로 끝나면 문장). 25건 표본에서 오판 15건이 2건이 됐고 진짜 표 5건은 그대로 유지됐습니다. 10월 초에는 법령 JSON 줄과 각주·부칙 줄이 표로 판정되는 것을 찾았고, 문장·목록 항목 줄의 비율과 열 수 최빈값 조건으로 다시 조였습니다.

두 번째 원인은 형태소 결합이었습니다. 추출기는 Kiwi 분석 결과에서 명사 태그(NNG·NNP·SL·SN)만 이어서 결합했습니다. Kiwi는 "현물환거래"를 현물/NNG + 환/XR + 거래/NNG로, "준법감시인"을 준법/NNG + 감시/NNG + 인/XSN으로 나눕니다. 어근(XR)과 명사 파생 접미사(XSN)가 빠지면서 이름이 끊긴 것입니다.

세 번째 원인은 정제 로직의 분산이었습니다. 정규화, 문장 판정, 형태소 처리가 추출기, 중복 제거기, Hearst 패턴, 검색에 각각 있어서 같은 라벨이 경로마다 다르게 다듬어졌습니다. 머리말 제거가 대표적이었습니다. 처음에는 기본 추출기와 관계 추출 배치 구성만 고쳐 배포했는데, 그래프 근거 로드와 근거 블록 요약 경로 두 곳이 더 남아 있다는 것을 나중에 찾았습니다.

네 번째는 게이트의 위치였습니다. 이름 검사를 추출기 안에서만 하면 관계 형성이나 DB 적재처럼 다른 경로로 들어온 이름과 이미 적재된 옛 그래프에는 적용되지 않습니다. 뷰의 클러스터 이름이 옛 빌드의 쓰레기 클래스로 잡힌 것이 그 증거였습니다.

다섯 번째는 그 남은 1%를 청크 원문까지 거슬러 올라가 찾은 레코드 덤프였습니다. 규정 평가 코퍼스 청크의 약 70%가 {"키": 값} 형태의 법령 JSON 레코드였고, 키인 "항내용"(7,549청크), "항번호", "조문제목" 같은 이름이 최상위 개체로 들어가 그래프 후보와 PPR 시드를 흔들고 있었습니다.

검증에서도 함정이 하나 있었습니다. 게이트 수정을 배포하고 재빌드했는데 한 글자 클래스 "목"(인스턴스 1,694개)이 그대로 남았습니다. 레지스트리를 보니 배포된 이미지가 머지보다 1분 먼저 빌드돼 있었습니다. 이후로는 배포를 시각이 아니라 동작(번들에 새 문자열이 있는지, 새 규칙이 그래프에 반영됐는지)으로 확인합니다.

설계 판단: 게이트는 적재 계층에, 규칙은 품사와 분포로

게이트 위치는 두 안을 비교했습니다.

선택지 장점 단점 판단
추출기마다 정제 경로별 맞춤 처리 경로가 늘 때마다 누락, 옛 그래프 미적용, 같은 라벨이 경로마다 달라짐 기각
그래프 적재 계층 단일 게이트 모든 경로가 같은 함수를 지나고, 검색 잡음 규칙도 같은 함수 사용 기존 그래프는 재빌드해야 반영 채택

정제 함수는 한 모듈로 모았고, 라벨 정규화 결과를 모든 적재 경로에서 노드 동일성 판정의 기준으로 썼습니다. Kiwi는 LLM이 아니라 사전·통계 기반 분석기라 기본 빌드에서 써도 "LLM 0회" 원칙을 깨지 않습니다.

규칙의 종류도 정했습니다. 단어 목록이나 근거 없는 모양 규칙 대신 품사와 코퍼스 분포만 쓴다는 원칙이었고, 이 원칙은 실측으로 규칙을 걷어내면서 굳어졌습니다.

규칙 의도 실측 판단
문장형 판정 넓은 판(후보 A) 문장 조각을 개체에서 제외 운영 라벨 10,716개 중 인스턴스 781개 변경·724개 삭제 기각
문장형 판정 좁은 판(후보 D) 같은 목적, 짧은 라벨 보호 167개 변경·103개 삭제, 삭제는 설문 응답·문장뿐 채택
별칭 병합(음절 정렬 키, 이후 회전 키) 순서만 바뀐 고유명사 표기 통합 원본 764건의 덩어리 고유명사 1,646개 중 겹침 2쌍(금융리스/리스금융, 영업양도/양도영업), 둘 다 다른 말 통째 제거
라틴 두 글자는 대문자 약어만 "of"·"mm" 조각 제거 id·db·js와 표 머리글까지 지운다는 리뷰 되돌리고 진짜 출처를 수정

마지막 규칙은 원인을 잘못 짚은 사례였습니다. "of"와 "mm"의 진짜 출처는 명사구의 모든 어절을 독립 이름으로 내던 조각 생성 함수였고, 한글이 든 어절만 조각으로 내게 고쳤습니다. 같은 맥락에서 10월에 넣은 행 덤프 기준("셋에 하나", "다섯에 넷")은 표본을 보며 정한 숫자라는 자기 점검을 거쳐, 문장·목록 줄 과반과 최빈 열 수 과반이라는 비율 규칙으로 다시 정리했습니다.

구현

정제 모듈의 함수는 다섯 가지입니다.

함수 역할
형태소 분석기 싱글턴 · 토큰화 Kiwi 인스턴스를 공유해 모든 경로가 같은 분석 결과를 씀
목록 표시 제거 앞의 항목 번호·글머리(1., ①, 가), □)와 뒤의 각주 기호 제거
텍스트 정규화 NFKC, 공백 정리, 자간을 벌린 글자 복원("기 관 명" → "기관명")
라벨 정규화 위 둘을 합친 이름 표준 표기, 모든 적재 경로의 노드 동일성 기준
문장형 판정 두 어절 이상·6자 이상이면서 어미나 용언 꼬리로 끝나면 문장 조각으로 제외

문장형 판정의 두 어절·6자 조건은 "비고"나 "보고" 같은 짧은 라벨이 문장으로 오인돼 사라지던 것을 막기 위한 것입니다. 형태소 결합은 XR·XSN을 앞에 명사가 붙어 있을 때만 이어 붙이고, 홀로 선 어근(깨끗/XR)이 개체가 되는 것은 계속 막습니다. "적"·"들"처럼 뜻이 아니라 꼴만 바꾸는 접사는 제외했고(경제적, 고객들), 접사 뒤에 띄어쓰기가 오면 이름을 끊습니다.

이름 모양 검사는 열 이름·명사구·표 칸·캡션·Hearst 쌍이 모두 같은 함수를 지나게 했습니다. 시작 글자, 괄호 짝, 꼬리 구분 부호, 통째로 감싼 괄호를 보는데, 괄호는 유니코드 범주 Ps/Pe를 깊이로 짝지어 판정하고 따옴표는 Pi/Pf와 곧은 따옴표로, 글머리 기호는 괄호·따옴표가 아닌 문장 부호와 기호 범주로 판정합니다. 꼬리 부호는 구분 부호(쉼표·세미콜론·콜론)와 기호 범주만 떼서 %는 보존합니다. 처음 정리안은 괄호 개수만 세다가 괄호가 두 쌍인 회사명을 통째로 감싼 것으로 잘못 벗겨서 깊이 판정으로 바꿨습니다. 레코드 덤프 청크는 값의 글만 읽고 키·괄호·숫자·날짜·한 글자 값은 버립니다.

이름 품질은 스키마 크기와도 이어졌습니다. 표의 열 이름을 속성 노드로 되살리면서 처음에는 클래스마다 선언했는데, 부모 클래스의 속성 선언을 자식에게 복사하는 상속 전개와 곱해져 Hearst 허브 하나("가동", 자식 301개) 때문에 스키마 엣지가 23,865개, 뷰 페이로드가 23MB가 됐습니다. 단일 클래스일 때만 그 클래스를 도메인으로 쓰는 절충안도 같은 허브에서 145 × 301 복사가 일어나 44,317개가 나왔고, 결국 도메인 없이 술어당 하나(컬렉션 공통)로만 선언했습니다.

옛 빌드가 남긴 한글 술어("관련", "속한다", "정의한다")도 정리 대상이었습니다. 로컬 그래프에만 각각 144,069건, 24,067건, 2,554건이 있었습니다. 처음에는 서비스 부팅 때 영어 어휘(relatedTo, belongsTo, defines)로 일괄 변경했고, 같은 (주어, 목적어)에 두 술어가 공존해 생기는 유니크 충돌은 중복 행을 먼저 지우는 멱등 SQL로 처리했습니다(실제 PostgreSQL에서 충돌 재현과 2회차 0건 확인). 이후 리뷰에서 부팅 시 마이그레이션의 범위가 넓다는 지적을 받아 제거했고, 지금은 조회 시점에 "관련" 술어를 relatedTo로 표시합니다.

결과

지표 이전 이후 측정 조건
마침표·조사로 끝나는 개체, 머리말 개체 다수 0건 실컬렉션 3곳, 청크 120개
추출 경로가 바뀐 청크 해당 없음 0/120(표·HTML 회귀 없음) 같은 표본
잘린 이름 62종 소실 61종 온전한 이름으로 흡수, 1종 기존 이름 통합 같은 표본, 설명 안 되는 소실 0
조사로 끝나는 이름 비율 7.6% 3.5% 원본 764건 로컬 재빌드
공백 표 오판정 청크 163 5 표본 1,235청크
스키마 엣지 / 뷰 페이로드 23,865 / 23MB 0 / 1.8MB 열 이름 속성 술어당 하나
검색 3세트 회수(165 · 구조 100 · 난이도 100) 141 · 99 · 87 142 · 99 · 88 이름 위생 반영 기본 재빌드 전후, 같은 검색 코드
검색 시간 중앙값(같은 3세트) 9.5 · 9.2 · 10.0초 7.5 · 7.2 · 8.0초 같은 조건

JSON 키에서 나온 개체 종류는 12종에서 2종으로 줄었습니다. 같은 표본에서 "쓰레기 꼴 개체가 0이 됐다"는 수치도 나왔지만, 그것은 걸러 내는 규칙과 같은 기준으로 센 동어반복이라 결과 지표로 쓰지 않았습니다. 대신 위 표처럼 판정 규칙과 독립적인 검색 회수와 시간으로 회귀가 없는지 확인했습니다.

회고와 남은 과제

작업 방식에서 가장 크게 바뀐 것은 고치는 순서였습니다. 처음에는 증상 하나에 MR 하나를 붙여 배포를 여러 번 했고, 머리말은 네 경로 중 두 곳만 고친 채 배포했습니다. 정형 적재 쪽에서는 이름 열을 "name·이름·명칭" 단어 목록으로 찾다가 "기 관 명"을 놓친 결함을 포함해 같은 증상의 원인 세 겹을 MR 세 개로 따로 고쳤는데, 첫 결함 때 실제 청크 형식을 먼저 열어 봤다면 한 번에 끝날 일이었습니다. 이후로는 고치기 전에 같은 유형의 결함을 코드 전체에서 먼저 훑고, 실제 입력 형식을 먼저 확인합니다.

남은 과제도 있습니다. 흔한 단어가 수백 개 클래스의 부모가 되는 Hearst 허브("가동"이 클래스 301개의 부모), 감독·수행·처리 같은 동작 명사가 개체로 승격되는 문제(개체 68종 중 17종), "이외"·"제외"처럼 앞말을 받는 명사가 머리인 이름이 남아 있습니다. 식별 열 머리글이 클래스로 선 "Up"(인스턴스 2,033개)처럼 모양 검사로는 거를 수 없는 이름과, Kiwi가 "이륜자동차"를 이/MM + 륜/NNG로 나누는 토크나이저 한계도 그대로입니다.