Skip to content

필사 모드: LangChain + RAG로 지능형 Telegram FAQ 봇 만들기: 문서 기반 질의응답 시스템

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며

규칙 기반 챗봇은 미리 정의된 질문에만 답할 수 있지만, RAG(Retrieval-Augmented Generation) 기반 챗봇은 문서에서 관련 정보를 검색하여 자연어로 답변합니다. 이 글에서는 회사 FAQ 문서를 기반으로 질문에 답하는 Telegram 봇을 구축합니다.

버전 확인부터: 이 글의 import는 이미 레거시입니다

이 글의 코드는 쓸 당시에는 동작했습니다. 2026년 8월 기준으로는 그대로 복사하면 안 됩니다. python-telegram-bot은 22.8(2026-06-12)이 최신이고 Telegram Bot API 10.0을 네이티브로 지원합니다. 공식 문서는 v20.0부터 파이썬 asyncio 위에 올라가 있다고 적고 있고, 이 사실이 뒤에서 발목을 잡습니다.

LangChain은 langchain 1.3.15(2026-08-11), langchain-core 1.5.5, langchain-classic 1.0.8, langchain-community 0.4.2입니다. 가장 큰 변화는 langchain-community의 sunset입니다. PyPI에 "langchain-community is being sunset" 배너가 붙었고 이슈 본문은 "This sunset will take effect immediately"라고 적고 있습니다. 날짜는 2026-05-22이고 EOL 날짜는 명시되지 않았으니 사라지는 시점을 추측해 일정을 잡지 마세요. v1의 langchain 패키지에는 agents, messages, tools, chat_models, embeddings만 남았고, 이 글이 쓰는 체인과 메모리는 langchain-classic으로 이사했습니다.

# 이 글의 import → 2026-08 기준 위치
langchain.chains.ConversationalRetrievalChain
  → langchain_classic.chains.conversational_retrieval.base.ConversationalRetrievalChain
    (0.1.17부터 deprecated)
langchain.chains.RetrievalQA
  → langchain_classic.chains.retrieval_qa.base.RetrievalQA
langchain.memory.ConversationBufferMemory
  → langchain_classic.memory.buffer.ConversationBufferMemory
langchain.text_splitter.RecursiveCharacterTextSplitter
  → langchain_text_splitters.RecursiveCharacterTextSplitter
langchain_community.vectorstores.Chroma
  → langchain_chroma.Chroma   (community 0.2.9부터 deprecated)
langchain_community.vectorstores.FAISS
  → 이동 경로 없음. langchain-faiss 패키지는 존재하지 않는다.

v0.2와 v0.3 시절에 레거시 체인의 대안으로 안내받았던 create_retrieval_chain, create_stuff_documents_chain, create_history_aware_retriever도 v1에서는 langchain-classic에 있습니다. 한 번 마이그레이션한 사람일수록 놀라는 부분입니다. v1이 앞세우는 것은 create_agent이고, 단기 메모리는 Memory 객체가 아니라 체크포인터입니다.

from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(model="openai:gpt-5.5", tools=[...], checkpointer=InMemorySaver())

thread_config = {"configurable": {"thread_id": "1"}}
response = agent.invoke(
    {"messages": [{"role": "user", "content": "Hi! My name is Bob."}]},
    thread_config,
)

프로덕션에서는 InMemorySaver 대신 PostgresSaver를 씁니다. 텔레그램에서 이 구조가 잘 맞는 이유는 thread_id 자리에 채팅 ID를 그대로 넣으면 되기 때문입니다. 설치는 이렇게 됩니다.

pip install "python-telegram-bot[rate-limiter]==22.8" \
  langchain langchain-classic langchain-openai \
  langchain-chroma langchain-text-splitters \
  chromadb tiktoken pypdf docx2txt

아래 코드는 원래 형태로 두었습니다. 레거시 경로가 아직 동작하고 유지보수 중인 코드베이스 대부분이 이 모양이기 때문입니다.

아키텍처

사용자 질문
Telegram Bot API
LangChain RAG Pipeline
    ├── 1. Query Embedding (OpenAI)
    ├── 2. Vector Search (ChromaDB)
    ├── 3. Context Retrieval (Top-K)
    └── 4. LLM Generation (GPT-4o)
답변 + 출처 표시

환경 설정

pip install langchain langchain-openai langchain-community \
  chromadb python-telegram-bot tiktoken \
  pypdf docx2txt unstructured
# config.py
import os

TELEGRAM_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]

# RAG 설정
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
TOP_K = 4
MODEL_NAME = "gpt-4o"
EMBEDDING_MODEL = "text-embedding-3-small"

문서 로딩과 인덱싱

