한국GPT협회 표준교재

921. 사내 규정집 RAG 챗봇 제작기

ChatGPT 스타일 웹앱 × 하이브리드 검색 × 환각 차단 — 설계 결정과 구현, 검증까지의 전 과정 기록
검색 파이프라인BM25 + BGE-M3 + RRF + 리랭커
스택FastAPI · React(Vite) · OpenAI API
지식규정집 4종 · 58개 조항 청크
검증스모크 5문항 + E2E 12 시나리오

프로젝트 개요와 최종 아키텍처

이 교재에서 배우는 것
  • 사내 문서 기반 RAG 챗봇 웹앱의 전체 구조를 이해하고 직접 재현할 수 있다.
  • 검색 방식·임베딩 모델·청킹 전략의 대안들을 비교하고, 상황에 맞는 선택 기준을 세울 수 있다.
  • 프롬프트만으로 부족한 환각 방지를 구조(원문 발췌 렌더링)로 해결하는 방법을 익힌다.

무엇을 만들었나

회사 HR 규정집 4종을 지식으로 삼아, 규정에 없는 말은 하지 않고 답변마다 원문 근거를 그대로 발췌해 보여주는 사내 규정 안내 챗봇이다. UI는 ChatGPT.com과 같은 사용감을 목표로 했다.

요구사항구현
ChatGPT 스타일 UI사이드바(새 채팅·대화 목록), 토큰 스트리밍, 라이트/다크, Enter 전송
지식 4종회사 인사 및 복무 규정 · 경조사 규정 · 전결 규정 · 출장 및 여비 규정
규정 선택 대화사이드바 체크박스 → 선택된 규정만 검색 대상(검색 수준에서 차단)
내 파일과 대화📎 업로드(pdf/docx/md/txt) → 세션 스코프 색인 → 즉시 질문 가능
환각 방지RAG 강화 프롬프트 + 근거 임계값 거부 + 원문 발췌는 서버가 렌더링
원문 근거 블록답변 하단 접이식 카드: 규정명 > 조항 경로 + 원문 그대로 + 관련도
LLMOpenAI gpt-5-mini (환경변수로 교체 가능)
완성된 챗봇 웰컴 화면
완성된 챗봇 — 좌측 사이드바에 대화 목록과 '지식 문서' 체크박스, 중앙에 추천 질문 카드.

최종 검색 파이프라인

질문 하나가 답변이 되기까지 7단계를 거친다. 핵심은 넓게 모아서(하이브리드) 정밀하게 거르고(리랭커) 근거를 원문 그대로 보여주는 것이다.

1
질의 재작성
후속질문 → 독립 질의
2
BM25 검색
kiwipiepy 형태소
2'
벡터 검색
BGE-M3 코사인
3
RRF 융합
순위 기반 결합
4
리랭킹
bge-reranker-v2-m3
5
근거 판단
점수 임계값
6
GPT 생성
SSE 스트리밍
7
원문 근거
인덱스 청크 표시
4종 · 58청크
규정집 · 조항 단위 청크
2개
로컬 오픈소스 모델 (임베딩·리랭커)
0건
E2E 중 프론트 콘솔 오류
100%
근거 블록 = 인덱스 원문 (모델 생성 0%)

설계 결정 — 대안과 선택의 이유

벡터 임베딩은 필수인가

출발 질문. "바이브코딩 때는 폴더에 파일만 두면 LLM이 알아서 찾던데, GPTs도 임베딩 없이 되던데 — 웹앱 RAG 챗봇이면 벡터 임베딩·벡터 서치가 필수인가?"

답: 필수는 아니다. 그러나 이 요구사항에서는 최선이다. 임베딩 없이 돌아가는 것처럼 보였던 사례의 실체부터 확인해야 한다. 바이브코딩 에이전트는 grep·파일읽기 도구로 직접 탐색하는 에이전틱 검색이고, GPTs는 임베딩을 안 하는 게 아니라 OpenAI file_search가 뒤에서 대신(청킹→임베딩→벡터검색) 해주는 것이다. 직접 만들 때는 그 부분이 우리 몫이 된다.

