[CS300 #276] RAG — 찾아서 읽고 답하게 한다
컴퓨터공학 300 주제 시리즈의 276번째 글이다. 전체 지도는 여기.
한 줄 요약
RAG(retrieval-augmented generation, 검색 증강 생성)는 질문과 관련된 문서를 먼저 검색해 프롬프트에 넣고, 모델이 그 문서를 근거로 답을 생성하게 하는 구조다. 모델을 다시 학습시키지 않고 최신·사내 지식을 쓰게 하고, 출처를 댈 수 있게 한다.
왜 필요한가
LLM 의 지식에는 세 가지 한계가 있다.
- 시점: 학습 데이터를 모은 시점 이후의 일을 모른다.
- 범위: 사내 위키, 장애 보고서, 계약서처럼 공개되지 않은 자료는 본 적이 없다.
- 근거: 가중치에 녹아든 지식은 어디서 왔는지 댈 수 없고, 모르는 것을 그럴듯하게 지어내기도 한다.
재학습(미세조정)으로 지식을 넣을 수도 있지만 비싸고 느리며, 문서가 바뀔 때마다 다시 해야 한다. 미세조정은 지식 주입보다 형식·말투를 바꾸는 데 더 효과적이라는 것이 일반적인 경험이다. RAG 는 지식을 모델 밖에 두고 필요할 때 꺼내 읽힌다. 문서를 고치면 바로 반영되고, 답마다 출처를 붙일 수 있다. Lewis 외(2020)가 이 이름으로 체계화했다.
핵심 개념
전체 흐름
[색인 단계 — 미리]
문서 ─▶ 정제 ─▶ 조각내기(chunking) ─▶ 임베딩 / 역색인 ─▶ 저장소
[질의 단계 — 매 요청]
질문 ─▶ (질의 재작성) ─▶ 검색 상위 k ─▶ (재순위) ─▶ 프롬프트 조립 ─▶ LLM ─▶ 답 + 출처
조각내기
문서를 통째로 넣으면 컨텍스트가 넘치고 관련 없는 내용이 섞인다. 그래서 수백 토큰 정도의 조각으로 나눈다.
- 너무 작으면 문맥이 끊긴다(“위 설정을 적용하면”의 “위”가 사라짐).
- 너무 크면 검색 정확도가 떨어지고 토큰 비용이 는다.
- 조각 사이를 조금 겹치게 하거나, 제목·절 구조를 따라 자르면 낫다. 조각마다 원문 제목·경로 같은 메타데이터를 붙여 두면 출처 표시와 필터링에 쓸 수 있다.
검색 — 희소와 밀집
| 방식 | 원리 | 강점 | 약점 |
|---|---|---|---|
| 희소(BM25 등) | 단어 일치 + 희귀 단어 가중 | 고유명사, 오류 코드, 정확한 용어 | 동의어·바꿔 말하기 |
| 밀집(임베딩) | 의미 벡터의 근접성 | 다른 표현의 같은 뜻 | 정확한 식별자, 새로운 용어 |
| 하이브리드 | 두 점수를 합치거나 순위 융합 | 둘의 장점 | 구성이 복잡 |
CrashLoopBackOff 같은 문자열은 BM25 가 확실히 찾고, “파드가 계속 재시작됨” 같은 서술은 임베딩이 더 잘 찾는다. 실무에서 하이브리드가 흔한 이유다. 밀집 검색의 대표 연구로 DPR(Karpukhin 외, 2020)이 있다.
BM25 점수는 질의 단어마다
IDF(w) · tf·(k1+1) / ( tf + k1·(1 − b + b·문서길이/평균길이) )
를 더한다. 드문 단어일수록(IDF), 많이 나올수록(tf, 단 포화됨) 점수가 높고, 긴 문서는 b 만큼 보정된다.
재순위와 프롬프트 조립
검색 상위 수십 개를 더 정밀한 모델(크로스 인코더 등)로 다시 매겨 상위 몇 개만 넣는 재순위가 품질을 많이 올린다. 프롬프트에는 다음을 명시한다.
- 제공된 문서만 근거로 답할 것
- 근거가 없으면 모른다고 할 것
- 각 주장에 문서 ID 를 붙일 것
평가는 둘로 나눠서
RAG 가 틀렸을 때 원인은 둘 중 하나다. 검색이 맞는 문서를 못 가져왔거나, 가져왔는데 생성이 잘못 썼거나.
| 단계 | 지표 |
|---|---|
| 검색 | recall@k (정답 문서가 상위 k 안에 있는가), MRR |
| 생성 | 충실성(문서에 근거했는가), 답의 정확성, 출처 정확성 |
질문-정답 문서 쌍을 수십 개만 만들어 둬도 조각 크기, k, 검색 방식을 숫자로 비교할 수 있다.
직접 해 보기
문서 네 개에 BM25 를 직접 구현하고, 상위 문서로 프롬프트를 조립한다.
import math, re
from collections import Counter
docs = {
"d1": "k3s 노드가 NotReady 가 되면 kubelet 로그와 디스크 압박 상태를 먼저 본다.",
"d2": "파드가 CrashLoopBackOff 이면 이전 컨테이너 로그를 --previous 로 확인한다.",
"d3": "인증서가 만료되면 API 서버 연결이 TLS 오류로 실패한다.",
"d4": "디스크 사용률이 높으면 kubelet 이 이미지 가비지 컬렉션을 시작한다.",
}
tok = lambda s: re.findall(r"[\w-]+", s.lower())
D = {k: tok(v) for k, v in docs.items()}
N = len(D); avgdl = sum(map(len, D.values())) / N
df = Counter(w for t in D.values() for w in set(t))
def bm25(q, k1=1.2, b=0.75):
out = {}
for k, t in D.items():
tf = Counter(t); s = 0.0
for w in tok(q):
if w not in tf: continue
idf = math.log(1 + (N - df[w] + .5) / (df[w] + .5))
s += idf * tf[w] * (k1 + 1) / (tf[w] + k1 * (1 - b + b * len(t) / avgdl))
out[k] = round(s, 3)
return sorted(out.items(), key=lambda kv: -kv[1])
q = "노드 디스크 kubelet 문제"
ranked = bm25(q); print("검색 순위:", ranked)
ctx = "\n".join(f"[{k}] {docs[k]}" for k, s in ranked[:2] if s > 0)
print("--- 생성 모델에 넘길 프롬프트 ---")
print(f"아래 문서만 근거로 답하고, 문장 끝에 [문서id] 를 붙여라. 근거가 없으면 모른다고 답하라.\n{ctx}\n질문: {q}")
실행 결과:
검색 순위: [('d4', 1.417), ('d1', 1.252), ('d2', 0.0), ('d3', 0.0)]
--- 생성 모델에 넘길 프롬프트 ---
아래 문서만 근거로 답하고, 문장 끝에 [문서id] 를 붙여라. 근거가 없으면 모른다고 답하라.
[d4] 디스크 사용률이 높으면 kubelet 이 이미지 가비지 컬렉션을 시작한다.
[d1] k3s 노드가 NotReady 가 되면 kubelet 로그와 디스크 압박 상태를 먼저 본다.
질문: 노드 디스크 kubelet 문제
질문의 “디스크”, “kubelet” 이 들어간 d4, d1 이 위로 왔다. 둘 다 맞은 단어는 두 개로 같은데 d1 이 낮다. d1 이 더 길어서 길이 보정(b)으로 점수가 깎였기 때문이다. 질문의 “노드” 는 d1 에 “노드가” 로 들어 있어 아예 일치하지 않았다. 조사 하나 때문에 놓친 것이다. d2, d3 은 겹치는 단어가 없어 0 점이고, s > 0 조건으로 프롬프트에서 빠졌다. 관련 없는 문서를 억지로 채워 넣지 않는 것도 중요하다. 모델은 넣어 준 것을 쓰려고 하기 때문이다.
이 예제에는 한국어 형태소 분석이 없다. “문제”와 “문제를”을 다른 단어로 본다. 실제 한국어 검색에서는 형태소 분석기나 n-gram 토큰화, 또는 밀집 검색을 함께 써야 한다.
현업에서는
- 사내 문서 Q&A: 가장 흔한 LLM 도입 사례다. 실패의 대부분은 모델이 아니라 문서 쪽에서 온다. 오래된 문서, 중복, PDF 표가 깨진 추출, 권한 없는 문서의 노출.
- 권한 필터링은 검색 단계에서. 사용자가 볼 수 없는 문서가 검색 결과에 들어가 프롬프트로 새면 그대로 정보 유출이다. 메타데이터로 권한을 먼저 거른다.
- 간접 프롬프트 인젝션: 검색된 문서 안에 악의적 지시가 들어 있을 수 있다. 외부에서 들어온 문서를 색인할 때 특히 주의한다.
- 운영 지식 베이스: 홈랩 같은 작은 환경에서도 과거 장애 기록과 런북을 조각내 색인해 두면, “이 증상 전에 본 적 있나”를 묻는 도구가 된다. 단, 답의 출처 링크를 반드시 확인하는 습관을 함께 들여야 한다.
확인 문제
- RAG 가 미세조정보다 사내 지식 반영에 유리한 점 두 가지는?
- 조각이 너무 작을 때와 너무 클 때의 문제는?
- 오류 코드
E1234로 검색할 때 BM25 와 임베딩 검색 중 어느 쪽이 더 믿을 만한가? - RAG 의 답이 틀렸을 때 원인을 나누어 진단하는 방법은?
- 예제에서 d1 과 d4 는 맞은 단어 수가 같은데 d1 이 낮다. 이유는? 그리고 질문의 “노드” 가 d1 과 일치하지 않은 이유는?
풀이
- 문서를 바꾸면 재학습 없이 바로 반영되고, 답에 출처를 붙여 검증할 수 있다. (비용이 싸다는 점도 있다.)
- 너무 작으면 문맥이 끊겨 의미를 잃고, 너무 크면 검색이 부정확해지고 컨텍스트·비용을 낭비한다.
- BM25. 정확한 문자열 일치를 보며, 드문 토큰이라 IDF 가중이 크다. 임베딩은 이런 식별자를 비슷한 다른 코드와 섞기 쉽다.
- 정답 문서가 검색 상위 k 에 들어왔는지(recall@k)를 먼저 보고, 들어왔다면 생성 단계의 충실성 문제로, 안 들어왔다면 검색 문제로 본다.
- d1 이 더 길어 길이 정규화로 점수가 깎였다. “노드” 는 d1 에 “노드가” 라는 다른 토큰으로 들어 있어, 형태소 분석 없는 공백·기호 기준 토큰화로는 일치하지 않는다.
더 읽을거리 (References)
- P. Lewis 외, “Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks”, 2020. arXiv:2005.11401
- V. Karpukhin 외, “Dense Passage Retrieval for Open-Domain Question Answering”, 2020. arXiv:2004.04906
- Y. Gao 외, “Retrieval-Augmented Generation for Large Language Models: A Survey”, 2023. arXiv:2312.10997
- S. Robertson, H. Zaragoza, “The Probabilistic Relevance Framework: BM25 and Beyond”, Foundations and Trends in Information Retrieval 3(4), 2009. (서지 정보)