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

멀티모달 프롬프트 설계와 파싱 품질 진단 — 파이프라인을 닫는다

표·이미지 해설을 만드는 프롬프트 4종의 설계 의도와 XML 태그·가상질문·수치 강조가 필요한 이유, 그리고 파싱 품질이 의심될 때의 진단 순서를 정리한다.

RAG 답변 품질이 떨어질 때 대부분의 팀이 프롬프트와 모델부터 확인한다. 순서가 거꾸로다. 파싱 산출물을 열어 표가 깨져 있으면 뒤 단계는 볼 필요가 없다.

이 글은 파싱 시리즈를 닫으면서 두 가지를 다룬다. 하나는 표와 이미지에서 검색 가능한 텍스트를 만들어 내는 프롬프트 4종의 설계 의도 — XML 태그를 쓰는 이유, 가상질문이 검색 적중률을 올리는 원리, 수치 강조를 두 곳에 반복해 넣는 이유다. 다른 하나는 품질이 의심될 때의 진단 순서와 체크리스트다. 앞 편들은 파싱의 병목부터 노드 파이프라인까지 이어진다.

용어 정리

용어풀이
hypothetical questions"이 표를 보고 물어볼 법한 질문"을 미리 만들어 저장하는 기법. 원리는 2편 참조
One-shot 예시프롬프트에 완성된 출력 예시를 1개 넣어 형식 준수율을 높이는 기법
SYSTEM / USER 분리변하지 않는 역할·절차와 문서마다 변하는 형식·문맥을 나눠 두는 프롬프트 구성
서브그래프 합성컴파일된 그래프를 상위 그래프의 노드로 삽입하는 것
entity그림·표에서 뽑아낸 제목·상세·개체명·가상질문 묶음. 검색 대상 텍스트가 된다

산출물 규약

Export 노드 넷은 같은 파싱 결과를 네 가지 형태로 떨어뜨린다.

Export출력 경로파일명 규칙
ExportImage<dir>/images/<category>/<BASENAME>_<CATEGORY>_Page_<page+1>_Index_<id>.png
ExportHTML<dir>/<basename>.html이미지는 base64로 HTML에 인라인 삽입
ExportMarkdown<dir>/<basename>.md이미지는 로컬 파일 경로 참조
ExportTableCSV<dir>/tables/<BASENAME>_TABLE_Page_<page>_Index_<id>.csv
def _add_base64_src_to_html(self, html, base64_encoding):
    """HTML img 태그에 base64 src를 주입"""
    if not base64_encoding:
        return html

    pattern = r"<img([^>]*)>"
    replacement = f'<img\\1 src="data:image/png;base64,{base64_encoding}">'
    return re.sub(pattern, replacement, html)

HTML은 인라인, 마크다운은 경로 참조로 나눈 이유: HTML은 단일 파일로 공유하는 용도라 브라우저에서 바로 열려야 하고, 마크다운은 편집·버전관리 용도라 base64가 들어가면 diff가 폭발한다.

같은 데이터라도 사용 시나리오가 다르면 표현을 달리한다는 판단이다.

CSV는 HTML 표를 pandas로 파싱한다.

for elem in state["elements_from_parser"]:
    if elem["category"] == "table":
        soup = BeautifulSoup(elem["content"]["html"], "html.parser")

        # 불규칙한 문자 정리
        for td in soup.find_all("td"):
            td.string = td.get_text(strip=True).replace("\\t", " ").replace("\t", " ")

        cleaned_html_io = StringIO(str(soup))

        try:
            parsed_tables = pd.read_html(cleaned_html_io)
            for table in parsed_tables:
                csv_filename = (
                    f"{base_without_ext.upper()}_TABLE_"
                    f"Page_{elem['page']}_Index_{elem['id']}.csv"
                )
                csv_filepath = os.path.join(table_dir, csv_filename)
                absolute_path = os.path.abspath(csv_filepath)

                table.to_csv(absolute_path, index=False, encoding="utf-8-sig")
                elem["csv_filepath"] = absolute_path
        except Exception as e:
            self.log(f"테이블 파싱 중 오류 발생: {str(e)}")
            continue