후보장점단점판정
풀 컨텍스트 주입
검색 없음
구현 최소. 규정 4개(~수만 토큰)면 실제로 동작매 턴 전체 토큰 비용 · 대용량 업로드에 취약 · 원문 발췌를 모델 생성에 의존(변형 위험)탈락
BM25 + 리랭커
임베딩 없음
로컬 모델 1개, 색인 단순유의어 질문("부친상"→"직계가족 사망")을 후보에 못 올릴 수 있음차선
하이브리드 + 리랭커어휘·의미 상호 보완. 2026년 실무 표준 구성임베딩 모델 운영 필요채택
결정적 근거. 요구사항 "원문 그대로 발췌" 때문이다. 청크 단위 검색이 있어야 모델은 사용한 청크 번호만 보고하고, UI는 인덱스에 저장된 원문을 그대로 렌더링하는 구조가 가능하다. 실제로 검증에서 "부친상"이라는 단어가 규정 어디에도 없는데 벡터 검색이 "직계가족 사망" 조항을 1위로 찾아냈다 — 하이브리드를 선택한 이유가 그대로 증명된 장면.
CORE IDEA
발췌 환각은 프롬프트가 아니라 구조로 차단한다

"원문을 그대로 인용하라"는 지시만으로는 모델이 인용문을 미묘하게 바꾸는 것을 막을 수 없다. 이 프로젝트의 핵심 설계는 인용문 생성을 모델에게 맡기지 않는 것이다.

STEP 1
모델은 번호만

답변 끝에 SOURCES: [1,3] — 사용한 발췌 번호만 보고한다.

STEP 2
서버가 원문을 찾고

번호를 검색 인덱스의 원문 청크(규정명·조항 경로·본문)로 매핑한다.

STEP 3
UI가 그대로 렌더링

근거 블록의 모든 글자는 인덱스 원문 — 모델이 만든 문장이 0%다.

스트리밍 중에는 SOURCES 마커 접두어를 보류하는 필터가 마지막 줄이 화면에 노출되는 것을 막는다. 사용자는 답변만 보고, 근거는 별도 블록에서 원문으로 확인한다.

임베딩 모델 선택

웹 리서치 후보 탐색 및 선택 — 2026년 중반 기준.

후보특징판정
BGE-M3 (2024)다국어 100+ · dense+sparse+multi-vector · MIT 라이선스. "2026년에도 프로덕션 RAG 스택의 기본값"으로 통용채택
KURE-v1BGE-M3의 한국어 특화 파인튜닝(고려대). 한국어 검색 벤치마크 최상위권 — 모델명 1줄 교체로 전환 가능하게 구현유력 대안
Qwen3-Embedding (2025)MTEB multilingual 리더보드 1위권(8B, 70.58). 0.6B도 강력 — 성능 실험용실험 후보
OpenAI text-embedding-3API라 운영 단순, 로컬 모델 불필요. 단 오픈소스 아님 · 색인마다 과금보류
multilingual-e5과거 표준. 신규 프로젝트 우선순위 낮음세대교체
운영 팁. 모델 가중치(~2.3GB)는 pip 패키지가 아니라 HuggingFace 캐시(~/.cache/huggingface)로 받아진다. 사용자 전역 캐시라 여러 프로젝트가 공유하며, 프로젝트 폴더에 두고 싶으면 HF_HOME 환경변수만 바꾸면 된다. 파이썬 패키지는 프로젝트 venv에 격리 설치했다.

리랭커·청킹·저장소 — 나머지 결정들

리랭커: 하이브리드에서 멈추지 않는다

1차 검색은 "넓게 모으는" 단계다. 크로스인코더 리랭커(bge-reranker-v2-m3)는 질문과 청크를 한 쌍으로 함께 읽고 "이 질문에 진짜 답하는 조항인가"를 재판정한다. 금액·일수·승인권자처럼 정답이 하나인 질문에서 체감 효과가 가장 크고, 업계 리서치에서도 "가장 ROI 높은 개선"으로 꼽힌다. 부가 효과로 리랭커 점수를 근거 충분성 임계값(sigmoid 0.15)으로 재활용해 미달 시 거부 경로로 보낸다.

청킹: 문단이 아니라 조항 단위

