학습 자료

답이 이상하다 — 고치기 전에 이름을 붙인다


Article

앞 편에서 RAG 를 완성했고, 답 하나가 뒤집혀 있는 것을 봤다. 오늘은 아무것도 안 고친다. 고칠 자리를 찾는다.

오늘 여섯 시간 중 지금
  1. ①② 모델을 얹고 부른다끝남
  2. ③ 검색기와 붙인다끝남
  3. ④ 답이 이상하다지금 여기 · 진단
  4. ⑤⑥ 고치고 잰다다음

답이 이상할 때 제일 흔한 대처가 프롬프트를 만지는 것이다. 그런데 오늘 볼 실패 중 프롬프트로 고쳐지는 것은 절반도 안 된다. 어디가 고장났는지 모르는 채로 고치면 고친 줄 알고 넘어간다. 그게 제일 나쁘다.

1. 진단은 세 번 묻는 것이다

RAG 는 부품이 셋이다. 답이 틀렸으면 셋 중 하나가 틀린 것이고, 순서대로 물어보면 갈린다.

답이 틀렸을 때 이 순서로 묻는다
  1. ① 자료에 답이 있나없으면 → 갈래 ①. RAG 문제가 아니다
  2. ② 검색이 데려왔나못 데려왔으면 → 갈래 ②. 검색을 고친다
  3. ③ 모델이 읽었나데려왔는데 틀렸으면 → 갈래 ③. 모델 문제다
이 순서를 거꾸로 하면 없는 답을 찾아 프롬프트만 백 번 고치게 된다.
세 갈래와 고칠 자리
갈래증상고치는 곳언제 배우나
① 자료에 없다모른다고 하거나 지어낸다자료를 늘린다. 코드로는 못 고친다
② 검색이 못 찾았다엉뚱한 근거를 물고 답한다검색을 고친다수요일 하루 전체
③ 모델이 못 읽었다근거는 맞는데 답이 틀리다프롬프트 · 더 큰 모델다음 편 · 다음 주 월요일
고치는 곳이 전부 다르다. 그래서 가르는 것이 먼저다.

2. 진단 도구를 먼저 만든다

「검색이 데려왔나」를 눈으로 확인하려면 정답이 몇 위였는지 알아야 한다.

25_diagnose.py
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위로 들어왔다.

터미널 — 같은 질문 5번
  • [근거] ai-eng-04-overlap (1위 · 정답 문장이 이 안에 있다)
  • [0] 겹침이 발생하지 않고 모든 조각은 그대로 유지됩니다
  • [1] 겹침의 수가 늘어나면서 조각의 수도 증가하게 됩니다
  • [2] 겹칠 조각이 사라지고 모든 글자들이 정확히 하나의 조각에
  • [3] 조각의 양이 크게 늘어나게 되는 편이다
  • [4] 겹침이 사라지면서 한 조각에는 모든 내용이 들어가는

「멈춘다」고 말한 답이 하나도 없다. 그 문장이 근거 안에 그대로 들어 있는데도 그렇다.

혹시 다른 조각 둘이 방해한 걸까. 정답 조각 하나만 손으로 넣어서 다시 물어본다.

26_isolate.py
# (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. 🔴 점수는 좋은 검색과 나쁜 검색을 안 갈라준다

마지막으로 오늘 제일 불편한 것을 본다.

27_score_is_useless.py
# (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 을 받는다

다음 편에서 갈래 ③ 을 프롬프트로 밀어본다.

Share
  • 파이썬
  • AI
  • LLM
  • RAG
  • 디버깅