Field Log · Entry
RAG 문서 수집: PDF·HTML을 검색 가능한 데이터로 바꾸기 (1/10)
오늘의 결론
- RAG의 첫 검색 대상은 PDF 파일이 아니라 검증된 document element입니다.
- 본문만 뽑지 말고 page, heading, table, bounding box, source, ACL을 함께 보존합니다.
- 원본 snapshot과 parser version이 없으면 파싱 개선 전후를 재현할 수 없습니다.
- OCR은 모든 문서에 켜는 기본값이 아니라 text layer가 실패했을 때 쓰는 분기입니다.
- 검색 모델을 바꾸기 전에 “정답 문장이 index 입력에 존재하는가?”를 먼저 확인합니다.
첫 RAG를 만들 때는 loader.load()가 돌면 ingestion이 끝난 것처럼 보입니다. 그러나 실제 문서는 깨끗한 문자열이 아닙니다. PDF에는 읽기 순서가 뒤섞인 두 단 편집, 반복 머리말, 표, 각주와 스캔 이미지가 있고, HTML에는 메뉴·광고·쿠키 배너가 섞여 있습니다.
검색이 정답을 못 찾는 이유가 embedding이 아니라 정답 문장이 파싱 과정에서 사라졌기 때문일 수 있습니다. retrieval을 공부하는 두 번째 묶음은 이 가장 앞단부터 시작합니다.
그림 1. parser의 출력은 한 덩어리 text가 아니라 provenance와 구조를 가진 element이며, quality gate를 통과한 revision만 index로 보낸다.
Ingestion은 검색보다 먼저 실행되는 데이터 pipeline이다
RAG에는 서로 다른 두 실행 경로가 있습니다.
Offline ingestion
source 발견 → 원본 저장 → 형식 판별 → parsing/OCR → 정규화
→ 품질 검사 → element/chunk 생성 → embedding/index
Online query
질문 → retrieval → reranking → context → generation
질문이 들어올 때마다 PDF를 다시 읽지 않습니다. 문서가 추가되거나 수정될 때 offline pipeline을 실행하고, 그 결과를 version이 있는 검색 자산으로 저장합니다.
여기서 중요한 경계는 다음과 같습니다.
| 단계 | 입력 | 출력 | 대표 실패 |
|---|---|---|---|
| fetch | URL·파일·object key | immutable snapshot | 최신 revision 누락 |
| classify | bytes·MIME | parser route | 확장자만 믿고 오분류 |
| parse | PDF·HTML·image | layout element | 읽기 순서·표 붕괴 |
| normalize | raw element | searchable text | 숫자·구조 과도 삭제 |
| quality gate | element set | pass/quarantine | 빈 문서를 정상 처리 |
| publish | validated revision | index input | ACL·version 누락 |
PDF는 문단 파일이 아니다
PDF는 페이지에 glyph와 선, 이미지를 어디에 그릴지 기록하는 형식입니다. 사람이 보는 제목·문단·표가 항상 의미 구조로 저장돼 있지는 않습니다.
예를 들어 두 단 문서는 내부 object 순서대로 text를 붙이면 이렇게 될 수 있습니다.
사람이 읽는 순서
왼쪽 제목 → 왼쪽 문단 A → 왼쪽 문단 B → 오른쪽 문단 C
잘못 추출된 순서
왼쪽 제목 → 오른쪽 문단 C → 왼쪽 문단 A → 각주 → 왼쪽 문단 B
따라서 PDF parser를 고를 때 “text를 뽑는가?”보다 다음을 확인해야 합니다.
- page별 block과 좌표를 제공하는가?
- heading, paragraph, list, caption을 구분하는가?
- multi-column reading order를 복원하는가?
- table의 row와 column 관계를 보존하는가?
- text layer가 없는 page를 감지하는가?
- 원문 위치로 되돌아갈 page와 bounding box가 남는가?
Docling 같은 layout-aware parser가 별도 layout 분석과 table structure model을 두는 이유가 여기에 있습니다. 특정 library가 모든 문서에서 정답인 것은 아니므로, 자신의 문서 fixture로 비교해야 합니다.
먼저 문서 계약을 정의한다
parser library의 반환값을 그대로 application 전체에 흘리지 않습니다. 내부에서 사용할 최소 계약을 먼저 만듭니다.
from dataclasses import dataclass
from typing import Literal
ElementType = Literal[
"title", "heading", "paragraph", "list_item",
"table", "caption", "footnote", "code", "image_text",
]
@dataclass(frozen=True)
class SourceDocument:
source_id: str
revision_id: str
uri: str
media_type: str
checksum: str
fetched_at: str
parser_name: str
parser_version: str
acl: tuple[str, ...]
@dataclass(frozen=True)
class Element:
element_id: str
source_id: str
revision_id: str
page: int | None
kind: ElementType
text: str
heading_path: tuple[str, ...]
bbox: tuple[float, float, float, float] | None
ordinal: int
이 계약이 있으면 parser를 교체해도 이후 chunker는 같은 입력을 받습니다. 또한 답변 인용을 source_id + page + bbox로 원문까지 연결할 수 있습니다.
source_id와 revision_id를 나누는 이유
source_id: 같은 논리 문서를 계속 가리키는 IDrevision_id: 특정 시점의 immutable 내용과 parser 결과를 가리키는 IDelement_id: 특정 revision 안의 element를 가리키는 ID
파일 내용이 바뀌어도 source_id는 유지되고 revision_id는 바뀝니다. parser version만 바뀌어도 검색 결과가 달라질 수 있으므로 revision에 포함합니다.
import hashlib
def digest(*parts: str) -> str:
raw = "\x1f".join(parts).encode("utf-8")
return hashlib.sha256(raw).hexdigest()[:20]
def make_revision_id(
source_id: str,
content_checksum: str,
parser_name: str,
parser_version: str,
) -> str:
return digest(source_id, content_checksum, parser_name, parser_version)
def make_element_id(revision_id: str, page: int | None, ordinal: int) -> str:
return digest(revision_id, str(page), str(ordinal))
text 앞부분을 ID로 쓰면 머리말 하나가 추가됐을 때 collision이 나거나 모든 참조가 흔들릴 수 있습니다. ID 정책은 citation, cache invalidation과 삭제 처리까지 고려해야 합니다.
1단계: 원본 snapshot을 먼저 보존한다
원본이 없으면 나중에 parser bug를 고쳐도 같은 입력으로 재실행할 수 없습니다.
최소 manifest에는 다음을 남깁니다.
{
"source_id": "policy:remote-work",
"uri": "s3://knowledge/hr/remote-work-v4.pdf",
"media_type": "application/pdf",
"content_length": 481920,
"checksum_sha256": "...",
"fetched_at": "2026-07-16T01:20:00Z",
"source_updated_at": "2026-07-15T09:00:00Z",
"acl": ["employee", "hr"]
}
updated_at만 믿지 말고 content checksum도 계산합니다. 반대로 checksum만으로 논리 문서를 식별하면 동일 파일이 여러 source에 배포된 관계를 잃습니다.
실무에서는 원본 bucket을 immutable하게 두고, parsing 산출물과 index manifest를 별도 경로에 versioning하는 편이 추적하기 쉽습니다.
raw/{source_id}/{content_checksum}.bin
parsed/{source_id}/{revision_id}/elements.jsonl
index-manifest/{index_version}.json
2단계: 확장자가 아니라 bytes를 분류한다
report.pdf라는 이름이 실제 PDF라는 보장은 없습니다. HTTP Content-Type도 잘못 설정될 수 있습니다.
분류할 때는 다음 신호를 함께 봅니다.
- 허용된 source와 extension
- MIME header
- file signature, 즉 magic bytes
- 암호화·손상·압축 여부
- page 수와 파일 크기 제한
지원하지 않는 형식을 억지로 text로 바꾸지 말고 quarantine queue로 보냅니다. 실패를 빈 문서로 바꾸면 pipeline은 성공처럼 보이고 retrieval에서 조용히 누락됩니다.
@dataclass(frozen=True)
class ParseFailure:
source_id: str
revision_id: str
stage: str
reason_code: str
detail: str
retryable: bool
3단계: native text와 OCR을 분기한다
digital PDF에 무조건 OCR을 적용하면 이미 있는 text layer와 OCR text가 중복되거나, 정확했던 코드·숫자가 오인식될 수 있습니다.
page별로 다음을 측정해 분기합니다.
def needs_ocr(
extracted_text: str,
image_area_ratio: float,
printable_ratio: float,
) -> bool:
compact = "".join(extracted_text.split())
return (
len(compact) < 30
or printable_ratio < 0.85
or (image_area_ratio > 0.80 and len(compact) < 120)
)
이 threshold는 예시입니다. 계약서 한 장과 300쪽 manual의 분포가 다르므로 corpus에서 tuning합니다.
OCR 결과에는 confidence와 engine version을 남깁니다. 낮은 confidence page는 검색에서 제외하기보다 quality_flag를 달아 관찰하거나 사람 검수 대상으로 보낼 수 있습니다.
4단계: 구조를 보존하며 정규화한다
정규화는 가능한 한 적게 합니다. 다음은 대체로 안전합니다.
- Unicode normalization 정책을 명시한다.
- 줄 끝 hyphenation은 사전·문맥 조건을 확인해 합친다.
- 반복 header/footer는 page 위치와 반복 빈도로 식별한다.
- 연속 공백은 줄과 paragraph 구조를 잃지 않는 범위에서 정리한다.
- control character와 zero-width artifact를 기록 후 제거한다.
다음은 위험합니다.
- 모든 newline을 공백 하나로 바꾸기
- 숫자와 punctuation 전부 제거하기
- 표를 cell 구분 없이 한 줄로 붙이기
- code indentation 없애기
- 제목과 본문을 구분하지 않기
문서 유형별 정규화 policy를 함수로 분리합니다.
from collections.abc import Callable
Normalizer = Callable[[Element], Element]
NORMALIZERS: dict[str, list[Normalizer]] = {
"application/pdf": [remove_control_chars, repair_safe_hyphenation],
"text/html": [remove_navigation_boilerplate, normalize_links],
"text/markdown": [preserve_code_fences, normalize_headings],
}
원문과 normalized text를 둘 다 보존하면 검색에는 정제 text를 쓰고 citation에는 원문을 보여줄 수 있습니다.
표는 문자열이 아니라 관계다
다음 표를 단순히 행 순서대로 붙이면 어떤 값이 어떤 열에 속하는지 흐려집니다.
| Plan | Storage | Retention |
|---|---|---|
| Basic | 10 GB | 30 days |
| Pro | 100 GB | 365 days |
검색용 representation에는 header를 각 row에 반복하는 방식이 유용합니다.
Plan: Basic | Storage: 10 GB | Retention: 30 days
Plan: Pro | Storage: 100 GB | Retention: 365 days
하지만 원래 grid, merged cell, page와 table caption도 metadata에 남겨야 합니다. 질문이 “Pro의 보존 기간은?”일 때 row 관계가 정답 근거이기 때문입니다.
HTML은 boilerplate와 본문을 나눈다
HTML parser가 DOM의 모든 text node를 합치면 navigation, footer, 추천 글과 cookie banner가 본문보다 더 많이 반복될 수 있습니다.
보존할 후보:
- article/main 영역
- heading hierarchy
- paragraph와 list
- table, figure caption, code block
- canonical URL, published/updated time
제거할 후보:
- navigation과 breadcrumb의 반복 text
- 광고·추천 카드
- script, style, hidden element
- cookie와 login banner
CSS selector 하나로 모든 site를 처리하려 하지 말고 domain adapter와 generic fallback을 구분합니다. adapter의 version도 revision에 포함합니다.
5단계: 품질 gate를 수치로 만든다
“파일을 열어 보니 괜찮다”는 자동 pipeline의 gate가 될 수 없습니다. document와 page 단위 report를 만듭니다.
@dataclass(frozen=True)
class QualityReport:
page_count: int
text_page_ratio: float
mean_chars_per_page: float
replacement_char_ratio: float
duplicate_line_ratio: float
low_ocr_confidence_pages: tuple[int, ...]
empty_pages: tuple[int, ...]
table_count: int
passed: bool
예시 규칙은 다음과 같습니다.
def quality_gate(report: QualityReport) -> tuple[bool, list[str]]:
reasons = []
if report.text_page_ratio < 0.70:
reasons.append("too_many_pages_without_text")
if report.replacement_char_ratio > 0.01:
reasons.append("garbled_unicode")
if report.duplicate_line_ratio > 0.25:
reasons.append("repeated_boilerplate")
return not reasons, reasons
threshold를 정하기 전에 정상 문서와 실패 문서의 실제 분포를 histogram으로 봅니다. 문서 유형별로 다른 gate가 필요할 수 있습니다.
반드시 눈으로 보는 golden fixture
자동 metric만으로 읽기 순서와 표 의미를 완전히 판정하기 어렵습니다. corpus의 위험한 사례를 작은 fixture set으로 고정합니다.
- 두 단 PDF
- 표가 두 page에 걸친 PDF
- text layer가 있는 scan
- 한글과 영문이 섞인 문서
- 수식·code·각주가 많은 문서
- navigation이 큰 HTML
- 암호화·손상 파일
parser를 upgrade할 때 expected element JSON과 diff합니다.
parser v1.8 → v1.9 regression
- element count: 184 → 179
- table rows: 42 → 16 ← 차단
- empty pages: 0 → 0
- golden reading-order checks: 12/12 → 10/12 ← 차단
ACL은 retrieval 이후가 아니라 ingestion부터 붙인다
문서 권한을 답변 직전에 검사하면 이미 허용되지 않은 vector가 검색 후보와 trace에 노출될 수 있습니다.
권한 metadata의 흐름을 끊지 않습니다.
source ACL
→ document revision ACL
→ element ACL
→ chunk ACL
→ retrieval pre-filter
→ citation authorization check
보안 경계도 명시합니다.
- parser는 untrusted file을 읽으므로 격리된 process/container에서 실행합니다.
- 압축 폭탄, 비정상 page 수, 거대한 image에 resource limit을 둡니다.
- 문서 안의 “system prompt를 무시하라”는 text는 instruction이 아니라 data입니다.
- 원본·산출물·trace의 PII 보존 기간과 접근 권한을 분리합니다.
삭제와 갱신도 ingestion의 일부다
새 문서를 넣는 것만 설계하면 오래된 revision이 검색에 남습니다.
manifest 단위 publish를 사용하면 안전합니다.
1. 새 revision parse
2. quality gate 통과
3. 새 chunk와 vector를 staging index에 기록
4. document 단위 count/checksum 검증
5. active manifest를 새 index version으로 원자적 전환
6. 이전 revision tombstone
7. 보존 정책에 따라 지연 삭제
중간 실패가 현재 production index를 반쯤 덮어쓰지 않게 합니다. 각 답변 trace에는 사용한 index_version을 남깁니다.
최소 관측성 dashboard
매 실행마다 다음 값을 기록합니다.
| 범주 | 지표 |
|---|---|
| 입력 | 발견·신규·수정·삭제 document 수 |
| parsing | 성공률, format별 latency, parser error code |
| 품질 | 빈 page, 글자 깨짐, OCR page, table 추출 수 |
| 산출물 | document당 element/chunk/token 분포 |
| version | parser·normalizer·embedding·index version |
| 보안 | ACL 누락, quarantine, 차단된 파일 |
“index에 10만 vector가 있다”보다 “어떤 source revision이 어떤 parser로 몇 element가 되었는가”가 debugging에 더 유용합니다.
작은 실습: 정답 존재 여부부터 검사한다
질문 20개와 각 정답 문자열 또는 원문 위치를 준비합니다. retrieval을 실행하기 전에 parsed element에 정답이 존재하는지 확인합니다.
from dataclasses import dataclass
@dataclass(frozen=True)
class ParseProbe:
question_id: str
source_id: str
required_phrases: tuple[str, ...]
def phrase_coverage(probe: ParseProbe, elements: list[Element]) -> float:
corpus = "\n".join(
e.text for e in elements if e.source_id == probe.source_id
).casefold()
hits = sum(phrase.casefold() in corpus for phrase in probe.required_phrases)
return hits / len(probe.required_phrases)
이 검사를 parse_coverage라고 따로 기록합니다.
answer failure
├─ parse_coverage = 0 → ingestion 문제
├─ parse_coverage = 1, Recall@k = 0 → chunk/retrieval 문제
└─ Recall@k = 1, answer wrong → context/generation 문제
이 한 단계 분리만으로 embedding model을 불필요하게 교체하는 실험을 줄일 수 있습니다.
Production 체크리스트
- 원본 immutable snapshot과 checksum이 있다.
- source ID와 revision ID가 분리돼 있다.
- parser·normalizer version이 기록된다.
- page, element type, heading path, bbox가 보존된다.
- native text와 OCR 분기 기준이 있다.
- table의 header-cell 관계를 잃지 않는다.
- 실패 문서를 빈 성공으로 바꾸지 않고 quarantine한다.
- golden document fixture와 regression diff가 있다.
- ACL이 chunk와 retrieval filter까지 전달된다.
- 갱신·삭제·rollback 가능한 index manifest가 있다.
스스로 확인하기
- PDF에서 text 추출 성공과 읽기 순서 복원 성공은 왜 다른가?
source_id,revision_id,element_id를 각각 어디에 쓰는가?- digital PDF에 OCR을 무조건 적용하면 어떤 오류가 생길 수 있는가?
- 정답이 parsed text에 없는 실패를 retrieval metric과 어떻게 분리할 수 있는가?
- parser upgrade가 안전하다는 것을 어떤 fixture와 metric으로 증명할 것인가?
다음 글에서는 검증된 element를 검색 단위로 자르는 RAG Chunking의 크기·경계·overlap·semantic·late chunking을 다룹니다.