플레이데이터 풀스택 백엔드 9기 20주차 주간회고 및 학습기록 (스무번째 기록)
이번 주는 크롤링한 뉴스들의 유사도를 분석하여 중복 기사를 제거하는 로직을 구현하였다. 크롤링한 뉴스들을 분석하는 과정은 파이썬으로 진행하였다. 각 카테고리의 뉴스끼리 제목 유사도 분석을 진행한 후, 동일한 내용의 기사로 의심되는 후보군에 대해 본문 유사도를 분석했다. 유사도는 TF-IDF 벡터를 기반으로 코사인 유사도를 계산하여 측정하였다. 이를 위해 작성한 코드들을 간략하게 기록하고자 한다.
1. 제목 유사도 분석
import re
import pandas as pd
from preprocess_config import okt, STOPWORDS, IMPORTANT_KEYWORDS
def preprocess_titles(text):
if pd.isna(text):
return ''
text = str(text)
text = re.sub(r'[^\w\s]', ' ', text) # 특수문자 제거
text = re.sub(r'\d+', '', text) # 숫자 제거
text = re.sub(r'\s+', ' ', text).strip() # 공백 정리
tokens = okt.nouns(text) # 명사 추출
tokens = [
t for t in tokens
if (len(t) > 1 or t in IMPORTANT_KEYWORDS) and t not in STOPWORDS
]
return ' '.join(tokens)
다음과 같이 각 기사의 제목들에 대해 전처리를 먼저 진행하였다. 특수문자, 숫자, 공백, 불용어를 제거하였다. 기사 제목은 기본적으로 명사 위주로 작성되기 때문에 명사를 추출하여 유사도를 비교하였다.
# ----- 제목 전처리 -----
df['clean_title'] = df['title'].apply(preprocess_titles)
# ----- 제목 기반 유사 그룹 생성 -----
groups, title_similar_pairs = build_title_similarity_groups(df, threshold=THRESHOLD_TITLE)
print(f"\n🔗 유사 그룹 수: {len(groups)}")
# 제목 유사도 출력
print("\n📌 제목 유사도:")
for i, j, sim in title_similar_pairs:
index_i = df.index[i] + 1
index_j = df.index[j] + 1
title_i = df.iloc[i]["title"]
title_j = df.iloc[j]["title"]
print(f" - (index {index_i}, {index_j}) 제목 유사도: {sim:.4f}")
print(f" ① {title_i}")
print(f" ② {title_j}")
print ("\n")
실행 파일에는 다음과 같이 적었으며 콘솔에 다음과 같은 형식으로 중복 기사 후보군이 출력되는 것을 확인할 수 있었다.

