기술

LLM한테 RUNS_ON을 쓰라고 했는데, HOSTED_ON을 만들어왔습니다

2026.04.097분 읽기

승격했는데 또 미매핑이 생겼습니다

온톨로지 거버넌스를 운영하면서 미매핑 타입을 리뷰하고, 의미 있는 것들을 승격시키는 프로세스를 만들었거든요. 처음에는 잘 동작했습니다. pending 목록에 쌓인 타입을 검토하고, 승격하고, 재임포트하면 깔끔해졌어요.

그런데 문서를 몇 번 더 임포트하니까, 승격한 타입인데도 미매핑이 또 생기는 거예요. RUNS_ON을 이미 온톨로지에 등록해놨는데, 다음 임포트에서 RUNS_ON_SYSTEM, HOSTED_ON, DEPLOYED_ON이 새로 나타났습니다.

승격해도 다음 임포트에서 또 다른 이름이 생기니까, 미매핑이 끝없이 증가하는 상황이었어요.


LLM이 프롬프트를 "권고"로 받아들입니다

원인을 찾아보니까 프롬프트에 있었습니다. 추출 프롬프트에서 온톨로지에 등록된 타입 목록을 알려주고 "이 타입을 사용하세요"라고 적어뒀거든요. 그런데 LLM은 이걸 강제가 아니라 권고로 받아들이고 있었어요.

LLM 입장에서는 데이터를 보고 "이건 단순 실행이 아니라 배포니까 DEPLOYS_ON이 더 정확해"라고 자체 판단하는 게 합리적이거든요. 실제로 발생한 변형을 보면 이렇습니다.

온톨로지에 있는 타입

LLM이 만든 변형

RUNS_ON

RUNS_ON_SYSTEM, HOSTED_ON, DEPLOYED_ON

INTERACTS_WITH

CALLS_SOURCE_SYSTEM, CALLS_BACKEND_SYSTEM, PERFORMS_EXTERNAL_INTERACTION_VIA

FORWARDS_TO

TARGETS_SYSTEM, SENDS_TO

한 타입당 변형이 3~5개씩 생기니까, 문서 10개만 넣어도 미매핑이 수십 건으로 불어납니다. 승격으로 해결할 수 있는 속도를 넘어선 거예요.

프롬프트를 더 강하게 써봤지만 근본적인 해결이 안 됐습니다. "절대 새로운 타입을 만들지 마세요"라고 적어도, LLM은 확률적으로 무시할 때가 있거든요. 프롬프트만으로는 100% 강제가 불가능하다는 걸 여기서 확인했어요.

1.jpeg


정규 추출과 발견을 분리했습니다

해결 방향은 "LLM을 더 잘 가르치는 것"이 아니라, 추출 단계 자체를 두 트랙으로 나누는 것이었습니다.

정규 추출 — 온톨로지에 등록된 타입만 사용해서 Neo4j에 바로 저장하는 트랙. 여기서는 새로운 타입을 절대 만들지 않습니다.

발견 — 온톨로지에 없는 타입을 _discovered라는 별도 섹션에 모아두는 트랙. 미매핑으로 수집되고, 온톨로지 거버넌스 대기열에 들어갑니다.

비유하자면, 정규 추출은 "채용된 직원만 출입하는 사무실"이고, 발견은 "면접 대기실"이에요. 면접 대기실에 있는 사람이 바로 사무실에 들어올 수는 없고, 채용 프로세스(승격)를 거쳐야 합니다.

이렇게 나누면 LLM이 HOSTED_ON을 만들어와도 정규 추출에는 영향을 주지 않습니다. HOSTED_ON은 자동으로 _discovered에 들어가고, 나중에 거버넌스에서 "이건 RUNS_ON이랑 같은 거니까 기각"이라고 판단하면 끝이에요.


프롬프트 + 코드, 2단계로 막기

이 분리를 실제로 동작하게 만드는 건 2단계 보호 구조입니다.

1단계는 프롬프트입니다. 추출 프롬프트에 온톨로지에 등록된 엔티티/관계 타입 목록을 명시하고, "위 목록에 없는 타입을 사용하지 마세요"라고 강제합니다. "RUNS_ON_SYSTEM, HOSTED_ON 같은 변형을 만들지 마세요"처럼 구체적인 금지 예시도 넣었어요. 새 타입을 발견했으면 _discovered 섹션에서만 쓰라고 안내합니다.

2단계는 코드입니다. LLM이 프롬프트를 무시하더라도 코드가 걸러줍니다. LLM의 추출 결과를 받으면, 온톨로지의 extraction 섹션에 등록된 타입인지 체크해요. 등록된 타입이면 정규 추출로 Neo4j에 저장하고, 등록 안 된 타입이면 자동으로 _discovered로 이동시킵니다.

프롬프트가 1차 방어선, 코드가 2차 방어선인 거예요. 프롬프트만으로는 100% 강제가 안 되지만, 코드까지 있으면 LLM이 뭘 만들어와도 정규 데이터가 오염되지 않습니다.

2.jpeg


ontology.yaml 하나가 모든 것을 결정한다

이 구조의 핵심은 ontology.yaml의 extraction 섹션이 유일한 기준이라는 점입니다. 여기에 등록된 타입만 정규 추출되고, 나머지는 전부 발견으로 분류돼요.

새로운 타입을 정규 추출에 포함시키고 싶으면, 거버넌스에서 승격한 뒤 extraction 섹션에 추가하면 됩니다. 다음 임포트부터 자동으로 정규 추출에 포함되고요. 코드를 수정할 필요가 없어요.

이 접근은 팔란티어 Foundry의 Schema-on-Write 원칙에서 배운 거예요. 팔란티어는 온톨로지에 미리 정의된 타입만 저장하고, 새 타입은 별도 프로세스로 추가하는 구조를 쓰거든요. 우리도 같은 원리를 적용해서 "정의된 것만 저장, 나머지는 대기"로 만든 겁니다.


미매핑 폭발이 멈추다

2단계 보호를 적용하고 나서 가장 큰 변화는, 승격한 타입이 다음 임포트에서 확실히 적용된다는 점이었습니다. 이전에는 RUNS_ON을 승격해도 LLM이 HOSTED_ON을 또 만들어왔는데, 이제는 코드가 HOSTED_ON을 자동으로 _discovered로 보내니까 정규 데이터에는 RUNS_ON만 남습니다.

ontology.yaml만 관리하면 추출 로직이 자동으로 적응하는 구조가 된 거예요. 프롬프트를 고치거나 코드를 수정할 필요 없이, yaml 파일에 타입을 추가하거나 빼는 것만으로 추출 범위가 바뀝니다.

LLM 추출에서 미매핑이 끝없이 늘어나는 문제를 겪고 있다면, 프롬프트를 더 정교하게 만드는 것보다 코드 레벨에서 분류하는 2단계 보호를 먼저 적용해보는 걸 추천합니다.