# indexer.py
from langchain_community.document_loaders import (
    DirectoryLoader,
    PyPDFLoader,
    TextLoader,
    Docx2txtLoader,
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

def load_documents(docs_dir: str):
    """다양한 형식의 문서 로딩"""
    loaders = {
        "**/*.pdf": PyPDFLoader,
        "**/*.txt": TextLoader,
        "**/*.md": TextLoader,
        "**/*.docx": Docx2txtLoader,
    }

    all_docs = []
    for glob_pattern, loader_cls in loaders.items():
        loader = DirectoryLoader(
            docs_dir,
            glob=glob_pattern,
            loader_cls=loader_cls,
            show_progress=True,
        )
        docs = loader.load()
        all_docs.extend(docs)
        print(f"Loaded {len(docs)} docs from {glob_pattern}")

    return all_docs

def create_vector_store(docs_dir: str, persist_dir: str = "./chroma_db"):
    """문서를 청크로 분할하고 벡터 스토어에 저장"""
    # 문서 로딩
    documents = load_documents(docs_dir)
    print(f"Total documents: {len(documents)}")

    # 텍스트 분할
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=200,
        separators=["\n\n", "\n", ".", "!", "?", ",", " "],
    )
    chunks = text_splitter.split_documents(documents)
    print(f"Total chunks: {len(chunks)}")

    # 임베딩 생성 & 벡터 스토어 저장
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_dir,
        collection_metadata={"hnsw:space": "cosine"},
    )

    print(f"Vector store created at {persist_dir}")
    return vectorstore

if __name__ == "__main__":
    create_vector_store("./docs")

인덱싱을 처음 돌렸을 때 화면에 뭐가 찍히나

indexer.py를 처음 돌리면 이런 출력이 나옵니다. 숫자는 문서마다 다르지만 형태는 같습니다.

$ python indexer.py
100%|█████████████████████████| 12/12 [00:04<00:00,  2.71it/s]
Loaded 47 docs from **/*.pdf
100%|█████████████████████████| 31/31 [00:00<00:00, 240.11it/s]
Loaded 31 docs from **/*.txt
Loaded 9 docs from **/*.md
Loaded 3 docs from **/*.docx
Total documents: 90
Total chunks: 412
Vector store created at ./chroma_db

볼 숫자는 세 개입니다. 첫째, Total documents입니다. PyPDFLoader는 페이지 단위로 Document를 만들기 때문에 PDF 12개를 넣고 47이 나오면 정상이고, 12가 나오면 페이지 분리가 안 된 것입니다. 둘째, Total chunks입니다. chunk_size 1000에 오버랩 200이면 청크 하나가 소화하는 새 텍스트는 800자 남짓이니, 전체 글자수를 800으로 나눈 값과 비슷해야 합니다. 문서 90개에 청크 95개라면 대부분이 1000자 미만이라 청킹이 하는 일이 없습니다. 셋째, chroma_db 디렉터리 크기입니다. 수십 KB에 그친다면 임베딩이 조용히 실패했거나 청크가 비어 있습니다. 스캔한 PDF처럼 텍스트 레이어가 없는 파일은 로더가 성공하고 page_content만 빈 문자열이 되는데, 이때 인덱싱은 끝까지 에러 없이 돌아갑니다.

봇을 켜기 전에 검색만 따로 확인하는 스크립트를 돌리세요.

# smoke_test.py — 봇 없이 검색만 확인한다
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
store = Chroma(
    embedding_function=embeddings,
    persist_directory="./chroma_db",
)

QUERIES = [
    "연차는 며칠부터 쓸 수 있나요",   # 문서에 있는 질문
    "재택근무 신청 절차",             # 문서에 있는 질문
    "회사 강아지 이름이 뭐야",        # 문서에 없는 질문 = 음성 대조군
]

for q in QUERIES:
    docs = store.similarity_search(q, k=3)
    print(f"\nQ: {q}  -> {len(docs)} hits")
    for d in docs:
        src = d.metadata.get("source", "?")
        print(f"   {src}: {d.page_content[:60]}...")

출력은 이런 모양입니다.

Q: 연차는 며칠부터 쓸 수 있나요  -> 3 hits
   docs/hr-policy.pdf: 연차 유급휴가는 입사일로부터 1년간 80퍼센트 이상 출근한 근로자에게...
   docs/hr-policy.pdf: 연차 사용 촉진 제도에 따라 미사용 연차는 매년 12월 31일 기준으로...
   docs/onboarding.md: 입사 첫 해에는 1개월 개근 시 1일의 유급휴가가 발생하며...

Q: 회사 강아지 이름이 뭐야  -> 3 hits
   docs/office-guide.md: 사무실 출입 카드는 1층 안내데스크에서 수령하며 분실 시...
   docs/hr-policy.pdf: 경조사 휴가는 다음 기준에 따라 부여합니다. 본인 결혼 5일...
   docs/onboarding.md: 슬랙 채널 안내는 다음과 같습니다. 전사 공지는 general 채널...

세 번째 쿼리가 이 스크립트의 전부입니다. 문서에 없는 질문을 넣어도 similarity_search는 언제나 k개를 돌려줍니다. 유사도가 아무리 낮아도 가장 가까운 것을 채워서 줍니다. 검색이 되는지 확인하려면 되는 케이스가 아니라 안 되는 케이스를 봐야 하는 이유가 이것입니다. 위 출력의 두 번째 블록에 나온 세 청크는 질문과 아무 상관이 없는데도 그대로 LLM 컨텍스트로 들어갑니다. 봇이 지어내는 답변의 상당수는 생성 단계가 아니라 이 지점에서 이미 결정되어 있습니다.

RAG 체인 구현

# rag_chain.py
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferWindowMemory
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate

