8월 2026 기준: 학습 데이터 큐레이션에서 Dedup이 ‘품질’로 이어지게 만드는 전처리 설계
학습 데이터 큐레이션에서 deduplication(중복 제거) 은 “디스크 절약”이 아니라 학습 신호의 왜곡을 줄이는 품질 공정입니다.
들어가며
학습 데이터 큐레이션에서 deduplication(중복 제거) 은 “디스크 절약”이 아니라 학습 신호의 왜곡을 줄이는 품질 공정입니다. 웹 규모 코퍼스(예: CommonCrawl 계열)를 모으다 보면, 같은 문서/문장/템플릿이 수십 번씩 재등장하고(미러/스크랩/뉴스 재배포), 그 결과 모델이 특정 패턴을 과대학습하거나 평가 셋 오염(contamination) 으로 점수가 부풀어 오릅니다. FineWeb 계열은 이런 “웹 슬러지”를 정리하기 위해 cleaning + filtering + MinHash 기반 dedup을 핵심 공정으로 문서화했고, 설계 선택을 실험으로 비교합니다.1
언제 쓰면 좋은가:
- 대규모 pretraining / continued pretraining: snapshot(월별/주별) 합칠 때 중복 폭발을 막아야 합니다.
- SFT/Preference 데이터: 템플릿형 QA/대화가 반복되면 “다양성”이 무너지고 reward model도 편향됩니다.
- RAG/평가용 데이터셋 구축: train/eval overlap 제거가 핵심입니다(성능 착시 방지).
언제 쓰면 안 되는가(또는 아주 조심해야 하는가):
- “중복 = 나쁨”을 맹신해 과도하게 dedup하면, 오히려 분포가 바뀌어 저품질/이상한 텍스트가 상대적으로 ‘유니크’해서 살아남는 역효과가 납니다. FineWeb 쪽에서도 dedup/필터링 선택이 품질과 분포에 큰 영향을 준다는 점을 반복적으로 강조합니다.2
- 법률/정책/표준처럼 의도적으로 반복되는 문구(정상 데이터)를 “중복”으로 잘못 판단하면 recall이 깨집니다.
🔧 핵심 개념
1) Dedup의 3계층: exact / near / semantic
- Exact dedup: 문서 전체를 normalize한 뒤 hash(md5/sha256 등)로 동일성 판단. RedPajama-V2 파이프라인도 “quality signals 계산 → dedup”을 주요 단계로 잡고, exact dedup을 Bloom filter 기반으로 설명합니다.3
- Near-duplicate(fuzzy) dedup: “거의 같은데 약간만 다른” 문서를 제거. 주류가 MinHash + LSH(Locality Sensitive Hashing) 입니다. NeMo Curator 문서도 문서 단위에서 Jaccard similarity 기반 fuzzy dedup을 수행하며, MinHash+LSH의 근사성 때문에 bucket 내에서 false positive check(선택적 재검증) 를 제공한다고 명시합니다.4
- Semantic dedup: embedding 유사도(ANN index)로 의미 중복 제거. NeMo Curator는 “Semantic (GPU-accelerated)”를 기능으로 내세웁니다.5
단, semantic dedup은 “정답이 없는” 영역이라 임계값/도메인별 정책이 중요합니다.
2) MinHash가 왜 표준이 됐나 (구조/흐름)
MinHash는 “문서를 n-gram(shingle) 집합으로 보고, 두 집합의 Jaccard similarity 를 근사”합니다. 실전 흐름은 대체로 이렇게 갑니다(NeMo Curator/FineWeb류 파이프라인에서 반복 등장):
- Normalize: HTML boilerplate 제거, whitespace/유니코드 정규화, 케이스 처리, 숫자/URL 처리 정책 결정
- Shingling: char n-gram 또는 token n-gram으로 문서를 조각냄
- MinHash signature 생성: k개의 hash로 “문서 지문”을 만듦
- LSH bucketing: signature를 band로 나눠 비슷한 것끼리 같은 bucket에 들어가게 함(근사 후보 생성)
- (옵션) 재검증: bucket 내 pair에 대해 실제 Jaccard를 다시 계산해 false positive를 제거(NeMo Curator가 이 옵션을 문서화)4
- Connected components: “A~B 유사, B~C 유사면 A,B,C를 한 덩어리”로 묶고 대표만 남김(NeMo Curator가 GPU connected components를 언급)4
여기서 중요한 차이점:
- “pairwise 비교”는 O(N²)로 망하지만, LSH가 후보를 줄여서 웹 규모에서도 가능해집니다.
- connected components는 “유사 관계의 전이”를 처리해 주지만, 임계값이 느슨하면 거대 컴포넌트(모든 게 연결) 가 생겨 과삭제 위험이 있습니다.
3) “Dedup = 품질”이 되려면: Quality signals와 결합
RedPajama-V2는 raw web 문서에 대해 quality signals/metadata를 제공해 필터링을 가능하게 하는 ‘풀(pool)’ 개념을 강조합니다.6
DataComp-LM(DCLM)도 “고정된 모델로 최고의 데이터를 찾는” 벤치마크로, baseline 분석에서 model-based filtering이 핵심이라고 요약합니다.7
즉 실무에서는:
- dedup만 단독으로 하지 말고,
- quality filter(heuristic + model-based) → dedup(정책적) → 최종 샘플링/믹싱 순서와 목적함수를 같이 설계해야 합니다.
💻 실전 코드
현실 시나리오: CommonCrawl/크롤링 텍스트 JSONL(수천만 문서) 를 “품질 필터 + MinHash near-dedup + (옵션) semantic dedup”으로 정제해서 학습용 JSONL로 내보내기.
아래 코드는 (1) CPU 환경에서 먼저 “재현 가능한 파이프라인”을 만들고, (2) 병목이 보이면 NeMo Curator 같은 GPU 경로로 갈 수 있게 설계합니다.
0) 의존성/실행
1
2
3
4
5
python -m venv .venv
source .venv/bin/activate
pip install orjson regex tqdm datasketch xxhash sentence-transformers faiss-cpu
# 대규모면: pip install duckdb pyarrow 도 추천
1) 초기 셋업: 스트리밍 품질 필터 + exact dedup
- 중요: exact dedup은 “정규화가 80%”입니다. normalize 정책이 바뀌면 hash가 바뀌니, 버전/설정값을 메타데이터로 남기세요.
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
# curate_step1_exact_and_quality.py
import re, unicodedata, hashlib, orjson
from tqdm import tqdm
WS = re.compile(r"\s+")
CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
def normalize_text(t: str) -> str:
t = unicodedata.normalize("NFKC", t)
t = CTRL.sub(" ", t)
t = WS.sub(" ", t).strip()
return t
def quality_gate(t: str) -> bool:
# 현실적인 휴리스틱(예: 너무 짧거나, 링크/보일러플레이트 과다 등)
if len(t) < 400: # pretraining 문서 기준(프로젝트에 맞게 조정)
return False
alpha = sum(ch.isalpha() for ch in t)
if alpha / max(1, len(t)) < 0.30:
return False
return True
def doc_hash(t: str) -> str:
return hashlib.sha1(t.encode("utf-8")).hexdigest()
def run(in_jsonl: str, out_jsonl: str):
seen = set()
kept = 0
total = 0
with open(in_jsonl, "rb") as f_in, open(out_jsonl, "wb") as f_out:
for line in tqdm(f_in, desc="step1"):
total += 1
obj = orjson.loads(line)
text = normalize_text(obj["text"])
if not quality_gate(text):
continue
h = doc_hash(text)
if h in seen:
continue
seen.add(h)
obj["text"] = text
obj["norm_hash"] = h
f_out.write(orjson.dumps(obj) + b"\n")
kept += 1
print(f"total={total} kept={kept} exact_dedup_kept_ratio={kept/max(1,total):.3f}")
if __name__ == "__main__":
run("raw.jsonl", "step1_clean_exact.jsonl")
예상 출력(형태):
exact_dedup_kept_ratio가 너무 낮으면 normalize가 과격하거나 입력이 중복 투성이입니다.- 너무 높으면(거의 1.0) exact 중복은 없고, 문제는 near-dup/semantic 쪽일 가능성이 큽니다.
2) 기본 동작: MinHash(near-dup) + connected components 제거
대규모에서 핵심은 “모든 pair를 비교하지 않는 것”이며, MinHash+LSH는 이 목적에 맞습니다. NeMo Curator도 같은 구조(LSH bucketing 후 optional Jaccard 재검증, 그래프 컴포넌트)를 문서화합니다.4
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
# curate_step2_minhash_dedup.py
import orjson
from tqdm import tqdm
from datasketch import MinHash, MinHashLSH
import xxhash
def shingles(text: str, n: int = 5):
# char n-gram(언어/도메인에 따라 token n-gram이 더 나을 수 있음)
t = text
for i in range(0, max(0, len(t) - n + 1)):
yield t[i:i+n]
def make_minhash(text: str, num_perm: int = 128) -> MinHash:
m = MinHash(num_perm=num_perm, hashfunc=lambda b: xxhash.xxh64(b).intdigest())
for sh in shingles(text, 5):
m.update(sh.encode("utf-8"))
return m
def run(in_jsonl: str, out_jsonl: str, threshold: float = 0.85):
# threshold는 “얼마나 공격적으로 near-dup을 제거할지”의 핵심 knob
lsh = MinHashLSH(threshold=threshold, num_perm=128)
kept = []
with open(in_jsonl, "rb") as f:
for idx, line in enumerate(tqdm(f, desc="minhash")):
obj = orjson.loads(line)
mh = make_minhash(obj["text"])
dup_ids = lsh.query(mh)
if dup_ids:
# 이미 존재하는 near-dup이 있으면 스킵(가장 단순한 정책)
continue
doc_id = str(idx)
lsh.insert(doc_id, mh)
kept.append(obj)
with open(out_jsonl, "wb") as f_out:
for obj in kept:
f_out.write(orjson.dumps(obj) + b"\n")
print(f"kept={len(kept)}")
if __name__ == "__main__":
run("step1_clean_exact.jsonl", "step2_near_dedup.jsonl", threshold=0.85)
현업에서는 여기서 더 나아가야 합니다:
- “첫 등장만 남기기”는 간단하지만, 대표 선택 정책(길이, 품질 점수, 최신성 등)으로 바꾸면 품질이 확 올라갑니다.
- connected components까지 구현하면 “전이 중복”까지 정리되지만, 그만큼 과삭제 위험도 커집니다(임계값 설계가 더 중요).
3) 확장: semantic dedup(옵션)으로 “의미상 반복” 제어
NeMo Curator/CuratorKIT 같은 프레임워크는 embedding 기반 dedup을 옵션으로 둡니다.5
아래는 “고품질 문서만 대상으로” semantic dedup을 얹는 패턴입니다(전체에 걸면 비용 폭발).
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
# curate_step3_semantic_dedup.py
import orjson, numpy as np
from tqdm import tqdm
from sentence_transformers import SentenceTransformer
import faiss
def run(in_jsonl: str, out_jsonl: str, sim_threshold: float = 0.92):
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
texts, objs = [], []
with open(in_jsonl, "rb") as f:
for line in f:
obj = orjson.loads(line)
objs.append(obj)
texts.append(obj["text"][:2000]) # 비용 절감: 앞부분만(정책 필요)
emb = model.encode(texts, batch_size=128, normalize_embeddings=True, show_progress_bar=True)
emb = np.asarray(emb, dtype="float32")
index = faiss.IndexFlatIP(emb.shape[1]) # cosine == inner product (normalized)
index.add(emb)
keep = np.ones(len(objs), dtype=bool)
for i in tqdm(range(len(objs)), desc="semantic"):
if not keep[i]:
continue
D, I = index.search(emb[i:i+1], 10) # top-k 후보
for score, j in zip(D[0], I[0]):
if j == i:
continue
if score >= sim_threshold:
keep[j] = False # i를 대표로 남김(대표 정책은 프로젝트에 맞게)
with open(out_jsonl, "wb") as f_out:
for k, obj in zip(keep, objs):
if k:
f_out.write(orjson.dumps(obj) + b"\n")
print(f"kept={int(keep.sum())} / {len(objs)}")
if __name__ == "__main__":
run("step2_near_dedup.jsonl", "final.jsonl", sim_threshold=0.92)
⚡ 실전 팁 & 함정
Best Practice (바로 적용 가능한 것만)
1) “Dedup 임계값”을 고정값으로 박지 말고, 샘플링 기반으로 튜닝
- threshold(예: 0.80/0.85/0.90)에 따라 과삭제/미삭제가 급변합니다.
- FineWeb/RedPajama 같은 공개 파이프라인들도 설계 선택을 ablation으로 검증하는 방향을 취합니다.1
2) Quality filtering → Dedup 순서를 기본으로
- 저품질(키워드 나열/광고/깨진 텍스트)은 “유니크”일 확률이 높습니다.
- 먼저 quality gate로 바닥을 쳐주고, 그 다음에 dedup을 해야 “유니크한 쓰레기만 남는” 현상을 줄일 수 있습니다(실제 FineWeb 분석에서도 dedup/필터링 설계가 분포를 바꿀 수 있음을 강조).2
3) Compute budget과 curation을 같이 최적화
- “필터를 세게 해서 더 좋은 데이터만”이 항상 이득은 아닙니다. DCLM/관련 연구 흐름에서는 모델/컴퓨트 스케일에 따라 최적 필터링이 달라진다는 문제의식을 강하게 제기합니다.8
흔한 함정/안티패턴
- 전처리 버전 관리 부재: normalize 정책이 바뀌면 dedup 결과가 재현 불가. 결과적으로 “왜 성능이 올랐지/내렸지?”가 설명이 안 됩니다.
- 전역(global) dedup만 맹신: snapshot 간 전역 dedup은 좋지만, 도메인별(뉴스/법률/코드/포럼) 분포가 다른데 같은 정책을 쓰면 손실이 큽니다.
- semantic dedup을 무턱대고 전체에 적용: 비용 폭발 + 의미 유사도는 도메인마다 “중복” 정의가 달라 과삭제로 이어짐.
비용/성능/안정성 트레이드오프
- CPU MinHash는 싸지만 규모가 커지면 느립니다. GPU 가속을 원하면 NeMo Curator는 GPU MinHash/connected components/false positive check까지 제공하며, cuDF 백엔드 제약 같은 운영 포인트가 있습니다.4
- semantic dedup은 “적은 양의 고가치 데이터”에만 적용하면 ROI가 좋고, 웹 전체에는 보통 과합니다.
🚀 마무리
정리하면, 2026년 현재의 “정석”은 MinHash(near-dup) + quality filtering + (필요 시) semantic dedup을 하나의 파이프라인으로 보고, 임계값/대표 선택/도메인별 정책을 실험으로 결정하는 접근입니다. FineWeb은 dedup/필터링 설계를 공개적으로 문서화하며, RedPajama-V2는 quality signals를 제공해 “필터링 가능한 풀”을 만드는 방향을 제시합니다.1
도입 판단 기준(프로젝트 체크리스트):
- 내 데이터가 “스냅샷/소스 병합” 구조인가? → near-dup는 거의 필수
- 학습이 특정 템플릿/문장에 과적합하는가? → semantic/서브도큐먼트 dedup까지 검토
- 필터를 강하게 했더니 오히려 이상한 문서가 늘었는가? → quality→dedup 순서/임계값 재튜닝
다음 학습 추천: