Skip to content

필사 모드: Slack Bot + LangChain RAG 챗봇 구축 실전 가이드 — 사내 문서 검색 봇 만들기

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

들어가며

"Confluence에서 배포 절차 문서 어디있지?" "Kubernetes 클러스터 접속 방법이 어떻게 되지?"

이런 질문에 매번 사람이 답하는 대신, 사내 문서를 검색하는 AI 챗봇을 만들어봅시다. LangChain + RAG(Retrieval-Augmented Generation) + Slack Bot 조합으로 실전 프로덕션 레벨의 챗봇을 구축합니다.

아키텍처 개요

# 인덱싱 파이프라인 (오프라인)
# 문서 → 청킹 → 임베딩 → 벡터 DB(ChromaDB)

# 질의 파이프라인 (온라인)
# Slack 메시지 → 임베딩 → 벡터 검색 → LLM 생성 → Slack 응답

프로젝트 설정

의존성 설치

mkdir slack-rag-bot && cd slack-rag-bot

# 가상환경
python -m venv .venv
source .venv/bin/activate

# 의존성
pip install \
  langchain==0.2.16 \
  langchain-openai==0.1.25 \
  langchain-community==0.2.16 \
  chromadb==0.5.3 \
  slack-bolt==1.20.0 \
  python-dotenv==1.0.1 \
  unstructured==0.15.0 \
  tiktoken==0.7.0

환경 변수

# .env
OPENAI_API_KEY=sk-xxx
SLACK_BOT_TOKEN=xoxb-xxx
SLACK_APP_TOKEN=xapp-xxx
SLACK_SIGNING_SECRET=xxx
CHROMA_PERSIST_DIR=./chroma_db
DOCS_DIR=./documents

프로젝트 구조

slack-rag-bot/
├── .env
├── main.py              # Slack Bot 엔트리포인트
├── indexer.py           # 문서 인덱싱
├── rag_chain.py         # RAG 체인
├── config.py            # 설정
├── documents/           # 사내 문서 (Markdown, PDF)
│   ├── deployment-guide.md
│   ├── k8s-access.md
│   └── onboarding.pdf
└── chroma_db/           # 벡터 DB 저장소

문서 인덱싱

문서 로드 및 청킹

# indexer.py
import os
from pathlib import Path
from langchain_community.document_loaders import (
    DirectoryLoader,
    UnstructuredMarkdownLoader,
    PyPDFLoader,
    TextLoader
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from dotenv import load_dotenv

load_dotenv()


def load_documents(docs_dir: str):
    """다양한 형식의 문서 로드"""
    documents = []

    # Markdown 파일
    md_loader = DirectoryLoader(
        docs_dir,
        glob="**/*.md",
        loader_cls=UnstructuredMarkdownLoader,
        show_progress=True
    )
    documents.extend(md_loader.load())

    # PDF 파일
    pdf_loader = DirectoryLoader(
        docs_dir,
        glob="**/*.pdf",
        loader_cls=PyPDFLoader,
        show_progress=True
    )
    documents.extend(pdf_loader.load())

    # 텍스트 파일
    txt_loader = DirectoryLoader(
        docs_dir,
        glob="**/*.txt",
        loader_cls=TextLoader,
        show_progress=True
    )
    documents.extend(txt_loader.load())

    print(f"총 {len(documents)}개 문서 로드됨")
    return documents


def split_documents(documents):
    """문서를 청크로 분할"""
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,
        chunk_overlap=200,
        length_function=len,
        separators=["\n## ", "\n### ", "\n\n", "\n", " ", ""]
    )

    chunks = text_splitter.split_documents(documents)
    print(f"총 {len(chunks)}개 청크 생성됨")
    return chunks


def create_vectorstore(chunks, persist_dir: str):
    """벡터 DB 생성"""
    embeddings = OpenAIEmbeddings(
        model="text-embedding-3-small",
        chunk_size=500
    )

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_dir,
        collection_metadata={"hnsw:space": "cosine"}
    )

    print(f"벡터 DB 생성 완료: {persist_dir}")
    return vectorstore


def index_documents():
    """전체 인덱싱 파이프라인"""
    docs_dir = os.getenv("DOCS_DIR", "./documents")
    persist_dir = os.getenv("CHROMA_PERSIST_DIR", "./chroma_db")

    # 로드 → 청킹 → 임베딩 → 저장
    documents = load_documents(docs_dir)
    chunks = split_documents(documents)
    vectorstore = create_vectorstore(chunks, persist_dir)

    return vectorstore


if __name__ == "__main__":
    index_documents()
# 인덱싱 실행
python indexer.py

RAG 체인 구축

# rag_chain.py
import os
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv

load_dotenv()