SYSTEM_PROMPT = """당신은 회사 FAQ 도우미입니다. 제공된 컨텍스트를 기반으로 질문에 답변하세요.

규칙:
1. 컨텍스트에 있는 정보만 사용하세요.
2. 확실하지 않으면 "제공된 문서에서 해당 정보를 찾을 수 없습니다"라고 답하세요.
3. 답변 끝에 참고한 문서 출처를 표시하세요.
4. 간결하고 명확하게 답변하세요.

컨텍스트:
{context}"""

def create_rag_chain(persist_dir: str = "./chroma_db"):
    """RAG 체인 생성"""
    # 벡터 스토어 로드
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = Chroma(
        persist_directory=persist_dir,
        embedding_function=embeddings,
    )

    # 리트리버 설정
    retriever = vectorstore.as_retriever(
        search_type="mmr",  # Maximal Marginal Relevance
        search_kwargs={
            "k": 4,
            "fetch_k": 10,
            "lambda_mult": 0.7,
        },
    )

    # LLM
    llm = ChatOpenAI(
        model="gpt-4o",
        temperature=0.1,
        max_tokens=1024,
    )

    # 대화 메모리 (최근 5턴)
    memory = ConversationBufferWindowMemory(
        k=5,
        memory_key="chat_history",
        return_messages=True,
        output_key="answer",
    )

    # 프롬프트
    prompt = ChatPromptTemplate.from_messages([
        SystemMessagePromptTemplate.from_template(SYSTEM_PROMPT),
        HumanMessagePromptTemplate.from_template("{question}"),
    ])

    # 체인 생성
    chain = ConversationalRetrievalChain.from_llm(
        llm=llm,
        retriever=retriever,
        memory=memory,
        return_source_documents=True,
        combine_docs_chain_kwargs={"prompt": prompt},
        verbose=False,
    )

    return chain

class RAGBot:
    """사용자별 대화 컨텍스트를 관리하는 RAG 봇"""

    def __init__(self, persist_dir: str = "./chroma_db"):
        self.persist_dir = persist_dir
        self.user_chains: dict[int, ConversationalRetrievalChain] = {}

    def get_chain(self, user_id: int):
        """사용자별 체인 (대화 메모리 분리)"""
        if user_id not in self.user_chains:
            self.user_chains[user_id] = create_rag_chain(self.persist_dir)
        return self.user_chains[user_id]

    async def ask(self, user_id: int, question: str) -> tuple[str, list[str]]:
        """질문에 답변하고 출처를 반환"""
        chain = self.get_chain(user_id)
        result = chain.invoke({"question": question})

        answer = result["answer"]
        sources = []
        for doc in result.get("source_documents", []):
            source = doc.metadata.get("source", "Unknown")
            page = doc.metadata.get("page", "")
            if page:
                sources.append(f"{source} (p.{page})")
            else:
                sources.append(source)

        # 중복 제거
        sources = list(dict.fromkeys(sources))
        return answer, sources

    def reset_memory(self, user_id: int):
        """사용자의 대화 메모리 초기화"""
        if user_id in self.user_chains:
            del self.user_chains[user_id]

검색이 되는 건지, LLM이 지어내는 건지

봇을 띄우면 어쨌든 답이 나옵니다. 그 답이 문서에서 나온 것인지 모델이 원래 알던 것인지는 채팅창만 봐서는 구분되지 않습니다. 세 가지를 켜 두면 구분됩니다.

첫째, 빈 결과를 만들 수 있게 합니다. as_retriever의 search_type은 기본값 similarity, mmr, similarity_score_threshold 셋뿐입니다. 앞의 둘은 절대 빈 리스트를 주지 않고, 세 번째만 관련 문서 없음을 표현할 수 있습니다.

retriever = store.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={"k": 4, "score_threshold": 0.5},
)

docs = retriever.invoke(question)
if not docs:
    # LLM을 아예 호출하지 않는다. 여기서 끝내는 것이 정답이다.
    return "제공된 문서에서 해당 정보를 찾을 수 없습니다.", []

임계값은 매직 넘버가 아닙니다. 임베딩 모델과 거리 함수에 따라 같은 0.5가 다른 의미가 됩니다. 앞 스모크 테스트의 음성 대조군 점수와 정상 질문 점수 사이에서 고르세요. 점수가 거리인지 유사도인지도 구현마다 다릅니다. 정확한 API는 사용 중인 버전의 문서에서 확인하세요.

둘째, 검색된 청크를 로그로 남깁니다.

logger.info(
    "retrieval chat_id=%s q=%r hits=%d sources=%s",
    chat_id,
    question[:80],
    len(docs),
    [d.metadata.get("source") for d in docs],
)

제보를 받으면 제일 먼저 보는 줄입니다. hits가 0인데 답변이 나갔다면 프롬프트 가드가 뚫린 것이고, hits가 4인데 sources가 전부 엉뚱하면 검색 문제이지 LLM 문제가 아닙니다. 이 구분이 안 되면 프롬프트만 몇 주 고치게 됩니다.

셋째, 검색 전용 명령을 붙입니다. CommandHandler(command, callback, filters=None, block=True, has_args=None)에서 명령 뒤에 붙은 인자는 CallbackContext.args로 들어옵니다.

