Joyuno's Dev Blog

중복 제거(dedup)가 LLM 학습 데이터 품질을 “결정”하는 이유: 2026년식 데이터 큐레이션 파이프라인 실전 가이드

AI Data

들어가며

웹/사내 로그/문서에서 학습 데이터를 모으면, 생각보다 빠르게 중복(duplicate)과 준중복(near-duplicate) 이 데이터의 대부분을 잠식합니다. 문제는 “데이터가 더 많아 보이는 착시”가 생긴다는 점입니다. 동일/유사 문장이 반복되면 모델은 특정 표현을 과대학습하고, 학습 효율은 떨어지며, 평가 시에는 benchmark contamination(훈련-평가 누수) 로 점수가 부풀 수 있습니다. (arxiv.org)

언제 쓰면 좋은가

  • (1) 웹 크롤/집계형 코퍼스, (2) 여러 소스 병합, (3) RAG용 지식베이스/청크, (4) fine-tuning 데이터(특히 QA/코드)에서 “비슷한 샘플이 돌고 도는” 느낌이 든다면 거의 필수입니다.
  • 특히 “train/test 간 누수” 방지를 위해 cross-split dedup(train↔test) 또는 benchmark decontamination을 파이프라인에 넣는 게 2026년에는 기본값에 가깝습니다. (arxiv.org)

언제 쓰면 안 되는가(혹은 조심해야 하는가)

  • 데이터가 원래부터 반복이 의미인 도메인(고객센터 템플릿, 규격 문서, 코드 스니펫 레퍼런스 등)이라면, aggressive dedup는 유효한 빈도 정보를 지워 모델의 우선순위를 망칠 수 있습니다.
  • “품질이 낮은 중복”만 지우고 싶다면, dedup만으로는 부족하고 quality filtering + 샘플링 전략이 같이 가야 합니다(뒤에서 트레이드오프 설명).

🔧 핵심 개념

1) Duplicate / Near-duplicate / Semantic duplicate

  • Exact duplicate: 문자열이 동일(또는 normalize 후 동일). 보통 MD5/SHA 같은 해시로 빠르게 제거.
  • Near-duplicate(fuzzy): 공백/마크업/문장 일부 변경, 문단 재배치처럼 “형태가 비슷한” 경우. 대표적으로 shingling + Jaccard + MinHash + LSH 조합이 2026년에도 주류입니다. (docs.nvidia.com)
  • Semantic duplicate: 표현은 다르지만 의미가 거의 같은 경우. embedding 기반으로 잡을 수 있지만 비용이 커서 “추가로 잡히는 비율 대비 compute ROI”를 따져야 합니다. NeMo Curator는 exact/fuzzy/semantic 세 갈래를 모두 제공하며, semantic은 임베딩 기반 워크플로우로 분리되어 있습니다. (docs.nvidia.com)

2) 내부 작동 방식(구조/흐름): “후보 생성”과 “검증”을 분리하라

대규모 dedup의 핵심은 전수 비교를 포기하고, 1) 후보(candidate)만 빠르게 모은 뒤 2) 후보에 대해서만 더 비싼 검증을 하는 2단 구조입니다.

(A) Shingling → Jaccard

  • 문서를 token/char n-gram으로 쪼개 shingle set을 만들고, 두 문서의 유사도는 Jaccard로 정의합니다: |A∩B| / |A∪B|. (crawlix.app)
  • 하지만 set 비교는 비싸니, 다음 단계가 필요합니다.

(B) MinHash → LSH(banding)

  • MinHash는 Jaccard를 근사하는 signature를 만들고,
  • LSH는 signature를 여러 band로 나눠 “같은 band에 충돌”하는 문서만 후보로 올립니다.
  • 이게 SlimPajama 같은 대형 코퍼스의 global dedup에도 쓰인 전형적 패턴입니다(대략 Jaccard threshold 0.8 등). (emergentmind.com)

(C) (선택) False Positive Check

  • LSH는 근사라서 같은 bucket에 들어갔지만 진짜 유사하지 않은 경우가 생깁니다.
  • NeMo Curator도 bucket 후보에 대해 추가 Jaccard 계산으로 false positive를 필터링하는 옵션을 제공합니다. (docs.nvidia.com)
  • 실무에서는 이 단계를 넣어야 “안 지워도 될 문서”를 과하게 삭제하는 사고를 줄입니다.

