검색
← 모듈 로드맵

직접 해 보기: 작은 RAG 만들기 (BM25 + 벡터 + RRF + 평가)

강의 · 준전문가 · KAITABASE 정리

원제 · Hands-on: Build a Mini RAG

BM25 · 글자 2-그램 벡터 · RRF · recall@k 평가로 작은 RAG 검색기를 직접 만드는 실습

먼저 알아야 할 것

BM25TF-IDF와 코사인 유사도RRFrecall@k와 MRR파이썬 기초

다루는 내용

가상의 사내 규정 문서 12개로 RAG 검색 단계를 파이썬 한 파일로 구현하고 실제 실행 결과를 함께 싣습니다. 단어 단위 BM25와 글자 2-그램 TF-IDF 벡터를 RRF로 합치고, 질문 14개로 recall@1 · recall@3 · MRR을 비교합니다. 조사 변형 · 비슷한 오류 코드 · 글자가 겹치지 않는 질문 같은 실패 사례와 RRF 구현 함정, 모의 쿼리 재작성 효과를 다룹니다.

해설

직접 해 보기: 작은 RAG 만들기 (BM25 + 벡터 + RRF + 평가)

이 실습에서는 사내 규정 문서 12개짜리 장난감 지식 베이스로 RAG의 검색 부분을 처음부터 만듭니다. 키워드 검색(BM25), 글자 2-그램 TF-IDF 벡터 검색, 두 결과를 합치는 RRF, 그리고 recall@k · MRR로 성능을 재는 평가까지 파이썬 한 파일(약 100줄)로 구현합니다. 생성 단계는 실제 LLM 대신 프롬프트를 조립해 보여 주는 모의 단계 로 두었습니다. 코드는 파이썬 3.14 · 넘파이로 실제 실행했고, 아래 출력은 그 실행 결과를 그대로 붙인 것입니다.

항목내용
참고 출처이 트랙의 검색 기초 · RAG 파이프라인 설계 수업, RRF(Cormack 외, SIGIR 2009)
트랙 · 수준RAG·검색 · 중급(실습)
준비물파이썬 3.9 이상, 넘파이. 인터넷 · GPU · API 키 불필요
걸리는 시간읽고 따라 하기 30~60분

들어가기 전에

한눈에 보기

  • 문서 → 색인(단어 · 2-그램) → 검색(BM25 · 벡터) → RRF → 프롬프트 조립의 흐름을 코드로 따라갑니다.
  • 평가 질문 14개로 세 검색 방식의 recall@1 · recall@3 · MRR을 직접 비교합니다.
  • 조사가 붙은 한국어에서 단어 단위 키워드 검색이 놓치는 경우와, 글자 2-그램이 이를 보완하는 모습을 봅니다.
  • RRF 구현에서 흔히 하는 실수(점수 0인 문서에도 순위 점수를 주기)가 결과를 어떻게 망치는지 확인합니다.
  • 글자가 하나도 겹치지 않는 질문은 두 방식 모두 실패하며, 쿼리 재작성이나 신경망 임베딩이 필요하다는 것을 확인합니다.

1. 설계: 무엇을 만들까

mermaid
flowchart LR
  D[문서 12개] --> W[단어 토큰<br/>BM25 색인]
  D --> G[글자 2-그램<br/>TF-IDF 벡터 색인]
  Q[질문] --> BM[BM25 점수]
  Q --> VS[코사인 유사도]
  W --> BM
  G --> VS
  BM --> H1[점수 0 초과만 순위 목록]
  VS --> H2[점수 0 초과만 순위 목록]
  H1 --> R[RRF k=60]
  H2 --> R
  R --> P[상위 2개로 프롬프트 조립<br/>모의 생성]
  R --> E[평가: recall@1 · @3 · MRR]

