본문으로 건너뛰기
신승엽
AI Research Engineer, Braincrew
모든 저자 보기

dupey: 본문 기반 문서 중복 및 버전 판별

· 약 11분
신승엽
AI Research Engineer, Braincrew

dupey가 문서 본문을 추출한 뒤 exact, near, contains 관계로 문서군과 최신본 후보를 만드는 흐름

공유 폴더에서 제안서_최종.docx, 제안서_최종_수정.pdf, 제안서_진짜최종.hwpx를 발견했다고 하자. 어떤 파일이 같은 문서이고, 무엇이 수정본이며, 어느 것을 먼저 읽어야 할까?

파일명은 힌트일 뿐이다. 최종이라고 적힌 파일이 가장 최근에 수정됐다는 보장은 없고, 다른 형식으로 저장한 같은 문서는 이름도 확장자도 달라진다. 파일의 SHA-256 해시를 비교하면 완전히 같은 바이트만 찾을 수 있다. 워드 문서를 다시 저장하거나 PDF로 내보내는 순간 컨테이너 구조와 메타데이터가 달라져 해시도 달라진다.

dupey는 이런 문서 폴더에서 정확한 중복, 내용이 비슷한 버전, 한 문서가 다른 문서를 포함하는 관계를 찾는 로컬 Rust CLI다. 형식별로 본문을 추출한 뒤 비교 가능한 텍스트로 정규화하고, 문서군마다 최신본 후보와 판단 근거를 돌려준다.

  • GitHub: NomaDamas/dupey
  • 역할: 문서 내용을 기준으로 중복본, 수정본, 초안과 확장본의 관계를 찾는 CLI
  • 핵심 상황: "파일명과 확장자는 다른데 같은 문서 계열인지 확인해야 할 때"
  • 이 글에서 확인한 버전: 0.1.2
  • 라이선스: MIT
  • 실행 환경: Rust 1.91 이상
  • 지원 형식: TXT, Markdown, DOCX, HWP, HWPX, PPTX, XLSX, PDF

문서 프로젝트를 하다 보면 중복본과 유사본을 판별하는 일을 반복하게 된다. dupey는 이 문제를 따로 떼어낸 도구다. 파일명 규칙이나 임베딩 유사도에 기대지 않고, 본문에서 계산한 해시와 중복도 점수를 판정 근거로 남긴다. AutoRAG 2.3.0도 문서 인덱싱 단계에서 dupey CLI를 호출할 수 있다.

exact, near, contains

dupey는 문서 사이의 관계를 세 가지로 구분한다. 본문이 같으면 exact, 일부만 달라졌으면 near, 짧은 문서의 본문이 긴 문서에 들어 있으면 contains다.

exact: 정확한 중복

dupey는 형식별로 본문을 추출하면서 작성 시각이나 문서 식별자처럼 저장할 때 달라지는 메타데이터를 비교 대상에서 뺀다. 비교할 본문이 있으면 정규화한 본문의 SHA-256 해시를 계산한다. 파일 형식과 이름이 달라도 이 해시가 같으면 exact다.

이미지 전용 PDF처럼 본문을 추출하지 못한 파일은 원본 파일의 SHA-256 해시를 비교한다. 이때는 원본 바이트까지 같은 파일만 exact로 묶인다.

다만 문서의 서식, 이미지, 서명, 댓글, 변경 추적 기록까지 같다는 뜻은 아니다. 삭제나 보관 결정을 내리기 전에는 이 비텍스트 요소를 별도로 확인해야 한다.

두 점수의 차이

Jaccard는 두 문서가 함께 가진 5-gram 수를 양쪽의 고유한 5-gram 전체 수로 나눈 값이다. 1.0이면 두 문서의 5-gram 구성이 같고, 한쪽에만 있는 내용이 늘어날수록 낮아진다.

containment는 작은 문서의 5-gram 중 큰 문서에도 있는 비율이다. 1.0이면 작은 문서의 내용이 큰 문서에 모두 들어 있다는 뜻이다.

near: 일부 내용이 달라진 수정본

near는 본문을 다섯 글자씩 겹쳐 나눈 문자 5-gram 집합을 비교한다. dupey는 128개 값으로 구성된 MinHash 서명으로 비슷할 가능성이 있는 문서 쌍을 먼저 추린다. MinHash는 후보를 찾는 데만 쓰고, 최종 판정에서는 후보 쌍의 전체 5-gram으로 정확한 Jaccard 유사도를 다시 계산한다.

기본 설정에서는 Jaccard가 0.90 이상이면 near로 묶는다. 기존 본문의 대부분이 남아 있고 일부 문장만 추가되거나 바뀐 수정본이 여기에 해당한다.