규정 문서는 제N조 (제목) 구조가 명확하다. 마크다운 헤딩 최하위 단위로 잘라 조항 1개 = 청크 1개를 만들고, 각 청크에 『규정명』 > 장 > 절 컨텍스트 헤더를 붙여 색인한다(Anthropic Contextual Retrieval의 경량판 — 원 실험에서는 리랭킹과 결합 시 검색 실패 67% 감소 보고). 긴 조항은 빈 줄 경계로만 2차 분할해 표가 중간에서 잘리는 일이 없다.

벡터 DB를 쓰지 않은 이유

Qdrant·pgvector·Elasticsearch도 검토했지만 numpy 인메모리 + 디스크 캐시를 채택했다. 청크 58개 규모에서 벡터 DB는 운영 부담만 더한다 — emb @ q_emb 한 줄이면 전량 코사인 검색이 끝난다. 문서가 수만 청크로 늘어나는 시점에 도입하는 것이 옳은 순서다.

후속 질문. "numpy 인메모리 + 디스크 캐시라면 어디에 저장되나? 임베딩이 다 된 데이터인데, 그래도 이걸 벡터 DB라고 부르지는 않나?"

저장 위치는 backend/.cache/emb-<해시>.npy 파일 하나다. BGE-M3로 58개 청크를 전부 임베딩한 58×1024 float32 행렬 그 자체(약 232KB — 58×1024×4바이트)이며, 파일명 해시를 모델명+청크 내용으로 만들어 규정이 바뀌면 자동으로 재계산된다. 서버 시작 시 이 파일을 메모리에 올려두고, 질문마다 내적 한 번으로 58개 전부와 코사인 유사도를 구한다. 그래도 벡터 DB라고 부르지 않는 이유는 "벡터 DB"가 데이터가 아니라 시스템을 가리키는 말이기 때문이다.

벡터 DB가 제공하는 것이 프로젝트의 numpy 방식
ANN 인덱스 (HNSW 등 근사 검색)없음 — 58개 전량 완전탐색. 이 규모에선 마이크로초 단위라 근사가 불필요
CRUD (벡터 개별 추가·삭제·수정)없음 — 문서가 바뀌면 통째로 재색인 (내용 해시 캐시가 자동 감지)
메타데이터 필터링없음 — 규정 선택 체크박스 필터는 파이썬 마스크로 처리
서버 프로세스 (동시성·스케일링)없음 — FastAPI 프로세스 메모리에 상주
용어 스펙트럼. numpy 배열(자료구조) → FAISS(벡터 인덱스 라이브러리 — 이것도 DB라고는 부르지 않는다) → Qdrant·Chroma·pgvector(벡터 DB). 이 프로젝트 방식의 정확한 이름은 인메모리 벡터 스토어 + 임베딩 디스크 캐시다. 벡터 검색은 하되, 벡터 DB는 없는 구성.

나머지 결정 요약

결정 항목선택이유
BM25 구현rank_bm25 (계획은 bm25s)순수 파이썬 — Windows 호환 확실. 이 규모에서 속도 차이 무의미
형태소 분석kiwipiepy한국어 BM25의 전제조건. 명사·동사·숫자·외래어 태그만 토큰화
웹 스택FastAPI + React(Vite)형태소분석·임베딩 생태계가 Python에 있음. UI 유지보수는 React
LLMgpt-5-mini (교체 가능)비용/품질 균형. gpt-5 계열은 temperature 미지원 → reasoning_effort로 제어
멀티턴질의 재작성 단계 추가"그럼 경조금은?" 같은 후속질문을 독립 검색 질의로 변환
대화 이력localStorage단일 사용자 로컬 앱 — 서버 DB는 과함
업로드 색인세션 스코프 인메모리개인 파일이 공용 지식과 섞이지 않음. 재시작 시 소멸(의도됨)

지식 베이스 구축

규정집 4종 — 정합성이 생명

지식 베이스는 규정집 4종이다. 챗봇이 여러 규정을 동시에 검색해 답을 합성하므로 규정 간 수치가 서로 모순되면 그대로 오답이 된다. 4종은 수치·담당자·승인 절차가 서로 맞물리도록 설계했다.

