← 블로그 목록
UMAPHDBSCAN클러스터링임베딩NLPPython뉴스AI

UMAP + HDBSCAN으로 뉴스 클러스터링 구현하기 — 임베딩부터 LLM 검증까지

2026년 4월 16일

뉴스를 하루 수백 건 수집하면 같은 사건을 다룬 기사들이 타임라인을 채웁니다. 이를 이벤트 단위로 압축하기 위해 UMAP + HDBSCAN 기반 클러스터링 파이프라인을 구축했습니다. 이 글에서는 임베딩 생성부터 UMAP 차원 축소, HDBSCAN 클러스터링, LLM 검증까지 실제 파라미터 수치와 함께 정리합니다.

문제: 하루 수백 건 뉴스를 10개 이벤트로 줄이기

투자킹의 뉴스 타임라인에는 관련도 65점 이상 기사가 하루 150~200건 유입됩니다. 비트코인 가격이 움직이면 10개 매체가 각자 기사를 씁니다. "비트코인 7만5천 돌파", "BTC 신고가 경신", "기관 매수에 비트코인 급등" — 전부 같은 사건인데 타임라인에 10줄을 차지합니다.

초기 구현은 LLM에 기사 목록을 통째로 넘겨 "같은 사건끼리 묶어달라"고 요청하는 방식이었습니다. 기사가 20~30건일 때는 동작했지만, 100건이 넘으면서 이벤트가 72개까지 폭증했습니다. 원인은 단순했습니다. LLM은 선택지가 40개를 넘으면 판단 정확도가 급격히 떨어집니다. 판단 실패 → 미분류 → 즉시 신규 이벤트 생성 → 이벤트 증가 → 다음 기사도 판단 실패라는 악순환이었습니다.

근본적인 구조 변경이 필요했습니다.


전체 파이프라인 개요

새 아키텍처는 두 단계로 나뉩니다.

초회 클러스터링 (이벤트가 0개일 때):

  1. 오늘 전체 기사 조회 (관련도 65점 이상)
  2. Infinity 서버에서 임베딩 벡터 조회
  3. UMAP으로 512차원 → 20차원 축소
  4. HDBSCAN으로 클러스터 생성 (noise = floating)
  5. LLM 검증 → 이벤트 제목/태그/감성 생성
  6. DB에 이벤트 저장

증분 클러스터링 (이벤트가 이미 있을 때):

  1. 새 기사의 임베딩 vs 기존 이벤트 centroid 코사인 유사도 계산
  2. 상위 3개 후보 → LLM이 합류 결정
  3. 합류 실패 기사 + 기존 floating → UMAP+HDBSCAN 재클러스터링
  4. 최소 3건 이상 묶이면 신규 이벤트 생성

각 단계에서 LLM의 역할을 최소화하고, 넓은 탐색은 임베딩에 맡기는 것이 핵심입니다.


임베딩 생성: Infinity 서버 + BAAI/bge-m3

