12장. 멀티모달 RAG 와 데이터셋 자동 생성
그림과 표가 섞인 문서
요리책 한 쪽에는 완성 사진, 재료 표, 만드는 법이 함께 있습니다. 이 쪽을 조각으로 자를 때 사진과 표를 따로 떼어 버리면 "이 재료가 어느 요리의 것인지"를 잃습니다. 그림과 표가 섞인 문서는 그림을 설명 글로, 표를 행 문장으로 바꾼 뒤, 한 상품의 것을 한 조각으로 묶습니다.
1번에서 안내서를 절(상품 종류)마다 읽습니다. 2번에서 그림 줄은 비전 모델이 만든 설명 글로 바꿉니다. 3번에서 표는 행마다 "가격 9,900원, 용량 350ml" 같은 문장으로 바꿉니다. 4번에서 코드가 같은 그림 설명과 표의 행을 한 조각으로 묶습니다. 여기서 요리책의 사진, 재료 표, 만드는 법이 그림, 표, 글입니다.
무엇을 기준으로 묶나
| 묶는 법 | 조각 하나 | 좋은 점 | 아쉬운 점 |
|---|---|---|---|
| 절 통째 | 종류 하나(상품 3개) | 나누기 쉽다 | 세 상품의 값이 섞여 LLM 이 헷갈린다 |
| 상품마다(이 장) | 상품 하나의 그림 설명 + 표의 행 + 절 안내 | 질문 하나에 조각 하나가 맞는다 | 그림과 행을 잇는 열쇠(코드)가 있어야 한다 |
| 그림, 표 따로 | 그림 설명 조각, 표 조각 | 만들기 쉽다 | 색으로 찾은 그림과 값이 있는 표가 따로 논다 |
표의 행을 문장으로
import re
from pathlib import Path
guide = Path("product_guide.md").read_text(encoding="utf-8")
section = guide.split("\n## ")[1] # 첫 절(머그컵)
header = [c.strip() for c in re.search(r"^\| 이름 .*$", section, re.M).group(0).strip("|").split("|")]
rows = re.findall(r"^\| (?!이름|---).*$", section, re.M) # 머리글 줄과 구분 줄을 뺀 행들
cells = [c.strip() for c in rows[0].strip("|").split("|")]
print(", ".join(f"{h} {v}" for h, v in zip(header, cells)))코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
guide.split("\n## ")[1] | 절 제목(## ) 앞에서 자른다. [0] 은 첫 절 앞의 머리말, [1] 이 첫 절이다 |
re.search(패턴, 글, re.M) | re 는 regular expression(정규 표현식, 글자 무늬)이다. 무늬에 처음 맞는 곳을 찾는다 |
re.M | MULTILINE(여러 줄)의 줄임이다. ^(줄 처음)와 $(줄 끝)를 줄마다 적용한다 |
.group(0) | 찾은 글 전체를 꺼낸다 |
(?!...) | 앞보기 부정(negative lookahead)이다. 그 자리 바로 뒤에 괄호 속 글이 오면 맞지 않은 것으로 친다 |
re.findall(...) | 무늬에 맞는 곳을 모두 찾아 목록으로 준다 |
.strip(...), .split(...) | 행 양 끝의 세로 막대를 떼고, 세로 막대마다 칸을 나눈다 |
zip(header, cells) | 머리글과 칸을 같은 순서끼리 짝짓는다 |
", ".join(...) | 짝들을 쉼표와 빈칸으로 이어 한 문장으로 만든다 |
이 코드가 뜻하는 것
요리책의 재료 표를 전화로 읽어 준다고 해 봅시다. 표를 그대로 보여 줄 수 없으니 "밀가루 200그램, 설탕 50그램"처럼 머리글과 칸을 짝지어 말로 풀어 읽습니다. 여기서 표의 머리글이 header, 한 행의 칸들이 cells, 말로 풀어 읽은 문장이 행 문장입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
guide = ...read_text(...) | 안내서 전체를 읽는다 | 요리책을 편다 |
section = guide.split(...)[1] | 첫 절(머그컵)만 떼어 낸다 | 첫 요리의 쪽을 편다 |
header = [...] | "이름"으로 시작하는 머리글 줄을 찾아 칸 이름을 뽑는다 | 표 맨 위의 칸 이름 |
rows = re.findall(...) | 머리글 줄과 구분 줄을 뺀 행을 모두 찾는다 | 재료 줄들 |
cells = [...] | 첫 행을 칸으로 나눈다 | 첫 재료 줄 |
print(", ".join(...)) | 칸 이름과 값을 짝지어 한 문장으로 | 전화로 읽어 주기 |
실행하면 이름 데일리 머그 1호, 코드 P01, 가격 9,900원, 용량 350ml, 무게 190g 이 나옵니다. 이 한 문장이면 칸이 흩어져도 "350ml 가 데일리 머그 1호의 용량"이라는 것을 잃지 않습니다.
작게 줄여서 눈으로 보기
설명을 위해 지어낸 작은 표로, 행 찾기와 re.M 의 차이, 칸 나누기를 봅니다.
import re
section = "머그컵\n| 이름 | 가격 |\n| --- | --- |\n| 머그 1호 | 9,900원 |\n| 머그 2호 | 10,900원 |"
print(re.findall(r"^\| (?!이름|---).*$", section, re.M))
print(re.findall(r"^\| .*$", section))
print("| 머그 1호 | 9,900원 |".strip("|").split("|"))['| 머그 1호 | 9,900원 |', '| 머그 2호 | 10,900원 |']
[]
[' 머그 1호 ', ' 9,900원 ']| 결과 | 읽는 법 |
|---|---|
| 첫 줄 | 머리글 줄과 구분 줄은 빠지고 상품 행 둘만 남았다 |
[] | re.M 이 없으면 ^ 는 글 전체의 맨 앞만 뜻한다. 맨 앞이 "머그컵"이라 아무것도 찾지 못했다 |
[' 머그 1호 ', ' 9,900원 '] | 양 끝 막대를 떼고 나눈 칸이다. 앞뒤 빈칸이 남아 있어 원래 코드는 c.strip() 으로 한 번 더 지운다 |
주의할 점
- 그림과 표를 잇는 열쇠가 문서에 없으면 묶을 수 없습니다. 이 안내서는 그림 파일 이름(
P01.png)과 표의 코드 열이 같아서 이을 수 있습니다. 실제 문서에서는 그림 바로 아래의 캡션이나 위치로 잇는 경우가 많습니다. - PDF 안의 표는 칸이 무너진 채 뽑히기 쉽습니다. 표가 중요한 문서는 표를 따로 뽑는 도구를 쓰거나, 원본(엑셀, 워드)에서 표를 읽습니다.