Mavent · 지도 위에서 찾는 지금 근처의 이벤트 · AI 코딩 에이전트와 1인 설계·구현·운영
두 블록 옆의 팝업을
놓치지 않게
약속 장소에서 기다리는 20분, 여행지에서 기차를 기다리는 30분. 바로 근처에서 열리는 팝업스토어나 마츠리를 알 방법이 없었습니다. Mavent는 웹에 흩어진 지역 이벤트를 LLM 파이프라인으로 모아 정리하고, 지도와 캘린더 위에서 자연어로 찾게 하는 서비스입니다.
실배포는 하지 않았습니다. 임시 배포 링크에서 실제 데이터로 동작합니다.규모 · 2026-10-25 운영 DB 실측
공개 이벤트는 수집 단계의 자동 검사(날짜·중복·이벤트 여부 등)를 통과해 지도와 검색에 나오는 이벤트입니다. 데이터베이스의 22만 행 중 끝나서 보관한 행사, 취소된 행사, 이벤트가 아니라서 걸러 낸 행은 뺐습니다. 다만 끝난 지 30일이 안 돼 아직 보관하지 않은 행사 46,615건이 들어 있어서, 이를 뺀 94,740건을 따로 적었습니다. 7개 언어는 제목과 요약을 일곱 언어로 저장한다는 뜻입니다(공개 이벤트의 86%가 일곱 언어를 모두 갖췄고, 나머지는 매일 도는 번역 배치가 채웁니다). 이벤트 보유 도시는 공개 이벤트가 1건 이상 있는 도시입니다(정기 수집 대상인 활성 도시 651곳 외의 도시도 포함).
결과
처음이라면 1·5·7·8·11장만 읽어도 전체 흐름이 잡힙니다. 7장은 길어서 앞의 세 소절만 읽어도 됩니다. 지원 직무에 맞춰 고른다면 데이터 수집·백엔드는 3~6장과 10장, 검색은 7·8장, AI 활용은 4장 후반·5·8·11장을 권합니다. 점선 밑줄 용어는 마우스를 올리거나 탭하면 뜻이 뜹니다. 기술 상세 표시가 붙은 소절은 건너뛰어도 흐름을 따라가는 데 지장이 없습니다. 세부 내용은 접어 두었으니, 관심 있는 부분만 펼치기를 눌러 보세요. 이 글에서 '운영'은 외부에 공개 출시하지 않고 제 서버에서 상시 돌리는 환경을, '실사용자'는 시험용 표시 없이 들어온 사용자(제가 브라우저로 직접 쓴 것 포함)를 뜻합니다.
데모 영상
썸네일이나 구간 시각을 누르면 그 장면부터 재생됩니다.


