외부 뉴스 소스 수십 개를 수집하는 파이프라인을 만들면서 수집보다 더 골치 아픈 문제가 따로 있었다.
같은 사건이 매체마다 제목만 살짝 바꿔서 들어온다.
연준이 금리를 올리면 그 한 사건이 30개 매체에서 30개의 제목으로 쏟아진다.
“연준, 기준금리 0.25%p 인상”, “Fed 25bp 금리 인상 단행”, “미 연준 금리 또 올렸다”…
사람 눈엔 같은 사건이지만 문자열로는 전부 다르다.
이걸 거르지 못하면 파이프라인 뒤쪽이 전부 망가진다.
같은 사건이 30번 카운트되면서 “이 뉴스가 지금 급격히 퍼지고 있다”는 잘못된 신호가 만들어진다.
그래서 중복 제거(dedup)는 이 시스템에서 선택이 아니라 생존 조건이었다.
처음엔 간단할 줄 알았다.
결국 3단계까지 갔다.
이 글은 왜 1단계가 아닌 3단계여야 했는지에 대한 기록이다.

1. “완전히 같은 것만 거른다”의 함정
처음 떠올린 답은 누구나 떠올리는 그거다.
제목 문자열을 해시로 만들어서 같은 해시면 중복.
content_hash = hashlib.md5(title.encode()).hexdigest()
이건 완전히 똑같은 제목만 잡는다.
그런데 현실의 중복은 대부분 완전히 같지 않다.
같은 사건의 제목들은 단어 하나, 띄어쓰기 하나, 문장부호 하나가 다르다.
“Fed raises rates”와 “FED RAISES RATES”는 사람에겐 같지만 MD5에겐 완전히 다른 문자열이다.
대문자 하나 때문에 해시가 통째로 달라진다.
즉 완전 일치 해시는 가장 쉬운 중복만 잡고, 진짜 문제인 ‘거의 같은’ 중복은 다 놓친다.
여기서 깨달았다.
중복 제거는 한 번에 끝나는 게 아니라, 거름망을 여러 겹 포개는 문제다.
거친 망으로 큰 걸 거르고, 고운 망으로 미세한 걸 거른다.
내 경우엔 그 망이 세 겹이 됐다.
2. 1단계: 정규화 – 비교의 출발선을 맞춘다
먼저 비교 자체가 공정하려면, 비교 대상을 같은 형태로 깎아야 한다.
대소문자, 문장부호, 중복 공백 등 의미에 영향 없는 차이를 다 제거한다.
@staticmethod
def normalize(text: str) -> str:
text = text.lower() # 대소문자 통일
text = re.sub(r"[^\w\s]", "", text) # 문장부호 제거
text = re.sub(r"\s+", " ", text).strip() # 공백 정리
return text
"FED RAISES RATES"와 "fed raises rates"는 정규화 후 똑같아진다.
이 한 단계만으로 대소문자·공백 때문에 새는 중복이 통째로 막힌다.
다만 여기엔 내가 한참 고민한 함정이 하나 있다.
문장부호를 통째로 날리면, 숫자에 붙은 기호도 같이 날아간다.
normalize("Fed: Rate +0.25%!") # → "fed rate 025"
+0.25%가 025가 된다.
이게 맞는 선택인지 오래 고민했다.
0.25%와 0.5%를 구분 못 하면 안 되는 거 아닌가.
결론은 “제목 단계에선 괜찮다”였다.
여기서 정규화의 목적은 정밀한 의미 보존이 아니라 다음 단계로 넘길 비교 키를 만드는 것이다.
숫자의 정밀한 차이는 어차피 다음 단계(유사도)에서 다른 단어들로 갈린다.
정규화를 너무 똑똑하게 만들려다 보면, 단계를 나눈 의미가 사라진다.
각 단계는 자기 일만 한다. 정규화는 “표면 잡음 제거”까지만.