처리이유
td 내부 탭 문자 치환탭이 남으면 CSV 컬럼이 어긋난다
encoding="utf-8-sig"BOM 포함 → Excel에서 한글이 깨지지 않는다
try/except + continue표 1개 실패가 전체 파이프라인을 죽이지 않게 격리

전체 그래프 조립 — 그래프를 노드로 꽂는다

도식을 탭하면 확대해서 볼 수 있습니다
def create_document_parse_graph():
    # 1단계 그래프: 파싱 루프
    split_pdf_node = SplitPDFFilesNode(batch_size=30, test_page=None, verbose=True)
    document_parse_node = DocumentParseNode(
        api_key=os.environ["UPSTAGE_API_KEY"], verbose=True
    )
    post_document_parse_node = PostDocumentParseNode(verbose=True)
    working_queue_node = WorkingQueueNode(verbose=True)

    workflow = StateGraph(ParseState)
    workflow.add_node("split_pdf_node", split_pdf_node)
    workflow.add_node("document_parse_node", document_parse_node)
    workflow.add_node("post_document_parse_node", post_document_parse_node)
    workflow.add_node("working_queue_node", working_queue_node)

    workflow.add_edge("split_pdf_node", "working_queue_node")
    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")
    workflow.set_entry_point("split_pdf_node")
    parser_graph = workflow.compile()

    # 2단계 그래프: 후처리 — 1단계 그래프를 하나의 노드로 삽입한다
    post_process_workflow = StateGraph(ParseState)
    post_process_workflow.add_node("document_parse", parser_graph)   # 서브그래프
    post_process_workflow.add_node(
        "create_elements_node", CreateElementsNode(verbose=True)
    )
    # ... 나머지 노드 추가 ...
    langchain_document_node = LangChainDocumentNode(
        verbose=True,
        splitter=RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=0),
    )

    post_process_workflow.set_entry_point("document_parse")

    memory = MemorySaver()
    return post_process_workflow.compile(checkpointer=memory)

add_node("document_parse", parser_graph) 한 줄이 핵심이다. 컴파일된 그래프를 노드로 넣는다.

파싱 루프 전체가 하나의 블랙박스 노드가 되어 후처리 그래프에 꽂힌다. 파싱 로직을 바꿔도 후처리 그래프는 영향받지 않고, 후처리를 재배선해도 파싱 루프는 그대로다. 모듈 경계가 그래프 경계와 일치하는 구조다.

사용법

import uuid
from langchain_core.runnables import RunnableConfig

# batch_size: 한 번에 처리할 페이지 수
# test_page: 테스트할 페이지 번호 (None이면 전체)
parser_graph = create_upstage_parser_graph(
    batch_size=30, test_page=None, verbose=True
)

config = RunnableConfig(
    recursion_limit=300,
    configurable={"thread_id": str(uuid.uuid4())},
)

inputs = {"filepath": "data/report.pdf"}
parser_graph.invoke(inputs, config=config)

# 추출된 요소 확인
elements = parser_graph.get_state(config).values["elements_from_parser"]

ParseState를 직접 만들어 해설 언어를 지정할 수도 있다.

inputs = ParseState(
    filepath="data/report.pdf",
    language="English",   # VLM 해설 출력 언어
)

결과는 pickle로 저장해 재사용한다.

def save_documents_to_pkl(documents, filepath):
    abs_path = os.path.abspath(filepath)
    base_path = os.path.splitext(abs_path)[0]
    with open(f"{base_path}.pkl", "wb") as f:
        pickle.dump(documents, f)


def load_documents_from_pkl(filepath):
    abs_path = os.path.abspath(filepath)
    base_path = os.path.splitext(abs_path)[0]
    with open(f"{base_path}.pkl", "rb") as f:
        return pickle.load(f)

왜 캐시하는가: 파싱은 비싸고 느리다(API 비용 + VLM 비용 + 수 분). 청킹 전략이나 임베딩 모델을 바꿔 실험할 때마다 재파싱하면 비용이 배로 든다.

파싱 결과를 고정하고 이후 단계만 반복 실험하는 것이 표준 작업 방식이다. 이 캐시 하나가 실험 비용을 한 자릿수로 떨어뜨린다.

