Local-LLM paper summary bot — Semantic Scholar + arXiv에서 논문을 수집하고, relevance 필터를 거쳐 Ollama(qwen3.8:27b)로 구조화 요약을 만들어 Slack/Zulip에 포스팅한다.
외부 LLM API 없이 연구실 GPU 서버(V100 ×2, Vulkan)에서 전부 돌아간다.
주제·학회·연도·인용수·필터 강도·발행량은 모두 config.yaml에서 조절한다.
- 파이프라인
- 설치
- Ollama와 모델
- 설정 (config.yaml)
- 검색 소스
- relevance 필터
- 발행 쿼터와 pending 대기열
- PDF 확보·추출·요약
- 메신저 발행
- 실행과 테스트
- 주기 실행
- 데이터·로그·상태
- 운영 메모
run.py 한 번 실행(=하루 1회)에서 일어나는 일:
loop.sh (tmux, 매일 07:00) ── Ollama 생존 확인 ──▶ run.py
│
├─ ① 발행 설정 검증 Slack 토큰·채널 확인. 실패 시 즉시 종료 (논문 소모 없음)
├─ ② 검색 topic × [Semantic Scholar → arXiv], 소스 단위 실패 격리
├─ ③ dedupe + 미처리 추출 uid(arXiv id > DOI > S2 id)로 통합, DB에 없는 것만
├─ ④ relevance 필터 임베딩 cosine → LLM judge(0~10, JSON 스키마 강제)
├─ ⑤ pending 대기열 만료 폐기 → topic×소스별 FIFO 로드
├─ ⑥ 쿼터 [대기열] + [신규] 순으로 상한까지 선택, 남은 신규 → pending
├─ ⑦ 논문별 처리 PDF 확보 → 텍스트·figure 추출 → 요약 → 발행 → done
└─ ⑧ 마무리 모델 언로드(GPU 반납), 집계 로그
| 단계 | 모듈 | 핵심 동작 |
|---|---|---|
| ② 검색 | sources/semantic_scholar.py, sources/arxiv_source.py |
소스별 최신순 max_results_per_query편. S2 429는 3·5·10초 백오프 4회, arXiv는 라이브러리 재시도(3초×3) |
| ③ dedupe | models.py, store.py |
make_uid()가 버전·arXiv 자동 DOI를 정규화. done/filtered_out/pending/expired/failed_permanent는 제외, failed는 재시도 |
| ④ 필터 | filter.py |
임베딩 threshold 미만 → filtered_out. judge는 thinking 끄고 {"score","reason"} 스키마로 채점, threshold 미만 → filtered_out |
| ⑤⑥ 쿼터 | run.py apply_quota(), store.py |
대기열이 먼저 소진되고 신규 최신 논문이 남은 자리를 채움. 초과 신규분은 pending(Paper 전체 JSON 보존) |
| ⑦ 처리 | extractor.py, summarizer.py, publisher.py |
폴백 체인으로 PDF 확보, References 제거, 캡션 기준 figure 선별, 6섹션 한국어 요약, 채널+스레드 발행 |
요구 사항: Python 3.10+, 상시 실행 Ollama(http://localhost:11434/v1),
Semantic Scholar API 키(권장), Slack 봇 토큰 또는 Zulip zuliprc(실전송 시).
cd ~/paperbot
python3 -m venv venv && source venv/bin/activate
pip install torch --index-url https://download.pytorch.org/whl/cpu # 임베딩은 CPU, CUDA 휠(~3GB) 회피
pip install -r requirements.txt
cp config.example.yaml config.yaml # 실제 config.yaml은 gitignore토큰·키는 config에 넣지 않고 ~/.paperbot_env(권한 600)에 둔다. loop.sh가 실행 직전에 읽는다.
# ~/.paperbot_env
export SLACK_BOT_TOKEN="xoxb-..."
export S2_API_KEY="..."curl -fL https://ollama.com/download/ollama-linux-amd64.tar.zst -o /tmp/ollama.tar.zst
mkdir -p ~/ollama && tar --zstd -xf /tmp/ollama.tar.zst -C ~/ollama
echo 'export PATH="$HOME/ollama/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc
tmux new-session -d -s ollama \
'CUDA_VISIBLE_DEVICES=1,2 GGML_VK_VISIBLE_DEVICES=1,2 OLLAMA_CONTEXT_LENGTH=65536 \
$HOME/ollama/bin/ollama serve 2>&1 | tee -a $HOME/ollama/serve.log'| 환경변수 | 이유 |
|---|---|
OLLAMA_CONTEXT_LENGTH=65536 |
미설정 시 전체 VRAM 기준으로 컨텍스트가 자동 확장(262144)되어 KV 캐시가 GPU 3장에 27GB로 퍼진다. 64K면 27b 기준 GPU 2장에 약 21GB |
GGML_VK_VISIBLE_DEVICES=1,2 |
Vulkan 백엔드의 GPU 고정. Vulkan은 CUDA_VISIBLE_DEVICES를 무시한다 |
CUDA_VISIBLE_DEVICES=1,2 |
드라이버 업데이트로 CUDA 백엔드가 켜질 경우 대비 |
num_ctx: 봇은 매 요청에
num_ctx를 넘기지만 Ollama 0.34의 OpenAI 호환 엔드포인트는 이를 무시한다. 실효값은 서버의OLLAMA_CONTEXT_LENGTH이며, 봇은 첫 호출 후/api/ps로 실제 컨텍스트를 확인해 config보다 작으면 경고를 남긴다.
ollama pull qwen3.8:27b # Q4_K_M 16.5GB — 현재 운영 모델| 항목 | 값 (V100 ×2, Vulkan 실측) |
|---|---|
| 배치 | GPU 1·2에 66/66 레이어, 가중치 15GB + 64K KV 4GB |
| 요약 1편 (전문 40~46k자) | 약 100~150초 |
| judge 1편 | 약 5초 |
- thinking은 코드에서 항상 꺼진다 (
summarizer.chat()이reasoning_effort="none"전송). Qwen3.8의 기본 추론 강도(xhigh)는 단순 요약에도 수천 토큰을 생각에 쓰고, 그 토큰이max_output_tokens를 소진하면 빈 응답이 나온다. Ollama 0.34 실측상think옵션은 무시되고reasoning_effort만 동작한다. - 8비트(q8_0/mxfp8)는 가중치만 28GB라 GPU 2장(32GB)에 들어가지 않는다. V100은 FP8 연산 유닛도 없다.
bf16·FlashAttention-2·AWQ 미지원 → GGUF(Ollama)만 사용, vLLM은 쓰지 않는다. 드라이버 535가 CUDA 백엔드 요구(550+)보다 낮아 Vulkan으로 동작한다. 임베딩 모델은 CPU.
topics:
- name: "graph-transformer"
channel: "#graph_transformer" # 이 topic의 발행 대상 (생략 시 slack.channel / zulip.stream)
interest: > # relevance 필터의 기준이 되는 자연어 서술
Graph Transformer and Graph Mamba–family architectures: ...
sources:
semantic_scholar:
query: '"graph transformer" | "graph mamba"' # S2 bulk 검색 문법
venues: ["ICLR", "NeurIPS", "ICML", "KDD"] # 빈 리스트 = venue 필터 없음
year: "2020-" # "2024" | "2024-2026" | "2024-"
min_citations: 5
sort: "recency" # recency(기본) | citations(백필용)
arxiv:
enabled: true
query: '(cat:cs.AI OR cat:cs.LG) AND (abs:"graph transformer" OR abs:"graph mamba")'| 섹션 | 키 | 현재 값 | 설명 |
|---|---|---|---|
fetch |
max_results_per_query |
10 | 소스별 서버 요청 크기(최신순). DB 제외는 그 뒤 → 신규 후보 = 10 − 이미 아는 논문 |
arxiv_lookback_days |
14 | arXiv 제출일 창. arXiv엔 서버측 연도 필터가 없어 필수(≥1) | |
s2_lookback_days |
0 | 0 = 비활성, 시간 범위를 topic year에 위임 (권장) |
|
unpaywall_email |
설정됨 | PDF 403 시 Unpaywall OA 사본 조회. 비우면 생략 | |
quota |
s2_per_topic / arxiv_per_topic |
2 / 1 | 실행당 채널·소스별 발행 상한. 0 = 무제한. topic 항목의 quota: {s2, arxiv}로 개별 덮어쓰기 가능 |
pending_max_age_days |
30 | 이보다 오래 대기한 pending 폐기. 0 = 무제한 | |
filter |
embedding_model / embedding_device |
MiniLM-L6-v2 / cpu | 임베딩 프리필터 |
embedding_threshold |
0.25 | interest↔title+abstract cosine 하한 | |
llm_judge / judge_threshold |
true / 6 | LLM judge 사용 여부와 0~10 점수 하한 | |
llm |
model |
qwen3.8:27b | Ollama 모델 태그 |
num_ctx / max_input_chars |
65536 / 150000 | 컨텍스트, 본문 입력 상한(≈40k 토큰, 초과 시 앞70%+뒤30%) | |
max_output_tokens / temperature |
8192 / 0.3 | 출력 상한(thinking 꺼짐 → 순수 출력), 샘플링 온도 | |
language |
ko | 요약 언어 | |
publisher |
backend / dry_run |
slack / false | 발행 백엔드, dry_run=true면 stdout |
slack |
channel / attach_figure / max_figures |
기본 채널 / true / 5 | 기본 채널, figure 첨부 여부·상한 |
zulip |
config_file / stream |
./zuliprc / papers | Zulip 백엔드용 |
storage |
db_path / pdf_dir / keep_pdfs |
NAS 경로 / NAS 경로 / false | 상태 DB, PDF 임시 저장, 요약 후 PDF 삭제 |
./로 시작하는 storage 경로는 paperbot 디렉토리 기준 상대 경로다. NAS는 절대 경로로 쓴다.
topic마다 S2 → arXiv 순서로 부른다. uid가 겹치면 메타데이터가 풍부한 S2 레코드가 남는다. 한 소스가 실패해도 나머지는 계속 진행한다.
- bulk 검색, venue·연도·최소 인용수 필터,
publicationDate내림차순. lookback에 닿으면 페이지네이션 조기 중단. - API 키가 있으면 요청 간격 1.2초, 없으면 8초(공용 풀). 키는 https://www.semanticscholar.org/product/api 에서 무료 발급.
- 429 백오프: 3초 → 5초 → 10초, 최대 4회 시도. S2의 429는 몇 초 단위로 열리고 닫혀서 이 간격이면 거의 잡힌다.
Retry-After가 있으면 그 값을 따른다(상한 120초). 401/403은 키 문제라 즉시 실패. - venue 이름은 축약형(ICLR, NeurIPS, CVPR 등)이 매칭된다. 마이너 학회는 아래로 확인:
curl -s "https://api.semanticscholar.org/graph/v1/paper/search/bulk?query=QUERY&venue=VENUE&fields=title,venue" | head -40
- 신선도 특성: proceedings 인덱싱이 수개월 늦고 버스트로 들어오며,
min_citations를 쓰면 인용 문턱을 넘는 순간부터 매칭된다. 그래서s2_lookback_days: 0으로 lookback을 끄고year에 시간 범위를 맡기는 것을 권장한다.
export.arxiv.org/api/query,submittedDate내림차순,arxiv_lookback_days안의 제출분만. venue 필터가 없으므로 결과는 반드시 relevance 필터를 거친다.arxiv라이브러리 내장 재시도: 3초 간격 최대 3회(총 4회). 실패하면 그 topic의 arXiv만 건너뛴다.- 쿼리는
cat:카테고리와abs:"구문"을 AND/OR로 조합하는 arXiv 검색 문법. - 현황(2026-09-14): arXiv 검색 API가 간헐적
429 Rate exceeded/503을 낸다. 9/12 4토픽 성공 → 9/13 2토픽 → 9/14 0토픽으로 같은 시각·같은 요청량에서 단계적으로 악화됐고, 하루 중 열리고 닫히는 시간대가 있다. arXiv 운영자는 "429는 서버 용량 문제"라고 밝혔으며, 한 실행 안에서 앞 요청은 통과하고 뒤 요청이 막히는 패턴은 IP별 예산이 매우 작다는 뜻이라 학교 공유 IP의 다른 트래픽이 겹치는 것으로 보인다. (학교 밖 네트워크에서 같은 요청이 200이면 IP 요인 확정.) 막힌 날은 arXiv 후보가 0이 되고 pending에 있던 arXiv 논문만 발행된다.
topic별로 묶어 처리하며, 탈락은 filtered_out으로 기록해 다시 채점하지 않는다.
1단계 — 임베딩 프리필터 (CPU, 밀리초). interest 문단과 title + abstract를
all-MiniLM-L6-v2로 임베딩해 cosine이 embedding_threshold 미만이면 컷.
abstract가 없는 논문은 제목만으로 점수를 내되 하드컷하지 않고 judge로 넘긴다(title-only 유사도는 체계적으로 낮다).
실측 예: 정면 관련 0.50.7, 주변부 0.20.3, 무관 0.2 이하.
2단계 — LLM judge (llm_judge: true). 관심사·제목·출처·abstract를 주고 0~10 정수 점수와 한 문장 근거를 받는다.
- thinking 꺼짐 +
response_format(JSON 스키마{"score": int 0~10, "reason": str}) 서버 강제 → 파싱 실패가 구조적으로 없다. - temperature 0, 논문당 약 5초.
judge_threshold(6) 미만이면filtered_out에 근거 기록. - 호출 자체가 3회 실패하면 보수적으로 통과(놓치는 것보다 낫다). 점수 없이 통과한 논문은 DB
judge_score가 NULL.
채점 기준(프롬프트): 910 핵심 주제 정면 / 68 상당히 관련 / 35 주변부 / 02 무관·명시적 제외.
감을 잡을 때는 --skip-filter로 전부 요약해 보고 로그의 sim=·judge 점수와 비교한다.
quota.s2_per_topic / quota.arxiv_per_topic은 실행(=하루)당 채널·소스별 발행 상한이다.
특정 topic만 다르게 두려면 그 topic 항목에 quota: {s2: N, arxiv: N}을 적는다 (빠진 키는 전역값, 0 = 무제한).
후보 = [pending 대기열 (topic×소스별 오래된 순)] + [신규 필터 통과분 (최신순)]
└── apply_quota(): 입력 순서대로 (topic, source)별 상한까지 선택
선택됨 → ⑦ 처리 → done
선택 안 된 신규 → store.mark_pending() (Paper 전체를 payload JSON으로 보존)
선택 안 된 대기열 → 그대로 유지 (진입 시각 보존 → FIFO 순서 유지)
- 대기열 논문은 재검색·재채점 없이 payload에서 복원해 바로 요약·발행한다. 검색 결과에 다시 나타나도 신규로 잡히지 않는다.
pending_max_age_days(30)를 넘긴 논문은expired로 폐기된다.--limit에 걸린 신규분도 pending으로 간다(누락 없음).- 현재 순서는 "judge → 쿼터"다. 후보가 많아져 judge 비용이 커지면 "임베딩 → 쿼터 → 선택분만 judge(+탈락 보충)"로 바꿀 여지가 있다.
확인:
sqlite3 ~/data2nas/Paper/papers.db "select topic, source, count(*) from papers where status='pending' group by 1,2;"출판사 서버(ACM/IEEE 등)는 봇 요청을 403으로 막는 경우가 많다. 앞 단계가 전부 실패했을 때만 다음으로 간다.
- 검색 API가 준 직접 링크 (S2
openAccessPdf/ arXiv PDF) - arXiv 미러 (arXiv id가 있을 때)
- Unpaywall — DOI로 합법 OA 사본 조회 (
fetch.unpaywall_email필요) - arXiv 제목 검색 — 정규화 제목 완전 일치 + 1저자 성 확인
- 전부 실패 → abstract 기반 요약 (
(abstract 기반 요약)표기) - abstract도 없음 → 발행하지 않음.
failed기록, 다음 실행 1회 재시도 후failed_permanent
- pymupdf로 전체 텍스트 → References/Bibliography 헤딩 이후 제거(문서 30% 지점 이후, 라인 전체가 헤딩일 때만) → 빈 줄 정리.
max_input_chars(150k) 초과 시 앞 70% + 뒤 30%만 남기고 중간 절단(안전망, 실제로는 드묾).- figure: 앞 8페이지에서
Figure N캡션을 기준점으로, 캡션 위 같은 단의 드로잉·이미지·짧은 레이블을 tight bbox로 묶고 캡션을 붙여 렌더(2x). 표·정리 박스·옆 단 병합·헤더 유입을 캡션 기준으로 배제한다.slack.max_figures개까지.
- 시스템 프롬프트가 6개 섹션을 고정한다: 한 줄 요약 / 문제 정의 / 핵심 방법 / 기존 연구 대비 novelty / 실험 결과 / 한계점.
- 한국어(
llm.language), temperature 0.3, thinking 꺼짐, 출력 상한 8192 토큰.<think>태그가 남아 있으면 제거한다. - 실측: 전문 40
46k자 입력에 100150초, 출력 3~4.5k자.
publisher.backend로 선택한다. 둘 다 없어도 dry_run: true 또는 --dry-run으로 전 과정을 테스트할 수 있다.
포스팅 구조: 채널 메인 메시지(제목·저자·venue·연도·인용수·링크·한 줄 요약·#topic) + 스레드 댓글(6섹션 전체 요약) + figure 첨부(스레드에 댓글 하나).
봇 토큰: https://api.slack.com/apps → Create New App → OAuth & Permissions에 chat:write(+ figure용 files:write) → Install → xoxb-... 토큰을 ~/.paperbot_env에. 대상 채널마다 /invite @paperbot.
topic별 channel을 지정하면 주제마다 다른 채널로 간다(미지정 topic은 slack.channel). 채널명 또는 채널 ID 모두 가능.
| 오류 | 원인 |
|---|---|
not_in_channel |
공개 채널에 봇 미초대 |
channel_not_found |
채널명 오타 또는 비공개 채널에 봇 미초대(초대 전엔 보이지 않음) |
invalid_auth / missing_scope |
토큰 오류 / 스코프 누락 |
이런 설정 오류는 실행 전체를 즉시 중단하며 논문을 failed로 소모하지 않는다. 고치고 재실행하면 이어서 처리된다.
zuliprc를 프로젝트 루트에 두고(.gitignore) publisher.backend: "zulip". 스트림에 논문당 topic 하나(제목 60자), 전체 요약 한 메시지.
topic의 channel은 스트림 이름으로 해석된다.
python run.py # 전체 파이프라인
python run.py --dry-run --topic graph-transformer --limit 1 # 한 topic 1편, 전송 없이
python run.py --backfill --dry-run # 첫 실행: arXiv lookback 무시
python run.py --skip-filter --dry-run --limit 2 # 필터 없이 (threshold 튜닝용)| 옵션 | 설명 |
|---|---|
--config PATH |
config.yaml 경로 (기본: run.py 옆) |
--dry-run |
전송 대신 stdout 출력 (DB 기록은 그대로 됨) |
--limit N |
요약·발행할 논문 수 제한. 잘린 신규분은 pending으로 |
--topic NAME |
특정 topic만 |
--backfill |
arXiv lookback 해제 |
--skip-filter |
relevance 필터 생략 (디버그) |
dry-run도 DB에 done을 기록한다. 운영 DB를 건드리지 않고 테스트하려면 DB를 복사하고
storage.db_path만 바꾼 임시 config를--config로 넘긴다.
- 오프라인 회귀 테스트:
python smoke_test.py— 네트워크·Ollama 불필요. 쿼터 항목 4개는 config가 3/2일 것을 가정해 현재(2/1) 설정에서 FAIL이 정상. - 채널 실전송 점검:
scripts/test_send.sh --check(토큰·스코프·Ollama·채널 확인만) /scripts/test_send.sh(topic마다 1편 실제 전송).
현재 방식은 tmux 러너다. crontab에는 재부팅 복구용 @reboot 두 줄만 있다.
@reboot tmux new-session -d -s ollama 'CUDA_VISIBLE_DEVICES=1,2 GGML_VK_VISIBLE_DEVICES=1,2 OLLAMA_CONTEXT_LENGTH=65536 $HOME/ollama/bin/ollama serve 2>&1 | tee -a $HOME/ollama/serve.log'
@reboot tmux new-session -d -s paperbot '$HOME/paperbot/scripts/loop.sh 07:00'scripts/loop.sh HH:MM은 지정 시각까지 잠들다가 ~/.paperbot_env를 읽고, Ollama가 죽어 있으면 재시작한 뒤 run.py를 실행하고 logs/cron.log에 남긴다.
실행 시각을 바꾸려면 crontab의 인자와 tmux 세션(tmux kill-session -t paperbot 후 재생성)을 함께 바꾼다.
config·코드 수정은 재시작이 필요 없다(매일 새 프로세스).
tmux attach -t paperbot # 상태 확인 (Ctrl+b d 로 분리)cron으로 직접 돌리려면 0 7 * * * . $HOME/.paperbot_env && cd $HOME/paperbot && ./venv/bin/python run.py >> logs/cron.log 2>&1 한 줄로 대체할 수 있다. 두 방식을 병행하면 lockfile이 중복을 막지만 하나만 쓴다.
| 상태 | VRAM |
|---|---|
| 대기 (ollama serve만) | 0 MiB |
| 봇 실행 중 | 약 21GB (GPU 1·2, 모델 15GB + 64K KV 4GB + 버퍼) |
| 실행 종료 직후 | 0 MiB — 봇이 종료 시 모델을 명시적으로 언로드 |
| 항목 | 위치 |
|---|---|
| 상태 DB | storage.db_path (현재 NAS /home/gwlee/data2nas/Paper/papers.db) |
| PDF 임시 저장 | storage.pdf_dir (요약 후 삭제, keep_pdfs: true면 보존) |
| lockfile | DB 폴더의 .lock (죽은 락은 자동 회수) |
| 실행 로그 | logs/paperbot.log (5MB×3 회전), 실행 출력 logs/cron.log |
| status | 의미 | 다음 실행에서 |
|---|---|---|
done |
요약·발행 완료 | 건너뜀 |
filtered_out |
relevance 필터 탈락 (judge_score, 사유 기록) |
건너뜀 (재채점 안 함) |
pending |
필터 통과, 쿼터 대기. payload에 Paper JSON |
대기열에서 FIFO로 우선 발행 |
expired |
pending이 pending_max_age_days 초과 |
건너뜀 |
failed |
1회 실패 | 다시 검색되면 재시도 |
failed_permanent |
재시도도 실패 | 건너뜀 |
payload 컬럼은 첫 실행 시 자동 추가된다(additive 마이그레이션). 변경 전 백업: papers.db.bak-20260914.
- arXiv API 429/503: 5장 참고. 요청량(하루 4건, 3초 간격, 10편)은 이미 최소라 코드로 풀 문제가 아니다. 학교 밖에서 확인 테스트 후
실행 후반(요약 종료 뒤) 재시도·하루 2회 실행·arXiv 몫을 S2로 보충 중 선택 예정. 현재는
arxiv라이브러리 기본 재시도(3초×3) 그대로. - S2 429: 실행 초반에 몇 초 단위로 발생하며 3·5·10초 백오프로 대부분 회복된다. 4회 모두 실패하면 그 topic의 S2만 빠지고, 대기열에 있던 논문은 그대로 발행된다.
- judge 실패 이력: 이전(qwen3.5, thinking on) 구성에서는 thinking이 토큰 상한을 소진해 빈 응답 → 3회 동일 실패 → 미채점 통과가 발생했다. thinking 끄기 + JSON 스키마 강제로 해결되었고, 실패는 응답 시간이 상한(약 100초)에 일정하게 걸리는 패턴으로 구분할 수 있다.
- 중복 발행: uid 정규화로 소스 간 중복은 합쳐진다. S2가 arXiv id를 누락한 학회 논문과 같은 프리프린트가 각각 들어오는 극히 드문 경우만 두 번 나갈 수 있다(허용).
- 향후 확장:
publisher.py의NotionPublisher스텁에 API 연동을 채우고make_publisher에 연결하면 Notion 아카이브가 가능하다.