프롬프트에 "이 형식으로 해라" 썼는데, LLM이 매번 다른 JSON을 뱉었습니다
DOCX 업로드했는데, Graph가 0개였습니다
긴급복구절차서(DOCX)를 지식그래프에 올리는 작업이었습니다. 문서 업로드 → 완료(completed) 표시까지 정상이었거든요. 그런데 결과를 보니 벡터는 4청크 쌓였는데 그래프 노드가 0개였습니다.
분명 절차서 안에 시스템명이 여러 개 있었어요. 호스트명도 있고, 담당자 이름도 있고. 그런데 노드가 하나도 안 잡힌 겁니다. 처음엔 "프롬프트가 잘못됐나?" 싶었는데, 파보니 그게 아니었습니다.
raw_extraction 테이블에 저장된 LLM 원본 응답을 열어봤더니 이렇게 나와 있었습니다.
{
"systems": [...],
"services": [...]
}그런데 임포트 함수(import_extracted_entities())가 기대하는 형식은 이랬어요.
{
"entities": {
"System": [...],
"Service": [...]
}
}키가 다릅니다. systems(소문자, 복수형 최상위 키) vs entities.System(대문자, 중첩 구조). 파서는 entities 키를 찾다가 없으니까 전부 스킵한 거였어요. 엔티티 0개로 끝났습니다.
프롬프트에 명시했는데 LLM이 무시했습니다
당황스러웠던 건 프롬프트에 형식을 이미 명시해놨거든요. "반드시 entities 아래에 System으로 넣어라"라고요. 예시까지 같이 넣었고요.
LLM이 그걸 무시하고 자기 판단으로 systems를 썼습니다. 아마 "시스템이 여러 개면 복수형 systems가 자연스럽지"라고 판단했겠죠. 언어 모델 입장에선 타당한 선택이에요. 문제는 그게 파서가 이해하는 형식이 아니라는 거였습니다.
엑셀 임포트는 이 문제가 없었어요. 엑셀은 규칙 기반 매핑이거든요. 행 데이터를 코드로 공통 포맷에 직접 꽂아넣기 때문에, LLM이 JSON을 자유롭게 생성할 여지 자체가 없습니다. 반면 DOCX/PPT는 LLM이 문서 전체를 읽고 자유 형식으로 JSON을 만들어내는 구조라서, 매번 출력 모양이 조금씩 달라질 수 있어요.
프롬프트는 부탁, 파서는 보험입니다
LLM 출력은 비결정적입니다. 같은 프롬프트를 10번 넣으면 8~9번은 원하는 형식으로 오는데, 1~2번은 미묘하게 다른 형식으로 옵니다. 프롬프트를 아무리 강하게 써도 100%를 보장할 수 없거든요.
그래서 방어선을 두 개로 나눴어요.
1차: 프롬프트 — "이렇게 해주세요"라고 부탁
2차: 파서 — "부탁을 안 들어준 경우에 여기서 바로잡기"
한쪽만 두면 놓칩니다. 프롬프트만 강화하면 남는 1~2%에서 터지고, 파서만 두면 LLM이 점점 이상한 형식을 뱉어도 모르는 채로 자동 변환되기만 합니다. 예전에 Schema-on-Write 글에서도 비슷한 원칙을 썼는데, 같은 발상이에요.
그리고 터진 김에 전수조사를 했어요. 같은 결의 문제가 더 있을 것 같았거든요. 실제로 있었습니다. 4단계의 방어 레이어가 필요했어요.