3) 다른 접근과의 차이점: MinHash/SimHash/Embedding

  • SimHash: 특징(feature) 벡터를 1-bit 해시로 요약해 Hamming distance로 유사도 추정. 웹 크롤러에서 near-duplicate 탐지에 사용된 것으로 알려져 있습니다. 다만 Jaccard 기반 파이프라인에 비해 “내가 원하는 유사도 정의”를 세밀하게 튜닝하기가 상대적으로 어렵습니다. (en.wikipedia.org)
  • Embedding-based semantic dedup: 의미 중복까지 잡지만, 임베딩 생성이 병목이고 threshold 설정이 더 까다롭습니다. 그래서 “텍스트 정규화+MinHash로 대부분 제거 → embedding은 고가치 subset만” 같은 하이브리드가 현실적입니다(NeMo Curator도 semantic을 별도 워크플로우로 분리). (docs.nvidia.com)
  • 최근 오픈소스 흐름: semantic dedup을 “가볍게” 쓰려는 시도로 SemHash 같은 도구가 나오고, 반대로 MinHash/LSH를 GPU로 극단적으로 가속하는 연구(FED)도 나옵니다. 즉, 2026년은 “의미 기반 vs 초대규모 근사”가 동시에 진화 중입니다. (github.com)

💻 실전 코드

아래는 현실적인 JSONL 문서 코퍼스(수백만 건까지 확장 가능) 를 가정한 파이프라인입니다.

  • 1단계: exact dedup(정규화+해시)
  • 2단계: near-dup 후보 탐지(MinHash LSH)
  • 3단계: 후보만 Jaccard로 재검증 후 “클러스터별 대표 1개만 유지”
  • (옵션) train/test 간 누수 방지를 위한 cross-split dedup도 같은 방식으로 적용

0) 의존성/실행

python -m venv .venv
source .venv/bin/activate
pip install polars datasketch xxhash regex tqdm

1) 파이프라인 코드 (대용량 전제: streaming + 후보만 메모리 사용)

import re
import json
import xxhash
from pathlib import Path
from tqdm import tqdm
import polars as pl
from datasketch import MinHash, MinHashLSH

WS = re.compile(r"\s+")

def normalize_text(s: str) -> str:
    # “의미”가 아니라 “형태” 기준 dedup이 목적이므로 공격적 normalize는 금물
    # (너무 공격적이면 다른 문서도 합쳐짐)
    s = s.strip()
    s = WS.sub(" ", s)
    return s

def char_ngrams(s: str, n: int = 13):
    # SlimPajama류에서 자주 쓰이는 방식: lowercase + char-ngrams ([emergentmind.com](https://www.emergentmind.com/topics/slimpajama-dataset?utm_source=openai))
    s = s.lower()
    if len(s) <= n:
        yield s
        return
    for i in range(0, len(s) - n + 1):
        yield s[i:i+n]

def exact_fingerprint(s: str) -> int:
    # 빠르고 충돌 낮은 64-bit 해시(cryptographic 해시가 꼭 필요하진 않음)
    return xxhash.xxh3_64_intdigest(s)

def build_minhash(s: str, num_perm: int = 128, ngram: int = 13) -> MinHash:
    mh = MinHash(num_perm=num_perm)
    for g in char_ngrams(s, n=ngram):
        mh.update(g.encode("utf-8", errors="ignore"))
    return mh

def jaccard_approx_set(s: str, ngram: int = 13) -> set:
    # 후보 검증용: bucket 내에서만 쓰이므로 set을 만들어도 감당 가능
    return set(char_ngrams(s, n=ngram))