구간 안내 12
- 00:00대시보드 — 외부 API 과금 · 어댑터 호출 · 질의 통계
- 00:09갭 대시보드 — 키워드 부족 · 이벤트 미달 · 검수 대기 · 희소 데이터
- 00:21수집 수동 트리거 · LLM-guided crawl · 백엔드 선택
- 00:50도시별 수집 잡 이력 · 수단별 마지막 수집 시각
- 01:12전용 어댑터 승격 후보 · 소스 후보 검수
- 01:30태그 별칭(파편화 방지) · 커버리지 갭
- 01:41이벤트 없는 도시 · 장소 정보
- 01:59이벤트 · 도시 데이터 관리
- 02:29비용 관측 · 질의 실행 양상
- 02:56지명 사전 · 검색 별칭
- 03:15BFS 크롤 역추적 · 중복 처리 현황
- 03:41설정 — 질의 한도 · 처리율 · 지출 상한 · 크론
시작 · 왜 만들었나
흩어진 정보를 위치와 시간으로 한 번에 보기
이벤트 정보는 공식 API, 관광청, 축제 홈페이지, 일반 웹페이지, SNS에 흩어져 있어서 "지금, 여기 근처"를 기준으로 훑어볼 방법이 없었습니다.
데이터만 갖춰진다면 인터페이스는 이미 답이 있다고 봤습니다. 숙박 예약 서비스가 쓰는 지도 + 캘린더 화면입니다. 지도의 보이는 영역이 곧 "어디", 캘린더의 선택 구간이 곧 "언제"가 되고, 시의성이 있는 이벤트는 이 두 축으로 정리됩니다.
문제는 둘로 나뉩니다. ① 모으는 일(여러 출처의 이벤트를 같은 형태로 정리해 좌표와 날짜를 붙이는 것)과 ② 찾게 하는 일(지도 위에서, 가능하면 자연어로). 어려운 절반은 ①이었고, 이 페이지의 3–6장이 그 이야기입니다. 7–8장이 ②, 9–10장이 비용과 운영, 11장이 AI 에이전트와 개발입니다.
수집이 어려운 이유는 출처의 생김새에 있습니다. 공식 API는 형식이 정해져 있어 기계가 바로 읽을 수 있지만, 그런 출처는 소수입니다. 대부분의 지역 이벤트는 사람이 읽으라고 만든 웹페이지에만 있습니다. 페이지마다 구조가 달라 규칙으로는 읽을 수 없으니, "글을 읽고 정리하는" LLM이 필요합니다. 대신 LLM은 느리고 비싸며 가끔 틀립니다. 이 프로젝트의 수집 설계는 LLM을 어디에 쓰고 어디에 쓰지 않을지 정하는 일이었습니다.
시작 · 한눈에 보기
수집과 검색을 분리한 구조
백그라운드 워커가 주기적으로 이벤트를 모아 데이터베이스에 정리해 두고, API 서버는 그 데이터로 지도·캘린더·자연어 검색에 답합니다. LLM을 부르는 곳은 페이지 추출과 자연어 검색처럼 몇 군데뿐이고(그림의 노란 상자), 나머지는 전부 코드와 규칙입니다.
기술 스택. Python 3.12 (FastAPI · SQLAlchemy 2.0 async · Celery) · PostgreSQL 16 (PostGIS · pgvector · pg_trgm) · Redis · React 18 + TypeScript (Zustand · Google Maps) · Docker Compose 9개 서비스 (db · redis · api · worker · worker-qwen · worker-drain · worker-crawl(ONNX: 링크 게이트 · 인젝션 분류기) · beat · frontend) · LLM: Anthropic Claude(Haiku · Sonnet, 기능마다 모델을 고름) · Gemini(임베딩) · 로컬 Qwen(개인 GPU, 추출용) · instructor(구조화 출력).
워커를 여럿으로 나눈 이유는 두 가지입니다. 첫째, GPU가 하나뿐이라 로컬 모델 작업은 전용 큐(worker-qwen · worker-drain)에서 한 번에 하나씩 돌려야 합니다. 둘째, ONNX 추론에 드는 런타임과 모델 메모리를 모든 워커에 싣지 않으려고 크롤 워커 이미지에만 넣었습니다. 링크 게이트와 인젝션 분류기처럼 ONNX 모델을 쓰는 작업은 그 워커의 큐로 보냅니다.
구조를 정한 원칙은 세 가지입니다. LLM은 판단만 하고, 통제는 코드가 맡습니다(4장). 비싼 LLM의 판정은 작은 모델에 학습시켜 그 모델로 대신합니다(5장). 모든 외부 호출은 용도별 예산 안에서만 합니다(9장).
순위 고정 테스트 76개(72개 통과 · 4개는 알려진 결함)인젝션 평가 게이트 780건크롤 순서 특성화 테스트마이그레이션 순서 검사스키마를 바꿀 때 잠금을 기다리는 시간 상한
수집 ① · 어디서 모을 것인가
수집할 사이트도 시스템이 찾는다
651개 활성 도시의 이벤트 사이트를 손으로 등록할 수는 없습니다. 도시마다 "그 도시다운" 검색어를 만들고, 검색 엔진으로 후보를 찾고, LLM이 매긴 확신도 순으로 승격했습니다.
- 도시별 검색어 생성LLM · 그 도시 현지어로, 행사 목록을 올리는 지역 사이트를 찾는 말
- 검색 API 5종DDG · Brave · Serper · Exa · Tavily
- 후보 사이트70,967건 누적
- "이벤트 소스인가?" 분류Claude Haiku · 사이트 종류 × 페이지 종류 × 위치
- 확신도 순 일괄 승격9월 2일 도시 214곳 · 1,483곳 승격 · 나머지는 대기
- 활성 소스 6,810곳쓸모는 크롤 결과로 다시 가림
코드 · 규칙 LLM 사람 저장소
키워드는 도시에 따라 다릅니다. 서울이라면 팝업스토어·K-pop, 오사카라면 마츠리·코스프레처럼, 그 도시 사람들이 이벤트를 실제로 부르는 말을 LLM이 제안합니다. 키워드로 찾아낸 사이트는 4장 크롤의 시드(seed)가 됩니다.
후보가 곧 수집 대상은 아닙니다. LLM이 후보마다 "이벤트를 정기적으로 올리는 사이트인지"를 분류하고, 0과 1 사이의 확신도를 스스로 적습니다. 저장하는 확신도는 이 값과 도메인 이름 점수 중 큰 값입니다. 도메인 이름 점수는 기본 0.2에, 호스트에 visit·festival·축제 같은 낱말이 있으면 0.4, 정부·공공 도메인(.go.kr · .gov 등)이면 0.4를 더한 값입니다. 9월 2일에는 새로 활성화한 도시들의 후보를 이 확신도로 줄 세워 도시 214곳에서 1,483곳을 한 번에 승격했습니다. 후보가 많은 도시일수록 임계값을 높게 두었습니다(후보 수에 따라 0.80~0.95). 확신도는 LLM이 스스로 적은 값이라 실제 정확도와 맞춰 본 적이 없습니다. 10월에 분류기를 다시 만든 뒤에도, 정답 표본에서 크롤할 만하다고 고른 사이트 중 실제로 그런 곳은 절반 남짓이었습니다(정밀도 0.52). 그 판정을 믿는 대신, 쓸모는 크롤 결과로 다시 가립니다. 연속 세 번 가져오지 못한 사이트와, 응답은 정상인데 네 번 연속 이벤트를 하나도 내지 않은 사이트는 자동으로 "정체"로 돌립니다. 임계값 아래 후보는 대기로 남고, 정기적으로 검토하지는 않습니다. 키워드 생성과 소스 발견은 정기 일정에서 빼 두었고, 필요한 도시만 손으로 돌립니다.
10월에는 검색어를 다시 설계했습니다. 현지어를 정하는 표가 23개국뿐이라, 당시 활성 도시 599곳 중 324곳(138개국)의 현지어가 영어로 잡혀 있었습니다. 검색어의 목적을 "행사 이름"에서 "행사 목록을 꾸준히 올리는 지역 사이트를 찾는 말"로 바꾸고, 현지어는 CLDR 데이터에 나라 · 도시 예외를 더해 정했습니다. 21개 도시에서 두 판을 번갈아 검색하자, 검색어 하나가 찾아 준 새 크롤 대상이 2.98개에서 4.83개로 늘었습니다(18개 도시에서 늘고 1개에서 줄어듦). 크롤 대상 여부는 위 분류기의 판정이라 절대 수는 부풀려져 있지만, 두 판을 같은 분류기로 견준 값입니다.
70,967 후보6,810 활성49,259 대기14,080 거절818 정체
수집 ② · 어떻게 훑을 것인가
LLM이 링크를 고르고 코드가 범위를 정하는 탐색(LLM-guided BFS)
처리율 제한과 비용 때문에 사이트를 다 읽을 수는 없습니다. 대신 페이지에서 이벤트를 뽑는 LLM 호출에서 "이벤트가 더 나올 법한 링크"도 함께 골라 받고, 그 제안을 코드가 걸러서 다음 탐색 대상을 정합니다.
탐색 LLM 가지치기됨 (규칙)
공식 API는 이 경로를 타지 않습니다. 형식이 정해진 응답은 정형 어댑터가 LLM 없이 정규화합니다(KOPIS, TourAPI, Ticketmaster, Connpass, ICS/RSS). LLM은 사람이 읽으라고 만든 페이지에만 씁니다.
LLM의 링크 제안은 그대로 따르지 않습니다. 다른 사이트로 넘어가면 활성 사이트 범위를 벗어나고, 탐색 깊이와 예산에 한도를 두지 않으면 한 사이트가 큐를 독식합니다. 도시별 방문 예산 안에서도 호스트마다 페이지 상한을 따로 두는 이유입니다. 이 구조에서 판단(어느 링크가 유망한가)은 모델이 하고, 나머지 통제(어디까지, 얼마나)는 전부 코드가 쥡니다. 이 탐색 순서는 지금의 동작을 그대로 기록해 두는 테스트(특성화 테스트)로 고정했습니다. 나중에 코드를 고치다 "어떤 페이지를 어떤 순서로 가져오는가"가 바뀌면 이 테스트가 바로 잡아냅니다.
LLM이 고르지 않은 나머지 링크는 버리지 않고 후보로 쌓아 둡니다. 처음에는 이 후보를 페이지마다 묶어 LLM에 보내 "이벤트 페이지인가"를 판정했습니다(링크 게이트). 이 판정을 소형 모델로 옮긴 이야기가 5장입니다.
자바스크립트로만 그려지는 페이지(SPA)는 일반 요청으로 이벤트가 0건이면 실제 브라우저(Playwright)로 다시 그려 읽어 봅니다. 오픈소스 크롤러 crawl4ai(3만 5천 줄)의 코드를 AI 코딩 도구(Claude Code)로 읽어, 페이지를 끝까지 제대로 그려 내는 기법 4가지만 골라 옮겼습니다. 로딩을 기다리는 방식 바꾸기, 스크롤해야 나오는 내용 불러오기, 화면을 가리는 팝업 치우기, 불필요한 리소스 막기입니다. 표본 8개로 비교해 보니 기존 방식이 실패한 4개 중 2개에서 본문을 되찾았고, 잘되던 페이지가 나빠진 경우는 없었습니다. 라이브러리를 통째로 들이지 않은 것은, crawl4ai가 자체 네트워크 계층으로 요청을 보내 Mavent의 외부 요청 안전 검사(SSRF 가드)와 사이트별 요청 간격 관리를 거치지 않기 때문입니다. 방문한 페이지는 캐시에 기록해, 바뀌지 않았으면 다시 읽지 않습니다.
고전 크롤러 문헌이 경고하는 "무한히 생겨나는 URL"이 이벤트 사이트에서는 달력 페이지였습니다. Gemini와 Claude의 딥리서치(11장)로 이 문헌들을 Mavent의 수집 코드와 대조하다가, 날짜만 바꿔 끝없이 이어지는 달력 링크 1,305개가 예산과 판정 비용을 잡아먹고 있는 것을 찾았습니다. 지난 날짜와 1년 넘게 먼 날짜는 건너뛰고, 날짜만 다른 같은 모양의 링크는 한 번 탐색에 2개까지만 따라가게 해 막았습니다.
에이전트는 코드가 부를 때만
탐색을 통째로 LLM 에이전트에 맡기면 어떨지, 같은 사이트 10곳과 같은 Claude Haiku로 첫 방문을 견줬습니다. 에이전트는 페이지를 직접 골라 읽으며 앞으로 열릴 이벤트만 적고, 기존 크롤은 위 구조 그대로 돌렸습니다. 뽑은 이벤트는 어느 쪽 결과인지 가린 채 Claude Sonnet이 원문 페이지와 대조해 채점했습니다(667건). 추출과 채점이 모두 Claude라 맞은 비율의 절대값은 후하게 나왔을 수 있어, 두 쪽을 견주는 데만 씁니다.
| 같은 10곳 · 첫 방문 | 기존 크롤 | 에이전트 |
|---|---|---|
| 뽑은 이벤트 중 맞은 비율 | 0.73 | 0.98 |
| 맞은 이벤트 하나당 비용 | $0.0023 | $0.0014 |
| LLM 호출 · 걸린 시간(합) | 283회 · 3,807초 | 108회 · 771초 |
| 비용이 드는 곳 | 출력이 62%(지난 행사와 중복까지 적음) | 쌓인 대화를 차례마다 다시 읽음(대부분 캐시) |
기존 크롤이 틀린 것의 상당수는 월만 적힌 날짜를 1일로 채우는 식의 날짜 짐작이었고, 맞은 이벤트 수는 둘이 거의 같았습니다(233 대 232).
재방문은 크롤 쪽이 쌀 것으로 봤지만, 같은 10곳을 운영 크롤이 마지막으로 방문하고 2~3일 뒤에 다시 재 보니 전제가 틀렸습니다. 바뀌지 않은 페이지를 LLM 앞에서 건너뛰는 장치가 원문 HTML의 해시를 봐서, 날짜 표시나 버튼 글자만 달라져도 '바뀜'으로 판정했습니다. 10월 22일 운영 실행에서 이 장치로 건너뛴 페이지는 1.3%뿐이었습니다. 정제한 본문의 해시로 판정하면 '그대로'인 사이트가 늘어, 그 뒤에 둔 에이전트의 재방문은 10곳에 $0.11~0.14였습니다(판정 없이 $0.22). 다만 이 판정은 크롤이 알던 페이지만 보아서, 크롤이 모르는 목록 경로에 있던 행사는 놓쳤습니다.
맞은 새 이벤트 하나당 비용은 크롤이 조금 쌌습니다($0.0022 대 $0.0030). 에이전트 비용의 절반 가까이는 대화가 10만 토큰을 넘긴 몇 차례에서 나왔고(Haiku는 그 구간 단가가 5배), 크롤의 호출은 서로 독립이라 배치 API(반값)로 보낼 수 있습니다. 10곳에서 한 번 잰 결과라 몸통은 지금 구조로 두고, 재방문에서 새는 비용을 줄이는 장치 셋을 따로 만들었습니다(에이전트 추출은 아직 운영 전).
| 장치 | 잰 것 | 지금 |
|---|---|---|
| 바뀌었는지를 정제한 본문의 해시로 판정 | 3일 뒤 다시 받은 292쪽 중 '그대로' 17% → 64%(LLM 없이 재생) | 섀도 모드. 건너뛰었을 페이지에서 새 행사가 나오는지 잽니다 |
| 이미 저장한 이벤트 목록을 함께 주고 새 것만 적게 | 69쪽 실호출: 출력 −25%, 비용 −22%, 다시 적은 아는 이벤트 −94%. 놓친 새 이벤트는 같은 프롬프트를 한 번 더 돌린 차이 안 | 구현, 꺼 둠 |
| 에이전트가 본문을 대화에 쌓지 않고 페이지마다 따로 읽게 | 같은 10곳: 최대 프롬프트 13.7만 → 6.1만 토큰, 10만 초과 차례 10 → 0, 비용 −30%(정확도 0.97 → 0.94) | 구현, 꺼 둠 |
운영에서 이미 쓰는 에이전트는 두 가지이고, 둘 다 코드가 부를 때만 돕니다. 도구 호출 수 · 가져오는 페이지 수 · 시간에는 상한을 둡니다.
| 에이전트 | 코드가 부르는 때 | 결과를 믿는 조건 |
|---|---|---|
| 목록 진입점 찾기 | 새 사이트에서 행사 목록 페이지를 찾을 때(매일) | 같은 사이트인지, 실제로 행사 목록인지 서버가 다시 확인 |
| 사이트 문서 판독 | 규칙으로 문서를 못 찾았거나 판정이 불확실할 때 | 판정마다 원문 인용을 받아, 받은 페이지에서 그 문장을 찾지 못하면 버림 |
목록 진입점 에이전트는 URL의 상위 경로를 거슬러 오르던 규칙을 대신합니다. 규칙이 내놓은 후보 중 실제 행사 목록은 일부(같은 도메인에서 11개 중 3개 남짓)였고, 에이전트는 21곳 중 19곳에서 확인된 목록을 찾았습니다(한 곳에 $0.0042, 28초).
문서 판독 에이전트는 사이트 문서에서 자동 수집에 관한 조항을 찾아 수집 설정에 반영합니다. 규칙으로 문서를 찾아 한 번 읽히는 방식은 112곳 중 43곳에서만 문서를 찾았습니다. 링크가 여러 문서를 모아 둔 안내 페이지 뒤에 숨어 있거나, 본문 추출기가 금지 항목 목록을 통째로 떨어뜨렸기 때문입니다. 에이전트는 101곳에서 문서를 찾았고, 자동 수집을 명시적으로 막은 8곳을 모두 찾았으며, 그렇지 않은 곳을 막았다고 본 경우는 없었습니다(한 곳에 평균 $0.011). 정답은 검증 에이전트가 모은 근거를 제가 다시 확인해 정했습니다. 가져온 글 안의 지시는 따르지 않고, 짧은 낱말 하나로는 근거가 되지 않게 글자 종류별로 인용의 최소 길이를 둡니다.
대화 압축은 오히려 비쌌다 기술 상세
에이전트의 대화를 줄이는 다른 길로, 상용 코드 에이전트처럼 대화가 길어지면 서버가 앞부분을 요약해 넣는 대화 압축도 앞의 10곳에서 쟀습니다. 10만 토큰은 넘지 않았지만 비용은 오히려 1.3배였습니다. 압축할 때마다 앞 대화를 다시 읽어 요약을 쓰고, 요약 뒤에는 캐시를 새로 써야 했으며, 맥락이 비워진 모델이 차례를 더 돌았습니다(112 → 145). 압축은 가져오는 페이지 상한을 크게 잡는 사이트가 생길 때 켤 안전망으로 남겼습니다.
566,966 링크 후보61,009 수집 잡 실행430,195 외부 호출 로그
수집 ③ · 링크 판정을 소형 모델로
LLM의 판정을 라벨로 모아, 소형 모델에 넘기다
4장의 링크 게이트는 페이지마다 LLM을 불러 링크를 판정했습니다(호출 한 번에 링크 약 21개). 그래서 링크를 모으는 탐색도 LLM 호출을 기다려야 했습니다. LLM이 내린 링크 판정과, 실제로 페이지를 열어 이벤트를 뽑아 본 결과를 학습 데이터로 쌓아 다국어 소형 인코더를 학습시키고 크롤 워커 안에서 직접 돌렸습니다. 지금은 링크마다 LLM을 부르지 않고, 소형 모델이 애매하다고 남긴 링크만 주 1회 묶어서 LLM에 다시 묻습니다.
- 링크 후보URL · 앵커 텍스트 · 경로 구조
- LLM 판정 (기존 링크 게이트)"이벤트 페이지인가?"
- 라벨 축적silver 약 3만 4천 · gold(추출 결과) 약 4만 6천 · v4 학습 기준
- mDeBERTa 파인튜닝다국어 소형 인코더 · RTX 3060
- ONNX 변환 · 워커 내 추론버전 폴더 핫리로드 · 무중단
- 3구간 라우팅확신 → 읽을 대기열 · 애매 → 주 1회 Haiku 2차 판정 · 잡음 → 버림
평가 데이터를 늘려 recall 다시 확인하기
첫 평가의 recall은 0.9885였습니다(평가셋의 이벤트 링크 87개 중 86개). gold 라벨을 478개에서 3,600개로 늘리고 학습 설정을 바꿔 가며 다시 학습하자, recall은 0.95 안팎에서 더 오르지 않았습니다. 첫 수치는 이벤트 87개짜리 작은 평가셋에서 나온 값이었습니다.
오분류된 페이지 30건을 직접 열어 라벨링해 보니, 문제는 정답을 만드는 방식에 있었습니다. gold 라벨은 "추출해서 이벤트가 나왔는가"로 만들어지는데, 날짜가 지난 행사 페이지는 추출 결과가 0건이라 '이벤트 아님'으로 라벨이 붙습니다. 분류기는 링크를 열기 전에 URL과 링크 구조만 보고 판단하므로, 날짜에 따라 바뀌는 이 라벨을 맞힐 방법이 없었습니다.
이 진단에 따라 라벨을 규칙으로 다시 매겼습니다. 추출 결과가 0건이어도 링크에 이벤트 구조 신호(이벤트 낱말, 이벤트 URL 패턴, 부모 페이지의 이벤트 구조화 데이터)가 있으면 '이벤트 페이지'로 바꿨습니다. 같은 단서를 모델도 입력으로 보기 때문에, 이렇게 바꾼 행의 라벨은 입력만 보고도 거의 맞힐 수 있습니다. 이 라벨로 잰 recall은 실제보다 좋게 나올 수 있습니다. 사람이 확인한 22개는 학습이나 예시로 쓰지 않는 홀드아웃으로 고정했습니다. 남은 오차의 예는 "GCN2026_4月"처럼 앵커와 URL만으로는 사람도 가릴 수 없는 링크였습니다.
7월에 배포한 모델(v3)의 운영점은 평가셋 추정으로 recall 약 0.92, LLM 호출 절감 약 69%였습니다. 이 recall은 애매한 링크를 LLM에 넘기던 당시 구성을 기준으로, 애매한 구간에 남은 이벤트 링크도 놓치지 않은 것으로 센 값입니다. 이 평가셋은 대부분 LLM이 붙인 허브·잡음 링크라, 절감률은 운영에서 잰 값이 아닙니다. 사람이 확인한 링크 22개 중 이벤트 링크 14개에서는 12개가 확신 또는 애매한 구간에 남았고, 확신 구간만 세면 11개였습니다. 표본이 작아서 95% 구간은 약 60~96%로 넓습니다.
운영에서는 링크 분류를 이 모델 혼자 하고, 링크마다 LLM을 부르지 않습니다. 확신한 이벤트 링크는 읽을 대기열로 보내고, 잡음은 버립니다. 애매한 링크는 대기열에서 빼고, 매 회차 읽는 양의 10%(60개 중 6개)만 애매한 링크와 버린 링크에서 무작위로 뽑아 읽습니다. 실제로 읽어 추출 결과로 붙은 라벨(gold와 같은 방식) 23,342개(9월 27일)로 보면 애매한 구간이 37.6%였고, 그중 57.8%가 이벤트 페이지였습니다. LLM 비용을 없앤 대신 이벤트 일부를 늦게 읽거나 놓치고 있었고, 읽히지 않은 애매한 링크가 9만 6천여 개까지 쌓였습니다.
파인튜닝과 라벨. 이미 학습된 모델을 이 프로젝트의 데이터로 더 학습시키는 것을 파인튜닝이라고 합니다. 라벨은 사람이 붙이지 않았습니다. LLM 링크 분류기의 판정(silver)과, 페이지를 열어 LLM이 이벤트를 뽑았는지(gold)를 정답으로 썼습니다. 큰 모델의 판정을 작은 모델이 그대로 따라 배우게 하는 방식을 증류(distillation)라고 부르는데, 여기서 이벤트 라벨은 판정이 아니라 추출 결과에서 나왔으므로 엄밀한 의미의 증류와는 다릅니다. mDeBERTa는 여러 언어를 함께 읽는 소형 인코더 모델이고, ONNX는 학습된 모델을 어느 환경에서든 빠르게 실행할 수 있는 표준 형식입니다. 덕분에 별도 서버 없이 크롤 워커 프로세스 안에서 판정합니다.
게이트가 소형 모델로 바뀌자 탐색이 LLM에서 분리됐다
링크 판정에서 LLM이 빠지자 "먼저 훑고, 나중에 읽는" 2단 구조가 가능해졌습니다. 9월 2일 실측입니다. 도시 225곳의 활성 사이트 818개를 시드로, LLM 호출 없이 게이트만으로 깊이 3까지 훑어 페이지 1,136개에서 후보 링크 5만 개를 42분에 모았습니다. 게이트가 판정한 링크 중 확신한 것은 38%였고, 애매한 링크는 그때는 일단 이벤트 후보로 분류했습니다. 나중에 이미 아는 URL을 뺀 확신 링크와 애매한 링크를 100개씩 로컬 모델로 읽어 보니, 이벤트가 1건 이상 나온 페이지의 비율은 각각 0.69와 0.20이었습니다. 사람이 아니라 추출 결과로 센 값이라 실제보다 낮게 나오는 하한입니다. 앞 문단의 57.8%는 수집기가 골라 읽은 링크에 붙은 라벨이라, 갓 모은 링크인 이 표본과는 대상이 다릅니다. 링크가 나온 도시는 206곳이었고, 한 도시당 링크는 중앙값 206개·평균 243개였습니다. 기존에 알던 URL과 겹친 것은 1% 미만이었습니다. 이렇게 빨리 모은 것은 여러 사이트에 동시에 요청하되 한 사이트에는 간격을 지키는 방식과, 요청을 계속 거부하는 사이트를 잠시 끊어 두는 서킷브레이커 덕분입니다. 처음에는 게이트가 확신하는 링크부터 읽었고, 읽는 순서는 아래 A/B로 다시 정했습니다.
애매한 링크만 LLM에 다시 묻기
쌓여 가는 애매한 구간에만 LLM을 다시 쓰기로 했습니다. 먼저 같은 입력(URL과 링크 글자만)으로 Claude Haiku와 v3를 견주자, 링크 1,471개에서 Haiku가 순위를 더 잘 매겼습니다(평균 정밀도 0.586 → 0.680, 사이트 단위 부트스트랩 95% 구간 +0.058~+0.130). 다만 판정이 낫다고 새 이벤트가 늘어난다는 보장은 없습니다. 같은 행사의 번역 사본이나 목록 중복도 이벤트 페이지로 세기 때문입니다.
상시로 붙이기 전에 수율 시험부터 했고, 통과 기준은 실행 전에 적어 두었습니다(사전 등록). 쌓인 링크에서 무작위로 고른 2,000개를 Haiku로 채점해 0.60 이상인 248개와, 점수와 상관없이 고른 대조군 500개를 같은 우선순위로 읽게 했습니다. 처음 정한 기준인 '이벤트 비율 1.5배'는 실행 전에 바꿨습니다. 지난 30일 동안 탐사 몫으로 읽힌 애매한 링크는 이미 71.5%가 이벤트 페이지여서(57.8%처럼 실제로 읽힌 링크만 센 값이라 갓 모은 링크의 0.20보다 높습니다), 아무리 잘 골라도 1.4배를 넘을 수 없었기 때문입니다. 대신 헛읽기 비율과 읽은 링크당 새 이벤트를 기준으로 삼았습니다.
Haiku가 고른 링크는 이벤트가 아닌 페이지를 읽는 비율이 대조군의 약 61%였고(1.63배 차이, 95% 구간 1.39~1.92), 읽은 링크당 새 이벤트는 2.45배였습니다(1.26~5.14). 두 기준을 모두 넘어, 지금은 주 1회 배치 API로 애매한 링크를 채점해 0.60 이상만 읽을 대기열에 올립니다. 10월 24일 첫 실행에서 12만 6,862개를 채점해 2만 864개(16.4%)를 올렸고, 비용은 약 $2.9였습니다.
애매한 링크의 주간 2차 판정
- 애매한 링크소형 모델 점수의 가운데 구간
- Claude Haiku 채점주 1회 · 배치 API · 링크 20개씩
- 0.60 이상만읽을 대기열로 · 14일 안에 읽음
- 읽고 추출추출 결과가 다음 학습 라벨
코드 · 규칙 LLM 저장소
같은 무렵 소형 모델도 다시 학습했습니다(v4). v3 학습 뒤 실제로 읽어 붙은 라벨이 4만 7천여 개로, 그때의 13배가 됐기 때문입니다. 학습에서 뺀 같은 평가 표본에서 애매한 구간의 평균 정밀도가 0.600에서 0.657로 올라 Haiku(0.659)와의 차이가 거의 사라졌고, 애매한 구간으로 가는 링크도 이 표본에서 37.7%에서 27.1%로 줄었습니다. 인코더를 더 새 모델(mmBERT)로 바꾼 판은 확신 구간의 정밀도를 v3만큼 맞추지 못해 버렸습니다. 2차 판정 시험은 v3 시절의 애매한 링크로 한 것이라, v4에서도 같은 이득이 남는지는 다시 재야 합니다.
읽는 단계의 GPU 시간 줄이기 기술 상세
링크를 읽어 이벤트를 뽑는 모델은 개인 GPU의 로컬 모델(Qwen)과 Claude Haiku 중에서 운영자 콘솔로 고릅니다(9장). 로컬 모델로 읽을 때의 비용은 돈이 아니라 GPU 시간이라, 무엇이 그 시간을 쓰는지부터 살폈습니다. LLM은 토큰을 하나씩 만들어 내므로 출력이 길수록 오래 걸립니다. 호출 기록에서도 처리 시간이 출력 길이를 거의 그대로 따라가는 것을 확인했습니다(상관계수 0.90). 이벤트가 수십 건 실린 긴 목록 페이지는 출력이 길어져 300초 제한에 걸렸고, 이렇게 결과 없이 끝난 호출이 GPU 시간의 18.5%를 차지했습니다.
한 링크가 LLM에 닿기까지
- 읽을 링크게이트가 이벤트로 본 링크
- 지난 일정 달력 보기?그렇다면 열지 않음
- 구조화 데이터 먼저JSON-LD · microdata · iCalendar · LLM 없음
- 날짜가 모두 지났나?판정만 기록 · 아직 켜지 않음
- LLM 추출 (로컬 GPU 또는 Haiku)출력 상한 · 잘리면 이어 읽기
- 저장 전 검사시작일 ≤ 오늘+3년 · 반복 회차 6개월
코드 · 규칙 LLM 저장소
고친 방법은 두 가지입니다. 첫째, 출력 길이에 상한을 두고, 잘리면 마지막으로 읽은 이벤트 다음부터 이어 읽거나 본문을 조각내 다시 읽게 했습니다. 배포 뒤 첫 320회 호출에서 시간 초과는 0건이었습니다. 다만 320회는 작은 표본입니다. 종전 규칙이었다면 0건으로 끝났을 한 목록 페이지에서는 이벤트 84건을 뽑았습니다(추출 수 기준).
둘째, 규칙으로 끝낼 수 있는 일은 LLM을 부르기 전에 끝냅니다. 웹페이지 중에는 기계가 읽으라고 넣어 둔 구조화 데이터에만 이벤트를 실은 곳이 있습니다. 본문을 정리하는 단계에서 이 부분이 지워져 모델이 보지 못했고, 미래 이벤트를 구조화 데이터로 실은 페이지를 읽은 호출 104회 중 28%에서 LLM이 이벤트를 하나도 뽑지 못했습니다. 이제는 구조화 데이터가 있으면 그것부터 읽고, 그것으로 충분하면 LLM을 부르지 않습니다. 지난 일정만 보여 주는 달력 보기 링크는 아예 열지 않습니다.
"날짜가 모두 지난 페이지는 읽지 않는다"는 규칙은 바로 켜지 않고 먼저 시험했습니다. 날짜처럼 생긴 글자만 찾아 판단하는 단순한 규칙(정규식)은 GPU 시간을 14.2% 아끼는 대신 날짜 근거가 있는 미래 행사의 3.1%를 잃어서 채택하지 않았습니다. 대신 연도가 없는 날짜는 함께 적힌 요일로 연도를 맞추고, "매주"·"상시" 같은 표현이 있으면 건너뛰지 않는 판별기를 만들어, 판정만 기록하는 섀도 모드로 돌렸습니다. 잃은 행사는 새 표본 203건과 옛 표본 392건 모두에서 0건이었습니다.
손실을 세는 채점기가 판별기와 같은 월 이름 사전을 빌려 쓰고 있어서, 판별기가 못 읽는 날짜는 채점기도 못 읽었습니다. 날짜 해석 오픈소스(dateparser)로 판별기와 사전을 공유하지 않는 채점기를 새로 만들어 다시 세자, 아랍어·우크라이나어 월 이름과 독일식 "14.10. 14:00" 표기에서 놓친 날짜가 나왔습니다. 판별기의 월 이름 표를 dateparser의 언어 데이터에서 뽑아 55개 언어로 넓혔고, 연도 없이 시각이 붙은 독일식 표기는 연도를 정할 수 없는 날짜로 세어 그 페이지를 건너뛰지 않게 했습니다. 다시 세자 날짜 근거가 있는 미래 행사 링크 409개 중 잃은 것은 0개였습니다(95% 상한 0.73%). 다만 이 표를 넣으면서 판별기와 채점기가 다시 같은 월 이름 데이터를 쓰게 됐습니다. 이 0개는 잠정 값이라, 켜기 전에 이 데이터와 무관한 채점기로 다시 확인해야 합니다. 켜면 GPU 시간을 12.1% 아끼지만 아직 켜지 않았습니다. 처리량을 막는 것이 GPU가 아니라 회차 크기(60개)와 수집기를 돌리는 시간이었고, 한 번 건너뛴 링크는 다시 읽지 않아 잘못 건너뛰면 그 행사를 영영 잃기 때문입니다.
어느 링크부터 읽을지도 A/B로 정했습니다. 이벤트가 나온 페이지에서 찾은 링크는 이벤트 페이지일 확률이 높았지만(88%), 대부분 이미 저장한 이벤트를 가리켰습니다. 아직 읽지 않은 페이지의 링크부터 읽고 이벤트가 0건만 나온 페이지의 링크는 뒤로 미는 순서를 만들어, 같은 대기열에서 30분마다 기존 순서와 번갈아 돌렸습니다. 읽은 링크당 새 이벤트는 기존 0.61건, 새 순서 0.94건이었고(회차 단위 순열 검정 p=0.0003), 10월 6일부터 새 순서로 읽습니다.
다시 분석해 보니 이 차이의 대부분은 실험 설계에서 나왔습니다. 두 순서가 대기열을 나눠 써서 새 순서가 신선한 링크를 먼저 가져갔고, 기존 순서는 번갈아 도는 동안 링크당 0.79건에서 0.56건으로 떨어졌습니다. 실제 이득은 10% 안팎으로 보고, 다시 잰다면 사이트를 둘로 나눠 대기열을 따로 쓰겠습니다.
LLM이 같은 말을 되풀이하다 3000년 12월 31일 같은 날짜를 만들어 내는 경우에 대비해, 저장 단계에 시작일 상한(오늘부터 3년)을 두고 반복 행사는 첫 회차부터 6개월 안의 회차만 받습니다.
학습 라벨 8만여(v4 · gold 4만 6천 · silver 3만 4천)사람 확인 라벨 22v4 recall 0.906(애매 포함 · 추출 결과 라벨)링크마다 부르는 LLM 0애매한 링크 37.7% → 27.1%(v3 → v4 · 평가 표본)Haiku 2차 판정 새 이벤트 2.45배(사전 등록 시험)시간 초과로 버린 GPU 18.5% → 0(배포 뒤 320회)읽는 순서 변경 이득 약 10%날짜 판별 예상 절감 12.1% · 손실 0/409(잠정)
수집 ④ · 모은 것을 쓸 수 있게
중복을 합치고 이벤트가 아닌 것을 거르기
같은 축제가 공식 API에서 한 번, 관광청 페이지에서 한 번, 일본어·한국어·영어로 각각 들어옵니다. 지도에 마커가 다섯 개 찍히면 곤란합니다. 또 가게 메뉴나 투어 상품처럼 이벤트가 아닌 것이 섞여 들어오기도 합니다.
싼 검사부터 하는 중복 판정
① 제목 해시 + 도시 + 시작일이 같으면 저장 단계에서 즉시 차단됩니다(데이터베이스 제약). ② 남은 후보는 임베딩 유사도로 비교합니다. 유사도가 0.92를 넘으면 중복 후보로 봅니다. 장소가 같거나 가깝거나 같은 날이면 0.75~0.85부터 후보로 봅니다. 같은 신사의 마츠리가 표기만 달리 세 번 들어온 사례가 유사도 0.80~0.83이어서, 장소가 같을 때의 하한을 0.85에서 0.75로 내렸습니다. ③ 후보 가운데 네 가지 고정 규칙에 맞는 쌍은 LLM 없이 바로 합칩니다. 유사도가 0.98 이상인 경우, 장소가 같고 0.95 이상인 경우, 다듬은 제목이 같은 경우, 같은 출처 페이지에서 같은 날짜로 다시 뽑힌 경우입니다. 0.98과 0.95는 처음의 0.97과 0.93을 제가 더 엄격하게 올린 값이고, 측정으로 정한 값은 아닙니다. 두 출처가 모두 정확한 날짜를 밝혔고 그 날짜가 하루도 겹치지 않으면, 유사도가 1.0이어도 합치지 않고 별개로 확정합니다. LLM에 묻지 않고 늘 같은 결과를 내는 고정 규칙(결정론적 규칙)으로, 같은 공연의 다음 주 회차를 삼키지 않기 위한 장치입니다(아래 반복 공연 사고). ④ 남은 애매한 쌍만 LLM에 묻습니다. 9월 29일부터 10월 19일까지는 이 단계를 꺼 두고 애매한 쌍을 보류했습니다. 보류된 쌍은 다시 켜면 후보로 돌아와, 10월 19일 하루에 1,183쌍을 판정해 545쌍을 합쳤습니다. 지금은 Claude Sonnet이 유사도 0.85 이상인 쌍만 판정하고, 그 아래는 보류합니다. 0.85 아래 80쌍을 따로 봤을 때 진짜 같은 이벤트가 4쌍뿐이어서 정한 하한입니다. 이 보류 쌍은 14일마다 다시 후보가 됐다가 또 보류되므로 사실상 합쳐지지 않습니다. "다른 이벤트"라는 판정은 이력으로 남아 같은 두 행을 다시 묻지 않습니다. LLM 호출량은 새로 들어오는 행 수를 따릅니다(주 300쌍 안팎 예상).
병합은 그래프 문제였습니다. A↔B, B↔C가 각각 같은 이벤트라면 셋은 한 집합입니다. union-find로 연쇄를 한 행으로 모으되, 연쇄를 그대로 믿으면 "비슷한 것의 비슷한 것"이 끝없이 이어져 다른 이벤트까지 삼키는 문제가 생깁니다. 합치기 전에는 묶음 전체를 한 번 더 검사합니다. 묶음은 8개를 넘지 않고, 정확한 시작일끼리 7일 넘게 벌어지면 안 되며, 병합 뒤 남을 대표 행과의 유사도가 0.75 이상이어야 합니다. 8개와 7일은 아래 반복 공연 사고 뒤에 정한 값이고, 따로 보정하지는 않았습니다. 이 검사는 대표 행 하나와만 견주므로, 사슬의 가운데 행이 대표면 양 끝이 함께 통과할 수 있습니다. 묶음 안의 모든 쌍을 서로 견주는 검사는 기록만 합니다. 0.80으로 강제해 보니 9월 14일 뒤 두 행 이상을 흡수한 1,179묶음의 33%가 걸렸는데, 표본 대부분이 티켓 등급별 행처럼 맞게 합친 묶음이었고 실제로 다른 행사가 낀 묶음은 없었기 때문입니다. 필드는 한쪽을 고르지 않고 합칩니다. 언어별 제목은 모두 모으고, 빈 값은 채웁니다. 제목처럼 임베딩의 입력이 바뀐 행은 데이터베이스 트리거가 표시해 두고, 그 행만 임베딩을 다시 만듭니다. 표시에서 빠진 행은 입력의 해시를 주기적으로 대조해 찾아냅니다. 병합 전 상태는 스냅샷으로 남아 운영자가 되돌릴 수 있습니다. 다만 9월 14일 전에 합친 행 일부는 남은 행과의 연결이 끊겨, 그대로는 되돌리기 어렵습니다.
더 큰 사고는 반복 공연에서 났습니다. 매주 같은 제목과 소개로 올라오는 공연은 유사도가 1.0이라, 일주일 간격의 회차들이 사슬처럼 이어 붙었습니다. 8월 31일 점검에서 해밀턴 420회, 해리 포터 377회, 루차 리브레 73회가 각각 한 건으로 합쳐진 것을 찾았습니다. 그날 바로, 두 행의 정확한 날짜가 서로 다르면 자동으로 합치지 않게 막고, 한 묶음에 합칠 수 있는 개수에 상한을 두었습니다. 그리고 제목이 같고 정확한 날짜가 다른 병합만 규칙으로 골라, 회차 4,087개를 병합 전 스냅샷에서 되살렸습니다(그중 다가오는 회차 907개). 9월 2일에는 이런 쌍을 LLM에 물은 2,858건이 한 번도 합쳐지지 않은 것을 확인하고, 위의 고정 규칙으로 바꿨습니다.
9월 29일에는 전체 도시의 중복을 한 번에 훑었습니다. 비용을 아끼려고 운영 중인 LLM 판정(유료 API)은 끄고, 규칙만으로 결론이 나지 않은 6,664쌍은 개발 도구의 Claude 서브에이전트가 군집 단위로 나눠 판정했습니다. 판정은 같음 2,823쌍, 다름 2,511쌍, 모름 1,330쌍이었습니다. 같음 가운데 2,185쌍을 합쳤고, 나머지는 회차가 많은 공연처럼 묶음 전체를 다시 확인하는 단계에서 막혔습니다. "다른 이벤트"로 본 쌍은 판정 이력에 남겨 다시 묻지 않습니다. "모름" 1,330쌍 중 923쌍은 같은 공연의 좌석·등급별 티켓 상품이었고, 이런 상품 행 607개는 따로 "이벤트 아님" 상태로 바꿨습니다. 스윕 도중에는 같은 출처 자동 병합 규칙이 목록 페이지의 다른 항목을 합친 것을 발견했습니다. 규칙을 고친 뒤, 고치기 전 규칙으로 합친 160건을 Claude가 다시 읽어 15건(9.4%)을 되돌렸습니다. 판정도 확인도 Claude가 했으므로, 사람이 표본을 직접 읽는 검토는 남은 과제입니다.
이벤트가 아닌 것이 10% 섞여 있었다
음료 행사를 찾다가 한정판 메뉴나 시즌 음료 출시 같은 상품 정보가 이벤트로 섞여 나오는 것을 봤습니다. 한 관광협회의 "여름 디저트 특집" 페이지에서는 가게별 디저트가 하나하나 1년짜리 이벤트로 저장돼 있었습니다. 무작위로 뽑은 500건을 Claude가 한 건씩 읽고 판정하자, 10.0%가 이벤트가 아니었고 9.0%는 정기적으로 되풀이되는 프로그램이었습니다.
무작위 500건을 한 건씩 판정한 결과
- 일정이 있는 이벤트 81.0%
- 반복 프로그램 9.0%
- 이벤트가 아님 10.0% 상품·판촉 4.4 · 상시 제공물 4.2 · 기타 1.4
원인은 두 군데에 있었습니다. 추출 지시문에 "이벤트란 무엇인가"라는 정의도, "이런 것은 이벤트가 아니다"라는 목록도 없었습니다. 그리고 파이프라인 어디에도 "이벤트가 아님"을 판정하는 관문이 없었습니다. 모델은 날짜가 없는 상시 메뉴에 빈칸을 채우려고 "1월 1일부터 12월 31일까지" 같은 날짜를 붙여 통과시켰습니다. 공개 이벤트 수만 보면 늘고 있어서 이런 문제가 드러나지 않았습니다.
지금의 거름망
- 경로로 먼저 거르기/shop · /cart · /coupon 등 · LLM 호출 전
- 추출하며 종류 고르기일정 행사 · 반복 프로그램 · 상시 제공물 · 상품·판촉
- 규칙 백스톱모델과 상관없이 늘 같은 결과
- 걸린 항목 처리새 항목은 저장 전에 버림 · 쌓인 행은 "제외"로 보관
코드 · 규칙 LLM 저장소
거름망은 세 겹입니다. 상점·장바구니·쿠폰 같은 경로는 LLM을 부르기 전에 거릅니다. 추출 결과에는 "종류" 칸을 새로 두어, 모델이 정의와 예시를 보고 일정 행사·반복 프로그램·상시 제공물·상품 중 하나를 고르게 했습니다. 그다음 모델과 상관없이 늘 같은 결과를 내는 규칙(백스톱)이 한 번 더 거릅니다. 새로 들어오다 걸린 항목은 저장하지 않고, 버린 이유만 수집 기록에 남깁니다. 이미 쌓여 있다가 걸린 행은 지우지 않고 "제외" 상태로 남깁니다. 이 행은 잘못 걸렀으면 되돌릴 수 있고, 다음 판정의 재료로도 씁니다.
이미 쌓인 행은 사람이 다 읽을 수 없는 양이었습니다. 판정 기준과 확정 예시 30여 개를 지침으로 정리하고, Claude 서브에이전트들이 1,000행씩 나눠 읽게 했습니다. 결과는 바로 적용하지 않았습니다. 검증 스크립트로 행 수·ID 일치·의심 표시를 확인하고, 경계 사례를 다시 판정한 묶음만 반영했습니다. 그렇게 약 3만 7천 행을 판정해 4,043행을 "제외" 상태로 바꿨습니다. 앞의 중복 정리에서 나온 티켓 상품 607행도 같은 상태로 바꿨습니다. 한꺼번에 몰리는 일회성 판정은 운영 중인 유료 API 대신 개발 도구의 에이전트에 맡깁니다(11장). 판정 근거가 지침과 CSV 파일로 남아 나중에 다시 검토할 수 있습니다. 검색 평가의 정답 라벨도 같은 방식으로 Claude가 매깁니다(7장).
병합 사건 150,917판정 이력 111,273이벤트 아님 제외 4,043 + 607
검색 ① · 흔한 질문은 LLM 없이
LLM 없이 푸는 일반 검색과 품질 측정
검색창에 들어오는 대부분은 "도쿄 마츠리" 같은 짧은 말입니다. 이런 질문까지 LLM에 보내면 비용과 지연만 붙으니, 흔한 질문은 사전과 규칙으로 풀고, 정말 어려운 질문만 8장의 에이전트로 보냅니다.
- "도쿄 마츠리"사용자 입력
- 규칙으로 분해위치 + 카테고리 + 때 + 남은 말 · LLM 없음
- 도쿄로 이동 + '축제' 태그 필터도시 사전 · 다국어 태그 별칭
- 계층형 단일 패스 검색형태소 · 트라이그램 · 의미 벡터
- 지도 + 리스트 + 칩목록 · 지도 · 칩이 같은 기준선
"도쿄 마츠리"를 글자 그대로 찾으면 제목에 그 글자가 든 이벤트만 나와 0건이었습니다. 위치(도쿄)와 카테고리(마츠리는 축제)로 쪼개면 "도쿄로 지도를 옮기고 축제만 보여 주기"가 되어 결과가 살아납니다. 도시 사전에 없는 동네나 역 이름("시부야", "강남역")은 자체 지명 사전에서 먼저 찾고, 거기에도 없으면 Google Places에 물은 뒤 그 답을 지명 사전에 기록합니다. 두 번째 조회부터는 무료입니다.
한중일 전문 검색 단계가 비어 있었다
PostgreSQL의 기본 전문(全文) 검색은 띄어쓰기로 단어를 나눠서, 띄어쓰기가 없는 중국어·일본어나 붙여 쓴 한국어 복합어("산천어축제")를 한 덩어리로 색인합니다. 글자를 그대로 찾는 부분 문자열 검색은 따로 있었지만, 전문 검색 단계에서는 "산천어축제"가 "산천어 축제"와 만나지 못했고, 일본어·중국어는 낱말 일부로 찾으면 사실상 하나도 걸리지 않았습니다. 언어별 형태소 분석기를 색인과 질의 양쪽에 붙이고 64,679행을 다시 색인해 세 언어 모두에서 회복을 확인했습니다.
의미 검색에 기준선을 두어 엉뚱한 결과 자르기
"말차"와 "matcha", "코스프레"와 "코스플레이"처럼 표기가 다른 것은 임베딩 검색이 잘 잇습니다. 문제는 ANN이 관련 이벤트가 하나도 없어도 "가장 덜 먼 몇 개"를 반드시 돌려준다는 점입니다. 부산에서 한 질의는 화면 안 167건 중 139건을 "결과"로 표시했는데 하나도 관련이 없었습니다.
부산의 한 화면에서 질의 6개의 로그로 비교해 보니 진짜 일치는 임베딩 거리 0.22–0.27, 가장 가까운 잡음은 0.40이었습니다. 그 사이 빈 구간에 둔 0.35를 기준선으로 삼았습니다(거리는 작을수록 뜻이 가까워서, 기준선을 올리면 더 먼 결과까지 받아들입니다). 이 구간은 질의마다 조금씩 움직여서, 글자로 일치한 결과의 거리를 기준점 삼아 질의마다 기준선을 보정합니다. 의미 검색은 글자 검색 결과가 5건 미만일 때만 켜지는 보완 단계입니다. 켜지면 문자 검색과 의미 검색의 순위를 RRF로 합칩니다. 목록·지도 마커·태그 칩·캘린더는 같은 기준선 함수를 써서 서로 다른 답을 내지 않습니다. 지금 일반 검색은 채점한 질의 211개로 다시 재어 기준선을 0.45로 넓혔고, 자연어 에이전트는 더 엄격한 0.35를 지킵니다.
MRR · nDCG. 검색 품질 지표입니다. MRR은 "첫 번째 정답이 몇 번째에 나오는가"(1위면 1.0, 2위면 0.5, 3위면 0.33)를 질의마다 매겨 평균합니다. nDCG@10은 "상위 10개가 얼마나 정답 순으로 잘 정렬됐는가"(최선이 1.0)를 봅니다. 0.02대는 거의 모든 질의에서 정답이 나오지 않았다는 뜻이고, 0.8대는 대부분의 질의에서 첫 결과가 정답이라는 뜻입니다. nDCG는 "가장 이상적인 순서"와 견준 점수라서, 그 이상적인 순서를 무엇으로 잡느냐에 따라 값이 달라집니다.
결과를 크게 바꾼 보정이 세 가지 더 있습니다. 첫째, "마츠리"처럼 특정 문화권의 단어가 들어 있으면 일본 도시의 결과를 앞에 둡니다. 둘째, "서울 만화"의 '서울'처럼 지명은 문장의 뜻을 대부분 차지해 버리므로, 의미 벡터를 만들기 직전에 지명 사전으로 떼어 냅니다. 셋째, 일본어처럼 임베딩이 약한 언어는 영어로 옮긴 질의도 함께 검색해 더 가까운 쪽을 취합니다.
검색 품질은 고정된 평가 질의로, 실제 검색 경로에서 잰다
검색 품질은 특정 시점의 데이터를 그대로 복사해 고정한 데이터베이스에서, 평가 질의 233개를 매번 다시 돌려 확인합니다(회귀 검증). AI 검색 기록(대부분 제 시험 질의)에서 뽑은 문장 123개, 손으로 만든 운영 시나리오 52개(그중 검색창에 실제로 들어온 원문 15개), 적대 질의 22개, 지역 질의 36개입니다. 정답 라벨은 처음에 Gemini가 매겼는데, 검색도 임베딩과 질의 번역에 Gemini를 써서 같은 계열의 모델이 채점까지 맡는 셈이었고, 10월 1일 Gemini 청구액의 대부분이 라벨링 호출 364회였습니다. 10월 2일에 제가 직접 매긴 25쌍과 견준 일치도(κ)는 Claude 0.53, Gemini 0.39였습니다. Gemini는 이 결과를 보고 판정 지침을 고친 뒤 같은 25쌍에서 0.56이 됐는데, 같은 쌍에 맞춰 고친 값이라 정확도는 바꾼 이유로 삼지 않았습니다. 바꾼 이유는 앞의 두 가지였습니다. 그래서 Claude가 질의와 결과 6,066쌍을 어느 설정에서 나온 결과인지 가린 채(블라인드) 다시 매겼고, 이와 별도로 제가 등급별로 나눠 뽑은 25쌍을 같은 방식으로 직접 매겨 견줬습니다. 주제 판정(0~3점)의 일치도는 가중 κ 0.60(가중치 없이 0.47)이었습니다. 25쌍이라 오차가 커서, 제가 직접 확인할 감사 문항 220개(그중 검색 라벨은 40쌍)를 따로 만들어 두었습니다. "도쿄 마츠리"에 요코하마 행사를 맞다고 볼지 같은 방침은 버전을 붙인 판정 규약으로 정하고, 규약이 바뀌면 영향을 받는 쌍만 다시 매깁니다. 라벨은 데이터셋 파일에 남겨 다시 돈을 들이지 않습니다.
측정을 보고 기능을 켜지 않은 적도 있습니다. 결과 순서를 다시 매기는 리랭커를 끝까지 붙여 A/B로 비교하니(질의 161개), 리랭커만의 몫은 nDCG@10 +0.030이었습니다(의미 검색 기준선을 넓힌 몫까지 더하면 +0.093). 품질 기준은 넘었지만 7월 6일, 운영에서는 켜지 않기로 했습니다. CPU에서 후보 50개를 다시 매기는 데 중앙값 1.76초가 걸려, 검색창의 지연 예산(0.2초)을 크게 넘었기 때문입니다. 이득이 작은 이유도 10월 평가셋으로 따져 봤습니다. 상위 10건의 오답 중 60%는 주제는 맞고 장소가 틀린 경우라 글만 읽는 리랭커로는 고칠 수 없었고, 판정된 관련 행사의 절반은 상위 20건 안에 들지도 못했습니다(질의 원문 경로). 후보를 넓히는 일(아래 기준선 완화)을 먼저 했고, 리랭커는 배선만 남겨 두었습니다.
평가 도구는 처음에 질의 원문을 통째로 검색에 보냈습니다. 실제 검색창은 "도쿄 마츠리"에서 도시 이름을 떼어 지도를 옮기고, 남은 말은 태그로 바꿔 찾습니다. 검색창이 질의를 나눈 결과를 그대로 고정해 평가도 같은 길을 따르게 하자, 첫 실행에서 결함 하나를 찾았습니다.
실제 검색창: 평가에서 그대로 재현
- "도쿄 마츠리"도쿄 근처를 보던 중
- 도시와 나머지 분리도쿄 · 마츠리 → 축제 태그
- 지도 이동 생략이미 도쿄 근처라서
- 축제 태그 · 날짜순도시 기준이 사라짐
- 상위 10건 중 9건이 다른 도시사이타마 · 요코하마
뗀 도시의 중심을 정렬 기준점으로 쓰게 고치자 9건이 0건이 됐고, 도시를 지정한 질의 54개 전체에서 다른 도시 결과의 비율이 6.0%에서 3.1%로 내려갔습니다. 지금은 이 실제 검색 경로의 점수를 주 지표로 씁니다. nDCG@10 0.674, MRR 0.821입니다(10월 14일, 채점 질의 224개 — 앞 질문이 있어야 뜻이 서는 후속 질문 7개와 관련 행사가 없는 2개는 뺌). 같은 질의를 원문 그대로 보내는 경로는 nDCG@10 0.518입니다.
평균 하나로는 층이 가려집니다. 질의를 출처별로 나눠 따로 계산해 보니(층별 비교, 10월 10일 · 채점한 204개), 실제 경로가 앞선 것은 AI 검색 문장(0.664 대 0.360) 덕분이었고, 일반 검색창에 흔한 짧은 검색어에서는 오히려 원문 경로보다 낮았습니다(0.683 대 0.741). 차이가 가장 큰 질의는 "마츠리"였습니다(원문 경로 0.94, 실제 경로 0.11). 검색창이 '마츠리'를 축제 태그로 바꾸면서 글자를 버려, 축제 전체가 날짜순으로 나왔습니다. 태그로 바꾼 낱말도 검색어로 남기되, 거르는 조건이 아니라 순위를 올리는 신호로만 쓰게 했습니다(거르는 조건으로 썼더니 간사이를 띄운 화면의 '간사이 마츠리'가 축제 211건 중 7건만 남았습니다). 이 변경과 아래의 때 표현 필터 등을 담은 브랜치를 같은 정답 라벨로 견주면 짧은 검색어의 nDCG@10이 0.680에서 0.757로 올랐습니다(14개 오름, 2개 내림). 지금은 회귀 검사가 전체 평균과 짧은 검색어를 따로 봅니다.
때를 뜻하는 말도 글자로 검색되고 있었습니다. "도쿄 이번 주말 재즈"에서는 실제로 이번 주말에 열리는 재즈 공연 5건 중 3건이 빠졌습니다. 이를 날짜 필터로 바꾸자 때 표현이 든 질의 44개의 nDCG@10은 0.791에서 0.544로 떨어졌습니다. 정답 라벨이 주제와 장소만 보고 날짜는 보지 않아, 기간 밖 행사를 걸러 낼수록 점수가 깎인 것입니다. 검색 코드의 날짜 해석과 상관없이 질의마다 기간을 손으로 적어 두고 대조하자, 상위 10건 중 기간 안에 드는 행사가 23.6%에서 74.1%로 늘었습니다.
- 날짜 필터 전 23.6%
- 날짜 필터 후 74.1%
상위 10건 중 질의가 말한 기간 안에 드는 행사의 비율(때 표현 질의 44개, 검색 코드와 따로 적은 기간으로 대조)
이 변경들을 모두 담은 비교에서 전체 평균은 0.6595에서 0.6538로, 우연과 구별되지 않는 차이였습니다(p=0.76). 짧은 검색어의 개선도, 기간의 개선도 평균에는 드러나지 않았습니다.
큰 검색 서비스와 견주어 빈틈 메우기
실제 검색 경로로 돌린 평가 질의 213개 중 13개(6.1%)는 결과가 0건이었습니다(10월 7일). 장소에 가격·대상 같은 조건이 함께 걸린 AI 검색 문장이 많았습니다. 에어비앤비·도어대시·핀터레스트·바이두 지도처럼 검색을 오래 다듬은 서비스가 공개한 기술 글을 딥리서치(11장)로 모아 Mavent 검색과 견주자 빈틈이 15개 나왔습니다. 그중 "지금 할 것"만 골라, 고정 질의 평가에서 유의한 하락(nDCG −0.02 이하, p<0.05)이 없을 때만 켰습니다.
결과가 0건일 때 넓혀 가는 순서
- 원래 질의결과 0건
- 오타 교정사전의 도시 · 태그 이름으로
- 남은 말 빼기장소 · 태그로 다시
- 기간 떼기때 표현이 있으면 · 장소는 그대로
- 같은 태그의 가장 가까운 곳화면에 알림 · 되돌리기
코드 · 규칙 (LLM 없음) 결과 없음 결과를 찾음
넓힐 때마다 무엇을 바꿔 찾았는지 화면에 알리고 원래대로 되돌릴 수 있게 했습니다. 이 순서를 넣자 실제 검색 경로에서 결과가 0건인 질의가 6.1%에서 2.3%로 줄었습니다(남은 5개 중 3개는 결과가 없는 것이 맞는 질의).
자동완성 후보는 밤마다 미리 계산해 두는 목록으로 바꾸고, 한글은 자모와 초성으로도 찾게 했습니다. 언어마다 행사 제목 100개를 한 글자씩 치는 과정을 재생해 보니, 원하는 행사가 상위 3위 안에 들기까지 누른 키가 한국어 7.1번 → 4.8번, 일본어 6.5번 → 3.8번으로 줄었습니다(사용자가 실제로 친 접두어가 아니라 제목을 그대로 재생한 값). 클릭 기록으로 순위를 배우는 방법은 공개 출시 전이라 배울 클릭 기록이 없어 넣지 않았습니다.
지역 이름은 미리 정한 범위로
'북해도 축제'를 치면 홋카이도의 축제가 하나도 나오지 않았습니다. 지도를 홋카이도로 옮기고 축제 태그만 켜면 38건이 나오는데도 그랬습니다. 검색창은 지명을 한 점으로 풀어 그 둘레 30km를 찾는데, 홋카이도의 중심점은 산속이라 둘레에 축제가 없었습니다. 결과가 없으니 다른 지역의 축제 20건으로 넓혀 보여 줬습니다. 이런 결함은 차분 테스트로 찾습니다. 질의마다 검색창에 치는 길과, 지도를 옮기고 필터를 손으로 켜는 길의 결과를 견줍니다.
전 · 지명을 한 점과 그 둘레 원으로
'북해도 축제' 홋카이도 축제 0건 '간사이 축제' 45건 (오사카 둘레)
10월 10일 · 지역 이름은 그 지역 범위로
'북해도 축제' 38건 (손 조작 38건) '간사이 축제' 687건 (손 조작 650건)
범위를 지도의 사각형으로 잡자 다른 문제가 나왔습니다. '간사이 마츠리'의 결과 207건 중 31건이 간사이 밖, 그중 29건이 나고야 행사였습니다. 사각형으로는 어느 도시가 간사이에 속하는지 알 수 없어서, 지금은 지역 이름을 미리 정한 광역 행정구역의 묶음으로 풉니다. '간사이'는 오사카부 · 교토부 · 효고현 등 여섯 곳이고, 간사이 밖 행사는 0건이 됐습니다. 묶음 목록에 없는 도 · 현 · 주 이름은 공개 지명 데이터(GeoNames · Natural Earth)로 만든 사전으로 풀어, 3,865개 지역을 지오코딩 호출 없이 찾습니다. 배포 전 리뷰에서 도쿄의 구 이름 '中央区'가 말라위의 지역으로 풀리는 것을 잡아, 낱말을 쓴 언어로 후보를 좁혔습니다.
행사가 어느 지역에 드는지는 행사가 배정된 도시의 행정구역 코드로 정합니다. 좌표가 없는 행사도 지역에 들고, 경계 다각형도 필요 없습니다. 구(區)는 도시보다 작아서 이 방식으로는 가를 수 없고, 좌표와 구 경계로 갈라야 합니다. Google은 경계 다각형을 주지 않아, 나라마다 다른 경계 데이터를 들여와 일곱 언어의 이름과 맞추는 일이 따로 필요합니다. 그 데이터를 들이기 전까지 구 이름은 중심점과 반경으로 찾습니다. 평가셋에는 지역 이름 질의가 거의 없어 이 결함을 보지 못했고, 지역 질의 20개를 더했습니다(실제 검색 경로 nDCG@10 0.834, 원문 경로 0.529).
평가 도구와 지표 정의 고치기 기술 상세
평가가 운영 중인 캐시와 설정값을 그대로 읽어서, 같은 코드로도 돌릴 때마다 숫자가 달라질 수 있었습니다. 지금은 설정값을 시작할 때 한 번 읽어 결과와 함께 기록하고, 질의 번역과 임베딩은 고정해 둔 캐시에서, 기준 날짜는 데이터셋에서 가져옵니다. 같은 입력으로 세 번 돌리면 질의별 값까지 모두 같습니다.
nDCG는 검색이 돌려준 목록이 아니라 정답으로 판정된 전체를 기준으로 삼는 표준 정의로 셉니다(종전 정의는 관련 결과를 덜 돌려줄수록 유리했습니다). 앞에서 정한 의미 검색 기준선(0.35)은 부산의 한 화면에서 질의 6개를 보고 정한 값이었습니다. 당시 채점한 질의 211개(213개 중 정답 라벨이 있던 것) 전체로 기준선 0.35 · 0.40 · 0.45 · 0.50을 표준 지표로 다시 계산해 보니, 일반 검색에서는 넓힐수록 nDCG@10이 올랐습니다(0.442 · 0.508 · 0.535 · 0.548). 0.35에서 0.45로 넓히자 질의와 다른 언어로 저장된 이벤트를 찾는 교차언어 질의 6개는 0.126에서 0.526으로 올랐습니다. 결과가 0건인 질의는 15.5%에서 2.8%로 줄었습니다(질의 원문을 그대로 보내는 경로, 당시 라벨 기준).
다만 무관한 결과도 늘었습니다. 당시 라벨에서 관련 이벤트가 하나도 없던 질의 25개 중 무언가를 보여 주는 질의가 기준선마다 15개 · 23개 · 24개 · 25개였습니다. 그래서 nDCG가 가장 높은 0.50 대신 0.45를 골랐습니다. 같은 211개로 고르고 같은 211개로 평가했으니, 이 값은 이 질의들에 맞춰졌을 수 있습니다. 기준선마다 질의별 결과를 남겨 두지 않아, 질의를 나눠 고르고 확인하는 교차검증은 하지 못했습니다. 0.45 기준선은 일반 검색에만 씁니다. 자연어 에이전트에 같은 값을 쓴 시험에서는 관련 이벤트가 없어야 할 시험 질의 195개 중 무언가를 보여 준 질의가 8개에서 71개로 늘어, 에이전트는 종전 0.35를 지킵니다. 일반 검색에 남는 잡음은 글자가 정확히 일치한 결과가 있을 때 기준선을 다시 좁히고, 의미 검색 결과를 20건까지로 묶어 줄였습니다. 관련 이벤트가 없는 서울의 한 단어 질의("피크닉"·"만화"·"코스프레")에서는 의미 검색이 수십~수백 건의 무관한 결과를 냈고, 이를 20건 상한으로 잘랐습니다. 남은 20건은 리랭커를 다시 켤 때 다룰 과제로 남겼습니다.
결정도 평균 하나로 내리지 않습니다. 같은 질의끼리 짝지어 오른 질의와 내린 질의를 세고, 그 비교가 우연과 구별해 낼 수 있는 가장 작은 차이(검출 가능한 최소 차이)를 함께 냅니다. 동네 검색 반경을 바꾼 비교에서는 이 값이 nDCG로 0.02였고 실제 변화는 그보다 작아서, 평균 대신 바뀐 질의 8개를 하나씩 보고 결정했습니다. 꼭 지켜야 할 순위 76개는 평균과 따로 순위 고정 테스트로 묶어 둡니다.
- 정확한 제목그 행사가 1위10개
- 고유명 · 표기 변형"나리타산 신쇼지"를 붙여 써도, 브루노 마스를 세 가지로 써도 1위12개
- 범주 · 다른 언어花火 · fireworks · 불꽃놀이 모두 상위 3위 안11개
- 도시상위 3건이 모두 그 도시12개
- 동네상위 3건이 '근처' 반경 안(에펠탑 1km 등)3개
- 지역상위 3건이 그 광역 행정구역 안('간사이' · '충남' · 'Hokkaido' 등)18개
- 고쳐진 결함'실패 예상'이던 것이 통과해 '반드시 통과'로 바꿈('도쿄 마츠리' 등)6개
- 알려진 결함지금은 실패가 맞음 · 고쳐지면 알림4개
지금 72개가 통과합니다. 알려진 결함 4개는 "실패 예상"으로 두었다가, 고쳐져서 통과하면 알림이 뜹니다. 10월 11일 검색어 분해를 고친 뒤 이 알림이 여섯 번 울려, 그 6개를 "반드시 통과"로 바꿨습니다. 지도는 순위보다 첫 화면에 무엇이 찍히는지가 중요해서, 첫 화면에 찍힌 핀 중 관련 없는 것의 비율도 함께 봅니다. 지금 기준값은 실제 검색 경로 29.8%, 질의 원문 경로 49.7%입니다(10월 14일, 질의 224개). 첫 화면 핀 열 개 중 셋꼴로 관련이 없다는 뜻이라 남은 과제이고, 변경을 견줄 때 nDCG와 함께 이 값이 오르지 않는지 봅니다.
이 장에 나오는 검색 지표 값
| 값 | 잰 때 · 질의 수 | 경로 · 정답 라벨 |
|---|---|---|
| MRR 0.023 → 0.838 | 6월 · 33개 | 질의 원문 · Gemini(종전 정의) |
| nDCG@10 0.674, MRR 0.821 (지금 기준값) | 10월 14일 · 224개 | 실제 검색 경로 · Claude 6,066쌍 |
| nDCG@10 0.518 | 10월 14일 · 224개 | 질의 원문 · 같은 라벨 |
질의와 라벨이 같은 값끼리만 견줍니다. 둘째 줄과 셋째 줄은 같은 질의 · 라벨에서 경로만 바꾼 값이라 견줄 수 있고, 첫째 줄(6월, 형태소 분석 전의 글자 검색과 견준 값)은 질의와 라벨이 달라 바로 견줄 수 없습니다.
결과 0건 질의 6.1% → 2.3%때 질의 상위 10건 중 기간 안 23.6% → 74.1%실제 검색 경로 nDCG@10 0.674(질의 224개)자동완성 키 입력(한국어 제목 100개) 7.1 → 4.8순위 고정 테스트 76개(72개 통과)직접 검토 25쌍 가중 κ 0.60
검색 ② · 문장으로 묻기
문장 질문을 검색 조건으로 바꾸는 에이전트
"8월 교토에서 마츠리나 말차 같은 거"는 위치·날짜·취향이 섞인 문장입니다. 에이전트는 이 문장을 데이터베이스 검색 조건으로 바꾸고, 그 조건으로 거를 수 없는 것만 마지막에 LLM이 판정합니다.
- 위치·날짜 파악(Ground)오늘 날짜와 지금 보는 지도 범위를 단서로 · LLM 없음
- 해석(Interpret)LLM 1회 · 정해진 형식(JSON) · 같은 질문은 하루 캐시
- 검색 실행(Dispatch)해석을 SQL · 벡터 검색으로 · 코드
- 조건 완화(Verify · Refine)0건이면 조건을 풂 · LLM 없음
- 적합성 판정(Judge)필요할 때만 · "연인과 가기 좋은"
- 요약(Summarize)출처 인용 · 검사 뒤 한 번에
코드 LLM · 해석과 요약은 거의 매번, 판정은 필요할 때만 · LLM 단계는 10월 19일부터 Claude Haiku(그 전 Gemini 3.1 Flash-Lite)
검색 조건은 중첩 없는 세 칸으로 받는다
질의를 함수 호출로 받아 검색 조건으로 바꿀 때, 처음에는 "A이면서 (B 또는 C)이고 D는 아닌" 식으로 조건을 겹겹이 쌓는 문법을 요구했습니다. 당시 Gemini 함수 호출은 이런 중첩 객체 인자를 빈 객체([{}, {}])로 내보냈고, 이 문법은 10분 만에 접었습니다. 모델의 한계였는지 당시 함수 호출 스키마의 지원 범위 탓이었는지는 가리지 않았습니다. 또 다른 초기 버전은 모든 개념을 "반드시" 조건으로 몰아 "말차 같은 거"가 must=말차가 되어 0건을 냈습니다.
이전 · 중첩 문법 (불안정)
{"and": [
{"tag": "festival"},
{"or": [{"text": "말차"},
{"tag": "tea"}]},
{"not": {"tag": "indoor"}}
]}
→ 당시 Gemini가 빈 객체로 냄현재 · 평면 3키 + 판정 위임
{"must": ["festival"],
"should": ["matcha", "tea"],
"must_not": ["indoor"],
"judge_attributes": ["연인과 가기 좋은"]}
→ 재귀 없음 · 위치·날짜·가격은 별도 칸
→ must=AND · should=하나 이상 맞음
→ judge_attributes=LLM 판정중첩 없이 키 세 개로 필요한 조건을 표현합니다. 한 단계짜리 Elasticsearch bool 쿼리와 같은 모양입니다(bool 쿼리 자체는 겹쳐 쓸 수 있습니다). 위치·날짜·가격은 따로 칸을 두고, 꼭 맞아야 할 카테고리는 must, 빼야 할 조건(예: 실내)은 must_not에 넣습니다. 취향("마츠리나 말차 같은 거")은 should로 모읍니다. Elasticsearch에서 must와 함께 쓴 should는 점수만 더하지만, 여기서는 늘 "하나 이상 맞음" 조건입니다. 결과가 0건이면 조건 완화 단계가 이 조건을 뺄 수 있습니다. "연인과 가기 좋은"처럼 데이터베이스 필드로는 거를 수 없는 적합성만 judge_attributes로 분리해, 결과를 놓고 마지막에 LLM이 판정합니다.
후속 질문과 보고 있는 지도를 문맥으로 쓰기
별도 위치 언급이 없으면 지금 보고 있는 지도 영역이 위치 조건이 됩니다. 후속 질문("그중에서 가격이 나온 것만")은 이전 해석 위에 얹히고, 단서가 부족하면 선택지를 제시하며 되묻습니다. "시부야나 신주쿠"처럼 여러 위치, "7월 중순 또는 8월 말"처럼 떨어진 날짜 구간, 국가·지역 단위 질의도 한 번에 받습니다. 대화는 로그인 계정에 저장되어 다음에 이어집니다(게스트는 브라우저에만 저장). 해석 결과(날짜·필터·위치)는 URL에 담겨, 뒤로 가기나 링크 공유로도 그대로 되살아납니다. 처리 단계는 SSE로 하나씩 화면에 뜨고, 요약은 출력 검사를 거친 뒤 한 번에 나옵니다.
후속 질문을 대화 단위로 평가하기
"서울 전시 추천해줘"에 이어 "이번 주말로", "무료만", "강남 근처로"라고 물으면, 뒤의 질문은 앞의 조건을 이어받아야 합니다. 전시·이번 주말·무료는 그대로 두고 장소만 강남으로 좁혀야 합니다. 질문을 하나씩 따로 평가하면 앞 조건을 놓치는 실수가 보이지 않아서, 대화 45개(질문 129개, 22가지 유형)를 만들어 대화 단위로 평가했습니다. 대화는 Claude가 질의 기록과 유형표를 바탕으로 썼고, 실제 세션에서 온 대화는 3개입니다. 통과 여부는 LLM이 아니라 규칙으로 정합니다. 질문마다 해석 결과의 칸(장소 · 날짜 범위 · 필터 등)을 정답 조건과 맞춰 보고, 결과의 종류(결과 있음 · 되묻기 등)까지 맞아야 통과입니다. 45개 중 32개가 한국어이고, 저는 그중 10개를 검토했습니다.
같은 대화를 네 가지 조건으로 돌렸습니다. 실제처럼 앞 질문의 답을 이어 가는 조건(3번 반복), 앞 질문의 해석을 정답으로 바꿔 넣는 조건, 질문마다 앞 문맥까지 담은 한 문장으로 다시 써서 묻는 조건, 문맥을 아예 주지 않는 조건입니다. 조건끼리 견주면 실패가 어디서 오는지 가를 수 있습니다.
| 조건 (대화 45개 · 질문 129개) | Gemini 3.1 Flash-Lite 10월 7일 | Claude Haiku 구성 10월 19일 |
|---|---|---|
| 한 문장으로 다시 써서 묻기 | 94% | 90% |
| 실제처럼 이어 묻기 (앞 질문의 답을 이어 감) | 69% | 76% |
| └ 대화를 끝까지 통과 | 48% | 59% |
| 앞 질문의 해석을 정답으로 바꿔 넣기 (이어지는 질문만 · 이어 묻기 대비) | +10.3%p (0.4~15.5) | +8.9%p (−3.6~16.1) |
| 문맥 없이 묻기 (이어지는 질문만) | 6% | 5% |
표의 왼쪽 칸은 해석 모델이 Gemini 3.1 Flash-Lite이던 10월 7일 값입니다. 실제처럼 이어 물은 조건의 질문 통과율은 69%로, 같은 내용을 한 문장으로 물었을 때(94%)보다 낮았습니다. 대화 하나를 끝까지 모두 통과한 비율은 48%입니다. 다만 69%와 94%에는 앞 조건을 이어받을 일이 없는 첫 질문도 들어 있습니다. 첫 질문을 빼고 이어지는 질문만 보면 한 문장으로 물을 때 96%, 이어 물을 때 57%로 차이는 약 39%p이고, 문맥을 빼면 6%까지 떨어지니 앞 질문의 정보는 실제로 쓰이고 있었습니다. 실패의 약 90%는 이어 가야 할 조건을 잃은 경우였고, 빼야 할 조건을 계속 남겨 둔 경우는 한 번도 없었습니다. 이어지는 질문에서 앞 질문의 해석을 정답으로 바꿔 넣어 메워진 것은 약 10%p(95% 구간 0.4~15.5%p)였습니다. 구간이 0을 겨우 넘어 차이는 있다고 볼 수 있지만, 이 평가로 확실히 잡히는 크기(12~18%p)보다 작아서 정확한 크기는 불확실합니다. 나머지는 앞 해석이 맞아도 조건을 이어받지 못해 생긴다고 봤습니다. 다음 설계(대화의 상태와 이번 질문에서 바뀐 부분을 따로 관리해 합치는 방식)는 이 결과와 같은 조건에서 비교할 계획입니다.
10월 19일 Claude Haiku 구성으로 같은 대화를 다시 돌린 값을 오른쪽 칸에 적었습니다(괄호는 질문을 짝짓고 대화 단위로 다시 뽑은 95% 구간). 이어 묻는 조건은 올랐고 한 문장으로 묻는 조건은 조금 내렸지만, 두 날짜의 값을 대화별 평균으로 짝지어 보면 어느 차이도 우연과 구별되지 않았습니다. 그사이 기준 날짜와 데이터 스냅샷, 검색 코드도 바뀌어 모델만의 차이로 볼 수도 없습니다. 실패의 모양은 달라졌습니다. 조건을 잃은 실패가 줄어든 대신, 빼야 할 조건을 남겨 둔 경우가 처음 나왔습니다(6건, 모두 날짜). 또 대화 대부분을 Claude가 썼는데 이제 에이전트도 Claude라, 이 비교는 Haiku 쪽으로 기울었을 수 있습니다.
실제 경로로 124번 물어 결함 19건을 찾다
Claude가 질의 기록에 남은 서로 다른 질의 139개를 먼저 읽었습니다. 그중에서 고른 질의를 다섯 가지 방식으로 바꿔, 운영 앱의 실제 요청 경로로 모두 124번 호출했습니다(비용 $0.51, 당시 모델 Gemini 3.1 Flash-Lite). 다섯 가지는 기록 그대로 다시 묻기, 같은 뜻을 7개 언어로 바꾸기, 바꿔 말하기·오타, 이어 묻기, 무의미한 입력·이모지·프롬프트 인젝션 같은 경계 입력입니다. 결함은 19건이었고, 그중 7건은 사용자에게 틀린 내용을 보여 주거나 말하는 결함이었습니다. 요약이 판정에서 탈락한 이벤트를 추천하거나, "내년 추석"을 모델의 기억에 기대 올해 날짜로 답하는 식이었습니다. 19건 중 15건은 코드를 고쳤습니다. 나머지 4건은 데이터를 정리했거나, 재현되지 않았거나, 지연만 줄였습니다. 명절 날짜처럼 모델의 기억에 맡기던 것은 2025~2032년 공휴일 표로 바로잡습니다.
질의에서 빠진 개념을 코드로 찾기
가장 까다로운 결함은 질의 속 개념이 아무 표시 없이 사라지는 문제였습니다. "도쿄 근처 맥주 축제"에서 '맥주'를 나타낼 태그가 없자 해석에는 축제·식음료만 남았고, 결과에는 꽁치 축제와 크리스마스 마켓이 나왔습니다. 처음에는 맥주·와인이라는 단어를 사전에 넣어 막았지만, 이 방식은 다음 단어에서 다시 깨집니다. 다음으로 해석 모델에 어느 낱말을 어느 태그로 표현했는지 스스로 적게 해 봤습니다. "서울 와인 행사"에서 와인을 빠뜨린 모델은 그 표에도 와인을 적지 않아, 실측 뒤 접었습니다. 해석 모델은 원래 '어느 낱말을 조건 칸에 담았는지' 목록도 함께 냅니다. 지금은 이 목록을 코드가 다시 검사합니다. 장소·날짜·태그 어휘로 설명되지 않는 낱말이 남으면 사라진 개념 후보로 봅니다. 모델이 이 목록에도 적지 않고 버린 낱말은 이 검사에 보이지 않습니다.
"도쿄 근처 맥주 축제"가 지나가는 길
- 해석도쿄 · 축제 · 식음료 ('맥주' 빠짐)
- 사라진 개념 찾기모델이 흡수했다고 적은 낱말 중 태그로 설명되지 않는 말
- 주제인지 확인처음 보는 개념만 · 결과는 기록에
- 넓혀서 검색축제 그리고 (식음료 태그 또는 '맥주' 글자)
- 주제 엄격 판정맥주에 관한 행사만 남김
코드 · 규칙 LLM
찾아낸 개념은 처음 한 번만 LLM에 "검색할 주제가 맞는지" 묻고, 답을 기록해 두고 다시 묻지 않습니다. 주제로 확인되면 태그 또는 글자 조건으로 넓혀 찾고, 판정기가 "그것에 관한 행사인지"를 엄격하게 봅니다. 지난 30일의 질의 해석 666건(대부분 제 시험 질의)을 지금 방식으로 다시 돌리자 사라진 개념 11건을 잡았고, 그 과정에서 LLM 확인 호출은 107번이었습니다(10월 5일, 당시 모델 Gemini 3.1 Flash-Lite). 이 방식이 놓친 개념이 몇 건인지는 세지 않았습니다. 자주 나오는 개념은 새 태그 후보로 운영자 검수 큐에 올라갑니다.
판정기 묶음 크기와 정확도 기술 상세
판정기는 후보를 50개씩 묶어 보냈습니다. 지연을 줄이려 15개로 줄이자 판정 결과도 달라져서, 바꾸기 전에 실제 판정 입력 133쌍으로 평가셋을 만들어 정확도부터 확인했습니다(정답은 Claude가 블라인드로 매기고, 25쌍은 제가 직접 검토). 15개씩 묶어도 잘못 통과는 늘지 않았고(걸러야 할 46건 중 50개 묶음 6 · 5건, 15개 묶음 3 · 3건, 표본이 작아 유의하지 않음), 판정 시간 중앙값은 3.8초에서 2.5초로 줄었습니다(10월 5일, Gemini 3.1 Flash-Lite). 판정 모델을 Claude Haiku로 옮긴 뒤 같은 평가셋으로 다시 재니 잘못 통과가 46건 중 6~7건이었고, 이 역시 표본이 작아 단정하지 않았습니다.
프롬프트 인젝션 방어와 평가셋 780건
사용자가 입력한 문장도, 수집한 웹페이지와 장소 리뷰의 문장도 모두 모델에 들어갑니다. 그 안에 모델을 속이려는 지시가 섞일 수 있습니다(프롬프트 인젝션). 지시를 글자 패턴으로 찾아내는 규칙 검사는 아래 평가셋에서 공격의 13.5%만 막았습니다. 방어의 중심은 탐지가 아니라, 공격이 통하더라도 할 수 있는 일이 없게 코드로 막는 쪽에 두었습니다.
질문 하나가 지나가는 방어 층
- 입력 검사7개 언어 규칙 · 숨은 글자를 풀어서 봄
- 해석 · 판정 · 요약호출마다 출력 길이 상한
- 출력 검사링크 · URL 삭제 · DB에 없는 연락처 삭제
- 화면HTML · 링크를 그리지 않음
코드 · 규칙 LLM
이런 통제를 원칙 8개로 정리하고, 원칙마다 그 원칙을 지키는 코드 위치와, 어기면 실패하는 고정 테스트를 짝지었습니다. 모델 출력이 서버에서 임의의 주소로 요청을 보내는 길은 없습니다. 모델이 낸 지명으로는 정해진 지오코딩 서비스만 부르고, 길이와 개수에 상한을 둡니다. 모델이 낸 지명 별칭은 10월 8일부터 모든 사용자가 함께 쓰는 별칭 사전에 저장하지 않습니다. 이전 질문의 해석처럼 한 번 모델을 거친 글도 다시 들어갈 때 같은 검사를 받습니다. 출력 검사가 고장 나면 요약을 내보내지 않습니다(막는 쪽으로 실패). 입력 검사가 고장 나면 요약만 막고, 해석과 판정은 그대로 돕니다. 하루 질의 한도와 월 지출 상한은 스트리밍과 비스트리밍 두 경로에 똑같이 걸려 있고, 막히는 질의를 되풀이하는 사용자에게는 자연어 검색을 잠시 닫습니다. 다만 Redis가 멈추면 이 한도들은 검사하지 않고 통과시킵니다(열린 쪽으로 실패). 그동안 Gemini 쪽은 선불 크레딧이 마지막 상한입니다. Claude 쪽에도 공급자 콘솔에서 지출 한도를 걸어 두는 일이 남았습니다. 빈칸도 있습니다. 판정 캐시와 개념 확인 기록, 질의 해석을 하루 동안 담아 두는 캐시는 다른 사용자의 질의에도 쓰입니다. 판정 캐시는 이벤트 내용이 바뀌어도 그대로라서, 수집한 페이지에 숨긴 지시가 규칙을 피해 판정을 비틀면 그 판정이 최대 7일 동안 다른 사용자의 검색에도 쓰일 수 있습니다. 개념 확인 기록은 처음 남은 판정이 만료 없이 이어집니다. 해석 캐시는 지도와 현재 위치가 있는지도 키에 넣어, 지도 없이 받은 되묻기가 지도를 보는 사용자에게 가지 않게 했습니다. 남은 빈칸은 우선순위와 함께 문서에 적어 둡니다.
평가셋은 780건이고, Claude가 탐지 코드를 보기 전에 썼습니다. 판단이 갈리기 쉬운 30건을 제가 블라인드로 다시 판정하니 29건 중 25건이 일치했고(κ 0.73), 어긋난 4건을 검토한 뒤에도 라벨은 바꾸지 않았습니다. 공격 385건은 11가지 유형을 7개 언어로 따로 썼습니다(번역본이 아닙니다). 지시 무시, 역할극, 권한 사칭, 시스템 프롬프트 빼내기, 인코딩 우회, 보이지 않는 유니코드 글자에 숨긴 지시 등입니다. 정상 395건 중 150건은 밴드 System of a Down의 공연, 방탈출 "Jailbreak", "아까 검색은 무시하고 부산으로"처럼 공격과 닮은 정상 질의입니다. 규칙 검사는 같은 입력에 늘 같은 결과를 내므로, 코드가 바뀔 때마다 이 평가셋을 돌려 정상 질의를 막는 비율이 5%를 넘으면 테스트가 실패합니다(지금 0%).
규칙이 놓친 공격에 분류 모델로 점수 매기기 기술 상세
규칙이 놓치는 공격을 잡으려고, 소형 분류기(Horizon small)가 질의마다 인젝션 점수를 매겨 기록합니다. Horizon-Labs가 공개한 prompt-injection-guard-small로, ModernBERT 계열의 1억 4천만 파라미터 모델이고 합성 데이터 위주로 학습됐습니다. 점수가 임계값을 넘으면 공격으로 표시만 하고, 막지는 않습니다. CPU에서 질의 하나에 11~13ms라 GPU를 쓰지 않습니다.
| 평가셋 780건 | 공격을 잡은 비율 | 정상을 잘못 잡은 비율 |
|---|---|---|
| 규칙 검사 (지금 적용) | 22.1% 그중 막은 것 13.5%, 나머지는 검색은 하고 요약만 건너뜀 | 0.8% 막은 것 0%, 모두 요약만 건너뜀 |
| 분류기 (표시만 · 임계값 0.63 · 같은 평가셋의 정상 점수로 정함) | 74.3% | 0.76% |
임계값은 평가셋 정상 395건 점수의 상위 1% 바로 위로 정했습니다. 공격 점수는 보지 않고 정했지만, 정상을 잘못 잡은 0.76%(395건 중 3건)는 같은 평가셋에서 잰 값입니다. 임계값과 무관한 AUC는 0.971입니다. 그동안 쌓인 질의 기록 중 정상 1,343건(대부분 제 시험 질의)에서는 분류기가 3건(0.22%)을 공격으로 표시했고, 셋 다 같은 무의미 문자열이었습니다. 임계값을 정한 데이터와 따로 모은 정상 질의셋으로 오탐률을 다시 확인하기 전까지는 표시만 하고 막지 않습니다.
실호출 시험 124회 · 결함 19건(15건 코드 수정)이어 묻기 통과율 69%(한 문장 94% · 10월 7일)규칙이 잡은 공격 22.1%(막은 것 13.5%)분류기 74.3%(표시만)피해 범위를 좁힌 원칙 8개(원칙마다 고정 테스트)
운영 ① · 무엇을 어디에 쓸 것인가
기능별 모델 선택과 월 예산
모델과 공급자는 기능마다 따로 골랐습니다. 대화형 검색은 정확도와 지연을, 배치 작업은 비용을 먼저 봤습니다. 지금은 대부분의 LLM 호출을 Claude(Haiku, 중복 판정만 Sonnet)가 맡고, Gemini는 임베딩과 검색어 번역에 남았습니다. 웹페이지 추출은 Claude Haiku와 로컬 GPU의 Qwen을 예산에 따라 바꿔 씁니다. 예산도 기능별 월 상한으로 나눴습니다.
기능별 모델 표 · 공급자 관문 · 월 상한 · 서킷브레이커
| 용도 | 선택 | 왜 |
|---|---|---|
| 자연어 검색 (대화형, 8장) | Claude Haiku 5.5 | 단계마다 노력 수준을 따로 둠: 해석 medium · 판정 low · 개념 확인 low · 요약은 생각 끔. 해석 정답 60건에서는 이전 Gemini 3.1 Flash-Lite와 둘 다 거의 다 맞혀 품질로는 가를 수 없었음(Haiku 60건 대 Gemini 57건, p=0.25). 호출당 비용($0.0045 대 $0.0062)으로 고름. |
| 이벤트 번역 (7개 언어, 배치) | Claude Haiku 5.5 · high · 배치 API | 실시간일 필요가 없어 정가의 50%인 배치 API로 보냄. 모델은 작은 표본 비교와 비용으로 고름. |
| 중복 판정 (6장) | Claude Sonnet 5.5 · low | 의미 유사도 0.85 이상인 쌍만 물음. 합친 쌍의 정밀도를 주 지표로 골랐고, 비교 라벨은 Claude가 매겼고 사람 검토 전. |
| 웹페이지 이벤트 추출 (4 · 5장) | Claude Haiku 5.5 · high Qwen 9B · 양자화 · 로컬 GPU | 운영자 콘솔에서 바꿔 씀. 같은 24쪽을 추출 당시 날짜로 다시 읽히자, Qwen이 뽑은 96건의 45%는 이미 지난 행사였고 Haiku는 0건이었음. 끝 날짜를 적은 행사는 Haiku 쪽이 3.5배였고, 정답 라벨이 없어 어느 쪽이 더 맞는지는 가리지 못함. Haiku는 쪽당 약 $0.002로 지금 읽는 양이면 월 약 $80이라, 예산에 여유가 있을 때 Haiku로, 아니면 비용이 0인 Qwen으로 돌림. 추출 지시의 고정 부분(약 4,400토큰)을 앞으로 옮겨 캐시하자 호출당 비용이 $0.0016에서 $0.0009 안팎으로 내려감(운영 기록이라 읽은 페이지가 달라, 정확한 짝 비교는 아님). |
| 소스 발견의 검색 결과 분류 (3장) | Claude Haiku 5.5 · xhigh | 사이트 종류 · 페이지 종류 · 위치를 따로 묻는 분류기. 120행 정답 표본에서 크롤 대상을 모두 찾았고(정밀도는 0.52), 노력 수준을 낮추면 재현율이 0.90~0.94로 떨어졌음. 로컬 Qwen(이 모델 · 설정)은 여러 항목을 한 번에 받는 형식에서 URL 칸을 빠뜨려, 이 분류에는 쓰지 않음. |
| 사이트 문서 판독 · 목록 진입점 찾기 (4장) | Claude Haiku 5.5 · xhigh | 도구를 쓰는 에이전트 루프. 모델 호출 · 도구 · 가져오기에 상한을 두고, 결과는 서버가 원문과 페이지로 다시 확인. |
| 애매한 링크 2차 판정 (5장) | Claude Haiku 5.5 · high · 배치 API | 주 1회, 링크 20개씩 묶어 채점. 첫 실행에서 12만 6,862개에 약 $2.9. |
| 검색어 영어 번역 | Gemini 2.5 Flash-Lite | 교차언어 검색용. 대부분 캐시에서 나와 호출은 드묾. |
| 링크 게이트 (5장) | mDeBERTa · ONNX (v4) | LLM 판정과 추출 결과를 라벨로 삼아 학습한 소형 모델. 링크마다 LLM을 부르지 않고 워커 안에서 판정. |
| 입력 인젝션 분류기 (8장) | Horizon small · ONNX · CPU | 질의 하나에 11~13ms라 GPU를 쓰지 않음. 지금은 점수만 기록하는 단계. |
| 검색 임베딩 | gemini-embedding-001 (768d) | 다국어 품질. Claude에는 임베딩 API가 없어 공급자를 고르지 않음. 로컬 e5-small(384d)은 A/B 후 판단하도록 보류. |
| 리랭커 | bge-reranker-v2-m3 (로컬) OFF | 고정 질의 평가에서 품질 기준은 통과했지만 리랭커만의 몫은 nDCG@10 +0.03. CPU에서 후보 50개에 중앙값 1.76초가 걸려 검색창 예산(0.2초)을 넘어 켜지 않음(7장). |
공급자 관문 · 월 상한 · 서킷브레이커
상용 LLM 호출은 모두 기능 이름을 단 채 한 관문(공급자 라우팅)을 지납니다. 기능 24개의 호출 지점 27곳이 이 관문을 지나고, 이름 없이 새로 생긴 호출은 테스트가 막습니다. 기능마다 공급자 · 모델 · 노력 수준을 적어 두면 운영자 콘솔에서 재시작 없이 바뀝니다. 한 공급자를 쓸 수 없게 되면 다른 공급자로 넘기지 않고 그 경로를 끈 뒤 기능마다 정해 둔 대로 물러섭니다(자연어 검색은 키워드 결과로 답하고, 번역과 중복 판정은 다음 실행으로 미룸). 모델을 고를 때 잰 것과 다른 판정이 조용히 섞이지 않게 하려는 것입니다.
지출은 기능별 월 상한 버킷 32개로 나눕니다. Claude는 기능별 몫을 더하면 전체 상한($150)을 넘어서, 실제로는 전체 상한이 먼저 걸립니다. 겹치지 않게 더한 상한은 약 $360입니다(Gemini · Google 약 $210 + Claude $150, 2026-10-25 운영 설정값). 상한에 닿으면 그 기능만 막히고, 그 아래에 출처별 월 호출 횟수 한도와 분당·초당 처리율 제한이 한 겹 더 있습니다. 서킷브레이커는 공급자마다 둡니다. Gemini 쪽은 선불 크레딧 소진을 잠깐의 과부하(429)와 구분해 1시간, Claude 쪽은 크레딧 · 인증 · 워크스페이스 설정 오류에 15분 동안 그 경로를 끕니다.
8월 2일, 운영자가 꺼 둔 예약 작업들이 배포 직후 한 번씩 실행됐고, 그중 하나가 활성 도시 352곳의 모든 어댑터를 돌아 상용 추출 12,238건으로 선불 크레딧이 바닥났습니다. 그 뒤 나흘 동안 실패율 100%가 이어졌는데 알림이 없었습니다. 실패한 호출은 과금되지 않아서, 지출 상한과 비용 대시보드로는 이런 장애가 보이지 않습니다. 그래서 지출 알림 옆에 실패율 알림을 따로 두었습니다(알림을 받을 웹훅은 아직 비어 있어 지금은 로그와 점검 리포트로 봅니다).
LLM 공급자 2 + 로컬 Qwen월 상한 버킷 32첫 번역 배치 3,268건 · $1.48 · 실패 0건추출 호출당 비용 $0.0016 → 약 $0.0009(프롬프트 캐시)로컬 GPU RTX 3060 1장
운영 ② · 혼자서도 팀처럼
혼자 운영하기 위한 콘솔과 요청 추적
수집·검색·비용을 코드 배포 없이 다룰 수 있게 운영자 콘솔(한/영 · 20여 화면 — 지출 · 수집 · 소스 검수 · 데이터 품질 · 설정)을 만들었습니다. 지출 상한 · 처리율 · 크론 일정 · 기능별 LLM 공급자 같은 설정은 재시작 없이 바뀌고, 바꾼 기록이 남습니다.
한 요청을 같은 ID로 따라가기
관측은 요청 ID 하나로 묶습니다. 자연어 질의 한 건이 브라우저 로그, 서버 로그, 질의 로그, 외부 API 호출 로그, 태그 제안까지 같은 ID로 이어져, "이 답이 왜 이렇게 나왔나"를 다섯 곳에서 되짚을 수 있습니다.
테스트 12,259(10월 19일)마이그레이션 149커밋 2,611크론 스케줄 51설계·기록 문서 273
개발 · 만드는 방식
AI 코딩 세션 여러 개가 한 저장소에서 일하는 환경
이 프로젝트 커밋의 98%는 AI 코딩 도구(Claude Code)와 함께 만들었고, 많을 때는 세션 여러 개가 동시에 일했습니다. 이 도구는 대화가 끝나면 앞의 일을 잊고, 문서도 목차대로 찾지 않았습니다. 목차를 거쳐 간 읽기는 약 1%였습니다. 문서를 구역 단위로 찾는 검색 도구를 만들었고, 2주 동안 에이전트가 스스로 부른 것은 72번이었습니다(grep은 약 700번). 지금은 파일을 열 때 관련 문서를 자동으로 붙이는 방식을 주로 씁니다. 이 장은 이런 문제를 측정하고 도구로 메운 기록입니다.
세션 하나가 지나가는 길
- 시작규칙 파일 · 자주 고치는 문서 목록
- 찾기문서 · 커밋 검색 · 파일 카드
- 작업워크트리마다 개인정보를 가린 자기 DB 사본
- 작업 한 번이 끝날 때함께 안 고친 설계 문서 알림
- 남기기커밋 · 기억 파일
- 다른 세션에 전하기다음 입력 때 규칙 변경을 붙임
훅 · 도구 (코드) AI 세션 저장소
- 맥락을 잊는다파일을 열면 관련 문서가 카드로 붙고, 필요하면 문서 · 커밋을 검색한다지식 베이스
- 문서가 코드와 어긋난다작업 한 번이 끝날 때 함께 안 바뀐 설계 문서를 알린다정합 검사
- 바뀐 규칙을 모른다커밋된 규칙 변경을 다음 입력 때 한 번 붙인다지시 동기화
- 서로의 작업을 덮는다작업마다 워크트리와 DB 복사본을 따로 띄운다격리 실행
- 바깥 근거가 없다나라별 기술 블로그 · 논문 · 오픈소스 코드를 읽는 조사 스킬딥리서치
- 리뷰어가 없다영역별 리뷰 에이전트가 읽고, 중요한 지적은 작업 세션이 재현해 확인하고, 맥락 없는 새 에이전트가 마지막에 다시 읽는다다중 에이전트 리뷰
문서를 목차 대신 검색으로 찾게 하기 기술 상세
설계·기록 문서가 200개 가까이(측정 당시인 9~10월 기준) 쌓이자, 에이전트가 문서를 실제로 어떻게 찾는지부터 세어 봤습니다. 3주 치 세션 기록에서 문서 읽기 2,953건을 따라간 결과입니다.
| 문서를 찾아간 길 | 비율 | 무엇 중의 비율인가 |
|---|---|---|
| 목차 (첫 문서 → 색인 → 문서) | 약 1% | 문서 읽기 전체 |
| 규칙 파일에 적어 둔 목록에서 바로 | 85% | 자주 고치는 문서의 읽기 |
| grep (글자 검색) | 32~46% | 처음 보는 문서의 읽기 |
| 문서 안 링크 | 17~25% | 처음 보는 문서의 읽기 |
목차가 덮지 못한 폴더에서 일어난 읽기가 절반을 넘었습니다(56~66%). 문서를 찾아 읽고 고치는 데 전체 토큰의 약 10%가 들었고, 그중 3분의 2는 읽기가 아니라 문서를 고치는 데 쓰였습니다.
목차 대신 검색으로 문서를 찾을 수 있게 도구를 만들었습니다. 직접 만든 도구(agent-kb)가 문서를 제목 단위 구역으로 나누고, 왜 바꿨는지가 적힌 커밋 메시지까지 함께 BM25로 색인합니다. 파일을 열거나 고치면 그 파일을 설명하는 문서 3개와 최근 커밋 3개가 자동으로 카드처럼 함께 뜹니다. 오픈소스 메모리 도구 claude-mem도 2026년 10월 초에 검토했습니다. 제 작업량이면 3주에 관찰 호출이 수만 회로 추산돼, 도구는 들이지 않고 아이디어만 빌렸습니다.
도구를 둔다고 쓰이지는 않았습니다. 10월 5~19일 에이전트가 이 검색을 스스로 부른 것은 72번으로, 문서를 grep으로 찾은 약 700번의 10분의 1이었습니다. 무게는 에이전트가 부르지 않아도 파일을 열 때 저절로 닿는 카드 쪽으로 옮겼습니다(같은 기간 697번 뜸, 카드가 없을 때와는 견주지 않음).
도구는 다른 프로젝트에서도 쓰도록 CLI, MCP 서버, Claude Code 플러그인을 한 묶음으로 만들었습니다(자체 테스트 약 100개).
세션이 바뀌어도 일이 이어지게
다음 세션이 이어받을 것은 저장소에 남깁니다. AI 코딩 도구와의 작업 한 번이 끝날 때마다, 이 세션이 바꾼 코드를 근거로 삼는 설계 문서 중 함께 안 바뀐 것을 훅이 알립니다. 처음에는 같은 폴더에서 일하는 다른 세션의 변경까지 알려서, 알림을 보고 연 문서의 41%가 수정 없이 닫혔습니다. 세션 기록에서 이 세션이 쓴 파일만 골라내고, 문서에 순위를 매겨 상위 5개만 자세히 알리도록 고쳤습니다. 큰 머지 뒤에는 여전히 정밀도가 낮아서, 프런트 점검을 머지한 뒤 알림 20건 중 실제로 고칠 문서는 2건이었습니다. 배운 점과 결정은 두 층의 기억으로 남깁니다. 하나는 저장소 밖의 자동 메모리입니다. 다른 하나는 저장소 안의 기억 파일인데, 이 파일은 브랜치를 따라가고 머지할 때 함께 합쳐집니다.
규칙 파일(CLAUDE.md)은 세션 시작 · /compact · /clear · 재시작 때만 읽혀서, 오래 도는 세션은 그사이 다른 세션이 고친 규칙을 모른 채 일합니다(9월 28일~10월 8일 메인 세션 입력의 24%, 넉넉하게 센 값). 바뀐 파일을 구독한 세션에 알려 주는 pub/sub 방식 대신 범위를 좁혀, main에 커밋된 규칙 변경만 다음 입력 때 한 번 붙여 주는 훅을 만들었습니다. 워크트리 세션에는 그 브랜치에 아직 없는 main의 변경과 머지 충돌이 날 파일도 함께 알립니다.
세션마다 따로 띄우는 DB 사본
세션 여러 개가 같은 작업 폴더와 데이터베이스를 쓰면 서로의 작업을 덮습니다. 6월에는 두 데이터베이스 프로세스가 같은 데이터 폴더를 열어 운영 DB가 깨지는 사고도 있었습니다. 백업으로 되돌리지 않고 제자리에서 복구했고, 기록상 잃은 데이터는 없었습니다. 지금은 작업마다 git 워크트리를 따로 만들고, 그 안에서 명령 하나로 DB 사본과 자기 포트의 API와 Redis를 띄웁니다(약 40초). 사본의 원본은 새벽 운영 백업 중 하나로 만들고, 만들 때 계정 · 세션 · 대화 · 피드백 같은 개인정보 표의 행을 지웁니다. 질의 로그 같은 로그 표는 기본으로 빼고, 넣을 때도 사용자 식별자와 질의 원문은 가립니다. 운영 스택은 건드리지 않고, 테스트도 운영 DB를 쓰지 않습니다. DB는 머지하지 않고, 운영 데이터를 바꾸는 일은 스크립트와 마이그레이션으로만 합니다.
워크트리 안에서 운영 컨테이너 · 볼륨 · 이미지 태그를 건드리는 명령은 가드가 미리 막습니다. 가드는 실수를 막는 그물이고 보안 경계는 아닙니다.
바깥 근거를 가져오고, 여러 에이전트가 검토하기
설계를 정하기 전에는 바깥 근거부터 모읍니다. 직접 만든 딥리서치 스킬은 주제를 하위 질문으로 나눠 미국·한국·일본·중국의 기술 블로그를 각 나라 말로 찾고, 논문과 실무 커뮤니티, 관련 오픈소스 저장소의 코드까지 받아 읽습니다. 본문을 바로 받지 못하는 페이지는 RSS 피드나 공식 API로 읽고, 그래도 안 되면 브라우저로 화면을 그려 읽습니다. 결론을 받치는 근거는 원문으로 다시 확인해 확인 정도를 표시합니다. 보고서를 내기 전에는 대조 스크립트가 인용 링크와 비율 표기를 검사합니다. 직접 닿지 못하는 곳(Reddit·X 등)은 다른 딥리서치 도구에 넣을 프롬프트로 써서 맡기고, 돌아온 결과를 다시 원문과 대조합니다. 7장의 빈틈 15개, 8장의 보안 원칙과 평가셋, 대화 단위 평가의 설계가 이렇게 모은 근거에서 나왔습니다.
큰 변경 묶음은 여러 에이전트가 나눠 검토합니다. 10월 8일에는 사흘 동안 쌓인 282커밋을 리뷰 에이전트 9개가 영역을 나눠(일반 검색, 자연어 검색, 보안, 수집, 평가, 두 검색 사이의 동기화 등) 같은 체크리스트로 읽었습니다. 중간 이상이거나 여러 리뷰어가 겹쳐 짚은 지적 18건은 검증 에이전트 5개가 반박을 시도했습니다. 반박된 것은 없었고, 8건은 심각도가 내려갔습니다(높음에서 중간으로 1건, 낮음으로 7건). 검증하며 지적을 쪼개 실제 항목은 23개였고, 중간 이상으로 남은 높음 1건과 중간 12건을 같은 날 고쳤습니다. 높음 1건은 인증 없이 부를 수 있는 통로로, 모든 사용자가 함께 쓰는 지명 캐시에 좌표를 쓸 수 있던 문제였습니다. 리뷰하는 동안 운영 DB는 읽기 전용으로만 열었습니다. 검증 에이전트는 나온 지적이 맞는지만 보고, 리뷰가 놓친 것은 보지 못합니다. 그 뒤 수정 커밋 70개 중 10개가 이 리뷰가 읽은 줄을 다시 고쳤습니다. 추려 보니 리뷰가 놓친 결함은 2건('북해도 축제' · 문장형 질의 분해)이었고, 둘 다 리뷰가 아니라 측정과 제 사용에서 찾았습니다. 나머지는 리뷰 뒤에 생긴 결함이거나 줄만 겹친 개선이었습니다. 이후 리뷰 방식을 두 가지 바꿨습니다.
지금의 리뷰 순서
- 작업한 세션실패하는 테스트 먼저 · 고침
- 영역별 리뷰 에이전트처음 의도대로 고쳐졌나 · 커밋 메시지 · 변경 이력 · 설계 문서
- 재현으로 확인실패하는 테스트 · 실행 계획 · 실제 Chrome
- 새 맥락 최종 리뷰작업 맥락을 모르는 에이전트가 변경 전체를 다시 읽음
- 머지배포는 제가 실행
AI 에이전트 재현 · 측정 저장소
먼저 리뷰어의 점검 목록에 '처음 의도대로 고쳐졌는가'를 넣고, 커밋 메시지와 코드 줄마다의 변경 이력(git blame), 설계 문서로 그 의도를 거슬러 확인하게 했습니다. 10월 16일 브랜치 리뷰(7개 영역, 지적 83건)에서는 이 확인이 앞서 내린 판단 하나를 뒤집었습니다. 배포 때 DB 변경에 잠금 대기 상한을 두지 않은 이유로 적어 둔 걱정("인덱스를 동시에 만드는 마이그레이션이 깨진다")이, 그런 마이그레이션이 하나도 없어 근거가 없었습니다. 우선순위가 높은 지적(P1 · P2)은 검증 에이전트에게 맡기지 않고, 작업 세션이 실패하는 테스트나 실행 계획, 실제 Chrome으로 재현해 확인합니다. 확인해 보니 틀린 제안도 있었습니다. 지도 스크립트를 미리 받자는 제안은 첫 화면이 64ms 늦어져서 따르지 않았습니다. 10월 24일에는 148커밋을 8개 영역으로 나눠 다시 읽어, 가장 급한 등급(P1) 3건과 그다음 등급(P2) 약 25건을 찾았습니다. P1은 검증을 거쳐 확인해 둔 판단을 자동 작업이 지워 버리는 결함과, 판정 한 번으로 대상을 영구히 막아 버리는 결함이었습니다. 그중에는 배치 API 결과를 수거하다 재시도되면 비용이 두 번 적립되는 결함도 있었고, 결과를 점유한 작업만 수거하고 커밋한 뒤 한 번만 적립하게 고쳤습니다.
다음으로, 작업한 세션은 자기가 짠 테스트와 같은 맥락으로 코드를 보기 때문에, 테스트가 모두 통과한 뒤 작업 맥락을 모르는 새 에이전트가 변경 전체를 다시 읽게 했습니다. 10월 13일부터 19일까지 다섯 번 돌렸고, 네 번은 중요 이상의 결함을 찾았습니다. 공용 기기에서 앞사람이 저장한 이벤트가 다음 사람 계정으로 합쳐지던 문제, 배포하는 동안 앱 대신 502 오류 화면이 뜨던 문제, 더 보기를 누르면 일부 행을 건너뛰던 문제가 그렇게 나왔습니다. 이 리뷰어도 같은 모델 계열이라는 한계는 남습니다.
10월 13일 프런트엔드 전체 점검에서는 관점 6개(사용성 · 모바일 · 접근성 · 다국어 · 시각 · 성능)의 리뷰어가 90건을 냈고, 여러 에이전트가 구현과 수정을 나눠 맡아 53커밋으로 마쳤습니다. 결정 41건은 '틀렸을 때의 비용'과 함께 기록에 적었습니다.
6장의 이벤트 여부 판정 3만 7천 행과 중복 판정, 7장의 정답 라벨(처음 4,805쌍, 지금 6,066쌍)은 같은 지침을 받은 서브에이전트들이 묶음을 나눠 처리했습니다. 반영 전에는 검증 스크립트와, 일을 나눠 맡긴 세션이 결과를 다시 확인합니다. 같은 계열의 모델끼리 확인하는 데는 한계가 있어서, 점수에 쓰이는 라벨은 제가 표본을 직접 검토합니다.
에이전트에게 맡긴 것과 제가 정한 것
에이전트는 구현 · 테스트 · 측정 · 리뷰와, 라벨 · 중복 · 이벤트 여부 같은 대량 판정을 맡습니다. 에이전트가 계획과 근거를 올리면 저는 그것을 읽고 방향을 정하고, 비용이 드는 실행은 제가 승인합니다. 머지와 운영 배포는 제가 직접 실행합니다. 아래는 커밋 · 설계 문서 · 결정 기록에 남은, 제가 정하거나 먼저 짚은 일입니다.
| 시기 | 제가 정하거나 먼저 짚은 것 |
|---|---|
| 5월 | 도시 × 어댑터 단위로 수집을 켜고 끄는 구조 · 검색 엔진으로 찾은 도메인을 수집 후보로 쓰자는 제안(3장) |
| 6월 | 제가 모아 온 리서치(수집 방식 · 순위 매기기)를 설계에 반영 |
| 9월 | 이미 방문한 곳을 기억하며 훑는 탐색 제안(4장) · 도시 256곳 활성화 · 좌표는 캐시를 먼저 보게 · 한꺼번에 몰리는 판정은 유료 API 대신 개발 도구의 에이전트로(6장) |
| 10월 초 | 공개 출시를 미루고 개인 장비에서 운영하기 · 평가 라벨은 Claude 블라인드에 제 표본 검토를 더하는 방식(7장) · 의미 검색 기준선을 AI 검색까지 넓히지 않기 · 동네 검색 범위 방침 · 일반 검색 평가셋에 AI 검색 문장이 섞였다는 지적(7장) |
| 10월 중순 | '북해도 축제' · '간사이 마츠리' 결함 발견과 지역 이름 사전 제안(7장) · Claude 경로 도입과 월 상한 · 공급자 자동 전환 금지와 Claude 기능별 예산 · 번역 배치 API(9장) · 중복 판정 모델을 정밀도로 고르기 |
AI와 함께 만든 커밋 98%추적한 문서 읽기 2,953건목차를 거친 읽기 1%규칙이 바뀐 걸 모른 채 받은 입력 24%작업용 DB 사본 띄우기 약 40초새 맥락 리뷰 5번 중 4번에서 중요 결함 찾음