async def debug(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    query = " ".join(context.args)
    if not query:
        await update.message.reply_text("사용법: /debug 검색어")
        return

    docs = retriever.invoke(query)
    if not docs:
        await update.message.reply_text("검색 결과 없음 (임계값 미달)")
        return

    lines = []
    for i, d in enumerate(docs, 1):
        src = d.metadata.get("source", "?")
        lines.append(f"{i}. {src}\n   {d.page_content[:120]}")
    await update.message.reply_text("\n".join(lines))

application.add_handler(CommandHandler("debug", debug))

LLM을 거치지 않고 검색 결과만 보여 주는 명령입니다. 답이 틀렸다는 제보의 문장을 그대로 넣어 보면 검색이 틀렸는지 생성이 틀렸는지가 한 번에 갈립니다.

끝으로 출처 표시에 대한 오해 하나. 소스가 붙어 있다는 것은 그 청크가 컨텍스트에 들어갔다는 뜻이지 답변 문장이 거기서 나왔다는 보증이 아닙니다. 반대로 소스가 비었는데 자신 있는 답변이 나왔다면 그건 문서가 아니라 모델이 말하고 있는 것입니다.

Telegram 봇 구현

# bot.py
import logging
from telegram import Update, BotCommand
from telegram.ext import (
    Application,
    CommandHandler,
    MessageHandler,
    filters,
    ContextTypes,
)
from rag_chain import RAGBot
from config import TELEGRAM_TOKEN

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

rag_bot = RAGBot()

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """시작 명령어"""
    welcome = (
        "안녕하세요! FAQ 도우미입니다.\n\n"
        "궁금한 것을 자유롭게 물어보세요.\n"
        "회사 문서를 기반으로 답변해 드립니다.\n\n"
        "명령어:\n"
        "/reset - 대화 초기화\n"
        "/sources - 검색 가능한 문서 목록"
    )
    await update.message.reply_text(welcome)

async def reset(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """대화 메모리 초기화"""
    user_id = update.effective_user.id
    rag_bot.reset_memory(user_id)
    await update.message.reply_text("대화가 초기화되었습니다.")

async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """일반 메시지 처리"""
    user_id = update.effective_user.id
    question = update.message.text

    # 타이핑 표시
    await context.bot.send_chat_action(
        chat_id=update.effective_chat.id,
        action="typing"
    )

    try:
        answer, sources = await rag_bot.ask(user_id, question)

        # 답변 포맷팅
        response = answer
        if sources:
            response += "\n\n📚 참고 문서:\n"
            for src in sources[:3]:
                response += f"  • {src}\n"

        await update.message.reply_text(response)

    except Exception as e:
        logger.error(f"Error: {e}")
        await update.message.reply_text(
            "죄송합니다. 답변을 생성하는 중 오류가 발생했습니다."
        )

async def post_init(application: Application):
    """봇 시작 시 명령어 등록"""
    commands = [
        BotCommand("start", "봇 시작"),
        BotCommand("reset", "대화 초기화"),
        BotCommand("sources", "검색 가능한 문서 목록"),
    ]
    await application.bot.set_my_commands(commands)

def main():
    app = Application.builder().token(TELEGRAM_TOKEN).post_init(post_init).build()

    app.add_handler(CommandHandler("start", start))
    app.add_handler(CommandHandler("reset", reset))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))

    logger.info("Bot started")
    app.run_polling(allowed_updates=Update.ALL_TYPES)

if __name__ == "__main__":
    main()

4096자 벽, 그리고 타이핑 표시

RAG 봇의 답변은 길어집니다. 청크 네 개를 넣고 근거를 들어 설명하라고 시키면 2,000자는 우습게 넘습니다. 그런데 telegram.constants.MessageLimit.MAX_TEXT_LENGTH는 4096입니다. 넘기면 전송이 실패하는데, 어떤 예외가 올라오는지는 단정하지 않겠습니다. telegram.error.BadRequest의 설명은 요청을 처리하지 못했다는 문장뿐이고 길이 언급이 없습니다. 정확한 예외 타입은 사용 중인 버전의 문서에서 확인하세요. 중요한 건 예외 이름이 아니라 미리 자르는 일입니다.

from telegram.constants import MessageLimit

def split_for_telegram(text: str, limit: int = MessageLimit.MAX_TEXT_LENGTH) -> list[str]:
    """문단 경계 -> 줄 경계 -> 강제 절단 순으로 자른다."""
    if len(text) <= limit:
        return [text]

    parts: list[str] = []
    buf = ""
    for block in text.split("\n\n"):
        if len(block) > limit:
            for line in block.split("\n"):
                while len(line) > limit:
                    parts.append(line[:limit])
                    line = line[limit:]
                if len(buf) + len(line) + 1 > limit:
                    parts.append(buf)
                    buf = line
                else:
                    buf = f"{buf}\n{line}" if buf else line
            continue
        if len(buf) + len(block) + 2 > limit:
            parts.append(buf)
            buf = block
        else:
            buf = f"{buf}\n\n{block}" if buf else block
    if buf:
        parts.append(buf)
    return parts

async def reply_long(update: Update, text: str) -> None:
    for part in split_for_telegram(text):
        await update.message.reply_text(part)