1단계: 키가 안 맞으면 자동 변환합니다
_normalize_entity_keys() 함수를 파서 앞단에 넣었어요. LLM이 어떤 키로 주든 우리가 기대하는 entities.{타입} 형식으로 바꿔줍니다.
systems → entities.System
services → entities.Service
최상위 키든 entities 내부 키든 양쪽 다 처리
이렇게 해두면 프롬프트 지시를 100% 따르지 않아도 저장은 됩니다. 그리고 프롬프트도 동시에 강화했어요. "반드시 entities 키 사용, systems/services 사용 금지"라고 명시했거든요. 둘 다 바꿔야 장기적으로 안정됩니다.
2단계: 속성이 중첩 Map으로 오면 평탄화합니다
키 변환 후 재임포트했더니 이번엔 Neo4j MERGE 단계에서 에러가 나더라고요.
Property values can only be of primitive types or arrays thereof.
Encountered: Map{...}LLM이 속성을 이렇게 뱉었어요.
{
"name": "nateifs01",
"properties": {
"type": "Application Server",
"group": "nateifs"
}
}"깔끔하게" 구조화하려고 properties 키 안에 type, group을 중첩시킨 거거든요. 사람이 보기엔 좋은데, Neo4j 입장에선 저장 불가입니다. Neo4j 노드 속성은 String, Integer, Float, Boolean, 그리고 이들의 배열만 허용하거든요. Map(dict) 타입은 속성 값으로 못 씁니다.
그래서 _sanitize_props() 함수를 추가했어요. 저장 직전에 속성을 Neo4j가 받을 수 있는 형태로 바꿉니다.
LLM 출력 | 변환 결과 |
|---|---|
|
|
|
|
| 무시 (2단 이상 중첩) |
|
|
LLM은 구조화하려는 경향이 있기 때문에 중첩 구조는 계속 등장합니다. 저장 직전에 타입 검증/변환 레이어를 두는 건 이제 당연한 설계라고 봐요.
3단계: 아이템을 문자열 배열로 주면 dict로 보정합니다
이건 아직 직접 터지진 않았지만 예방적으로 넣었어요. 같은 결의 문제가 더 있을 것 같아서 패턴을 쭉 뽑아봤거든요.
LLM은 엔티티를 이런 식으로도 줄 수 있습니다.
"System": ["nateifs01", "nateifs02"] — 문자열 배열
"System": "nateifs01" — 단일 문자열
"System": {"name": "nateifs01"} — 배열이 아닌 dict
전부 [{"name": "nateifs01"}, {"name": "nateifs02"}] 형태로 맞춰주는 _normalize_entity_items() 함수를 만들었어요. 한 번 문제가 터지면 비슷한 패턴이 더 있을 가능성이 높거든요. 같이 처리하는 게 나중에 또 삽질하는 것보다 비용이 낮습니다.

4단계: 엔티티 같아 보이지만 사실은 설정값입니다
이 문제는 앞의 셋과 결이 달라요. 앞의 셋은 "형식"이 문제였는데, 이건 "의미"가 문제거든요.
시스템 엑셀을 대량으로 올렸는데, 온톨로지 리뷰에서 MQConfigurationItem(22건), TypeCorrespondence(70건) 같은 낯선 타입이 미매핑으로 올라왔어요. 실제 데이터를 열어보니 MQ Queue Manager, Channel, Queue 설정값, DCMF 전문 타입 정의 같은 것들이었거든요.
LLM이 엔티티라고 판단한 기준은 이랬을 거예요.
행마다 고유한 값이 있다 ✅
구조화된 테이블이다 ✅
→ "이건 엔티티네!"
그런데 실제로는 시스템의 설정값이지 독립 엔티티가 아니에요. 도메인 지식이 없으면 LLM은 "행이 고유하면 엔티티"로 판단하는 경향이 있습니다.
판단 기준을 정리했어요.
엔티티가 맞는 경우
독립적으로 존재하는 것 (서버, 서비스, 사람)
다른 엔티티와 관계를 맺는 것
"이것의 상태가 어때?"라는 질문이 의미 있는 것
엔티티가 아닌 경우 (기각)
시스템 내부 설정값 (MQ Queue, 환경변수, 포트 번호)
데이터 정의/매핑 (전문 타입, 코드값 설명)
임시 상태값 (로그 메시지, 에러 코드 값)
이런 건 온톨로지 리뷰에서 기각하고, 해당 시트는 벡터로만 저장되도록 했어요. 엔티티로 들어가진 않지만 검색은 여전히 가능합니다. 새 도메인 문서를 처음 올리면 도메인 특화 미매핑이 대량 발생하는 건 정상이거든요. 대부분은 기각이고, 기존 관계로 표현 가능한 경우가 많았습니다.
마무리
_normalize_entity_keys(), _normalize_entity_items(), _sanitize_props() 세 개의 파서 함수와, 사람의 도메인 판단(온톨로지 리뷰)이 합쳐져서 4단계 방어 레이어가 됐습니다. 앞 3단계는 형식 문제라 코드로 자동화했고, 4단계는 의미 문제라 사람 판단이 필요합니다.
새 문서 유형에서 추출 실패가 발생하면 루틴은 단순해요. raw_extraction 테이블에서 LLM 원본 응답을 열어보고, 4단계 중 어디서 실패했는지 파악한 뒤, 해당 레이어에 방어 로직을 추가합니다. LLM은 앞으로도 다양한 형식을 뱉을 텐데, 레이어를 미리 나눠두면 새로운 변형이 나와도 어디에 방어를 추가해야 할지 금방 보입니다.
프롬프트에 "이렇게 해라"라고 명시했는데도 LLM이 안 따르는 경험이 있다면, 파서 방어를 한 번 점검해보는 걸 추천합니다.