
RAG에서 멀티 시트 Excel과 Word를 파괴하지 않고 다루기 위해, 구조화 전처리 및 Chunking을 구현해 보았다
요약
Excel과 Word 같은 Office 문서를 RAG 시스템에 도입할 때, 문서 구조를 유지하며 검색 가능한 단위로 변환하는 전처리 및 청킹 전략을 소개합니다. 단순 텍스트 추출 방식의 한계를 지적하고, 메타데이터를 활용해 데이터 간의 관계를 보존하는 구현 방법을 다룹니다.
핵심 포인트
- 단순 글자 수 기반 청킹은 문서의 구조적 관계를 파괴함
- Excel은 시트별 형식 판정, Word는 계층 구조 유지가 핵심
- 의미가 깨지지 않는 최소 단위(표 행, 절차 단계 등)를 먼저 정의해야 함
- 메타데이터(sheet, section_path 등)를 남겨 검색 정확도 보완
RAG에서 PDF를 다루는 기사는 상당히 많아졌습니다.
반면, 실제 업무에서는 다음과 같은 Office 문서도 자주 사용됩니다.
- 여러 개의 sheet를 가진 Excel
- 일람표와 작업 절차가 혼재된 Excel
- 제목, 본문, 불렛 포인트, 표가 혼재된 Word
- 수식, 병합된 셀, 숨겨진 sheet를 포함한 Excel
이것들을 단순히 plain text로 변환한 뒤 1,000자마다 분할하면, 겉보기에는 텍스트가 되어 있더라도 질문에 답하기 위한 관계가 깨져버립니다.
그래서 이번에는 Excel과 Word를 RAG에 도입할 때, 문서 구조를 남긴 채로 "검색 가능한 증거 단위"로 변환하는 방법을 정리합니다.
대상 독자는 실제로 Office 문서를 사용한 RAG를 구축·개선하고 싶은 분들입니다.
단순한 개념뿐만 아니라, 실수하기 쉬운 부분, 데이터 구조, 샘플 문서 생성 코드, 변환 후의 JSONL, chunk 분할 코드까지 소개합니다.
이 기사에 등장하는 파일명, 상품, 커맨드, 데이터는 모두 설명을 위한 가공의 데이터입니다.
먼저 결론을 쓰자면, Office 문서의 RAG에서는 다음 순서가 안정적입니다.
1. 파일 형식별로 구조를 읽는다
2. Excel은 sheet별로 "표 형식"과 "절차 형식"을 판정한다
3. Word는 제목 계층과 본문·표의 출현 순서를 유지한다
...
중요한 것은 처음부터 글자 수만으로 chunk를 만들지 않는 것입니다.
Excel의 1행, 작업 절차의 1단계, Word의 1절, Word 표의 1행 등, "이 이상 나누면 의미가 깨지는 단위"를 먼저 만듭니다.
흔히 사용하는 도입 방법은 Excel이나 Word에서 문자열을 추출하여 모두 연결한 뒤 고정 길이로 분할하는 방법입니다.
text = extract_all_text("document.xlsx")
chunks = [text[i:i + 1000] for i in range(0, len(text), 1000)]
구현은 간단하지만, 업무 문서에서는 다음과 같은 문제가 발생합니다.
- sheet 이름이 사라져서, 동일한 열 이름을 가진 표를 구별할 수 없다
- 표의 헤더와 값이 별도의 chunk가 된다
- 작업 커맨드와 확인 방법이 별도의 chunk가 된다
...
예를 들어, 다음 작업 절차를 생각해 봅시다.
| No. | 작업 항목 | 커맨드/확인 포인트 | 비고 |
|---|---|---|---|
| 1.1 | 사전 확인 | samplectl service status | running을 확인 |
samplectl disk check | 빈 용량을 확인 |
1행과 2행을 별개의 chunk로 나누면, "무엇을 실행할지"는 검색할 수 있어도 "무엇을 확인할지"가 검색 결과에 포함되지 않을 가능성이 있습니다.
LLM에 전달하기 전에 문서 구조가 깨져 있으면, Embedding이나 Rerank 모델을 개선하더라도 한계가 있습니다.
Excel과 Word는 읽는 방법이 다릅니다.
다만, 후속 처리까지 별도로 분리하면 Embedding, 검색, 인용, 평가 구현이 복잡해집니다.
그래서 Preprocess의 출구만 공통화합니다.
{
"source_file": "sample_office_docs.xlsx",
"source_type": "excel",
...
최소한 다음의 metadata를 남깁니다.
| 항목 | 목적 |
|---|---|
source_file | 원본 파일을 특정한다 |
source_type | Excel, Word, CSV 등을 구별한다 |
block_type | 표의 1행, 절차, Word 절, Word 표의 1행을 구별한다 |
sheet | Excel의 출처 sheet로 돌아간다 |
section_path | 상위 제목을 포함하여 의미를 보완한다 |
row_start / row_end | 원본 행 또는 문서 내 위치로 돌아간다 |
content | 문장·절차용 본문 |
fields | 표 형식 데이터의 열과 값의 대응 |
빈 항목은 출력하지 않습니다.
이를 통해 Excel과 Word의 차이를 남기면서도, 후속 단계에서는 동일한 JSONL로 다룰 수 있습니다.
Excel에서는 workbook 전체를 하나의 표로 취급하지 않는 것이 중요합니다.
sheet마다 역할, 열 구성, 밀도가 다르기 때문입니다.
구현에서는 다음 순서로 처리합니다.
workbook
├─ visible sheet를 열거
├─ 제외 대상 sheet를 제외
...
모든 sheet를 무조건적으로 가져오면, 평가용 답변, 계산용 중간 sheet, 오래된 백업, 숨김 설정 등이 지식 베이스 (Knowledge Base)에 섞일 수 있습니다.
다음 항목들은 설정 가능하도록 해두는 것이 안전합니다.
excluded_sheets = {"QA", "Answer", "Evaluation"}
include_hidden = False
제외 이름을 코드 곳곳에 심는 것이 아니라, 변환 함수의 인자(Argument) 또는 설정 파일로부터 전달합니다.
convert_excel_to_text_document(
file_path,
excluded_sheets=("QA", "Answer", "Evaluation"),
...
여기서 중요한 것은 단순히 여러 sheet를 읽을 수 있다는 것만이 아닙니다.
"어떤 sheet를, 왜 읽지 않았는가"를 로그 (Log)에 남겨두면, 나중에 검색 누락을 조사하기 쉬워집니다.
openpyxl에서는 동일한 workbook을 두 번 엽니다.
from openpyxl import load_workbook
formula_book = load_workbook(path, data_only=False)
value_book = load_workbook(path, data_only=True)
data_only=False : 수식 (Formula)을 가져옴
data_only=True : Excel이 저장한 계산 결과값을 가져옴
기본 방침은 다음과 같습니다.
value = cached_value if cached_value is not None else formula
저장된 계산 결과가 있다면 값을 우선하고, 없다면 수식을 남깁니다.
참고로, openpyxl 자체는 Excel의 수식 계산 엔진 (Calculation Engine)이 아닙니다. 생성 직후 등 계산 결과가 저장되어 있지 않은 경우에는 수식 문자열이 출력됩니다.
동일한 Excel이라도 다음 두 가지 종류는 chunk를 만드는 방식이 다릅니다.
상품ID | 상품명 | 단가 | 재고수 | 업데이트 일시
ITEM-001 | 노트북 스탠드 | 3200 | 12 | ...
ITEM-002 | USB 허브 | 4800 | 8 | ...
표 형식 (Table format)에서는 데이터 행 1개를 하나의 Evidence Block으로 만듭니다.
{
"block_type": "table_row",
"sheet": "Products",
...
이를 통해 상품ID, 상품명, 가격, 재고수 사이의 관계를 동일한 chunk 내에 유지할 수 있습니다.
No. | 작업 항목 | 커맨드/확인 포인트
1 | 월간 유지보수 |
1.1 | 사전 확인 | samplectl service status
...
절차 형식 (Procedure format)에서는 1행을 1개의 chunk로 만들어서는 안 됩니다.
"번호 열과 작업 항목 열 모두에 값이 있는 행"을 다음 절차의 시작 위치로 삼아, 다음 시작 위치 직전까지를 동일한 Evidence Block으로 묶습니다.
{
"block_type": "procedure_step",
"sheet": "Maintenance",
...
상위 작업명도 각 하위 단계 (Sub-step)에 반복해서 넣어줍니다.
그렇게 하면 "월간 유지보수의 사전 확인은 무엇인가"와 같은 질문에도, 검색 대상인 chunk 단독으로 의미가 통하게 됩니다.
자동 판정 구현 예시입니다.
from statistics import median
def detect_sheet_mode(rows, header_index, active_columns):
if has_number_and_title_boundaries(
...
이 예시에서는 최대 50개의 비어 있지 않은 행을 조사하여, 이용 열의 중앙값 (Median)이 60% 이상 채워져 있으면 표 형식으로 간주합니다.
60%가 절대적인 정답은 아닙니다.
실제 데이터에서 오판정된 sheet를 로그에 남기고, sheet 단위로 table 또는 procedure를 덮어쓸 수 있도록 해두는 것이 현실적입니다.
구현 시에는 다음 사항도 확인합니다.
| 대상 | 처리 |
|---|---|
| 빈 줄 | chunk를 만들지 않음 |
| ... | column_C와 같은 이름을 붙임 |
| 병합된 셀 | 논리 블록 내에서만 값을 계승 |
| 숨겨진 시트 | 초기값으로는 읽어오지 않음 |
| 수식 | 저장된 값을 우선하며, 없을 경우 수식을 남김 |
.xls | xlrd 등 별도의 reader를 사용 |
| CSV | UTF-8 외에 CP932도 고려 |
중복 헤더의 예입니다.
담당 | 담당
이대로 dict로 변환하면, 나중의 값으로 덮어씌워집니다.
담당
담당__H
와 같이 열 기호로 구분하면, 원래의 열 위치도 알 수 있습니다.
Word에서는 제목(Heading), 본문, 불렛 포인트(Bullet points), 표가 교대로 등장합니다.
제목
본문
불렛 포인트
...
python-docx의 document.paragraphs와 document.tables를 별도로 처리하면, 본문과 표의 출현 순서를 알 수 없게 됩니다.
따라서, 문서 body의 XML 요소를 순서대로 탐색합니다.
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table
...
예를 들어, 다음과 같은 Word 문서가 있습니다.
운영 가이드 Heading 1
백업 Heading 2
백업을 생성합니다. 본문
...
백업을 생성합니다.만 chunk에 넣는 것이 아니라, 다음과 같이 제목 계층을 부여합니다.
{
"source_file": "sample_operations_guide.docx",
"source_type": "word",
...
검색 시에는 본문뿐만 아니라, 운영 가이드 > 백업도 임베딩 (Embedding) 대상으로 포함합니다.
동일한 "확인"이라는 단어가 여러 장(Chapter)에 있더라도, 의미를 구분하기 쉬워집니다.
Word의 목차에는 본문과 동일한 제목 문자열이 나열됩니다.
TOC 1, TOC 2 등의 스타일(style)을 본문으로 가져오면, 검색 결과에 목차만 나올 수 있습니다.
if style_name.casefold().startswith("toc"):
continue
단, 독자적인 스타일을 사용하는 문서도 있으므로, 스타일 이름은 로그에서 확인할 수 있도록 합니다.
표는 첫 번째 행을 헤더로 하여, 각 데이터 행을 필드 매핑 (field mapping)으로 변환합니다.
{
"source_file": "sample_operations_guide.docx",
"source_type": "word",
...
표의 각 행에 현재의 section_path를 계승시키는 것이 포인트입니다.
표만 보더라도, 그 표가 무엇에 대해 설명하고 있는지 알 수 있게 됩니다.
전처리 (Preprocess)에서 올바르게 구조화하더라도, 후속 단계에서 다시 1,000자마다 자르면 의미가 없습니다.
그래서, 증거 블록 (Evidence Block)의 끝에 고정 구분자 (fixed delimiter)를 붙입니다.
{"block_type":"table_row", ...} <FIXED_DELIMITER>
{"block_type":"procedure_step", ...} <FIXED_DELIMITER>
{"block_type":"word_section", ...} <FIXED_DELIMITER>
스플리터 (splitter) 측에서는 이 구분자가 있는 경우, 일반적인 chunk_size와 chunk_overlap보다 블록 (Block) 경계를 우선합니다.
FIXED_DELIMITER = "<FIXED_DELIMITER>"
def split_fixed_blocks(text: str) -> list[str]:
if FIXED_DELIMITER not in text:
...
이렇게 하면 원칙적으로 다음과 같은 관계가 성립합니다.
1 Evidence Block = 1 Chunk
하나의 Word 절이나 작업 절차가 극단적으로 긴 경우에는 상한선도 필요합니다.
단, 3,000자가 되는 순간 문자열을 자르는 것이 아니라, 다음 경계에서만 분할합니다.
- Word의 단락 경계
- 불렛 포인트(箇条書き) 항목 경계
- Excel 절차의 커맨드/확인 행 경계
...
표의 1행이 상한을 초과하는 경우에도, 중간에 분할하면 열(Column)과 값(Value)의 대응 관계가 깨집니다.
따라서 표의 1행이나 단독 커맨드는 원자적(atomic)인 요소로 취급하여, 소프트 리밋(soft limit)을 초과하더라도 유지하는 선택지가 필요합니다.
고정 길이 청크(fixed-length chunk)에서는 앞뒤 문맥을 보완하기 위해 오버랩(overlap)을 사용합니다.
하지만 구조화된 블록(structured Block)에 기계적으로 오버랩을 적용하면, 다른 시트(sheet)나 다른 절차의 JSON이 섞일 가능성이 있습니다.
Evidence Block에 section_path나 상위 타이틀을 반복하고 있는 경우, 고정 구분자(fixed delimiter)를 사용하는 청크에는 오버랩을 붙이지 않는 것이 다루기 쉽습니다.
여기서부터는 가상의 Excel과 Word를 생성합니다.
Excel에는 다음 시트(sheet)를 만듭니다.
| sheet | 내용 | 기대하는 처리 |
|---|---|---|
Products | 상품 목록 | 표 형식으로서 1행 1블록(1Block) |
Maintenance | 월간 작업 | 절차 형식으로서 여러 행을 1블록(1Block) |
QA | 평가용 데이터 | 제외 |
HiddenConfig | 내부 설정 | 숨김 처리되어 제외 |
Word에는 헤딩(Heading), 본문, 불렛 포인트(箇条書き), 표를 순서대로 배치합니다.
pip install openpyxl python-docx
from datetime import datetime
from pathlib import Path
from docx import Document
...
samplectl은 설명을 위한 가상의 커맨드입니다.
Products의 2행은 다음과 같은 블록(Block)이 됩니다.
{
"source_file": "sample_office_docs.xlsx",
"source_type": "excel",
...
이 파일은 Python으로 생성한 직후이므로, Excel에 의한 수식의 재계산 결과는 저장되어 있지 않습니다.
따라서 在庫金額(재고 금액)에는 수식이 남아있다고 가정합니다.
Maintenance의 3~4행은 하나의 작업 블록(work Block)이 됩니다.
{
"source_file": "sample_office_docs.xlsx",
"source_type": "excel",
...
실행 커맨드와 확인 조건이 동일한 청크(chunk)에 들어있습니다.
バックアップ(백업) 섹션의 본문과 불렛 포인트(箇条書き)입니다.
{
"source_file": "sample_operations_guide.docx",
"source_type": "word",
...
직후의 표는 동일한 헤딩 계층을 상속합니다.
{
"source_file": "sample_operations_guide.docx",
"source_type": "word",
...
Evidence Block을 1행 1JSON으로 저장합니다.
import json
from pathlib import Path
FIXED_DELIMITER = "<FIXED_DELIMITER>"
...
출력 이미지입니다.
JSONL 형식으로 저장하면 다음 용도로 사용하기 편리합니다.
- 청크(chunk) 단위 재처리
- 출처 메타데이터를 이용한 필터링
- 변환 전후의 diff 비교
- 검색 실패 시 조사
- Embedding만 재작성
- 블록 단위 자동 테스트
구조화된 Preprocess에서는 단순히 변환되었는지 여부뿐만 아니라, '관계가 깨지지 않았는지'를 테스트합니다.
[ ] 여러 개의 visible sheet를 읽을 수 있음
[ ] 제외(exclude)된 sheet가 지식 베이스에 들어가지 않음
[ ] hidden sheet의 초기값이 제외되어 있는지
...
[ ] Heading 1~3의 계층 구조를 유지함
[ ] 목차 스타일을 제외함
[ ] 본문, 목록(bullet point), 표의 출현 순서를 유지함
...
가장 중요한 테스트입니다.
chunks = split_fixed_blocks(evidence_text)
assert len(chunks) == len(evidence_blocks)
assert all(
...
게다가 다음도 확인합니다.
[ ] 1 Evidence Block이 1 chunk가 됨
[ ] 구조화된 Block에 overlap(겹침)이 혼입되지 않음
[ ] 모든 chunk에 source_file이 있음
...
검색 정확도가 높아 보여도, 평가용 답변 시트만 검색하는 것만으로는 의미가 없습니다.
평가 데이터와 지식 데이터는 가져오는(取り込み) 시점에서 분리해야 합니다.
일람표(一覧表)와 작업 절차에서는 의미의 단위가 다릅니다.
workbook 단위가 아니라, sheet 단위로 판정합니다.
800자, 1,000자, 1,500자로 변경해도, 원래 구조가 망가지면 개선되지 않습니다.
먼저, 표의 1행, 절차의 1단계, Word의 1절을 올바르게 만듭니다.
답변만 표시할 수 있어도, 이용자가 근거를 확인할 수 없다면 업무에서는 사용하기 어렵습니다.
source_file
,
sheet
,
section_path
,
row_start
,
row_end
을 처음부터 가지고 있어야 합니다.
절차의 실행 내용과 확인 조건이 분리되면, 위험한 답변이 되기 쉽습니다.
글자 수 상한선보다 먼저, 의미의 최소 단위를 정해야 합니다.
| 단계 | 확인할 것 |
|---|---|
| Input | 원본을 변경하지 않고, 별도의 output에 저장함 |
| ... | |
| Office 문서를 RAG로 다룰 때 중요한 것은 'Excel이나 Word를 텍스트화했는지'가 아닙니다. |
중요한 것은 질문에 답하는 데 필요한 관계를 망가뜨리지 않고, 검색 가능한 Evidence Block으로 변환했는지입니다.
Excel:
sheet를 남김
표는 1행 단위
...
먼저 Parser와 Chunking을 안정화시키면, Embedding, Hybrid Search, Rerank, LLM 답변 개선 등을 올바르게 평가할 수 있습니다.
멀티 시트 Excel이나 Word를 RAG에 넣을 때는, 먼저 하나의 질문에 필요한 정보가 '같은 chunk 안에 남아 있는지'를 확인해 보세요.
꼭 한번 사용해 보시길 바랍니다!
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기