인사 및 복무 규정
근무 시간·휴가·급여·복리후생·평가/승진과 업무별 담당자 정보. 경조휴가 일수·경조금 금액(1.2/2.2절)이 여기에도 있어 다른 규정과 맞물리는 기준점 역할
경조사 규정
경조휴가(본인 결혼 5일 · 배우자 출산 3일 · 직계가족 사망 5일...)와 경조금(결혼 50만 · 부모 사망 100만...) — 인사·복무 규정 1.2/2.2절과 수치가 일치하고, 담당자(조윤석 · 복지)도 동일 인물
전결 규정
결재권자(팀장/본부장/대표이사) 전결 매트릭스 — 인사·복무 규정의 승인 절차("연장근무 월 20시간 초과 시 팀장 및 인사팀 별도 승인" 등)와 정합
출장·여비 규정
직급별 일비·숙박비·식비 한도표, 국내/해외 구분 — 출장 승인 권한은 전결 규정 제9조와 상호 참조
색인 규칙. docs/ 최상위 *.md만 자동 색인한다. 하위 폴더(이 교재가 있는 docs/교재/ 등)는 제외 — 교재가 규정으로 검색되는 사고를 막는다. 파일 내용 해시로 임베딩을 캐시해 내용이 바뀐 경우에만 재계산한다.

핵심 구현

조항 단위 청킹 + 컨텍스트 헤더

원문(text)과 색인용(search_text)을 분리 저장하는 것이 포인트다. 발췌 블록에는 원문이, 검색 인덱스에는 경로가 붙은 버전이 쓰인다.

backend/rag/chunker.py — 핵심부
path_titles = [t for lv, t in stack if lv >= 2]
section_path = " > ".join(path_titles)   # "제2장 경조휴가 > 제4조 (경조휴가 일수)"
header = f"『{doc_title}』 > {section_path}"
chunks.append(Chunk(
    ...,
    text=part,                        # 원문 그대로 → 근거 발췌 블록용
    search_text=f"{header}\n{part}",  # 컨텍스트 헤더 부착 → BM25/임베딩 색인용
))

하이브리드 검색 → RRF → 리랭킹

backend/rag/retriever.py — 핵심부
bm25_scores = shard.bm25.get_scores(q_tokens)     # 어휘 검색
dense_scores = shard.emb @ q_emb                  # 의미 검색 (정규화 벡터 내적 = 코사인)

# 방식별 순위를 RRF로 융합: score = Σ 1/(60 + rank)
for rank, (key, score) in enumerate(bm25_scored[:20]):
    if score > 0:
        rrf[key] = rrf.get(key, 0.0) + 1.0 / (60 + rank + 1)
for rank, (key, _) in enumerate(dense_scored[:20]):
    rrf[key] = rrf.get(key, 0.0) + 1.0 / (60 + rank + 1)

# 상위 12개만 크로스인코더로 정밀 재정렬
pairs = [(query, candidates[key].search_text) for key, _ in fused]
logits = get_reranker().predict(pairs)
...
has_evidence = results[0].score >= 0.15           # 근거 충분성 판단
체크박스 필터의 구현 위치. 선택되지 않은 규정의 청크는 후보 수집 단계에서부터 제외된다(allowed_doc_ids). 프롬프트 수준이 아니라 검색 수준에서 차단하므로 "선택 안 한 규정으로 답하는" 사고가 구조적으로 불가능하다.

환각 방지 시스템 프롬프트

