뉴스 소스를 늘리다 보면 반드시 품질 문제가 생깁니다.
코인 투자 관련 정보를 수집하다 보면 기술적 분석 기사와 관련 없는 일반 기업 실적 기사가 섞입니다. 단순 키워드 필터는 두 가지 문제를 동시에 만납니다 — 있어야 할 기사를 놓치고, 없어도 될 기사를 통과시킵니다.
이 글에서는 기사 제목과 본문 일부를 LLM에 넘겨 1~100점을 매기는 방식으로 전환한 과정을 설명합니다. 구현 예시는 코인 투자 정보 서비스 twojaking.com의 실제 프롬프트 코드를 기반으로 합니다.
키워드 필터의 한계
코인 심볼 포함 기사만 통과시키는 방식은 빠르게 한계가 왔습니다.
- 부정문 형태의 관련 없는 기사도 통과
- CPI 발표, 금리 결정처럼 실제로 중요한 거시경제 기사는 키워드가 없어서 탈락
- 심볼이 들어간 기술적 분석 기사는 전량 통과
키워드는 단어의 존재만 확인하고 기사의 의미는 판단하지 못합니다.
점수 시스템 설계
점수는 두 축의 곱으로 계산합니다.
최종 점수 = 경제관련성 점수 × 실시간성 점수 / 100
경제관련성 점수
지금 경제 상황을 파악하는 데 이 기사가 얼마나 유용한가.
| 점수 | 해당 기사 |
|---|---|
| 90~100 | 암호화폐 가격 급변·대규모 청산·ETF 자금흐름, 중앙은행 금리 결정, GDP·CPI·PPI·PCE 발표, 유가 10% 이상 급변 |
| 70~89 | 암호화폐 규제 조치(SEC·CFTC·DOJ), 주가지수 1% 이상 등락, 에너지 공급 차질, 주요국 휴전·전쟁 합의 |
| 50~69 | 거시경제 지표(고용·무역수지), 환율 급변, 에너지 소폭 변동, 금·원자재 가격 |
| 20~49 | 암호화폐 해킹·보안, 온체인 지표, 금융 기업 실적 |
| 1~19 | 비금융 기업 실적·인사·M&A(자동차·방산·소매·IT·의약품), 사회·문화·스포츠 |
실시간성 점수
이 기사가 얼마나 지금 일어나는 일인가.
| 점수 | 해당 기사 |
|---|---|
| 90~100 | 지금 막 발생·확인된 사건, 오늘 발표된 수치 |
| 50-89 | 진행 중인 협상·정책 움직임, 오늘-이번 주 데이터 |
| 20~49 | 분석·해설 기사 (사건은 실시간이지만 해석 위주) |
| 1~19 | 예측·전망, 오피니언, 기술적 분석(차트·패턴·지지선·저항선) |
상한선 제약
두 축만 쓰면 특정 유형 기사가 과도한 점수를 받습니다. 그래서 유형별 상한선을 별도로 지정합니다.
- 암호화폐 기술적 분석(저항선·강세장 진입·폭등 전망): 최대 45점
- 예측·전망·시나리오만 담긴 기사: 최대 45점
- 오피니언·칼럼·특정인 의견만: 최대 35점
- 비금융 기업 실적·인사·M&A: 최대 20점
- 암호화폐 보안(스캠 동결·해킹 수사): 최대 30점
- 문화·스포츠·사회·환경: 최대 10점
- 부동산·임대·모기지: 최대 25점
- 군사·외교 (에너지·금리 수치 충격 없는 경우): 최대 45점
실제 프롬프트 구조
app/prompts/news.py의 build_classification_messages가 이 루브릭을 그대로 담습니다.
def build_classification_messages(items: list[dict]) -> tuple[list[AIMessage], dict[str, str]]:
# 기사 관련도 점수 산정 (1~100). 출처 + 제목 + 본문 일부를 보고 판단.
# Returns: (messages, idx_to_real) — idx_to_real은 {1: real_id, ...} 매핑
idx_to_real: dict[str, str] = {}
articles_text = ""
for i, item in enumerate(items, 1):
real_id = item.get("link_hash", item.get("id", ""))
idx_to_real[str(i)] = real_id
title = item.get("title", item.get("raw_title", ""))
content = (item.get("summary") or item.get("raw_content") or "")[:150]
source = item.get("source_label", "")
prefix = f"[{source}] " if source else ""
if content:
articles_text += f"[{i}] {prefix}제목: {title} / 내용: {content}\n"
else:
articles_text += f"[{i}] {prefix}제목: {title}\n"
prompt = (
# 점수 목적: 지금 이 순간 경제 상황을 파악하는 데 얼마나 유용한가 (1~100)
# 최종점수 = 경제관련성 점수 x 실시간성 점수 / 100
# ... (루브릭 전체 포함)
"기사 목록:\n" + articles_text
)
return [
AIMessage(role="system", content="You are a financial news relevance scorer. Respond only in JSON."),
AIMessage(role="user", content=prompt),
], idx_to_real
인덱스 치환 패턴을 사용한 이유가 있습니다. LLM에 UUID를 직접 전달하면 ID를 변형하거나 포맷이 오염되는 hallucination이 발생합니다. 순번(1, 2, 3, …)만 전달하고 응답에서도 순번으로 받은 뒤, 서비스 레이어에서 idx_to_real로 역치환합니다.
AI 응답의 일관성을 위해 temperature=0.1로 낮게 설정했습니다. 같은 기사를 두 번 넣어도 비슷한 점수가 나오는 것이 중요합니다.
Threshold 65를 정한 과정
점수 시스템을 만들었으면 어디서 자를지도 결정해야 합니다.
1단계: ground truth 데이터 구축
실제 수집된 기사 중에서 직접 보여줘야 할 기사와 안 보여줘도 될 기사를 수작업으로 분류했습니다. 12개의 통과 기사와 13개의 차단 기사로 ground truth를 구성했습니다.
2단계: 점수 분포 확인
test_news_classifier.py로 샘플을 돌려 통과해야 할 기사와 차단해야 할 기사의 점수 분포를 확인했습니다.
3단계: DB 전체 기사 대상 평가
test_news_classifier_db.py로 DB에 쌓인 실제 기사 수백 개를 대상으로 실행했습니다. 결과적으로 65 미만 기사의 약 80%가 실제로 불필요한 기사였고, 65 이상 기사는 대부분 의미 있었습니다.
65보다 낮추면 노이즈가 증가하고, 높이면 중요한 기사가 탈락합니다. 이 트레이드오프를 확인한 후 65를 선택했습니다.
65 미만 기사도 DB에는 저장됩니다. visible=false로 저장하기 때문에 나중에 threshold를 변경하면 즉시 재적용할 수 있습니다.
소스 관리
뉴스 소스는 코드에 하드코딩하지 않고 Supabase의 news_sources 테이블에서 관리합니다.
rss_url: RSS 피드 URLparser_type:rss또는htmlparser_config: HTML 스크래핑용 CSS 선택자is_active: 활성화 여부
RSS를 제공하지 않는 사이트는 HTML 파서로 직접 수집합니다. DB에서 관리하기 때문에 소스 추가·비활성화 시 코드 배포 없이 처리할 수 있습니다.
점수 시스템 덕분에 소스를 공격적으로 늘릴 수 있게 됐습니다. 소스 품질이 낮아 70%가 필터링되더라도 65점 이상만 노출되기 때문에 추가 비용이 없습니다. 소스가 많을수록 중요한 기사를 놓칠 확률이 낮아집니다.
결과
키워드 필터에서 LLM 점수 시스템으로 전환한 이후 세 가지가 개선됐습니다.
- CPI 발표, 금리 결정 같은 키워드 없는 거시경제 기사가 정상 노출됩니다
- 기술적 분석·예측 기사가 상한선에 걸려 노출 우선순위가 낮아집니다
- 소스를 추가할 때 품질 걱정 없이 진행할 수 있습니다
다음 단계는 이렇게 필터링된 기사들을 사건 단위로 묶어 타임라인으로 보여주는 클러스터링 파이프라인입니다.