왜 이런 선택을 했나.

  • 두 가지 토큰화. 한국어는 "휴가는 · 휴가를 · 휴가"처럼 조사가 붙어 단어 모양이 바뀝니다. 형태소 분석기를 쓰지 않고 공백 기준 단어만 쓰면 이런 변형을 놓칩니다. 그래서 BM25는 일부러 단어 그대로(정확한 문자열 일치에 강함), 벡터 쪽은 단어 안의 글자 2-그램(모양이 조금 달라도 겹침)으로 만들어 서로 약점을 보완하게 했습니다.
  • '벡터 검색'이 신경망이 아닌 이유. 실제 RAG는 문장 임베딩 모델(→ Sentence-BERT)을 쓰지만, 이 실습은 설치 없이 돌아가야 해서 TF-IDF 벡터 + 코사인 유사도로 대신했습니다. 구조(벡터화 → 정규화 → 내적)는 같으므로, 나중에 tfidf_vec 만 임베딩 모델 호출로 바꾸면 됩니다.
  • 문서가 12개라 평탄 검색. 모든 문서와 점수를 계산합니다. 규모가 커지면 ANN 인덱스로 바꿉니다.

2. 코드 전체

아래를 mini_rag.py 로 저장하고 python mini_rag.py 로 실행합니다.

python
# 작은 RAG: BM25 + TF-IDF 벡터 + RRF + 평가 (표준 라이브러리 + numpy)
import math, re
from collections import Counter
import sys
import numpy as np
sys.stdout.reconfigure(encoding="utf-8")

DOCS = [
    "연차 휴가는 입사 1년이 지나면 15일이 주어지고, 남은 연차는 다음 해로 이월할 수 없습니다.",
    "재택근무는 주 2회까지 가능하며, 전날 오후 6시까지 팀장에게 신청해야 합니다.",
    "출장비는 출장 종료 후 7일 안에 영수증을 첨부해 경비 시스템으로 청구합니다.",
    "사내 와이파이 비밀번호는 매월 1일에 바뀌며, 보안팀 공지 게시판에서 확인할 수 있습니다.",
    "노트북이 고장 나면 IT 지원 포털에 접수하고, 대체 장비는 보통 이틀 안에 지급됩니다.",
    "오류 코드 ERR-4012는 VPN 인증서가 만료되었다는 뜻이며, 인증서를 다시 발급받으면 해결됩니다.",
    "오류 코드 ERR-4021은 비밀번호를 5회 틀려 계정이 잠긴 상태로, 30분 뒤 자동으로 풀립니다.",
    "점심 식대는 하루 1만 원까지 법인카드로 결제할 수 있습니다.",
    "신규 입사자는 첫 주에 보안 교육과 개인정보 보호 교육을 반드시 이수해야 합니다.",
    "회의실 예약은 그룹웨어 캘린더에서 하며, 최대 2시간까지 잡을 수 있습니다.",
    "육아휴직은 자녀 1명당 최대 1년까지 쓸 수 있고, 인사팀에 30일 전까지 신청합니다.",
    "택배는 1층 안내 데스크에서 받을 수 있으며, 3일이 지나면 반송됩니다.",
]
EVAL = [  # (질문, 정답 문서 번호)
    ("집에서 일하려면 언제까지 신청해야 하나요?", 1),
    ("ERR-4012 오류는 어떻게 해결하나요?", 5),
    ("남은 휴가를 내년에 쓸 수 있나요?", 0),
    ("출장 다녀와서 돈은 어떻게 돌려받나요?", 2),
    ("와이파이 비밀번호가 바뀌었어요", 3),
    ("계정이 잠겼을 때 얼마나 기다려야 하나요?", 6),
    ("노트북 고장 시 대체 장비는 언제 받나요?", 4),
    ("아이가 태어나면 휴직을 얼마나 쓸 수 있나요?", 10),
    ("ERR-4021이 떴어요", 6),
    ("연차는 며칠 받나요?", 0),
    ("점심값은 얼마까지 회사가 내 주나요?", 7),
    ("VPN 접속이 안 돼요", 5),
    ("회의실은 몇 시간까지 잡을 수 있나요?", 9),
    ("아기 돌보려고 한동안 쉬고 싶어요", 10),
]

