RAG · 검색증강생성수정 2026-08-07

파싱 파이프라인 패키지 구조 — 노드 규약과 상태 설계

노트북 코드를 책임별 모듈로 분해한 4세대 구조를 다룬다. Document Parse v2 응답 스키마, 16줄짜리 노드 추상 클래스, 리듀서로 동시성을 설계하는 상태 스키마까지.

21페이지 산업 리포트를 파싱하면 465개 element가 나오는데, 그중 62개(13%)가 머리말·꼬리말이고 표·차트·그림은 42개(9%)뿐이다. 13%는 버리려고, 9%는 살리려고 파이프라인의 대부분이 존재한다. 수치의 대부분이 그 9%에 있기 때문이다.

이 글은 그 처리를 노트북 코드에서 재사용 가능한 패키지로 옮긴 4세대 구조를 다룬다. Document Parse v2가 크롭 코드를 통째로 없앤 방식, 11개 노드를 하나로 묶는 16줄짜리 추상 클래스, 그리고 리듀서 선택이 곧 동시성 설계가 되는 상태 스키마를 정리한다. 2·3세대 파이프라인의 코드를 모듈로 분해한 결과에 해당한다.

용어 정리

용어풀이
element레이아웃 파싱의 최소 단위. 구성은 1편, 유형별 처리는 2편 참조
base64_encoding잘라낸 그림·표 이미지를 API 응답 안에 문자열로 실어 보내는 필드
ParseState그래프가 공유하는 상태 스키마(TypedDict). 각 노드는 자기가 채우는 키만 반환한다
리듀서(reducer)노드 반환값을 기존 상태에 합치는 규칙. operator.add면 누적, 기본은 덮어쓰기
BaseNode모든 노드의 추상 기반 클래스. "상태를 받아 상태를 반환한다"는 계약을 강제한다
센티널(sentinel)루프 종료를 알리는 특수 값. 여기서는 <<FINISHED>>
페이지 오프셋분할 파일 기준 페이지 번호를 전체 문서 기준으로 되돌리는 보정값

Document Parse v2 — 크롭 코드가 사라진다

항목v1 layout-analysisv2 document-parse
출력 형식html 단일html + markdown + text
좌표절대 픽셀 (bounding_box)정규화 좌표 (coordinates, 0~1)
이미지직접 크롭 필요base64_encoding 필드로 잘린 이미지 제공
카테고리기본 세트chart 분리 등 세분화
사용량billed_pagesusage.pages + model 버전 명시

세 번째 행이 결정적이다. 2세대에서 좌표 정규화와 크롭에 쓰던 코드 전체가 사라진다. 좌표계 불일치라는 미묘한 버그 원인도 함께 사라진다.

요청 설정

DEFAULT_CONFIG = {
    "ocr": False,
    "coordinates": True,
    "output_formats": "['html', 'text', 'markdown']",
    "model": "document-parse",
    "base64_encoding": "['figure', 'chart', 'table']",
}

response = requests.post(
    "https://api.upstage.ai/v1/document-ai/document-parse",
    headers={"Authorization": f"Bearer {self.api_key}"},
    data=self.config,
    files={"document": open(input_file, "rb")},
)
옵션설명선택 근거
ocr: FalseOCR 비활성화텍스트 레이어가 있는 PDF는 OCR이 오히려 오탈자를 만든다. 스캔본만 True
coordinates: True좌표 반환원본 위치 추적·시각화·재크롭에 필요
output_formats3종 동시 요청텍스트는 text, 표는 markdown, 그림은 html로 각각 다르게 쓴다
base64_encodingfigure·chart·table만 이미지 인코딩전체에 걸면 응답이 불필요하게 커진다

응답 스키마

{
  "api": "2.0",
  "model": "document-parse-240910",
  "usage": {"pages": 21},
  "content": {...},          # 문서 전체 통합 결과
  "elements": [...]          # 요소 배열 (핵심)
}

element 하나의 구조는 이렇다.

{
  "category": "paragraph",
  "content": {
    "html": "<p id='0' data-category='paragraph' style='font-size:20px'>Europe, Africa ...</p>",
    "markdown": "Europe, Africa, Middle East and Asia-Pacific prices and commentary",
    "text": "Europe, Africa, Middle East and Asia-Pacific prices and commentary"
  },
  "coordinates": [
    {"x": 0.3217, "y": 0.1249},
    {"x": 0.9273, "y": 0.1249},
    {"x": 0.9273, "y": 0.1560},
    {"x": 0.3217, "y": 0.1560}
  ],
  "id": 0,
  "page": 1
}

table element는 content.htmlcolspan·rowspan이 살아 있는 완전한 <table> 태그가 들어온다.