2. 본문 유사도 분석 및 중복 제거
import re
from preprocess_config import okt, STOPWORDS, IMPORTANT_KEYWORDS
def preprocess_content(text):
"""
뉴스 본문 전처리 (Okt.morphs 기반)
- 특수문자, 숫자 제거
- 형태소 분석 후 조사/불용어 제거
"""
if not isinstance(text, str):
return ''
# 특수문자/숫자 제거 및 정리
text = re.sub(r'[^\w\s]', ' ', text)
text = re.sub(r'\d+', '', text)
text = re.sub(r'\s+', ' ', text).strip()
# 형태소 분석 (전체 토큰화) + 불용어 제거 + 1글자 필터링
tokens = [
t for t in okt.morphs(text)
if (len(t) > 1 or t in IMPORTANT_KEYWORDS) and t not in STOPWORDS
]
return ' '.join(tokens)
이렇게 중복 기사 후보군에 오른 기사들은 각각 본문 유사도 검사를 진행했다. 먼저 간단히 전처리를 해주었다. 제목과 달리 명사만 추출하지 않고 모두 토큰으로 쪼개어 비교할 수 있도록 하였다.
# 본문 유사도 기반 대표 기사 선택 로직
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from config import THRESHOLD_CONTENT, THRESHOLD_RELATED_MIN
import numpy as np
from preprocessing_content import preprocess_content
from sentence_transformers import SentenceTransformer, util
# 모델 로드 (전역에서 한 번만 로드)
model = SentenceTransformer("snunlp/KR-SBERT-V40K-klueNLI-augSTS")
def filter_and_pick_representative_by_content(group, df, threshold=THRESHOLD_CONTENT, threshold_related_min=THRESHOLD_RELATED_MIN):
"""
그룹 내 본문 유사도를 기준으로 대표 기사 선택
:param group: set 또는 list of indices
:param df: 전체 기사 DataFrame
:param threshold: 유사도 임계값
:return: (대표 인덱스 or None, 중복 그룹 여부, 로그 문자열)
"""
log_lines = []
indices = list(group)
docs = [preprocess_content(df.loc[i, 'content']) for i in indices]
if len(indices) == 1:
return indices[0], False, ""
# ------------------------------------------------------------
# 문장 → 벡터 임베딩
embeddings = model.encode(docs, convert_to_tensor=True)
# 코사인 유사도 계산 (동일한 구조)
sim_matrix = util.pytorch_cos_sim(embeddings, embeddings).cpu().numpy()
# ------------------------------------------------------------
# 대표 기사 선정 (가장 중심에 가까운 기사)
row_avg = sim_matrix.mean(axis=1)
rep_idx = indices[int(row_avg.argmax())]
removed_ids = [] # 중복 기사 (제거 대상)
related_articles = [] # 연관 뉴스 (rep_id, related_id, similarity)
# ------------------------------------------------------------
# 로그 및 유사도 쌍 기록
log_lines.append(f"\n➡️ 본문 유사도 그룹: {[i + 1 for i in sorted(indices)]}")
similar_count = 0
total_pairs = 0
for i in range(len(indices)):
idx = indices[i]
if idx == rep_idx:
continue
sim = sim_matrix[i][indices.index(rep_idx)]
if sim >= THRESHOLD_CONTENT:
removed_ids.append(idx) # 중복 제거
elif sim >= THRESHOLD_RELATED_MIN:
related_articles.append((rep_idx, idx, round(float(sim), 4))) # 연관 뉴스
else:
pass
title_i = df.loc[idx, 'title']
title_r = df.loc[rep_idx, 'title']
log_lines.extend([
f" - ({rep_idx + 1}, {idx + 1}) 본문 유사도: {sim:.4f}",
f" ① {title_r}",
f" ② {title_i}",
])
return rep_idx, removed_ids, related_articles, "\n".join(log_lines)
본문 유사도 분석 과정에서는 SBERT 임베딩을 통해 TF-IDF로는 놓치기 쉬운 동의어/어순 변형을 인식하도록 하였다. 각 후보군의 기사들 중 다른 기사들과의 평균 유사도가 가장 높은 기사를 대표로 정하고, 대표 기사와의 코사인 유사도 분석 결과 0.8을 넘으면 중복으로 인정되어 삭제 처리, 0.4를 넘으면 연관 기사 처리, 0.4 미만이면 아예 다른 기사로 처리하였다.

또한 아래와 같은 형태로 본문 유사도 비교 결과를 로그로 남기게 하였다.

아직 크롤링을 진행한 자바 코드와 중복 제거 로직을 구현한 파이썬 코드를 연동하진 못했다. 스프링 및 fast api로 두 서비스를 나누어서 각 카테고리별로 뉴스를 크롤링하여 중복제거 및 저장까지 하는 api를 만들 계획이다.
'PLAYDATA 주간회고' 카테고리의 다른 글
| 플레이데이터 풀스택 백엔드 9기 8월 2주차 회고 (0) | 2025.08.19 |
|---|---|
| 플레이데이터 풀스택 백엔드 9기 7월 4주차 회고 (2) | 2025.07.30 |
| 플레이데이터 풀스택 백엔드 9기 7월 3주차 회고 (2) | 2025.07.28 |
| 플레이데이터 풀스택 백엔드 9기 7월 2주차 회고 (1) | 2025.07.07 |
| 플레이데이터 풀스택 백엔드 9기 7월 1주차 회고 (0) | 2025.07.03 |