class RAGChain:
    def __init__(self):
        self.embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        self.vectorstore = Chroma(
            persist_directory=os.getenv("CHROMA_PERSIST_DIR", "./chroma_db"),
            embedding_function=self.embeddings
        )
        self.retriever = self.vectorstore.as_retriever(
            search_type="mmr",  # Maximum Marginal Relevance
            search_kwargs={
                "k": 5,
                "fetch_k": 20,
                "lambda_mult": 0.7
            }
        )
        self.llm = ChatOpenAI(
            model="gpt-4o-mini",
            temperature=0.1,
            max_tokens=2000
        )
        self.chain = self._build_chain()

    def _build_chain(self):
        """RAG 체인 구성"""
        prompt = ChatPromptTemplate.from_messages([
            ("system", """당신은 사내 문서 기반 Q&A 어시스턴트입니다.
아래 컨텍스트를 기반으로 질문에 답변하세요.

규칙:
1. 컨텍스트에 있는 정보만 사용하세요.
2. 확실하지 않으면 "관련 문서를 찾지 못했습니다"라고 답하세요.
3. 답변에 출처 문서를 포함하세요.
4. 코드나 명령어가 있으면 코드 블록으로 포맷하세요.

컨텍스트:
{context}"""),
            ("human", "{question}")
        ])

        def format_docs(docs):
            formatted = []
            for i, doc in enumerate(docs):
                source = doc.metadata.get("source", "unknown")
                formatted.append(f"[문서 {i+1}] ({source})\n{doc.page_content}")
            return "\n\n---\n\n".join(formatted)

        chain = (
            {"context": self.retriever | format_docs, "question": RunnablePassthrough()}
            | prompt
            | self.llm
            | StrOutputParser()
        )

        return chain

    def ask(self, question: str) -> dict:
        """질문에 답변"""
        # 관련 문서 검색
        docs = self.retriever.invoke(question)

        # LLM 생성
        answer = self.chain.invoke(question)

        # 출처 문서 정보
        sources = list(set(
            doc.metadata.get("source", "unknown") for doc in docs
        ))

        return {
            "answer": answer,
            "sources": sources,
            "num_docs": len(docs)
        }

    def refresh_index(self):
        """인덱스 새로고침"""
        from indexer import index_documents
        self.vectorstore = index_documents()
        self.retriever = self.vectorstore.as_retriever(
            search_type="mmr",
            search_kwargs={"k": 5, "fetch_k": 20, "lambda_mult": 0.7}
        )
        self.chain = self._build_chain()

Slack Bot 연동

Slack App 설정

1. https://api.slack.com/apps 에서 새 앱 생성
2. Socket Mode 활성화
3. Bot Token Scopes 추가:
   - app_mentions:read
   - chat:write
   - im:history
   - im:read
   - im:write
4. Event Subscriptions 활성화:
   - app_mention
   - message.im
5. 워크스페이스에 설치

Slack Bot 구현

# main.py
import os
import logging
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from rag_chain import RAGChain
from dotenv import load_dotenv

load_dotenv()
logging.basicConfig(level=logging.INFO)

# Slack App 초기화
app = App(token=os.environ["SLACK_BOT_TOKEN"])

# RAG Chain 초기화
rag = RAGChain()


@app.event("app_mention")
def handle_mention(event, say, client):
    """@멘션으로 질문받기"""
    user = event["user"]
    text = event["text"]
    channel = event["channel"]
    thread_ts = event.get("thread_ts", event["ts"])

    # 봇 멘션 제거
    question = text.split(">", 1)[-1].strip()

    if not question:
        say(
            text="질문을 입력해주세요! 예: `@DocBot 배포 절차 알려줘`",
            thread_ts=thread_ts
        )
        return

    # 로딩 메시지
    loading_msg = client.chat_postMessage(
        channel=channel,
        thread_ts=thread_ts,
        text=":mag: 문서를 검색하고 있습니다..."
    )

    try:
        # RAG 질의
        result = rag.ask(question)

        # 응답 포맷
        response = f"<@{user}>\n\n{result['answer']}"

        if result["sources"]:
            sources_text = "\n".join(f"• `{s}`" for s in result["sources"])
            response += f"\n\n:page_facing_up: *참고 문서:*\n{sources_text}"

        # 로딩 메시지 업데이트
        client.chat_update(
            channel=channel,
            ts=loading_msg["ts"],
            text=response
        )

    except Exception as e:
        logging.error(f"RAG error: {e}")
        client.chat_update(
            channel=channel,
            ts=loading_msg["ts"],
            text=f"죄송합니다, 오류가 발생했습니다: {str(e)}"
        )


@app.event("message")
def handle_dm(event, say):
    """DM으로 질문받기"""
    if event.get("channel_type") != "im":
        return
    if event.get("bot_id"):
        return

    question = event["text"]

    try:
        result = rag.ask(question)

        response = result["answer"]
        if result["sources"]:
            sources_text = "\n".join(f"• `{s}`" for s in result["sources"])
            response += f"\n\n:page_facing_up: *참고 문서:*\n{sources_text}"

        say(text=response)

    except Exception as e:
        say(text=f"오류가 발생했습니다: {str(e)}")


@app.command("/docbot-reindex")
def handle_reindex(ack, say):
    """슬래시 커맨드로 인덱스 새로고침"""
    ack()
    say("인덱스를 새로고침합니다... :hourglass_flowing_sand:")

    try:
        rag.refresh_index()
        say("인덱스 새로고침 완료! :white_check_mark:")
    except Exception as e:
        say(f"인덱스 새로고침 실패: {str(e)}")


if __name__ == "__main__":
    handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    print("Slack RAG Bot started!")
    handler.start()

처음 켜면 로그에 무엇이 찍히나

앱을 워크스페이스에 설치하고 python main.py를 실행한 다음부터가 진짜 시작인데, 튜토리얼은 대부분 정확히 여기서 끝납니다.

Socket Mode 핸들러는 앱 레벨 토큰으로 apps.connections.open을 호출해 WebSocket URL을 받고 그 소켓으로 이벤트를 받습니다. 공개 URL도 인바운드 포트도 필요 없습니다. 문서 표현 그대로 "When using Socket Mode, your app does not need a Request URL to use the Events API."입니다.

토큰 세 개가 헷갈립니다. SLACK_BOT_TOKENxoxb-, SLACK_APP_TOKENxapp-로 시작하고, SLACK_SIGNING_SECRET은 HTTP 모드에서만 씁니다. 앱 레벨 토큰에 connections:write가 없으면 소켓 자체가 열리지 않고, app_mention을 받으려면 app_mentions:read, 답을 올리려면 chat:write가 필요합니다.

# 실행
python main.py

