사내 규정집 수백 쪽을 챗봇에 연결했는데, 사용자가 “문서 폐기 대장은 몇 년 보관하나요”라고 물으면 엉뚱한 조항을 가져옵니다. RAG를 만들어 보신 분이라면 한 번쯤 겪는 일입니다. 원인은 모델이 아니라 문서를 자르는 방식인 경우가 많습니다. 글자 수로 뚝뚝 자르면 “제4조 폐기 절차”라는 제목은 이전 청크에 남고 본문만 다음 청크로 넘어가 버리거든요.
이 글은 kordoc 입문 시리즈의 마지막 5편입니다. kordoc이 처음이시면 필라글 kordoc 설치부터 사용법까지, HWP·PDF를 AI가 읽게 만드는 MCP 완전 가이드부터, 명령어는 2편 kordoc CLI 사용법을, Claude 연결은 3편 kordoc Claude Desktop 연결 방법을, 양식 자동 작성은 4편 kordoc으로 HWPX 양식 자동 작성하기를 참고하세요. 이번에는 문서를 검색 가능한 지식으로 만드는 두 가지, 구조 청크와 스캔 PDF 처리를 다룹니다.

글자 수로 자르면 왜 검색이 틀어질까
공문서는 구조가 곧 의미입니다. 제2장 보존 기간 아래 제3조가 있고, 그 아래 표가 있다면 이 표는 “보존 기간 구분”이라는 맥락 안에서만 뜻이 통합니다. 표만 덜렁 잘라 내면 “영구, 10년, 3년”이라는 숫자가 무엇의 기간인지 알 수 없습니다.
그래서 RAG용 청크에는 텍스트뿐 아니라 이 글이 문서의 어느 자리에 있는지가 함께 붙어야 합니다. kordoc의 청크 변환이 노리는 것이 바로 이 지점입니다. 제목 위계를 경로로 달아 주고, 표는 독립된 청크로 분리해 줍니다.
명령어 한 줄로 청크 만들기
사용법은 2편에서 본 옵션 하나입니다.
npx kordoc 규정.hwpx --format chunks -o 규정_chunks.json
테스트용으로 사내 문서관리 규정이라는 짧은 문서를 HWPX로 만들어 돌려 봤습니다. 장이 두 개, 조가 네 개, 보존 기간 표가 하나 들어 있는 문서입니다. 결과는 청크 12개짜리 JSON 배열로 나왔고, 각 청크에는 id, type, breadcrumb, text, blockRange, page가 들어 있었습니다. type은 제목이면 heading, 본문이면 text, 표면 table입니다. 표 청크에는 행과 열 수를 알려 주는 table 항목이 추가로 붙었습니다.
가장 중요한 필드는 breadcrumb입니다. 제2조 본문 청크의 경로는 이렇게 나왔습니다.
사내 문서관리 규정 > 제1장 총칙 > □ 제2조 적용 범위
표 청크의 경로는 사내 문서관리 규정 > 제2장 보존 기간 > □ 제3조 보존 기간 구분이었습니다. 표 단독으로는 알 수 없는 “이 표가 어느 조의 표인가”가 경로에 그대로 담겨 있습니다. 제 샘플은 보고서 프리셋으로 만들어서 조 제목이 □ 부호로 바뀌어 있었는데, 그 부호 줄도 경로에 그대로 들어갑니다. 공식 설명에도 □, ○, 하이픈 같은 개조식 항목과 1., 가., 1) 같은 번호 위계가 경로로 보존된다고 되어 있습니다. 공문서에서 흔한 구조가 대부분 커버됩니다.
임베딩에 넣을 때는 경로를 앞에 붙이세요
청크 JSON을 받았다고 끝이 아니고, 벡터DB에 넣을 문자열을 어떻게 만드느냐가 검색 품질을 좌우합니다. 저는 본문 청크 앞에 경로를 붙이는 방식을 권합니다. 라이브러리로 만들면 이렇게 됩니다.
import { parse, blocksToChunks } from "kordoc"
import { readFileSync } from "fs"
const r = await parse(readFileSync("규정.hwpx"))
const chunks = blocksToChunks(r.blocks)
const docs = chunks
.filter(c => c.type !== "heading")
.map(c => `[${c.breadcrumb.join(" > ")}]\n${c.text}`)
제2조 청크는 이런 문자열이 됩니다.
[사내 문서관리 규정 > 제1장 총칙 > □ 제2조 적용 범위]
모든 부서의 문서에 적용하며, 보안 문서는 별도 규정을 따른다.
본문 자체에는 “제2조”라는 말이 없는데도, 임베딩 문자열에는 들어갑니다. 그래서 “적용 범위가 어떻게 되나요”라는 질문과 이 청크가 가까워집니다. 제목만 있는 청크는 내용이 없어서 위 코드처럼 걸러 냈습니다. 실제로 제 샘플에서도 “제1조 목적” 같은 소제목은 본문과 별개의 짧은 청크로 나왔고, 그대로 임베딩하면 검색 결과만 지저분해집니다.
청크 크기는 kordoc이 아니라 내가 정합니다
여기서 하나 분명히 알아 둘 점이 있습니다. kordoc의 MCP 도구 설명에는 자르기, 즉 토큰 상한과 오버랩은 소비자 몫이라고 적혀 있습니다. kordoc은 문서 구조에 따라 나눠 줄 뿐이고, 한 청크가 너무 길면 그것을 다시 쪼개는 것은 내가 해야 합니다. 한 조항이 몇 쪽이나 이어지는 규정집이라면 구조 청크를 받은 뒤 임베딩 모델의 입력 한도에 맞춰 한 번 더 자르는 단계가 필요합니다. 이때도 경로는 쪼개진 조각마다 붙여 주세요.
반대로 짧은 문단이 줄줄이 이어지는 경우를 위한 옵션이 있습니다. blocksToChunks의 granularity입니다. 기본값 section은 같은 경로 아래의 연속된 본문을 하나로 합치고, block은 문단 하나를 청크 하나로 만듭니다. 점검 안내 문서에 같은 제목 아래 두 문장을 넣고 비교해 봤더니 section은 청크 1개, block은 2개가 나왔습니다. 문서마다 문단 길이가 다르니 두 가지로 모두 돌려 보고 검색 결과가 좋은 쪽을 고르세요. CLI 도움말에는 이 옵션이 보이지 않았고, 라이브러리와 MCP의 parse_chunks 도구에서 지정할 수 있습니다.
표 청크는 셀 단위로도 받을 수 있습니다
표는 독립 청크가 되어 마크다운 표 텍스트로 나옵니다. 보존 기간 표는 머리 행과 데이터 행이 그대로 파이프 표가 되었습니다. 여기에 includeTableCells를 켜면 셀 텍스트를 2차원 배열로도 받습니다.
const withCells = blocksToChunks(r.blocks, { includeTableCells: true })
제 표는 첫 행이 ["구분", "보존 기간", "비고"]이고 다음 행이 ["영구", "영구", "정관, 이사회 의사록"]인 배열로 나왔습니다. 표를 문장으로 풀어 임베딩하거나, 특정 열만 메타데이터로 쓰고 싶을 때 유용합니다. MCP에서는 include_table_cells 옵션으로 같은 기능을 씁니다.
병합 셀이 많은 표는 --table-format gfm 옵션이 도움이 된다고 공식 문서에 안내되어 있습니다. HTML 없이 모든 표를 파이프 표로 통일하고, 셀 안에 표가 들어 있으면 부모 표 뒤로 꺼내 관계를 표시하는 방식입니다. 제 샘플 표는 단순해서 옵션을 줘도 결과가 같았으니, 복잡한 표에서의 차이는 직접 확인하시길 권합니다.
Claude에 연결해 두셨다면 대화로도 됩니다. “이 규정 파일을 kordoc으로 청크로 나눠서 breadcrumb까지 보여 줘”라고 하면 parse_chunks 도구를 호출합니다.
스캔한 PDF는 에러 없이 조용히 비어 나올 수 있습니다
이제 두 번째 주제입니다. 공문서를 모으다 보면 종이를 스캔해서 만든 PDF가 반드시 섞여 있습니다. 눈에는 글씨인데 파일 안에는 사진만 있고 글자층이 없는 파일입니다.
테스트를 위해 신청서 내용을 이미지로 그려서 글자층 없는 PDF를 만들었습니다. 그리고 OCR 옵션 없이 변환해 봤는데, 예상과 달리 에러가 나지 않았습니다. 변환은 성공으로 끝났고, 결과물에는 글자 하나 없이 라는 그림 자리 표시만 남았습니다. 자동화 파이프라인에서 이걸 모르고 넘어가면, 내용이 텅 빈 문서가 지식베이스에 조용히 들어갑니다.
다행히 신호는 결과 안에 들어 있습니다. JSON 형식으로 받아 보니 isImageBased가 true로 나왔고, 경고 목록에 NEEDS_OCR가 붙어 있었습니다. 메시지는 이미지 기반 PDF라서 텍스트가 0자이고 OCR이 필요하다는 내용이었습니다. 쪽별 품질 정보에서도 needsOcr가 true, 사유는 low_text로 표시됐고, 전체 요약의 ocrCandidatePages에는 OCR이 필요한 쪽 번호가 담겨 있었습니다.
npx kordoc 스캔본.pdf --format json --no-images
그래서 대량 변환 파이프라인에서는 변환 결과의 warnings에 NEEDS_OCR가 있는지, isImageBased가 true인지를 꼭 검사해서 OCR 대상 목록으로 따로 빼야 합니다. 공식 문서에는 라이브러리에서 parsePdf가 돌려주는 qualitySummary.needsOcr를 보고 OCR 옵션으로 다시 파싱하는 예제가 나와 있습니다. 사유 값으로는 제가 본 low_text 말고도 high_pua, high_control, high_replacement, garbled_hangul, vector_text가 있습니다. 글자층은 있는데 깨진 문자로 가득한 PDF도 걸러 낼 수 있다는 뜻입니다.
OCR은 내 컴퓨터에서, 모델은 처음에 한 번 받습니다
OCR을 켜는 방법은 2편에서 소개한 대로 간단합니다.
npx kordoc 스캔본.pdf --ocr
kordoc의 OCR은 외부 서비스에 파일을 보내지 않고 로컬 CPU에서 한국어 PP-OCRv5 모델로 읽는 방식이고, API 키도 필요 없습니다. 기본 동작은 글자층이 없거나 깨진 쪽만 골라서 읽는 것이며, 모든 쪽을 다시 읽으려면 --ocr-force를 씁니다. 도움말을 보면 PNG, JPG, WebP 같은 이미지 파일은 OCR이 자동으로 적용된다고 되어 있습니다.
[여기에 직접 OCR을 돌린 결과를 넣어 주세요. 변환 전 스캔 이미지와 변환된 마크다운을 나란히 비교한 캡처, 틀리게 읽은 글자가 있었는지, 표가 복원되었는지에 대한 소감을 적으시면 됩니다.]
모델을 못 받는 환경에서 만난 경고
여기서 제가 직접 만난 상황을 공유합니다. 처음 OCR을 돌릴 때 모델을 내려받아야 하는데, 외부 통신이 막힌 환경에서는 받지 못했습니다. 그때 결과는 이랬습니다. 변환은 끝났지만 경고 목록에 OCR_FAILED가 붙었고, 메시지는 “OCR 실행 불가: fetch failed”였습니다. 본문은 앞서 본 것처럼 그림 자리 표시만 남았습니다.
모델 상태를 확인하는 명령도 써 봤습니다.
npx kordoc models --status
결과에는 텍스트 OCR 그룹의 모델 파일이 나열되고, 파일마다 존재 여부와 검증 여부가 표시됩니다. 받지 못한 제 환경에서는 글자 위치를 찾는 검출 모델이 약 5MB, 한국어 글자 인식 모델이 약 13MB로 나왔고 모두 없다고 표시됐으며 전체 준비 상태는 false였습니다. 모델은 홈 디렉터리 아래 .cache/kordoc/models/ppocr 폴더에 저장됩니다.
회사 폐쇄망에서 쓰시는 분이라면 이 부분이 핵심입니다. 공식 문서에는 KORDOC_OFFLINE=1을 주면 모든 외부 통신이 막히고, 폐쇄망에서는 인터넷이 되는 곳에서 모델을 내보낸 뒤 안쪽에서 가져오는 방법이 models 명령의 내보내기와 가져오기 옵션으로 제공된다고 나와 있습니다. 저는 이 내보내기와 가져오기를 직접 돌려 보지는 못했습니다. 폐쇄망에 도입하실 계획이라면 모델 반입을 가장 먼저 시험해 보세요. 이 단계가 막히면 OCR 파이프라인 전체가 멈추기 때문입니다.
스캔 품질에 맞는 OCR 모델 고르기
기본 모델이 잘 읽지 못하는 문서를 위해 옵션이 몇 개 더 있습니다. 도움말에 나온 내용을 정리하면, 글줄을 찾는 검출 모델은 기본인 mobile 외에 약 88MB짜리 server와 약 62MB짜리 medium이 있습니다. medium은 기울어지거나 흐린 스캔 입력용이라고 설명되어 있고, 모두 처음 쓸 때 내려받습니다. 글자 인식 쪽에는 손글씨가 많은 서류를 위한 handwriting 모델이 약 15MB 크기로 있습니다. 다만 공식 문서에는 장평이 좁은 숫자나 1/2 같은 쪽 표기는 기본 모델보다 잘못 읽는 경우가 있다고 적혀 있어서, 손글씨 모델이 항상 낫다고 생각하시면 안 됩니다.
npx kordoc 수기신청서.pdf --ocr --ocr-rec handwriting
npx kordoc 흐린스캔.pdf --ocr --ocr-det medium
글자 위치와 신뢰도를 알고 싶을 때는 --ocr-lines를 JSON 출력과 함께 쓰면 OCR이 읽은 줄마다 상자와 신뢰도가 나옵니다. 신뢰도가 낮은 줄만 사람이 검수하는 흐름을 만들 때 쓸 수 있습니다. PDF 속 수식을 읽는 --formula-ocr도 있지만 모델이 약 155MB로 크니, 수식이 정말 필요한 문서에만 켜세요.
정리하면 이런 파이프라인이 됩니다
지금까지의 내용을 흐름으로 묶으면 이렇습니다. 먼저 모든 문서를 JSON 형식으로 변환해서 품질 신호를 봅니다. NEEDS_OCR나 isImageBased가 걸린 파일은 OCR 대상 목록에 올리고 --ocr로 다시 변환합니다. 그다음 본문이 확보된 문서는 청크로 바꾸고, 본문 청크마다 경로를 앞에 붙여 임베딩 문자열을 만듭니다. 제목만 있는 청크는 거르고, 한 청크가 너무 길면 직접 한 번 더 쪼갭니다. 벡터DB에는 경로와 쪽 번호, 청크 id를 메타데이터로 함께 저장해 두면 답변에 출처 위치를 보여 주기 좋습니다.
OCR과 청크 변환을 한 번에 묶어 실행하는 것도 옵션상으로는 함께 쓸 수 있게 되어 있지만, 저는 모델을 받지 못해서 끝까지 이어서 돌려 보지는 못했습니다. 직접 시험해 보시고 결과를 확인하세요.
마무리, 입문 가이드 5편을 마치며
시리즈를 한 줄로 요약하면 이렇습니다. kordoc은 한국 문서를 읽고, 채우고, 만들고, 고치는 일을 한 도구로 처리하게 해 주고, 마지막에는 그 결과를 AI가 검색할 수 있는 지식으로 바꿔 줍니다.
이번 5편에서 기억하실 것은 세 가지입니다. 청크는 --format chunks로 만들고 경로를 앞에 붙여 임베딩한다는 것, 청크 크기 조절은 내 몫이라는 것, 스캔 PDF는 에러 없이 비어서 나올 수 있으니 NEEDS_OCR 신호를 반드시 검사한다는 것입니다. 이 세 가지만 지켜도 RAG가 엉뚱한 조항을 가져오는 일이 눈에 띄게 줄어듭니다.
이 5편 시리즈를 끝까지 따라오셨다면 이제 HWP 한 장을 열어 놓고 막막하던 상태에서, 문서를 자동으로 변환하고 AI에 연결하고 검색까지 붙일 수 있는 지도를 갖게 되셨습니다. 가진 문서 한 묶음으로 오늘 바로 청크 변환부터 해 보세요.