개발 취재 노트
AI 콘텐츠 파이프라인이 거의 같은 주제를 매번 다시 웹 검색할 때 (발행 글·같은 배치 중복 걸러내기)
주제를 뽑고 웹 조사·집필·모델 심사를 거쳐 통과한 글을 저장하는 자동 콘텐츠 생성 파이프라인(content-loop-mvp)을 손보던 중이었다 . 여기서 구현 보고가 “발행 글”이라고 부르는 것은 저장소의 비교 대상 글이며, 외부 블로그 자동 발행까지 확인한 것은 아니다 . 최근 실행에서 통과한 글은 2개, 폐기는 309개였고, 폐기 목록에는 같은 소재의 표현만 바꾼 주제가 계속 나왔다 . 이 글이 다루는 범위는 발행된 글 및 같은 배치에서 이미 통과한 주제와 비슷한 후보를 웹 조사 전에 거르는 것이다. 이전 실행에서 조사까지 하고 폐기된 주제를 다음 실행에서 다시 조사하지 않게 막는지는 기록으로 확인하지 못했다.
결론
중복 검사는 가장 싼 단계(주제 선정) 에 둔다. 웹 조사·집필·심사를 다 돌린 뒤 "이미 쓴 주제네"라며 버리면 그 호출은 전부 날아간다.
이 사례의 파이프라인(discover → research → write → review)은 주제 점수 게이트 바로 뒤, research 호출 전에 제목 유사도 게이트를 넣었다고 보고했다 . 보고된 구성은 이렇다.
normalized_title()/title_similarity()— 공백·기호를 뺀 제목끼리 유사도 비교ready_titles()— 이미 발행된 글의 front matter 제목 목록known_titles를 발행 글 제목으로 시작하고, 주제 게이트를 통과한 후보의 제목을 계속 추가maximum_title_similarity(설정값0.8) 이상이면 조사 전에duplicate_topic으로 폐기- 정확히 같은 주제는 기존
exists()가 먼저already_generated로 거르므로, 새 게이트는 근접 중복만 맡는다
비교 대상은 발행 글 + 같은 배치에서 앞서 통과한 후보뿐이다. 이전 실행에서 폐기된 주제는 비교 목록에 들어간다는 보고가 없다 . 미확인: 그래서 폐기됐던 주제를 다음 실행에서 다시 조사하지 않게 막는지는 확인하지 못했다. 막고 싶다면 폐기 이력도 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())는 이미 있었지만 표현이 조금만 달라도 통과했다 .
증상
- 최근 실행 기준 통과 2개, 폐기 309개였다 .
- 폐기 목록에
ps5-듀얼센스-연결변형이 10개 넘게 반복됐다. assistant는 "discover가 매번 거의 같은 주제를 다시 만들어 웹검색·집필까지 태우고 버려요"라고 진단했다 . 다만 같은 보고의 단계 분포는 주제 점수 290개, 모델 심사 10개, 근거 9개다 . 주제 점수 탈락은 조사 전에 버려지는 공짜 탈락이므로, 그 변형 10여 개가 실제로 어느 단계에서 죽었는지는 기록에 없다. 조사·집필까지 갔다는 것은 assistant의 진단이지 확인된 집계가 아니다. - 다른 실행의 discover 출력에서도 같은 소재가 표현만 바뀌어 반복된다. "PS5 듀얼센스 컨트롤러 연결 안 될 때 단계별 해결법" , "PS5 듀얼센스가 본체에 연결되지 않을 때 리셋·재페어링 방법" 같은 식이다.
원인
단계별 비용 차이. 같은 진단에서 assistant는 탈락 비용을 단계별로 나눴다 .
| 단계 | 탈락 시 비용 |
|---|---|
| 주제 점수 탈락 | 사실상 공짜. 웹 검색·집필·심사 없이 점수만 비교하고 버린다 |
| 모델 심사 탈락 | 비싸다. discover + research(웹 검색) + write + review를 다 쓰고 마지막에 버린다 |
중복 주제가 싼 게이트를 그대로 통과하면 비싼 쪽 비용을 전부 치른다(이 사례에서 그런 경우가 실제 몇 건이었는지는 위 이유로 확인되지 않는다). 그래서 중복 판정은 research 앞으로 옮겨야 효과가 있다. 구현 보고의 표현으로는 "중복 판정을 웹 조사·집필 이전(주제 단계)으로 끌어올려" 호출 낭비를 막는 설계다 .
해결
보고된 게이트는 두 종류의 누수를 겨냥한다 .
- 같은 배치 안의 변형 — 후보를 점수순으로 처리하므로 가장 점수가 높은 변형만 조사로 넘어가고, 나머지는 조사 전에 걸러진다.
- 발행된 글과의 중복 — 이미 발행한 글과 비슷한 주제가 다시 나와도 조사·집필을 돌리지 않는다.
제목을 조사 전에 등록하는 정책. 위 재구성 예시는 구현 보고의 “주제 게이트 통과 시 추가”를 따라 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짜리 한 쌍만 다뤘다 .
취재 후기 — 공짜 탈락과 비싼 탈락
ps5-듀얼센스-연결 변형이 10개 넘어요 .