SYSTEM PROMPT — backend/rag/prompts.py
당신은 회사의 인사·총무 규정 안내 담당자입니다. 임직원의 규정 관련 문의에 친절하고 명확하게 답변합니다. ## 절대 규칙 (환각 방지) 1. 반드시 아래 [규정 발췌]에 제시된 내용만을 근거로 답변한다. 발췌에 없는 내용은 일반 상식이나 사전지식으로 절대 보충하지 않는다. 2. 발췌에 질문과 관련된 내용이 전혀 없을 때만 정확히 이렇게 답한다: "제공된 규정집에서 확인되지 않는 내용입니다. 인사팀에 문의해 주세요." 발췌에 관련 내용이 일부라도 있으면 그 범위에서 답하고, 부족한 부분만 담당자 문의를 안내한다. 2-1. 후속 질문은 이전 대화 맥락으로 해석한다. [질문 해석]이 제공되면 그 해석을 기준으로 발췌에서 답을 찾는다. 표(테이블) 안의 행도 반드시 확인한다. 3. 금액, 일수, 기한, 비율 등 수치는 발췌 원문에 있는 그대로만 인용한다. 계산이나 해석이 필요한 경우 그 근거를 명시한다. 4. 답변에서 근거로 사용한 발췌는 해당 문장 끝에 [1], [2] 형식으로 번호를 표기한다. 5. 답변에는 근거가 된 규정집명과 조항(또는 항목명)을 자연스럽게 언급한다. 6. 발췌에 담당자 정보(이름, 이메일, 사무실 번호)가 있으면 답변 마지막에 담당자를 안내한다. 7. 회사 규정과 무관한 질문(일반 상식, 잡담, 다른 회사 이야기 등)에는 "해당 문의는 사내 규정 안내 범위를 벗어납니다."라고 답한다. 8. 사용자가 선택한 규정집 외의 문서를 근거로 사용하지 않는다 (발췌에 없는 규정을 아는 척하지 않는다). ## 출력 형식 - 명확하고 존중하는 어조의 한국어로, 마크다운 형식으로 답변한다. - 답변의 맨 마지막 줄에는 실제로 근거로 사용한 발췌 번호를 반드시 다음 형식으로 출력한다: SOURCES: [1, 3] - 근거를 사용하지 않았거나 답할 수 없는 경우: SOURCES: [] - SOURCES 줄 뒤에는 아무것도 출력하지 않는다.
프롬프트만으로는 부족하다. 이중 안전장치를 함께 둔다 — ① 검색 결과가 임계값 미달이면 컨텍스트에 "(검색된 규정 발췌가 없습니다)"를 넣어 거부를 유도하고, ② 근거 블록은 모델 출력이 아니라 서버가 인덱스에서 꺼낸 원문을 렌더링한다.

SOURCES 스트리밍 필터와 멀티턴 재작성

토큰을 실시간으로 흘리면서도 마지막 줄 SOURCES: [1,3]은 화면에 노출하지 않아야 한다. 누적 버퍼 끝이 마커의 접두어와 겹치는 길이만큼 전송을 보류하는 필터를 구현했다.

backend/main.py — 스트리밍 보류 필터
def _held_suffix_len(pending: str) -> int:
    """pending 끝부분이 SOURCES: 마커의 접두어와 겹치는 길이 (스트리밍 중 보류)."""
    for k in range(min(len(_SOURCE_MARKER), len(pending)), 0, -1):
        if _SOURCE_MARKER.startswith(pending[-k:]):
            return k
    return 0

멀티턴 처리: "부친상 휴가 며칠?" → (답변) → "그럼 경조금은?" 같은 후속질문은 그대로 검색하면 실패한다. 직전 대화 6턴을 넣고 "마지막 질문을 독립적인 검색 질의 한 문장으로 재작성하라"는 소형 GPT 호출(reasoning_effort: minimal)을 검색 전에 수행하고, 재작성된 해석을 [질문 해석]으로 생성 프롬프트에도 전달한다.

답변과 근거 원문 블록
답변 + 근거 원문 블록 — 규정명·조항 경로·관련도(%)와 함께 인덱스 원문을 그대로 표시.
다크 모드
다크 모드 — ChatGPT 팔레트를 CSS 변수로 구현, 토글 즉시 전환.

검증 — 스모크 테스트와 E2E

실습

검색 품질 스모크 테스트

서버 없이 검색 파이프라인만 검증하는 CLI(backend/smoke_test.py)를 먼저 만들었다. 검색이 틀리면 그 뒤는 전부 무의미하기 때문에 검색부터 검증하는 순서가 중요하다.

질문1위 검색 결과리랭커 점수
부친상 때 며칠 쉬나요?경조사 규정 > 제4조 (경조휴가 일수)0.503
배우자 출산 축하금 얼마 받아요?인사·복무 규정 > 2.2 경조금 지급0.725
해외 출장은 누구 승인을 받아야 하나요?출장여비 규정 > 제3조 (출장 신청 및 승인)0.730
과장 국내 출장 숙박비 한도는?출장여비 규정 > 제4조 (국내 출장 여비 지급 기준)0.729
연장근무 월 20시간 넘으면 승인은?인사·복무 규정 > 1.1 근무 시간 (2위: 전결 규정 제6조)0.715
주목. "부친상"이라는 단어는 어느 규정에도 없다. 벡터 검색이 "직계가족 사망"과의 의미 유사성으로 정답 조항을 1위에 올렸다.
실습