문단 경계를 우선하는 이유가 있습니다. 코드 블록이 섞인 답변을 4096자에서 기계적으로 자르면 여는 백틱과 닫는 백틱이 다른 메시지로 갈라지고, parse_mode를 켜 뒀다면 포맷 오류로 전송이 통째로 실패합니다. 잘려서 오는 게 아니라 아예 안 옵니다.

타이핑 표시는 RAG 봇에서 선택이 아닙니다. 임베딩과 벡터 검색에 LLM 생성까지 더하면 체감 지연이 3초에서 10초입니다. 값은 문자열 대신 상수를 쓰세요. 경로는 telegram.constants.ChatAction이고 TYPING, UPLOAD_PHOTO, UPLOAD_DOCUMENT, CHOOSE_STICKER, FIND_LOCATION 등이 있습니다.

from telegram.constants import ChatAction

await context.bot.send_chat_action(
    chat_id=update.effective_chat.id,
    action=ChatAction.TYPING,
)

주의할 점은 지속 시간입니다. 텔레그램 문서 표현으로 "The status is set for 5 seconds or less"입니다. 생성이 12초 걸리면 사용자는 7초 동안 아무 반응 없는 화면을 봅니다. 생성이 끝날 때까지 4초 간격으로 다시 호출하는 백그라운드 태스크를 두세요. send_chat_action의 정확한 파라미터 목록은 사용 중인 버전의 문서에서 확인하세요. 레이트 리밋도 따라옵니다. 텔레그램 봇 FAQ의 문장입니다.

# https://core.telegram.org/bots/faq
"In a single chat, avoid sending more than one message per second."
"In a group, bots are not be able to send more than 20 messages per minute."
"For bulk notifications, bots are not able to broadcast more than about
 30 messages per second, unless they enable paid broadcasts."

4096자 분할과 레이트 리밋은 붙어 있는 문제입니다. 긴 답변을 네 조각으로 잘라 연달아 보내면 1초에 네 개를 던져 단일 채팅 권고를 넘기고, 그룹이라면 분당 20개에도 걸립니다. 사용자가 많은 봇이 아니라 답변이 긴 봇이 먼저 걸린다는 점이 RAG 봇의 특이한 지점입니다. telegram.ext.AIORateLimiter의 기본값은 overall_max_rate=30, overall_time_period=1, group_max_rate=20, group_time_period=60, max_retries=0으로 위 숫자를 그대로 반영합니다. 별도 extra 설치가 필요합니다.

pip install "python-telegram-bot[rate-limiter]"
from telegram.ext import AIORateLimiter, ApplicationBuilder

application = (
    ApplicationBuilder()
    .token(TELEGRAM_TOKEN)
    .rate_limiter(AIORateLimiter())
    .concurrent_updates(True)
    .build()
)

max_retries 기본값이 0이라는 점은 기억해 두세요. 기본 설정에서는 RetryAfter를 받아도 재시도하지 않습니다. RetryAfter에는 retry_after 속성이 있으니 값을 올리거나 직접 처리해야 합니다. 참고로 Application.builder()는 정적 메서드로 ApplicationBuilder를 돌려주며, token과 bot은 배타적입니다.

대화 메모리는 재시작하면 사라진다

위의 RAGBot은 user_chains 딕셔너리에 사용자별 체인을 들고 있습니다. 프로세스 메모리라 배포 한 번이면 전부 날아가고, 사용자 수만큼 객체가 쌓이는데 정리하는 코드가 없습니다. 사내 100명이면 괜찮지만 오픈 채널에 붙이면 그대로 누수입니다.

라이브러리가 이미 이 자리를 제공합니다. context.user_data는 사용자 ID마다 매핑되는 딕셔너리이고, chat_data는 채팅 ID 기준, bot_data는 봇 전체에서 하나입니다.

여기서 설계 선택이 하나 생깁니다. 1:1 대화만 받으면 user_data와 chat_data가 같지만, 그룹에 초대되는 순간 갈립니다. user_data는 같은 사람이 여러 그룹에서 맥락을 이어 가는 모양이고, chat_data는 그룹 전체가 하나의 맥락을 공유하는 모양입니다. 사내 FAQ 봇이라면 대개 chat_data가 자연스럽습니다.

기본값으로는 이것도 프로세스 메모리입니다. 디스크로 내리려면 PicklePersistence(filepath, store_data=None, single_file=True, on_flush=False, update_interval=60, context_types=None)를 붙입니다. user_data, chat_data, bot_data, callback_data, conversations를 저장합니다.

from telegram.ext import ApplicationBuilder, PicklePersistence

persistence = PicklePersistence(filepath="bot_state.pickle")

application = (
    ApplicationBuilder()
    .token(TELEGRAM_TOKEN)
    .persistence(persistence)
    .build()
)

Docker라면 bot_state.pickle이 반드시 볼륨 위에 있어야 합니다. 이미지 안에 있으면 컨테이너를 다시 만들 때마다 초기화되어, 붙였다고 생각한 영속성이 없는 것과 같습니다. 아래 docker-compose.yml의 chroma-data 볼륨 옆에 하나 더 추가하세요.

v1 스타일이라면 이 자리를 체크포인터가 대신하고, thread_id에 채팅 ID를 그대로 넣습니다.

from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