def words(text):
    # 키워드 검색용: 공백 · 문장부호로 자른 단어(조사가 붙은 채로)
    return [w.lower() for w in re.findall(r"[0-9A-Za-z\-]+|[가-힣]+", text)]

def bigrams(text):
    # 벡터용: 단어 안의 글자 2-그램(형태가 조금 달라도 겹치게)
    out = []
    for w in words(text):
        out += [w[i:i+2] for i in range(len(w) - 1)] or [w]
    return out

DT = [words(d) for d in DOCS]      # BM25 색인
DB = [bigrams(d) for d in DOCS]    # 벡터 색인
N = len(DOCS); avgdl = sum(map(len, DT)) / N
df = Counter(t for d in DT for t in set(d))      # 단어 문서 빈도
dfb = Counter(t for d in DB for t in set(d))     # 2-그램 문서 빈도

def bm25_scores(q, k1=1.5, b=0.75):
    qt = words(q); s = np.zeros(N)
    for i, d in enumerate(DT):
        tf = Counter(d)
        for t in qt:
            if t not in tf: continue
            idf = math.log(1 + (N - df[t] + 0.5) / (df[t] + 0.5))
            s[i] += idf * tf[t] * (k1 + 1) / (tf[t] + k1 * (1 - b + b * len(d) / avgdl))
    return s

VOCAB = {t: j for j, t in enumerate(dfb)}
def tfidf_vec(toks):
    v = np.zeros(len(VOCAB))
    for t, c in Counter(toks).items():
        if t in VOCAB: v[VOCAB[t]] = (1 + math.log(c)) * math.log(N / dfb[t] + 1)
    n = np.linalg.norm(v); return v / n if n else v
DV = np.stack([tfidf_vec(d) for d in DB])
def vec_scores(q): return DV @ tfidf_vec(bigrams(q))   # 코사인 유사도

def rank(scores): return list(np.argsort(-scores, kind="stable"))
def hits(scores): return [i for i in rank(scores) if scores[i] > 0]   # 점수 0인 문서는 '검색 안 됨'으로
def rrf(*rankings, k=60):
    s = np.zeros(N)
    for r in rankings:
        for pos, i in enumerate(r, 1): s[i] += 1 / (k + pos)
    return rank(s)

def evaluate(name, fn):
    r1 = r3 = mrr = 0
    for q, gold in EVAL:
        r = fn(q); p = r.index(gold) + 1
        r1 += p <= 1; r3 += p <= 3; mrr += 1 / p
    n = len(EVAL)
    print(f"{name:8s} recall@1={r1/n:.3f} recall@3={r3/n:.3f} MRR={mrr/n:.3f}")

methods = {
    "BM25": lambda q: rank(bm25_scores(q)),
    "TF-IDF": lambda q: rank(vec_scores(q)),
    "RRF": lambda q: rrf(hits(bm25_scores(q)), hits(vec_scores(q))),
}
for name, fn in methods.items(): evaluate(name, fn)

print("\n질문별 정답 문서 순위 (BM25 / TF-IDF / RRF)")
for q, gold in EVAL:
    print(f"- {q} → " + " / ".join(str(fn(q).index(gold) + 1) for fn in methods.values()))

def answer(q, top_k=2):
    # 생성 단계는 LLM 대신 템플릿(모의)으로: 프롬프트만 조립해 보여 줌
    ids = methods["RRF"](q)[:top_k]
    ctx = "\n".join(f"[{j+1}] {DOCS[i]}" for j, i in enumerate(ids))
    return f"다음 자료만 근거로 답하고, 근거 번호를 붙이세요. 자료에 없으면 '자료에 없음'이라고 답하세요.\n{ctx}\n질문: {q}"
print("\n--- 조립된 프롬프트 예 ---")
print(answer("ERR-4012 오류는 어떻게 해결하나요?"))

# 쿼리 재작성(모의): 실제로는 LLM이 쓰는 검색용 질문을 사람이 대신 적어 둔 것
REWRITE = {"아기 돌보려고 한동안 쉬고 싶어요": "육아휴직은 최대 얼마나 쓸 수 있나",
           "점심값은 얼마까지 회사가 내 주나요?": "점심 식대 한도 법인카드"}
