개발 취재 노트

AI 콘텐츠 파이프라인이 거의 같은 주제를 매번 다시 웹 검색할 때 (발행 글·같은 배치 중복 걸러내기)

주제를 뽑고 웹 조사·집필·모델 심사를 거쳐 통과한 글을 저장하는 자동 콘텐츠 생성 파이프라인(content-loop-mvp)을 손보던 중이었다 . 여기서 구현 보고가 “발행 글”이라고 부르는 것은 저장소의 비교 대상 글이며, 외부 블로그 자동 발행까지 확인한 것은 아니다 . 최근 실행에서 통과한 글은 2개, 폐기는 309개였고, 폐기 목록에는 같은 소재의 표현만 바꾼 주제가 계속 나왔다 . 이 글이 다루는 범위는 발행된 글 및 같은 배치에서 이미 통과한 주제와 비슷한 후보를 웹 조사 전에 거르는 것이다. 이전 실행에서 조사까지 하고 폐기된 주제를 다음 실행에서 다시 조사하지 않게 막는지는 기록으로 확인하지 못했다.

결론

중복 검사는 가장 싼 단계(주제 선정) 에 둔다. 웹 조사·집필·심사를 다 돌린 뒤 "이미 쓴 주제네"라며 버리면 그 호출은 전부 날아간다.

이 사례의 파이프라인(discover → research → write → review)은 주제 점수 게이트 바로 뒤, research 호출 전에 제목 유사도 게이트를 넣었다고 보고했다 . 보고된 구성은 이렇다.

비교 대상은 발행 글 + 같은 배치에서 앞서 통과한 후보뿐이다. 이전 실행에서 폐기된 주제는 비교 목록에 들어간다는 보고가 없다 . 미확인: 그래서 폐기됐던 주제를 다음 실행에서 다시 조사하지 않게 막는지는 확인하지 못했다. 막고 싶다면 폐기 이력도 known_titles에 합치고, 같은 후보 목록으로 두 번 연속 실행해 두 번째 실행의 research 호출이 0회인지 확인한다(제안일 뿐 이 사례에서 구현·검증된 것은 아니다).

흐름을 옮기면 대략 이렇다. 보고 내용을 바탕으로 다시 짠 예시이고 원본 코드가 아니다.

known_titles = storage.ready_titles()          # 발행된 글 제목

for topic in sorted(candidates, key=score, reverse=True):
    if storage.exists(topic):                  # 정확 일치
        reject(topic, "already_generated"); continue
    if topic.score < config.minimum_topic_score:
        reject(topic, "topic_score"); continue
    if any(title_similarity(topic.title, t) >= config.maximum_title_similarity
           for t in known_titles):            # 근접 중복 → 조사 전에 폐기
        reject(topic, "duplicate_topic"); continue
    known_titles.append(topic.title)          # 조사 전에 등록
    evidence = provider.research(topic)        # 여기서부터 비싼 호출
    ...

유사도 계산법은 기록에 없다. 세션 기록에는 "공백·기호를 제거한 제목끼리 비교"라는 요약과 유사도 0.919라는 결과값만 남아 있고, 알고리즘과 입력 제목은 확인되지 않는다 . 그래서 원본과 같은 점수는 재현할 수 없다. 직접 적용한다면 아래처럼 표준 라이브러리만으로 같은 성격의 함수를 만들면 된다. 이것은 이 글이 제안하는 예시 구현이고 원본이 아니다.

import re
from difflib import SequenceMatcher

def normalized_title(t: str) -> str:
    return re.sub(r"[\W_]+", "", t.casefold())     # 공백·기호 제거, 소문자화

def title_similarity(a: str, b: str) -> float:      # 0.0(전혀 다름) ~ 1.0(동일)
    return SequenceMatcher(None, normalized_title(a), normalized_title(b)).ratio()

점수는 문자 단위 비교라 어순이 바뀌거나 표현이 크게 다르면 낮게 나온다. 위 함수가 내 제목 데이터에서 몇 점을 주는지는 직접 돌려 봐야 안다.

검증은 "중복이 걸렀나"만 보지 말고 "research가 호출되지 않았나"로 잡는다. 이 사례의 테스트 test_near_duplicate_topic_rejected_before_research는 유사도 0.919인 두 후보를 넣고 ready 1 / rejected 1(duplicate_topic), research 호출 1회를 확인했다고 보고했다 . 실제 테스트 입력 제목은 기록에 없으니, 같은 구조로 직접 만든다. 아래는 테스트 의사 코드다. Topic, provider, evidence, pipeline과 결과 필드는 적용할 프로젝트에 맞게 준비해야 한다.