# 우리가 직접 찍은 로그가 이 순서로 나오면 정상입니다.
# (Bolt가 자체적으로 출력하는 문구는 버전마다 다르니 기준으로 삼지 마세요.)
[INFO] socket mode handler started
[INFO] event=app_mention channel=C123ABC456 user=U061F7AUR ts=1515449522.000016
[INFO] question='배포 절차 알려줘' thread_ts=None
[INFO] retrieved 5 chunks in 0.42s
[INFO] llm answered in 6.1s len=1842
[INFO] chat_update ok

이 로그를 찍으려면 리스너 인자 하나만 더 받으면 됩니다. Bolt는 리스너 함수의 인자를 이름으로 주입하므로 필요한 것만 적으면 됩니다.

ack       Slack 서버에 수신 확인을 돌려준다
say       연결된 채널 ID로 chat.postMessage 를 호출한다
respond   연결된 response_url 을 이용한다
body      파싱된 요청 본문 전체
payload   요청 본문에서 핵심 데이터만 벗겨낸 것
event / message / command / action / shortcut / view / options
          각 리스너에서 payload 의 별칭
client    유효한 토큰이 들어간 WebClient 인스턴스
logger    로거
context   BoltContext 인스턴스
next      미들웨어 체인의 다음 단계로
# 인자는 이름으로 주입됩니다. 순서도, 전부 다 받을 필요도 없습니다.
@app.event("app_mention")
def handle_mention(event, say, client, logger):
    logger.info(
        "event=app_mention channel=%s user=%s ts=%s thread_ts=%s",
        event["channel"],
        event["user"],
        event["ts"],
        event.get("thread_ts"),
    )

이 한 줄만 있어도 뒤에 나올 문제 대부분은 로그만 보고 판별됩니다. 특히 마지막 항목, thread_tsNone으로 찍히는지 값이 찍히는지가 뒤 두 섹션의 핵심입니다.

3초 규칙 — 이 봇의 구조적 문제

증상부터. 봇이 같은 질문에 두 번, 심하면 네 번 답합니다. 그런데 중복이 리듬을 탑니다. 첫 중복은 거의 즉시, 다음은 약 1분 뒤, 마지막은 약 5분 뒤. 이 간격을 보는 순간 원인이 하나로 좁혀집니다.

Events API 문서는 이렇게 적습니다. "Your app should respond to the event request with an HTTP 2xx within three seconds." Bolt 문서는 더 직설적입니다. "We recommend calling ack() right away before initiating any time-consuming processes… since you only have 3 seconds to respond before Slack registers a timeout error."

RAG 파이프라인은 3초에 끝나지 않습니다. 질의 임베딩, 벡터 검색, LLM 생성. 마지막 하나만으로도 보통 3초에서 15초입니다. 이 글의 구조는 기본값 그대로 두면 타임아웃이 나게 되어 있고, 그래서 우연한 버그가 아니라 구조적 문제입니다.

타임아웃이 나면 Slack은 재시도합니다. 문서 표현 그대로 "retrying a failed request up to 3 times in a gradually increasing timetable"이고, 첫 재시도는 거의 즉시, 두 번째는 1분 뒤, 마지막은 5분 뒤입니다. 재시도 요청에는 x-slack-retry-num 헤더가 붙고 값은 "1", "2", "3" 중 하나, 이유는 x-slack-retry-reason에 담깁니다. 다시 받고 싶지 않으면 200이 아닌 응답에 x-slack-no-retry: 1을 실으면 되고, 문서는 이를 "we'll understand it to mean you'd rather this specific event not be re-delivered"라고 설명합니다.

14:02:10.114  event=app_mention ts=1515449522.000016   답변 생성 시작
14:02:10.140  event=app_mention ts=1515449522.000016   <- 재시도 1
14:03:10.203  event=app_mention ts=1515449522.000016   <- 재시도 2
14:08:10.377  event=app_mention ts=1515449522.000016   <- 재시도 3

# ts가 전부 같습니다. 사용자는 한 번 물었고, 우리는 네 번 답했습니다.

하나 짚고 갑니다. 문서가 "반드시 ack()로 확인 응답해야 한다"고 못 박은 대상은 actions, commands, shortcuts, options requests, view submissions입니다. 이벤트는 그 목록에 없고, 이벤트의 확인 응답은 프레임워크가 처리합니다. 그렇다고 3초가 사라지지는 않습니다. 3초는 Bolt가 아니라 Events API 쪽 규칙이니까요. 확실한 방법은 하나뿐입니다. 리스너를 즉시 반환시키고 실제 작업은 다른 스레드에서 돌리는 것.

import threading

@app.event("app_mention")
def handle_mention(event, client, logger):
    # 리스너는 즉시 반환합니다. RAG는 여기서 돌리지 않습니다.
    threading.Thread(
        target=answer_in_background,
        args=(event, client, logger),
        daemon=True,
    ).start()


@app.command("/docbot-reindex")
def handle_reindex(ack, say):
    ack()  # 슬래시 커맨드는 문서가 ack()를 명시적으로 요구합니다.
    threading.Thread(target=reindex_in_background, args=(say,), daemon=True).start()

여기까지 해도 이미 나가버린 재시도는 막지 못합니다. LLM이 느린 날에는 여전히 중복이 오므로 한 겹 더 깝니다.

from collections import OrderedDict
import threading

_seen = OrderedDict()
_seen_lock = threading.Lock()


def already_handled(body) -> bool:
    """같은 event_id가 다시 오면 True. Slack의 재전송을 걸러냅니다."""
    event_id = body.get("event_id")
    if not event_id:
        return False
    with _seen_lock:
        if event_id in _seen:
            return True
        _seen[event_id] = True
        while len(_seen) > 5000:
            _seen.popitem(last=False)
    return False