프롬프트 4종의 설계

도식을 탭하면 확대해서 볼 수 있습니다
파일역할변수
IMAGE-SYSTEM-PROMPT.yaml이미지 분석 절차 5단계 정의없음
IMAGE-USER-PROMPT.yaml출력 태그 구조·예시·주의사항{language}, {context}
TABLE-SYSTEM-PROMPT.yaml표 분석 절차 5단계 (수치 강조 추가)없음
TABLE-USER-PROMPT.yaml표 출력 태그 구조·예시{language}, {context}

SYSTEM — 절차를 고정한다

_type: "prompt"
template: |
  Extract key information and insights from an image based on the provided context.

  Given the context related to the image, analyze and interpret the image to generate a structured output that includes a title, key details, entities, and hypothetical questions.

  # Steps

  1. **Analyze the Context**: Understand the context provided in relation to the image.
  2. **Title Generation**: Create a concise and descriptive title.
  3. **Details Extraction**: Identify and articulate key insights and details visible in the image.
  4. **Entity Identification**: Recognize and list significant entities or objects.
  5. **Hypothetical Questions**: Formulate relevant hypothetical questions that arise from the content.

표 전용 SYSTEM은 세 번째 단계에 한 문장이 더 붙는다 — Be sure to include numerical values. 표는 숫자가 본체이므로 이미지보다 강한 지시가 필요하다.

USER — 형식과 예시를 준다

_type: "prompt"
template: |
  # Output Format

  - The output should be structured using the following tags:
    - `<image>`: Wrap the entire output.
    - `<title>`: Enclose the generated title.
    - `<details>`: Include detailed insights extracted from the image.
    - `<entities>`: List the identified entities.
    - `<hypothetical_questions>`: Present the formulated hypothetical questions.
  - The output must be written in {language}.

  # Example

  **Input**:
  Here is the context related to the image:
  {context}

  **Output**:
  <image>
  <title>
  The Rise of Artificial Intelligence in Modern Technology
  </title>
  <details>
  The image depicts the integration of AI in various technological devices,
  highlighting advancements in automation and data processing.
  </details>
  <entities>
  AI algorithms, robotics, smart devices
  </entities>
  <hypothetical_questions>
  - How will AI continue to evolve in the next decade?
  - What are the ethical implications of AI in everyday life?
  </hypothetical_questions>
  </image>

  # Notes

  - Use the provided context to inform and enhance the extraction process.
  - Ensure that the hypothetical questions are thought-provoking and relevant.
  - Be sure to include numerical values, proper nouns, terms, and terminologies.
input_variables: ["language", "context"]

표 전용 USER는 태그가 <table>로 바뀌고 예시가 재무 표로 교체될 뿐 구조가 같다.

각 설계 요소가 무엇을 막는가

설계 요소의도
SYSTEM/USER 분리SYSTEM은 역할·절차(변하지 않음), USER는 출력 형식·문맥(문서마다 변함). 캐싱과 재사용에 유리
5단계 Steps 명시문맥 이해 → 제목 → 상세 → 엔티티 → 질문 순으로 사고하도록 강제. Chain-of-Thought의 구조화 버전
XML 태그 출력마크다운보다 파싱이 안정적이고, 표 내용에 파이프 문자가 있어도 충돌하지 않는다
One-shot 예시태그 구조를 말로만 설명하면 모델이 변형한다. 완성 예시 1개가 형식 준수율을 크게 올린다
{language} 변수화한국어 문서는 한국어 해설. 문서 언어와 임베딩 언어를 일치시킨다
{context} 주입잘라낸 이미지만으로는 무슨 표인지 모른다. 같은 페이지 텍스트가 제목·의미를 부여한다
hypothetical_questions검색 적중률 장치. 사용자 질문과 저장된 가상질문이 직접 매칭된다
numerical values 반복SYSTEM(표 전용)과 USER Notes 양쪽에 넣었다. 요약 과정의 숫자 증발이 가장 치명적이기 때문
proper nouns, terms고유명사·전문용어는 검색 키워드 그 자체. 일반화하면 검색이 불가능해진다

