들어가기 전에...
이 글은 RAG 시스템을 구축하면서 웹 데이터를 활용하려는 분들을 위한 글입니다.
앞서 RAG 논문 리뷰와 Chunking 전략을 다뤘는데 사실 그 전에 먼저 짚고 넘어갔어야 할 주제가 있습니다.
바로 데이터 정제입니다. 아무리 좋은 청킹 전략을 써도 인덱싱하는 데이터 자체가 오염되어 있으면 의미가 없습니다.
이번 글에서는 웹 크롤링 데이터를 RAG에 쓸 수 있는 형태로 만드는 방법을 jusText, Trafilatura, newspaper3k 세 가지 도구를 중심으로 정리 해보았습니다.
데이터 정제가 왜 필요할까?
열심히 크롤링해서 수천 개의 페이지를 긁어왔는데 막상 RAG 시스템에 넣어보면 이상한 답변이 나오는 경험 해보셨죠?
그런 문제는 크롤링 코드보다 크롤링한 HTML을 그대로 청킹했기 때문에 발생한 게 원인입니다.
<nav>홈 | 로그인 | 회원가입 | 고객센터</nav>
<div class="ad-banner">지금 구매하면 30% 할인!</div>
<article>실제로 필요한 본문 내용...</article>
<footer>Copyright 2024 | 개인정보처리방침 | 이용약관</footer>
네비게이션, 광고 배너, 푸터, 로그인 버튼 텍스트까지 모두 청크로 만들어져 인덱스에 들어갑니다. '개인정보처리방침'이나 '지금 구매하면 30% 할인!' 같은 내용이 검색 결과로 나오는 RAG 시스템은 쓸 수가 없죠.
이런 불필요한 내용을 Boilerplate(보일러플레이트) 라고 부릅니다. 웹 페이지에서 실제 본문이 아닌 반복적으로 등장하는 구조적 요소들입니다.
크롤링 결과물이 쓰레기가 되는 이유가 바로 여기에 있는 것이죠. 데이터가 많다고 좋은 게 아니라 본문만 깔끔하게 추출해야 비로소 쓸 수 있는 데이터가 됩니다.
일반적인 뉴스 페이지를 크롤링해보면 전체 HTML 중 실제 기사 본문이 차지하는 비율은 20~30% 정도에 불과한 경우가 많습니다. 나머지 70~80%는 광고, 관련 기사 링크, 댓글 유도 문구, 소셜 공유 버튼 텍스트, 쿠키 동의 팝업 같은 것들입니다. 이 상태로 청킹하면 인덱스의 대부분이 노이즈로 채워지는 셈이 되는 것이죠.
그 외에도 더 까다로운 문제도 있는데 같은 사이트에서 수백 개의 페이지를 크롤링하면, 모든 페이지에 공통으로 들어있는 헤더, 푸터, 사이드바 텍스트가 수백 번 중복으로 인덱싱됩니다. '이 사이트의 다른 기사도 확인해보세요' 같은 문구가 수백 개의 청크로 만들어져, 검색할 때마다 관련 없는 결과로 튀어나올 수 있습니다.
이제 데이터 정제는 선택이 아닙니다. RAG 파이프라인에서 크롤링 다음 단계로 반드시 거쳐야 하는 과정이죠. 그리고 이 과정을 제대로 하는 데 도움을 주는 도구들이 있습니다. 지금부터 하나씩 살펴보겠습니다.
첫 번째 보일러플레이트 제거 도구 - jusText
jusText란?
jusText는 HTML 페이지에서 네비게이션 링크, 헤더, 푸터 같은 보일러플레이트 콘텐츠를 제거하는 도구입니다.
완전한 문장을 포함한 텍스트를 주로 보존하도록 설계되어 있어 웹 코퍼스 같은 언어 자원을 만드는 데 적합합니다.
Trafilatura나 newspaper3k 같은 더 편리한 도구들이 있음에도 jusText를 먼저 소개하는 이유는 실제로 Trafilatura 내부에서 fallback으로 jusText를 사용할 만큼 신뢰받는 알고리즘이고, 무엇보다 '보일러플레이트를 어떤 기준으로 판단하는가?'를 가장 투명하게 들여다볼 수 있는 도구이기 때문입니다.
원리를 이해하면 다른 도구를 쓸 때도 어떤 상황에서 실패하는지, 어떻게 보완할지를 판단하는 데 도움이 됩니다.
알고리즘 동작 방식
jusText의 핵심 아이디어는 단순한 규칙 기반이 아니라 블록 분류 + 문맥 고려 방식입니다. HTML 전체를 한 번에 보는 게 아니라
먼저 텍스트 블록 단위로 잘라낸 뒤 각 블록을 개별적으로 분류하고, 마지막으로 주변 블록의 분류 결과를 참고해 불확실한 블록을 재판단합니다.
1단계 - 세그멘테이션
HTML을 블록 단위로 분리합니다. <p>, <div>, <br><br> 같이 브라우저에서 시각적으로 블록을 나누는 태그를 기준으로 텍스트를 잘라냅니다. 이때 한 블록 안에 본문과 보일러플레이트가 섞이는 경우는 드물기 때문에 블록 단위 분류가 꽤 효과적으로 동작합니다.
2단계 - 전처리
<script>, <style>, <header> 태그의 내용을 제거합니다. © 저작권 기호가 포함된 블록이나 <select> 태그 내용은 즉시 보일러플레이트로 분류합니다. 명백히 본문이 아닌 것들을 빠르게 걸러내는 단계입니다.
3단계 - 문맥 독립적 분류
블록을 주변과 무관하게 독립적으로 4가지 클래스 중 하나로 분류합니다.
- Good: 본문으로 판단
- Bad: 보일러플레이트로 판단
- Short: 짧아서 판단 보류
- Near-good: 본문에 가깝지만 불확실
분류 기준은 이렇습니다. 링크가 포함된 짧은 블록은 거의 항상 보일러플레이트입니다. 링크가 많은 블록도 마찬가지입니다.
반대로 문법적인 텍스트를 포함한 긴 블록은 거의 항상 본문이고, 문법적이지 않은 긴 블록은 거의 항상 보일러플레이트입니다.
여기서 '문법적인 텍스트인지'를 판단하는 기준이 흥미롭습니다. jusText는 불용어(stop words) 비율을 사용합니다. 불용어란 영어의 'the', 'is', 'at', 한국어의 '은', '는', '이', '가' 같은 기능어입니다.
자연스러운 문장에는 이런 기능어가 일정 비율 포함되어 있는 반면 내비게이션 링크('홈 | 로그인 | 회원가입')나 목록 항목('1. 개요 2. 설치 3. 사용법') 같은 보일러플레이트는 기능어가 거의 없습니다. 이 차이를 수치로 포착하는 것이 핵심입니다.
4단계 - 문맥 고려 분류
3단계에서 Good/Bad로 확실히 분류된 블록은 그대로 유지합니다. Short/Near-good처럼 애매하게 분류된 블록들은 이 단계에서 주변 블록을 보고 재판단합니다. Good 블록에 둘러싸인 Short 블록은 Good으로 Bad 블록에 둘러싸인 Short 블록은 Bad로 재분류됩니다.
보일러플레이트와 본문은 각각 클러스터를 이루는 경향이 있다는 관찰에서 나온 아이디어입니다. 헤더 근처의 짧은 텍스트는 보일러플레이트일 가능성이 높고, 긴 본문 단락 근처의 짧은 문장은 본문의 일부일 가능성이 높습니다.
코드로 보기
import requests
import justext
response = requests.get("https://example.com/article")
paragraphs = justext.justext(
response.content,
justext.get_stoplist("Korean") # 한국어 불용어 목록 사용
)
for paragraph in paragraphs:
if not paragraph.is_boilerplate:
print(paragraph.text)
각 paragraph 객체에는 is_boilerplate 외에도 cf_class(문맥 독립 분류 결과), class_(최종 분류 결과), links_density(링크 밀도) 같은 속성이 있어서 분류 결과를 직접 들여다보거나 커스터마이징할 수 있습니다.
어떤 블록이 왜 보일러플레이트로 분류됐는지 추적하고 싶을 때 유용합니다.
두 번째 보일러플레이트 제거 도구 - Trafilatura
jusText는 알고리즘을 이해하기에 좋지만 실무에서는 더 강력한 도구가 필요하죠.
Trafilatura는 벤치마크에서 다른 오픈소스 라이브러리들을 일관되게 앞서는 성능을 보여주며, HuggingFace, IBM, Microsoft Research 같은 기업과 Stanford, Allen Institute 같은 기관에서 수천 개의 프로젝트에 통합되어 사용되고 있습니다.
Trafilatura가 하는 일
Trafilatura는 lxml을 기반으로 하며 fallback으로 readability와 jusText를 사용합니다. 추출에 집중하는 영역은 주로 중앙에 표시되는 부분, 즉 좌우 사이드바, 헤더, 푸터를 제외한 부분이지만 제목과 선택적으로 댓글을 포함합니다.
jusText가 '보일러플레이트 제거'라는 한 가지 일에 집중한다면 Trafilatura는 URL 다운로드 → 본문 추출 → 메타데이터 파싱 → 출력 포맷 변환까지 전 과정을 하나의 파이프라인으로 처리해주는 도구입니다.
크롤링 이후 필요한 대부분의 전처리를 한 라이브러리로 해결할 수 있다는 게 가장 큰 장점이죠.
추출 결과를 TXT, JSON, CSV, XML, Markdown 등 다양한 형식으로 출력할 수 있고, 제목, 저자, 발행일, 사이트명, 카테고리 같은 메타데이터도 함께 추출합니다. RAG 파이프라인에서 문서 출처나 발행일을 메타데이터로 저장해두면 나중에 필터링이나 출처 추적에 유용하게 쓸 수 있습니다.
기본 사용법
import trafilatura
# URL에서 직접 다운로드 + 추출
downloaded = trafilatura.fetch_url("https://example.com/article")
text = trafilatura.extract(downloaded)
print(text) # 본문 텍스트만 출력
단 두 줄로 URL에서 본문을 추출합니다. fetch_url이 HTML을 다운로드하고 extract가 본문만 골라냅니다.
내부적으로 lxml 파싱 → 본문 추출 시도 → 실패 시 readability, jusText 순으로 fallback하는 과정이 자동으로 처리됩니다.
다양한 출력 형식
# JSON으로 추출 (메타데이터 포함)
result = trafilatura.extract(
downloaded,
output_format="json",
with_metadata=True, # 제목, 저자, 날짜 등 포함
include_comments=False, # 댓글 제외
include_tables=True # 표 포함
)
import json
data = json.loads(result)
print(data["title"]) # 제목
print(data["author"]) # 저자
print(data["date"]) # 발행일
print(data["text"]) # 본문
with_metadata=True로 설정하면 본문 텍스트뿐 아니라 페이지의 메타데이터까지 함께 추출합니다. RAG에서 단순히 텍스트만 저장하는 것보다 출처 URL이나 발행일을 함께 저장해두면 나중에 '최근 1년 이내 문서만 검색' 같은 필터링이 가능해집니다.
RAG 파이프라인에서 활용하기
import trafilatura
from trafilatura.settings import use_config
# 설정 커스터마이징
config = use_config()
config.set("DEFAULT", "MIN_EXTRACTED_SIZE", "200") # 최소 200자 이상만 추출
urls = [
"https://example.com/article1",
"https://example.com/article2",
]
documents = []
for url in urls:
downloaded = trafilatura.fetch_url(url)
if downloaded:
text = trafilatura.extract(downloaded, config=config)
if text:
documents.append({
"url": url,
"text": text
})
MIN_EXTRACTED_SIZE는 실무에서 특히 유용한 설정인데 보일러플레이트만 남은 페이지나 로그인이 필요한 페이지를 크롤링하면 추출 결과가 매우 짧게 나오는 경우가 많습니다. 최소 글자 수를 설정해두면 이런 빈 껍데기 문서가 인덱스에 들어가는 것을 막을 수 있습니다. 200자는 시작점으로 좋지만 다루는 문서 유형에 따라 500~1000자로 높여가며 조정하는 것을 권장합니다.
세 번째 보일러플레이트 제거 도구 - newspaper3k
Trafilatura가 범용 웹 페이지에 강하다면 newspaper3k는 뉴스 기사 특화 라이브러리입니다. 자동 추출에 집중하며 기사 본문, 제목, 저자, 발행일, 이미지 URL 등을 추출합니다.
newspaper3k를 소개하는 이유는 단순히 뉴스 크롤링에 편리한 도구이기 때문만이 아닙니다. 소스코드를 살펴보면 실제 프로덕션에서 수년간 검증된 크롤 데이터 전처리 패턴이 잘 구현되어 있습니다. 다운로드와 파싱을 분리하는 구조, 짧은 기사를 걸러내는 방식, 사이트 전체 URL을 자동으로 수집하는 패턴 등은 직접 파이프라인을 구성할 때 참고하기 좋은 레퍼런스입니다.
기본 사용 패턴
from newspaper import Article
article = Article("https://example.com/news/article")
# 1. 다운로드
article.download()
# 2. 파싱 (본문, 메타데이터 추출)
article.parse()
print(article.title) # 제목
print(article.authors) # 저자 목록
print(article.publish_date) # 발행일
print(article.text) # 본문
# 3. NLP 처리 (선택)
article.nlp()
print(article.summary) # 자동 요약
print(article.keywords) # 핵심 키워드
download() → parse() → nlp() 순서로 단계를 나눠 호출하는 구조입니다. nlp()는 NLTK를 사용해 자동 요약과 핵심 키워드를 추출하는데, 연산 비용이 parse()만큼 큽니다. 요약이나 키워드가 필요하지 않다면 호출하지 않는 것이 좋습니다.
이미 다운로드한 HTML에서 파싱하기
실무에서는 다운로드와 파싱을 분리하는 패턴이 중요합니다. newspaper3k의 기본 download()는 단순한 requests 호출이라 봇 탐지에 걸리거나, 프록시가 필요한 경우, 재시도 로직이 필요한 경우에는 한계가 있습니다. 이럴 때는 HTML을 직접 다운로드해서 newspaper3k에 주입하는 방식을 사용합니다.
import requests
from newspaper import Article
url = "https://example.com/article"
# 직접 HTML을 다운로드 (헤더, 프록시 등 직접 제어 가능)
response = requests.get(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
# 다운로드한 HTML을 newspaper에 주입
article = Article(url)
article.download(input_html=response.text)
article.parse()
input_html로 HTML을 직접 넘기면 newspaper3k는 다운로드를 건너뛰고 파싱만 수행합니다. 덕분에 다운로드 단계에서 Scrapy, httpx, 또는 Selenium 같은 다른 도구를 자유롭게 조합할 수 있습니다. 대규모 크롤링 파이프라인에서는 다운로드 레이어와 파싱 레이어를 이렇게 분리해두는 것이 유지보수에도 훨씬 유리합니다.
사이트 전체 기사 수집 패턴
from newspaper import build
# 사이트 전체 구조를 분석해 기사 URL 자동 수집
site = build("https://example.com", memoize_articles=False)
for article in site.articles[:10]: # 처음 10개만
try:
article.download()
article.parse()
if len(article.text) > 500: # 너무 짧은 기사 제외
print(article.title)
print(article.text[:200])
except Exception as e:
print(f"실패: {e}")
continue
build()는 사이트의 RSS, 사이트맵, 내부 링크를 분석해 기사 URL 목록을 자동으로 수집합니다. 일일이 URL을 수동으로 모을 필요 없이 사이트 구조를 자동으로 파악해주는 것이 장점입니다.
memoize_articles=False는 이전에 수집한 기사를 캐시하지 않도록 설정합니다. 기본값은 True인데 반복 실행 시 이미 처리한 기사를 건너뛰어 중복을 방지합니다. 매번 전체를 새로 수집하고 싶다면 False로 설정합니다.
len(article.text) > 500 조건도 중요한 패턴입니다. 기사 URL로 보이지만 실제로는 카테고리 페이지이거나, 로그인이 필요하거나, 광고 랜딩 페이지인 경우 본문이 매우 짧게 추출됩니다. 최소 글자 수 조건으로 이런 빈 껍데기 문서를 걸러내는 것이 실무에서 기본적으로 적용하는 패턴입니다.
세가지 도구 비교
| 도구 | 강점 | 주요 용도 |
| jusText | 알고리즘이 투명하고 설명 가능 | 보일러플레이트 원리 이해, 커스터마이징 |
| Trafilatura | 범용, 고성능, 다양한 출력 형식 | 일반 웹 페이지 본문 추출 |
| newspaper3k | 뉴스/기사 메타데이터 추출에 특화 | 뉴스 데이터 수집 파이프라인 |
실무에서는 세 가지를 조합하는 경우가 많습니다. 예를 들어 Trafilatura가 실패하면 jusText로 fallback하는 식입니다.
실제로 Trafilatura 내부에서도 이 패턴을 사용합니다.
세가지 도구를 조합한 실전 파이프라인 예제
지금까지 소개한 도구들을 실제로 어떻게 조합해서 쓸지와 크롤 데이터를 RAG 인덱싱에 쓸 수 있는 형태로 정제하는 전형적인 패턴을 코드로 정리했습니다.
import trafilatura
import re
def clean_text(text: str) -> str:
"""기본적인 텍스트 정제"""
# 연속된 공백/줄바꿈 정리
text = re.sub(r'\n{3,}', '\n\n', text)
text = re.sub(r' {2,}', ' ', text)
# 앞뒤 공백 제거
text = text.strip()
return text
def extract_and_clean(url: str) -> dict | None:
"""URL에서 본문 추출 + 정제"""
downloaded = trafilatura.fetch_url(url)
if not downloaded:
return None
result = trafilatura.extract(
downloaded,
output_format="json",
with_metadata=True,
include_comments=False,
)
if not result:
return None
import json
data = json.loads(result)
text = data.get("text", "")
# 너무 짧은 문서 제외 (보일러플레이트만 추출된 경우)
if len(text) < 300:
return None
return {
"url": url,
"title": data.get("title", ""),
"date": data.get("date", ""),
"text": clean_text(text),
}
clean_text 함수는 Trafilatura가 추출한 텍스트에 남아있는 잔여 노이즈를 정리합니다. 추출된 본문에는 종종 연속된 줄바꿈이나 불필요한 공백이 포함되어 있는데 이를 그대로 청킹하면 청크 경계가 이상하게 잡히거나 토큰을 낭비하게 됩니다.
정규식으로 3줄 이상 연속된 줄바꿈을 2줄로, 2칸 이상 연속된 공백을 1칸으로 줄이는 것만으로도 청크 품질이 눈에 띄게 좋아집니다.
extract_and_clean 함수에서 300자 미만 문서를 걸러내는 부분도 중요합니다. Trafilatura가 본문 추출에 실패하거나 실제로 내용이 거의 없는 페이지를 크롤링했을 때 빈 껍데기 문서가 인덱스에 들어가는 것을 방지합니다. 300자는 최솟값으로 다루는 문서 유형에 따라 조정하세요.
반환값에 url, title, date를 함께 담는 것도 실무에서 중요한 패턴입니다. 텍스트만 저장하면 나중에 '이 답변이 어느 문서에서 나왔는지' 추적하거나 '최신 문서만 검색'하는 필터링이 불가능해집니다. 처음부터 메타데이터를 함께 저장해두는 습관을 들이는 것이 좋습니다.
마치며
RAG 성능은 모델 선택이나 청킹 전략만큼이나 데이터 품질에도 크게 좌우됩니다. 보일러플레이트가 섞인 데이터로 아무리 좋은 청킹을 해도 검색 결과는 오염됩니다. 'Garbage in, garbage out'은 RAG에서도 마찬가지입니다.
좋은 RAG는 좋은 데이터에서 시작됩니다.
'AI' 카테고리의 다른 글
| [바미] AI가 거짓말을 하는지 어떻게 알 수 있을까? - LLM이 그 답변 봐봐! 혹시 사쿠라야? (2) | 2026.03.30 |
|---|---|
| [바미] 구글도 키워드 검색을 버리지 않은 이유 (하이브리드 검색과 RAG-Fusion) (0) | 2026.03.29 |
| [바미] 어떻게 자르느냐가 검색 품질을 결정한다. (0) | 2026.03.27 |
| [바미] AI도 구글 검색을 한다면 어떨까요? (0) | 2026.03.26 |
| [바미] 지식 증류(Knowledge Distillation)로 성능은 챙기고, 모델은 줄이기 (0) | 2026.02.22 |