LLM 엔지니어링6장 · RAG 의 원리와 로컬 모델로 옮기기19 / 23← 이전목차다음 →MetaContents

6장. RAG 의 원리와 로컬 모델로 옮기기

실습 5 — Ollama 로 같은 일 하기

실습 4의 RAG 를 Ollama 서버의 모델로 옮깁니다. 모델을 파이썬 안에 올리지 않고, openai 라이브러리로 http://localhost:11434/v1 에 요청을 보냅니다. Ollama 를 설치하고 ollama pull qwen2.5:0.5b 로 모델을 받아 두어야 합니다(setup.md). 같은 Qwen2.5-0.5B-Instruct 이지만, Ollama 의 모델 파일은 4비트 남짓으로 줄인 것이라 답이 실습 4와 조금 다를 수 있습니다.

1단계. 서버와 모델 확인하기

Python
from openai import OpenAI

# Ollama 의 OpenAI 호환 주소. api_key 는 검사하지 않으므로 아무 글자나 넣는다
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
# 내려받은 모델 이름 목록. ollama list 와 같다
print(sorted(m.id for m in client.models.list()))
출력
['nomic-embed-text:latest', 'qwen2.5:0.5b', 'qwen2.5:1.5b', 'qwen2.5:3b']

2단계. 질문 하나 보내기

Python
import time

def ask(messages, model="qwen2.5:0.5b"):
    """Ollama 에 메시지를 보내고 답 글을 돌려준다. temperature=0, seed=42 로 다시 물어도 같은 답이 나오게 한다"""
    resp = client.chat.completions.create(model=model, messages=messages, temperature=0, seed=42, max_tokens=120)
    return resp.choices[0].message.content.strip()

# 첫 요청은 모델을 GPU 에 올리느라 오래 걸린다. 한 번 불러 둔 뒤에 시간을 잰다
ask([{"role": "user", "content": "안녕하세요"}])
start = time.time()
print(ask([{"role": "user", "content": "휴대폰으로 결제하면 한도가 있나요?"}]))
print(f"걸린 시간 {time.time() - start:.1f}초")
출력
네, 휴대폰 결제는 한도가 있습니다. 일반적으로, 결제 금액은 100,000원 이하로 제한됩니다. 하지만, 이는 단순히 금액을 제한하는 것이 아니라, 특정 기능이나 서비스를 사용할 수 있는 최대한 적합한 금액을 설정하는 것입니다. 예를 들어, 일부 서비스는 50,000원 이상의 결제를 지원할 수 있으며, 100,000원 이상의 결제는
걸린 시간 0.8초

3단계. 검색을 붙여 30개 채점하기

Python
import json
from collections import Counter
from openai import APIError
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

# 실습 4와 같은 준비: 검색, 프롬프트, 채점(모델은 불러오지 않는다)
text = open("shop_policy.txt", encoding="utf-8").read()
chunks = [c.strip() for c in text.split("\n\n")[1:]]
qa = [json.loads(line) for line in open("shop_policy_qa.jsonl", encoding="utf-8")]
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
X = vec.fit_transform(chunks)
def search(question, k=3):
    scores = cosine_similarity(vec.transform([question]), X)[0]
    return [(int(i), float(scores[i])) for i in scores.argsort()[::-1][:k]]
SYSTEM = ("당신은 쇼핑몰 상담원입니다. 아래 [문서]에 적힌 내용만 근거로 짧게 답하세요. "
          "문서에 답이 없으면 '문서에서 찾지 못했습니다.'라고만 답하세요.")
def build_messages(question, k=3):
    found = [chunks[i] for i, _ in search(question, k)]
    return [{"role": "system", "content": SYSTEM},
            {"role": "user", "content": "[문서]\n" + "\n".join(found) + "\n\n[질문]\n" + question}]
REFUSE = ["찾지 못", "찾을 수 없", "나와 있지 않", "제공되지 않"]
def grade(key, answer):
    refused = any(w in answer for w in REFUSE)
    if key == "":
        return "모름 맞음" if refused else "지어냄"
    if any(k in answer for k in key.split("|")):
        return "정답"
    return "거절" if refused else "오답"

def safe_ask(messages, model="qwen2.5:0.5b"):
    """서버가 오류를 돌려주면 멈추지 않고 '[오류]' 로 적어 둔다"""
    try:
        return ask(messages, model)
    except APIError as e:
        return f"[오류] {e.message[:40]}"