def test_near_duplicate_skips_research():
    calls = []
    provider.research = lambda topic: calls.append(topic.title) or evidence
    candidates = [
        Topic("PS5 듀얼센스 연결 안 될 때 단계별 해결법", score=90),
        Topic("PS5 듀얼센스 연결 안 될 때 해결 방법 단계별", score=85),  # 표현만 다른 변형
        Topic("갤럭시버즈 한쪽 소리가 안 날 때", score=80),               # 다른 주제(오탐 확인용)
    ]
    report = pipeline.run(candidates)
    assert len(calls) == 2                                   # 변형 1개는 조사 전에 폐기
    assert report.rejected_reasons == ["duplicate_topic"]
    assert "갤럭시버즈 한쪽 소리가 안 날 때" in calls          # 다른 주제는 통과

두 변형의 실제 점수가 maximum_title_similarity를 넘는지는 위 함수로 먼저 출력해서 확인하고, 넘지 않으면 테스트 제목을 더 가깝게 고치거나 임계값을 조정한다.

상황

이 파이프라인은 discover(주제 후보 생성) → research(웹 검색으로 근거 수집) → write(집필) → review(모델 심사) 순서로 돈다. 주제 점수 게이트는 discover 직후에 점수만 비교하는 단계다 . 정확 일치 검사(exists())는 이미 있었지만 표현이 조금만 달라도 통과했다 .

증상

원인

단계별 비용 차이. 같은 진단에서 assistant는 탈락 비용을 단계별로 나눴다 .

단계탈락 시 비용
주제 점수 탈락사실상 공짜. 웹 검색·집필·심사 없이 점수만 비교하고 버린다
모델 심사 탈락비싸다. discover + research(웹 검색) + write + review를 다 쓰고 마지막에 버린다

중복 주제가 싼 게이트를 그대로 통과하면 비싼 쪽 비용을 전부 치른다(이 사례에서 그런 경우가 실제 몇 건이었는지는 위 이유로 확인되지 않는다). 그래서 중복 판정은 research 앞으로 옮겨야 효과가 있다. 구현 보고의 표현으로는 "중복 판정을 웹 조사·집필 이전(주제 단계)으로 끌어올려" 호출 낭비를 막는 설계다 .

해결

보고된 게이트는 두 종류의 누수를 겨냥한다 .

  1. 같은 배치 안의 변형 — 후보를 점수순으로 처리하므로 가장 점수가 높은 변형만 조사로 넘어가고, 나머지는 조사 전에 걸러진다.
  2. 발행된 글과의 중복 — 이미 발행한 글과 비슷한 주제가 다시 나와도 조사·집필을 돌리지 않는다.

제목을 조사 전에 등록하는 정책. 위 재구성 예시는 구현 보고의 “주제 게이트 통과 시 추가”를 따라 known_titles.append를 research 호출 앞에 둔다 . 그래서 조사나 심사가 실패해도 그 제목은 이미 등록돼, 같은 배치의 다른 변형은 계속 걸러진다. 보고에는 이 동작이 의도한 정책인지 설명이 없다 . 이는 위 예시의 순차 처리에서 도출되는 동작이다. 첫 후보가 실패한 뒤에도 처리를 계속한다면, 유사도 문턱을 넘는 후속 변형은 같은 배치에서 재시도되지 않는다. 조사 성공 뒤에 등록하도록 바꾸면, 성공한 후보의 후속 변형은 여전히 조사 전에 걸러지고 실패한 후보의 변형은 재시도할 수 있다. 네트워크 예외 뒤에도 실제 파이프라인이 계속 도는지는 확인하지 못했으므로, 이 정책 비교를 원본의 검증 결과로 읽어서는 안 된다.

임계값 조정 최소 절차. 0.8은 이 프로젝트가 고른 값일 뿐 검증된 값이 아니다 . 내 데이터에서 정하려면 (1) 이미 쌓인 폐기·발행 제목에서 "같은 주제" 쌍 10개와 "다른 주제" 쌍 10개를 손으로 라벨링하고, (2) 위 title_similarity로 두 집단의 점수를 출력해서, (3) 다른 주제 쌍의 최고점보다 높고 같은 주제 쌍의 대부분을 잡는 값을 고른다. 두 집단이 겹치면 문자 단위 비교로는 한계이므로 다른 방식(단어 집합 비교 등)을 검토한다. 적용 전 오탐 확인은 이 사례에서도 있었다. assistant는 구현 전에 픽스처 후보 제목끼리 오탐이 나지 않는지 먼저 확인했다 . 이후 전체 테스트 9개가 통과했고 픽스처 엔드투엔드도 정상이라고 보고했다 .

측정되지 않은 것. 이후 실제 provider(claude CLI) 실행 결과는 후보 31개 중 통과 0개였다. source_conflict 27개, 모델 심사 실패 4개, 주제 탈락 0개로 보고됐다 . 27 + 4 = 31이라 이 보고 표에는 duplicate_topic 행이 없다. 이 한 번의 실행으로는 게이트가 실전에서 호출을 몇 번 아꼈는지 말할 수 없다. 이 글이 확인하는 것은 게이트의 위치와 그 위치를 검증하는 테스트 방식까지다.