# 프로덕션에서는 InMemorySaver 대신 PostgresSaver를 쓰라고 문서가 안내한다.
# 정확한 import 경로는 사용 중인 버전의 문서에서 확인할 것.
agent = create_agent(model="openai:gpt-5.5", tools=[], checkpointer=InMemorySaver())

async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    config = {"configurable": {"thread_id": str(update.effective_chat.id)}}
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": update.message.text}]},
        config,
    )
    # 반환 구조는 버전에 따라 다르다. 정확한 API는 사용 중인 버전의 문서에서 확인할 것.
    await reply_long(update, extract_answer(result))

이 몇 줄이 RAGBot의 get_chain, user_chains, reset_memory를 전부 대신합니다.

asyncio 위에서 벡터 검색을 await 한다는 것

python-telegram-bot은 v20부터 asyncio 기반입니다. 이벤트 루프 하나가 모든 업데이트를 처리하므로 핸들러에서 블로킹 호출을 하면 봇 전체가 멈춥니다. 여기까지는 익숙합니다.

문제는 await를 붙였다고 안심할 수 없다는 점입니다. LangChain VectorStore의 비동기 메서드는 대부분 진짜 비동기가 아니라 스레드풀 래핑입니다. langchain-core 소스는 이렇게 생겼습니다.

async def asimilarity_search(self, query: str, k: int = 4, **kwargs: Any) -> list[Document]:
    return await run_in_executor(None, self.similarity_search, query, k=k, **kwargs)

소스 주석은 이 메서드들을 "temporary workarounds"라 부르고, 제대로 된 해법은 벡터 스토어 구현이 직접 비동기가 되는 것이라고 적어 두었습니다. 그리고 langchain_chroma의 Chroma에는 async def가 하나도 없습니다. 동기 메서드만 정의하고 a로 시작하는 메서드는 전부 베이스에서 상속받습니다.

그래서 await retriever.ainvoke(question)은 이벤트 루프를 놓아주는 것처럼 보이지만 실제로는 스레드풀 워커 하나를 점유합니다. 워커 수는 유한하고, 동시 질문이 그 크기를 넘으면 큐잉이 시작됩니다. 사용자 눈에는 가끔 느려진다로 보이고, 원인을 LLM API 지연으로 오해하기 쉽습니다.

같은 맥락의 실수가 하나 더 있습니다. run_polling과 run_webhook은 코루틴이 아니라 블로킹 동기 메서드입니다. async def main 안에서 await를 붙이면 동작하지 않고, 평범한 def main에서 호출해야 합니다. 위 원본 코드가 def main인 것이 맞습니다.

run_polling(poll_interval=0.0, timeout=datetime.timedelta(seconds=10),
            bootstrap_retries=0, allowed_updates=None, drop_pending_updates=None,
            close_loop=True, stop_signals=None)

run_webhook(listen='127.0.0.1', port=80, url_path='', cert=None, key=None,
            bootstrap_retries=0, webhook_url=None, allowed_updates=None,
            drop_pending_updates=None, ip_address=None, max_connections=40,
            close_loop=True, stop_signals=None, secret_token=None, unix=None)

기본 설정에서는 업데이트가 순차 처리됩니다. 앞 사용자의 RAG 호출이 끝나야 다음 차례가 옵니다. concurrent_updates를 켜면 동시 처리가 되지만, 켜는 순간 위의 스레드풀 문제와 앞 절의 레이트 리밋이 동시에 현실이 됩니다. 셋을 따로 결정하지 말고 같이 보세요.

Docker로 배포

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# 문서 인덱싱
RUN python indexer.py

CMD ["python", "bot.py"]
# docker-compose.yml
services:
  faq-bot:
    build: .
    environment:
      - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    volumes:
      - ./docs:/app/docs
      - chroma-data:/app/chroma_db
    restart: unless-stopped

volumes:
  chroma-data:
docker-compose up -d

문서 자동 업데이트

# watcher.py - 문서 변경 감지 및 자동 재인덱싱
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import time

class DocChangeHandler(FileSystemEventHandler):
    def __init__(self, indexer_fn):
        self.indexer_fn = indexer_fn
        self.last_indexed = 0

    def on_modified(self, event):
        if event.is_directory:
            return
        # 디바운스 (5초 이내 중복 방지)
        now = time.time()
        if now - self.last_indexed < 5:
            return
        self.last_indexed = now

        print(f"Document changed: {event.src_path}")
        self.indexer_fn()

def watch_docs(docs_dir, indexer_fn):
    handler = DocChangeHandler(indexer_fn)
    observer = Observer()
    observer.schedule(handler, docs_dir, recursive=True)
    observer.start()
    return observer

성능 최적화

캐싱

from functools import lru_cache
import hashlib

class CachedRAGBot(RAGBot):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.cache: dict[str, tuple[str, list[str]]] = {}

    async def ask(self, user_id: int, question: str):
        cache_key = hashlib.md5(question.lower().strip().encode()).hexdigest()

        if cache_key in self.cache:
            return self.cache[cache_key]

        answer, sources = await super().ask(user_id, question)
        self.cache[cache_key] = (answer, sources)
        return answer, sources

실패 사례와 함정

증상을 먼저 적고 진단을 붙였습니다.

봇이 조용해지고 로그에 Conflict가 찍힌다

