목차
- 문제와 접근
- unstructured 설치와 기본 설정
- PDF partition
- fast vs hi_res 전략
- 문서 분석 함수
- HTML/DOCX/PPTX 파싱
- chunk_by_title vs chunk_elements
- 텍스트 클리닝
- UnstructuredFileLoader mode
- answer_from_pdf
- HuggingFace SFTTrainer 보조 실습
- 핵심 개념 총정리
- 자주 나오는 실수/주의사항
- 파싱 품질을 검색 결과까지 연결해 보기
1. 문제와 접근
문서 파싱은 텍스트를 많이 추출하는 것보다 원래 구조와 출처를 얼마나 보존하는지가 중요하다. unstructured의 요소와 청킹 방식을 비교하면서 검색에 넘길 문서 단위를 정한다. 후반의 SFTTrainer 예제는 별도의 학습 실습으로 구분해 준비 상태와 확인할 결과를 정리한다.
1
2
3
4
5
6
7
8
9
10
11
| PDF/HTML/DOCX/PPTX
↓
partition
↓
element 분석
↓
chunking
↓
Document 변환
↓
FAISS 검색 + LLM 답변
|
2. unstructured 설치와 기본 설정
1
| !pip install -q "unstructured[pdf,docx,pptx]" unstructured-inference
|
1
2
3
4
5
6
7
8
9
| import os
import warnings
warnings.filterwarnings("ignore")
from dotenv import load_dotenv
load_dotenv()
SAMPLE_DIR = "samples"
BATCH_DIR = os.path.join(SAMPLE_DIR, "batch")
|
3. PDF partition
partition()은 파일 확장자를 보고 자동 파서를 선택한다.
1
2
3
4
5
6
7
| from unstructured.partition.auto import partition
from collections import Counter
pdf_path = os.path.join(SAMPLE_DIR, "business_report.pdf")
elements = partition(filename=pdf_path)
len(elements)
|
1
2
| for i, el in enumerate(elements[:5]):
print(el.category, "|||", el)
|
4. fast vs hi_res 전략
전략 비교는 같은 PDF에서 제목·본문·표가 어떻게 분리됐는지부터 시작한다. 복잡한 전략을 사용했다는 이유만으로 결과가 더 적합하다고 가정하지 않고, 필요한 구조가 보존됐는지와 처리 시간을 함께 확인한다.
PDF 전용 파서에서는 전략을 명시할 수 있다.
1
2
3
4
5
6
7
8
| from unstructured.partition.pdf import partition_pdf
elements = partition_pdf(filename=pdf_path, strategy="fast")
elements_hi = partition_pdf(
filename=pdf_path,
strategy="hi_res",
infer_table_structure=True,
)
|
1
2
3
4
5
6
7
| fast:
- 텍스트 위주 추출
- 빠름
hi_res:
- 레이아웃, 표, 이미지 구조까지 더 자세히 분석
- 느리지만 표/레이아웃 문서에 유리
|
1
2
3
4
5
| for c, n in Counter(el.category for el in elements).most_common():
print(f" {c}: {n}")
for c, n in Counter(el.category for el in elements_hi).most_common():
print(f" {c}: {n}")
|
5. 문서 분석 함수
요소 타입 분포, 페이지별 분포, 긴 본문 요소를 요약한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| from collections import defaultdict
def analyze_document(pdf_path: str, strategy: str = "fast") -> dict:
els = partition_pdf(filename=pdf_path, strategy=strategy)
type_stats = dict(Counter(el.category for el in els))
page_dist = defaultdict(lambda: Counter())
for el in els:
p = int(el.metadata.page_number or 0)
page_dist[p][el.category] += 1
narratives = [el for el in els if el.category == "NarrativeText"]
narratives.sort(key=lambda e: len(str(e)), reverse=True)
top3 = [{
"length": len(str(el)),
"page": int(el.metadata.page_number or 0),
"text_preview": str(el)[:80],
} for el in narratives[:3]]
return {
"total_elements": len(els),
"type_statistics": type_stats,
"page_distribution": {p: dict(c) for p, c in page_dist.items()},
"top3_longest_narratives": top3,
}
|
6. HTML/DOCX/PPTX 파싱
문서 포맷별 전용 파서를 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
| from unstructured.partition.html import partition_html
from unstructured.partition.docx import partition_docx
from unstructured.partition.pptx import partition_pptx
html_path = os.path.join(SAMPLE_DIR, "product_page.html")
docx_path = os.path.join(SAMPLE_DIR, "quarterly_report.docx")
pptx_path = os.path.join(SAMPLE_DIR, "investor_deck.pptx")
html_els = partition_html(filename=html_path)
docx_els = partition_docx(filename=docx_path)
pptx_els = partition_pptx(filename=pptx_path)
|
1
2
3
4
5
6
7
8
9
| import pandas as pd
rows = []
for fmt, els in [("html", html_els), ("docx", docx_els), ("pptx", pptx_els)]:
cats = Counter(el.category for el in els)
rows.append({"format": fmt, "total": len(els), **dict(cats)})
df = pd.DataFrame(rows).fillna(0).set_index("format")
print(df.to_string())
|
7. chunk_by_title vs chunk_elements
문서 구조를 보존하려면 제목 기반 청킹을 쓴다.
1
2
3
4
5
6
7
8
9
10
11
| from unstructured.chunking.title import chunk_by_title
from unstructured.chunking.basic import chunk_elements
chunks_title = chunk_by_title(
elements,
max_characters=1000,
new_after_n_chars=800,
combine_text_under_n_chars=200,
)
chunk_basic = chunk_elements(elements, max_characters=500, overlap=50)
|
adaptive_chunk
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def adaptive_chunk(elements: list) -> list:
if not elements:
return []
avg_len = sum(len(str(e)) for e in elements) / len(elements)
if avg_len >= 200:
max_chars = 1000
elif avg_len >= 50:
max_chars = 500
else:
max_chars = 300
return chunk_by_title(
elements,
max_characters=max_chars,
combine_text_under_n_chars=200,
)
|
8. 텍스트 클리닝
공백, 따옴표, 줄바꿈을 정리한다.
1
2
3
4
5
6
7
8
9
10
11
| from unstructured.cleaners.core import clean_extra_whitespace, replace_unicode_quotes
samples = [
" This is a test with extra spaces. ",
"Revenue was “$2.8 billion” in FY2024.",
"Line\n\n\n\nwith too many\nlinebreaks",
]
for s in samples:
cleaned = clean_extra_whitespace(replace_unicode_quotes(s))
print(cleaned)
|
9. UnstructuredFileLoader mode
LangChain loader로 문서를 불러올 때 mode에 따라 결과 단위가 달라진다.
1
2
3
4
5
6
7
| from langchain_community.document_loaders import UnstructuredFileLoader
for mode in ["single", "elements", "paged"]:
loader = UnstructuredFileLoader(pdf_path, mode=mode, strategy="fast")
docs = loader.load()
avg = sum(len(d.page_content) for d in docs) / max(len(docs), 1)
print(f"{mode} {len(docs)} {avg} {docs[0].page_content[:30]}")
|
1
2
3
| single : 전체 문서 하나
elements : 요소별 Document
paged : 페이지별 Document
|
10. answer_from_pdf
질의응답 함수의 결과가 잘못됐다면 파싱된 요소, 생성한 청크, 검색 문서를 순서대로 살펴본다. 원문 페이지 정보를 유지하면 LLM의 답변 오류와 파싱 단계에서 빠진 내용을 구분하기 쉽다.
PDF를 파싱하고, chunking, embedding, retrieval, generation까지 한 함수로 묶는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.messages import SystemMessage, HumanMessage
from langchain_core.documents import Document
def answer_from_pdf(pdf_path, question, k=3) -> dict:
els = partition_pdf(filename=pdf_path, strategy="fast")
cleaned = [el for el in els if el.category not in ["Header", "Footer", "PageBreak"]]
chunks = chunk_by_title(
cleaned,
max_characters=600,
combine_text_under_n_chars=150,
)
docs = [
Document(
page_content=str(c),
metadata={
"source": os.path.basename(pdf_path),
"page": getattr(c.metadata, "page_number", None),
"category": getattr(c.metadata, "category", None),
},
)
for c in chunks
if str(c).strip()
]
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = FAISS.from_documents(docs, embeddings)
hits = vectorstore.similarity_search(question, k=k)
context = "\n\n".join(
f"[p{h.metadata.get('page', '?')}] {h.page_content}" for h in hits
)
llm = ChatOpenAI(model="gpt-4o-mini")
msg = llm.invoke([
SystemMessage(content="주어진 컨텍스트에 근거해 답변하세요"),
HumanMessage(content=f"컨텍스트:\n{context}\n\n질문: {question}"),
])
return {
"answer": msg.content,
"sources": [
{"page": h.metadata.get("page", "?"), "preview": h.page_content[:30]}
for h in hits
],
}
|
11. HuggingFace SFTTrainer 보조 실습
5월 20일 정리의 10절 Qwen LoRA 설정에 이어 trl.SFTTrainer로 학습한다. 새 노트북이라면 아래 항목을 먼저 준비한다.
model: Qwen/Qwen2.5-1.5B-Instruct에 LoRA를 적용한 모델tokenizer: 같은 모델의 토크나이저와 채팅 템플릿ds: messages 열이 있는 JSONL을 불러온 train / validation 데이터셋- 실행 환경:
torch, transformers, peft, datasets, trl, accelerate가 설치된 환경. 아래 설정은 BF16을 지원하는 GPU 환경을 전제로 하며, 다른 환경에서는 모델 로딩 dtype과 bf16 설정을 함께 조정한다.
전날 생성한 train/validation 파일 경로를 확인하고 모델·토크나이저·데이터 로딩 셀을 먼저 실행한다. 라이브러리 버전에 따라 인자가 달라질 수 있으므로 사용한 버전을 기록한다. (SFTTrainer 공식 문서)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| from trl import SFTTrainer, SFTConfig
OUTPUT_DIR = "qwen25-1.5b_polite_email_lora"
sft_cfg = SFTConfig(
output_dir=OUTPUT_DIR,
num_train_epochs=80,
per_device_train_batch_size=2,
per_device_eval_batch_size=2,
learning_rate=2e-4,
warmup_ratio=0.1,
lr_scheduler_type="cosine",
bf16=True,
max_length=512,
)
trainer = SFTTrainer(
model=model,
args=sft_cfg,
train_dataset=ds["train"],
eval_dataset=ds["validation"],
processing_class=tokenizer,
)
trainer.train()
# 학습 종료 후 validation 데이터로 평가
metrics = trainer.evaluate()
print(metrics)
|
num_train_epochs=80은 강의 실험값이며 모든 데이터에 권장되는 횟수는 아니다. per_device_train_batch_size=2는 장치당 배치 크기이고, max_length=512는 학습 시퀀스 길이 제한이다. 긴 예제가 잘려 답변 부분이 사라지지 않는지 토큰화 결과를 확인한다.
trainer.evaluate()의 결과에서 eval_loss를 확인한다. 이는 검증 데이터의 다음 토큰 예측 손실이며 답변 정확도 자체는 아니다. 동일한 검증 데이터와 설정에서 손실과 생성 답변을 함께 비교한다. 학습 중 검증 손실 추이를 보려면 eval_strategy를 별도로 설정해야 한다.
12. 핵심 개념 총정리
| 개념 | 역할 |
|---|
| partition | 파일을 문서 요소로 분해 |
| partition_pdf fast | 빠른 텍스트 추출 |
| partition_pdf hi_res | 레이아웃/표 구조 추출 |
| chunk_by_title | 제목 구조 기반 청킹 |
| chunk_elements | 기본 길이 기반 청킹 |
| UnstructuredFileLoader | LangChain Document로 로드 |
| answer_from_pdf | PDF 파싱부터 RAG 답변까지 연결 |
| SFTTrainer | HuggingFace supervised fine-tuning |
13. 자주 나오는 실수/주의사항
실수 1: defaultdict import 누락
1
| from collections import Counter, defaultdict
|
실수 2: PDF strategy 선택 착각
1
2
| 표/이미지/레이아웃이 중요하면 hi_res,
텍스트 위주 빠른 처리면 fast.
|
1
| cleaned = [el for el in els if el.category not in ["Header", "Footer", "PageBreak"]]
|
실수 4: loader mode 혼동
1
2
| single은 문서 전체가 하나라 검색 단위가 너무 클 수 있다.
RAG에는 elements 또는 chunking 후 Document 생성이 더 적합하다.
|
14. 파싱 품질을 검색 결과까지 연결해 보기
파서를 선택할 때는 같은 문서의 표와 본문이 어떤 형태로 남는지 비교한다. 레이아웃을 잘 인식해도 청킹 과정에서 제목과 내용이 분리되면 검색에 필요한 정보가 사라질 수 있다.
후속 확인에는 원문 페이지를 기준으로 답할 수 있는 질문을 몇 개 정해 두는 방식이 유용하다. 추출된 텍스트 양과 실제로 회수한 근거를 함께 보고, 파싱 전략과 청킹 설정을 한 번에 바꾸지 않도록 비교 단위를 나눈다.