@app.event("app_mention")
def handle_mention(body, event, client, logger):
    if already_handled(body):
        logger.info("duplicate event_id=%s skipped", body.get("event_id"))
        return
    threading.Thread(
        target=answer_in_background, args=(event, client, logger), daemon=True
    ).start()

솔직하게 적어둡니다. 재시도 스케줄과 헤더 이름은 문서에 명시된 동작이지만, event_id로 걸러내는 위 코드는 Bolt 문서의 공식 레시피가 아니라 제가 쓰는 관용구입니다. HTTP 모드에서 x-slack-retry-num이 붙은 요청을 통째로 건너뛰는 더 거친 방법도 마찬가지입니다. 그리고 Socket Mode의 재전송이 위 표와 똑같이 동작하는지는 문서에서 확인하지 못했습니다. event_id 기준 방어는 두 모드 모두에서 안전합니다.

프로세스를 여러 개 띄운다면 메모리 딕셔너리로는 부족합니다. Redis 같은 공용 저장소에 event_id를 TTL과 함께 넣으세요.

스레드에 답이 안 달리고 채널에 뜨는 이유

app_mention 페이로드는 이렇게 생겼습니다. 문서에 실린 예시 그대로입니다.

{
  "type": "app_mention",
  "user": "U061F7AUR",
  "text": "<@U0LAN0Z89> is it everything a river should be?",
  "ts": "1515449522.000016",
  "channel": "C123ABC456",
  "event_ts": "1515449522000016"
}

thread_ts 키가 없다는 점이 중요합니다. 채널 최상단에서 멘션하면 스레드라는 개념 자체가 없으니 키가 아예 오지 않고, 그래서 event["thread_ts"]라고 쓰면 그 자리에서 KeyError가 납니다. 로그에는 예외만 남고 사용자 쪽에는 아무 반응이 없습니다. "봇이 죽은 것 같다"는 신고가 대개 여기서 나옵니다.

반대로 이미 있는 스레드 안에서 멘션하면 thread_ts에 부모 메시지의 ts가 채워져 옵니다. 그래서 관용구는 이렇게 굳었습니다.

# 최상위 멘션이면 이 메시지 자신이 스레드의 시작점이 됩니다.
# 스레드 안 멘션이면 부모의 ts가 이미 thread_ts에 들어 있습니다.
thread_ts = event.get("thread_ts") or event["ts"]

say(text=answer, thread_ts=thread_ts)

chat.postMessagethread_ts 문서는 이렇게 적습니다. "Provide another message's ts value to make this message a reply. Avoid using a reply's ts value; use its parent instead." 위 한 줄이 정확히 그 규칙을 지킵니다. 스레드 안에서 답글 자신의 타임스탬프를 그대로 넘기면 답글에 다시 스레드를 파려는 셈이 되고, 사용자가 기대한 자리에 답이 붙지 않습니다. 원문 코드의 event.get("thread_ts", event["ts"])도 결과는 같습니다.

밝혀둘 것이 하나 있습니다. saythread_ts를 넘기는 정확한 호출 형태를 Bolt 문서에서 그대로 찾지는 못했습니다. say가 "calls chat.postMessage API with the associated channel ID"라는 설명과 위 규칙을 합치면 나오는 관용구입니다.

답이 잘리는 자리 — 4,000자, 3,000자, 40,000자

RAG 답변은 깁니다. 출처 목록까지 붙이면 더 깁니다. 그런데 Slack에는 서로 다른 상한이 세 개 있습니다.

  • chat.postMessage — 최선의 결과를 위해 text를 4,000자로 제한하라고 권합니다. 40,000자를 넘기면 Slack이 잘라냅니다.
  • chat.update — 에러 문자열이 못 박습니다. "Message text is too long. The text field cannot exceed 4,000 characters." markdown_text는 12,000자까지입니다.
  • Block Kit section 블록 — text의 최소 길이는 1, 최대는 3,000자. fields 배열 항목은 각각 2,000자입니다.

함정은 마지막 줄입니다. 답변을 예쁘게 보이려고 section 블록에 담는 순간 상한이 3,000으로 내려앉습니다. 평범한 RAG 답변 하나가 3,000자를 넘기는 일은 아주 흔해서, 텍스트로 보낼 때 멀쩡하던 답이 보기 좋게 바꾸는 순간부터 잘립니다.

SECTION_LIMIT = 3000   # Block Kit section 블록의 text 상한
TEXT_LIMIT = 4000      # chat.postMessage / chat.update의 권장 상한


def chunk_for_slack(answer: str, limit: int = TEXT_LIMIT) -> list[str]:
    """문단 경계에서 자릅니다. 아무 데서나 끊으면 코드 블록이 깨집니다."""
    parts, buf = [], ""
    for para in answer.split("\n\n"):
        if len(buf) + len(para) + 2 > limit:
            if buf:
                parts.append(buf)
                buf = ""
            while len(para) > limit:
                parts.append(para[:limit])
                para = para[limit:]
            buf = para
        else:
            buf = f"{buf}\n\n{para}" if buf else para
    if buf:
        parts.append(buf)
    return parts

그다음은 호출 빈도입니다. chat.postMessage는 문서상 "generally allows posting one message per second per channel"이고 워크스페이스 전체 한도도 함께 걸립니다. chat.update는 Tier 3, 즉 분당 50회 이상입니다(티어는 1이 분당 1회, 2가 20회, 3이 50회, 4가 100회 이상). 로딩 메시지를 올리고 chat.update로 갈아 끼우는 패턴 자체는 좋지만, 스트리밍처럼 보이려고 토큰마다 부르면 Tier 3에 정면으로 부딪힙니다. 넘기면 문서 표현 그대로 "Slack will return a HTTP 429 Too Many Requests error, and a Retry-After HTTP header containing the number of seconds until you can retry."입니다.