가상질문이 가장 반직관적인 장치다. 질문과 문서를 비교하는 것보다 질문과 질문을 비교하는 쪽이 유사도가 높기 때문에, "이 표를 보고 물어볼 법한 질문"을 미리 만들어 저장해 두면 실제 사용자 질문과 직접 매칭된다.

세대 간 변화

항목3세대 (코드 내장 f-string)4세대 (YAML 외부화)
태그<summary> <entities> <data_insights><details> <entities> <hypothetical_questions>
가상질문SYSTEM에 "provide five hypothetical questions"별도 태그로 승격
언어 지정user prompt 끝에 한 줄Output Format 규칙에 명시
예시없음 (태그 골격만)One-shot 완성 예시 포함
관리코드 수정 필요YAML 편집만으로 변경

프롬프트 엔지니어링이라기보다 인터페이스 설계에 가깝다. 프롬프트를 코드에서 분리하고, 출력 형식을 태그로 고정하고, 예시를 하나 넣는 것 — 셋 다 "출력이 예측 가능해야 후속 시스템이 소비할 수 있다"는 같은 요구에서 나온다.

품질이 의심될 때의 진단 순서

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

순서가 전부다. 대부분의 팀이 4번부터 시작해 시간을 쓴다. 1번에서 표가 깨져 있으면 2~4번은 볼 필요가 없다. 파싱 단계에서 사라진 정보는 뒤에서 복구되지 않기 때문이다.

체크리스트

구분점검 항목실패 시 증상
파싱header/footer/footnote가 제거되었는가청크에 회사명·페이지번호 반복
파싱표가 마크다운 구조로 남아 있는가숫자만 나열된 텍스트
파싱그림·차트에 해설 텍스트가 붙었는가"자료에 없음" 응답 다발
파싱페이지 번호가 전역 기준으로 복원됐는가출처 인용이 엉뚱한 페이지
파싱수식이 마크다운으로 보존됐는가기호 깨짐
청킹표가 청크 경계에서 잘리지 않았는가표 절반만 검색됨
검색표에 대해 서술문 Document가 별도로 있는가표 관련 질문 재현율 저하
비용파싱 결과가 캐시되어 있는가실험할 때마다 API 재과금
운영배치 실패가 격리되는가1페이지 실패로 전체 재시작
운영로그로 단계별 소요시간이 보이는가병목 지점 파악 불가

파싱 단계의 정량 지표는 셋으로 잡는다. header/footer 제거율, 표 element 대비 마크다운 변환 성공률, 그림 대비 해설 생성률이다. 검색 단계에서는 정답 청크가 top-k에 들어오는 재현율을 먼저 보고 그다음 정밀도를 본다.

실무 이식 시 손볼 지점

#지점문제개선
1MergeEntityNode 이중 루프O(n·m)id 딕셔너리 인덱싱
2DocumentParseNode 파일 핸들open() 후 미closewith 구문
3재시도 없음API 일시 실패 시 전체 중단지수 백오프 재시도
4total_cost 상수 하드코딩단가 변경 시 코드 수정설정 외부화
5분할 PDF 파일 미삭제디스크 누적임시 디렉터리 + 정리 노드
6pickle 사용역직렬화 보안 위험신뢰 경계 밖에서는 JSON
7프롬프트 경로가 상대경로실행 위치 의존패키지 리소스 경로

이 목록이 학습용 코드와 운영 코드의 거리다. 6번은 특히 주의할 항목으로, 신뢰할 수 없는 출처의 pickle을 역직렬화하면 임의 코드가 실행된다. 내부 캐시 용도를 벗어나 파일이 외부에서 들어올 수 있다면 JSON으로 바꿔야 한다.

파이프라인은 여기서 닫힌다. 다시 요약하면, 파싱은 유형별로 다르게 처리하고, 그림을 버리지 않고 텍스트로 번역하며, 노이즈는 의도적으로 버리는 세 가지 판단의 반복이다. 그리고 그 판단이 옳았는지는 검색 재현율로만 확인된다.

파싱과 검증에서 반복해 나오는 질문들은 RAG 품질 Q&A에 문답으로 모았다.