기술

LLM이 온톨로지 타입을 안 쓰고 변형을 만드는 건 어떻게 막아야 할까?

2026.04.078분 읽기

승격했는데 또 미매핑이 생긴다

온톨로지 거버넌스를 도입하고 나서 한동안은 잘 돌아갔습니다. LLM이 새 타입을 발견하면 pending으로 대기시키고, 시그널이 쌓이면 승격하는 흐름이 안정적이었거든요.

그런데 이상한 일이 벌어졌습니다. 온톨로지에 RUNS_ON이라는 관계 타입이 이미 등록돼 있는데, 문서를 임포트할 때마다 RUNS_ON_SYSTEM, HOSTED_ON, DEPLOYED_ON 같은 미매핑 타입이 계속 생기는 거예요.

승격을 해도 다음 임포트에서 또 다른 이름이 나옵니다. 미매핑 목록이 줄어드는 게 아니라 끝없이 늘어나는 거였어요.


LLM은 "이 타입만 써라"를 안 따른다

원인은 단순했습니다. 프롬프트에서 온톨로지 타입 목록을 보여주고 "이 타입을 사용하세요"라고 권고만 하고 있었거든요. 강제하지 않았습니다.

LLM 입장에서는 데이터를 보고 "이건 단순 실행이 아니라 배포니까 DEPLOYS_ON이 더 정확해"라고 자체 판단을 내리는 거예요. LLM이 틀린 게 아니라, 맥락상 더 정확한 이름을 만들려고 하는 건데, 그게 온톨로지 일관성을 깨뜨리는 결과가 됩니다.

실제로 발생한 사례를 보면 규모가 꽤 컸어요.

RUNS_ON이 있는데 → RUNS_ON_SYSTEM, HOSTED_ON, DEPLOYED_ON, PERFORMED_ON

INTERACTS_WITH가 있는데 → CALLS_SOURCE_SYSTEM, CALLS_BACKEND_SYSTEM, CALLS_SERVICE, PERFORMS_EXTERNAL_INTERACTION_VIA

FORWARDS_TO가 있는데 → TARGETS_SYSTEM, SENDS_TO

하나의 타입에서 변형이 3~4개씩 만들어지니까, 문서 10개만 넣어도 미매핑 목록이 수십 개로 불어납니다. 거버넌스로 아무리 정리해도 다음 임포트에서 새 변형이 또 생기는 악순환이었어요.

1.jpeg


프롬프트를 고쳐도 한계가 있었다

처음에는 프롬프트를 먼저 고쳤습니다. "위 목록에 있는 타입만 사용하세요", "RUNS_ON_SYSTEM 같은 변형을 만들지 마세요" 같은 지시를 추가했어요.

개선은 됐습니다. 변형이 줄어들긴 했거든요. 그런데 문서 내용이 조금만 달라지면 LLM이 또 "이건 다른 관계인데?"라고 판단하고 새 이름을 만들어냅니다. 프롬프트를 아무리 정교하게 다듬어도 100%를 보장할 수가 없었어요.

이건 LLM의 본질적인 특성이에요. LLM은 확률 기반으로 동작하니까, "절대 이것만 써라"는 지시를 100% 따르는 게 구조적으로 불가능합니다. 90%는 따라도 나머지 10%에서 변형이 계속 만들어지면, 문서가 쌓일수록 미매핑 노이즈가 누적돼요.


해결: 정규 추출과 발견을 분리한다

방향을 바꿨습니다. LLM한테 "이것만 써라"고 가르치는 걸 포기하고, 코드에서 강제하는 구조를 만든 거예요.

핵심 아이디어는 단순합니다. LLM 추출 결과를 받은 뒤, 코드가 두 갈래로 분류합니다.

온톨로지에 있는 타입 → 정규 추출 → Neo4j에 바로 저장

온톨로지에 없는 타입 → 발견(discovered)으로 분리 → 거버넌스 대기

LLM이 HOSTED_ON을 만들어내도, 온톨로지에 HOSTED_ON이 없으면 자동으로 discovered로 빠집니다. Neo4j에는 온톨로지에 등록된 타입만 들어가요.

이걸 Schema-on-Write라고 부릅니다. 데이터를 쓰는 시점에 스키마가 강제되는 원칙이에요. Palantir Foundry가 이 원칙을 쓰고 있는데, 같은 개념을 LLM 추출 파이프라인에 적용한 겁니다.

2.jpeg


이중 방어: 프롬프트 + 코드

프롬프트를 완전히 버린 건 아닙니다. 프롬프트와 코드, 두 겹으로 방어하는 구조예요.

1차 방어: 프롬프트. LLM 추출 프롬프트에 온톨로지 타입 목록을 명시하고, "이 목록에 없는 타입을 사용하지 마세요"라고 지시합니다. "RUNS_ON_SYSTEM 같은 변형을 만들지 마세요"까지 구체적으로 금지하고요. 새로 발견한 타입은 별도 _discovered 섹션에만 넣도록 안내합니다.

2차 방어: 코드. LLM이 프롬프트를 무시해도 코드가 걸러줍니다. ontology.yaml에 등록된 타입 목록과 LLM 추출 결과를 대조해서, 없는 타입은 자동으로 discovered로 이동시킵니다.

프롬프트만 있으면 LLM이 무시할 수 있고, 코드만 있으면 LLM이 의도적으로 잘못 분류할 수 있어요. 둘 다 걸어야 변형 타입이 Neo4j에 들어가는 걸 막을 수 있습니다.


ontology.yaml이 유일한 기준이 된다

이 구조에서 ontology.yaml은 Single Source of Truth 역할을 합니다. 여기에 등록된 타입만 정규 추출되고, 나머지는 전부 discovered로 빠져요.

거버넌스에서 새 타입을 승격하면 ontology.yaml에 추가되고, 다음 임포트부터 그 타입이 정규 추출에 포함됩니다. 기각하면 끝이고요. 온톨로지 파일 하나만 관리하면 추출 로직이 자동으로 따라오는 구조예요.

이전에 쓴 온톨로지 거버넌스(시그널 누적 → 승격)와 이번 Schema-on-Write(정규 추출과 발견 분리)는 세트로 돌아갑니다. 거버넌스가 "새 타입을 어떻게 관리하나"를 담당하고, Schema-on-Write가 "기존 타입을 LLM이 확실히 쓰게 하는 것"을 담당하는 거거든요.


왜 처음부터 이렇게 안 했나

돌이켜보면, 처음에는 LLM을 너무 믿었던 것 같아요. "프롬프트에 타입 목록을 주면 알아서 쓰겠지"라는 기대가 있었거든요.

실제로 LLM은 대부분의 경우 잘 따릅니다. 문제는 "대부분"이 100%가 아니라는 점이에요. 문서가 10개일 때는 변형이 2~3개 생기는 정도라 수동으로 정리할 수 있었는데, 50개, 100개가 되면 미매핑이 감당이 안 됩니다.

LLM이 추출을 담당하는 파이프라인에서는 "LLM 출력을 그대로 쓰지 않는다"는 원칙이 필요합니다. LLM은 훌륭한 추출기이지만, 스키마 일관성을 보장하는 건 코드의 역할이에요. 프롬프트는 1차 가이드이고, 코드가 최종 방어선입니다.