def dedup_jsonl(
    in_path: str,
    out_path: str,
    text_col: str = "text",
    id_col: str = "id",
    num_perm: int = 128,
    ngram: int = 13,
    lsh_threshold: float = 0.8,
    verify_threshold: float = 0.82,  # LSH 후보에 대해 조금 더 엄격하게
):
    in_path = Path(in_path)
    out_path = Path(out_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)

    # LSH: threshold는 “후보 생성” 기준(근사)
    lsh = MinHashLSH(threshold=lsh_threshold, num_perm=num_perm)

    seen_exact = set()
    kept = {}          # doc_id -> normalized text
    clusters = {}      # doc_id -> representative doc_id (union-find 대신 단순 매핑)

    def find_rep(x):
        while clusters.get(x, x) != x:
            x = clusters[x]
        return x

    def union(a, b):
        ra, rb = find_rep(a), find_rep(b)
        if ra != rb:
            # 단순히 먼저 등장한 것을 대표로 유지
            clusters[rb] = ra

    # Polars로 streaming 읽기(메모리 절약)
    lf = pl.scan_ndjson(str(in_path)).select([id_col, text_col])
    rows = lf.collect(streaming=True).iter_rows(named=True)

    for row in tqdm(rows, desc="dedup"):
        doc_id = str(row[id_col])
        text = normalize_text(row[text_col] or "")
        if len(text) < 200:
            # 너무 짧은 텍스트는 MinHash가 불안정(충돌/과탐 증가). 별도 정책 권장
            # 여기서는 exact만 적용하고 통과
            fp = exact_fingerprint(text)
            if fp in seen_exact:
                continue
            seen_exact.add(fp)
            kept[doc_id] = text
            continue

        # 1) exact dedup
        fp = exact_fingerprint(text)
        if fp in seen_exact:
            continue
        seen_exact.add(fp)

        # 2) near-dup 후보 탐지
        mh = build_minhash(text, num_perm=num_perm, ngram=ngram)
        candidates = list(lsh.query(mh))

        # 3) 후보만 재검증(Jaccard set) 후 클러스터링
        if candidates:
            a_set = jaccard_approx_set(text, ngram=ngram)
            merged = False
            for cand in candidates:
                rep = find_rep(cand)
                rep_text = kept.get(rep)
                if not rep_text:
                    continue
                b_set = jaccard_approx_set(rep_text, ngram=ngram)
                jac = len(a_set & b_set) / max(1, len(a_set | b_set))
                if jac >= verify_threshold:
                    union(rep, doc_id)  # rep 유지, doc_id는 흡수
                    merged = True
                    break
            if merged:
                continue

        # 대표로 유지 + LSH 인덱싱
        kept[doc_id] = text
        clusters[doc_id] = doc_id
        lsh.insert(doc_id, mh)

    # 결과 저장(대표만)
    with out_path.open("w", encoding="utf-8") as f:
        for doc_id, text in kept.items():
            if find_rep(doc_id) == doc_id:
                f.write(json.dumps({id_col: doc_id, text_col: text}, ensure_ascii=False) + "\n")

    print(f"input={in_path} kept_representatives={sum(1 for k in kept if find_rep(k)==k)} total_seen={len(seen_exact)}")
    print(f"output={out_path}")

if __name__ == "__main__":
    dedup_jsonl(
        in_path="data/raw_corpus.jsonl",
        out_path="data/curated_corpus.dedup.jsonl",
        text_col="text",
        id_col="id",
        num_perm=128,
        ngram=13,
        lsh_threshold=0.8,
        verify_threshold=0.82,
    )

예상 출력(예시)

dedup: 100%|██████████| 2,350,000/2,350,000 [12:34<00:00, 3116.23it/s]
input=data/raw_corpus.jsonl kept_representatives=1,420,000 total_seen=2,350,000
output=data/curated_corpus.dedup.jsonl

이 구조는 NeMo Curator가 제시하는 “exact → fuzzy(MinHash+LSH) → (옵션) false positive check” 흐름과 동일한 철학입니다. 다만 여기서는 GPU 없이 CPU로도 확장 가능한 형태로 잡았습니다. (docs.nvidia.com)


⚡ 실전 팁 & 함정

Best Practice 1) “글로벌 dedup”과 “로컬 dedup”을 구분하라

  • 여러 소스를 합치는 경우 소스별로 따로 dedup하면 같은 문서가 소스 간에 남습니다. SlimPajama-류에서는 이를 피하려고 global dedup을 강조합니다. (emergentmind.com)
  • 실무 기준:
    • 모델 pretraining/대규모 코퍼스: global dedup 우선
    • 특정 도메인 fine-tuning: 도메인 내부의 반복은 학습 신호일 수 있으니 로컬 dedup + 샘플링(가중치) 고려

