6장. RAG 의 원리와 로컬 모델로 옮기기
RAG 의 세 단계
오픈북 시험에서는 문제를 읽고, 교재에서 그 내용이 있는 쪽을 찾고, 그 쪽을 펼쳐 옆에 둔 채 보며 답을 씁니다. 외운 것에만 기대지 않으니 교재에 있는 것은 틀리기 어렵습니다. RAG 가 이 방식입니다.
1번 질문이 들어오면, 2번 검색에서 문서 조각 가운데 관련된 것을 몇 개 고릅니다. 3번 증강에서 그 조각을 질문과 함께 프롬프트에 붙이고, 4번 생성에서 LLM 이 그것을 보고 답합니다. 여기서 교재가 문서, 쪽 찾기가 검색, 펼쳐 두기가 증강, 보고 쓰기가 생성입니다.
단계마다 하는 일
| 단계 | 하는 일 | 이 장에서 쓰는 것 |
|---|---|---|
| 준비 | 문서를 조각으로 나눠 찾기 좋게 둔다 | 빈 줄로 조항 나누기 |
| 검색 | 질문과 가까운 조각 k 개를 고른다 | TF-IDF 키워드 검색 |
| 증강 | 조각과 질문을 프롬프트에 붙인다 | [문서], [질문] 꼴의 프롬프트 |
| 생성 | LLM 이 답을 쓴다 | Qwen2.5(transformers, Ollama) |
세 단계를 글자로만 흉내 내기
모델 없이 흐름만 보면 이렇습니다. 검색은 겹치는 글자 수, 생성은 아직 비워 둡니다.
docs = ["제14조(결제 수단) 휴대폰 결제는 한 달에 50만원까지입니다.",
"제16조(쿠폰) 주문 하나에 쿠폰은 한 장만 쓸 수 있습니다.",
"제24조(고객센터) 평일 오전 9시부터 오후 6시까지 상담합니다."]
question = "휴대폰 결제 한도가 있나요?"
# 1) 검색: 질문의 글자 두 개짜리 조각이 문서에 몇 번 나오는지 센다
pieces = {question[i:i + 2] for i in range(len(question) - 1)}
scores = [sum(p in d for p in pieces) for d in docs]
best = docs[scores.index(max(scores))]
# 2) 증강: 찾은 조각을 질문과 함께 프롬프트로 만든다
prompt = f"[문서]\n{best}\n\n[질문]\n{question}"
print(scores)
print(prompt)
# 3) 생성: 이 prompt 를 LLM 에 넘긴다(실습 4부터)점수는 [7, 2, 0] 으로, "휴대", "대폰", "결제" 같은 조각이 겹친 제14조가 뽑힙니다. 쿠폰 조항도 " 한", " 있"처럼 어디에나 나오는 조각이 겹쳐 2점을 얻습니다. 흔한 조각에 무게를 덜 주도록, 실습 3에서는 이 셈을 TF-IDF 로 다듬습니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
question[i:i + 2] | 질문의 i 번째부터 두 글자를 잘라 낸다 |
{... for i in range(len(question) - 1)} | 한 칸씩 옮기며 자른 두 글자 조각을 모은다. 중괄호라서 같은 조각은 한 번만 남는다 |
sum(p in d for p in pieces) | 조각마다 문서 d 에 들어 있으면 1, 없으면 0 으로 세어 더한다. 곧 겹치는 조각의 수다 |
scores.index(max(scores)) | 가장 높은 점수가 몇 번째에 있는지 찾는다 |
f"[문서]\n{best}\n\n[질문]\n{question}" | 찾은 조항과 질문을 정해진 꼴의 글로 이어 붙인다. \n 은 줄바꿈이다 |
이 코드가 뜻하는 것
오픈북 시험으로 다시 읽어 봅니다. 문제의 낱말을 쪽마다 대 보고, 가장 많이 겹치는 쪽을 펼쳐 문제 옆에 놓는 일입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
pieces = {...} | 질문을 두 글자 조각으로 자른다 | 문제에서 눈에 띄는 낱말을 뽑는다 |
scores = [...] | 조항마다 겹치는 조각 수를 센다 | 쪽마다 그 낱말이 몇 개 나오나 센다 |
best = docs[...] | 점수가 가장 높은 조항을 고른다(검색) | 가장 잘 맞는 쪽을 고른다 |
prompt = f"..." | 조항과 질문을 한 글로 붙인다(증강) | 그 쪽을 문제 옆에 펼쳐 둔다 |
| 마지막 주석 | 이 글을 LLM 에 넘기는 일(생성)은 실습 4부터 한다 | 펼친 쪽을 보며 답을 쓴다 |
결과의 [7, 2, 0] 은 조항 셋의 점수이고, 7점인 제14조가 프롬프트에 붙었습니다. 이 코드만으로 RAG 의 앞 두 단계(검색, 증강)가 끝난 것입니다.
작게 줄여서 눈으로 보기
어떤 조각이 겹쳐서 점수가 났는지 직접 찍어 봅니다. 질문을 짧게 줄이고 조항도 둘만 둡니다.
docs = ["휴대폰 결제는 한 달에 50만원까지입니다.",
"쿠폰은 주문 하나에 한 장만 쓸 수 있습니다."]
question = "휴대폰 결제 한도"
# 질문을 두 글자씩 겹치게 자른 조각
pieces = sorted({question[i:i + 2] for i in range(len(question) - 1)})
print("질문 조각:", pieces)
# 문서마다 어떤 조각이 들어 있는지
for d in docs:
print(d[:6], "→", [p for p in pieces if p in d])질문 조각: [' 결', ' 한', '결제', '대폰', '제 ', '폰 ', '한도', '휴대']
휴대폰 결제 → [' 결', ' 한', '결제', '대폰', '폰 ', '휴대']
쿠폰은 주문 → [' 한']| 결과 | 읽는 법 |
|---|---|
| 질문 조각 8개 | 빈칸도 글자로 치므로 " 결", "폰 " 처럼 빈칸이 붙은 조각이 생긴다 |
| 휴대폰 조항의 6개 | "휴대", "대폰", "결제" 같은 뜻 있는 조각이 겹쳐 점수가 높다 |
| 쿠폰 조항의 1개 | 뜻과 상관없는 " 한"(빈칸 다음의 "한")만 겹쳤다. 흔한 조각 때문에 엉뚱한 조항도 점수를 얻는다 |
"한도"는 두 조항 어디에도 없어서 아무 점수도 보태지 못했습니다. 글자가 겹쳐야만 찾는 검색의 한계가 여기서 보입니다.
주의할 점
- 검색이 틀리면 생성도 틀립니다. 엉뚱한 쪽을 펼치면 아무리 좋은 모델도 답을 못 찾습니다. RAG 가 틀렸을 때는 검색 결과부터 봅니다.
- 조각을 많이 붙일수록 답이 들어 있을 가능성은 커지지만, 프롬프트가 길어져 느려지고 모델이 헷갈리기 쉽습니다. 이 장은 3개를 붙입니다.