포스트

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

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

들어가며

웹/사내 로그/문서에서 학습 데이터를 모으면, 생각보다 빠르게 중복(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) 의존성/실행

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
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,
    )

예상 출력(예시)

1
2
3
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)
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.