Recent Posts

All Posts →

RAG 시스템의 구성요소와 성능을 좌우하는 요소

RAG 시스템을 구성하는 요소들 — Vector DB, 임베딩 모델, 청킹, 재순위, 생성 LLM — 의 역할을 정리하고, 한국어 처리에서 중요한 지점과 성능이 나쁠 때 어디를 손봐야 하는지 살펴봅니다.

앞 글에서 RAG가 결국 프롬프트 레이어에 데이터를 공급하는 장치지만, 앞단의 검색/필터링 파이프라인이 충분히 복잡해 별도 서브시스템으로 다룰 만하다고 정리했습니다. 이 글은 그 서브시스템 안을 들여다봅니다. 어떤 부품으로 구성되는지, 한국어를 다룰 때 어디가 까다로운지, 성능이 안 나올 때 어디를 의심해야 하는지를 정리합니다.

1. RAG 파이프라인의 기본 흐름

전형적인 RAG는 두 시점으로 나눠 볼 수 있습니다.

인덱싱 시점 (오프라인)

원본 문서 → 청킹 → 임베딩 → Vector DB 저장. 메타데이터(출처, 작성일, 권한 등)도 함께 저장.

질의 시점 (온라인)

사용자 질문 → 쿼리 재작성 → 임베딩 → Vector DB 검색 (+ 키워드 검색) → 재순위(rerank) → 컨텍스트 조립 → LLM에 프롬프트로 전달 → 응답 생성.

각 단계마다 별도 모델/시스템이 끼어들 수 있고, 단계마다 다른 튜닝이 필요합니다.

2. Vector DB는 어떤 역할을 하는가

Vector DB는 한마디로 "고차원 벡터의 근사 최근접 이웃(approximate nearest neighbor, ANN) 검색에 특화된 저장소" 입니다. 임베딩 모델이 만든 수백~수천 차원 벡터를 저장하고, "쿼리 벡터와 가장 가까운 K개"를 빠르게 돌려줍니다.

핵심 책임은 세 가지입니다.

  • 벡터 인덱싱: HNSW, IVF, PQ 같은 ANN 인덱스로 검색을 sub-linear 시간에 처리합니다. 정확도 vs. 속도/메모리의 트레이드오프가 있습니다.

  • 유사도 계산: 코사인 유사도, 내적, L2 거리 등. 임베딩 모델이 가정한 거리에 맞춰야 합니다.

  • 메타데이터 필터링: "2025년 이후 문서만", "권한이 있는 문서만" 같은 조건을 벡터 검색과 결합. pre-filter / post-filter 시점이 성능을 크게 좌우합니다.

흔한 오해 지점들이 있습니다.

  • Vector DB가 의미를 이해하지는 않습니다. 의미를 벡터로 인코딩하는 건 임베딩 모델이 하고, Vector DB는 벡터 간 기하학적 거리만 봅니다. 검색 품질이 나쁘면 대개 임베딩 모델 문제이지 Vector DB 문제가 아닙니다.

  • 벡터 검색이 항상 키워드 검색보다 낫지는 않습니다. 고유명사, 제품번호, 코드 같은 토큰은 BM25 같은 lexical 검색이 더 정확합니다. 그래서 실제 RAG는 벡터 + 키워드 하이브리드가 기본입니다.

  • 전용 Vector DB가 늘 필요하지도 않습니다. PostgreSQL pgvector, Elasticsearch/OpenSearch의 dense vector 필드, Redis vector search 모듈로도 상당 규모까지 커버됩니다. 운영 부담을 줄이려면 기존 DB 확장이 합리적일 때가 많습니다. Pinecone, Weaviate, Qdrant, Milvus 같은 전용 솔루션은 규모/성능 요구가 분명해질 때 고려하면 됩니다.

3. 한국어 처리에서 중요한 구성요소

한국어 RAG는 영어보다 손이 많이 갑니다. 어디서 차이 나는지 짚어봅니다.

3.1. 임베딩 모델의 한국어 이해 능력

가장 결정적인 부품입니다. OpenAI text-embedding-3, Cohere embed-multilingual, Voyage, BGE-M3 같은 다국어 임베딩이 점점 좋아지고 있지만, 한국어 전용 튜닝 모델(KURE, bge-m3-korean, 네이버 HyperCLOVA 임베딩 등)이 도메인에 따라 더 잘 맞기도 합니다. MTEB 한국어 벤치마크를 참고하되, 자기 도메인 문서로 직접 평가해보는 게 가장 정확합니다.

3.2. 토크나이저와 형태소 분석

  • 벡터 검색 쪽: 임베딩 모델 내부 토크나이저를 따라가므로 사용자가 손댈 여지가 적습니다.

  • 키워드 검색 쪽: 한국어에서 가장 손이 많이 가는 지점입니다. 영어는 공백 분리만으로도 어느 정도 작동하지만, 한국어는 조사/어미가 붙은 채로 인덱싱되면 검색이 망가집니다. "은행에서", "은행이", "은행은"이 다 다른 토큰이 되니까요.

    Elasticsearch/OpenSearch는 Nori 분석기, 그 외 Mecab-ko / Khaiii / KIWI 같은 형태소 분석기를 식별자 추출/색인에 씁니다. 사용자 사전(custom dictionary) 관리가 매우 중요합니다. 신조어, 사내 용어, 제품명을 분석기가 모르면 엉뚱한 토큰으로 쪼개집니다.

3.3. 청킹 전략

