직접 해 보기: 작은 RAG 만들기 (BM25 + 벡터 + RRF + 평가)
BM25 · 글자 2-그램 벡터 · RRF · recall@k 평가로 작은 RAG 검색기를 직접 만드는 실습
먼저 알아야 할 것
다루는 내용
가상의 사내 규정 문서 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분 |
들어가기 전에
- 검색의 기초: TF-IDF · BM25 · 벡터 검색 · 검색 평가 지표 — BM25 식과 recall@k · MRR의 뜻.
- RAG 파이프라인 설계: 청킹 · 하이브리드 검색 · 리랭킹 · 쿼리 재작성 — 오늘 조립할 부품들의 이론.
- 벡터 검색과 근사 최근접 이웃 — 여기서는 문서가 12개라 평탄(전수) 검색으로 충분합니다.
한눈에 보기
- 문서 → 색인(단어 · 2-그램) → 검색(BM25 · 벡터) → RRF → 프롬프트 조립의 흐름을 코드로 따라갑니다.
- 평가 질문 14개로 세 검색 방식의 recall@1 · recall@3 · MRR을 직접 비교합니다.
- 조사가 붙은 한국어에서 단어 단위 키워드 검색이 놓치는 경우와, 글자 2-그램이 이를 보완하는 모습을 봅니다.
- RRF 구현에서 흔히 하는 실수(점수 0인 문서에도 순위 점수를 주기)가 결과를 어떻게 망치는지 확인합니다.
- 글자가 하나도 겹치지 않는 질문은 두 방식 모두 실패하며, 쿼리 재작성이나 신경망 임베딩이 필요하다는 것을 확인합니다.
1. 설계: 무엇을 만들까
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 로 실행합니다.
# 작은 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}위")코드 읽는 법(부분별).
DOCS·EVAL— 지식 베이스와 평가 세트입니다. 평가 세트는 (질문, 정답 문서 번호) 쌍입니다. 실제 프로젝트에서도 이렇게 정답이 표시된 질문 목록 부터 만드는 것이 출발점입니다.words·bigrams— 두 가지 토큰화입니다. "점심값은"은 단어로는점심값은하나지만, 2-그램으로는점심 · 심값 · 값은이 되어 문서의 "점심"과 겹칩니다.bm25_scores— 검색 기초 에서 본 BM25입니다. , 는 이 실습에서 고른 값이고, IDF는 음수가 나오지 않도록 로그 안에 1을 더한 흔한 변형을 썼습니다.tfidf_vec·vec_scores— 2-그램 빈도에 로그 가중치와 IDF를 곱하고 길이 1로 정규화한 벡터입니다. 정규화했으므로 내적이 곧 코사인 유사도입니다.hits·rrf— 점수가 0보다 큰 문서만 순위 목록에 넣고, 로 합칩니다.evaluate— 질문마다 정답 문서의 순위 를 구해, recall@1( 비율), recall@3( 비율), MRR( 의 평균)을 냅니다.answer— RRF 상위 2개로 프롬프트를 조립합니다. 여기서 LLM을 부르면 실제 RAG가 됩니다.
3. 실행 결과 (실제 출력)
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. 더 해 보기
- 청킹 붙이기: 긴 규정 문서 하나를 200자 청크 · 50자 겹침으로 자르는 함수를 만들고, 청크 단위로 색인해 보세요.
- 리랭커 자리 만들기: RRF 상위 5개를 받아 다시 점수를 매기는
rerank(q, ids)함수를 만들고, 처음에는 "질문 단어가 문서에 몇 개 들어 있나" 같은 간단한 규칙으로 채워 보세요. 나중에 크로스 인코더로 바꿀 자리입니다. - 평가 세트 늘리기: 질문을 30개 이상으로 늘리고, 일부러 동의어 · 줄임말 질문을 넣어 어떤 방식이 어디서 무너지는지 표로 정리해 보세요.
- 진짜 생성 붙이기: 쓰는 LLM API가 있다면
answer()가 만든 프롬프트를 그대로 보내고, 답이 근거 번호를 제대로 다는지 확인해 보세요. 이때부터는 RAG 평가 의 충실도 지표가 필요해집니다.
연습 문제
- 질문 4개의 정답 순위가 1, 2, 1, 4위라면 recall@1, recall@3, MRR은?
- 풀이: recall@1 , recall@3 , MRR 입니다.
bigrams("휴가를")의 결과는?- 풀이:
["휴가", "가를"]입니다. 문서의 "휴가는" →["휴가", "가는"]과휴가가 겹칩니다.
- 풀이:
- RRF에서 한 문서가 BM25 1위 · 벡터 목록에 없음, 다른 문서가 BM25 2위 · 벡터 2위라면 어느 쪽이 위인가요()?
- 풀이: 앞 문서 , 뒤 문서 이므로 뒤 문서가 위입니다.
스스로 점검
- 이 실습에서 BM25와 벡터 쪽 토큰화를 다르게 한 이유는?
- 답: 단어 단위는 정확한 문자열(오류 코드 등)에 강하고, 글자 2-그램은 조사 변형에 강해 서로 약점을 보완하기 때문입니다.
- 점수 0인 문서까지 RRF에 넣으면 왜 나빠지나요?
- 답: 실제로 찾지 못한 문서가 원래 순서대로 순위 점수를 받아, 다른 검색기가 찾은 정답보다 앞에 끼어들 수 있기 때문입니다.
- "아기 돌보려고…" 질문을 푸는 두 가지 방법은?
- 답: LLM으로 검색용 질문을 다시 쓰는 쿼리 재작성, 그리고 의미를 학습한 신경망 임베딩으로 벡터 검색을 바꾸는 것입니다.
용어 정리
| 용어 | 쉬운 뜻 |
|---|---|
| 지식 베이스 | RAG가 검색 대상으로 삼는 문서 모음 |
| 평가 세트 | 정답 근거가 표시된 질문 목록 |
| 글자 n-그램 | 단어 안에서 연속한 글자 n개 묶음 |
| TF-IDF 벡터 | 단어(또는 n-그램) 빈도에 희귀도 가중치를 곱한 벡터 |
| recall@k | 정답이 상위 k개 안에 든 질문의 비율 |
| MRR | 정답 순위의 역수 평균 |
| 모의 생성 | LLM 호출 대신 프롬프트만 조립해 보여 주는 단계 |
더 깊이 보려면
- RAG 평가와 고급 패턴 — 생성 단계까지 포함한 평가(충실도 · 답변 관련성)
- 가상 문서로 하는 제로샷 밀집 검색 (HyDE) — 쿼리 쪽을 LLM으로 바꾸는 또 다른 방법
- 문장 임베딩: 샴 BERT 네트워크 (Sentence-BERT) —
tfidf_vec를 바꿔 끼울 신경망 임베딩
다음에 볼 것
- RAG 평가와 고급 패턴: 검색 지표 · 충실도 · GraphRAG · 에이전틱 RAG — 오늘 잰 검색 지표 너머, 답변 품질을 재는 법
- REALM: 검색을 더한 언어 모델 사전학습 — 검색기와 언어 모델을 함께 학습하는 접근
라이선스
본문과 코드는 KAITABASE가 이 실습을 위해 직접 작성했습니다. 문서 · 질문은 모두 가상의 예이며 실제 회사 규정이 아닙니다. 실행 결과는 2026-10-22 파이썬 3.14.3 · 넘파이 2.5.1에서 얻은 값입니다.
출처
- Cormack · Clarke · Büttcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009): plg.uwaterloo.ca/~gvcormac/cormacksigir09…
- Thakur 외, BEIR (2021): arxiv.org/abs/2104.08663
- Manning · Raghavan · Schütze, Introduction to Information Retrieval: nlp.stanford.edu/IR-book
AI 원리부터 이해하기 · RAG·검색 모듈
RAG·검색 모듈 학습
이 글은 RAG·검색 모듈의 학습 자료입니다. 전체 21개 자료 중 11번째입니다.
출처·원문
원문 보기요약만