3. 2단계: MD5 완전 일치 – 싸고 확실한 1차 거름망
정규화된 제목으로 이제 완전 일치를 본다.
content_hash = hashlib.md5(normalized.encode()).hexdigest()
if content_hash in self.seen_hashes:
return True, content_hash, normalized
여기서 굳이 해시를 쓰는 이유가 있다.
제목 원문을 통째로 들고 비교하면 메모리도 크고 비교도 느리다.
MD5 해시는 32자 고정 문자열이고, set 안에서 조회가 o(1)이다.
수만 개가 쌓여도 “이거 본 적 있어?”가 한 번에 끝난다.
이 단계의 역할은 명확하다.
완전히 같은 제목을 가장 싼 비용으로 즉시 쳐낸다.
정규화 덕분에 대소문자·공백만 다른 중복까지 여기서 다 걸린다.
전체 중복의 상당 부분이 사실 이 단계에서 끝난다.
문제는, 여기까지 통과한 제목들이다.
정규화해도 다르고 해시도 다른데, 사람이 보면 같은 사건.
“연준 0.25%p 인상 단행”과 “연준, 0.25%p 금리 인상”처럼 단어 순서나 조사 하나가 다른 경우.
이게 dedup의 진짜 난이도고 3단계가 존재하는 이유다.
4. 3단계: MinHash LSH – ‘거의 같은’ 제목을 잡는다
남은 문제는 유사도다.
두 제목이 몇 퍼센트나 겹치는가.
가장 순진한 방법은 모든 제목 쌍을 비교하는 거다.
새 제목이 들어올 때마다 기존 제목 전부와 비교하면 된다.
그런데 이건 O(n²)다.
제목이 수만 개 쌓이면 새 제목 하나 넣을 때마다 수만 번 비교한다.
실시간 수집 파이프라인에서 이 비용은 감당이 안 된다.
그래서 MinHash + LSH를 썼다. (datasketch 라이브러리)
원리만 짧게 말하면 이렇다.
- MinHash: 제목을 단어 집합으로 보고, 고정 길이 시그니처(여기선 128차원)로 압축한다. 두 시그니처가 비슷하면 원본 집합도 비슷하다.
- LSH: 비슷한 시그니처끼리 미리 같은 버킷에 넣어둔다. 그래서 “비슷한 거 있어?”를 전부 비교하지 않고 같은 버킷만 본다.
전부 비교(O(n²)) 대신, 유사한 후보만 근사적으로 끌어온다.
SIMILARITY_THRESHOLD = 0.85
NUM_PERM = 128
# ...
minhash = self._get_minhash(normalized)
similar = self.lsh.query(minhash) # 비슷한 후보만 즉시 조회
if similar:
return True, content_hash, normalized
NUM_PERM = 128은 시그니처의 차원 수다.
높일수록 유사도 추정이 정확해지지만 그만큼 느리고 무거워진다.
128은 정확도와 비용의 일반적인 타협점이라 거기서 시작했고 문제가 없어서 유지했다.
이 단계가 “Fed raises rates by 25 basis points today“와 “…now“처럼 한 단어만 다른 제목을 잡아준다.
MD5로는 절대 못 잡는 중복이다.
5. 왜 0.85인가 – 문턱값은 측정과 감의 타협이다
3단계에서 가장 손이 많이 간 건 코드가 아니라 숫자 하나였다.
SIMILARITY_THRESHOLD = 0.85.
85% 이상 비슷하면 같은 사건으로 본다는 뜻이다.
이 숫자엔 정답이 없다.
너무 낮추면… 예를 들어 0.6이라면 비슷한 주제의 다른 사건까지 같은 걸로 묶어버린다.
“연준 0.25%p 인상”과 “연준 0.5%p 인상”이 한 사건으로 합쳐지면 진짜 다른 뉴스를 잃는다.
너무 높이면… 예를 들어 0.95라면 조사 하나, 단어 순서 하나 다른 같은 사건을 못 잡는다.
다시 중복이 줄줄 샌다.
0.85는 “표현만 바꾼 같은 사건은 잡되, 다른 사건은 안 합치는” 지점을 찾아간 결과다.
여기서 배운 건, 이런 문턱값은 한 번 정하고 잊는 값이 아니라는 거다.
소스 구성이 바뀌거나 도메인이 바뀌면 다시 봐야 한다.
그래서 dedup 결과는 항상 로그로 남겨, 무엇이 무엇과 묶였는지 사후에 볼 수 있게 했다.
logger.debug(f"[Dedup] Similar to {similar[0]}: {title[:50]}")
“왜 이 둘이 같은 걸로 묶였지”를 추적할 수 없으면, 문턱값을 조정할 근거도 없다.