한국어 문서는 한 문장이 길고 접속사 없이 늘어지는 경우가 많아 고정 길이 청킹이 의미 단위를 자주 끊습니다.

  • 문장 분리(sentence splitting) 가 마침표 한 종류만으로는 안 됩니다. "다.", "요.", "음.", "임." 같은 어미와 줄바꿈, 표/리스트 마크업까지 함께 봐야 합니다. KSS(Korean Sentence Splitter) 같은 라이브러리가 도움이 됩니다.

  • 의미 단위 청킹(semantic chunking) — 임베딩 유사도가 급격히 변하는 지점에서 자르기 — 이 한국어에서 효과가 좋은 편입니다.

  • 계층적 청킹: 큰 청크로 검색하고 매칭된 부분의 작은 청크를 LLM에 넘기는 식. 문맥 의존성이 강한 한국어에 특히 유용합니다.

3.4. 재순위(rerank) 모델

Cross-encoder 기반 reranker는 검색 결과를 다시 정렬해 정확도를 크게 끌어올립니다. Cohere Rerank 다국어 모델, BGE-reranker, Jina multilingual reranker 등이 한국어를 다룹니다. 한국어 전용 reranker는 아직 선택지가 적어, 다국어 모델로 시작해 자체 평가셋으로 fine-tune을 검토하는 흐름이 흔합니다.

3.5. 쿼리 처리

한국어 질문은 구어체, 줄임말, 오타가 많습니다. 쿼리 재작성(query rewriting) 이나 HyDE(hypothetical document embedding) 로 LLM을 한 번 거쳐 정제하면 검색 품질이 눈에 띄게 좋아집니다. 이때 쓰는 LLM은 작아도 충분합니다.

4. RAG 안에서 쓰이는 LLM들

"RAG에 LLM 하나"라고 생각하기 쉽지만, 실제 시스템에는 서로 다른 역할의 모델 여러 개가 들어가고, 각각 요구되는 특성도 다릅니다.

위치 역할 필요한 특성

임베딩 모델

텍스트를 벡터로 변환

다국어/한국어 품질, 적절한 차원 수, 빠른 추론 속도, 긴 입력 지원

쿼리 재작성 / 분기

사용자 질문을 정제하거나 어떤 도구/인덱스를 쓸지 결정

작고 빠른 instruction-following 모델로 충분. 지연 시간이 중요

재순위 모델

검색된 후보 K개를 정밀 점수화

Cross-encoder. 일반 LLM이 아니라 전용으로 학습된 reranker가 효율적

응답 생성 LLM

검색된 문맥을 바탕으로 최종 답변 작성

긴 컨텍스트, 인용 능력, 환각 억제, 한국어 자연스러움. 대형 모델이 유리

평가 / 가드레일

응답이 컨텍스트에 충실한지, 안전한지 검증

비교적 작은 모델로 LLM-as-a-judge. 일관성과 비용이 중요

흔한 구성은 "쿼리 처리/판정은 작고 빠른 모델, 최종 생성은 큰 모델" 입니다. 전부 큰 모델로 돌리면 비용과 지연이 누적되고, 전부 작은 모델로 돌리면 답변 품질이 무너집니다. 단계별 적정 사이즈 선택이 RAG 설계의 중요한 부분입니다.

또 한 가지, 검색 결과를 잘 활용하는 능력은 모델마다 다릅니다. 같은 컨텍스트를 줘도 어떤 모델은 인용에 충실하고, 어떤 모델은 사전 지식으로 답을 만들어버립니다. RAG 평가 때는 모델의 groundedness(컨텍스트 충실도)를 따로 측정하는 게 좋습니다.

5. 성능이 안 나올 때 어디를 손볼 것인가

RAG 결과가 만족스럽지 않을 때, 무작정 더 큰 LLM으로 갈아끼우기 전에 파이프라인의 어느 단계가 망가졌는지 진단하는 게 우선입니다. 흔한 원인을 단계별로 정리합니다.

5.1. 1단계 — 검색이 잘못 가져온다 (retrieval failure)

가장 흔한 원인입니다. 답이 든 문서를 애초에 못 찾아왔다면, 뒤에 어떤 LLM을 써도 좋아질 수 없습니다.

진단: 정답이 든 청크가 top-K 안에 들어오는지 확인. 안 들어오면 검색 단계 문제입니다.

손볼 곳:

  • 임베딩 모델 교체: 한국어 품질이 더 좋은 모델로. 도메인 특화 모델 검토.

  • 하이브리드 검색 도입: 벡터 + BM25 결합. 고유명사/숫자 매칭이 안 되던 케이스가 풀립니다.

  • 청킹 재설계: 너무 크면 노이즈, 너무 작으면 문맥이 끊깁니다. 한국어는 보통 300~800 토큰에서 시작해 조정.

  • 메타데이터 필터: 검색 공간을 좁히는 가장 효과적인 수단. 날짜, 출처, 권한 등을 적극 활용.

  • 사용자 사전: 키워드 검색 쪽 도메인 용어가 잘못 토큰화되는지 점검.

5.2. 2단계 — 가져오긴 했는데 순서가 나쁘다 (ranking failure)

top-20에는 정답이 있는데 top-3에는 없는 경우. LLM에 넘기는 컨텍스트가 한정돼 있어 순서가 결과를 좌우합니다.