증상    답변이 문장 중간에서 뚝 끊긴다
확인    len(answer) 를 로그로 찍는다  ->  3214
진단    Block Kit section 블록(3,000)에 넣었다. text 필드(4,000)였다면 통과했을 길이다.

증상    답변은 나오는데 몇 초 뒤 갱신이 멈춘다
확인    응답 코드와 헤더를 찍는다  ->  429 / Retry-After: 12
진단    chat.update 를 초당 여러 번 불렀다. Tier 3(분당 50회 이상)를 넘겼다.

Bolt에는 say_stream, WebClient.chat_stream 같은 스트리밍용 표면도 있습니다. 다만 전체 인자와 동작까지는 확인하지 못했으니, 정확한 API는 사용 중인 버전의 문서에서 확인하세요.

질문 하나가 답이 되기까지 — 한 번 끝까지

지금까지 나온 것들을 한 핸들러에 모으면 이렇게 됩니다. 새로운 개념은 없고 순서가 전부입니다.

[채널 #dev-help]
지우     @DocBot 스테이징 배포 절차 알려줘                14:02:10

DocBot   :mag: 문서를 검색하고 있습니다...                14:02:10
         (같은 메시지가 6초 뒤 답변으로 바뀝니다)

DocBot   @지우                                            14:02:16
         스테이징 배포는 다음 순서입니다.
         1. main 브랜치에 머지
         2. CI 통과 확인
         3. deploy-staging 워크플로 수동 실행

         :page_facing_up: 참고 문서:
         - documents/deployment-guide.md
         - documents/ci-cd.md
# main.py — 확인 응답 / 중복 제거 / 스레드 / 길이까지 반영한 형태
import os
import threading
import logging

from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from rag_chain import RAGChain

logging.basicConfig(level=logging.INFO)
app = App(token=os.environ["SLACK_BOT_TOKEN"])
rag = RAGChain()


def answer_in_background(event, client, logger):
    channel = event["channel"]
    thread_ts = event.get("thread_ts") or event["ts"]
    question = event["text"].split(">", 1)[-1].strip()
    logger.info("question=%r thread_ts=%s", question, event.get("thread_ts"))

    placeholder = client.chat_postMessage(
        channel=channel,
        thread_ts=thread_ts,
        text=":mag: 문서를 검색하고 있습니다...",
    )
    try:
        result = rag.ask(question)
        body = f"<@{event['user']}>\n\n{result['answer']}"
        if result["sources"]:
            lines = "\n".join(f"- `{s}`" for s in result["sources"])
            body += f"\n\n:page_facing_up: *참고 문서:*\n{lines}"

        parts = chunk_for_slack(body)
        client.chat_update(channel=channel, ts=placeholder["ts"], text=parts[0])
        for extra in parts[1:]:
            client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=extra)
        logger.info("answered len=%d parts=%d", len(body), len(parts))
    except Exception:
        # 예외 문자열을 채널에 그대로 올리지 않습니다. 자세한 건 로그로.
        logger.exception("rag failed")
        client.chat_update(
            channel=channel,
            ts=placeholder["ts"],
            text="답변을 만들지 못했습니다. 잠시 후 다시 시도해주세요.",
        )


@app.event("app_mention")
def handle_mention(body, event, client, logger):
    if already_handled(body):
        return
    threading.Thread(
        target=answer_in_background, args=(event, client, logger), daemon=True
    ).start()


if __name__ == "__main__":
    SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()

같은 질문 한 번에 대해 로그는 이렇게 남습니다.

[INFO] event=app_mention channel=C123ABC456 user=U061F7AUR ts=1515449522.000016 thread_ts=None
[INFO] question='스테이징 배포 절차 알려줘' thread_ts=None
[INFO] retrieved 5 chunks in 0.42s
[INFO] llm answered in 6.1s
[INFO] answered len=1842 parts=1

thread_ts=None이면 최상위 멘션이라는 뜻이고, 그래서 그 메시지 자신이 스레드의 시작점이 됩니다. 값이 찍혔다면 스레드 안에서 물어본 것이고 답도 그 스레드에 붙습니다. 이 한 줄이면 앞 섹션의 문제가 났는지 즉시 압니다.

시간을 쪼개 보는 것도 중요합니다. 검색 0.4초에 생성 6.1초라면 병목은 명확히 생성 쪽입니다. 검색이 2초를 넘으면 kfetch_k부터 의심하세요. parts=1은 답이 한 번에 들어갔다는 뜻이고, 2 이상이 잦다면 프롬프트에서 답변 길이를 제한하는 편이 낫습니다.

Docker 배포

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

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

COPY . .

# 인덱싱 후 봇 시작
CMD ["python", "main.py"]
# docker-compose.yml
version: '3.8'

services:
  slack-rag-bot:
    build: .
    env_file: .env
    volumes:
      - ./documents:/app/documents
      - ./chroma_db:/app/chroma_db
    restart: unless-stopped
# 빌드 및 실행
docker compose up -d

# 로그 확인
docker compose logs -f

성능 최적화

임베딩 캐싱

from langchain.storage import LocalFileStore
from langchain.embeddings import CacheBackedEmbeddings

store = LocalFileStore("./embedding_cache")
cached_embeddings = CacheBackedEmbeddings.from_bytes_store(
    underlying_embeddings=OpenAIEmbeddings(model="text-embedding-3-small"),
    document_embedding_cache=store,
    namespace="text-embedding-3-small"
)

대화 히스토리 (스레드 컨텍스트)

from langchain.memory import ConversationBufferWindowMemory

