건축 법규검토 AI에서 “4층 이하"와 “4층 이상"을 혼동하면 어떻게 될까? 높이 상한이 뒤집혀 불법 건축물이 합법으로 판정된다. 이 글은 그 한 글자 차이를 잡기 위한 여정이다.
문제: PDF 표가 검색되지만 신뢰하기 어렵다
건축 법규검토 시스템은 지구단위계획 고시, 설계 지침서 등 건축 관련 PDF를 분석하여 건폐율, 용적률, 높이제한 등의 기준을 추출한다. PDF 전처리 파이프라인은 Docling을 사용해 문서를 파싱하고, 텍스트를 청킹한 후 임베딩을 생성하여 하이브리드 검색(키워드 + 시맨틱)을 지원한다.
Docling의 HierarchicalChunker는 표 내용도 마크다운 형태로 청킹하여 검색 인덱스에 포함한다. 표가 아예 빠지는 건 아니다. 문제는 그 마크다운의 품질이었다.
- 병합 셀 구조가 깨지면서 “건폐율 60%“가 어떤 가구번호에 해당하는지 관계가 사라진다
- OCR 오류(“이하” → “이상”, “커” → “키”)가 검색 결과에 그대로 노출된다
- 에이전트가 “주1 건폐율"을 검색해서 청크를 찾아도, 그 값이 맞는지 신뢰할 수 없다
지구단위계획 고시의 건폐율/용적률/높이 기준은 대부분 복합 표에 존재하기 때문에, 이는 치명적인 문제였다.
테스트 대상: 대구연호 공공주택지구 지구계획 고시
테스트에 사용한 PDF는 「국토교통부고시 제2024-598호」(54페이지)로, Docling이 추출한 표는 총 109개, 54개 전 페이지에 분포한다.
아래는 핵심 표가 포함된 36페이지의 실제 PDF 이미지다:
그림 1. 고시 PDF 36페이지 — 가구별 건폐율/용적률/높이 기준표. 병합 셀이 복잡하게 얽혀 있다.
Docling 마크다운의 한계
Docling은 PDF의 표를 2D 그리드(행×열 배열)와 마크다운으로 추출한다. 단순한 표에서는 잘 동작하지만, 건축 고시 PDF의 복합 표에서는 병합 셀 관계가 손실되고, 한국어 OCR 오류(“커” → “키”)가 발생하며, 어떤 값이 어떤 가구에 해당하는지 관계가 불분명해진다.
접근: Vision + OCR 하이브리드
왜 Vision인가?
LLM의 Vision 기능은 이미지를 직접 보고 해석한다. PDF 페이지를 이미지로 렌더링하면, 사람이 표를 읽는 것과 동일한 방식으로 병합 셀의 시각적 경계를 인식하고, 행과 열 간의 논리적 관계를 파악하여 구조화된 JSON으로 출력할 수 있다.
하지만 Vision만으로는 부족했다.
Vision-only의 한계: 체계적 오류
Bedrock Claude Haiku 4.5로 Vision-only 테스트를 진행했을 때, 높이와 용적률 필드에서 체계적인 “이하” → “이상” 오류가 발생했다.
36페이지 실제 Vision-only 결과 (오류 부분 발췌):
{
"구분": "공1, 공2",
"건폐율": "60% 이하",
"용적률": "400% 이하",
"높이": "20층 이상" // ← 원본: "20층 이하"
},
{
"구분": "공3, 공4",
"건폐율": "60% 이하",
"용적률": "400% 이하",
"높이": "10층 이상" // ← 원본: "10층 이하"
},
{
"구분": "초1",
"건폐율": "60% 이하",
"용적률": "200% 이하",
"높이": "5층 이상" // ← 원본: "5층 이하"
},
{
"구분": "키1 ~ 키2", // ← 원본: "커1, 커2"
"건폐율": "60% 이하",
"용적률": "400% 이하",
"높이": "8층 이상" // ← 원본: "8층 이하"
}
37페이지에서도 용적률 1건의 오류가 발생했다:
{
"구분": "기타1",
"건폐율": "60% 이하",
"용적률": "200% 이상", // ← 원본: "200% 이하"
"높이": "4층 이하"
}
흥미로운 점은 건폐율은 모두 정확했다는 것이다. “이하"와 “이상"의 시각적 유사성이 문제인데, 특히 높이 필드에서 집중적으로 오류가 발생했다.
하이브리드: 이미지 + OCR 텍스트 교차 검증
핵심 아이디어: Docling OCR은 텍스트 인식에 강하고, Vision은 구조 인식에 강하다. 둘을 결합하면?
| 능력 | Docling OCR | Vision |
|---|---|---|
| 텍스트 인식 (“이하”/“이상”) | 강함 | 약함 |
| 표 구조 파악 (병합 셀) | 약함 | 강함 |
| 행-열 관계 이해 | 약함 | 강함 |
Docling이 이미 추출한 마크다운을 OCR 텍스트로 함께 제공하고, Vision에게 “이미지의 구조를 보되, 텍스트는 OCR과 교차 검증하라"고 지시하면 된다.
프롬프트 엔지니어링: 한 글자의 차이를 잡다
하이브리드 방식을 도입했지만, 첫 번째 프롬프트에서는 여전히 “이하” → “이상” 오류가 발생했다. 프롬프트에 “이미지의 시각 정보를 우선하되"라고 썼기 때문이다. Haiku는 이 지시를 충실히 따라 OCR의 올바른 “이하"를 무시하고 이미지의 “이상"을 채택했다.
Claude 프롬프트 엔지니어링 가이드를 참고하여 프롬프트를 재설계했다.
적용한 원칙들
1. <role> + WHY (왜 정확해야 하는지)
<role>
지구단위계획 고시 PDF에서 표를 구조화 JSON으로 추출하는 전문가입니다.
이미지와 OCR 텍스트가 함께 제공됩니다.
건축 법규검토에 사용되므로 "이하"와 "이상"의 구분이 정확해야 합니다.
</role>
단순히 “정확하게 하라"가 아니라, 왜 정확해야 하는지(건축 법규검토)를 명시했다. Claude(LLM)는 맥락이 주어졌을 때 더 환각(Hallucination)을 줄일 수 있다.
2. <workflow> + 도메인 힌트
<workflow>
1. 이미지에서 표의 구조(행/열/병합)를 파악합니다.
2. 각 셀의 텍스트를 읽되, OCR 텍스트와 교차 검증합니다.
건폐율/용적률/높이 셀에서 "이하"와 "이상"이 나오면,
OCR 텍스트의 해당 셀과 반드시 비교하세요.
지구단위계획에서 건폐율·용적률·높이는 상한 규제이므로 "이하"가 일반적입니다.
3. 아래 JSON 스키마에 맞게 출력합니다.
</workflow>
핵심은 두 가지다:
- 교차 검증 지시: “반드시 비교하세요"로 OCR 텍스트를 참조하도록 강제
- 도메인 힌트: “상한 규제이므로 이하가 일반적” — 모델이 확신이 없을 때 올바른 방향으로 기울게 함
3. <examples> — Good/Bad 대비
<examples>
<good_example>
OCR: " 이하 60%" → "건폐율": "60% 이하"
OCR: " 층 이하 4" → "높이": "4층 이하"
</good_example>
<bad_example>
OCR에 "이하"인데 "이상"으로 출력
→ 건축 규제 방향이 반대가 되어 법규검토 오류 발생
</bad_example>
</examples>
Bad example에서 **결과(consequence)**를 명시한 것이 중요하다. “법규검토 오류 발생"이라는 실질적 피해를 알려주면 모델이 해당 패턴을 더 강하게 회피한다.
결과: 오류 6건 → 0건
실제 테스트 결과 (Bedrock Claude Haiku 4.5):
| 페이지 | 표 유형 | Vision-only 오류 | 하이브리드 오류 |
|---|---|---|---|
| 36 | 건폐율/용적률/높이 | 높이 4건 + 가구번호 1건 | 0건 |
| 37 | 건폐율/용적률/높이 | 용적률 1건 (“이상”) | 0건 |
| 38 | 면적조서 | 0건 | 0건 |
| 7 | 토지공급계획 | 0건 | 0건 |
| 40 | 도로현황 | 0건 | 0건 |
36페이지의 실제 추출 결과를 나란히 비교하면 차이가 명확하다:
Vision-only — 높이 필드 4건 전부 “이상” 오류, 가구번호 “커”→“키” 오류
{"구분": "공1, 공2", "건폐율": "60% 이하", "용적률": "400% 이하", "높이": "20층 이상"},
{"구분": "공3, 공4", "건폐율": "60% 이하", "용적률": "400% 이하", "높이": "10층 이상"},
{"구분": "초1", "건폐율": "60% 이하", "용적률": "200% 이하", "높이": "5층 이상"},
{"구분": "키1 ~ 키2", "건폐율": "60% 이하", "용적률": "400% 이하", "높이": "8층 이상"}
하이브리드 — 전 항목 정확, 가구번호도 “커"로 올바르게 추출
{"구분": "공1, 공2", "건폐율": "60% 이하", "용적률": "400% 이하", "높이": "20층 이하"},
{"구분": "공3, 공4", "건폐율": "60% 이하", "용적률": "400% 이하", "높이": "10층 이하"},
{"구분": "초1", "건폐율": "60% 이하", "용적률": "200% 이하", "높이": "5층 이하"},
{"구분": "커1, 커2", "건폐율": "60% 이하", "용적률": "400% 이하", "높이": "8층 이하"}
OCR 텍스트가 텍스트 인식의 기준점(Anchor) 역할을 수행하며, 높이와 가구번호의 오류가 모두 교정되었다.
그림 2. 37페이지 — 종교시설/기타 가구의 규모 기준. Vision-only에서 용적률 “200% 이상” 오류가 발생했으나, 하이브리드에서는 정확히 “200% 이하"로 추출되었다.
비용 비교 (실측)
| 모델 | 방식 | 페이지 | 토큰 (in+out) | 비용 | 소요시간 |
|---|---|---|---|---|---|
| Haiku 4.5 | Vision-only | 36 | 2,020+1,402 | $0.0090 | 11.9s |
| Haiku 4.5 | 하이브리드 | 36 | 7,463+1,478 | $0.0149 | 11.3s |
| Haiku 4.5 | Vision-only | 37 | 2,020+1,519 | $0.0096 | 12.4s |
| Haiku 4.5 | 하이브리드 | 37 | 3,918+945 | $0.0086 | 9.8s |
하이브리드는 OCR 텍스트가 추가되어 입력 토큰이 늘지만, 출력 정확도가 높아 재시도가 불필요하다. 페이지당 평균 비용은 $0.009~0.015 수준이다. (Haiku 4.5 공식 단가: $1.00/MTok input, $5.00/MTok output)
참고로 Sonnet 4.5 Vision-only도 테스트했다. “이하”/“이상"은 정확했지만 가구번호 “커"를 “기"로 오인식했고, 비용은 $0.028/페이지(Haiku의 3배), 소요시간은 18.3초(1.5배)였다. 프롬프트 엔지니어링과 OCR 하이브리드를 결합하면, 이 작업에서는 Haiku만으로도 충분했다.
아키텍처: 검색 레이어를 건드리지 않는 통합
핵심 설계
Docling의 HierarchicalChunker가 이미 표 마크다운을 청킹하고 있으므로, Vision이 생성한 고품질 flat_text를 추가 청크로 같은 인프라(docling_chunks + docling_embeddings)에 저장한다.
Docling 마크다운 청크 → docling_chunks (기존, 품질 낮음)
Vision 추출 flat_text → docling_chunks (추가, 구조화된 고품질)
→ docling_embeddings (임베딩 벡터)
이렇게 하면:
- Docling 청크와 Vision 청크가 공존 — 검색 시 더 정확한 Vision 청크가 상위에 랭킹
- 기존 키워드/시맨틱 검색 코드 변경 0줄
- Vision이 실패해도 Docling 청크가 fallback으로 동작
구조화 JSON은 docling_tables.structured_data에 별도 저장하여, 향후 프로그래밍 방식의 값 추출이나 프론트엔드 표 렌더링에 활용한다.
파이프라인
PDF 업로드
│
├─ 1. S3 다운로드 (pdf_bytes 보존)
├─ 2. Docling 변환 (OCR + 레이아웃 분석)
├─ 3. 테이블 추출 (DoclingTable + 마크다운)
├─ 4. 이미지 추출
│
├─ 5. ★ Vision 표 구조화 (NEW)
│ │
│ ├─ 페이지별 그룹핑 (같은 페이지의 표들 묶음)
│ ├─ PyMuPDF로 페이지 → PNG 이미지
│ ├─ Docling 마크다운을 OCR 텍스트로 사용
│ ├─ Bedrock Converse API (Haiku 4.5) 호출
│ │
│ ├─ structured_data → docling_tables에 저장
│ ├─ flat_text → docling_chunks에 저장
│ └─ embedding → docling_embeddings에 저장
│
├─ 6. HierarchicalChunker 텍스트 청킹
├─ 7. 임베딩 생성 (텍스트 + Vision 표 통합)
└─ 8. DB 저장
flat_text: 어떤 표든 검색 가능한 평문으로
Vision이 출력하는 구조화 JSON은 표 유형마다 다르다. 이를 재귀적으로 평문화하는 structured_json_to_flat_text 함수가 모든 구조를 처리한다:
def _flatten_dict(obj, lines, depth=0):
indent = " " * depth
if isinstance(obj, dict):
for key, val in obj.items():
if isinstance(val, (dict, list)):
lines.append(f"{indent}{key}:")
_flatten_dict(val, lines, depth + 1)
else:
lines.append(f"{indent}{key}: {val}")
elif isinstance(obj, list):
for item in obj:
_flatten_dict(item, lines, depth)
36페이지 하이브리드 추출의 실제 flat_text 출력:
[변경 - 제20869호 관보 2024-11-08]
위치: 주1 ~ 주5
용도:
허용용도: 「주차장법」 제2조 제1호의 노외주차장, ...
불허용도: 허용용도 이외의 용도, ...
규모:
구분: 주1
건폐율: 60% 이하
용적률: 200% 이하
높이: 4층 이하
구분: 주2
건폐율: 70% 이하
용적률: 500% 이하
높이: 12층 이하
키워드 “건폐율 60%”, “높이 4층” 등으로 검색하면 해당 청크가 매칭된다.
Graceful Degradation
Vision API 호출은 외부 서비스 의존성이므로, 실패에 대비한 fallback을 설계했다:
if extract_tables and file_result.tables and pdf_bytes:
try:
vision_chunks, vision_embeddings, updated_tables = refine_tables_with_vision(
pdf_bytes=pdf_bytes,
tables=file_result.tables,
source_id=source_id,
file_id=file_id,
)
# Vision 결과 반영
except Exception as e:
logger.warning(f"[Vision] 표 구조화 실패 (Docling 원본 유지): {e}")
Vision이 실패하면:
- Docling의 원본 마크다운이 그대로 유지
- 표가 검색에서 누락되는 기존 동작으로 fallback
- 텍스트 청킹/임베딩은 정상 진행
즉, Vision은 **추가 가치(additive)**이지 **필수 의존성(dependency)**이 아니다.
비용 분석
테스트 PDF(54페이지, 표 125개, 61개 고유 페이지) 기준 예상 비용:
| 항목 | 산출 근거 | 비용 |
|---|---|---|
| Docling 처리 (CPU) | 로컬 실행 | ~$0 |
| Vision 표 추출 (Haiku) | 54페이지 × $0.011/페이지 | ~$0.59 |
| 텍스트 임베딩 (Cohere) | 기존 파이프라인 | ~$0.05 |
| 합계 | ~$0.64/PDF |
표가 포함된 페이지에서만 Vision을 호출하므로, 텍스트 위주의 PDF에서는 비용이 거의 발생하지 않는다.
결론
- 문제: Docling 마크다운의 낮은 표 품질(병합 셀 구조 손실, OCR 오류 “이하”→“이상”)
- 해결: 멀티모달 Vision + Docling OCR 하이브리드 → 구조화 JSON + 검색용 flat_text
- 핵심: 프롬프트 엔지니어링으로 Haiku급 모델에서도 오류 0건 달성
<role>+ WHY: “건축 법규검토에 사용되므로”<workflow>+ 도메인 힌트: “상한 규제이므로 이하가 일반적”<examples>: Good/Bad + consequence
- 교훈: 단순히 이미지를 신뢰하기보다, OCR과의 교차 검증을 강제하고 도메인 맥락(Why)을 주입하는 것이 훨씬 효과적이다.
검색 레이어를 건드리지 않고, 기존 파이프라인에 한 단계를 추가하는 것만으로 PDF 표의 검색 품질이 근본적으로 개선되었다. 가장 우아한 해결책은 때로 최소한의 코드 변경만으로 완성된다.