손볼 곳:

  • Reranker 추가: cross-encoder reranker가 가장 큰 효과를 내는 지점입니다.

  • 쿼리 재작성: 모호하거나 구어체인 질문을 검색에 유리한 형태로 변환.

  • 다중 쿼리 (multi-query): 한 질문을 여러 변형으로 던져 결과를 합칩니다.

5.3. 3단계 — 컨텍스트는 좋은데 생성이 망가진다 (generation failure)

검색은 잘 됐고 정답 청크가 top에 있는데 최종 응답이 틀리거나 환각이 섞이는 경우.

손볼 곳:

  • 프롬프트 구조: "주어진 문맥에만 근거해 답하라", "문맥에 없으면 모른다고 답하라" 같은 지시. 인용 형식 강제.

  • 컨텍스트 배치: 중요 문서를 맨 앞이나 맨 뒤에. "lost in the middle" 회피.

  • 생성 모델 업그레이드: 여기서는 큰 모델이 효과적. 단, 1~2단계가 망가져 있으면 모델만 키워도 효과가 없습니다.

  • citation / grounded generation: 답변마다 출처 청크를 명시하게 하면 환각이 줄어듭니다.

5.4. 4단계 — 평가가 없어서 어디가 망가졌는지 모른다 (evaluation gap)

실은 가장 근본적인 문제입니다. 평가셋이 없으면 변경이 개선인지 알 수 없습니다. "감으로" 튜닝하다 보면 한쪽을 고치며 다른 쪽이 망가집니다.

손볼 곳:

  • 질문-정답-출처 셋으로 된 평가 데이터셋 구축. 50~100개로도 시작 가능.

  • 단계별 지표: 검색은 recall@K, MRR. 생성은 faithfulness, answer relevance.

  • Ragas, TruLens 같은 평가 프레임워크 활용.

  • LLM-as-a-judge로 자동화하되 사람이 주기적으로 보정.

6. 어디부터 손댈지

새 RAG 시스템을 만들거나 기존 것을 개선할 때 제가 보는 우선순위는 다음과 같습니다.

  1. 평가셋부터 만든다. 없으면 개선이 개선인지 모릅니다.

  2. 하이브리드 검색을 기본으로. 벡터만 쓰지 않습니다.

  3. 한국어라면 형태소 분석기와 사용자 사전 정비. 키워드 검색의 비중이 의외로 큽니다.

  4. reranker 도입. 비용 대비 효과가 큰 편입니다.

  5. 생성 모델 업그레이드는 마지막에. 가장 비싸고, 1~3이 부실하면 효과가 작습니다.

RAG의 성능 개선은 "더 큰 모델"이 아니라 "더 정확한 검색" 에서 오는 경우가 대부분입니다. 앞 글에서 RAG를 "프롬프트 레이어로 데이터를 흘려보내는 파이프라인"이라 부른 이유도 여기 있습니다. 모델은 토큰으로 들어온 것에 답할 뿐, 들어오지 않은 것에는 답할 수 없습니다.


이 포스트는 Claude(claude.ai)와 정상혁이 함께 작성했습니다.

LLM에 정보를 전달하는 계층 분류와 회색지대

LLM에 정보를 주입하는 방법을 모델 레이어와 프롬프트 레이어로 나누어 보고, 그 사이의 회색지대와 RAG도 이 분류에서 어디에 놓이는지 정리해봅니다.

LLM(Large Language Model)에 정보를 전달하는 방법을 정리할 때 저는 보통 모델 가중치 vs. 프롬프트의 2계층으로 나눠 생각해 왔습니다. "파라미터에 새기는가, 컨텍스트에 띄우는가"의 구분입니다. 최근 몇 년 사이 그 사이를 비집고 들어온 기법들이 늘었습니다.

1. 모델 레이어

여기서 말하는 모델 파라미터(model parameter) 는 함수 인자나 설정값이 아니라, 신경망 내부에 학습으로 결정된 숫자값입니다. 입력 토큰을 다음 토큰의 확률로 변환하는 모든 계산이 이 숫자들로 이뤄집니다. 대부분은 레이어 간 연결 강도인 가중치(weight) 이고, 편향(bias) 등이 더해집니다. 물리적으로는 낱개가 아니라 벡터/행렬(텐서) 형태로 묶여 GPU에서 행렬 곱으로 한꺼번에 계산됩니다. 요즘 LLM은 이 숫자가 수십억~수조 개 수준이며, 학습은 결국 이 숫자를 조금씩 바꾸는 과정입니다. 모델이 "아는 것"은 이 파라미터 안에 압축돼 있습니다.