# 스레드별 메모리 관리
thread_memories = {}

def get_memory(thread_ts: str) -> ConversationBufferWindowMemory:
    if thread_ts not in thread_memories:
        thread_memories[thread_ts] = ConversationBufferWindowMemory(
            k=5,
            memory_key="chat_history",
            return_messages=True
        )
    return thread_memories[thread_ts]

2026년 8월 기준 버전 점검

이 글의 코드는 2026년 3월 기준입니다. 지금 그대로 설치하면 절반은 다른 자리에 있습니다. slack_bolt는 1.30.0이 2026년 7월 15일에 나왔고 Python 3.7부터 3.14까지 지원합니다. LangChain 쪽은 langchain 1.3.15(2026년 8월 11일), langchain-core 1.5.5, langchain-classic 1.0.8, langchain-community 0.4.2입니다.

문제는 langchain-community입니다. PyPI 배너에 "langchain-community is being sunset. See #674 for details and guidance."가 붙었고, 이슈 본문은 "We are making the decision to sunset the langchain-community package… This sunset will take effect immediately."입니다. 2026년 5월 22일자이고 종료 날짜는 명시돼 있지 않으니, 있지도 않은 마감일로 일정을 짜지 마세요. v1의 langchainagents, messages, tools, chat_models, embeddings로 줄었고 예전 체인과 메모리는 langchain-classic으로 갔습니다.

# 2026-08 기준으로 다시 깐다면
pip install \
  "langchain==1.3.15" \
  "langchain-core==1.5.5" \
  "langchain-classic==1.0.8" \
  "langchain-openai" \
  "langchain-text-splitters" \
  "langchain-chroma>=0.1.2" \
  "slack-bolt==1.30.0" \
  "python-dotenv"
# 이 글에 나온 import -> 2026-08 기준 위치
# langchain.memory.ConversationBufferMemory
#   -> langchain_classic.memory.buffer.ConversationBufferMemory
# langchain.embeddings.CacheBackedEmbeddings
#   -> langchain_classic.embeddings.cache.CacheBackedEmbeddings
# langchain.storage.LocalFileStore
#   -> langchain_classic.storage.file_system.LocalFileStore
# langchain.chains.RetrievalQA
#   -> langchain_classic.chains.retrieval_qa.base.RetrievalQA

# 텍스트 분할기는 전용 패키지로 나왔습니다.
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    add_start_index=True,
)

# Chroma도 전용 패키지가 정식입니다.
# (langchain_community.vectorstores.Chroma 는 community 0.2.9부터 deprecated)
from langchain_chroma import Chroma

# LCEL 조각들은 langchain-core에 그대로 남아 있습니다.
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.documents import Document

# 검색기 옵션 정리
#   search_type   : 'similarity'(기본) / 'mmr' / 'similarity_score_threshold'
#   search_kwargs : k(기본 4), score_threshold, fetch_k(기본 20),
#                   lambda_mult(기본 0.5), filter
#   이 글이 쓴 lambda_mult=0.7 은 다양성보다 관련도에 무게를 둔 값입니다.

한 번 마이그레이션해 본 사람이 더 놀랍니다. v0.2와 v0.3 시절 표준이라고 안내받았던 create_retrieval_chain, create_stuff_documents_chain, create_history_aware_retriever도 v1에서는 langchain-classic에 있습니다. FAISS는 사정이 더 나쁩니다. 여전히 from langchain_community.vectorstores import FAISS이고 langchain-faiss라는 패키지는 없습니다. 일몰이 예고된 패키지 안에 이주처 없이 남은 유일한 경로입니다.

LCEL 조각은 langchain-core에 그대로 살아 있고, Runnable 문서도 "Any chain constructed this way will automatically have sync, async, batch, and streaming support."라고 적습니다. 다만 v1 문서에서 "LCEL"이라는 이름은 사라졌습니다. 폐기된 것은 아니고, 문서가 더 이상 그 이름으로 가르치지 않을 뿐입니다.

v1이 앞세우는 것은 create_agent입니다. "create_agent is the standard way to build agents"이고, 메모리는 체크포인터가 됐습니다. "To add short-term memory (thread-level persistence) to an agent, you need to specify a checkpointer when creating an agent."

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

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

# 여기가 Slack 봇과 정확히 맞아떨어지는 지점입니다.
# 스레드 하나 = 대화 하나. thread_ts를 그대로 thread_id로 씁니다.
thread_ts = event.get("thread_ts") or event["ts"]
thread_config = {"configurable": {"thread_id": thread_ts}}

대응이 깔끔합니다. thread_memories 딕셔너리가 하던 일을 체크포인터가 대신하고, 키는 이미 Slack이 주고 있습니다. 운영에서는 PostgresSaver 같은 영속 체크포인터를 쓰세요.

비동기도 하나. VectorStore의 비동기 메서드는 진짜 비동기가 아니라 스레드풀 래퍼입니다.

# langchain-core의 VectorStore 구현 일부
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)


# Bolt 비동기 쪽 임포트 경로도 함께 적어둡니다.
# from slack_bolt.app.async_app import AsyncApp          (짧은 별칭: slack_bolt.async_app)
# from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
#   (slack_bolt.adapter.socket_mode.aiohttp 에도 있습니다)
# await handler.start_async()

소스 주석은 이걸 "temporary workarounds"라 부르고 "The proper solution is to make the similarity search asynchronous in the vector store implementations."라고 적어뒀습니다. langchain_chromaChroma에는 async def가 하나도 없으니, 비동기로 바꿔도 검색이 빨라지지는 않습니다.

끝으로 CacheBackedEmbeddings.from_bytes_store의 전체 인자 목록은 현재 langchain-classic 레퍼런스에서 확인하지 못했습니다. 그 페이지에 작성 중 표시가 붙어 있습니다. 정확한 API는 사용 중인 버전의 문서에서 확인하세요.