여기서 비교하는 것은 의미가 아니라 어휘 중복도다. 임베딩을 사용하면 같은 주제를 다룬 서로 다른 문서도 가깝게 나올 수 있다. 버전 판별에서는 기존 문장이 얼마나 그대로 남았는지가 더 직접적이고 설명하기 쉬운 근거다.

contains: 초안과 확장본

contains는 방향이 있는 관계다. 짧은 초안의 본문이 긴 확장본에 거의 그대로 들어 있으면 긴 문서가 짧은 문서를 포함한다고 판정한다.

Jaccard는 교집합을 합집합으로 나누기 때문에 문서 길이 차이가 커질수록 낮아진다. 작은 문서의 5-gram 가운데 96% 이상이 큰 문서에 있고, 두 문서의 Jaccard도 0.40 이상이면 contains로 묶는다.

Jaccard 하한이 필요한 이유가 있다. 회사 공통 양식이 짧은 문서의 대부분을 차지하면, 내용이 다른 긴 보고서 여러 개가 같은 문서군으로 잘못 이어질 수 있다. 두 조건을 함께 적용해 이런 연결을 줄인다.

처리 흐름

파일을 스캔하면 아래 순서로 문서군을 만든다.

문서 파일
-> 형식별 본문 및 수정 시각 추출
-> 비교 가능한 텍스트 정규화
-> SHA-256 본문 해시 또는 빈 본문의 원본 바이트 해시
-> 문자 5-gram 및 MinHash 서명
-> exact / near / contains 관계 검증
-> 문서군 구성
-> 최신본 후보와 근거 반환

관계가 확인된 파일 쌍은 edge로 기록된다. 여러 edge가 연결된 파일들은 하나의 family가 된다. 한 family 안에 nearcontains 관계가 섞이면 전체 라벨은 mixed가 된다.

family는 직접 확인된 관계가 이어진 연결 요소다. A가 B와 비슷하고 B가 C를 포함하면 세 파일은 같은 family가 된다. 그렇다고 A와 C의 관계까지 직접 검증된 것은 아니다. 실제로 확인된 파일 쌍과 관계는 JSON 결과의 edges에 남는다.

최신 후보를 고르는 기준

dupey는 family 안에서 가장 늦게 수정된 파일을 최신 후보로 제시한다. 어떤 시각을 볼지는 파일 형식에 따라 달라진다.

  1. DOCX, HWPX, PDF 등에서 문서 내부 수정 시각을 추출할 수 있으면 그 값을 사용한다.
  2. 내부 시각이 없으면 파일시스템 수정 시각을 사용한다.

final, 최종, v3 같은 파일명이나 문서 길이, 포함 방향은 최신 후보를 고르는 기준에 넣지 않는다. 복사나 다운로드로 파일시스템 시각이 바뀌더라도 문서 내부 시각이 남아 있으면 그 값을 먼저 사용한다.

dupey는 가장 최근 파일을 확정본이 아닌 최신본 후보로 표시한다. 가장 늦은 시각이 하나뿐이면 점수 1.0과 신뢰도 0.9를 부여한다. 시각이 같거나 확인할 수 없으면 신뢰도는 0.5이고, 이때 정렬 순서는 최신성의 근거가 되지 못한다.

직접 만든 문서군으로 확인

세 관계가 실제로 구분되는지 보려고 서로 겹치지 않는 문서군 세 개를 만들었다. 같은 본문을 확장자만 달리한 exact, 한 문구만 바꾼 near, 초안 뒤에 새 내용을 붙인 contains를 하나씩 준비했다.

문서군파일만든 차이
보안 정책security-policy.txt, security-policy-copy.md확장자만 다르고 본문은 동일
제안서proposal-v1.md, proposal-v2.md긴 본문에서 갱신 주기 한 곳 변경
회의록meeting-draft.md, meeting-expanded.md초안 뒤에 장애 대응 논의 추가

최신 후보 선택도 함께 확인했다. TXT와 Markdown에는 문서 내부 수정 시각이 없어서 이번에는 파일시스템 수정 시각을 사용했다. 각 문서군에서 security-policy-copy.md, proposal-v2.md, meeting-expanded.md의 시각을 짝 파일보다 늦게 설정한 뒤 기본 임계값으로 폴더를 스캔했다.

dupey scan ./corpus

스캔 결과는 의도한 세 문서군으로 나뉘었다.

문서군판정JaccardContainment최신본 후보
보안 정책exact--security-policy-copy.md
제안서near0.9570.982proposal-v2.md
회의록contains0.6041.000meeting-expanded.md

보안 정책은 TXT와 Markdown이라는 형식 차이가 있어도 추출된 본문 해시가 같아 exact로 묶였다. 제안서는 한 문구만 바꾼 결과 정확한 Jaccard가 0.9565로 나와 기본 임계값 0.90을 넘었다.

