단일 SQLite 파일로 구동되는 AI 개인 맞춤형 뉴스 봇 구축기 — 아키텍처와 발생한 문제들
요약
단일 SQLite 파일과 sqlite-vec 확장을 활용하여 별도의 DB 서버 없이 구동되는 AI 뉴스 봇 아키텍처를 소개합니다. 중복 제거, 클러스터링, 번역 기능을 갖춘 저비용 고효율의 개인 맞춤형 뉴스 피드 구축 경험을 다룹니다.
핵심 포인트
- pgvector 대신 sqlite-vec을 사용하여 인프라 복잡도와 비용 절감
- Redis나 별도 DB 서버 없이 단일 SQLite 파일로 상태 관리
- OpenRouter와 로컬 ONNX 임베딩을 활용한 저비용 LLM 체인 구축
- Fly.io 환경에서 단일 워커 프로세스로 효율적인 서비스 운영
단일 SQLite 파일로 구동되는 AI 개인 맞춤형 뉴스 봇 구축기 — 아키텍처와 발생한 문제들
요약(TL;DR) — @futur_e_news_bot. 이중 언어
(EN/RU) Telegram 뉴스 피드입니다. AI가 중복을 제거하고, 하나의 이벤트를 하나의 카드로 클러스터링(Clustering)하며,
번역을 수행하고, 사용자의 반응에 따라 피드를 조정합니다. 스택: aiogram, 로컬 ONNX
임베딩 (Embeddings), pgvector 대신 sqlite-vec 사용, OpenRouter를 통한 무료 LLM 체인,
Fly.io의 단일 워커(Worker). 월 약 $5로 시작했으나, 세 가지 흥미로운 방식으로
고장이 났으며 이에 대해 설명하겠습니다.
저는 두 가지가 없는 뉴스 피드를 원했습니다: 중복 (다섯 개의 매체에서 다섯 개의 헤드라인으로 보도하는 동일한 사건)과
노이즈 (관심 없는 의제의 90%). 그래서 주말 동안 작업한 결과, 중복을 제거하고 🔥/❤️/😢
반응을 통해 제가 무엇을 좋아하는지 학습하며, 모든 것을 두 가지 언어로 보여주는 Telegram 봇을 만들게 되었습니다.
흥미로운 점은 아이디어가 아니라, 이 모든 것이 데이터베이스 서버, Redis, 큐(Queue) 없이 단일 SQLite 파일에 대항하는 하나의 워커
프로세스로 실행된다는 점입니다. 어떻게 구현했는지, 그리고 그 비용(달러와 디버깅 측면 모두)이 얼마였는지 소개하겠습니다.
아키텍처: 하나의 워커, 하나의 SQLite, DB 서버 없음
┌──────────────────────────┐ ┌────────────────────┐
│ Fly machine #1 │ │ Fly machine #2 │
│ ┌─────────────────────┐ │ │ (private-only) │
...
공용 HTTP, 로드 밸런서(Load Balancer), 별도의 DB 머신이 없습니다. 봇이 Telegram을 폴링(Polling)하고,
APScheduler가 작업을 실행하며, 모든 상태(State)는 볼륨(Volume) 내의 단일 SQLite 파일에 저장됩니다.
RSSHub(공용 Telegram 채널을 RSS로 연결하는 도구)는 Fly의 프라이빗 네트워크를 통해서만 접근 가능한 두 번째 앱입니다.
취미용 인스턴스는 512 MB에서 약 월 $5면 충분합니다 (트래픽 급증 이후 현재는 2 GB ≈ 월 $15를 사용 중입니다 — 이유는 아래에서 설명하겠습니다). LLM 비용은 대부분의 호출이 무료 모델을 사용하기 때문에 월 $0–1 수준입니다.
pgvector 대신 sqlite-vec
처음에는 Postgres + pgvector로 시작했습니다. 그것도 아주 잘 작동하지만, 별도의 DB 머신(Fly에서 추가로 약 월 $5 소요)이 필요하며, 자체적인 비밀 정보(secrets), 백업, 마이그레이션(migrations) 등이 필요합니다. 즉, 임베딩(embeddings)을 저장하기 위해 너무 많은 인프라(infra)가 필요했습니다.
sqlite-vec은 SQL 내에서 코사인(cosine)/L2/Hamming KNN을 지원하는 vec0 가상 테이블을 추가해 주는 네이티브 SQLite 확장 기능입니다. 기본적으로 pgvector와 유사하며, 임베디드(embeddable) 방식이고 서버리스(serverless)로 동작합니다.
# 벡터 테이블 생성
await conn.exec_driver_sql(
"CREATE VIRTUAL TABLE IF NOT EXISTS story_vec "
...
이것은 세 가지 기능을 수행합니다: 수집 시 중복 제거 (dedup at ingest) (임계 거리 내의 최근접 이웃(nearest neighbor) 탐색 → 중복으로 간주), "이와 유사한 내용(more like this)", 그리고 시맨틱 인라인 검색 (semantic inline search). 결과적으로, 두 대의 머신 대신 한 대의 머신만 사용하게 되었고, 벡터 저장 비용은 0이며, 인프라 비용은 거의 제로에 가깝습니다. SQLite는 단일 쓰기(single-writer) 방식이지만, 단일 워커(worker) 환경에서는 WAL + busy_timeout으로 해결되므로 문제가 되지 않습니다.
무료 LLM 체인
각 기사는 한 번의 LLM 패스를 거칩니다: 분류(카테고리, 태그, 중요도, "속보"), 요약, 영-러(EN↔RU) 번역, 그리고 톤(tone) 점수 산정 0–3 ( "좋은 뉴스만 보기" 토글용). OpenRouter를 사용하면 모델 배열을 전달할 수 있으며, 자동으로 폴백(fallback)이 수행됩니다:
"models": [
"qwen/qwen3-next-80b-a3b-instruct:free",
"meta-llama/llama-3.3-70b-instruct:free",
...
그 위에 글로벌 속도 제한기(rate limiter)와 429/5xx 에러에 대한 지수 백오프(exponential backoff)를 적용했습니다. 무료 모델이 트래픽의 90% 이상을 처리하므로, 월간 청구 금액은 기본적으로 오차 범위 수준입니다.
로컬 임베딩 (fastembed / ONNX)
모든 기사와 모든 검색 쿼리에 대해 임베딩(embeddings)이 생성됩니다. 이를 위해 API를 호출하는 것은 비용, 지연 시간(latency), 그리고 개인정보 유출의 원인이 됩니다. fastembed는 PyTorch 없이 ONNX 상에서 sentence-transformers를 실행합니다 — paraphrase-multilingual-MiniLM-L12-v2 모델을 사용하며, 384 차원(dims), CPU에서 약 10–30ms가 소요됩니다. 무료이며 프라이빗(private)합니다.
추천 시스템: 맛 벡터(taste vector) + EWMA + 안티 버블(anti-bubble)
각 사용자는 taste_vec(스토리와 동일한 384차원 공간)를 가집니다. 처음에는 null 상태로 시작하며, 첫 번째 🔥/❤️ 반응이 발생하면 해당 스토리의 임베딩(embedding)을 복사합니다. 그 이후의 모든 반응은 지수 이동 평균(Exponential Moving Average, EWMA)을 통해 벡터를 미세하게 조정합니다. 링크 클릭은 '좋아요'보다 더 강력한 신호이므로 벡터를 더 크게 이동시킵니다. 😢 반응은 벡터를 멀어지게 만듭니다.
랭킹(Ranking) = cosine(taste, story) + 카테고리 친화도(category affinity) + 태그 친화도(tag affinity) + 최신성(recency)이며, 피드가 에코 체임버(echo chamber)로 붕괴되는 것을 방지하기 위해 작은 "발견(discovery)" 주입이 추가됩니다. 전체 과정은 마지막 수백 개의 후보군에 대해 numpy를 이용한 브루트 포스(brute-force) 방식으로 수행됩니다. 이 규모에서는 밀리초(milliseconds) 단위로 처리되며, 랭킹을 위해 벡터 인덱스(vector index)를 연결하는 것보다 훨씬 간단합니다(sqlite-vec은 KNN 형태의 문제를 처리하며, 랭킹은 최근접 이웃(nearest-neighbor)이 아닌 가중치 점수(weighted score)를 필요로 합니다).
그리고 세 번의 장애가 발생했습니다
솔직한 이야기입니다. 이 중 어느 것도 의도된 것이 아니었으며, 모두 운영상의 문제였습니다.
1. sqlite-vec 0.1.6 버전이 arm64용 32비트 바이너리를 배포했습니다. 첫 배포 시 OSError: wrong ELF class: ELFCLASS32가 발생했습니다. 당신의 코드가 잘못된 것처럼 보이지만, 사실은 업스트림(upstream) 문제입니다. sqlite-vec==0.1.9로 버전을 고정(pin)하세요.
2. fastembed가 마이너 버전 업데이트 과정에서 기본 풀링(pooling) 방식을 변경했습니다 (CLS → mean). 새로운 임베딩이 기존에 저장된 임베딩과 조용히 호환되지 않게 되었고, KNN은 쓰레기 값(garbage)을 반환하기 시작했습니다. 유일한 해결책은 테이블 전체를 다시 임베딩하고 모든 사용자의 맛 벡터(taste vector)를 재계산하는 것입니다. 임베딩 라이브러리의 버전을 고정하고 변경 로그(changelog)를 확인하세요.
3. 며칠 동안 숨어있던 OOM(Out Of Memory) 크래시 루프(crash-loop)입니다. ONNX 모델을 로드하면 RSS(Resident Set Size)가 약 400MB까지 치솟습니다. 512MB 사양의 머신에서는 커널이 모델 로드 중에 프로세스를 OOM-kill 했고, Fly가 이를 재시작하면 다시 OOM이 발생하는 루프가 반복되었습니다. 봇은 계속 *폴링(polling)*을 하고 있었기에 살아있는 것처럼 보였지만, 스토리를 전혀 생성하지 못했습니다. 그 사이 RSS 항목들이 쌓이면서 모든 다이제스트(digest)는 빈 상태로 발송되었습니다. 사양을 1GB로 올리고(부하 시에는 2GB까지), 이 문제는 해결되었습니다.
4. 타임아웃이 없는 피드 하나가 약 4일 동안 전체 파이프라인을 멈추게 했습니다. feedparser.parse(url)는 타임아웃 (timeout) 없이 블로킹 페치 (blocking fetch)를 수행합니다. 응답이 없거나 느린 피드 하나가 실행 과정을 영원히 붙잡아 두었고, max_instances=1 설정 때문에 이후의 모든 실행이 건너뛰어졌습니다. 봇은 즐겁게 메시지에 답변하고 있었지만, 실제로는 며칠 동안 조용히 서비스 중단 상태가 지속된 것입니다. 첫날부터 적용했어야 할 해결책은 다음과 같습니다: httpx를 사용하여 타임아웃을 엄격하게 설정하여 가져오고, 각 파이프라인 실행을 asyncio.wait_for로 감싸는 것입니다.
3번과 4번 사례에서 얻은 교훈은 동일합니다: 폴링 (polling)을 수행하는 워커 (worker)는 기능적으로는 죽어 있는 상태임에도 겉보기에는 건강해 보일 수 있습니다. 저는 N시간 동안 새로운 스토리가 생성되지 않으면 저에게 DM을 보내는 와치독 (watchdog)을 추가했습니다. 그랬다면 며칠이 아니라 몇 분 만에 모든 문제를 잡아냈을 것입니다.
직접 체험해보기
현재 라이브 상태입니다: @futur_e_news_bot — /start를 입력하고, 언어를 선택한 뒤, 몇 가지 스토리에 반응(react)해 보세요. 그러면 피드가 스스로 튜닝을 시작합니다. 기본적으로는 긍정적/중립적인 뉴스가 제공되며, 부정적인 뉴스의 비중을 4단계로 조절할 수 있습니다.
중복 제거 (dedup)/클러스터링 (clustering) 품질과 추천 엔진 (recommender)에 대한 피드백을 환영합니다. 이 부분들이 아직 "훌륭함"에 가장 미치지 못하는 부분들입니다. 댓글을 통해 sqlite-vec, 무료 LLM 체인 (free-LLM chain), 또는 추천 엔진에 대해 더 깊이 논의할 준비가 되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기