실패 사례와 함정

증상을 먼저 적고 진단 순서를 붙입니다.

봇이 자기 답변에 또 답한다@app.event("message") 핸들러는 봇이 올린 메시지도 받습니다. 가드가 없으면 자기 답을 새 질문으로 읽는 루프가 돕니다. 원문 코드의 if event.get("bot_id"): return 두 줄이 그 가드입니다. 지우지 마세요.

멘션 한 번에 답이 두 번 온다 — 재시도가 아니라 이중 구독일 수 있습니다. app_mentionmessage를 둘 다 구독하면 채널 멘션 하나가 두 리스너를 깨웁니다. 로그에 이벤트 이름을 찍어두면 갈립니다. 같은 이름이 두 번이면 재시도, 서로 다른 이름이 한 번씩이면 이중 구독입니다.

인덱싱은 됐다는데 검색 결과가 빈다 — 셋을 순서대로 봅니다. 인덱싱할 때와 질의할 때의 임베딩 모델이 같은가(다르면 벡터 공간이 달라 유사도가 무의미해집니다), persist_directory가 같은 곳인가(볼륨을 안 붙여 컨테이너 안의 빈 디렉터리를 보는 경우가 가장 흔합니다), 컬렉션 이름이 같은가. 이 셋보다 먼저 청킹이나 프롬프트를 건드리지 마세요.

늘 엉뚱한 문서를 가져온다 — 청크 경계를 의심합니다. chunk_size=1000chunk_overlap=200이면 표나 코드 블록이 중간에서 잘립니다. 잘린 조각은 의미가 없는데 임베딩은 멀쩡히 됩니다. add_start_index=True를 켜두면 원문의 어느 위치에서 잘렸는지 메타데이터로 남습니다.

재인덱싱 커맨드를 부르면 봇 전체가 멈춘다 — 슬래시 커맨드도 같은 3초 규칙 아래입니다. 원문 코드가 ack()를 먼저 부르는 것은 맞지만, 전체 재인덱싱을 같은 스레드에서 동기로 돌리는 게 문제입니다. 3분 걸리는 워크스페이스라면 그 3분 동안 들어온 질문은 전부 타임아웃, 재시도, 한참 뒤 한꺼번에 답변으로 돌아옵니다.

재인덱싱 중 들어온 질문이 이상한 답을 받는다refresh_index()가 벡터스토어, 검색기, 체인을 차례로 바꾸는 동안 다른 스레드가 그 객체들을 읽습니다. 새 인덱스에 옛 체인이 섞입니다. 임시 경로에 전부 만든 뒤 참조 하나만 바꾸세요.

청구서가 조용히 커진다k=5에 청크가 1,000자면 질문마다 5,000자 이상이 프롬프트로 들어갑니다. 히스토리까지 붙으면 더 늘어납니다. 로그에 프롬프트 길이를 찍어두면 월말이 아니라 당일에 보입니다.

예외 문자열이 채널에 그대로 박힌다 — 원문 코드는 예외를 문자열로 만들어 메시지에 올립니다. 커넥션 문자열이나 키 조각이 섞여 있으면 그게 공개 채널에 남습니다. 채널에는 일반적인 안내만, 자세한 내용은 로그로.

증상                       먼저 볼 것              그다음
같은 답이 2~4회            event_id 중복 여부      3초 초과 -> 재시도
멘션 하나에 답 2회         로그의 이벤트 이름      app_mention + message 이중 구독
검색 결과가 빈다           임베딩 모델 일치        persist_directory / 컬렉션 이름
답이 문장 중간에서 끊긴다  답변 길이               3,000(Block Kit) 대 4,000(text)
스레드 밖에 답이 뜬다      thread_ts 로그값        답글의 ts 를 그대로 쓰고 있는지
# ask()가 프롬프트 길이를 함께 돌려주도록 한 줄만 늘려두면
# 비용 문제는 청구서가 아니라 로그에서 먼저 보입니다.
try:
    result = rag.ask(question)
    logger.info("prompt_chars=%d chunks=%d", result["prompt_chars"], result["num_docs"])
except Exception:
    logger.exception("rag failed question=%r", question[:200])
    client.chat_update(
        channel=channel,
        ts=placeholder["ts"],
        text="답변을 만들지 못했습니다. 잠시 후 다시 시도해주세요.",
    )

언제 쓰지 않나

이 조합이 답이 아닌 경우가 분명히 있습니다.

문서마다 열람 권한이 다른 경우. 이게 제일 위험합니다. Slack 봇은 질문한 사람의 권한이 아니라 봇 자신의 문서 접근 권한으로 답합니다. 인덱싱 스크립트가 읽을 수 있었던 문서는 전부 봇의 지식이 되고, 워크스페이스의 아무나 멘션 한 번으로 그 지식을 꺼냅니다. 인사 평가 문서 한 폴더가 실수로 문서 디렉터리에 들어가 있으면, 권한 없는 사람도 물어보기만 하면 답을 받습니다. 벡터 DB에는 원문 조각이 그대로 저장되니 "요약만 나가니까 괜찮다"는 위안도 성립하지 않습니다.

# 사람이 문서를 열 때
사용자 -> 문서 저장소 -> 권한 확인 -> 열람 허용 또는 거부

# 봇이 답할 때
사용자 -> 봇 -> 봇의 권한으로 이미 인덱싱된 벡터 DB -> 답변
              (질문한 사람이 누구인지는 여기서 아무 영향이 없습니다)