회의록은 Jaccard가 0.6036이라 near 기준에는 미치지 못했다. 반면 초안의 5-gram이 확장본에 모두 들어 있어 containment가 1.0으로 계산됐고, contains 관계의 방향도 meeting-expanded.md에서 meeting-draft.md로 정확하게 나타났다.

세 family의 최신본 후보는 모두 더 늦게 설정한 파일시스템 수정 시각을 근거로 선택됐고 신뢰도는 0.9였다.

재현 메모: macOS arm64에서 Rust 1.94로 dupey 0.1.2를 소스 빌드했다. 저장소 테스트 84개를 통과한 뒤 UTF-8 텍스트와 Markdown 파일 6개로 세 문서군을 만들고, 파일시스템 수정 시각을 고정해 기본 임계값으로 스캔했다. 직접 만든 코퍼스는 관계 판별과 최신본 후보 선택만 확인했다. DOCX, HWP/HWPX, PPTX, XLSX, PDF 추출은 저장소의 upstream 테스트가 통과한 것만 확인했고, 형식을 섞은 별도 코퍼스에서는 재현하지 않았다. 이 결과는 대규모 저장소의 처리량이나 실제 조직 문서에서 발생하는 오탐률을 보여주는 벤치마크가 아니다.

특정 파일 쌍을 조사할 때는 compare로 중간 점수를 볼 수 있다.

dupey compare proposal-v1.md proposal-v2.md
exact_equal    false
near_score 0.9844
jaccard 0.9565
containment_a_in_b 0.9735
containment_b_in_a 0.9821

near_score는 MinHash 추정치이고 jaccard는 전체 5-gram 집합으로 다시 계산한 값이다. 최종 near 판정에는 정확한 Jaccard가 사용된다.

문서 수를 늘렸을 때

작은 문서군에서 관계가 맞게 나오는 것과 문서가 많은 폴더를 빠르게 훑는 것은 별개의 문제다. 저장소가 제공하는 합성 코퍼스 생성기로 TXT, Markdown, DOCX, HWPX를 섞은 폴더를 만들고, release 빌드의 scan --json을 세 번씩 실행했다.

폴더 안 파일처리된 파일전체 실행 시간최대 메모리본문 추출 및 서명관계 계산
1181170.01초16 MiB7.2ms2.3ms
1,0181,0170.06초34 MiB23.5ms30.9ms
10,01810,0170.60초191 MiB181.3ms389.2ms

표의 값은 세 번 실행한 중앙값이다. Apple M4 Pro 14코어, 메모리 48GB, macOS 26.4, Rust 1.94 환경에서 dupey 0.1.2를 사용했다. 코퍼스를 만든 직후 반복 실행해 운영체제 파일 캐시가 반영된 결과이며, 파일 생성 시간은 제외했다.

각 규모에서 한 파일이 적게 처리된 이유는 코퍼스에 추출 오류 확인용 PDF가 하나씩 들어 있기 때문이다. 10,018개 코퍼스의 실제 데이터는 약 7.4MB이고 파일 크기 중앙값은 817바이트였다. 작은 문서가 많은 폴더를 얼마나 빨리 훑는지는 보여주지만, 용량이 큰 PDF나 실제 조직 문서의 판별 정확도를 나타내는 결과는 아니다.

설치와 사용

Cargo가 설치된 환경에서는 한 줄로 설치할 수 있다.

cargo install dupey

사람이 읽을 요약과 기계가 처리할 JSON을 모두 제공한다.

dupey scan ./documents
dupey scan ./documents --json
dupey fingerprint ./documents/proposal.docx
dupey compare ./documents/proposal.docx ./documents/proposal-final.docx

기본 스캔은 .git, node_modules, target, dist, build 같은 폴더를 건너뛴다. 추가 폴더는 반복 가능한 --exclude-dir 옵션으로 제외할 수 있다.

에이전트가 결과를 해석하도록 Agent Skill도 함께 배포된다. exact, near, contains, mixed를 구분하고, 점수만 보고 파일을 지우지 않으며, 정리 전에 사용자 승인을 받도록 안내한다.

Agent Skill을 쓸 때는 한 가지 먼저 확인할 게 있다. 이 글 작성 시점의 SKILL.md는 사용자가 명시적으로 거부하지 않으면 인증된 GitHub 계정으로 dupey 저장소에 star를 추가하도록 지시한다. 문서 스캔과 관계없는 외부 상태 변경이므로, 원하지 않으면 해당 지시를 제거하거나 실행 전에 거부해야 한다. dupey CLI 자체에는 이 동작이 없다.

AutoRAG 인덱싱에 들어간 dupey

AutoRAG 2.3.0은 dupey를 검토용 스캔과 인덱싱 전 필터, 두 경로에 연결했다. 참고로 dupey는 별도 설치가 필요하며 AutoRAG 패키지에는 포함되지 않는다.

autorag duplicates ./documents
autorag duplicates ./documents --json