Playwright E2E — 12개 시나리오

실제 브라우저(번들 Chromium)로 사용자 시나리오 전체를 자동 검증했다: 앱 로드 → 질문/근거 블록 → 멀티턴 후속질문 → 규정 선택 필터(거부) → 파일 업로드 질문 → 다크 모드 → 대화 이력 복원. 아래 스크린샷이 그 기록이다.

멀티턴 후속질문
멀티턴 — "그럼 경조금은?"이 질의 재작성을 거쳐 "부친상 경조금 100만 원"으로 정확히 답변.
규정 선택 거부 응답
경조사 규정만 체크한 상태에서 출장 질문 → 범위 밖 안내(환각 없이 거부).
파일 업로드 질문
📎로 업로드한 IT 보안 지침에 대해 질문 → 업로드 문서가 근거 블록에 표시.
대화 이력
대화 이력(localStorage) — 새 채팅 후에도 사이드바에서 이전 대화 복원 가능.
사례

디버깅 사례 — 후속질문이 거부당한 이유

트러블슈팅
증상 — 근거가 있는데도 "확인되지 않는 내용입니다"

1차 E2E에서 후속질문("그럼 경조금은 얼마 받을 수 있어?")이 거부 응답을 받았다. 단계별로 재현한 결과: 질의 재작성 정상 → 검색 정상(컨텍스트에 "100만 원" 포함 확인) → 생성 단계에서 모델이 거부. 원인은 3중 조합이었다 — ① reasoning_effort: minimal(추론 거의 없음)로는 표 안의 행까지 못 찾고, ② 거부 규칙이 지나치게 엄격했으며("확인할 수 없으면 정확히 이렇게 답한다"가 쉬운 탈출구가 됨), ③ 후속질문의 생략된 주어("그럼 경조금은")를 해석해야 했다.

수정내용
추론 수준 상향답변 생성은 minimallow (재작성은 minimal 유지 — 용도별 차등)
질문 해석 전달검색용으로 이미 계산한 재작성 질의를 [질문 해석]으로 생성 프롬프트에 재활용
거부 규칙 완화"관련 내용이 전혀 없을 때만" 거부. 부분 근거면 그 범위에서 답변

수정 후 같은 질문에 "부친(직계가족) 상의 경우 위로금 100만 원입니다 [1][3]"로 정확히 답했다. 교훈: RAG 실패는 검색 실패가 대부분이라지만, 검색이 완벽해도 생성 단계 설정(추론 수준·규칙의 강도)이 답을 망칠 수 있다. 단계별 재현으로 원인을 분리하라.

트러블슈팅 기록

문제원인해결
PowerShell 한글 경로 파일 복사 실패콘솔 인코딩(cp949)과 UTF-8 충돌Git Bash로 파일 작업 수행
curl 한글 JSON 전송 시 파싱 오류msys→win32 경계의 인자 인코딩 변환UTF-8 파일로 저장 후 --data-binary @file
Chrome이 localhost 접속 실패브라우저 HTTPS 강제 설정이 http:// 차단사용자 브라우저 설정을 건드리지 않고 Playwright(번들 Chromium)로 E2E
Vite가 IPv6(::1)에만 바인딩Node 최신 버전의 localhost 해석server.host: '127.0.0.1' 명시
gpt-5 계열 temperature 오류reasoning 모델은 temperature 미지원모델명 분기로 reasoning_effort 사용

챗봇 작동 프로세스 설명

사례

실제 질문 하나를 끝까지 추적한다

파이프라인 실측

질문 "부친상 때 며칠 쉬나요?" 가 답변이 되기까지의 모든 중간 결과를 실측 그대로 보인다. 이 질문을 고른 이유: "부친상"이라는 단어는 규정집 어디에도 없다(규정에는 "직계가족 사망"이라 쓰여 있다). 단계마다 검색 기법이 어떻게 성공하고 실패하는지가 극명하게 드러난다. 아래 숫자는 예시가 아니라 실제 실행 결과다.