telegram.error.Conflict의 설명은 "Raised when a long poll or webhook conflicts with another one"입니다. 같은 토큰으로 폴링하는 프로세스가 둘 이상이라는 뜻입니다. 서버의 봇이 살아 있는데 로컬에서 python bot.py를 돌린 경우가 압도적으로 흔하고, 다음은 재배포 시 이전 컨테이너가 아직 안 죽은 상태입니다. 해결은 BotFather에서 개발용 토큰을 따로 만드는 것입니다. 이 라이브러리의 에러는 TelegramError 아래로 NetworkError, BadRequest, TimedOut, Forbidden, InvalidToken, EndPointNotFound, ChatMigrated, RetryAfter, Conflict, PassportDecryptionError로 갈립니다.

답변이 그럴듯한데 문서에 없는 내용이다

순서대로 봅니다. 검색 로그의 sources가 엉뚱하면 검색 문제입니다. 파일이 맞으면 그 청크의 실제 텍스트를 봅니다. PDF 로더가 표를 뭉갰거나 머리글과 바닥글이 본문에 섞였을 수 있습니다. 청크까지 맞아야 그제서야 프롬프트 문제입니다. 이 순서를 건너뛰면 검색이 원인인 문제를 프롬프트로 고치려다 몇 주를 씁니다.

긴 답변만 통째로 사라진다

짧은 질문은 되는데 설명을 요구하는 질문에만 답이 안 옵니다. 4096자입니다. except Exception으로 삼키고 오류 메시지만 뿌리면 로그에도 원인이 안 남습니다. logger.exception으로 스택을 남기고 split_for_telegram을 붙이세요.

재시작하면 대화가 리셋된다

메모리에만 있는 상태입니다. PicklePersistence나 체크포인터를 붙이고, 그 파일이 볼륨 위에 있는지까지 확인합니다.

Chroma를 import할 수 없다

langchain_community.vectorstores.Chroma는 community 0.2.9부터 deprecated이고 현재 경로는 langchain_chroma입니다. langchain-chroma를 0.1.2 이상으로 설치하고 import를 바꾸세요. 다만 0.4.2에서 물리적으로 제거되었는지는 확인하지 못했으니, ImportError인지 DeprecationWarning인지는 돌려 보고 판단하세요.

FAISS만 갈 곳이 없다

langchain-faiss 패키지는 존재하지 않고 langchain_community.vectorstores의 FAISS가 여전히 유일한 경로입니다. sunset이 예고된 패키지 안에 깨끗한 이전처 없이 남아 있는 셈이니, 벡터 스토어를 고를 때 이 점을 알고 고르세요.

마이그레이션했는데 또 레거시라고 한다

create_retrieval_chain, create_stuff_documents_chain, create_history_aware_retriever는 v1에서 langchain-classic으로 갔습니다. 레거시 체인의 현대적 대안이라고 배운 것들이 지금은 같은 레거시 패키지에 있습니다. LCEL 자체는 죽지 않았습니다. langchain_core.runnables의 RunnablePassthrough와 RunnableLambda, StrOutputParser, ChatPromptTemplate, Document는 전부 살아 있고 파이프 연산자도 그대로입니다. 다만 v1 레퍼런스의 Runnable 페이지에는 LCEL이라는 용어가 등장하지 않고 전용 문서 페이지도 없습니다. 폐기된 것이 아니라, 문서가 더 이상 그 방식으로 가르치지 않는 것입니다.

보안 공지를 놓친다

증상이 없어서 위험한 항목입니다. 2차 출처 기준으로 CVE-2025-68664(CVSS 9.3, 역직렬화), CVE-2026-34070(7.5, 프롬프트 로딩 API의 경로 순회), CVE-2025-67644(7.3, LangGraph SQLite 체크포인트의 SQL 인젝션)가 보고되어 있습니다. 수정 버전은 langchain-core 0.3.81 이상 또는 1.2.22 이상, langgraph-checkpoint 3.0 이상, langgraph-checkpoint-sqlite 3.0.1 이상입니다. 벤더 문서가 아니라 취약점 데이터베이스 계열 2차 출처이므로, 대응 전에 사용 중인 배포판의 보안 공지를 직접 확인하세요.

언제 쓰지 않나

이 구조가 안 맞는 경우가 분명히 있습니다.

문서가 작을 때 — 전체 FAQ가 A4 열 장이면 임베딩도 벡터 스토어도 필요 없습니다. 전문을 그대로 프롬프트에 넣으세요. 검색 단계가 없으니 검색이 잘못 골랐다는 실패 모드 자체가 사라지고, 인덱싱 파이프라인과 워처와 임계값 튜닝이 전부 없어집니다. RAG는 프롬프트에 안 들어갈 때 쓰는 것입니다.

권한이 필요한 문서일 때 — 가장 중요한 항목입니다. 텔레그램에는 문서 단위 접근 제어라는 개념이 없습니다. 봇이 그룹에 들어가 있으면 그 방의 모든 사람이 같은 질문에 같은 답을 받습니다. 인사 평가나 급여 테이블처럼 누가 묻느냐에 따라 답이 달라져야 하는 문서를 인덱스에 넣으면 벡터 스토어는 그 구분을 해 주지 않습니다. as_retriever의 search_kwargs에 filter를 넣어 흉내 낼 수는 있지만, 봇 코드가 신원을 정확히 판별한다는 가정 위에서만 성립하고 한 곳이라도 빠뜨리면 그대로 유출입니다. 인가가 요구사항이라면 인가를 먼저 설계할 수 있는 곳에 두세요.

