RAG 시스템 직접 만들기 — 사내 문서 검색 챗봇 구축기

게시일: 2025년 7월 15일 · 17분 읽기

3000개의 Confluence 문서가 아무도 안 읽었던 이유

우리 회사의 Confluence는 서보(documentation graveyard)였다. 3000개의 문서가 있지만, 누구도 찾지 않는다. 새 직원이 온라인 가이드를 찾으려면? "누가 알아요" 하면서 Slack에서 물어본다. 이건 비효율이었다.

그래서 RAG(Retrieval-Augmented Generation) 시스템을 만들었다. 이제 "우리 회사의 배포 프로세스가 뭐야?"라고 물어보면 2초 안에 정답이 나온다.

RAG 시스템 아키텍처

문서 수집(Confluence, Notion, Markdown)
  -> 정규화/클린업
  -> chunking
  -> 임베딩 생성
  -> 벡터 DB 저장
  -> 질의 임베딩 + top-k 검색
  -> 재정렬(re-ranking)
  -> LLM 답변 생성 + 출처 표시

1단계: 문서 추출 및 청킹

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=900,
    chunk_overlap=120,
    separators=["\n## ", "\n### ", "\n", ". ", " "],
)

docs = load_confluence_pages()  # [{id, title, body, updated_at, space}]
chunks = []
for d in docs:
    for i, text in enumerate(splitter.split_text(d["body"])):
        chunks.append({
            "id": f'{d["id"]}:{i}',
            "text": text,
            "meta": {
                "title": d["title"],
                "updated_at": d["updated_at"],
                "space": d["space"],
                "source_url": d.get("url", "")
            }
        })

청킹은 길이보다 "의미 단위"가 중요하다. 처음에 1500자로 크게 잘랐더니 답변 정확도는 올라가지 않고, 관련 없는 문단이 같이 들어와 hallucination이 늘었다. 800~1000자 + 100~150 overlap이 가장 안정적이었다.

2단계: 임베딩과 벡터 DB 저장

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

client = QdrantClient(url="http://localhost:6333")
client.recreate_collection(
    collection_name="company_docs",
    vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
)

embeddings = embed_model.embed_documents([c["text"] for c in chunks])
points = [
    PointStruct(id=idx, vector=vec, payload=chunks[idx]["meta"] | {"text": chunks[idx]["text"]})
    for idx, vec in enumerate(embeddings)
]
client.upsert(collection_name="company_docs", points=points)

메타데이터를 빼먹으면 운영 단계에서 필터링이 안 된다. 최소한 문서 최신일(`updated_at`), 문서 타입(runbook/policy/spec), 출처 URL은 넣는 게 좋다.

3단계: 검색 + 재정렬 + 응답 생성

query_vec = embed_model.embed_query(user_query)
hits = client.search(
    collection_name="company_docs",
    query_vector=query_vec,
    limit=12,
    score_threshold=0.32,
)

contexts = rerank(user_query, hits)[:5]  # cross-encoder 또는 LLM rerank

prompt = f"""
질문: {user_query}
아래 문서 조각만 근거로 답해라. 모르면 모른다고 답해라.
출처 문서 제목과 URL을 마지막에 표시해라.

문맥:
{format_contexts(contexts)}
"""

answer = llm.generate(prompt)

운영에서 가장 중요했던 4가지

1) 최신성

문서는 계속 바뀐다. 우리는 30분 간격 incremental indexing으로 바꿨다. 최신성 없는 RAG는 검색이 아니라 "오답 캐시"가 된다.

2) 권한 제어

모든 직원이 모든 문서를 보면 안 된다. 질의자의 그룹 정보를 받아 space/label 필터를 걸지 않으면 보안 사고로 이어진다.

3) 답변 포맷 강제

"근거 문서 2개 이상, 없으면 모른다고 답변" 규칙을 강제하니 환각 답변 비율이 크게 줄었다.

4) 평가 데이터셋

팀 공통 질문 100개를 만들고 정답/허용 오차를 정의했다. 배포 전에는 반드시 이 데이터셋으로 회귀 평가를 돌렸다.

실패 사례

도입 결과 (3개월)

지표도입 전도입 후
문서 검색 평균 시간8분 40초1분 15초
신입 온보딩 FAQ 응답수동 대응자동 67%
중복 질문(슬랙)주 140건주 58건

결론

RAG의 성패는 모델이 아니라 데이터 파이프라인에 달려 있다. 문서 정규화, 메타데이터, 권한 필터, 평가셋을 먼저 설계하면 챗봇 품질은 안정적으로 올라간다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.