사용자가 위 명령으로 폴더를 검사할 수 있고, 라이브러리언 에이전트도 scan_duplicate_documents 도구로 같은 스캔을 실행할 수 있다. 두 경로 모두 exact, near, contains 문서군과 추출 오류를 보고할 뿐 원본 파일을 옮기거나 지우지 않는다.

자동 제외는 refresh에서 parsed mirror를 갱신하기 직전에 일어난다.

autorag refresh
-> searchPaths마다 dupey scan --json
-> content_hash가 같은 exact 그룹 구성
-> 파일시스템 수정 시각이 가장 늦은 파일 한 개 선택
-> 이전 복사본을 parsed mirror에서 제외
-> BM25와 MinSync 인덱스 갱신

같은 본문이 여러 파일에 들어 있어도 검색 인덱스에는 대표본 하나만 남는다. AutoRAG가 고르는 대표본은 dupey의 최신 후보와 기준이 다르다. 현재 필터는 문서 내부 시각이 아니라 파일시스템 수정 시각만 사용한다.

nearcontains는 자동 제외 근거로 사용하지 않는다. 한 줄이 다른 수정본이나 초안과 확장본에는 서로 다른 정보가 있을 수 있기 때문이다. 에이전트와 사용자가 문서군을 검토할 수 있도록 관계와 근거만 제공한다.

dupey 실행 파일이 없거나 스캔이 실패하면 refresh는 중복 제외 없이 계속된다. excludeExactDuplicates를 끄면 dupey가 설치돼 있어도 모든 복사본을 인덱싱할 수 있다.

이 경로도 직접 확인했다. 같은 본문을 가진 older.txtnewest.txt를 만들고 후자의 파일시스템 수정 시각을 더 늦게 설정했다. CLI는 exact 그룹 하나를 찾았고, 에이전트 도구도 중복본 한 개를 보고했다. refresh 뒤 parsed mirror에는 newest.txt만 남고 older.txt는 제외됐다.

기존 연재에서 다룬 도구들과 놓고 보면 역할이 더 분명해진다.

  • agentdir는 원본을 건드리지 않고 에이전트가 볼 파일 구조를 만든다.
  • Jikji는 자연어 단서로 읽을 파일 후보를 좁힌다.
  • MinSync는 변경된 청크만 다시 임베딩해 vector index를 최신 상태로 유지한다.
  • dupey는 인덱싱 전에 같은 문서 계열을 찾아 불필요한 중복과 검토 대상을 구분한다.

현재 AutoRAG 코드에는 Jikji, MinSync, dupey가 직접 연결되어 있다. agentdir는 runtime에 연결되지는 않지만 원본과 에이전트용 view를 분리한다는 설계 관점을 공유한다.

도입 전에 알아둘 점

  • 이미지로만 된 스캔 PDF에는 비교할 본문이 없다. 텍스트 유사도 비교 대상에서는 빠지지만, 원본 바이트까지 같은 복사본은 exact로 묶인다.
  • 비교 가능한 본문이 있는 파일의 exact는 추출 본문 기준이다. 서식, 이미지, 서명, 댓글, 변경 추적 기록의 동일성을 보장하지 않는다.
  • near는 의미적 유사도를 찾지 않는다. 문장을 크게 바꿔 같은 의미만 남긴 문서는 놓칠 수 있다.
  • 최신본 후보는 수정 시각에 의존한다. 잘못된 내부 시각이나 복사로 바뀐 파일시스템 시각은 사람이 다시 확인해야 한다.
  • family는 연결 요소다. 제거 여부를 판단할 때는 family 라벨보다 각 edge의 직접 근거를 봐야 한다.
  • dupey는 파일을 업로드하거나 삭제하지 않는다. 정리 작업은 결과를 검토한 뒤 별도로 수행한다.

정리

공유 폴더에서 판별하기 어려운 것은 바이트가 같은 복사본보다 내용이 조금씩 달라진 문서들이다. 같은 내용을 다른 형식으로 저장한 파일, 한두 문장을 고친 수정본, 초안이 확장된 최종안은 서로 다른 관계다.

dupey는 형식별 본문 추출과 두 종류의 중복도 기준으로 이 관계를 exact, near, contains로 나눈다. 최신본 후보도 파일명 대신 문서 내부 또는 파일시스템 수정 시각을 근거로 제시한다. 직접 만든 세 문서군에서도 형식이 다른 동일 본문, 한 문구를 바꾼 수정본, 내용이 추가된 확장본을 각각 구분했다.

결과를 곧바로 삭제 목록으로 쓰지는 말아야 한다. near인 파일에는 서로 다른 정보가 남아 있을 수 있고, 가장 최근 시각의 파일이 업무상 최종 승인본이라는 보장도 없다. dupey는 검토할 문서군과 확인 가능한 근거까지 제공한다.

References