6. 재시작하면 중복이 되살아난다 (warm-up 문제)
여기까지 짜고 잘 돌아간다고 생각했는데 운영에서 한 방 맞았다.
seen_hashes와 LSH 인덱스는 메모리에 있다.
프로세스를 재시작하면 그게 전부 날아간다.
재시작 직후엔 “본 적 있는 제목”이 하나도 없는 상태가 된다.
그러면 어제 이미 걸렀던 중복들이 새것처럼 다시 통과한다.
배포 한 번 할 때마다 중복이 한 무더기 되살아나는 거다.
그래서 시작할 때 최근 데이터를 DB에서 끌어와 인덱스를 데우는 warm-up을 붙였다.
WARMUP_DAYS = 7
WARMUP_TIMEOUT_SECONDS = 30
def warm_up(self, session):
cutoff = datetime.utcnow() - timedelta(days=self.WARMUP_DAYS)
events = (session.query(Event.id, Event.normalized_title, Event.content_hash)
.filter(Event.collected_at >= cutoff).all())
for event in events:
if (datetime.now() - start_time).seconds > self.WARMUP_TIMEOUT_SECONDS:
break # 타임아웃 가드
self.seen_hashes.add(event.content_hash)
if event.normalized_title:
self.lsh.insert(event.content_hash, self._get_minhash(event.normalized_title))
최근 7일치만 데운다.
그보다 오래된 뉴스는 어차피 다시 들어올 일이 거의 없어서 메모리에 이고 있을 이유가 없다.
여기에 두 가지 안전장치를 더 뒀다.
하나, 타임아웃 가드.
warm-up이 30초를 넘기면 거기서 멈춘다.
기동 시점에 dedup 데우다가 서비스 전체가 안 뜨는 상황을 막기 위해서다.
완벽한 dedup보다 일단 뜨는 게 우선이다.
둘, 실패해도 계속 진행.
warm-up이 통째로 실패해도 예외를 삼키고 빈 인덱스로 출발한다.
except Exception as e:
logger.error(f"[Dedup] Warm-up failed: {e}")
self._warmed_up = True # 실패해도 계속
return 0
dedup은 어디까지나 품질 향상 장치지, 서비스의 생사를 쥐어선 안 된다.
이 우선순위를 코드에 명시적으로 박아둔 셈이다.
7. 이 방법이 못 잡는 것: 정직한 한계
3단계를 다 깔아도 못 잡는 중복이 있다.
이걸 알고 쓰는 것과 모르고 쓰는 건 다르다.
가장 큰 구멍은 언어가 다른 같은 사건이다.
MinHash는 결국 단어 집합의 겹침으로 유사도를 본다.
그런데 “Fed raises rates”(영문 소스)와 “연준 금리 인상”(국문 소스)은 같은 사건인데 단어가 단 하나도 안 겹친다.
집합 교집합이 0이니 유사도도 0이다.
내 시스템은 소스가 국문·영문 섞여 있어서 이 교차 언어 중복은 3단계를 그냥 통과한다.
이건 단어 기반 접근의 구조적 한계라 문턱값을 아무리 만져도 안 풀린다.
제대로 풀려면 제목을 임베딩 벡터로 바꿔 의미 유사도로 비교해야 한다.
다만 그건 매 제목마다 임베딩 호출 비용이 붙어서 “수집 단계에서 싸게 거른다”는 이 파이프라인의 전제와 충돌했다.
그래서 지금은 의도적으로 안 했다.
교차 언어 중복은 수집 단계가 아니라 사건을 묶는 더 뒤쪽 단계에서 다루기로 미뤘다.
두 번째 구멍은 너무 짧은 제목이다.
단어가 서너 개뿐인 제목은 MinHash 시그니처가 불안정해서
우연히 한두 단어 겹치면 다른 사건이 묶이거나 그 반대가 된다.
dedup은 만능 필터가 아니다.
“단어가 충분히 겹치는, 같은 언어의, 표현만 다른 중복”에 강하고 그 바깥은 약하다.
이 경계를 알아야 어디까지 믿고 어디부터 다른 장치를 붙일지 판단할 수 있다.
마무리하며: 이 3단계는 LLM 쪽에서도 똑같이 쓰인다
정리하면 dedup은 거름망 세 겹이다.
- 정규화 — 표면 잡음을 제거해 비교 출발선을 맞춘다.
- MD5 완전 일치 — 똑같은 건 가장 싸게 즉시 쳐낸다.
- MinHash LSH — ‘거의 같은’ 건 근사 유사도로 잡는다.
거친 망에서 고운 망 순서로, 싼 비용에서 비싼 비용 순서로 포갠다.
이걸 뉴스 수집기에서 배웠지만 적용 대상은 훨씬 넓다.
특히 요즘 내가 다루는 LLM 작업에 그대로 들어온다.
- RAG: 검색해온 청크 중 거의 같은 내용이 여러 개면 컨텍스트만 낭비된다. 같은 3단계로 걸러야 한다.
- 학습·평가 데이터: 거의 중복인 샘플이 데이터셋에 섞이면 평가가 부풀려진다. MinHash 중복 제거는 데이터셋 정제의 기본기다.
“완전히 같은 것”만 거르는 dedup은 절반만 푼 거다.
진짜 비용은 항상 ‘거의 같은 것’에 숨어 있다.
새로 중복 제거를 짤 때 나는 이제 이 질문을 던진다.
- 완전 일치만 잡고 있지 않은가 : ‘거의 같은’ 중복을 거르는 단계가 있는가?
- 유사도 비교가 O(n²)로 터지지 않는가 : LSH 같은 근사 인덱스를 쓰는가?
- 문턱값을 정한 근거가 있고 사후에 추적 가능한가?
- 재시작하면 상태가 날아가 중복이 되살아나지 않는가?
중복은 없앨 수 있다.
다만 “같다”의 정의를 한 겹이 아니라 여러 겹으로 봐야 한다.

함께 읽으면 좋은 글
글쓴이 · Plutojoshua
핀테크에서 LLM과 AI 에이전트를 실제 서비스에 적용하는 개발자다. 카카오 AI 앰배서더로 Kanana 모델을 앱에 직접 연동해 검증하고, 비전공자로 시작해 실제 코드와 측정값을 근거로 “왜 그렇게 설계했는지”를 1인칭으로 기록한다. 운영자 소개는 About, 코드는 GitHub에서 볼 수 있다.