왜 중복 인용이 문제인가
RAG 파이프라인에서 검색 결과 상위 k개를 그대로 LLM에 넘기면, 의미가 거의 같은 청크가 여러 개 섞여 들어오는 경우가 흔하다. 사내 위키의 복붙 문단, 문서 버전 차이, 청킹 경계가 겹친 조각들이 대표적이다. 이 near-duplicate(근접 중복)들은 세 가지 비용을 만든다. 첫째, 동일 내용이 컨텍스트 윈도우를 차지해 실제로 필요한 다른 근거가 밀려난다. 둘째, LLM이 "여러 소스가 같은 말을 한다"고 오인해 근거 없는 확신을 갖는다. 셋째, 인용 각주에 같은 내용이 [1][3][5]처럼 중복 표기되어 사용자 신뢰를 떨어뜨린다.
핵심은 검색 단계에서 relevance만 최적화하면 diversity가 무너진다는 점이다. 벡터 유사도가 높은 문서끼리는 서로도 유사하기 때문에, top-k가 한 주제로 쏠린다.
중복의 종류부터 구분한다
대응 전략이 다르므로 먼저 유형을 나눈다.
| 유형 | 판별 신호 | 대응 |
|---|---|---|
| 완전 중복(exact) | 정규화 후 해시 일치 | 색인 단계에서 제거 |
| 근접 중복(near-dup) | MinHash/SimHash 유사 | 색인·검색 단계 병합 |
| 의미 중복(semantic) | 임베딩 코사인 높음 | 검색 후 MMR/클러스터링 |
색인 단계: MinHash로 사전 제거
표면 텍스트가 거의 같은 청크는 애초에 색인에 넣지 않는 게 가장 싸다. LSH로 후보를 O(n)에 가깝게 좁힌다.
from datasketch import MinHash, MinHashLSH
def shingles(text, k=5):
t = "".join(text.split())
return {t[i:i+k] for i in range(len(t)-k+1)}
def make_mh(text, num_perm=128):
mh = MinHash(num_perm=num_perm)
for s in shingles(text):
mh.update(s.encode())
return mh
lsh = MinHashLSH(threshold=0.85, num_perm=128)
kept = []
for cid, chunk in enumerate(chunks):
mh = make_mh(chunk)
if lsh.query(mh): # 이미 유사한 청크 존재
continue # 스킵(대표만 유지)
lsh.insert(str(cid), mh)
kept.append((cid, chunk))
threshold는 0.8~0.9 사이에서 문서 특성에 맞춰 조정한다. 너무 낮추면 서로 다른 내용까지 병합된다.
검색 단계: MMR로 의미 중복 걷어내기
색인에서 못 거른 의미 중복은 검색 후 처리한다. MMR(Maximal Marginal Relevance)은 질의 관련성과 이미 선택된 문서와의 차별성을 함께 본다.
import numpy as np
def mmr(query_vec, doc_vecs, k=5, lamb=0.7):
sim_q = doc_vecs @ query_vec
selected, cand = [], list(range(len(doc_vecs)))
while cand and len(selected) < k:
if not selected:
best = max(cand, key=lambda i: sim_q[i])
else:
S = doc_vecs[selected]
best = max(cand, key=lambda i:
lamb * sim_q[i]
- (1 - lamb) * float((doc_vecs[i] @ S.T).max()))
selected.append(best)
cand.remove(best)
return selected
lambda를 1.0에 두면 순수 관련성, 0.5 근처면 다양성이 강해진다. 실무에서는 0.6~0.8이 무난하다.
인용 병합과 근거 보존
near-dup을 하나로 합치더라도, 원본 문서 ID는 모두 보존해야 한다. 대표 청크 하나만 컨텍스트에 넣되, 그 청크가 대표하는 원본 목록을 메타데이터로 들고 다닌다. 그래야 최종 답변에서 "이 근거는 문서 A, C, F에 동일하게 존재"라는 식으로 정확히 한 번만 인용하면서도 출처 커버리지를 잃지 않는다.
-- 대표 청크와 원본 매핑을 분리 저장
CREATE TABLE dup_group (
rep_chunk_id BIGINT, -- 컨텍스트에 넣는 대표
member_doc_id BIGINT, -- 실제 원본 문서
sim REAL
);
-- 답변 후 인용 집계: 대표 기준으로 dedup
SELECT rep_chunk_id, array_agg(member_doc_id) AS sources
FROM dup_group GROUP BY rep_chunk_id;
주의점
병합에는 실질적 위험이 있다. 첫째, 버전이 다른 문서를 near-dup으로 합치면 최신 정보가 아닌 대표가 선택될 수 있다. 대표 선정 시 최신 갱신일이나 신뢰도 점수를 tie-breaker로 넣어야 한다. 둘째, 숫자·날짜 한 곳만 다른 문서는 표면 유사도가 매우 높지만 의미가 완전히 다르다. 이런 필드는 병합 전 별도 비교 규칙으로 예외 처리한다. 셋째, threshold를 전역 상수로 고정하면 도메인별로 오작동한다. 로그/법률/코드처럼 반복 구조가 많은 코퍼스는 별도 값을 쓴다.
마지막으로 병합 결정은 로깅해 두는 것이 좋다. 어떤 문서가 왜 대표에서 밀렸는지 추적할 수 없으면, 잘못된 병합이 조용히 답변 품질을 갉아먹는다.