RAG는 검색 품질이 낮으면 아무리 좋은 LLM을 붙여도 답변 품질이 무너집니다. 이번 시리즈는 이 문제를 감이 아니라 숫자로 검증하기 위한 테스트베드를 네이버 뉴스 데이터로 구축한 기록입니다.
배경
RAG를 운영하다 보면 "검색이 이상한 문서를 가져온다", "비슷한 내용의 기사가 중복으로 잡힌다" 같은 품질 이슈를 자주 마주칩니다. 이 프로젝트의 목표는 실제 한국어 뉴스 텍스트로 RAG 파이프라인 전체(전처리→클러스터링→임베딩→검색→리랭킹)를 구성하고, 각 단계의 변경이 최종 검색·생성 품질에 어떤 영향을 주는지 수치로 비교하는 실험 환경을 만드는 것입니다.
핵심 원칙은 세 가지입니다. 한 번에 하나의 변수만 바꾸고, 체감이 아니라 지표로만 판단하며, 평가 코드(evaluations/)는 프로덕션 앱 코드(app/)와 완전히 분리합니다. 이번 1편에서는 파이프라인의 첫 단계인 클러스터링만 다룹니다.
문제: 왜 클러스터링부터인가
뉴스 API로 기사를 수집하면 같은 이슈를 다룬 기사가 수십 건씩 중복 수집됩니다. 이 상태로 바로 임베딩해서 벡터 DB에 넣으면 검색 결과가 비슷한 기사로만 채워지는 문제가 생깁니다. 그래서 임베딩 이전에 두 가지를 먼저 검증해야 했습니다.
수집된 원문에 HTML 태그나 특수문자가 얼마나 남아있는가 (전처리 품질)
비슷한 주제의 기사를 얼마나 정확하게 묶어낼 수 있는가 (클러스터링 품질)
전처리와 클러스터링 단계에서 노이즈가 남으면 이후 임베딩·리랭커 성능 비교 결과 자체를 신뢰하기 어려워집니다. 그래서 프로젝트 구조를 app/services/text_preprocessor.py와 app/services/cluster_service.py로 분리해 이 단계를 독립적으로 검증할 수 있게 설계했습니다.
해결: 전처리 + BERTopic 클러스터링 구현
1단계. 프로젝트 구조 잡기
프로덕션 코드(app/)와 평가 코드(evaluations/)를 분리하고, Docker 파일은 docker/ 폴더에만 두는 규칙을 정했습니다.
naver-news-cluster/
├── app/
│ ├── services/
│ │ ├── news_fetcher.py # 네이버 API 호출
│ │ ├── text_preprocessor.py # HTML 제거, 특수문자 정리
│ │ └── cluster_service.py # BERTopic (UMAP + HDBSCAN)
│ ├── infra/
│ │ └── embedding.py # SentenceTransformer 래퍼
│ └── core/
│ └── settings.py # 환경변수 중앙 관리
└── evaluations/
└── metrics/
└── clustering_metrics.py # noise_ratio 등 계산2단계. 설정값은 .env로 분리
모델 경로나 클러스터링 파라미터를 코드에 하드코딩하면 나중에 실험 비교가 불가능해집니다. pydantic-settings로 타입 안전하게 관리했습니다.
# app/core/settings.py
"""
환경 설정 모듈
- pydantic-settings 기반 타입 안전 환경 변수 파싱
- .env만 바꾸면 코드 수정 없이 모델/파라미터 교체 가능
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# ── 네이버 검색 API ──────────────────────────────
naver_client_id: str
naver_client_secret: str
# ── 임베딩 모델 경로 (2편/3편에서 사용) ───────────
embedding_model_path: str = "BAAI/bge-m3"
# ── BERTopic 파라미터 ─────────────────────────────
# min_topic_size: 몇 건 이상 모여야 하나의 토픽으로 인정할지
bertopic_min_topic_size: int = 3
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
# 앱 전체에서 이 인스턴스 하나만 import해서 재사용 (싱글턴)
settings = Settings()3단계. 전처리 — 텍스트부터 정리
네이버 뉴스 API 응답에는 <b> 태그 같은 HTML이 섞여 있습니다. 이걸 그대로 넣으면 품질이 안 좋아 집니다.
# app/services/text_preprocessor.py
"""
텍스트 전처리 모듈
- 목적: 임베딩/클러스터링에 들어가기 전 노이즈 제거
- 순수 함수로 작성 (외부 IO 없음) -> 단위 테스트 쉬움
"""
import re
_HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
_SPECIAL_CHAR_PATTERN = re.compile(r"[^\w\s가-힣.,!?]")
def clean_text(raw_text: str) -> str:
"""네이버 API 원문에서 HTML 태그, 특수문자를 제거한다.
Args:
raw_text: 네이버 뉴스 API가 반환한 title/description 원문
Returns:
정제된 텍스트. 빈 문자열이 나올 수도 있으니 호출부에서 검증 필요.
"""
text_without_html: str = _HTML_TAG_PATTERN.sub("", raw_text)
text_without_special: str = _SPECIAL_CHAR_PATTERN.sub("", text_without_html)
return text_without_special.strip()4단계. BERTopic으로 클러스터링
UMAP으로 차원을 줄이고, HDBSCAN으로 밀도 기반 군집을 잡는 조합입니다. min_topic_size를 .env에서 주입받아 실험마다 값을 바꿀 수 있게 했습니다.
# app/services/cluster_service.py
"""
뉴스 기사 클러스터링 서비스
- BERTopic(UMAP + HDBSCAN)으로 유사 기사를 묶는다
- 목적: 중복/유사 기사를 그룹화해 검색 후보군의 다양성을 확보
"""
from bertopic import BERTopic
from hdbscan import HDBSCAN
from sentence_transformers import SentenceTransformer
from umap import UMAP
from app.core.settings import settings
class NewsClusterService:
"""기사 리스트를 입력받아 토픽별로 묶어주는 서비스."""
def __init__(self, embedding_model: SentenceTransformer) -> None:
# UMAP: 고차원 임베딩을 저차원으로 압축해 군집화 성능을 높인다
umap_model = UMAP(
n_neighbors=15,
n_components=5,
min_dist=0.0,
metric="cosine",
random_state=42, # 재현 가능한 실험을 위해 시드 고정
)
# HDBSCAN: 군집 개수를 미리 정하지 않아도 되는 밀도 기반 클러스터링
hdbscan_model = HDBSCAN(
min_cluster_size=settings.bertopic_min_topic_size,
metric="euclidean",
cluster_selection_method="eom",
prediction_data=True,
)
self._topic_model = BERTopic(
embedding_model=embedding_model,
umap_model=umap_model,
hdbscan_model=hdbscan_model,
language="multilingual", # 한국어 지원을 위해 다국어 모드
calculate_probabilities=False,
)
def cluster_articles(self, clean_texts: list[str]) -> dict[str, object]:
"""정제된 기사 텍스트 리스트를 클러스터링한다.
Args:
clean_texts: text_preprocessor를 거친 기사 본문 리스트
Returns:
topics: 각 기사가 속한 토픽 번호 (-1은 노이즈)
topic_info: 토픽별 대표 키워드, 기사 수 등 메타데이터
"""
topics, _ = self._topic_model.fit_transform(clean_texts)
topic_info = self._topic_model.get_topic_info()
# -1(노이즈) 비율이 너무 높으면 파라미터를 재검토해야 한다는 신호
noise_count: int = topics.count(-1)
noise_ratio: float = noise_count / len(topics) if topics else 0.0
return {
"topics": topics,
"topic_info": topic_info.to_dict(orient="records"),
"noise_ratio": round(noise_ratio, 4),
"total_topics": int(topic_info.shape[0]) - 1, # -1 행 제외
}5단계. 실행
# 1. 의존성 설치 (uv 기반)
uv add bertopic hdbscan umap-learn sentence-transformers pydantic-settings
# 2. .env 작성
echo 'NAVER_CLIENT_ID=발급받은_id' >> .env
echo 'NAVER_CLIENT_SECRET=발급받은_secret' >> .env
echo 'BERTOPIC_MIN_TOPIC_SIZE=3' >> .env
# 3. 뉴스 수집 -> 전처리 -> 클러스터링 순서로 실행
python -m app.services.news_fetcher결과
noise_ratio(군집화되지 않은 기사 비율)를 0.30 미만으로 유지하는 것을 1차 목표로 잡았고, 이 값은 이후 검색 품질 KPI와도 연결됩니다. 클러스터링 결과가 안정되면 벡터 DB 저장 시 topic_id를 payload로 함께 저장해, 같은 토픽 내에서만 검색을 필터링하는 실험의 기반이 마련됩니다.
회고
클러스터링과 전처리는 RAG의 부가 기능이 아니라 뒤 단계 실험 결과를 신뢰할 수 있게 만드는 전제 조건입니다. HTML 잔류율, 빈 텍스트 비율 같은 기초 지표를 먼저 확인하지 않으면, 이후 임베딩이나 리랭커를 바꿔도 성능 변화의 원인을 정확히 짚어낼 수 없습니다. 다음 편에서는 이 클러스터링/전처리 결과를 KPI로 측정하고 Golden Dataset 기반 평가 파이프라인을 구성한 과정을 다루겠습니다.