권한이 갈리는 코퍼스라면 인덱스를 등급별로 쪼개거나 검색 단계에서 사용자별 필터를 거는 설계가 먼저입니다. 그게 부담이면 이 봇에는 누구나 봐도 되는 문서만 담으세요.

붙여넣으면 끝나는 분량. 문서가 열 페이지 남짓이면 벡터 DB를 세울 이유가 없습니다. 전부 프롬프트에 넣으세요. 인덱싱 파이프라인, 재인덱싱 커맨드, 볼륨 마운트, 임베딩 비용이 한꺼번에 사라집니다. RAG는 컨텍스트에 다 들어가지 않을 때 쓰는 도구입니다.

Slack 검색이 더 잘하는 질문. "그 스레드 어디 갔지" 같은 질문은 RAG가 아니라 Slack 자체 검색의 영역입니다. 대화 기록은 문서가 아니고, 임베딩해서 요약하면 오히려 맥락이 날아갑니다.

Marketplace에 올릴 계획이 있는 경우. Socket Mode 문서에 이렇게 적혀 있습니다. "Apps using Socket Mode are not currently allowed in the public Slack Marketplace." 사내용이면 문제없지만, 제품으로 팔 생각이라면 처음부터 HTTP 모드로 가세요. 서명 시크릿을 넘기고 Request URL을 노출하는 형태로 바뀌는데, 나중에 바꾸기 꽤 귀찮은 결정입니다.

하나 더. 규정이나 법무처럼 틀리면 곤란한 영역에서는 답을 만들지 말고 출처 링크만 돌려주는 봇이 낫습니다. "컨텍스트에 있는 정보만 쓰라"고 적어도 LLM은 가끔 어기고, 그 가끔이 감사 대상이라면 도구의 모양 자체를 바꿔야 합니다.

마무리

Slack RAG 챗봇 핵심 포인트:

  1. 문서 청킹: RecursiveCharacterTextSplitter로 의미 단위 분할
  2. 벡터 검색: MMR(Maximum Marginal Relevance)로 다양한 문서 검색
  3. 프롬프트: 출처 명시 + 불확실한 경우 솔직하게 답하도록 설계
  4. Slack 연동: Socket Mode + app_mention/DM 이벤트 처리
  5. 재인덱싱: 슬래시 커맨드로 문서 업데이트 반영
  6. 3초 규칙: 리스너는 즉시 반환하고 RAG는 별도 스레드에서. 재시도 중복은 event_id로 방어
  7. 길이 상한: 텍스트 4,000자, Block Kit section 블록 3,000자. 잘리는 자리가 다릅니다
  8. 버전: langchain-community 일몰 예고, 옛 체인은 langchain-classic으로 이동

참고 자료

전부 2026-08-16 확인 기준입니다.

옛 주소 tools.slack.dev/bolt-python/docs.slack.dev/tools/bolt-python/으로 301 리다이렉트됩니다. 사내 위키에 남아 있다면 바꿔두세요. 메시지 길이 상한과 Block Kit 필드 규격은 docs.slack.dev의 해당 메서드·블록 문서가 정확합니다.


📝 퀴즈 (7문제)

Q1. RAG의 풀네임과 핵심 아이디어는? Retrieval-Augmented Generation. 외부 지식을 검색하여 LLM의 생성에 활용

Q2. RecursiveCharacterTextSplitter에서 chunk_overlap의 역할은? 청크 간 겹치는 부분을 두어 컨텍스트 손실을 방지

Q3. MMR(Maximum Marginal Relevance) 검색의 장점은? 유사도가 높은 문서만 반환하지 않고, 다양성도 고려하여 중복 줄임

Q4. Slack Socket Mode의 장점은? 별도의 공개 URL/인바운드 포트 없이 WebSocket으로 이벤트 수신 가능

Q5. 프롬프트에서 "컨텍스트에 있는 정보만 사용하세요"라고 명시하는 이유는? LLM의 할루시네이션을 방지하고 문서 기반 정확한 답변 유도

Q6. thread_ts를 사용하는 이유는? Slack 스레드 내에서 대화 컨텍스트를 유지하기 위해

Q7. 임베딩 캐싱의 효과는? 동일 문서에 대한 반복 임베딩 API 호출을 방지하여 비용과 시간 절감

퀴즈

Q1: 봇이 같은 질문에 여러 번 답할 때 가장 먼저 의심할 것은? 확인 응답이 3초 안에 나가지 못해 Slack이 재시도한 경우입니다. 중복 간격이 즉시, 1분, 5분이면 거의 확정입니다.

Q2: 최상위 멘션 페이로드에 thread_ts 키가 없다는 사실이 왜 중요한가? 키를 직접 인덱싱하면 그 자리에서 예외가 납니다. 그래서 값이 없을 때 자기 자신의 타임스탬프로 떨어지는 관용구를 씁니다.

Q3: 답변을 Block Kit section 블록에 담을 때 새로 생기는 제약은? 텍스트 상한이 4,000자에서 3,000자로 내려갑니다. 보기 좋게 바꾸는 순간부터 답이 잘리기 시작합니다.

Q4: 2026년 8월 기준으로 이 글의 LangChain import 중 무엇이 옮겨갔나? 메모리, 임베딩 캐시, 로컬 파일 스토어, RetrievalQA는 모두 langchain-classic으로 이동했고, langchain-community는 일몰이 예고됐습니다.

Q5: 사내 문서 RAG 봇을 쓰면 안 되는 대표적인 경우는? 문서마다 열람 권한이 다른 코퍼스입니다. 봇은 질문한 사람의 권한이 아니라 봇 자신의 접근 권한으로 답하기 때문입니다.

현재 단락 (1/607)

"Confluence에서 배포 절차 문서 어디있지?"

작성 글자: 0원문 글자: 25,184작성 단락: 0/607