왜 중복이 문제인가

파인튜닝 성능은 데이터의 양보다 분포와 품질에 좌우된다. 크롤링·로그·합성 데이터를 합치면 거의 항상 중복이 섞인다. 완전 동일 문서뿐 아니라 공백·개행·인용부호만 다른 근사 중복(near-duplicate)이 더 많다.

중복이 남으면 세 가지 문제가 생긴다. 첫째, 특정 패턴이 과대표집되어 모델이 그 표현을 암기(memorization)하고 일반화가 떨어진다. 둘째, 평가셋과 학습셋이 겹치면 지표가 부풀려져 실제 성능을 오판한다. 셋째, 동일 내용을 반복 학습하며 GPU 시간을 낭비한다. 큐레이션의 절반은 "무엇을 넣을까"가 아니라 "무엇을 뺄까"다.

중복의 3단계 분류

제거 전략을 고르려면 먼저 중복을 계층으로 나눠야 한다.

유형정의탐지 방법
완전 중복바이트 단위 동일SHA-256 해시
근사 중복정규화 후 거의 동일MinHash / SimHash
의미 중복표현은 다르나 내용 동일임베딩 코사인 유사도

비용은 위에서 아래로 갈수록 커진다. 실무에서는 값싼 완전 중복부터 걷어내고, 남은 데이터에 MinHash를, 최종 소량에만 임베딩을 적용하는 파이프라인이 효율적이다.

1단계: 정규화와 정확 중복 제거

해시 전에 정규화를 반드시 선행한다. 정규화 없이 해시하면 근사 중복을 놓친다.

import hashlib, re, unicodedata

def normalize(text: str) -> str:
    text = unicodedata.normalize("NFKC", text)
    text = text.lower()
    text = re.sub(r"\s+", " ", text)      # 연속 공백/개행 축약
    text = re.sub(r"[\"'`]", "", text)     # 인용부호 제거
    return text.strip()

def dedup_exact(records):
    seen, out = set(), []
    for r in records:
        h = hashlib.sha256(normalize(r["text"]).encode()).hexdigest()
        if h not in seen:
            seen.add(h)
            out.append(r)
    return out

2단계: MinHash + LSH로 근사 중복 제거

수백만 건에서 모든 쌍을 비교하면 O(n²)이라 불가능하다. MinHash로 문서를 시그니처로 압축하고 LSH로 후보만 좁힌다. Jaccard 임계값은 0.8 부근에서 시작해 표본을 눈으로 확인하며 조정한다.

from datasketch import MinHash, MinHashLSH

def shingles(text, k=5):
    tokens = normalize(text).split()
    return {" ".join(tokens[i:i+k]) for i in range(len(tokens)-k+1)}

lsh = MinHashLSH(threshold=0.8, num_perm=128)
kept = []
for i, r in enumerate(records):
    m = MinHash(num_perm=128)
    for s in shingles(r["text"]):
        m.update(s.encode())
    if not lsh.query(m):          # 유사 문서가 아직 없으면
        lsh.insert(f"d{i}", m)
        kept.append(r)

짧은 텍스트는 shingle 수가 적어 Jaccard가 불안정하다. 토큰 수 하한(예: 20토큰)을 두고 짧은 샘플은 별도 처리한다.

3단계: 학습/평가 누수 차단

중복 제거 못지않게 중요한 것이 평가셋 오염 방지다. 평가셋을 먼저 고정한 뒤, 학습셋에서 평가셋과 근사 중복인 항목을 제거해야 한다. 순서를 바꾸면 평가 샘플이 학습에 흡수될 수 있다.

-- 평가셋 해시를 기준으로 학습셋에서 누수 제거
DELETE FROM train
WHERE norm_hash IN (SELECT norm_hash FROM eval);

-- 남은 학습셋의 소스별 분포 점검 (편향 확인)
SELECT source, COUNT(*) AS n,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM train
GROUP BY source
ORDER BY n DESC;

주의점과 실무 팁

  • 과제거 경계. 코드·수식·표는 정당하게 반복되는 구조가 많다. 도메인별로 임계값을 분리하지 않으면 유효 데이터를 날린다.
  • 다양성 보존. 중복 클러스터에서 무작위 1건만 남기기보다, 길이·출처가 다양한 대표를 남기면 분포가 건강해진다.
  • 결정성 확보. 정규화 규칙과 시드를 버전 관리해야 재현 가능한 데이터셋이 된다. 규칙이 바뀌면 데이터셋 버전도 올린다.
  • 지표 검증. 제거 전후로 소스 분포, 평균 길이, 토큰 수 변화를 기록해 의도치 않은 편향이 생기지 않았는지 확인한다.

중복 제거는 한 번 돌리고 끝나는 배치가 아니라, 정규화 규칙·임계값·표본 검수를 반복하는 순환 과정이다. 값싼 단계부터 계층적으로 적용하고 매 단계 분포를 확인하는 습관이 결국 학습 비용과 평가 신뢰도를 동시에 지킨다.