이 레이어의 방법들은 가중치를 직접 바꾸거나, 가중치에 준하는 영구적 상태를 만듭니다.

  • Pre-training: 가장 근본적인 정보 주입. 코퍼스의 분포가 곧 모델의 세계관이 됩니다.

  • Continued pre-training (도메인 적응): 의료/법률/코드 등 특정 도메인 코퍼스로 추가 학습. 파인튜닝보다 광범위한 분포 이동을 노립니다.

  • Supervised fine-tuning (SFT): 입출력 페어로 행동을 맞춥니다. 지식 주입보다 형식/스타일/태스크 수행에 효과적입니다.

  • 선호 학습 (RLHF(Reinforcement Learning from Human Feedback), DPO(Direct Preference Optimization), KTO(Kahneman-Tversky Optimization), IPO(Identity Preference Optimization) 등): "어떤 응답이 더 나은가"를 가중치에 새깁니다. 사실 지식이 아니라 가치/취향 주입이라는 점에서 결이 다릅니다.

  • 검증 가능한 보상을 통한 강화학습(RL, Reinforcement Learning) — RLVR(Reinforcement Learning with Verifiable Rewards): 수학 정답, 코드 실행 결과처럼 자동 채점 가능한 신호로 강화학습. o1, DeepSeek-R1 계열 추론 모델이 이 방식입니다. 사람의 선호가 아니라 풀이 능력 자체를 새긴다는 점에서 RLHF와 구분됩니다.

  • Model editing: 특정 사실 하나를 가중치 차원에서 직접 수정. "에펠탑은 로마에 있다"로 바꾸는 수술 같은 기법입니다. 본격적 학습이 아니어서 회색지대로 보기도 합니다.

    • ROME(Rank-One Model Editing): 해당 사실을 저장한 MLP 가중치에 rank-1 업데이트를 가해 사실 하나를 갈아끼움

    • MEMIT(Mass-Editing Memory In a Transformer): ROME을 다중 레이어로 확장해 수천 개의 사실을 동시에 편집

    • MEND(Model Editor Networks with Gradient Decomposition): 메타 학습된 보조 네트워크가 편집용 그라디언트로 변환해 빠르게 적용

  • Knowledge distillation: 큰 모델의 지식을 작은 모델의 가중치로 압축.

  • Merging: 여러 파인튜닝 결과의 가중치를 평균/합성. 학습 없이 "정보를 섞는다"는 점에서 새로운 결입니다.

    • TIES(TrIm, Elect Sign & Merge): 변화가 작은 파라미터는 잘라내고, 부호 충돌을 다수결로 정한 뒤 평균

    • DARE(Drop And REscale): 파인튜닝 델타의 대부분을 무작위로 버리고 남은 값을 재스케일링해 머지

    • SLERP(Spherical Linear intERPolation): 두 모델 가중치를 단위 구면상 호로 보간

2. 프롬프트 레이어

해당 레이어는 추론 시점에 컨텍스트 윈도우로 들어가는 모든 것입니다.가중치는 건드리지 않습니다.

  • 시스템 프롬프트 / 페르소나 지시

  • Few-shot 예시 (in-context learning)

  • Chain-of-Thought, ReAct(Reasoning + Acting), self-consistency 같은 reasoning 패턴 유도

  • Tool use / function calling의 결과를 컨텍스트로 되먹임

  • RAG(Retrieval-Augmented Generation)로 검색해 온 문서 청크

  • 외부 메모리 시스템에서 불러온 과거 대화 (Claude의 memory 같은 것)

  • 구조화된 출력 강제 (스키마, XML 태그)

  • MCP(Model Context Protocol)/connector를 통해 들어오는 외부 데이터

공통점은 inference time에 토큰으로 입력된다는 것입니다. 모델 입장에서는 출처를 구분할 방법이 없습니다.

3. 회색지대

PEFT(Parameter-Efficient Fine-Tuning) 계열 (LoRA(Low-Rank Adaptation), QLoRA(Quantized LoRA), adapters, prefix tuning, prompt tuning, P-tuning): 가중치를 학습시키니 형식상 모델 레이어지만, 베이스 모델은 두고 작은 어댑터만 붙였다 뗐다 합니다. 특히 prefix tuning / prompt tuning은 학습 가능한 "가상 토큰"을 컨텍스트 앞에 붙이는 방식이라 소프트 프롬프트(soft prompt) 라 불릴 만큼 프롬프트 레이어의 사촌입니다.

KV(Key-Value) cache 주입 / cache steering: 특정 컨텍스트로 만든 KV cache를 저장해 두고 추론 시 재사용. 매번 프롬프트로 넣지 않지만 효과는 같습니다. 원래 비용 최적화 용도지만 "캐시된 페르소나"처럼 쓰면 준영구적 상태가 됩니다.

Activation steering / representation engineering: 추론 중 특정 레이어의 활성값에 벡터를 더해 행동을 바꿉니다. 가중치도 프롬프트도 건드리지 않는 완전히 새로운 결입니다.

Decoding 제어 (constrained decoding, logit bias, guided generation, speculative decoding): 출력 확률 분포를 직접 조작. "주입"보다 출력 제한에 가깝지만, 도메인 지식을 grammar로 강제하면 사실상 정보 주입입니다.

Tool use의 양면성: 도구 호출 자체는 프롬프트 레이어지만, 도구가 외부 세계의 상태를 바꾼다면 "LLM에 정보를 주는 것"이 아니라 "LLM이 정보를 만들어내는 것"입니다. 경계가 모호합니다.

4. 두 레이어 외에 추가로 생각해볼 만한 축

위의 2계층 분류는 정보가 어디에 저장되는가 기준입니다. 다른 축도 있습니다.

아키텍처 레이어: 모델 구조 자체. MoE(Mixture of Experts)로 전문가를 분리하거나, retrieval head를 내장하거나, 메모리 토큰 슬롯을 두는 식. 가중치 학습 이전에 "어떤 형태로 정보를 담을 것인가"를 정합니다. retrieval head를 내장한 예:

  • REALM(Retrieval-Augmented Language Model pre-training): 사전학습 단계부터 retriever를 함께 학습해 외부 코퍼스 검색을 모델에 통합

  • RETRO(Retrieval-Enhanced TRansfOrmer): 트랜스포머 블록에 검색된 청크의 chunked cross-attention을 끼워 넣어 작은 모델로 큰 효과