덤 — 제안과 구현의 비교 대상이 달랐다

처음 진단에서는 "이미 폐기한 주제와의 유사도 체크"를 제안했다 . 그런데 완료 보고에서 known_titles의 초기값은 발행된 글 제목(ready_titles())이다 .

보고 설명대로라면 이전 실행에서 조사까지 하고 폐기된 주제는 다음 실행의 비교 목록에 들어가지 않는다. 처음 문제였던 ps5-듀얼센스-연결 변형은 폐기 목록에서 반복됐다 . 그러므로 이 글의 방법으로는 "폐기됐던 주제의 다음 실행 재조사"를 막았다고 확인하지 못한다. 막으려면 폐기 이력 저장소를 비교 목록에 합치고, 같은 후보 목록으로 두 번 연속 실행해 두 번째 실행의 research 호출이 0회인지 확인하는 테스트가 필요하다. 이는 제안일 뿐 이 사례에서 구현·검증됐다는 기록은 없다. 최종 코드 전체는 취재에서 보지 못했으므로 확정하지 않는다.

같은 이유로 어순과 표현이 크게 다른 변형을 잡아내는지도 기록으로 확인되지 않는다. 테스트는 유사도 0.919짜리 한 쌍만 다뤘다 .

취재 후기 — 공짜 탈락과 비싼 탈락

박도은
주제를 뽑고 웹 조사와 집필, 모델 심사를 거쳐 통과한 글을 저장하는 자동 글 생성기를 손보고 있잖아. 그걸 손보는 중인데 최근 실행이 통과 2개, 폐기 309개야
정바다
309개 버렸으면 309번 돈 쓴 거잖아. 이건 글 품질 문제야. 집필 모델을 바꾸면 결국 우상향이야.
정하늘
잠깐. 버린 단계 분포 있어?
박도은
있어요. 주제 점수에서 290개, 모델 심사 10개, 근거 9개 .
박도현
290개는 글을 쓰기도 전에 점수만 보고 버린 거야. 웹 검색도 집필도 안 했고. 그러니까 공짜 .
정바다
그럼 돈 새는 데가 없는 거네? 좋은 포트폴리오인데?
정하늘
원본 더 봐. 폐기 목록에 같은 게 반복돼.
박도은
잠깐, 이건 찍어야 돼. ps5-듀얼센스-연결 변형이 10개 넘어요 .
박도현
290개가 공짜 탈락이니까 그 변형 대부분은 싸게 죽었을 수도 있어. 근데 하나라도 싼 문을 통과하면 비싼 방까지 들어가지. 문 위치가 문제야.
정바다
그럼 심사에서 "이거 전에 쓴 거네" 하고 거르면 되잖아.
박도현
심사 도착했으면 이미 조사하고 쓴 뒤야. 버려도 돈은 나갔어.
정하늘
그래서 제목 중복을 거르는 검사, 즉 게이트를 research라는 웹 조사 단계 앞에 뒀지. 비싼 호출 전에 비교하려고 .
박도은
근데 제목으로 비교한다는 게 정확히 뭐예요? 글자를 그대로 비교해요?
정하늘
공백이랑 기호를 뺀 제목끼리 유사도를 재. 정확한 계산식은 기록에 없어서, 테스트에서 나온 0.919만 확인돼 .
박도은
그럼 0.919는 기준값이 아니라 두 제목을 비교한 결과고, 그 두 후보에서 웹 조사 호출이 한 번만 나왔다는 거죠?
박도현
맞아. 좋아. 그래서 얼마 아꼈는데.
정하늘
측정 안 했어. 다음 실전 실행은 31개 중 통과 0개, 중복 행은 없고 .
정바다
아낀 횟수는 몰라도 중복 기준 0.8은 그대로 쓰면 되겠네? 우리 설정값이잖아 .
정하늘
그 값은 우리가 고른 설정일 뿐이야 . 다른 제목 목록에 적용하려면 같은 주제 쌍과 다른 주제 쌍의 점수를 비교해 오탐부터 확인해야지.
박도은
하나 더요. 처음 제안은 "폐기한 주제랑 비교"였는데, 구현은 "발행한 글 제목"에서 시작해요 .
박도현
그럼 어제 버린 변형은 오늘 또 조사할 수 있단 거네. 폐기 이력을 넣고 두 번 돌려 보는 테스트가 있어야 막았다고 말하지.
정하늘
맞아. 어제 폐기한 제목까지 비교하는지는 아직 확인하지 못했어 . 중복 검사는 조사 앞에 두되, 어떤 제목과 비교하고 어떤 호출을 막았는지까지 확인해야 해결했다고 말할 수 있어.