정확한 값을 조회할 때 — 회의실 예약 상태, 남은 연차 일수, 주문 배송 상태 같은 질문은 RAG의 문제가 아닙니다. 벡터 유사도는 근사이고, 근사로 답하면 안 되는 질문입니다. 키워드 검색이나 데이터베이스 조회로 답을 얻고 봇은 그 결과를 문장으로 만드는 역할만 맡는 편이 정확합니다. 애매하면 이렇게 물어보세요. 틀린 답이 나왔을 때 비슷한 걸 찾긴 했네로 넘어갈 수 있는 질문인가, 아니면 그냥 틀린 것인가.

규제 대상 내용일 때 — 의료, 법률, 금융처럼 답변에 책임이 따르는 영역에서는 출처 표시가 면책이 되지 않습니다. 소스 표시는 컨텍스트에 들어갔다는 기록이지 답변이 거기서 나왔다는 보증이 아니기 때문입니다. 대개는 생성된 문장 대신 검색 결과 원문을 그대로 보여 주는 편이 낫습니다.

문서가 자주 바뀌는데 정확성이 중요할 때 — 재인덱싱이 도는 동안 벡터 스토어에는 옛 청크와 새 청크가 섞여 있고, 삭제된 문서의 청크는 명시적으로 지우지 않으면 계속 검색됩니다. 이미 폐지된 규정을 봇이 자신 있게 인용하는 사고가 여기서 나옵니다. 변경이 잦다면 증분 갱신 대신 새 컬렉션을 만들어 통째로 교체하세요.

정리

LangChain + RAG + Telegram으로 지능형 FAQ 봇을 구축했습니다:

  • 문서 기반 답변: 정확한 정보만 제공, 환각 최소화
  • 대화 메모리: 사용자별 컨텍스트 유지
  • 출처 표시: 답변의 근거 문서를 투명하게 제시
  • MMR 검색: 다양성과 관련성을 균형 있게 검색
  • 자동 업데이트: 문서 변경 시 자동 재인덱싱

운영에 필요한 목록은 여기에 하나가 더 붙습니다. 지금 쓰는 패키지가 어느 버전인지 아는 것입니다.

참고 자료

모두 2026-08-16 확인 기준입니다.


✅ 퀴즈: RAG Telegram 봇 이해도 점검 (7문제)

Q1. RAG에서 Retrieval의 역할은?

사용자 질문과 관련된 문서 청크를 벡터 유사도 검색으로 찾아 LLM의 컨텍스트로 제공합니다.

Q2. MMR(Maximal Marginal Relevance) 검색의 장점은?

단순 유사도 검색과 달리 결과의 다양성을 고려하여 중복된 내용의 청크를 줄입니다.

Q3. chunk_overlap을 설정하는 이유는?

문장이 청크 경계에서 잘리는 경우 문맥이 손실되는 것을 방지합니다.

Q4. 사용자별 대화 메모리를 분리하는 이유는?

여러 사용자가 동시에 사용할 때, 다른 사용자의 대화 컨텍스트가 섞이지 않도록 합니다.

Q5. ConversationBufferWindowMemory의 k=5는 무엇을 의미하나요?

최근 5턴의 대화만 메모리에 유지하여 토큰 비용을 제어합니다.

Q6. 봇이 "제공된 문서에서 해당 정보를 찾을 수 없습니다"라고 답하는 것이 중요한 이유는?

RAG 봇이 문서에 없는 정보를 환각(hallucination)으로 생성하는 것을 방지합니다.

Q7. 문서 자동 업데이트(watchdog)의 동작 원리는?

파일 시스템 변경을 감지하여 문서가 수정되면 자동으로 벡터 스토어를 재인덱싱합니다.

퀴즈

Q1: 「LangChain + RAG로 지능형 Telegram FAQ 봇 만들기: 문서 기반 질의응답 시스템」에서 다루는 주요 주제는 무엇인가요?

LangChain과 RAG 파이프라인을 활용한 Telegram FAQ 봇을 구축합니다. 문서 로딩, 벡터 스토어, 대화 메모리, 소스 인용까지 핸즈온으로 다룹니다.

Q2: 이 글의 핵심 요점은 무엇인가요? LangChain과 RAG 파이프라인을 활용한 Telegram FAQ 봇을 구축합니다. 문서 로딩, 벡터 스토어, 대화 메모리, 소스 인용까지 핸즈온으로 다룹니다.

Q3: 이 글의 개념을 실무에 어떻게 적용할 수 있나요? 글 전체에서 다룬 실용적인 예제와 패턴을 참고하세요.

현재 단락 (1/528)

규칙 기반 챗봇은 미리 정의된 질문에만 답할 수 있지만, **RAG(Retrieval-Augmented Generation)** 기반 챗봇은 문서에서 관련 정보를 검색하여 자연어로...

작성 글자: 0원문 글자: 22,977작성 단락: 0/528