표현 / 활성값 레이어: 앞서 언급한 activation steering, probing, representation engineering. 가중치도 컨텍스트도 아닌 forward pass 중간 상태에 개입합니다.

디코딩 / 샘플링 레이어: temperature, top-p, constrained decoding, beam search. 정보 주입이 아니라 추출 방식 제어지만, 모델이 "무엇을 말할 수 있는가"를 좌우합니다.

오케스트레이션 / 에이전트 레이어: 여러 LLM 호출을 엮는 외부 통제 흐름. 단일 추론에서는 안 보이지만 시스템 전체 동작을 결정합니다. 멀티 에이전트, 워크플로우, planner-executor 패턴 등.

5. RAG는 어느 레이어인가

RAG는 결국 프롬프트 레이어에 데이터를 공급하는 장치입니다. 검색해 온 청크는 컨텍스트에 토큰으로 들어갑니다. 이 관점이 실용적인 이유는 RAG의 한계가 곧 프롬프트 레이어의 한계(컨텍스트 윈도우, 어텐션의 long-range 약점, 토큰 비용, "lost in the middle")로 환원되기 때문입니다.

다만 RAG를 별개의 레이어로 보는 시각도 일리가 있습니다. 두 가지 이유입니다.

  1. 시스템 구성요소가 훨씬 많음. 임베딩 모델, 벡터 DB, 청킹 전략, 재순위 모델, 쿼리 재작성 등 프롬프트 엔지니어링과 결이 다른 검색 시스템 설계 영역입니다.

  2. 모델과 무관하게 진화함. 베이스 LLM이 그대로여도 RAG 파이프라인 개선만으로 성능이 오릅니다. 독립적 최적화 표면이 있다는 뜻이고, 별도 레이어로 다룰 실용적 이유가 됩니다.

즉 RAG는 개념적으로는 프롬프트 레이어의 하위 범주, 실무적으로는 독립된 서브시스템입니다. "LLM에 정보가 들어가는 경로"를 논할 때는 프롬프트 레이어로 묶고, "이 RAG 파이프라인을 어떻게 개선할까"를 논할 때는 별도 레이어로 다루는 식입니다. 세상에 있는 데이터 → 검색/필터링 → 프롬프트 윈도우 흐름에서 앞단(검색/필터링)이 충분히 복잡해 별도 시스템이 됩니다.

같은 논리로 memory 시스템, MCP, tool use 결과 통합도 "프롬프트 레이어로 흘러드는 데이터 파이프라인"이라는 같은 범주입니다. 이들을 묶어 컨텍스트 공급 레이어(context provisioning layer) 라 부르면, 처음의 2계층이 자연스럽게 3계층으로 확장됩니다.

  1. 모델 레이어 (파라미터)

  2. 컨텍스트 공급 레이어 (RAG, 메모리, 도구, 커넥터)

  3. 프롬프트 레이어 (실제 컨텍스트에 들어간 토큰의 구성과 배치)

여기에 회색지대(PEFT, activation steering)와 아키텍처/디코딩 축까지 보태면, 단순했던 2계층 지도가 더 풍부하게 세분화됩니다.


이 포스트는 Claude(claude.ai)와 정상혁이 함께 작성했습니다.

Hermes와 Modal로 AI agent 챗봇을 0원에 운영하기

Hermes 에이전트와 Modal 서버리스, Telegram webhook, LLM provider를 조합해 AI 챗봇을 무료로 운영한 기록을 정리합니다.

제가 GitHub에 정리한 개인 기록을 근거로 답하는 텔레그램 챗봇을 고정 비용 0원으로 운영하고 싶었습니다. 항상 켜 둘만한 개인 PC가 있지도 않았습니다. 그래서 LLM 에이전트인 Hermes와 서버리스 플랫폼인 Modal, Telegram 웹훅 등의 여러 기술을 조합하여 챗봇을 띄웠습니다. 그 선택 과정과 핵심 코드를 이 글에서 정리했습니다.

이 구성은 트래픽이 많지 않고, Modal의 월별 무료 크레딧 안에서 호출과 저장소 사용량이 감당되며, K-QMD 임베딩 갱신을 아주 자주 돌리지 않는다는 전제에서 0원에 가깝게 운영할 수 있습니다.

1. 챗봇의 기능

제가 그동안 개인적으로 기록해둔 블로그, 독서 감상문, 일기, 개발 관련 링크 모음은 Markdown이나 Asciidoc 형태로 GitHub 저장소에 쌓여 있습니다. 일종의 개인 지식 DB입니다. 해당 지식 저장소들을 한 번에 검색해서 대답을 하는 Telegram 챗봇을 만들었습니다.

아래와 같은 질문들에 재미있는 답을 해주었습니다.

  • 내가 가장 재미있게 읽은 만화책은?

  • 내가 가장 감명 깊게 보낸 한 해는?

  • 나의 주 관심사와 어울리는 책을 추천해줘.

Telegram 챗봇 응답 예시

2. 구성 요소

챗봇은 다음과 같은 구성요소로 설계했습니다.

컴포넌트 역할 설명

Hermes

LLM 에이전트

Telegram webhook gateway, MCP 클라이언트, 에이전트 루프를 모두 제공

K-QMD

검색 엔진

Markdown, Asciidoc 을 임베딩으로 인덱싱하고 의미 검색을 MCP로 제공. QMD에 Kiwi 형태소 분석을 결합한 fork

Modal

실행 환경

컨테이너 실행, 데이터 저장 공간