print("\n--- 쿼리 재작성 전후 정답 순위 (RRF) ---")
for q, new in REWRITE.items():
    gold = dict(EVAL)[q]
    print(f"- {q} → {methods['RRF'](q).index(gold)+1}위 / '{new}' → {methods['RRF'](new).index(gold)+1}위")

코드 읽는 법(부분별).

  1. DOCS · EVAL — 지식 베이스와 평가 세트입니다. 평가 세트는 (질문, 정답 문서 번호) 쌍입니다. 실제 프로젝트에서도 이렇게 정답이 표시된 질문 목록 부터 만드는 것이 출발점입니다.
  2. words · bigrams — 두 가지 토큰화입니다. "점심값은"은 단어로는 점심값은 하나지만, 2-그램으로는 점심 · 심값 · 값은 이 되어 문서의 "점심"과 겹칩니다.
  3. bm25_scores — 검색 기초 에서 본 BM25입니다. k1=1.5k_1=1.5, b=0.75b=0.75 는 이 실습에서 고른 값이고, IDF는 음수가 나오지 않도록 로그 안에 1을 더한 흔한 변형을 썼습니다.
  4. tfidf_vec · vec_scores — 2-그램 빈도에 로그 가중치와 IDF를 곱하고 길이 1로 정규화한 벡터입니다. 정규화했으므로 내적이 곧 코사인 유사도입니다.
  5. hits · rrf — 점수가 0보다 큰 문서만 순위 목록에 넣고, ∑1/(60+순위)\sum 1/(60+\text{순위}) 로 합칩니다.
  6. evaluate — 질문마다 정답 문서의 순위 pp 를 구해, recall@1(p≤1p\le1 비율), recall@3(p≤3p\le3 비율), MRR(1/p1/p 의 평균)을 냅니다.
  7. answer — RRF 상위 2개로 프롬프트를 조립합니다. 여기서 LLM을 부르면 실제 RAG가 됩니다.

3. 실행 결과 (실제 출력)

text
BM25     recall@1=0.786 recall@3=0.857 MRR=0.837
TF-IDF   recall@1=0.929 recall@3=0.929 MRR=0.935
RRF      recall@1=0.929 recall@3=0.929 MRR=0.935

질문별 정답 문서 순위 (BM25 / TF-IDF / RRF)
- 집에서 일하려면 언제까지 신청해야 하나요? → 1 / 1 / 1
- ERR-4012 오류는 어떻게 해결하나요? → 1 / 1 / 1
- 남은 휴가를 내년에 쓸 수 있나요? → 2 / 1 / 1
- 출장 다녀와서 돈은 어떻게 돌려받나요? → 1 / 1 / 1
- 와이파이 비밀번호가 바뀌었어요 → 1 / 1 / 1
- 계정이 잠겼을 때 얼마나 기다려야 하나요? → 1 / 1 / 1
- 노트북 고장 시 대체 장비는 언제 받나요? → 1 / 1 / 1
- 아이가 태어나면 휴직을 얼마나 쓸 수 있나요? → 1 / 1 / 1
- ERR-4021이 떴어요 → 1 / 1 / 1
- 연차는 며칠 받나요? → 1 / 1 / 1
- 점심값은 얼마까지 회사가 내 주나요? → 8 / 1 / 1
- VPN 접속이 안 돼요 → 1 / 1 / 1
- 회의실은 몇 시간까지 잡을 수 있나요? → 1 / 1 / 1
- 아기 돌보려고 한동안 쉬고 싶어요 → 11 / 11 / 11

--- 조립된 프롬프트 예 ---
다음 자료만 근거로 답하고, 근거 번호를 붙이세요. 자료에 없으면 '자료에 없음'이라고 답하세요.
[1] 오류 코드 ERR-4012는 VPN 인증서가 만료되었다는 뜻이며, 인증서를 다시 발급받으면 해결됩니다.
[2] 오류 코드 ERR-4021은 비밀번호를 5회 틀려 계정이 잠긴 상태로, 30분 뒤 자동으로 풀립니다.
질문: ERR-4012 오류는 어떻게 해결하나요?

