LLM 없이 150건을 39건으로 줄이는 전처리부터 만들었다
새로 만든 AI 뉴스 다이제스트의 수집기가 사흘치 248건을 쏟았다. "LLM을 무한정 쓸 수 없잖아"에서 시작한 규칙 전처리, 임베딩 기각, 그리고 한글 제목을 전부 중복으로 버리던 정규식 한 글자
AI 뉴스 다이제스트는 전날 밤에 시작한 새 프로젝트다. 소스 21곳에서 사흘치 248건이 한 번에 들어왔는데, 이걸 그대로 LLM에 넘기면 토큰이 감당이 안 된다. 그래서 LLM을 부르기 전에 규칙만으로 중복을 접고 후보를 줄이는 단계부터 만들어야 했다.
하려던 것
전날 밤 10시 27분에 새 프로젝트를 시작했다. “AI 관련 뉴스를 볼 수 있는 서비스를 만들려고 하는데 괜찮을까? 메일링도 하려고. 내가 보려고 만드는 거기도 하고.” 소스 후보 47곳을 curl로 확인해 41곳이 살아 있었고 21개를 골랐다. 30분 뒤 사흘치 248건이 DB에 들어갔다.
첫 화면을 보고 한 말은 “너무 AI가 만든 티가 나네”였고, 두 번째는 이거였다.
이게 너무 많긴 한데. 중요도나 겹치지 않게 뭔가 할 수 있으려나?
어떻게 시켰나
다음 날 낮에 제약을 못 박았다.
편집하면 어떻게 할 건데? LLM을 무한정 쓸 수 없잖아. 중복을 걸러내거나 아무튼 전처리를 LLM 없이 하는 단계가 필요해서.
에이전트는 설명 대신 DB의 실제 기사로 파이썬 프로토타입을 돌려 보여줬다. 150건이 규칙으로 141건이 되고, 그중 18건이 묶음 4개로 접혔다. 제일 큰 묶음은 12건이었다. 같은 발표를 열두 곳이 보도한 것이다.
설계 문서 초안에는 임베딩이 선택지로 남아 있었다. 규칙이 놓치는 게 잦으면 다국어 임베딩 모델을 더하자는 것이었고, 집에 있는 GPU 장비의 서빙을 쓸 수 있는지 확인하자고 했다. 턴 중간에 잘랐다. “임베딩도 사용하지 마. 그 장비 팔려고 생각 중이야.” 문서에서 임베딩과 장비 언급이 사라지고 규칙에 “임베딩 금지”가 들어갔다. 규칙이 놓친 건 LLM 잡 하나가 25묶음 안에서 합친다. 놓치는 묶음이 반복되면 별칭 사전에 단어를 더한다.
어디서 막혔나
파이프라인은 열 단계다. 소스별 상한, 노이즈 규칙(프리릴리스, 뉴스레터형, 같은 저장소 릴리스 중복), 제목 완전중복, 토큰화와 음차 별칭(아스트라→astra, 오픈에이아이→openai, 조사 제거), IDF 가중 제목 유사도로 union-find 묶기, 규칙 점수(소스 수, 티어 가중치, HN 점수의 로그, 국내 홍보성 패턴은 −3), 그리고 LLM 잡 두 개. 잡 1은 상위 25묶음의 묶음 확인과 중요도, 잡 2는 상위 7건의 한국어 요약이다.
dry run에서 한글 제목이 줄줄이 “제목 완전중복”으로 빠졌다. 국내 매체 기사 아홉 건이 전부. 원인은 정규식 \W+였다. u 플래그 없이 쓰면 한글이 전부 non-word라 모든 한글 제목의 키가 빈 문자열이 되고, 빈 문자열끼리 완전중복이었다. 파이썬 프로토타입은 \W가 유니코드 기본이라 멀쩡했고 TypeScript로 옮기며 터졌다. [^\p{L}\p{N}]+에 u 플래그로 고치고, 키에 소스를 붙여 같은 소스 안에서만 중복 처리하게 했다. 다른 소스의 같은 제목은 묶음으로 가야 링크가 남는다.
잡 1이 성공한 직후 저장에서 죽었다. bun의 SQL 템플릿이 JS 배열을 PostgreSQL 배열로 만들지 않고 콤마 문자열로 직렬화했다. 배열 리터럴을 직접 만들어 캐스트했다. 재실행은 멱등 키 덕에 LLM 결과를 재사용해 토큰 0이었다.
어떻게 풀었나
첫 실제 실행. 후보 149건, 규칙 제외 8건, 141건이 126묶음, 상위 25묶음 39건을 LLM에 넘겼다. 잡 1은 27초, 입력 5,546토큰, 출력 1,935토큰, 스토리 23개. 잡 2 상위 7건 전부 성공. 설계상 하루 3만 토큰 안팎이다.
검증이 하나 나왔다. The Verge의 “ChatGPT, Grok, Claude가 동시에 다운됐다”와 Hacker News의 “왜 OpenAI, Claude, Grok이 동시에 다운됐나”는 규칙이 못 묶었다. 제목에 겹치는 토큰이 적었다. 잡 1이 둘을 한 스토리로 합쳐 3위에 올렸다. 임베딩 없이도 LLM이 25묶음 안에서 합친다는 설계가 첫 실행에서 맞았다.
출력을 눈으로 보다가 하나 더 잡았다. 3위 요약 끝에 </summary_ko></invoke> 태그가 그대로 붙어 있었다. 모델이 JSON 문자열 안에 태그를 흘린 것이다. 태그 제거 후처리를 넣었다. 다음 날 아침엔 요약 7건 중 3건이 구조화 출력 5회 재시도 끝에 실패했다가 큐의 2차 시도에서 살아났다. 스키마의 additionalProperties: false 때문에 모델이 키 하나만 더 붙여도 검증에 떨어지고 있었다. 스키마에서 빼고 프롬프트에 “세 필드만”을 적었다.
배운 점
- LLM 앞에 규칙 층을 둔다. 149건이 39건이 되고 나서야 LLM이 본다. 하루 3만 토큰이면 구독 안에서 매일 돈다.
- 규칙이 놓친 걸 LLM이 줍게 한다. 임베딩 대신 잡 1이 25묶음 안에서 합친다. 첫 실행에서 실제로 합쳤다.
- 정규식
\W는 언어마다 다르다. 파이썬은 유니코드가 기본, JS는u플래그가 있어야 한다. 한글 제목이 전부 빈 키로 중복 처리됐다. - 모델 출력은 문자열 안까지 본다. JSON은 통과했는데 값 안에 태그가 있었다.
- 구조화 출력 스키마에
additionalProperties: false를 넣지 않는다. 키 하나 더 붙는 것을 5회 재시도 실패로 만든다. 프롬프트로 제한한다.