0단계 — 질문 형태소 토큰화 (kiwipiepy)

BM25용 토큰
"부친상 때 며칠 쉬나요?"  →  ['부친', '상', '때', '며칠', '쉬']

BM25는 단어(형태소) 단위로 문서를 찾으므로 한국어에선 형태소 분석이 선행된다. "부친상"이 부친+으로 분해된 것에 주목 — 이 분해가 다음 단계의 운명을 결정한다.

1단계 — BM25 키워드 검색: 사실상 실패

순위청크BM25 점수
1『전결 규정』 > 제2조 (용어의 정의)3.670
2『인사 및 복무 규정』 > 잔업비 규정2.300
3~(정답 조항 포함 나머지 전부)0.000

규정집에 "부친"이 없으므로 정답 조항(경조사 규정 제4조)은 0점. 상위 2개는 · 같은 조각이 우연히 걸린 무관한 문서다. 키워드 검색의 근본 한계 — 사용자의 어휘와 문서의 어휘가 다르면 못 찾는다. (반대로 "제7조"·"30만원"처럼 정확한 용어를 쓰는 질문에선 BM25가 벡터보다 강하다.)

2단계 — BGE-M3 벡터 검색: 의미로 구조

순위청크코사인
1『인사 및 복무 규정』 > 1.2 휴가 정책 > 특별 휴가0.5843
2『인사 및 복무 규정』 > 1.1 근무 시간0.5295
3『인사 및 복무 규정』 > 연차휴가0.5254
4『경조사 규정』 > 제4조 (경조휴가 일수)0.5247
5『인사 및 복무 규정』 > 2.3 복리후생0.4925

임베딩 모델이 "부친상"↔"직계가족 사망", "며칠 쉬나요"↔"휴가 일수"의 의미적 유사성을 잡아냈다. 1위와 4위 모두 실제 정답("직계 가족 사망: 5일")을 담고 있다. 단 벡터도 완벽하진 않다 — "근무 시간"·"연차휴가" 같은 넓은 의미의 무관 문서도 섞여 있다.

3단계 — RRF 융합: 두 검색의 순위를 합친다

점수 스케일이 다른 두 검색을 순위만으로 결합한다: 점수 = Σ 1/(60+순위). BM25 0점 문서는 제외.

융합순위청크BM25 순위벡터 순위RRF 점수
1인사·복무 > 잔업비 규정280.0308
2전결 > 제2조 (용어의 정의)1-0.0164
3인사·복무 > 특별 휴가-10.0164
4~5인사·복무 > 근무 시간 · 연차휴가-2·30.016
6경조사 > 제4조 (경조휴가 일수)-40.0156
7~12(복리후생, 전결 제6조, 경조사 제5·6조 등)

융합 1위가 정답이 아니라 잔업비 규정이다(양쪽 검색에 모두 등장했다는 이유로). 정답은 6위. 융합까지도 완벽하지 않다 — 그래서 상위 12개를 '후보'로만 취급하고 다음 단계가 정밀 검증한다.

4단계 — 크로스인코더 리랭킹: 정답을 1위로 교정

임베딩(바이인코더)은 질문과 문서를 따로 벡터화해 거리를 재지만, 크로스인코더(bge-reranker-v2-m3)는 질문과 청크를 한 쌍으로 함께 읽고 판정한다. 느리므로 후보 12개에만 적용.

최종순위청크리랭커 점수판정
1경조사 > 제4조 (경조휴가 일수)0.503컨텍스트 포함
2인사·복무 > 특별 휴가0.502컨텍스트 포함
3경조사 > 제6조 (경조휴가 신청 절차)0.501컨텍스트 포함
4~6경조사 제5조 · 연차휴가 · 근무 시간0.500컨텍스트 포함
7~12(융합 상위였던 잔업비·전결 용어정의 등 무관 문서)0.500탈락

융합 1위였던 잔업비 규정이 밀려나고 정답 제4조가 1위로 올라왔다. 근거 충분성: 1위 0.503 ≥ 임계값 0.15 → 답변 생성으로 진행.