--- 쿼리 재작성 전후 정답 순위 (RRF) ---
- 아기 돌보려고 한동안 쉬고 싶어요 → 11위 / '육아휴직은 최대 얼마나 쓸 수 있나' → 1위
- 점심값은 얼마까지 회사가 내 주나요? → 1위 / '점심 식대 한도 법인카드' → 1위

4. 결과 읽기

① 세 방식 비교. BM25는 recall@1이 0.786으로 가장 낮고, 2-그램 벡터와 RRF가 0.929로 같습니다. 이 장난감 데이터에서는 조사 변형 때문에 단어 단위 BM25가 손해를 봤습니다. 반대로 실제 서비스 문서처럼 어휘가 다양하고 길면 BM25가 강한 경우도 많습니다(BEIR 는 BM25를 견고한 기준선으로 보고했습니다). 질문 14개로 낸 숫자는 방향만 보여 줄 뿐 이고, 어떤 방식이 낫다는 일반 결론으로 쓰면 안 됩니다.

② BM25가 놓친 질문. "점심값은 얼마까지 회사가 내 주나요?"는 BM25에서 8위입니다. 문서에는 "점심 식대는"이 있는데, 질문 단어 점심값은 과 글자가 정확히 같은 단어가 없습니다. 2-그램 벡터는 점심 이 겹쳐 1위로 찾았습니다. "남은 휴가를 내년에…"도 BM25는 2위입니다(문서는 "휴가는", 질문은 "휴가를").

③ 오류 코드 질문. "ERR-4012"와 "ERR-4021"은 글자 구성이 거의 같아 2-그램 벡터에서는 서로 가깝습니다. 조립된 프롬프트를 보면 2번 근거로 ERR-4021 문서가 함께 들어갔습니다. 이번에는 1위가 맞았지만, 이런 비슷하지만 다른 근거가 섞일 수 있으므로 프롬프트에 "자료에 없으면 없다고 답하라", "근거 번호를 붙여라"는 지시를 넣어 둡니다.

④ 모두 실패한 질문. "아기 돌보려고 한동안 쉬고 싶어요"는 세 방식 모두 11위입니다. 정답 문서("육아휴직은 …")와 글자가 하나도 겹치지 않기 때문입니다. 글자 기반 검색의 한계이며, 해결책은 두 가지입니다.

  • 쿼리 재작성: 마지막 출력처럼 질문을 "육아휴직은 최대 얼마나 쓸 수 있나"로 고쳐 쓰면 1위가 됩니다. 이 실습에서는 재작성 문장을 사람이 적었지만, 실제로는 LLM에게 "검색용 질문으로 다시 써라"라고 시킵니다.
  • 신경망 임베딩: 의미를 학습한 임베딩은 "아기 돌보기"와 "육아"를 가깝게 둘 수 있습니다. tfidf_vec 자리를 문장 임베딩 모델로 바꿔 보는 것이 다음 과제입니다.

⑤ RRF 구현의 함정(직접 겪은 것). 처음에는 BM25 · 벡터의 전체 순위 목록(점수 0인 문서까지)을 RRF에 넣었습니다. 그러자 RRF의 recall@1이 0.857, MRR이 0.887로 벡터 단독(0.929 · 0.935)보다 나빠졌고, "점심값" 질문은 3위로 밀렸습니다. 점수 0인 문서는 사실상 '검색되지 않은' 것인데도 원래 문서 순서대로 순위 점수를 받아 끼어들었기 때문입니다. hits 로 점수 0 초과만 남기자 위 결과처럼 고쳐졌습니다. 하이브리드 검색에서는 각 검색기가 실제로 찾은 상위 N개만 넘기는 것이 기본입니다.

