사내 RAG 봇 '피클박사' 사례 정리 — 비용은 구조에서, 품질은 측정에서, 함정은 '변경 시각'에서
피클플러스 기술 블로그에 올라온 「월 2만 원짜리 AI 봇이 사내 질문 1,198개에 답한 건에 대하여」(김민재 / Joshua, 백엔드 파트 리드)를 읽고 정리한다. 노션과 슬랙을 학습해서 출처를 달아 답하는 사내 슬랙 봇 “피클박사” 를 약 6개월 동안 만들고 운영한 기록이다.
글이 좋은 건 완성된 구조가 아니라 삽질 순서대로 쓰였다는 점이다. 각 결정이 어떤 사고에서 나왔는지 보이니까, 그대로 따라 하지 않아도 판단 기준을 가져올 수 있다.
이 글의 수치는 모두 원문 저자의 보고다. 내가 재현하거나 검증한 값이 아니다. 원문이 기대는 기술(RRF, bge-m3, lost in the middle, 프롬프트 캐싱, Socket Mode 등)은 1차 출처를 따로 찾아 붙였다.
0. 한눈에 보기
요구사항 세 개 — 슬랙에서 @피클박사 로 물으면 사내 문서를 근거로 답하고 출처를 남긴다. 문서에 없으면 지어내지 않고 “자료에서 찾을 수 없다” 고 한다. 운영비는 최대한 낮춘다. 정책을 다루는 봇이라 두 번째가 핵심이었다고 한다. “그럴듯하게 틀린 답을 하는 봇은 없는 것보다 위험하니까요.”
최종 구조 — EC2 한 대(t3.xlarge) 위 docker compose 컨테이너 다섯 개.
| 컨테이너 | 역할 |
|---|---|
app |
Spring Boot + Kotlin. 슬랙 봇, 하이브리드 검색, Claude 호출, 피드백 수집 |
chromadb |
벡터·메타데이터 저장소 |
ollama |
bge-m3 임베딩 (1024차원) |
sync |
노션 → ChromaDB 증분 동기화 (Python, 매시간) |
slack-sync |
슬랙 스레드 → ChromaDB (3시간마다) |
원문이 보고한 숫자 — 청크 25,000개 이상, 질문당 응답 5~10초, 서버 이전 후 3개월 반 동안 동료 32명이 1,198건 질문. 유료 항목은 Claude API 와 EC2 한 대뿐이다.
1. 비용 — 모델이 아니라 구조가 정한다
첫 버전은 노션 문서 350여 개를 통째로 Claude 에 넣었다. 답은 좋았지만 질문당 약 120,000 토큰, 약 $0.72. 하루 50건이면 월 약 $1,080. 게다가 문서가 조금만 늘어도 컨텍스트 창을 넘어선다. 비용 문제이기 전에 확장이 안 되는 구조였다.
RAG 로 바꾸자 질문당 약 5,000 토큰, 약 $0.04 로 내려갔다(원문 표현으로 약 94% 절감). 그 위에 두 가지를 더 얹었다.
- 유사도 컷 — Top 15 를 무조건 넘기지 않고 거리가 먼 조각은 버린다(최소 3개 보장). 전달 텍스트가 약 40% 줄고 답이 더 정확해졌다.
- 프롬프트 캐싱 — 매번 같은 시스템 프롬프트를 캐시. 질문 간격이 5분보다 길어 캐시가 계속 만료돼서 TTL 을 1시간으로 늘렸다.
그리고 비싼 모델은 한 곳에만 쓴다. 답변 작성만 Sonnet, 의도 추출·집계 판별·map 단계는 전부 Haiku. 임베딩·벡터 검색·키워드 검색은 서버에 직접 띄워 0원.
2. 한국어라면 임베딩 모델부터 의심하라
처음 쓴 nomic-embed-text 로는 “25년 지표” 를 검색하면 “API 명세” 가 나왔다. 원인은 단순했다. 한국어를 못 했다. 다국어 모델 bge-m3 로 바꾸자 같은 질문에 맞는 문서가 나왔다.
bge-m3 는 BAAI 의 M3-Embedding 논문 모델로, 논문 초록에 “100개 이상의 언어”, 최대 8,192 토큰 입력, dense·sparse·multi-vector 검색을 한 모델로 지원한다고 적혀 있다.1
원문의 조언 그대로 옮긴다.
한국어 문서로 RAG 를 만든다면, 파이프라인을 손보기 전에 임베딩 모델부터 한국어로 테스트해보세요.
청크 크기, 프롬프트, 리랭커를 아무리 만져도 임베딩이 한국어를 못 알아들으면 소용없다. 순서의 문제다.
3. 배치와 상주 봇을 한 프로세스에 두지 마라
처음엔 노션 동기화도 Spring Boot 앱 안에서 돌렸다. 매번 OOM. WebFlux 를 걷어내도, 응답 크기를 재봐도(페이지당 22~74KB) 원인이 아니었다. 진짜 원인은 청크 분할 함수의 무한 루프였다 — 텍스트 끝에서 start = end - overlap 이 같은 자리로 계속 돌아왔다. 메모리가 50GB 넘게 올라가 macOS 가 SIGKILL 로 죽였다.
버그를 고친 뒤 구조도 바꿨다. “무거운 배치 작업과, 항상 응답해야 하는 봇을 하나의 JVM 에 같이 두지 않기로 했습니다.” 동기화를 Python 으로 분리하자 sync 약 30MB, app 약 256MB 로 안정됐다.
덤으로 하나. 컨테이너가 로그 한 줄 없이 137 로 죽었던 건 Python 의 stdout 버퍼링 때문이었고, PYTHONUNBUFFERED=1 을 주고서야 죽기 직전 로그가 보였다. 조용히 죽는 컨테이너를 디버깅할 때 가장 먼저 의심할 곳이다.
4. 벡터 검색만으로는 고유명사가 묻힌다 — BM25 + RRF
운영해 보니 “NPS”, “핫딜”, 사람 이름처럼 짧고 중요한 단어가 문장 임베딩에 희석됐다. 문서가 늘수록 주제만 비슷한 문서가 상위를 채웠다. 벡터 검색이 약한 곳이 정확히 키워드 검색이 강한 곳이다.
둘을 합치려면 문제가 있다. 벡터 검색은 거리를, BM25 는 점수를 내놓아 단위가 다르다. 그래서 점수 대신 순위만 쓰는 RRF(Reciprocal Rank Fusion)를 썼다.
score(d) = Σ 1 / (60 + rank_i(d))
RRF 는 Cormack·Clarke·Büttcher 의 SIGIR 2009 논문에서 나온 방법이고, 상수 60 도 그 논문에서 왔다. 논문은 k = 60 을 파일럿 실험에서 고정했고 거의 최적이었다고 적는다.2 여러 검색기에서 동시에 상위에 오른 문서가 자연스럽게 올라오는 구조다.
구현 디테일 몇 가지.
- BM25(Lucene) 인덱스는 앱 메모리에 두고, 시작 시 ChromaDB 전체를 읽어 만들고 60분마다 재생성. 준비 전 질문은 벡터 검색만으로 답한다.
- Haiku 가 질문에서 군더더기를 걷어낸 검색어를 따로 만든다(“조슈아에 대해 너가 아는 거 다 알려줘” → “조슈아”).
- 검색기 네 개를 각각 예외 격리해서 하나가 죽어도 나머지로 RRF 를 돈다.
청크가 14,000개에서 25,000개 이상으로 늘자 파라미터도 다시 잡았다. 유사도 거리 컷 0.6 → 0.5, 페이지당 최대 청크 3개(긴 페이지 하나가 상위를 독점하지 못하게), 90일 이내 문서 최대 +20% 가산점, JVM heap 512m → 1g. 코퍼스 크기가 바뀌면 검색 파라미터도 바뀐다는 게 요점이다.
5. “전부 세어 줘” 는 RAG 가 못 한다 — MBTI 사건
“10문 10답 기준으로 구성원 MBTI 분포 정리해줘” 라는 질문에 봇이 40명 가까운 구성원 중 7명만 자신 있게 정리했다. 버그가 아니었다. 검색 결과는 최종 10청크, 페이지당 3청크 제한. 40명의 문서를 10청크에 담는 건 구조적으로 불가능하다.
“가장 비슷한 문서 K개를 찾는 것”과 “전체를 빠짐없이 세는 것”은 다른 문제입니다.
그래서 집계 전용 경로를 따로 만들었다. Haiku 가 “문서 유형 전체를 집계하는 질문인가” 를 판별하고, 유사도 검색을 건너뛰고 제목 메타데이터 필터로 해당 유형을 전수 조회(38개 문서, 44청크)한다.
그런데 38개를 다 넘겨도 Claude 가 중간 문서 몇 개를 빠뜨렸다. 원문은 이걸 lost in the middle 이라 부른다. Liu 등의 논문은 관련 정보가 입력의 처음이나 끝에 있을 때 성능이 가장 높고, 중간에 있을 때 크게 떨어진다고 보고했다.3
해법은 map-reduce 다. 문서를 8개씩 나눠 Haiku 가 병렬로 {이름, 값} 만 뽑고(map), 압축된 목록만 Sonnet 이 집계한다(reduce). 출처 목록은 LLM 이 “외 N개” 로 뭉뚱그리지 못하게 코드가 직접 만든다. 결과가 29명에서 37명으로 늘었다.
6. 외부 서비스의 “변경 시각” 은 실측으로 확인하라
원문에서 가장 오래 고생한 부분이다. 증분 동기화는 노션의 last_edited_time 으로 바뀐 페이지만 다시 학습한다. 탐색도 노션 /v1/search 를 last_edited_time 내림차순으로 받아 워터마크보다 오래된 페이지가 나오면 멈추게 바꿔서, 몇 시간 걸리던 탐색을 수십 초로 줄였다. search API 가 last_edited_time 기준 정렬(ascending/descending)을 지원하는 건 노션 API 레퍼런스에 나와 있다.4
문제는 이 시각이 기대한 때 갱신되지 않는다는 것이었다. 같은 원인으로 세 번 터졌다.
- 본문이 빈 페이지 — 정보가 전부 페이지 안의 인라인 DB 에 있었는데 sync 가 DB 블록을 건너뛰었다. → DB 행을
통신사=SKT | 지역=…처럼 한 줄씩 펼쳐 부모 페이지에 학습. 청크가 잘려도 “열=값” 쌍이 깨지지 않게 한 줄에 담았다. - 행을 추가해도 반영 안 됨 — 원문이 실제 API 응답으로 확인한 바로는, 하위 DB 의 행이 바뀌어도 부모 페이지의
last_edited_time이 갱신되지 않았다. → 변경된 DB 가 보이면 부모 페이지 캐시 시각을 강제로 갱신. - 행 속성값을 고쳐도 반영 안 됨 — 이번엔 DB 자체의 시각도 안 바뀌었다. 바뀌는 건 행 페이지 자신의 시각뿐. → 행 페이지를 학습할 때 속성까지 직렬화해서, 부모나 DB 타임스탬프에 기대지 않게 했다.
2·3 번은 원문 저자가 “공식 문서에도 나와 있지 않은 동작” 이라고 쓴 관찰이다. 교훈은 노션에 한정되지 않는다.
외부 서비스의 동작은 문서가 아니라 실제 응답으로 확인해야 합니다.
같은 맥락에서 하나 더. DB 하나가 “변경됨” 으로 잡히면 그 DB 의 모든 행을 재처리하던 버그를 행 조회에 last_edited_after 필터로 막자, 변경 감지 건수가 3,701건에서 7건으로 줄었다.
7. 증분 동기화는 한 번 놓친 걸 다시 보지 않는다
본문 124,596자, 84청크짜리 페이지 하나가 CPU 로 도는 bge-m3 에 한꺼번에 들어가 타임아웃이 났다. 3번 재시도 후 실패, 다음 실행에서 또 실패. 2분이면 끝나던 동기화가 19분, 33분으로 늘었다.
더 나쁜 건 그다음이다. 증분 동기화는 워터마크 이후 변경분만 보기 때문에 실패한 페이지는 다시 수정되기 전까지 영원히 학습되지 않는다.
- 84청크를 16개씩 하위 배치로 나눠 배치마다 따로 재시도.
- 실패 페이지를
failed_pages.json에 남겨 다음 동기화에서 자동 재시도, 24번(업무 시간 기준 약 이틀) 넘으면 멈추고 사람에게 알림. - 알림을 성공/실패 2단계에서 🚨 새 실패 / ⚠️ 재시도 대기 / ✅ 정상 3단계로, 예외 이름 대신 상황 설명으로.
33분짜리 작업이 2분 30초가 됐다. 같은 이유로 “사용 중단” 태그가 붙은 페이지를 제외할 때도, 오래전에 태그가 붙어 수정 시각이 안 바뀐 페이지는 일회성 스크립트로 따로 정리해야 했다.
8. 품질은 이모지로 잰다
MBTI 답에 달린 👎 를 우연히 보고 나서, 답변에 달리는 이모지를 전부 SQLite 에 모으기 시작했다. 설계 포인트가 좋다.
- 이모지는 원본 그대로 저장하고, 저장 시점에 의미를 해석하지 않는다.
- 반응 취소도 지우지 않고
removed_at만 기록한다. - 해석용
sentiment(+1/0/-1) 컬럼은 분류 규칙이 바뀌면 앱 시작 시 과거 데이터까지 재계산.
원본을 남기고 해석은 나중에 하는 구조라, 실제로 👍🏻·✅ 가 중립으로 잘못 분류되던 걸 나중에 고칠 수 있었다.
원문의 처음 두 달 집계는 답변 752건, 반응 305건(반응률 28%), 만족도 63%(긍정 114 / 명확한 긍정·부정 180). 더 쓸모 있었던 건 숫자보다 부정 반응이 몰린 곳이었다. 휴대폰 사업 용어와 CX 정책 예외(재매칭, 해산, 환불)에 몰려 있었고, 봇이 틀린 게 아니라 그걸 학습한 적이 없었다. 해당 팀 질문 채널을 학습 대상에 넣어 해결했다. 측정이 검색 품질이 아니라 데이터 커버리지 구멍을 찾아 준 셈이다.
9. 인프라 — 문제 크기에 맞춘다
ECS Fargate 로 태스크 정의까지 만들어 두었다가 EC2 한 대 + docker compose 로 내렸다. 결정적 이유는 4장에서 고른 슬랙 Socket Mode 였다.
Socket Mode 는 공개 HTTP Request URL 없이 Events API 를 쓰게 해 주는 방식으로, 슬랙과 앱이 WebSocket 으로 통신한다.5 봇이 먼저 나가서 연결하니 들어오는 요청이 하나도 없고, 로드밸런서·도메인·HTTPS 인증서가 모두 필요 없다. 같은 원리로 서버 접속은 SSM Session Manager(인바운드 포트 0, SSH 키 없음), 배포 인증은 GitHub OIDC(장기 AWS 키를 어디에도 저장하지 않음). 파이프라인은 GitHub Actions → ECR → SSM → docker compose.
하나 짚어 두면, 슬랙 문서는 Socket Mode 앱이 현재 공개 Slack Marketplace 에는 올라갈 수 없다고 적고 있다.5 사내 봇이라 상관없지만, 같은 봇을 외부 배포용으로 키울 계획이면 처음부터 고려할 제약이다.
또 하나 실용적인 팁. 맥북에서 90분 걸려 만든 임베딩을 서버에서 다시 만들지 않고 ChromaDB 볼륨을 그대로 옮겼다(1.3G → tar 714M → S3 → EC2). 조건은 두 가지였다. ChromaDB 버전 고정(원문은 0.5.23), 볼륨 이름 일치(compose 프로젝트 이름이 다르면 빈 볼륨이 새로 생겨 “복원했는데 데이터가 없는” 상태가 된다).
10. 읽으며 짚어 둘 점
원문은 충실하지만, 숫자를 가져다 쓸 때 주의할 곳이 있다.
① “월 2만 원” 은 API 비용만이다. 원문의 비용 표는 Claude API(답변, Sonnet) 월 약 $7~19, Haiku 거의 0, 임베딩·검색 $0, 그리고 EC2 t3.xlarge 월 약 $150(서울 리전 온디맨드 기준)이다. 저자도 “(물론 서버비는 별도입니다…)” 라고 적었다. 합치면 월 비용의 대부분은 서버다. 이건 흠이 아니라 오히려 이 글의 진짜 결론을 보여 준다 — 구조를 잘 짜면 LLM 호출비는 서버비 아래로 내려간다. 다만 제목만 보고 “월 2만 원이면 사내 RAG 가 된다” 고 옮기면 틀린다.
② 프롬프트 캐싱의 할인율과 1시간 TTL 의 비용. 원문은 캐시된 부분이 “90% 할인” 된다고 썼다. Anthropic 문서 기준 캐시 읽기는 기본 입력 단가의 0.1배가 일반적이지만 모델에 따라 다르고, 1시간 TTL 은 캐시 쓰기 단가가 기본 입력의 2배(5분 TTL 은 1.25배)다.6 질문이 드문 사내 봇이라면 1시간 TTL 이 맞는 선택이지만, 쓰기 비용이 늘어난다는 점까지 같이 봐야 계산이 맞는다.
③ “200시간 절약” 은 가정이다. 원문 스스로 “질문 하나에 묻는 사람과 답하는 사람이 5분씩만 아꼈다고 쳐보겠습니다” 라고 전제를 밝혔다. 1,198건 × 10분 ≈ 200시간은 그 가정의 산술이지 측정값이 아니다. 반대로 맥북 시절 질문은 집계에서 빠졌다고 하니, 실제 질문 수는 더 많을 수 있다.
④ 만족도 63% 의 분모. 752건 전체가 아니라 명확한 긍정·부정 반응 180건 중 긍정 114건이다. 반응률 28% 와 같이 읽어야 한다.
11. 가져갈 체크리스트
원문 13장의 체크리스트를 내 말로 줄이면 이렇다.
- 설계 — 슬랙 봇은 Socket Mode 로 시작(공개 배포 계획이 없다면). 배치와 상주 봇은 프로세스 분리. 프롬프트에 “문서에 없으면 모른다고 하라” 를 넣는다.
- 검색 — 한국어면 임베딩 모델부터 한국어로 테스트. 벡터 + BM25 를 RRF 로 합친다. 코퍼스가 커지면 거리 컷·페이지당 청크 수·최신성 가산점을 다시 잡는다.
- 질문 유형 — “전부 정리해 줘” 는 RAG 가 아니라 메타데이터 전수 조회 + map-reduce. 출처 목록은 LLM 말고 코드가 만든다.
- 데이터 — 외부 서비스의 변경 신호는 실제 API 응답으로 확인. 실패한 동기화는 재시도 큐에 남긴다. 슬랙 데이터는 학습할 때와 내보낼 때 두 번 거른다(그룹 멘션이 답변에 섞여 CX팀 전체에 알림이 간 사고가 있었다).
- 운영 — 이모지 반응을 원본 그대로 저장. 모델 ID 는 설정으로 뺀다.
원문 마지막 문장 중 하나를 그대로 옮기며 마친다. “좋은 모델보다 좋은 구조가 비용을 줄입니다.”
References
- 원문: 김민재(Joshua), 「월 2만 원짜리 AI 봇이 사내 질문 1,198개에 답한 건에 대하여」, Pickle Plus 기술 블로그. https://pickle.plus/tech-blog/posts/pickle-doctor/
-
J. Chen, S. Xiao, P. Zhang, K. Luo, D. Lian, Z. Liu, “M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation”, arXiv:2402.03216. https://arxiv.org/abs/2402.03216 ↩
-
G. V. Cormack, C. L. A. Clarke, S. Büttcher, “Reciprocal Rank Fusion outperforms Condorcet and Individual Rank Learning Methods”, SIGIR ‘09. http://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf ↩
-
N. F. Liu, K. Lin, J. Hewitt, A. Paranjape, M. Bevilacqua, F. Petroni, P. Liang, “Lost in the Middle: How Language Models Use Long Contexts”, Transactions of the ACL. arXiv:2307.03172. https://arxiv.org/abs/2307.03172 ↩
-
Notion API Reference,
POST /v1/search— sort bylast_edited_time. https://developers.notion.com/reference/post-search ↩ -
Slack Developer Docs, “Using Socket Mode”. https://docs.slack.dev/apis/events-api/using-socket-mode/ ↩ ↩2
-
Anthropic, Claude Platform Docs, “Prompt caching” — cache lifetime and pricing multipliers. https://platform.claude.com/docs/en/build-with-claude/prompt-caching ↩