start = time.time()
answers = [safe_ask(build_messages(r["질문"])) for r in qa]
results = [grade(r["정답열쇠"], a) for r, a in zip(qa, answers)]
print(f"30문항 {time.time() - start:.0f}초")
print("문서에 있는 질문:", Counter(x for x, r in zip(results, qa) if r["근거조항"]))
print("문서에 없는 질문:", Counter(x for x, r in zip(results, qa) if not r["근거조항"]))
print("오류가 난 답:", sum(a.startswith("[오류]") for a in answers), "개 ·", next((a for a in answers if a.startswith("[오류]")), "없음"))
출력
30문항 14초
문서에 있는 질문: Counter({'정답': 14, '오답': 8, '거절': 2})
문서에 없는 질문: Counter({'지어냄': 6})
오류가 난 답: 0 개 · 없음

이 실습이 뜻하는 것

이 실습은 아르바이트생을 가게 안에 앉혀 두는 대신, 상담 창구에 전화로 묻는 방식으로 바꾸는 것과 같습니다. 창구가 열려 있고 어떤 상담원이 있는지 확인하고(1단계), 안내서 없이 질문 하나를 보내 걸리는 시간을 재고(2단계), 실습 4처럼 안내서 세 쪽을 붙여 30문제를 채점합니다(3단계). 여기서 상담 창구가 Ollama 서버(http://localhost:11434), 전화가 openai 라이브러리로 보내는 요청, 상담원이 qwen2.5:0.5b 모델입니다. 비유와 다른 점은, 이 창구는 멀리 있는 곳이 아니라 같은 PC 안에서 따로 돌아가는 프로그램이라는 것입니다.

단계출력뜻
1. 모델 목록['nomic-embed-text:latest', 'qwen2.5:0.5b', 'qwen2.5:1.5b', 'qwen2.5:3b']서버에 받아 둔 모델 넷이 보입니다. 이 실습은 qwen2.5:0.5b 를, 실습 6은 qwen2.5:1.5b, qwen2.5:3b 도 씁니다
2. 문서 없는 답"결제 금액은 100,000원 이하로 제한됩니다"실습 1처럼 문서 없이 물으니 지어낸 한도를 말했습니다
2. 시간걸린 시간 0.8초모델을 미리 올려 둔 뒤라 답 하나가 1초 안에 왔습니다
3. 시간30문항 14초문서를 붙인 질문 30개에 14초, 한 문제에 0.5초쯤 걸렸습니다
3. 문서에 있는 질문'정답': 14, '오답': 8, '거절': 2정답 14개로 실습 4(17개)보다 조금 낮습니다. Ollama 의 모델 파일은 4비트 남짓으로 줄인 것이라 답이 조금 다릅니다
3. 문서에 없는 질문'지어냄': 6실습 4와 마찬가지로 6개 모두 지어냈습니다
3. 오류오류가 난 답: 0 개 · 없음이번에는 서버 오류 없이 30문제가 모두 답을 받았습니다

한 줄로 줄이면, 모델을 서버로 옮겨도 코드 몇 줄만 바꾸면 같은 RAG 가 돌고, 정답 수도 비슷하다(17개와 14개)는 것을 보인 실습입니다. 실습 6에서는 이 짜임 덕분에 모델 이름만 바꿔 더 큰 모델과 견줍니다. Ollama 는 실행마다 정답 수가 하나둘 달라질 수 있으니, 3개 차이는 흐름으로 읽습니다.

코드 설명

  • OpenAI(base_url=..., api_key="ollama") 하나로 Ollama 에 붙습니다. 본문 "외부 API 로 만드는 RAG"의 코드와 다른 것은 이 줄과 모델 이름뿐입니다. client.models.list() 는 서버에 있는 모델 목록을 돌려줍니다.
  • 문서 없이 보낸 질문에는 "결제 금액은 100,000원 이하로 제한됩니다"처럼 실습 1과 같은 환각이 나옵니다. 모델을 올려 둔 뒤의 답은 0.8초 걸렸습니다. 먼저 보낸 "안녕하세요"가 모델을 GPU 에 올려 두는 몫입니다.
  • 30문항에 14초, 문서에 있는 질문의 정답은 14개(오답 8, 거절 2)이고 문서에 없는 6개는 모두 지어냈습니다. 같은 0.5B 인데 실습 4(정답 17개)보다 조금 낮은 것은 Ollama 의 모델 파일이 4비트로 줄인 것이라 그렇습니다. 대신 파이썬 안에 모델을 올리지 않아도 되고 빠릅니다.
  • 이번 실행에서는 오류가 난 답이 없었습니다. 교안을 만들며 따로 돌렸을 때는 0.5B 모델이 같은 말을 되풀이하다 Ollama 가 prediction aborted, token repeat limit reached 오류로 멈춘 적이 있어, safe_ask() 가 오류를 받아 적고 다음 질문으로 넘어가게 했습니다.
← 이전 절6장 · 실습 4 — 근거를 붙여 답하게 하기다음 절 →6장 · 실습 6 — 모델 바꿔 끼우기