<table id='10' style='font-size:14px'>
  <tr><td colspan="4">Bitumen prices at key locations, 16-22 Mar</td><td rowspan="2">$/t ±</td></tr>
  <tr><td></td><td></td><td>Low</td><td>High</td></tr>
  <tr><td>Mediterranean</td><td></td><td>445.43</td><td>449.77</td><td>+29.05</td></tr>
</table>

병합 셀이 보존된다는 것이 중요하다. 단순 텍스트 추출에서는 헤더가 사라지고 숫자만 남는 실패가 바로 이 지점에서 발생한다.

실제 문서의 카테고리 분포

21페이지 산업 리포트를 파싱한 결과다.

category개수처리 방침
paragraph322텍스트로 누적
header40버림
heading138# 붙여 마크다운 헤딩화
table23마크다운 + 이미지 + VLM 해설
footer22버림
chart13이미지 + VLM 해설
figure6이미지 + VLM 해설
list1텍스트로 누적

전체 465개 중 62개(13%)가 header·footer다. 버리지 않으면 청크의 13%가 "회사명·페이지번호" 노이즈로 채워진다.

반대로 table+chart+figure는 42개(9%)뿐이지만 보고서에서 수치의 대부분이 여기에 있다. 9%를 살리기 위해 파이프라인의 대부분이 존재하는 셈이다.

비용 구조

pages_count = 0
for meta in state["metadata"]:
    for k, v in meta.items():
        if k == "usage":
            pages_count += int(v["pages"])

total_cost = pages_count * 0.01
항목
과금 단위페이지 수 (usage.pages)
단가 (코드 상수 기준)페이지당 $0.01
21페이지 문서 1회$0.21
1,000페이지 문서$10

여기에 VLM 비용이 별도로 붙는다. 표·그림 1개당 멀티모달 호출 1회다.

비용 항목통제 수단
파싱 APItest_page 옵션으로 앞 N페이지만 시험 파싱
VLM 호출배치 처리(10개 단위), figure/chart/table에만 호출
재실행파싱 결과를 JSON·pickle로 저장 → 재파싱 없이 후처리만 반복
모델 선택해설 생성은 소형 멀티모달 모델로 충분

파싱은 1회성 비용, 검색은 상시 비용이다. 파싱에 페이지당 몇 센트를 더 쓰더라도 검색 정확도가 오르면 전체적으로 이득이다.

반대로 문서가 자주 바뀐다면 변경 감지(해시 비교) 후 변경분만 재파싱하는 구조가 필요하다. 세 번째 행의 캐시가 실험 비용을 한 자릿수로 떨어뜨리는 지점이다.

모듈별 책임

3세대의 노트북 코드를 책임별로 분해한 결과다.