임베딩 모델은 BAAI/bge-m3를 사용합니다. Infinity 서버(http://localhost:7997)를 통해 배치 처리합니다. 원래 4096차원이지만 Matryoshka 방식으로 512차원으로 잘라서 저장합니다. 정확도 손실이 거의 없으면서 저장 공간과 연산 비용을 절감할 수 있습니다.

기사 제목과 요약을 붙여 하나의 텍스트로 만든 뒤 임베딩합니다.

async def embed_articles_live(articles: list[dict]) -> dict[str, list[float]]:
    """DB 임베딩이 없을 때 Infinity로 즉석 생성."""
    ai_client = get_ai_client()
    texts, uuids = [], []
    for a in articles:
        title   = (a.get("title")   or "").strip()
        summary = (a.get("summary") or "").strip()
        text = f"{title} {summary}" if summary else title
        if not text:
            continue
        texts.append(text)
        uuids.append(a["uuid"])

    BATCH = 20
    for i in range(0, len(texts), BATCH):
        embeddings = await ai_client.embed(texts[i:i + BATCH])
        for uid, emb in zip(uuids[i:i + BATCH], embeddings):
            result[uid] = emb
    return result

실제 운영에서는 기사 수집 시점에 임베딩을 미리 생성해 news_article_embeddings 테이블에 저장합니다. 클러스터링 시점에는 DB에서 조회하고, 누락된 경우에만 Infinity를 호출합니다.


UMAP 차원 축소: 실제 파라미터

512차원 벡터를 직접 클러스터링하면 차원의 저주 문제가 생깁니다. 고차원에서는 모든 점 간 거리가 비슷해지는 현상이 발생해 클러스터가 잘 분리되지 않습니다.

UMAP(Uniform Manifold Approximation and Projection)으로 20차원으로 축소합니다. 단순히 차원을 잘라내는 PCA와 달리, 데이터 포인트 간의 이웃 관계를 보존하면서 압축합니다.

import umap

def cluster_umap_hdbscan(
    articles: list[Article],
    min_cluster_size: int = 3,
    n_components: int = 20,
    min_samples: int = 1,
) -> tuple[list[list[Article]], list[Article]]:
    arts = [a for a in articles if a.embedding]
    embs = np.array([a.embedding for a in arts], dtype=np.float32)

    n_neighbors = min(15, len(arts) - 1)
    reducer = umap.UMAP(
        n_components=min(n_components, len(arts) - 2),
        n_neighbors=n_neighbors,
        min_dist=0.0,
        metric="cosine",
        random_state=42,
    )
    reduced = reducer.fit_transform(embs)
    ...

파라미터별 역할:

파라미터역할
n_components20목표 차원 수
n_neighborsmin(15, n-1)지역 이웃 구조 보존 범위
min_dist0.0클러스터 내 점들을 최대한 밀착
metriccosine텍스트 임베딩에 적합한 거리 측정
random_state42재현성 확보

min_dist=0.0이 중요합니다. 이 값을 높이면 시각화에 유리하지만, 클러스터링 목적에서는 같은 그룹의 점들이 최대한 가까이 모여야 합니다.

n_neighborsn_components는 기사 수에 따라 동적으로 조정됩니다. 기사 수가 적으면 n_neighbors=14, n_components=18처럼 자동으로 낮아집니다.


HDBSCAN 클러스터링: noise point = floating

UMAP으로 축소한 20차원 벡터에 HDBSCAN(Hierarchical Density-Based Spatial Clustering)을 적용합니다.

import hdbscan

# HDBSCAN: noise(-1)는 floating
clusterer = hdbscan.HDBSCAN(
    min_cluster_size=min_cluster_size,  # 3
    min_samples=min_samples,            # 1
    metric="euclidean",
    cluster_selection_method="eom",     # Excess of Mass
)
labels = clusterer.fit_predict(reduced)

cluster_dict: dict[int, list[Article]] = defaultdict(list)
noise: list[Article] = []
for art, label in zip(arts, labels):
    if label == -1:
        noise.append(art)       # floating으로 분류
    else:
        cluster_dict[label].append(art)

HDBSCAN을 선택한 결정적인 이유는 noise point 개념입니다. K-Means 같은 알고리즘은 모든 데이터를 강제로 어딘가에 배정합니다. 뉴스에는 어떤 이벤트에도 속하지 않는 기사가 반드시 존재합니다. HDBSCAN의 noise(label == -1)가 이 floating 기사와 자연스럽게 대응됩니다.

cluster_selection_method="eom"은 Excess of Mass 방식으로 클러스터 경계를 결정합니다. 밀도가 높은 영역을 더 견고하게 유지합니다.

파라미터 실험 결과:

min_cluster_size클러스터 수결과
226개2건짜리 소규모 클러스터 대량 생성, 쪼개짐 악화
313개Ground Truth 14개와 근접
58개중소 이벤트 누락

min_cluster_size=3이 적정선입니다. 2로 낮추면 의미 없는 소규모 클러스터가 대량 생산됩니다.


LLM 검증: build_cluster_validation_messages

UMAP+HDBSCAN이 만든 클러스터는 수학적으로는 유사한 기사들의 묶음이지만, 실제로 "하나의 이벤트"로 노출할 만한지는 별도로 검증합니다.

build_cluster_validation_messages 프롬프트는 클러스터 내 기사 제목과 관련도 점수를 LLM에 전달하고, 이 묶음이 독립적인 이벤트로 노출할 만큼 유의미한지 판단을 요청합니다.

def build_cluster_validation_messages(articles: list[dict]) -> list[AIMessage]:
    """Pool 클러스터링: 기사 그룹이 주요 이슈인지 LLM 검증."""
    articles_text = "\n".join(
        f"- [{a.get('relevance_score', 0)}] {a.get('title', '')}" for a in articles
    )
    prompt = (
        "아래 기사들이 하나의 주요 이슈/사건으로 묶일 수 있는지 판단하세요.\n"
        "반드시 아래 JSON 형식으로만 응답하세요.\n\n"
        '{"is_significant": true, "reason": "이유"}\n\n'
        "판단 기준:\n"
        "- is_significant=true: 기사들이 같은 사건/이슈를 다루고 있으며 독자적인 이벤트로 노출할 만큼 중요\n"
        "- is_significant=false: 주제가 제각각이거나 중요도가 낮음\n\n"
        f"기사 목록:\n{articles_text}"
    )
    return [
        AIMessage(role="system", content="당신은 암호화폐/금융 뉴스 분석 전문가입니다."),
        AIMessage(role="user", content=prompt),
    ]

LLM이 is_significant=false로 판단하면 클러스터 전체가 floating으로 내려갑니다. 검증을 통과한 클러스터에 대해서만 제목/태그/감성 생성 단계로 진행합니다.

이 단계에서 LLM의 부담은 매우 낮습니다. 기사 목록 하나를 보고 yes/no만 판단하기 때문입니다.


증분 클러스터링: 새 기사를 기존 이벤트에 합류시키기

매 시간 새 기사가 유입됩니다. 이미 이벤트가 존재하는 상황에서 새 기사를 처리하는 방법이 증분 클러스터링입니다.

centroid 기반 후보 선별

각 이벤트의 centroid(소속 기사 임베딩의 평균 벡터)와 새 기사 임베딩의 코사인 유사도를 계산합니다.

@dataclass
class Cluster:
    articles: list[Article]
    centroid: np.ndarray

    def update_centroid(self) -> None:
        if self.articles and all(a.embedding for a in self.articles):
            embs = np.array([a.embedding for a in self.articles], dtype=np.float32)
            self.centroid = embs.mean(axis=0)

    def cosine_sim(self, emb: list[float]) -> float:
        v = np.array(emb, dtype=np.float32)
        norm_c = np.linalg.norm(self.centroid)
        norm_v = np.linalg.norm(v)
        if norm_c == 0 or norm_v == 0:
            return 0.0
        return float(np.dot(self.centroid, v) / (norm_c * norm_v))

유사도 0.55 이상인 클러스터 중 상위 3개를 후보로 선발하고, 이 후보들을 LLM에 전달합니다.

TOP_K_JOIN = 3
scored = sorted(
    [(i, c, c.cosine_sim(art.embedding)) for i, c in enumerate(clusters)],
    key=lambda x: x[2], reverse=True,
)
candidates = [(i, c, s) for i, c, s in scored if s >= EMBED_JOIN_TH][:TOP_K_JOIN]
if candidates:
    chosen_idx = await llm_validate_join_topk(ai_client, candidates, art)

Top-3이 필요한 이유

실험 과정에서 Top-1만 LLM에 보내면 합류가 0건인 문제가 발생했습니다. 원인을 분석하니, 임베딩 유사도 1위 클러스터가 실제로 합류해야 할 클러스터가 아닌 경우가 있었습니다.

예를 들어 "이란 유류 제재" 기사의 임베딩 best match가 "영국 금리" 클러스터(유사도 0.66)였고, 실제로 합류해야 할 "중동 지정학" 클러스터는 2위(유사도 0.63)였습니다.

Top-1만 전달하면 LLM이 "영국 금리"를 보고 거부한 뒤 끝납니다. Top-3을 보내면 2위인 "중동 지정학"을 선택할 수 있습니다. 이 변경으로 합류 0건 → 다수 발생으로 바뀌었습니다.

embedding vs reranker 비교

centroid 코사인 유사도 대신 Reranker(교차 어텐션 모델)를 후보 선별에 쓰면 어떨지 실험했습니다. Ground Truth 14개 클러스터에 대해 각 기사가 자기 정답 클러스터를 찾아내는 비율(Precision@1)을 측정했습니다.

지표Embedding CentroidReranker
Precision@197.4%77.9%
Precision@399.1%94.0%
MRR0.980.87

임베딩 centroid가 압도적으로 높았습니다. Reranker는 텍스트 표면의 키워드 매칭에 강하지만, 여러 기사의 의미가 평균된 centroid 벡터와 비교하는 작업에는 벡터 연산이 더 적합합니다.


실험 결과 및 파라미터 튜닝

Ground Truth 구성

하루치 기사 164건(관련도 65점 이상)을 직접 분류해 **14개 이벤트, 커버리지 72%**의 Ground Truth를 만들었습니다.

이벤트기사 수
미·이란 협상 및 전쟁 종식 기대14
글로벌 증시 사상 최고치 달성15
비트코인 7만5천달러 돌파13
이란 전쟁으로 에너지 공급 불안11
... (총 14개)

실험 진행 순서

Exp 1 — UMAP+HDBSCAN, Top-1 합류: 클러스터 28개, 커버리지 74%, 합류 0건. 이전 Agglomerative 방식(10개, 22%)보다 훨씬 좋았지만 합류가 전혀 없었습니다.

Exp 2 — Top-K=3 도입: 합류가 발생하기 시작했습니다. 클러스터 19개, 커버리지 80%.

Exp 3 — min_cluster_size=2: 2건짜리 소규모 클러스터가 대량 생산되면서 26개로 쪼개짐 악화. 실패.

Exp 4 — embed_join_th 완화 (0.55 → 0.50): 합류가 더 활발해졌습니다.

지표결과Ground Truth
클러스터 수13개14개
커버리지73%약 72%
floating50건약 46건

Ground Truth에 근접하는 결과입니다.

최종 권장 파라미터

벤치마크 스크립트(bench_clustering.py)의 기본값으로 채택된 파라미터입니다.

@dataclass
class ClusteringConfig:
    pool_threshold: int   = 65    # 기사 pool 최소 관련도 점수
    min_cluster_size: int = 3     # 클러스터 최소 기사 수
    embed_cluster_th: float = 0.75  # 초회 agglomerative 임계값
    embed_join_th: float   = 0.55   # 합류 embedding 임계값
    top_k_join: int        = 3      # LLM에 제시할 후보 클러스터 수
    dedup_th: float        = 0.88   # 중복 기사 제거 임계값
파라미터역할
UMAP n_components20차원 축소 목표
UMAP min_dist0.0클러스터 내 밀집도 최대화
HDBSCAN min_cluster_size3최소 기사 수
HDBSCAN min_samples1코어 포인트 조건 완화
embed_join_th0.55합류 후보 최소 유사도
top_k_join3LLM에 전달할 후보 수
dedup_th0.88중복 기사 제거 임계값

embed_join_th를 너무 높이면 합류가 줄어들고, 너무 낮추면 잘못된 클러스터에 합류 시도가 늘어납니다. LLM이 최종 거부할 수 있으므로 약간 낮은 쪽이 안전합니다.


72개까지 폭증했던 이벤트가 13개로 줄었고, Ground Truth 14개와 거의 일치합니다.

각 단계에 맞는 도구를 분리한 것이 핵심이었습니다.

  • 유사도 계산: 임베딩 centroid (Precision@1 97.4%, Reranker보다 정확)
  • 그룹핑: UMAP+HDBSCAN (noise point가 floating과 자연스럽게 대응)
  • 최종 판단: LLM (후보를 3개로 좁힌 뒤 개입)

다음 단계로는 시각대별 동적 이벤트 상한(_calc_max_events)을 적용해 아침에 이벤트가 과도하게 생성되는 문제를 다루고 있습니다. 오전 9시 이전 5개, 오전 9-14시 8개, 오후 15-18시 12개, 저녁 이후 15개로 시간대별 상한을 두는 방식입니다.

0

댓글 0

Ctrl+Enter