들어가기 전에..
지금까지 다룬 RAG 파이프라인은 대부분 텍스트를 전제로 했습니다. 하지만 실무에서 다루는 문서는 텍스트만으로 이루어지지 않죠.
예를 들어 재무 보고서에는 수십 개의 표가 있고, 기술 매뉴얼에는 다이어그램이 가득하고, 제품 문서에는 스크린샷이 첨부됩니다.
이런 문서를 그냥 텍스트로 변환해서 청킹하면 어떻게 될까요? 표의 행과 열 관계가 무너지고, 이미지 속 정보는 통째로 사라집니다. 이번 글에서는 테이블과 이미지를 RAG에서 제대로 다루는 방법을 정리해보겠습니다.
테이블은 왜 그냥 텍스트로 변환하면 안 될까?
텍스트는 선형으로 읽힙니다. 앞에서 뒤로 순서대로 이해하면 되죠.
하지만 테이블은 다릅니다. 2차원 구조이고, 행과 열의 교차점에서 의미가 생깁니다.
'3분기 매출이 얼마야?'라는 질문에 답하려면 행(3분기)과 열(매출)이 교차하는 셀을 찾아야 합니다.
사람은 표를 보는 순간 이 관계를 직관적으로 파악하지만 텍스트로 변환된 청크에서는 이 관계가 무너지게 됩니다.
# 원본 HTML 테이블
분기 | 매출 | 영업이익
1Q | 100억 | 10억
2Q | 120억 | 15억
3Q | 110억 | 12억
# 일반적인 텍스트 변환 결과
분기 매출 영업이익 1Q 100억 10억 2Q 120억 15억 3Q 110억 12억
변환 결과를 보면 '3분기', '매출', '110억'이라는 단어는 모두 살아있지만 이 셋이 하나의 행에 속한다는 구조 정보는 사라졌습니다. 벡터 검색은 이 텍스트를 임베딩으로 변환할 때 '3분기 매출 = 110억'이라는 관계를 제대로 포착하지 못합니다. 게다가 '3분기 영업이익'을 물어봤을 때 '110억'이 엉뚱하게 검색될 수도 있게 되죠.
더 심각한 경우는 표가 길거나 복잡할 때입니다. 20개 행, 10개 열짜리 테이블을 텍스트로 변환하면 200개의 값이 줄줄이 늘어서고, LLM은 어떤 값이 어떤 행과 열에 속하는지 파악하기 매우 어려워집니다. 특히 여러 테이블이 하나의 청크에 섞이면 더욱 혼란스러워지게 되죠..
이 문제를 해결하는 접근은 크게 두 가지입니다. TAPAS처럼 테이블 구조 자체를 직접 이해하도록 설계된 모델을 쓰거나 테이블을 LLM이 이해하기 좋은 형식의 텍스트로 변환하는 방법입니다. 전자는 개념을 이해하는 데 도움이 되고, 후자가 실무에서 더 많이 쓰입니다.
TAPAS - SQL 없이 테이블에서 바로 답을 찾는 모델
TAPAS(Table Parser, Google, 2020)는 테이블에 대한 자연어 질문에 직접 답할 수 있는 모델입니다.
기존 접근 방식은 자연어 질문을 먼저 SQL 같은 논리식으로 변환하고 그 쿼리를 테이블에 실행해서 답을 구하는 방식이었습니다.
그런데 이 변환 과정이 생각보다 복잡합니다. '3분기 매출 중 가장 큰 달은?'이라는 질문을 SQL로 바꾸려면 테이블 스키마를 알아야 하고, 언어 모델이 정확한 쿼리를 생성해야 하며 오타 하나로 쿼리가 실패할 수 있습니다. TAPAS는 이 중간 단계를 없앴습니다.
질문과 테이블을 함께 입력받아 어떤 셀이 답에 해당하는지 어떤 연산을 적용할지를 직접 예측합니다.
TAPAS는 BERT 아키텍처를 테이블 입력에 맞게 확장한 구조입니다. 테이블을 1차원 시퀀스로 펼친 뒤 2차원 구조 정보를 보존하기 위해 네 가지 임베딩을 추가합니다.
Column ID(몇 번째 열인지), Row ID(몇 번째 행인지), Rank ID(숫자 열의 크기 순위, 최댓값·최솟값 질문 처리용), Previous Answer(이전 질문의 답변 셀, 연속 질문 처리용)가 그것이죠.
예를 들어 '3분기'라는 토큰은 Column ID=1(분기 열), Row ID=4(4번째 행) 임베딩을 가지게 되어 모델이 이 토큰이 테이블의 어느 위치에 있는지 파악할 수 있습니다.
답변 생성은 두 단계로 이루어집니다. 먼저 질문에 답하는 데 필요한 셀들을 선택합니다. '3분기 매출'이라면 해당 셀 하나를 '전체 매출 합계'라면 매출 열의 모든 셀을 선택합니다. 그 다음 선택한 셀들에 어떤 연산을 적용할지 결정합니다.
NONE(그대로 반환), SUM, COUNT, AVERAGE, MAX, MIN 중 하나를 선택합니다. 이 두 단계가 합쳐지면 SQL 없이도 '3분기에서 1분기를 뺀 매출 증가분은?' 같은 계산 질문에 답할 수 있습니다.
TAPAS는 구조화된 단일 테이블 QA에 특화되어 있어 여러 테이블에 걸친 질문이나 복잡한 다단계 추론에는 약합니다. 학습 데이터가 주로 영어 Wikipedia 기반이라 한국어 테이블이나 도메인 특화 테이블에는 성능이 떨어질 수 있어 별도 파인튜닝이 필요합니다. 실무에서 TAPAS를 직접 쓰는 경우는 많지 않지만 '테이블 구조를 임베딩으로 인코딩한다.'는 아이디어가 이후 테이블 처리 연구에 큰 영향을 주었다는 점에서 공부할 가치가 있는것이죠.
테이블을 LLM이 읽을 수 있는 형태로 바꾸는 세 가지 방법
TAPAS 같은 전용 모델 없이도 테이블을 잘 처리하는 실용적인 방법이 있습니다. 테이블을 LLM이 이해하기 좋은 텍스트 형식으로 변환하는 것입니다. 여기서 핵심은 행과 열의 구조 정보를 최대한 살리면서 동시에 벡터 임베딩이 의미를 잘 포착할 수 있는 형태를 만드는 것입니다.
첫 번째 패턴 : Markdown 테이블 변환
가장 기본적인 방법입니다. HTML 테이블을 Markdown 형식으로 변환하면 '|' 기호로 구조를 표현하기 때문에 단순 텍스트 변환과 달리 행과 열 관계가 유지됩니다. 대부분의 최신 LLM은 Markdown 테이블을 잘 이해하고 있습니다.
from bs4 import BeautifulSoup
def html_table_to_markdown(html: str) -> str:
"""HTML 테이블을 Markdown 형식으로 변환"""
soup = BeautifulSoup(html, 'html.parser')
table = soup.find('table')
if not table:
return ""
rows = []
headers = []
# 헤더 추출
header_row = table.find('tr')
if header_row:
headers = [th.get_text(strip=True) for th in header_row.find_all(['th', 'td'])]
rows.append('| ' + ' | '.join(headers) + ' |')
rows.append('| ' + ' | '.join(['---'] * len(headers)) + ' |')
# 데이터 행 추출
for tr in table.find_all('tr')[1:]:
cells = [td.get_text(strip=True) for td in tr.find_all('td')]
if cells:
rows.append('| ' + ' | '.join(cells) + ' |')
return '\n'.join(rows)
# 사용 예시
html = """
<table>
<tr><th>분기</th><th>매출</th><th>영업이익</th></tr>
<tr><td>1Q</td><td>100억</td><td>10억</td></tr>
<tr><td>2Q</td><td>120억</td><td>15억</td></tr>
<tr><td>3Q</td><td>110억</td><td>12억</td></tr>
</table>
"""
print(html_table_to_markdown(html))
# | 분기 | 매출 | 영업이익 |
# | --- | --- | --- |
# | 1Q | 100억 | 10억 |
# | 2Q | 120억 | 15억 |
# | 3Q | 110억 | 12억 |
Markdown 형식의 가장 큰 장점은 구조를 유지하면서 LLM에 그대로 넘길 수 있다는 점입니다.
RAG 파이프라인에서 검색된 컨텍스트를 LLM에게 전달할 때도 이 형식이면 별도의 후처리 없이 바로 사용할 수 있습니다.
두 번째 패턴 :자연어 서술 변환
Markdown이 LLM에게는 좋지만 벡터 임베딩 관점에서는 자연어 텍스트가 더 유리한 경우가 있습니다.
임베딩 모델은 Markdown 기호(|, ---)보다 자연스러운 문장에서 의미를 더 잘 포착하기 때문입니다. 특히 검색 쿼리가 '3분기 매출 얼마야?'처럼 자연어로 들어올 때 자연어로 변환된 테이블 청크가 검색에서 더 잘 매칭됩니다.
def table_to_natural_language(headers: list, rows: list) -> str:
"""테이블을 자연어 문장으로 변환"""
sentences = []
for row in rows:
parts = []
for header, value in zip(headers, row):
parts.append(f'{header}은(는) {value}')
sentence = ', '.join(parts) + '입니다.'
sentences.append(sentence)
return ' '.join(sentences)
# 결과 예시:
# "분기은(는) 1Q, 매출은(는) 100억, 영업이익은(는) 10억입니다.
# 분기은(는) 2Q, 매출은(는) 120억, 영업이익은(는) 15억입니다. ..."
이렇게 두 패턴을 함께 쓰는 것도 좋은 방법입니다. Markdown 형식은 LLM에게 전달하는 컨텍스트로 자연어 변환본은 벡터 인덱싱용으로 각각 저장하면 검색 품질과 답변 품질을 동시에 챙길 수 있습니다.
세 번째 패턴 : 메타데이터 + 테이블 조합
테이블 앞에 문맥 설명을 붙이는 방법입니다. 단독으로 보면 의미를 파악하기 어려운 테이블도, 제목과 설명이 붙으면 검색 시 훨씬 정확하게 매칭됩니다.
def enrich_table_with_context(title: str, description: str, markdown_table: str) -> str:
"""테이블에 문맥 정보를 추가"""
return f"""## {title}
{description}
{markdown_table}
"""
# 예시
enriched = enrich_table_with_context(
title="2024년 분기별 실적",
description="아래 표는 2024년 각 분기별 매출 및 영업이익을 나타냅니다.",
markdown_table=markdown_table
)
예를 들어 '110억'이라는 값만 있는 셀은 맥락 없이는 무슨 의미인지 알기 어렵습니다. 하지만 '2024년 분기별 실적' 제목과 '각 분기별 매출 및 영업이익' 설명이 붙으면 '3분기 매출'을 검색할 때 이 테이블이 정확하게 검색됩니다. 특히 문서 안에 여러 테이블이 있을 때 각 테이블이 무엇을 다루는지를 메타데이터로 명시해두는 것이 검색 품질에 크게 도움이 됩니다.
이미지를 텍스트로 만들어주는 Vision LLM
테이블보다 더 어려운 게 이미지입니다. 차트, 다이어그램, 스크린샷, 플로우차트는 텍스트 변환으로는 정보를 전혀 살릴 수 없습니다. 이미지를 그냥 무시하면 문서 속 정보의 상당 부분이 인덱스에서 빠지게 됩니다.
이 것에 해결법은 Vision LLM을 활용해 이미지를 텍스트 설명으로 변환한 뒤 인덱싱하는 것입니다.
이미지 자체를 검색하는 게 아니라 이미지를 설명한 텍스트를 검색하는 방식입니다. 사람이 이미지를 보고 캡션을 다는 것과 같은 원리인데 이 작업을 Vision LLM이 자동으로 해줍니다.
역시 중요한 건 프롬프트 설계입니다.
단순히 "이 이미지를 설명해주세요."라고 하면 "파란색 막대 차트가 있습니다." 수준의 추상적인 설명이 나올 수 있어 검색 쿼리와 잘 매칭되려면 이미지 유형, 수치, 텍스트, 핵심 인사이트를 구체적으로 요청해야 합니다. 아래 코드에서 프롬프트 구조를 참고하세요.
Claude Vision API 패턴
Cerebras를 이미 쓰고 있다면 API 패턴은 동일합니다. 이미지를 base64로 인코딩해서 메시지에 포함하는 방식입니다.
import anthropic
import base64
from pathlib import Path
client = anthropic.Anthropic()
def image_to_text_description(image_path: str, context: str = "") -> str:
"""이미지를 텍스트 설명으로 변환 (RAG 인덱싱용)"""
# 이미지를 base64로 인코딩
image_data = Path(image_path).read_bytes()
base64_image = base64.standard_b64encode(image_data).decode("utf-8")
# 이미지 타입 확인
ext = Path(image_path).suffix.lower()
media_type_map = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif',
'.webp': 'image/webp'
}
media_type = media_type_map.get(ext, 'image/png')
prompt = f"""이 이미지를 RAG 시스템에서 검색 가능하도록 상세히 설명해주세요.
다음 항목을 포함해주세요:
1. 이미지 유형 (차트, 다이어그램, 스크린샷, 사진 등)
2. 핵심 내용과 데이터 (수치, 레이블, 관계 등)
3. 이미지에 포함된 텍스트
4. 전달하는 핵심 메시지나 인사이트
{f'추가 문맥: {context}' if context else ''}
검색 쿼리와 잘 매칭되도록 구체적이고 풍부하게 설명해주세요."""
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": base64_image,
},
},
{
"type": "text",
"text": prompt
}
],
}
],
)
return response.content[0].text
# 사용 예시
description = image_to_text_description(
"chart_q3_revenue.png",
context="2024년 3분기 실적 발표 자료의 매출 차트"
)
print(description)
# "이 이미지는 막대 차트로, 2024년 분기별 매출을 나타냅니다.
# 1분기 100억, 2분기 120억, 3분기 110억으로 표시되어 있으며..."
context 파라미터가 중요합니다. 이미지만 보내면 Vision LLM이 이 이미지가 어떤 문서의 어떤 섹션에 있는지 알 수 없습니다.
문맥을 함께 넘기면 설명의 정확도와 검색 품질이 크게 올라갑니다. 같은 막대 차트라도 "2024년 3분기 실적 발표 자료"라는 문맥이 있으면 설명에 그 맥락이 반영되어 관련 검색어와 더 잘 매칭됩니다.
GPT-4o 패턴
import openai
import base64
client = openai.OpenAI()
def image_to_text_openai(image_path: str, context: str = "") -> str:
"""GPT-4o를 사용한 이미지 설명 생성"""
with open(image_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
ext = image_path.split(".")[-1].lower()
mime_type = "image/jpeg" if ext in ["jpg", "jpeg"] else f"image/{ext}"
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{image_data}",
"detail": "high" # 고해상도 분석
}
},
{
"type": "text",
"text": f"이 이미지를 RAG 검색에 최적화된 형태로 상세히 설명해주세요. {context}"
}
]
}
],
max_tokens=1024
)
return response.choices[0].message.content
detail: "high" 옵션은 이미지를 여러 타일로 나눠 고해상도로 분석합니다.
차트의 수치나 스크린샷 속 작은 텍스트가 중요한 경우에 사용하되, 토큰 비용이 늘어난다는 부분을 인지하셔야 합니다.
단순한 사진이나 아이콘이라면 "low"로도 충분합니다.
RAG 파이프라인에 통합하기
from pathlib import Path
def process_document_with_images(doc_dir: str) -> list[dict]:
"""문서 디렉토리에서 텍스트와 이미지를 함께 처리"""
documents = []
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
for file_path in Path(doc_dir).iterdir():
if file_path.suffix.lower() in image_extensions:
# 이미지: Vision LLM으로 텍스트 설명 생성
try:
description = image_to_text_description(str(file_path))
documents.append({
"type": "image",
"source": str(file_path),
"text": description, # 인덱싱할 텍스트
"metadata": {
"file_name": file_path.name,
"content_type": "image"
}
})
except Exception as e:
print(f"이미지 처리 실패: {file_path} - {e}")
elif file_path.suffix.lower() == '.html':
# HTML: 텍스트 추출 + 테이블 변환
html_content = file_path.read_text()
markdown_content = html_table_to_markdown(html_content)
documents.append({
"type": "table",
"source": str(file_path),
"text": markdown_content,
"metadata": {
"file_name": file_path.name,
"content_type": "table"
}
})
return documents
metadata에 content_type을 저장해두는 것이 유용합니다. 검색 결과를 사용자에게 보여줄 때 텍스트 청크인지 이미지 설명인지를 구분해서 이미지 설명이 검색되었다면 source 경로를 통해 원본 이미지를 함께 보여주는 식으로 활용할 수 있습니다.
Vision LLM 사용 시 주의할 점
비용과 지연 시간이 가장 큰 제약입니다. 이미지를 Vision LLM으로 처리하는 것은 텍스트보다 훨씬 비쌉니다.
100페이지 문서에 이미지가 50개 있다면 모든 이미지를 처리하는 데 상당한 비용과 시간이 소요됩니다.
따라서 문서 전체를 한 번에 처리하지 말고 이미지가 실제로 중요한 페이지만 선별하거나 배치 처리로 비동기로 진행하는 것을 권장합니다.
그리고 복잡한 다이어그램에는 한계가 있습니다. 단순한 막대 차트나 스크린샷은 잘 처리하지만 여러 단계가 얽힌 플로우차트나 회로도처럼 정밀한 구조 파악이 필요한 이미지는 아직 한계가 있죠.
의료, 법률, 엔지니어링 같이 이미지의 정확성이 중요한 도메인에서는 생성된 설명을 반드시 사람이 검토하는 것이 좋습니다.
마지막으로 이미지 설명 생성은 인덱싱 시점에 한 번만 하는 것이 원칙입니다. 검색 시점마다 이미지를 다시 분석하면 비용이 급증합니다. 설명을 생성해서 저장해두고 검색은 텍스트 설명으로만 하고 사용자가 관련 이미지를 요청할 때 원본 이미지를 함께 제공하는 방식이 효율적입니다.
중간 정리
| 문제 | 접근 | 핵심 |
| 테이블 QA | TAPAS 개념 | 행/열 임베딩으로 2D 구조 인코딩, 셀 선택 + 집계 연산 |
| 테이블 → 텍스트 | Markdown / 자연어 변환 | 구조 보존하며 LLM 친화적 형식으로 변환 |
| 이미지 → 텍스트 | Vision LLM | base64 인코딩 후 텍스트 설명 생성, 인덱싱 |
실무에서 가장 자주 쓰이는 패턴은 테이블은 Markdown으로 변환하고 이미지는 Vision LLM으로 설명을 생성한 뒤 함께 인덱싱하는 방식입니다. TAPAS는 개념을 이해하는 데 유용하지만 직접 구현보다는 테이블 처리의 원리를 파악하는 용도로 공부하면 됩니다.
마치며
텍스트만 처리하는 RAG는 절반짜리입니다. 실제 기업 문서의 상당 부분이 표와 이미지로 구성되어 있고 이를 제대로 처리하지 못하면 그만큼 정보가 빠진 채로 검색이 이루어집니다.
테이블을 구조 정보를 살려서 변환하고 이미지를 Vision LLM으로 설명 텍스트로 만들어 인덱싱하는 것.
이 두 가지만 추가해도 RAG 시스템의 커버리지가 눈에 띄게 넓어집니다.
참고 자료
'AI' 카테고리의 다른 글
| [바미] RAG도 쓸수록 좋아질 수 있을까? (2) | 2026.04.01 |
|---|---|
| [바미] AI가 거짓말을 하는지 어떻게 알 수 있을까? - LLM이 그 답변 봐봐! 혹시 사쿠라야? (2) | 2026.03.30 |
| [바미] 구글도 키워드 검색을 버리지 않은 이유 (하이브리드 검색과 RAG-Fusion) (0) | 2026.03.29 |
| [바미] 내가 크롤링한 결과물이 쓰레기가 되는 이유 (2) | 2026.03.28 |
| [바미] 어떻게 자르느냐가 검색 품질을 결정한다. (0) | 2026.03.27 |