문서 중복도 측정으로 AI가 쓴 글이 서로 얼마나 겹치는지 재보기 (n-gram 유사도)

AI에게 블로그 글을 맡기다 보면 어느 순간 이상한 기시감이 듭니다. 주제는 분명히 다른데 도입부 흐름이 똑같고, 예제 코드의 변수명까지 비슷합니다. 이 블로그도 글이 57편을 넘어가면서 같은 고민이 생겼습니다. 문제는 사람 눈으로는 이걸 확인할 수가 없다는 겁니다. 57편이면 서로 비교해야 할 조합이 1,596쌍입니다. 한 쌍을 1분씩만 봐도 26시간이 넘습니다. 그래서 이번 글에서는 문서 중복도 측정을 직접 코드로 해봅니다. 설치 없이 되는 방법부터, n-gram 유사도로 폴더 전체를 한 번에 훑는 스크립트까지 순서대로 만들어 보겠습니다.

왜 눈대중으로는 안 되는가

중복은 보통 “복사해 붙여넣은 문단”처럼 눈에 띄는 형태로 오지 않습니다. 진짜 문제는 구조의 반복입니다. 상황 묘사 → 수치로 손해 환산 → 가장 쉬운 방법 → 전용 도구 → 주의사항. 이 뼈대가 똑같으면 소재가 달라도 독자는 “아까 그 글”이라고 느낍니다.

검색엔진 쪽 손해도 있습니다. 한 사이트 안에서 비슷한 글이 여러 편이면 같은 검색어를 두고 내 글끼리 경쟁하게 됩니다. 어느 쪽도 확실히 올라가지 못하는 상태가 되는데, 애널리틱스만 봐서는 원인이 보이지 않습니다. 중복도를 숫자로 뽑아두면 “이 두 편은 합치는 게 낫다”는 판단을 감이 아니라 근거로 내릴 수 있습니다.

1단계: 설치 없이, 두 글만 비교해 보기

파이썬 표준 라이브러리 difflib만으로 바로 됩니다. 아무것도 설치할 필요가 없습니다.

from difflib import SequenceMatcher
from pathlib import Path

a = Path("posts/ai-json-parsing.md").read_text(encoding="utf-8")
b = Path("posts/ai-regex-guide.md").read_text(encoding="utf-8")

# autojunk=False가 중요합니다. 기본값 True면 2,000자가 넘는 글에서
# "자주 나오는 문자"를 쓰레기 취급해 비교에서 빼버립니다.
# 한국어 글은 조사와 공백이 바로 그 대상이라 유사도가 실제보다 낮게 나옵니다.
sm = SequenceMatcher(None, a, b, autojunk=False)
print("유사도 %.3f" % sm.ratio())

여기서 autojunk=False를 빼먹으면 안 됩니다. SequenceMatcher는 시퀀스 길이가 200을 넘으면 “전체의 1% 넘게 등장하는 요소”를 자동으로 비교에서 제외합니다. 영어 소스코드 비교에는 쓸만한 최적화지만, 한국어 산문에서는 공백과 흔한 조사가 통째로 빠져버려서 유사도가 실제보다 낮게 나옵니다.

이 방법의 한계는 속도입니다. 글자 단위로 비교하기 때문에 글이 길어질수록 급격히 느려집니다. 두세 편을 들여다볼 때는 충분하지만, 수십 편을 전부 비교하는 용도로는 쓰기 어렵습니다.

2단계: n-gram 자카드 유사도 — 측정의 핵심

글을 연속된 단어 n개짜리 조각들의 집합으로 바꿔놓고, 두 집합이 얼마나 겹치는지 보는 방식입니다. “작업 스케줄러에 등록하면”이라는 문장은 n=3일 때 작업 스케줄러에 등록하면 같은 조각들로 쪼개집니다. 단어 하나하나가 아니라 순서까지 같은 덩어리를 세기 때문에, 단순히 같은 단어를 많이 쓴 글과 진짜로 비슷하게 쓴 글이 구분됩니다.

import re

BOILERPLATE = (
    "이번 글에서는",
    "다음 글에서는",
    "마무리하겠습니다",
)

def normalize(text):
    """비교에 방해되는 것들을 먼저 걷어냅니다."""
    text = re.sub(r"```.*?```", " ", text, flags=re.S)   # 코드 블록
    text = re.sub(r"https?://\S+", " ", text)             # URL
    for phrase in BOILERPLATE:
        text = text.replace(phrase, " ")                 # 매 글에 들어가는 상용구
    text = re.sub(r"[^0-9A-Za-z가-힣 ]", " ", text)       # 문장부호·마크다운 기호
    return re.sub(r"\s+", " ", text).strip()

def ngrams(text, n=3):
    """단어 n개짜리 조각들의 집합. 순서가 같아야 한 조각이 됩니다."""
    words = normalize(text).split()
    if len(words) < n:
        return set()
    return {" ".join(words[i:i + n]) for i in range(len(words) - n + 1)}