OpenClaw와 Hermes를 비교하는 글에서 Hermes가 클라우드에 올리기 편하다는 설명을 보고 선택했습니다. 검색 엔진은 처음엔 QMD를 썼는데, 한글 문서에서 검색 recall이 낮아서 한글 형태소 분석기를 결합한 K-QMD fork로 옮겼습니다. K-QMD는 binary 이름과 CLI를 그대로 유지하는 drop-in replacement라 교체 비용이 거의 없었습니다. Modal은 Gemini의 추천을 받아서 선택했습니다. Hermes의 소개글에서 클라우드 실행 환경으로는 Daytona와 Modal을 사용할 수 있다는 설명을 먼저 보고, Gemini에게 무료로 챗봇 운영을 하기에 유리한 플랫폼을 추천해달라고 요청한 결과입니다.

LLM 에이전트는 처음엔 OpenClaw를 썼는데, 위 구성으로 가려고 Hermes로 옮겼습니다. Hermes가 OpenClaw 데이터 마이그레이션을 지원해서 큰 어려움은 없었습니다.

2.1. LLM 엔진

LLM 엔진은 GPT 5.5로 연동했습니다. 평소 주로 쓰는 모델은 Claude 계열이지만, 해당 시점에서는 개인 구독하는 Claude 상품이 Hermes와 연동이 되지 않아서 ChatGPT 구독을 통한 GPT 5.5를 선택했습니다.

2.2. Modal의 운영 모델

Modal은 실제 사용자 호출을 처리하는 동안에만 컨테이너가 깨어나는 서버리스 플랫폼입니다. 대기 시간에는 비용이 발생하지 않고, 현재 Starter 플랜에서는 월 $30의 무료 크레딧이 제공되므로 트래픽이 띄엄띄엄한 Telegram 챗봇은 그 범위 안에서 0원으로 운영할 수 있을 것으로 예상합니다. 대신 서버가 깨어나는 첫 호출에는 cold start 지연이라는 트레이드오프가 있습니다. 첫 메시지는 몇 초~십수 초 늦게 답이 옵니다. 챗봇이 지연에 민감하지 않다면 받아들일 만한 단점입니다.

OpenClaw에서는 기본적으로는 long polling으로 Telegram과 연동을 합니다. long polling을 하기 위해서는 서버가 계속 켜져 있어야 하기에 Modal을 통해 추구하는 "필요할 때만 깨어나는" 운영 모델과는 맞지 않았습니다. 그래서 Hermes로 전환하면서 Telegram의 webhook을 통해 연동하는 구조로 바꿨습니다.

3. 챗봇 앱의 핵심 코드

전체 소스는 benelog/hermes-modal 저장소에 있습니다. 이 글에서는 그중 설계 의도를 보여주는 코드 조각만 발췌해 설명합니다. Modal로 실행하는 파이썬 앱의 설계와 구현은 Claude Code의 도움을 받았습니다.

3.1. 최소 재현 절차

처음부터 같은 구성을 따라 하려면 아래 순서로 접근하는 편이 시행 착오가 적습니다.

  1. Telegram bot token, LLM provider 인증 정보, GitHub token을 준비합니다.

  2. Modal의 Secret과 Volume을 생성합니다.

  3. TELEGRAM_WEBHOOK_URL 없이 먼저 modal deploy 해서 외부 URL을 발급받습니다.

  4. 발급받은 URL을 Telegram webhook과 Modal secret에 반영한 뒤 다시 modal deploy 합니다.

  5. `modal run modal_app.py::sync_qmd --embed`를 실행해서 지식 저장소를 가져오고 인덱스를 만듭니다.

이후에는 Telegram에서 메시지를 보내 챗봇을 호출하고, 문서가 바뀌면 `sync_qmd`만 다시 돌리면 됩니다.

3.2. 메시지가 올 때만 프로세스가 깨어나게

@app.function(
    image=image,
    secrets=[secret, github_secret],
    volumes=volume_mounts,
    timeout=60 * 60,
    max_containers=1,
    scaledown_window=60 * 10,
    env=common_env,
)
@modal.concurrent(max_inputs=50)
@modal.web_server(WEBHOOK_PORT, startup_timeout=120)
def gateway():
    ...
  • `min_containers`를 지정하지 않았습니다. 24/7 고정 컨테이너가 없어 대기 시의 비용이 0이 됩니다.

  • `scaledown_window=600`으로 10분 동안 요청이 없으면 자동 종료합니다.

  • `max_containers=1`로 두 개 이상 컨테이너가 동시에 webhook을 받지 않게 막았습니다. Telegram bot token은 webhook을 받는 주체가 둘 이상이면 동작이 불안정합니다. 한 컨테이너 안에서의 동시 처리는 `@modal.concurrent(max_inputs=50)`로 받습니다.

3.3. 갱신 주기에 따른 Volume 분리

영속적으로 저장되어야할 파일은 Modal의 Volume에 저장했습니다. 이를 이용해서 Hermes 설정 파일, K-QMD 인덱스, 지식 DB 역할의 Git 저장소가 컨테이너 재시작과 무관하게 유지됩니다. Volume은 아래 4개로 나눴습니다.

hermes_volume    = modal.Volume.from_name("hermes-home", ...)
workspace_volume = modal.Volume.from_name("hermes-workspace", ...)
qmd_cache_volume = modal.Volume.from_name("qmd-cache", ...)
qmd_config_volume = modal.Volume.from_name("qmd-config", ...)