모듈줄수책임주요 심볼
base.py16모든 노드의 추상 기반. __call__execute 위임, log 제공BaseNode
state.py39그래프 공유 상태 스키마ParseState
element.py17파싱 결과 요소의 값 객체Element
utils.py / pdf.py43 / 43PDF 배치 분할SplitPDFFilesNode
upstage.py166API 호출·후처리·작업 큐·루프 분기DocumentParseNode, PostDocumentParseNode, WorkingQueueNode
preprocessing.py208raw element → Element 변환, 엔티티 병합, 페이지 재조립CreateElementsNode, MergeEntityNode, ReconstructElementsNode, LangChainDocumentNode
extractor.py257페이지별 요소 분류, VLM 엔티티 추출PageElementsExtractorNode, ImageEntityExtractorNode, TableEntityExtractorNode
export.py218산출물 내보내기 (PNG·HTML·Markdown·CSV)ExportImage, ExportHTML, ExportMarkdown, ExportTableCSV
file.py30Document 리스트 pickle 저장·로드save_documents_to_pkl, load_documents_from_pkl
그래프 팩토리178그래프 조립create_upstage_parser_graph, create_export_graph
prompts/*.yaml4개이미지·표 엔티티 추출 프롬프트IMAGE-SYSTEM, IMAGE-USER, TABLE-SYSTEM, TABLE-USER
도식을 탭하면 확대해서 볼 수 있습니다

프롬프트가 코드가 아니라 YAML 파일로 분리돼 있는 것에 주목한다. 프롬프트를 코드에 f-string으로 박으면 문구 하나 바꾸는 데 배포가 필요하고 변경 이력도 코드 diff에 묻힌다.

노드 규약 — 16줄이 11개 노드를 통일한다

from .state import ParseState
from abc import ABC, abstractmethod


class BaseNode(ABC):
    def __init__(self, verbose=False, **kwargs):
        self.name = self.__class__.__name__
        self.verbose = verbose

    @abstractmethod
    def execute(self, state: ParseState) -> ParseState:
        pass

    def log(self, message: str, **kwargs):
        if self.verbose:
            print(f"[{self.name}] {message}")
            for key, value in kwargs.items():
                print(f"  {key}: {value}")

    def __call__(self, state: ParseState) -> ParseState:
        return self.execute(state)
설계 요소효과
__call__execute 위임LangGraph는 callable을 노드로 받는다. 인스턴스를 그대로 add_node에 넘길 수 있다
self.name = self.__class__.__name__로그에 노드명이 자동으로 찍힌다
verbose 플래그로그를 노드별로 켜고 끈다. 운영에서는 전부 끔
@abstractmethodexecute 미구현 시 인스턴스화 자체가 실패 → 실수 조기 발견

이 16줄이 하는 일은 "노드는 상태를 받아 상태를 반환한다"는 계약을 코드로 강제하는 것이다. 11개 노드가 전부 같은 모양이 되므로 새 처리 단계를 추가하는 비용이 거의 0이 된다.

로그 접두어를 클래스명으로 강제한 것도 작지만 결정적이다. 모듈 경계가 그대로 관측 경계가 된다.

상태 스키마 — 리듀서가 곧 동시성 설계다

from typing import TypedDict, Annotated, List, Dict
import operator
from .element import Element
from langchain_core.documents import Document


class ParseState(TypedDict):
    filepath: Annotated[str, "filepath"]
    filetype: Annotated[str, "filetype"]
    split_filepaths: Annotated[List[str], "split_filepaths"]
    working_filepath: Annotated[str, "working_filepath"]

    metadata: Annotated[List[Dict], operator.add]     # api, model, usage
    total_cost: Annotated[float, "total_cost"]

    raw_elements: Annotated[List[Dict], operator.add]
    elements_from_parser: Annotated[List[Dict], "elements_from_parser"]

    elements: Annotated[List[Element], "elements"]
    reconstructed_elements: Annotated[List[Dict], "reconstructed_elements"]

    export: Annotated[List, operator.add]

    texts_by_page: Annotated[Dict[int, str], "texts_by_page"]
    images_by_page: Annotated[Dict[int, List[Element]], "images_by_page"]
    tables_by_page: Annotated[Dict[int, List[Element]], "tables_by_page"]

    extracted_image_entities: Annotated[List[Element], "extracted_image_entities"]
    extracted_table_entities: Annotated[List[Element], "extracted_table_entities"]

    documents: Annotated[List[Document], "documents"]
    language: Annotated[str, "language"]

operator.add가 붙은 세 키가 결정적이다.

이유
metadata배치마다 API 메타데이터가 하나씩 생긴다 → 누적해야 총 페이지 수 계산 가능
raw_elements배치 루프가 돌 때마다 element 목록이 추가된다 → 덮어쓰면 마지막 배치만 남는다
exportexport 노드 4개가 병렬로 실행되어 각자 파일 경로를 반환 → 누적해야 전부 수집된다

나머지 키는 Annotated[..., "설명"] 형태로 덮어쓰기(replace) 동작이다.

리듀서 선택이 곧 동시성 설계다. 병렬 노드가 같은 키를 반환하는데 리듀서가 덮어쓰기면 결과가 조용히 사라진다. 에러도 나지 않고 값 하나만 남는다.

export가 이 경우의 교과서적 사례다. 노드 4개가 동시에 끝나는데 누적 리듀서가 없으면 산출물 3개를 잃는다.

값 객체

from typing import Dict
from dataclasses import dataclass
from copy import deepcopy


@dataclass
class Element:
    category: str  # table, figure, chart, heading1, header, footer, caption,
                   # paragraph, equation, list, index, footnote
    content: str = ""
    html: str = ""
    markdown: str = ""
    base64_encoding: str = None
    image_filename: str = None
    page: int = None
    id: int = None
    coordinates: list[Dict] = None
    entity: str = ""

    def copy(self):
        return deepcopy(self)

copy()deepcopy인 이유는 엔티티 추출 시 원본 Element를 복제해 entity만 채워 넣기 때문이다. 얕은 복사면 coordinates 리스트가 공유되어 한쪽 수정이 다른 쪽에 새어 나간다.

파싱 루프 — 큐와 조건 분기

도식을 탭하면 확대해서 볼 수 있습니다

WorkingQueueNode가 다음 처리 대상 파일을 하나 꺼내고, 다 떨어지면 <<FINISHED>> 센티널을 세운다.

class WorkingQueueNode(BaseNode):
    def execute(self, state: ParseState):
        working_filepath = state.get("working_filepath", None)
        # 비어 있으면 첫 번째 파일 선택
        if (
            "working_filepath" not in state
            or state["working_filepath"] is None
            or state["working_filepath"] == ""
        ):
            if len(state["split_filepaths"]) > 0:
                working_filepath = state["split_filepaths"][0]
            else:
                working_filepath = "<<FINISHED>>"
        else:
            if working_filepath == "<<FINISHED>>":
                return {"working_filepath": "<<FINISHED>>"}

            current_index = state["split_filepaths"].index(working_filepath)
            if current_index + 1 < len(state["split_filepaths"]):
                working_filepath = state["split_filepaths"][current_index + 1]
            else:
                working_filepath = "<<FINISHED>>"
        return {"working_filepath": working_filepath}


def continue_parse(state: ParseState):
    return state["working_filepath"] != "<<FINISHED>>"
workflow.add_conditional_edges(
    "working_queue_node",
    continue_parse,
    {True: "document_parse_node", False: "post_document_parse_node"},
)
workflow.add_edge("document_parse_node", "working_queue_node")

재귀 한도에 주의한다. 이 루프 때문에 실행 시 recursion_limit=300을 지정한다. 배치 1개당 노드 2회(큐 → 파싱)를 소비하므로, 30페이지 배치에 300 한도면 약 4,000페이지까지 처리 가능하다는 계산이 나온다.

페이지 오프셋 복원

API는 보낸 파일 기준으로 페이지 번호를 매긴다. 4049페이지를 잘라 보내면 응답은 110페이지로 돌아온다. 파일명에 인코딩해 둔 시작 페이지로 이것을 되돌린다.

class DocumentParseNode(BaseNode):
    def __init__(self, api_key, use_ocr=False, verbose=False, **kwargs):
        super().__init__(verbose=verbose, **kwargs)
        self.api_key = api_key
        self.config = DEFAULT_CONFIG
        if use_ocr:
            self.config["ocr"] = True

    def parse_start_end_page(self, filepath):
        """파일명 끝 9글자(예: 0040_0049)에서 페이지 범위를 읽는다"""
        filename = os.path.basename(filepath)
        name_without_ext = filename.rsplit(".", 1)[0]

        try:
            if len(name_without_ext) < 9:
                return (-1, -1)

            page_numbers = name_without_ext[-9:]

            if not (
                page_numbers[4] == "_"
                and page_numbers[:4].isdigit()
                and page_numbers[5:].isdigit()
            ):
                return (-1, -1)

            start_page = int(page_numbers[:4])
            end_page = int(page_numbers[5:])

            if start_page > end_page:
                return (-1, -1)

            return (start_page, end_page)

        except (IndexError, ValueError):
            return (-1, -1)

    def execute(self, state: ParseState):
        filepath = state["working_filepath"]
        parsed_json = self._upstage_layout_analysis(filepath)

        start_page, _ = self.parse_start_end_page(filepath)
        page_offset = start_page - 1 if start_page != -1 else 0

        with open(parsed_json, "r") as f:
            data = json.load(f)
            for element in data["elements"]:
                element["page"] += page_offset

        metadata = {
            "api": data.pop("api"),
            "model": data.pop("model"),
            "usage": data.pop("usage"),
        }

        return {"metadata": [metadata], "raw_elements": [data["elements"]]}

형식 검증에 실패하면 (-1, -1)을 반환해 오프셋 0으로 안전하게 폴백한다. 파일명 규칙이 깨졌을 때 예외로 파이프라인을 죽이지 않고 원래 번호를 유지하는 선택이다.

전역 ID 재부여

배치별로 id가 0부터 다시 시작하므로, 전 배치를 이어붙이면서 문서 전역 유일 ID를 다시 매긴다.

class PostDocumentParseNode(BaseNode):
    def execute(self, state: ParseState):
        elements_list = state["raw_elements"]
        id_counter = 0
        post_processed_elements = []

        for elements in elements_list:
            for element in elements:
                elem = element.copy()
                elem["id"] = id_counter
                id_counter += 1
                post_processed_elements.append(elem)

        pages_count = 0
        for meta in state["metadata"]:
            for k, v in meta.items():
                if k == "usage":
                    pages_count += int(v["pages"])

        total_cost = pages_count * 0.01
        self.log(f"Total Cost: ${total_cost:.2f}")

        return {
            "elements_from_parser": post_processed_elements,
            "total_cost": total_cost,
        }

이 ID가 이후 이미지 파일명과 엔티티 병합의 키가 된다. 배치 경계를 넘어 유일해야 하는 이유가 여기 있다.

여기까지가 구조와 규약이다. 이 위에서 실제로 element를 분류하고 VLM으로 엔티티를 뽑아 페이지 단위로 재조립하는 노드 파이프라인이 다음 편이다.