5. 더 해 보기

  1. 청킹 붙이기: 긴 규정 문서 하나를 200자 청크 · 50자 겹침으로 자르는 함수를 만들고, 청크 단위로 색인해 보세요.
  2. 리랭커 자리 만들기: RRF 상위 5개를 받아 다시 점수를 매기는 rerank(q, ids) 함수를 만들고, 처음에는 "질문 단어가 문서에 몇 개 들어 있나" 같은 간단한 규칙으로 채워 보세요. 나중에 크로스 인코더로 바꿀 자리입니다.
  3. 평가 세트 늘리기: 질문을 30개 이상으로 늘리고, 일부러 동의어 · 줄임말 질문을 넣어 어떤 방식이 어디서 무너지는지 표로 정리해 보세요.
  4. 진짜 생성 붙이기: 쓰는 LLM API가 있다면 answer() 가 만든 프롬프트를 그대로 보내고, 답이 근거 번호를 제대로 다는지 확인해 보세요. 이때부터는 RAG 평가 의 충실도 지표가 필요해집니다.

연습 문제

  1. 질문 4개의 정답 순위가 1, 2, 1, 4위라면 recall@1, recall@3, MRR은?
    • 풀이: recall@1 =2/4=0.5=2/4=0.5, recall@3 =3/4=0.75=3/4=0.75, MRR =(1+0.5+1+0.25)/4=0.6875=(1+0.5+1+0.25)/4=0.6875 입니다.
  2. bigrams("휴가를") 의 결과는?
    • 풀이: ["휴가", "가를"] 입니다. 문서의 "휴가는" → ["휴가", "가는"] 과 휴가 가 겹칩니다.
  3. RRF에서 한 문서가 BM25 1위 · 벡터 목록에 없음, 다른 문서가 BM25 2위 · 벡터 2위라면 어느 쪽이 위인가요(k=60k=60)?
    • 풀이: 앞 문서 1/61≈0.01641/61\approx0.0164, 뒤 문서 1/62+1/62≈0.03231/62+1/62\approx0.0323 이므로 뒤 문서가 위입니다.

스스로 점검

  1. 이 실습에서 BM25와 벡터 쪽 토큰화를 다르게 한 이유는?
    • 답: 단어 단위는 정확한 문자열(오류 코드 등)에 강하고, 글자 2-그램은 조사 변형에 강해 서로 약점을 보완하기 때문입니다.
  2. 점수 0인 문서까지 RRF에 넣으면 왜 나빠지나요?
    • 답: 실제로 찾지 못한 문서가 원래 순서대로 순위 점수를 받아, 다른 검색기가 찾은 정답보다 앞에 끼어들 수 있기 때문입니다.
  3. "아기 돌보려고…" 질문을 푸는 두 가지 방법은?
    • 답: LLM으로 검색용 질문을 다시 쓰는 쿼리 재작성, 그리고 의미를 학습한 신경망 임베딩으로 벡터 검색을 바꾸는 것입니다.

용어 정리

용어쉬운 뜻
지식 베이스RAG가 검색 대상으로 삼는 문서 모음
평가 세트정답 근거가 표시된 질문 목록
글자 n-그램단어 안에서 연속한 글자 n개 묶음
TF-IDF 벡터단어(또는 n-그램) 빈도에 희귀도 가중치를 곱한 벡터
recall@k정답이 상위 k개 안에 든 질문의 비율
MRR정답 순위의 역수 평균
모의 생성LLM 호출 대신 프롬프트만 조립해 보여 주는 단계

더 깊이 보려면

다음에 볼 것

라이선스

본문과 코드는 KAITABASE가 이 실습을 위해 직접 작성했습니다. 문서 · 질문은 모두 가상의 예이며 실제 회사 규정이 아닙니다. 실행 결과는 2026-10-22 파이썬 3.14.3 · 넘파이 2.5.1에서 얻은 값입니다.

출처

AI 원리부터 이해하기 · RAG·검색 모듈

RAG·검색 모듈 학습

이 글은 RAG·검색 모듈의 학습 자료입니다. 전체 21개 자료 중 11번째입니다.

AI 기초 과정은 현재 진도 저장을 지원하지 않습니다.

출처·원문

원문 보기

요약만

본문 · 코드 자체 작성