각 Volume의 수명과 갱신 주기가 다릅니다.

  • hermes-home: Hermes config, runtime skill, OAuth auth.json. 크기는 작고, 컨테이너 재시작마다 일부가 갱신됩니다.

  • hermes-workspace: K-QMD가 인덱싱하는 git 저장소들. 크기가 가장 크고, sync_qmd 호출 때만 갱신됩니다.

  • qmd-cache: 임베딩/리랭크 GGUF 모델과 캐시. 거의 안 바뀌지만 용량이 큽니다.

  • qmd-config: K-QMD index 설정. 작고 자주 바뀌지 않습니다.

한 Volume에 다 몰아넣으면 데이터 저장, 갱신을 할 때 상관없는 큰 데이터까지 함께 끌려옵니다. 갱신 주기가 비슷한 것끼리 묶는 편이 안전하고 효율적입니다.

3.4. 지식 DB 소스

봇이 답할 때 참고하는 문서는 scripts/qmd_repos.yml에 정의했습니다.

repos:
  - name: blog
    repo_url: git@github.com:benelog/blog.git
    branch: master
    collection_path: src/content
    pattern: "**/*.{md,adoc}"
  - name: wiki
    ...

sync_qmd 함수는 이 manifest를 기준으로 repo를 clone/pull하고, repo 내부의 `collection_path`만 K-QMD config에 연결합니다. 내가 평소 쓰는 노트 저장소(블로그, 위키, devnote, 책장, 일기 등)가 그대로 챗봇의 지식 베이스가 됩니다.

문서를 갱신하고 싶을 때는 텔레그램 채팅창에서 봇에게 "qmd 갱신해줘", "최신 내용 가져와" 같이 요청합니다. sync-qmd 스킬이 git pull과 K-QMD 인덱싱을 실행해줍니다. 동일한 동작은 로컬에서 다음 명령으로도 트리거할 수 있습니다.

modal run modal_app.py::sync_qmd          # repo만 갱신
modal run modal_app.py::sync_qmd --embed  # 임베딩까지

이걸 cron이나 GitHub Actions로 주기 호출하면 챗봇 지식이 자동 최신화됩니다.

3.5. 연동 시스템을 위한 Secret 관리

GitHub, ChatGPT, Telegram 등 다른 시스템과 연동을 하기 위한 Secret도 2개로 나눴습니다. secret의 원본 저장소와 갱신하는 프로세스를 분리하기 위해서입니다.

  • ChatGPT, Telegram 인증을 위한 secret: `scripts/create_modal_secret.py`는 로컬 `~/.hermes/.env`와 `auth.json`에서 Modal secret을 만듭니다. 내부적으로 `modal secret create --force`를 써서 secret 전체를 통째로 교체합니다.

  • GitHub Access token : Modal의 UI에서 직접 입력하도록 의도했습니다.

`modal_app.py`는 두 그룹의 secret을 함께 주입합니다.

secret = modal.Secret.from_name("hermes-modal-secrets")
github_secret = modal.Secret.from_name("github-secret")

@app.function(secrets=[secret, github_secret], ...)
def gateway(): ...

3.6. idempotent한 부팅 스크립트

gateway, sync_qmd, doctor 함수 모두 시작할 때 `scripts/prepare_runtime.py`를 호출합니다. 이 스크립트가 하는 일은 다음과 같습니다.

  • auth.json 작성 (base64로 secret에 들어 있는 OAuth 토큰)

  • Hermes config.yaml, .env 작성

  • ~/.hermes/SOUL.md, ~/.hermes/skills/, ~/.hermes/hooks/`를 image의 `scripts/ 트리와 동기화

  • (옵션) 지식 repo clone/pull과 K-QMD 인덱싱

핵심은 컨테이너가 어떤 상태에서 시작하더라도 같은 결과가 나오게 하는 것입니다. Modal Volume에 이전 실행의 부산물이 남아 있어도, image에서 새로 실어 보낸 skill이 있어도, 이 스크립트 한 번이면 정상 상태로 수렴합니다.

3.7. 런타임에 추가되는 skill을 git으로 관리하기

Hermes는 실행 중에 새로운 skill이 생기거나 기존 skill이 업데이트될 수 있는 Agent입니다. agent의 주인인 제가 그 변화를 추적하고 필요하면 직접 수정할 필요성도 있다고 판단했습니다. 그래서 Modal Volume에 저장되는 skill도 변화가 있을 때 git으로 자동으로 커밋되고 푸시되도록 구성했습니다.

Hermes가 실행 중 만든 skill을 다시 git repo로 push하는 흐름은 scripts/hooks/skills-autocommit/HOOK.yaml 훅으로 걸어 두었습니다.

name: skills-autocommit
description: Push HERMES_HOME/skills changes back to the hermes-modal git repo after each agent turn.
events:
  - agent:end

agent:end 이벤트가 매 에이전트 종료마다 발생할 때 commit_skills.py가 호출됩니다. 이 스크립트는 Modal Volume의 ~/.hermes/skills/ 변경분을 hermes-modal repo의 scripts/skills/`로 복사하고 push합니다. 결과적으로 `git log`에 `Sync skills from Modal volume 커밋이 자동으로 쌓입니다.