def scores(ga, gb):
    if not ga or not gb:
        return 0.0, 0.0
    inter = len(ga & gb)
    jaccard = inter / len(ga | gb)          # 전체 대비 겹친 비율
    containment = inter / min(len(ga), len(gb))  # 짧은 글이 긴 글에 묻힌 비율
    return jaccard, containment

두 가지 점수를 같이 계산하는 이유가 있습니다. 자카드는 길이 차이에 취약합니다. 500자짜리 글이 5,000자짜리 글에 통째로 들어가 있어도 자카드는 0.1 근처로 낮게 나옵니다. 짧은 쪽을 기준으로 삼는 포함도(containment)를 함께 보면 이런 경우가 잡힙니다.

상용구를 미리 걷어내는 것도 중요합니다. 모든 글 끝에 “다음 글에서는…”을 붙이는 패턴이 있다면, 그 문구 때문에 전혀 무관한 글끼리도 기본 점수가 깔립니다.

3단계: 폴더 전체를 한 번에 훑기

이제 조각 집합만 미리 만들어 두면 모든 쌍을 비교하는 건 순식간입니다. 집합 연산은 글자 비교와 달리 거의 비용이 들지 않습니다.

from itertools import combinations
from pathlib import Path

DOCS = Path("posts")
THRESHOLD = 0.05

texts = {f.name: f.read_text(encoding="utf-8") for f in sorted(DOCS.glob("*.md"))}
grams = {name: ngrams(text) for name, text in texts.items()}

rows = []
for a, b in combinations(grams, 2):
    jaccard, containment = scores(grams[a], grams[b])
    if jaccard >= THRESHOLD:
        rows.append((jaccard, containment, a, b))

print("자카드  포함도  문서쌍")
for jaccard, containment, a, b in sorted(rows, reverse=True):
    print("%.3f   %.3f   %s  <->  %s" % (jaccard, containment, a, b))

print("총 %d쌍 비교, %d쌍이 임계값 초과" % (len(grams) * (len(grams) - 1) // 2, len(rows)))

57편 기준으로 1,596쌍을 비교하는 데 체감상 1초가 걸리지 않습니다. 앞의 difflib 방식으로 같은 일을 하면 몇 분 단위로 넘어갑니다.

자카드  포함도  문서쌍
0.214   0.389   ai-regex-guide.md  <->  ai-sql-query.md
0.118   0.205   ai-json-parsing.md  <->  ai-sql-query.md
0.061   0.094   venv-setup.md  <->  pip-install-errors.md
총 1596쌍 비교, 3쌍이 임계값 초과

출력에서 볼 지점은 자카드와 포함도가 같이 높은 쌍입니다. 위 예시의 첫 줄은 포함도가 0.389로, 짧은 쪽 글의 40% 가까이가 다른 글에 그대로 들어 있다는 뜻입니다. 이건 실제로 손봐야 하는 쌍입니다.

어느 숫자부터 손을 봐야 하나

절대적인 기준선은 없습니다. 다만 IT 팁 글처럼 용어가 반복될 수밖에 없는 장르에서, 단어 3-gram 기준으로 운영하며 잡은 대략의 감각은 이렇습니다.

자카드포함도해석대응
0.00 ~ 0.030.1 미만사실상 무관한 글그냥 둡니다
0.03 ~ 0.080.1 ~ 0.2같은 분야라 용어가 겹치는 수준내부 링크로 연결해 두면 오히려 이득
0.08 ~ 0.150.2 ~ 0.35구성이 닮았음. 도입부나 예제가 재탕일 가능성겹친 구간을 직접 확인
0.15 이상0.35 이상사실상 같은 글의 변주한 편으로 합치거나 한쪽을 다시 씀

이 표는 출발점일 뿐입니다. 자기 글 열 편 정도를 돌려보고, 이미 “비슷하다”고 알고 있던 쌍이 몇 점으로 나오는지 확인해서 임계값을 조정하는 편이 정확합니다.

겹친 부분을 눈으로 확인하기

점수가 높게 나온 쌍은 실제로 어디가 겹쳤는지 봐야 고칠 수 있습니다. 이때는 다시 difflib가 유용합니다. 전체 쌍이 아니라 걸러진 몇 쌍에만 쓰면 되니 속도도 문제되지 않습니다.

from difflib import SequenceMatcher

def common_chunks(a, b, min_len=40):
    """두 글에 똑같이 들어 있는 긴 구간만 뽑아냅니다."""
    sm = SequenceMatcher(None, a, b, autojunk=False)
    found = []
    for block in sm.get_matching_blocks():
        if block.size >= min_len:
            found.append(a[block.a:block.a + block.size])
    return found

for chunk in common_chunks(a, b):
    print("-" * 50)
    print(chunk.strip())

40자 이상 똑같이 이어지는 구간만 뽑아 출력합니다. 돌려보면 대개 도입부 한두 문장, 그리고 주의사항 섹션이 걸립니다. AI가 같은 프롬프트 틀로 글을 쓸 때 가장 먼저 굳어지는 자리가 그곳입니다.

더 정확하게: TF-IDF 코사인 유사도

n-gram 자카드는 표현이 똑같은 재탕을 잘 잡습니다. 반대로 같은 내용을 다른 말로 풀어 쓴 경우에는 점수가 낮게 나옵니다. 이쪽까지 보고 싶다면 단어 가중치를 쓰는 TF-IDF 코사인 유사도를 얹습니다. pip install scikit-learn이 필요합니다.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

names = list(texts)
corpus = [normalize(texts[name]) for name in names]

# 한국어는 형태소 분석기 없이 단어를 자르면 조사 때문에 어긋납니다.
# char_wb(글자 단위 n그램)로 가면 분석기 설치 없이도 꽤 잘 맞습니다.
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), sublinear_tf=True)
matrix = vec.fit_transform(corpus)
sim = cosine_similarity(matrix)