점수 읽는 법. 점수가 0.5 근처에 몰린 것은 이 리랭커가 미세한 로짓 차이로 순위를 가르기 때문 — 절대값보다 상대 순위가 의미다. 임계값(0.15)의 역할은 따로 있다: 정말 무관한 질문(예: 경조사 규정만 선택하고 출장 질문)이면 전 후보가 임계값 아래로 떨어져 "확인되지 않는 내용" 거부 경로로 간다 — E2E에서 실제 확인된 동작.

5단계 — GPT 답변 생성

최종 6개 청크에 [1]~[6] 번호를 붙여 환각 방지 프롬프트와 함께 gpt-5-mini에 전달했다. 실제 생성 결과 전문:

gpt-5-mini 실제 출력

부친상(직계가족 사망) 시 유급 경조휴가 5일이 부여됩니다. 『경조사 규정』 제2장 제4조에 따른 내용입니다. [1]

증빙서류로는 사망진단서 및 가족관계증명서가 필요합니다. 『경조사 규정』 제2장 제4조에 명시되어 있습니다. [1]

신청은 인사 시스템을 통해 하여 소속 팀장의 승인을 받아야 하며, 긴급한 경우 팀장에게 유선 또는 메신저로 먼저 보고한 후 복귀 후 3일 이내에 증빙서류를 제출할 수 있습니다. 또한 증빙서류를 기한 내 제출하지 않으면 해당 휴가는 연차휴가로 대체 처리될 수 있습니다. 『경조사 규정』 제2장 제6조에 명시되어 있습니다. [3]

SOURCES: [1, 3]  ←  이 줄은 스트리밍 필터가 화면에서 숨기고, 서버가 번호를 인덱스 원문 청크로 매핑해 '근거 원문' 블록으로 보낸다

한눈에 보는 교훈 — 각 단계는 서로의 약점을 보완한다

단계이 질문에서의 성적역할
BM25실패 (정답 0점)정확한 용어·조항번호·금액 질문에 강함
벡터 검색정답을 후보에 올림 (4위)유의어·의미 질문에 강함
RRF 융합1위는 오답, 정답은 6위두 검색을 안전하게 결합해 후보군 확보
리랭커정답을 1위로 교정후보 정밀 재판정 + 근거 충분성 게이트
GPT 생성5일 + 증빙 + 절차, 근거 번호 보고발췌 안에서만 답하고 출처 표기
직접 재현. cd backend && .venv/Scripts/python trace_pipeline.py "아무 질문" — 어떤 질문이든 위와 같은 단계별 표가 출력된다. 교육 실습으로 각자 질문을 넣어 BM25가 이기는 질문과 벡터가 이기는 질문을 찾아보게 하면 좋다.

확장 아이디어와 재현 체크리스트

실습

더 해보기

도전 1검색 평가셋 구축

질문-정답조항 쌍 30개를 만들어 Recall@k·MRR을 자동 측정하라. 리랭커 임계값(0.15)·top-k(6) 튜닝이 감이 아니라 숫자로 가능해진다.

도전 2KURE-v1 A/B 테스트

indexer.pyEMBED_MODEL_NAMEnlpai-lab/KURE-v1로 바꾸고 평가셋으로 BGE-M3와 비교하라. 캐시 키에 모델명이 포함되어 있어 재색인은 자동이다.

도전 3표 행 단위 보조 청크

직급별 한도표를 행 단위로도 색인하면 "차장 숙박비"류 질문의 정밀도가 오른다. chunk_type 메타데이터를 추가해 보라.

도전 4운영 기능

대화 서버 저장(SQLite)·사용자 인증·답변 피드백(👍👎) 수집 → 실패 질문 로그로 규정집을 보완하는 운영 루프를 만들어 보라.

재현 체크리스트
  • 조항 단위 청킹 + 컨텍스트 헤더가 적용되어 있는가 (표가 잘리지 않는가)
  • BM25(형태소)와 벡터 검색이 모두 동작하고 RRF로 융합되는가
  • 리랭커 점수 임계값 미달 시 거부 경로로 가는가
  • 근거 블록이 모델 출력이 아니라 인덱스 원문을 렌더링하는가
  • 체크박스 필터가 프롬프트가 아니라 검색 단계에서 적용되는가
  • 후속질문이 질의 재작성을 거쳐 검색되는가
  • 스모크 테스트(검색)와 E2E(전체 흐름)를 모두 통과했는가