이 구조에는 두 가지 의미가 있습니다.

  • runtime에서 학습한 동작이 image로 흡수됩니다. 다음 컨테이너 부팅 때 `prepare_runtime.py`가 image의 `scripts/skills/`를 그대로 Volume에 sync하므로, 결국 Volume은 image를 따라가게 됩니다.

  • 에이전트가 스스로를 진화시키는 사이클이 git에 그대로 남습니다. 어떤 skill이 언제 추가됐는지를 commit log로 확인하고, 사람이 review/revert 할 수 있습니다.

`commit_skills.py`에는 안전장치 두 개를 두었습니다.

LOCK_FILE = Path("/tmp/hermes-modal-commit-skills.lock")
FINGERPRINT_FILE = SKILLS_DIR / ".last_committed_hash"
  • flock으로 동시에 여러 호출이 들어와도 직렬화합니다.

  • SHA256 fingerprint로 skill 트리가 안 바뀌었으면 git clone조차 하지 않고 빠르게 끝냅니다. `agent:end`는 매 에이전트 실행마다 발생하므로 변경이 없을 때의 비용을 0에 가깝게 두는 것이 중요합니다.

디버깅 등 수동으로 트리거하고 싶을 때는 `modal run modal_app.py::commit_skills`를 사용합니다.

4. 배포, 운영

4.1. webhook URL을 위한 두 단계 배포

Modal에서 webhook 봇을 띄울 때 자주 부딪히는 닭-달걀 문제가 있습니다.

  • Telegram에 webhook URL을 등록하려면 Modal URL이 필요합니다.

  • 그런데 Modal URL은 deploy 후에야 알 수 있습니다.

이 문제를 해결하기 위해 gateway 함수가 `TELEGRAM_WEBHOOK_URL`이 비어 있으면 placeholder HTTP 서버만 띄우고 끝냅니다.

if not os.environ.get("TELEGRAM_WEBHOOK_URL", "").strip():
    print("TELEGRAM_WEBHOOK_URL is not set; starting placeholder server ...")
    subprocess.Popen(["python", "-m", "http.server", str(WEBHOOK_PORT), ...])
    return

subprocess.Popen(["bash", "-lc",
    "while true; do hermes gateway run --replace; ... done"], env=os.environ.copy())

흐름은 이렇습니다.

  1. webhook URL 없이 modal deploy → Modal URL 발급

  2. 그 URL을 secret에 넣고 다시 modal deploy → Hermes가 정상 webhook 모드로 전환

Hermes를 while true; do …​ done 루프로 감싼 까닭은, 일시적인 네트워크 문제나 OAuth refresh 실패로 프로세스가 죽었을 때 곧바로 다시 띄우기 위해서입니다.

4.2. ChatGPT(Codex) OAuth refresh token 충돌

챗봇이 다음과 같이 에러 응답을 하는 상황이 있었습니다.

Provider authentication failed: Codex refresh token was already consumed by
another client (e.g. Codex CLI or VS Code extension). Run codex in your
terminal to generate fresh tokens, then run hermes auth to re-authenticate.

OAuth refresh token이 갱신되면서 Modal의 앱이 참조하는 토큰이 무효화된 것이 원인이었습니다. 당장의 복구는 ChatGPT 인증을 다시 하고 `~/.hermes/auth.json`을 새로 받아 Modal Secret을 다시 올리고 재배포하는 것입니다. 근본 해결은 provider를 OpenRouter/Anthropic/OpenAI API key 같은 단순 키 방식으로 바꾸는 것입니다. 토큰 회전이 없는 provider는 이 충돌이 아예 없습니다. 무료에 가깝게 쓰겠다는 처음 목표 때문에 OAuth provider를 일단 유지하고 있습니다.

5. 핵심 팁 정리

이 프로젝트를 통해 얻은 서버리스 플랫폼에서의 AI 챗봇 운영 팁은 다음과 같습니다.

  • 고정 비용을 피하려면 대기 비용을 최소화 : Modal에서는 `min_containers`를 두지 않고 `scaledown_window`만 짧게 잡는 것이 free tier 친화적입니다.

  • 상태를 다시 만들 수 있게 부팅 과정을 idempotent하게 : Volume에 이전 실행 흔적이 남아도 prepare_runtime.py 한 번으로 원하는 상태로 수렴하게 하는 것이 운영 부담을 줄입니다.

  • 갱신 주기가 다른 데이터는 Volume도 분리 : 설정, 캐시, 지식 저장소를 따로 두면 큰 데이터를 괜히 함께 흔들지 않게 됩니다.

  • 에이전트의 runtime 변경이 의미가 있다면 git으로 관리 : 서버 Volume에만 남으면 데이터를 잃기 쉽고, 사람이 추적, 수정 하기가 어렵습니다.

  • Long polling 보다는 Webhook 방식으로 : 챗봇을 위한 프로세스가 항상 켜져 있을 필요가 없는 Webhook 방식이 서버리스 플랫폼과 더 잘 맞습니다.

    • 초기 배포는 두 단계 : webhook URL이 deploy 이후에 생기는 구조라면 URL 확보를 위한 배포가 먼저 필요합니다.

6. 참고 자료

  • hermes-modal — 이 글에서 설명한 저장소

  • Modal docs — Volume, Secret, web_server, concurrent 등의 공식 문서

  • Hermes Agent — Nous Research의 LLM 에이전트 프레임워크

  • K-QMD — QMD에 Kiwi 한글 형태소 분석을 결합한 fork (실제 사용 중)

  • QMD — K-QMD의 upstream인 markdown/asciidoc 임베딩 검색 MCP

  • Telegram Bot API: Webhooks — webhook 모드 공식 문서


이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.