답이 이상하다 — 고치기 전에 이름을 붙인다
Article
앞 편에서 RAG 를 완성했고, 답 하나가 뒤집혀 있는 것을 봤다. 오늘은 아무것도 안 고친다. 고칠 자리를 찾는다.
- ①② 모델을 얹고 부른다끝남
- ③ 검색기와 붙인다끝남
- ④ 답이 이상하다지금 여기 · 진단
- ⑤⑥ 고치고 잰다다음
답이 이상할 때 제일 흔한 대처가 프롬프트를 만지는 것이다. 그런데 오늘 볼 실패 중 프롬프트로 고쳐지는 것은 절반도 안 된다. 어디가 고장났는지 모르는 채로 고치면 고친 줄 알고 넘어간다. 그게 제일 나쁘다.
1. 진단은 세 번 묻는 것이다
RAG 는 부품이 셋이다. 답이 틀렸으면 셋 중 하나가 틀린 것이고, 순서대로 물어보면 갈린다.
- ① 자료에 답이 있나없으면 → 갈래 ①. RAG 문제가 아니다
- ② 검색이 데려왔나못 데려왔으면 → 갈래 ②. 검색을 고친다
- ③ 모델이 읽었나데려왔는데 틀렸으면 → 갈래 ③. 모델 문제다
| 갈래 | 증상 | 고치는 곳 | 언제 배우나 |
|---|---|---|---|
| ① 자료에 없다 | 모른다고 하거나 지어낸다 | 자료를 늘린다. 코드로는 못 고친다 | — |
| ② 검색이 못 찾았다 | 엉뚱한 근거를 물고 답한다 | 검색을 고친다 | 수요일 하루 전체 |
| ③ 모델이 못 읽었다 | 근거는 맞는데 답이 틀리다 | 프롬프트 · 더 큰 모델 | 다음 편 · 다음 주 월요일 |
2. 진단 도구를 먼저 만든다
「검색이 데려왔나」를 눈으로 확인하려면 정답이 몇 위였는지 알아야 한다.
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 rank_of(question, needle, limit=200):
"""정답이라고 아는 글자가 든 조각이 몇 위에 오나."""
qv = model.encode([question], normalize_embeddings=True)[0]
order = (vecs @ qv).argsort()[::-1]
# enumerate(..., 1) 은 0 이 아니라 1 부터 센다. 순위는 1위부터니까
for pos, i in enumerate(order[:limit], 1):
if needle in str(texts[i]):
return pos, str(slugs[i])
return None, None # 200위 안에 없다
print(rank_of("겹침을 조각 크기와 같게 주면 어떻게 되나", "멈춘다"))
print(rank_of("우리가 자른 조각의 크기는 몇 자인가", "size=200"))
print(rank_of("블로그 글을 몇 편 긁어왔나", "264"))
- (1, 'ai-eng-04-overlap')
- (23, 'ai-eng-04-overlap')
- (None, None)
needle 은 우리가 답을 알기 때문에 쓸 수 있다 — 「이 말이 든 조각이 정답이다」를 사람이 정해주는 것이다. 지금은 질문 하나를 손으로 넣지만 수요일에 질문 20개짜리 표로 키운다. 6강에서 정답을 아는 질문 여섯 개로 자르기를 채점한 것과 같은 방법이다.
3. 갈래 ① 자료에 아예 없다
answer, sources = ask("블로그 글을 몇 편 긁어왔나")
- [근거] git-10-commit-message / ai-eng-05-paragraph-chunking / dev-25-error-handling-ux
- 자료에서 직접 블로그 글의 수를 언급한 것은 없습니다. 자료는 그저 일기장
- 조각들을 다루고 있습니다. 따라서 블로그 글의 수에 대한 정보가 제공되지
- 않았습니다.
답은 264편인데 우리가 그걸 어디에도 안 써뒀다. 강의 원고를 색인한 것이지 작업 기록을 색인한 게 아니다.
없는 것을 못 찾은 것은 정상 동작이다. 실무에서 「챗봇이 이걸 모르네요」라는 말을 들으면 제일 먼저 확인할 것이 이것이다 — 자료에 있기는 한가. 고치는 방법은 자료를 넣는 것 하나뿐이다.
같은 갈래인데 결과가 다른 경우를 하나 보고 간다.
- 이 자료에는 서울과 부산 간 이동 시간에 대한 정보는 포함되어 있지 않습니다.
- 따라서, 이 문제에 답변을 제공하기 위해서는 추가적인 자료 또는 정보가
- 필요합니다. 일반적으로 경유를 통해 최단 시간으로 계산하면 서울에서
- 부산까지 약 3시간 정도 걸립니다.
모델은 자료에 없다는 것까지는 정확히 알았는데 거기서 멈추지 않았다. 3시간은 대충 맞는 값이라 티가 안 나고, 사용자에게는 자료에서 온 것처럼 보인다. 멈추게 만드는 것이 다음 편 주제다.
4. 갈래 ② 자료엔 있는데 검색이 못 데려왔다
answer, sources = ask("우리가 자른 조각의 크기는 몇 자인가")
정답은 200자다. 9강에서 정하고 14강부터 계속 쓰고 있다.
- [근거] ai-eng-02-functions-and-ranking / ai-eng-06-measuring-chunks / ai-eng-06-measuring-chunks
- 자료에서 언급된 조각 크기별 점수와 길이 정보를 종합하면, 우리가 자른
- 조각의 최대 길이는 69자이고, 최소는 6자입니다. 이는 다음과 같이
- 정리할 수 있습니다:
- - 6자: 최저 점수(0.21점)
- - 43자: 0.36점
- - 69자: 0.64점
- 따라서 우리가 자른 조각의 크기는 최대 69자입니다.
검색이 데려온 것은 6강의 「6자 0.21점 · 43자 0.36점 · 69자 0.64점」이었다. 전부 진짜 우리 문서인데 질문과 상관없는 대목이다. size=200 이 든 조각은 rank_of() 로 재보니 23위였고, k=3 이라 안 들어갔다. 모델은 본 적도 없다.
여기서 「정확한 숫자를 말해라」 · 「추측하지 마라」를 아무리 붙여도 23위에 있는 조각은 안 들어온다. 고칠 자리는 검색이다.
5. 갈래 ③ 검색은 1위로 맞췄는데 모델이 못 읽었다
이게 오늘 제일 중요한 대목이다.
answer, sources = ask("겹침을 조각 크기와 같게 주면 어떻게 되나")
정답은 9강에 「겹침을 조각 크기까지 키우면 아예 멈춘다」로 적혀 있고, 그 문장이 든 조각이 검색 1위로 들어왔다.
- [근거] ai-eng-04-overlap (1위 · 정답 문장이 이 안에 있다)
- [0] 겹침이 발생하지 않고 모든 조각은 그대로 유지됩니다
- [1] 겹침의 수가 늘어나면서 조각의 수도 증가하게 됩니다
- [2] 겹칠 조각이 사라지고 모든 글자들이 정확히 하나의 조각에
- [3] 조각의 양이 크게 늘어나게 되는 편이다
- [4] 겹침이 사라지면서 한 조각에는 모든 내용이 들어가는
「멈춘다」고 말한 답이 하나도 없다. 그 문장이 근거 안에 그대로 들어 있는데도 그렇다.
혹시 다른 조각 둘이 방해한 걸까. 정답 조각 하나만 손으로 넣어서 다시 물어본다.
# (25_diagnose.py 의 위쪽을 그대로 복사해 온다 — data, model)
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
# 검색을 아예 빼고, 정답이 든 조각만 골라 넣는다
i = [j for j in range(len(texts)) if "키우면 아예 멈춘다" in str(texts[j])][0]
context = str(texts[i])
question = "겹침을 조각 크기와 같게 주면 어떻게 되나"
prompt = f"아래 자료를 읽고 질문에 답해라.\n\n[자료]\n{context}\n\n[질문]\n{question}\n"
for n in range(3):
res = client.chat.completions.create(
model="qwen2.5:3b",
messages=[{"role": "user", "content": prompt}],
)
print(f"[{n}]", res.choices[0].message.content[:70])
- [0] 겹침을 조각의 크기에 맞춰 설정할 경우, 겹칠 수 있는 부분이 늘어나게 됩니다
- [1] 겹침을 조각 크기와 같은 수준으로 설정하면, 조각끼리 겹칠 수 있는 부분이
- [2] 겹침을 조각 크기와 같은 크기로 만드는 경우, 조각끼리 겹치는 현상이
근거를 1위로 데려왔고, 방해물도 치웠고, 정답 문장이 눈앞에 있다. 그래도 못 읽는다. 3b 모델의 한계다 — 「키우면 아예 멈춘다」를 붙잡지 못하고 앞에 있는 「조각 수가 늘어난다」에 끌려간다.
이 갈래에 해당하면 프롬프트를 고칠 게 아니라 모델을 갈아야 한다. 다음 주 월요일에 상용 API 로 바꾸고 이 질문을 그대로 다시 던진다 — 오늘 값이 있어야 그날 비교가 된다.
6. 덤으로 나오는 두 가지
실습 중에 반드시 보게 되는 것이 둘 있다.
첫째, 매번 답이 다르다. 위 다섯 번이 전부 다른 문장이었다. 모델은 다음 글자를 확률로 고르기 때문이고, 그래서 「어제는 잘 됐는데」가 성립한다. 재현되게 만드는 것이 내일 주제다.
둘째, 말이 섞인다. 「오늘 점심 뭐 먹지」를 물었더니 중국어로 답했다. 영어 조각이 끼는 것도 흔하다. 둘 다 코드 잘못이 아니라 작은 모델을 쓰기로 한 대가다.
7. 🔴 점수는 좋은 검색과 나쁜 검색을 안 갈라준다
마지막으로 오늘 제일 불편한 것을 본다.
# (25_diagnose.py 의 위쪽을 그대로 복사해 온다 — data, model)
for q in ["조각을 겹치게 자르는 이유가 뭔가", "겹쳐서 자르는 이유", "오늘 점심 뭐 먹지"]:
scores = vecs @ model.encode([q], normalize_embeddings=True)[0]
print(f"{q:24s} 1위 {scores.max():.3f} 평균 {scores.mean():.3f}")
- 조각을 겹치게 자르는 이유가 뭔가 1위 0.903 평균 0.807
- 겹쳐서 자르는 이유 1위 0.887 평균 0.812
- 오늘 점심 뭐 먹지 1위 0.881 평균 0.818
「오늘 점심 뭐 먹지」의 1위는 Git 편 24강 풀리퀘스트의 「드디어 남과 하는 깃입니다.」였다. 아무 상관이 없는데 점수는 제대로 된 질문의 2위와 똑같다.
이 임베딩은 모든 한국어 문장 쌍에 0.7~0.9 를 준다. 절대값에는 뜻이 없고 같은 질문 안에서의 순서에만 뜻이 있다. 그래서 「0.8 넘으면 통과」 같은 문턱을 세우면 아무거나 다 통과한다.
이번 편에 나온 것
| 쓴 것 · 안 것 | 내용 |
|---|---|
| 진단 순서 | 자료에 있나 → 검색이 데려왔나 → 모델이 읽었나 |
rank_of(질문, 정답글자) | 정답 조각이 몇 위였나. 수요일 평가셋의 씨앗 |
enumerate(목록, 1) | 1부터 센다. 순위를 매길 때 쓴다 |
| 갈래 ① | 자료에 없다 → 자료를 늘린다. 코드로는 못 고친다 |
| 갈래 ② | 23위라 못 들어왔다 → 검색을 고친다(수요일) |
| 갈래 ③ | 1위인데 못 읽었다 → 모델을 갈아야 한다(다음 주) |
| 근거 하나만 넣어보기 | 갈래 ②·③ 을 가르는 결정적 실험 |
| 점수의 절대값 | 뜻이 없다. 무관한 질문도 0.881 을 받는다 |
다음 편에서 갈래 ③ 을 프롬프트로 밀어본다.