검색기와 붙인다 — 여기까지가 RAG 다
Article
앞 편 마지막에서 모델이 엉뚱한 소리를 했다. 모델이 우리 문서를 모르기 때문이었다. 그런데 우리한테는 그 문서가 있다. 오늘 그 조각을 질문과 같이 넣어준다.
- ① 모델을 얹는다끝남
- ② 파이썬에서 부른다끝남
- ③ 검색기와 붙인다지금 여기
- ④~⑥ 답을 고치고 잰다오후
1. RAG 는 세 글자가 그대로 순서다
어려운 개념이 아니라 방금 말한 그 순서에 붙은 이름이다.
Retrieval-Augmented Generation
- Retrieval
- 검색
- 17강에서 만들어둔 것
- Augmented
- 보태서
- 오늘 배울 것 · f-string 하나
- Generation
- 생성
- 앞 편에서 만든 것
- 질문「조각을 겹치게 자르는 이유가 뭔가」
- 검색9,005조각에서 가까운 3개
- 프롬프트 조립조각 + 질문을 한 덩이로
- 모델읽고 답한다
여기서 제일 흔한 오해가 있다. 모델을 우리 문서로 학습시키는 게 아니다. 모델은 하나도 안 바뀐다 — 받아둔 그 2GB 짜리 파일 그대로다. 바뀌는 것은 매번 보내는 질문지다. 시험지에 비유하면 학생을 다시 가르치는 게 아니라 오픈북으로 바꾸는 것이다.
2. 검색기를 다시 꺼낸다
17강에서 만든 것을 그대로 쓴다. 한 군데만 다르다.
import numpy as np
from sentence_transformers import SentenceTransformer
data = np.load("index.npz", allow_pickle=True)
vecs, slugs, texts = data["vecs"], data["slugs"], data["texts"]
model = SentenceTransformer("intfloat/multilingual-e5-small")
def search(question, k=3):
"""질문과 가까운 조각 k 개를 찾는다."""
qv = model.encode([question], normalize_embeddings=True)[0]
scores = vecs @ qv
# 17강과 다른 곳은 여기 한 줄이다. 글 단위로 묶지 않고
# 점수 높은 순서대로 조각을 그냥 k 개 집는다
out = []
for i in scores.argsort()[::-1][:k]:
out.append((float(scores[i]), str(slugs[i]), str(texts[i])))
return out
for score, slug, text in search("조각을 겹치게 자르는 이유가 뭔가"):
print(f"{score:.3f} {slug}")
print(f" {text[:40]}")
- 0.903 ai-eng-04-overlap
- ## 정리하면\n\n글자 수로만 자르면 경계에 걸친 표현이 사라진다.
- 0.900 ai-eng-04-overlap
- 조각이 많아지면 뒤에서 임베딩을 그만큼 더 계산해야 하고,
- 0.896 ai-eng-04-overlap
- 겹치면 무슨 일이 생기나\n\n1. 잘린 대목「사용자 눈에 / 보이지 않는」이
17강에서는 한 글에서 제일 잘 맞은 조각 하나만 남겼다. 「어느 글이 답인가」를 물었고, 같은 글이 다섯 번 나오면 목록이 망가지기 때문이다.
오늘은 목적이 다르다. 모델에 먹일 근거를 찾는 것이라 글 전체는 너무 길어 못 넣고, 같은 글의 조각 셋이면 오히려 좋다. 그래서 묶는 코드를 뺐다 — 목적이 바뀌면 잘 만든 코드가 군더더기가 된다.
3. 조각을 질문에 붙인다
여기가 오늘 새로 배우는 유일한 부분인데, f-string 하나가 전부다.
# (21_rag_search.py 의 위쪽을 그대로 복사해 온다 — data, model, search)
question = "조각을 겹치게 자르는 이유가 뭔가"
hits = search(question, k=3)
# 조각 셋을 빈 줄 하나로 이어 붙인다
context = "\n\n".join(text for score, slug, text in hits)
prompt = f"""아래 자료를 읽고 질문에 답해라.
[자료]
{context}
[질문]
{question}
"""
print(f"자료 {len(context)}자 / 프롬프트 전체 {len(prompt)}자")
print("-" * 40)
print(prompt)
- 자료 468자 / 프롬프트 전체 519자
- ----------------------------------------
- 아래 자료를 읽고 질문에 답해라.
- [자료]
- ## 정리하면
- 글자 수로만 자르면 경계에 걸친 표현이 사라진다. 건너뛰는 폭을 조각
- 크기보다 좁게 하면 조각끼리 겹치고, 찢겼던 대목이 어느 한 조각에는
- 온전히 들어간다. ... (조각 셋이 이어진다)
- [질문]
- 조각을 겹치게 자르는 이유가 뭔가
| 부분 | 왜 그렇게 썼나 |
|---|---|
"""...""" 세 겹 따옴표 | 여러 줄 글자를 그대로 쓴다. 줄바꿈을 \n 으로 안 적어도 된다 |
[자료] · [질문] 표시 | 어디까지가 자료고 어디부터가 질문인지 모델에 알려준다 |
"\n\n".join(...) | 조각 사이를 빈 줄로 띄운다. 붙여 놓으면 한 덩어리로 읽는다 |
f-string 안의 중괄호는 그 자리에 변수 값을 넣으라는 표시다. 그래서 중괄호를 글자 그대로 쓰려면 두 번 적어야 하는데, 내일 JSON 을 다룰 때 이게 바로 사고가 된다.
4. 모델에 넣는다 — 여기서 완성된다
앞 편에서 쓴 호출을 그대로 쓴다. content 에 들어가는 글자만 바뀐다.
# (21 · 22 의 위쪽을 그대로 복사해 온다 — search, question, hits, context, prompt)
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
res = client.chat.completions.create(
model="qwen2.5:3b",
messages=[{"role": "user", "content": prompt}], # 질문 대신 프롬프트 전체
)
print(res.choices[0].message.content)
먼저 근거를 안 준 쪽부터 본다.
- "조각을 겹치게 자르는 것"이라는 표현은 구체적인 상황이나 제품이 무엇인지
- 잘 모르겠습니다. 하지만 일반적으로 이 용어를 사용할 수 있는 몇 가지
- 상황들을 설명해 드리겠습니다:
- 1. 공예와 조형 예술: 조각 작업에서 특정 모양을 만들 때, 여러 부분으로
- 자르는 것을 통해 원하는 형태를 얻을 수 있습니다.
- 2. 정밀공학 제품 제조: 일부 기계 공학 및 정밀가공 시스템에서, 특정
- 크기 또는 형상의 부품을 만드는데 사용됩니다.
이제 근거를 넣은 쪽이다.
- 조각을 겹치게 자르는 이유는 주로 저장과 계산 효율성을 개선하려는
- 목적입니다. 조각이 많아지면, 각 조각에 대해 저장 및 관리하는 작업들이
- 늘어나며, 이러한 과정은 비효율적일 수 있습니다.
- 하지만 겹침은 겹쳤을 때의 상실된 내용들을 보완하는 역할도 하므로,
- 과도하게 겹치는 것은 오히려 좋지 않은 값을 가져올 수 있습니다.
근거가 없을 때는 공예 · 정밀가공 같은 우리 수업에 없는 단어가 나왔고, 모른다고는 하지 않았다. 근거를 넣으니 저장 · 계산 · 겹침 · 보완 — 9강에 실제로 있는 말들이 나온다. 모델은 안 바뀌었고 프롬프트 한 덩어리가 바뀌었을 뿐이다.
여기까지가 RAG 다. 검색해서, 붙여서, 물어봤다.
5. 🔴 그런데 답을 자세히 읽어보자
축하는 여기까지다. 위 답의 첫 문장을 다시 읽는다.
자료에 적힌 것은 「겹치면 조각 수가 늘어나고, 그래서 저장과 계산이 늘어난다」였다. 겹침은 그 비용을 치르고 사는 안전장치다. 그런데 모델은 겹치는 이유를 「저장과 계산 효율성을 개선하려는 목적」이라고 했다. 정반대로 말했다. 그런데 문장은 매끄럽다.
자료를 안 읽은 게 아니다. 자료의 단어를 쓰면서 뜻을 뒤집었다.
근거를 안 줬을 때는 틀린 게 티가 났다 — 공예 이야기가 나오면 누구나 안다. 근거를 주고 나면 우리 문서의 단어를 쓰고 있어서 읽는 사람은 맞는 줄 안다. RAG 는 문제를 없앤 게 아니라 어렵게 만들었다.
6. 함수 하나로 묶는다
오늘 만든 것을 ask() 하나로 접어둔다. 오늘 이후 계속 이 함수를 고쳐 쓴다.
import numpy as np
from sentence_transformers import SentenceTransformer
from openai import OpenAI
data = np.load("index.npz", allow_pickle=True)
vecs, slugs, texts = data["vecs"], data["slugs"], data["texts"]
model = SentenceTransformer("intfloat/multilingual-e5-small")
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
def search(question, k=3):
qv = model.encode([question], normalize_embeddings=True)[0]
scores = vecs @ qv
return [
(float(scores[i]), str(slugs[i]), str(texts[i]))
for i in scores.argsort()[::-1][:k]
]
def ask(question, k=3):
"""검색 -> 조립 -> 생성. 답과 근거를 같이 돌려준다."""
hits = search(question, k)
context = "\n\n".join(text for score, slug, text in hits)
prompt = f"""아래 자료를 읽고 질문에 답해라.
[자료]
{context}
[질문]
{question}
"""
res = client.chat.completions.create(
model="qwen2.5:3b",
messages=[{"role": "user", "content": prompt}],
)
# 답만 주면 어디서 왔는지 알 수 없다. 근거 글 이름도 같이 돌려준다
return res.choices[0].message.content, [slug for score, slug, text in hits]
for q in ["브랜치는 왜 따로 만드나", "엑셀 파일을 파이썬으로 어떻게 여나"]:
answer, sources = ask(q)
print(f"\n[질문] {q}")
print(f"[근거] {sources}")
print(f"[답] {answer[:80]}")
- [질문] 브랜치는 왜 따로 만드나
- [근거] ['git-21-branch', 'git-21-branch', 'git-21-branch']
- [답] 브랜치를 따로 만들 때, 원본(기존의) 코드에는 영향이 없다는 것을 강조한
- [질문] 엑셀 파일을 파이썬으로 어떻게 여나
- [근거] ['ai-eng-07-excel-report', 'ai-eng-07-excel-report', 'ai-eng-07-excel-report']
- [답] 엑셀 파일을 파이썬으로 열고 사용하려면, 먼저 설치된 openpyxl 라이브러리를
return 에 sources 를 붙인 이유가 있다. 답이 이상할 때 모델이 이상한 건지 검색이 이상한 건지를 여기서 가른다. 근거가 엉뚱한 글이면 검색 문제이고, 근거는 맞는데 답이 틀리면 모델 문제다 — 고칠 자리가 완전히 다르다.
7. 근거를 넣으면 느려진다
앞 편에서 예고한 것이 여기서 숫자로 나온다. 자료를 붙이면 입력이 45토큰에서 397토큰이 된다.
| 입력 토큰 | CPU 프리필 | CPU 총 | GPU 총 | |
|---|---|---|---|---|
| 근거 없이 | 45 | 0.4초 | 9.4초 | 4.5초 |
| 조각 3개 | 397 | 3.8초 | 13.2초 | 4.6초 |
GPU 쪽은 4.5초에서 4.6초라 티도 안 난다. CPU 쪽은 9.4초에서 13.2초가 되고, 늘어난 것은 답을 만드는 시간이 아니라 입력을 읽는 시간이다. 학원 노트북은 CPU 쪽이라 k 를 아무 생각 없이 10 으로 올리면 수업이 멈춘다. k 를 몇으로 둘지는 오늘 마지막 편에서 재서 정한다.
이번 편에 나온 것
| 쓴 것 | 하는 일 |
|---|---|
| RAG | 검색(Retrieval) · 보태서(Augmented) · 생성(Generation) |
scores.argsort()[::-1][:k] | 점수 높은 조각 k 개. 글 단위로 안 묶는다 |
"\n\n".join(...) | 조각 사이를 빈 줄로 띄워 이어 붙인다 |
f"""...{context}...""" | 여러 줄 프롬프트 안에 값을 끼운다 |
[자료] · [질문] | 어디까지가 근거인지 모델에 알려주는 표시 |
print(prompt) | 모델에 실제로 뭐가 갔는지 눈으로 본다 |
ask(question) | 셋을 묶은 함수. 답과 근거를 같이 돌려준다 |
return answer, sources | 검색 문제인지 모델 문제인지 가르는 근거 |
다음 편에서 이 틀림을 종류별로 잡아본다.