for i, name in enumerate(names):
    pairs = sorted(
        ((sim[i][j], names[j]) for j in range(len(names)) if j != i),
        reverse=True,
    )
    score, closest = pairs[0]
    if score >= 0.35:
        print("%-32s 가장 가까운 글: %s (%.3f)" % (name, closest, score))

한국어 처리에서 analyzer="char_wb" 선택이 핵심입니다. 기본값인 단어 단위로 자르면 “스케줄러에”와 “스케줄러를”이 서로 다른 단어가 됩니다. 글자 2~4개 단위로 자르면 형태소 분석기를 설치하지 않고도 어미 변화를 상당 부분 흡수합니다.

세 방식 정리

방식설치속도잘 잡는 것놓치는 것
difflib SequenceMatcher불필요느림정확히 일치하는 긴 구간문장 순서만 바꾼 재탕
n-gram 자카드불필요빠름표현까지 닮은 재탕, 대량 비교다른 말로 풀어 쓴 같은 내용
TF-IDF 코사인scikit-learn보통주제·어휘가 겹치는 글짧은 글, 고유명사 위주 글

실전에서는 n-gram 자카드로 전체를 훑어 후보를 추리고, 추려진 몇 쌍만 difflib로 들여다보는 조합이 가장 손이 덜 갑니다. TF-IDF는 글이 쌓여서 “주제 분포”까지 보고 싶어질 때 추가하면 됩니다.

주의할 점

  • 코드 블록은 반드시 제외하세요. 같은 라이브러리를 다루는 글끼리는 import 구문과 보일러플레이트가 통째로 겹쳐서, 산문이 전혀 다른데도 점수가 치솟습니다.
  • n 값을 2로 내리지 마세요. 2-gram은 한국어에서 흔한 단어쌍("있습니다 그런데" 같은)이 과하게 잡혀 전부 비슷하게 나옵니다. 3이 기본값으로 무난하고, 더 엄격하게 보려면 4로 올립니다.
  • 숫자를 자동 삭제의 근거로 쓰지 마세요. 중복도는 “여기를 확인해 보라”는 신호일 뿐입니다. 글을 합칠지 다시 쓸지는 내용을 읽고 판단해야 합니다.
  • 발행 전에 돌리는 게 핵심입니다. 이미 색인된 글을 나중에 합치면 URL 정리와 리다이렉트까지 따라옵니다. 초안 단계에서 거르는 쪽이 훨씬 쌉니다.
  • 다른 사이트와의 중복은 이 방법으로 알 수 없습니다. 여기서 재는 건 어디까지나 내 글들 사이의 거리입니다.

AI에게 글을 맡길 때 같이 해두면 좋은 것

측정만으로는 중복이 줄지 않습니다. 측정 결과를 다음 글 작성 과정에 되먹이는 단계가 필요합니다. 가장 간단한 방법은 이렇습니다.

  1. 새 글 초안이 나오면 발행 전에 스크립트를 돌려 기존 글과의 최고 점수를 확인합니다.
  2. 임계값을 넘으면, 가장 가까운 글의 목차를 AI에게 같이 주면서 “이 글과 겹치지 않는 각도로 다시 구성해 달라”고 요청합니다.
  3. 겹친 구간이 도입부라면 그 문단만 직접 고쳐 씁니다. AI는 같은 프롬프트에서 같은 도입 패턴을 반복하는 경향이 있어, 지시만으로는 잘 바뀌지 않습니다.

중복을 없애는 것이 목표가 아닙니다. 같은 분야 글이라면 어느 정도 겹치는 게 자연스럽습니다. 목표는 “겹친 줄도 모르고 쌓이는 상태”에서 벗어나는 것입니다.

마무리

가장 작은 첫걸음은 2단계의 함수 세 개를 파일 하나에 넣고, 이미 비슷하다고 느끼던 글 두 편을 넣어보는 것입니다. 거기서 나온 숫자가 본인 기준의 출발점이 됩니다. 폴더 전체 스캔은 그다음에 붙여도 늦지 않습니다.

다음 글에서는 예약 발행이 엉뚱한 시각에 나가는 문제를 다룹니다. 워드프레스와 서버의 타임존이 어긋났을 때 9시에 걸어둔 글이 왜 자정에 공개되는지, 어디를 확인해야 하는지 짚어 보겠습니다.

댓글 남기기