Best Practice 2) threshold를 “한 번에 결정”하지 말고, 샘플 감사(audit) 루프를 돌려라

  • LSH threshold(후보 생성)와 Jaccard verify threshold(검증)는 별개입니다.
  • 추천 절차: 1) 보수적으로 후보를 넓게 잡음(LSH threshold 낮게) 2) 후보 페어를 샘플링해 사람이 보고 3) verify threshold로 “삭제해도 되는 수준”을 결정
  • 이 과정을 생략하면, 특히 템플릿/정형 문서에서 “다른 문서인데 비슷하게 생겼다”가 대량 삭제됩니다.

Best Practice 3) benchmark decontamination은 “평가 신뢰도”의 문제다

  • 2026년에도 benchmark 누수는 계속 문제고, “훈련 데이터에서 제거” 또는 “평가 시 억제” 같은 접근이 병행됩니다. (arxiv.org)
  • 내 프로젝트에서 “리더보드 점수”가 중요한 순간일수록, dedup는 성능 최적화가 아니라 평가의 정직성을 지키는 장치가 됩니다.

흔한 함정/안티패턴

  • 안티패턴: normalize를 과도하게(구두점 제거/숫자 제거/스톱워드 제거까지)
    → 서로 다른 정책/버전/가격표/스펙 문서가 합쳐져 “정보 다양성”이 급감.
  • 안티패턴: 짧은 텍스트에 MinHash 강행
    → shingle 수가 너무 적어 충돌이 잦고 과탐이 늘어납니다. 짧은 텍스트는 exact 중심 또는 다른 전략(예: prefix/suffix, Levenshtein)으로 분리하세요.
  • 안티패턴: embedding dedup을 처음부터 전량 적용
    → 비용 폭발 + threshold 해석이 더 어려움. NeMo Curator도 semantic을 별도 단계로 둡니다. (docs.nvidia.com)

비용/성능/안정성 트레이드오프(현실 조언)

  • CPU로도 MinHash/LSH는 충분히 크지만, 문서 수가 수천만~수억이면 병목이 옵니다. 이때 선택지는:
    • (A) 분산 처리(Ray/Spark)
    • (B) GPU 가속 파이프라인(예: NeMo Curator) (github.com)
    • (C) 더 공격적인 시스템 연구(FED처럼 GPU 최적화) (arxiv.org)
  • 중요한 건 “내가 잡고 싶은 중복의 정의”입니다. 형태 중복이면 MinHash 계열이 여전히 ROI가 좋고, 의미 중복이면 subset에만 embedding을 태우는 게 보통 더 싸게 먹힙니다.

🚀 마무리

  • 2026년 5월 기준으로도, 학습 데이터 큐레이션에서 dedup는 선택이 아니라 품질의 하한선을 올리는 기본 공정에 가깝습니다. NeMo Curator처럼 exact/fuzzy/semantic을 분리해 제공하는 흐름은 실무적으로도 타당합니다. (docs.nvidia.com)
  • 도입 판단 기준(빠른 체크리스트): 1) 데이터가 여러 소스에서 합쳐지나? → global dedup 필요 2) 평가 점수 신뢰가 중요한가? → benchmark decontamination 필수(최소 train/test cross-dedup) 3) “같은 말 반복”이 모델 품질을 해치고 있나? → fuzzy(MinHash+LSH)부터 4) “다른 말로 같은 뜻”이 문제인가? → semantic dedup은 비용 대비 효과를 샘플로 검증 후 확대

다음 학습 추천은 두 갈래입니다.

  • 실무 구현 관점: NeMo Curator의 fuzzy/semantic 워크플로우 구조를 참고해, 내 파이프라인을 “후보 생성/검증”으로 분리 설계하기 (docs.nvidia.com)
  • 스케일 업 관점: 수천만 문서 이상이면 GPU 가속/클러스터 최적화(FED 같은 접근)로 넘어갈 시점을 계산하기 (arxiv.org)