hmy751.dev

프롬프트로 환각을 잡으려다 Context를 다시 보게 됐다

첫 스프린트에서는 모델을 학습시키고 실험 결과를 읽는 법을 배웠습니다. accuracy만 보면 놓치는 것이 있었고, loss와 gap을 같이 보면서 실험을 다시 해석해야 했습니다.

두 번째 스프린트의 주제는 RAG였습니다. 앞에서는 모델 자체를 조정했다면, 이번에는 모델 밖에 있는 문서를 찾아서 답변에 넣어주는 구조를 만들었습니다.

처음에는 조금 더 익숙한 문제처럼 느껴졌습니다. 데이터를 저장하고, 검색하고, 응답을 조립하는 흐름은 프론트엔드나 제품 개발에서 다루던 데이터 흐름과도 닮아 있었습니다.

그런데 막상 답변을 보면 문제가 다른 곳에서 튀어나왔습니다. 문서에 없는 내용을 그럴듯하게 말하거나, 질문과 관련이 약한 문서를 보고 엉뚱한 답을 했습니다. 처음에는 자연스럽게 프롬프트를 의심했습니다.

모델에게 더 강하게 말하면 될 것 같았습니다. "문서에 없는 내용은 말하지 말라", "관련 없는 Context는 무시하라", "모르면 모른다고 답하라" 같은 규칙을 넣으면 환각이 줄어들 것 같았습니다.

하지만 실험을 해보니 문제는 그렇게 단순하지 않았습니다. 프롬프트는 마지막 안전장치일 수는 있어도, 잘못 들어온 Context를 항상 복구해주지는 못했습니다. 이번 스프린트에서 가장 크게 남은 것은 이 지점이었습니다.

RAG에서 답변 품질은 "모델에게 어떻게 말할 것인가"보다 먼저, "모델에게 무엇을 보여줄 것인가"에서 결정되는 경우가 많았습니다.

이 글은 그 질문이 어떻게 프롬프트에서 검색과 Context 설계로 옮겨갔는지에 대한 기록입니다. 환각을 줄이는 방법을 하나로 정리하기보다, 답변 전에 어떤 입력이 만들어지는지 확인해가는 과정에 가깝습니다.

프롬프트를 먼저 의심했다

RAG의 기본 흐름은 단순하게 볼 수 있습니다. 문서를 일정한 크기로 나누고, 각 조각을 embedding으로 바꾼 뒤, 사용자의 질문과 가까운 조각을 찾아 LLM에 함께 전달합니다. 그러면 LLM은 질문과 Context를 보고 답변을 생성합니다.

먼저 답변이 나오기 전까지의 흐름을 펼치면 이렇습니다. 프롬프트는 이 흐름의 뒤쪽에서 작동합니다.

text
질문
  -> 검색: 질문과 가까운 chunk 후보를 찾는다
  -> Context 조립: threshold, top-k, chunk size가 실제 입력을 결정한다
  -> LLM: 주어진 Context를 보고 답변을 생성한다
  -> 답변: 정답, 거절, 환각으로 드러난다

환각은 마지막 답변에서 보이지만, 그 답변의 재료는 이미 앞단에서 정해지고 있었습니다. 그래서 프롬프트를 고치기 전에 검색과 Context 조립을 같이 봐야 했습니다.

처음 구현했을 때도 구조 자체는 이런 방향이었습니다. 문서를 chunk로 나누고, Supabase에 embedding을 저장하고, 질문이 들어오면 유사도가 높은 chunk를 가져와 답변에 넣었습니다.

문제는 답변이었습니다. 검색 결과가 비어 있거나 약한데도 모델은 뭔가를 말하려고 했습니다. 관련 문서가 들어왔더라도 핵심 조건을 놓치거나, 질문과 조금 다른 문서의 표현을 가져와 답했습니다.

이 장면에서 처음 떠올린 해결책은 프롬프트였습니다. LLM이 마지막에 문장을 만들기 때문에, 답변의 실패도 프롬프트에서 고칠 수 있을 것처럼 보였습니다.

그래서 시스템 프롬프트에 규칙을 넣었습니다.

  • 제공된 Context를 우선 사용한다.
  • Context에 없는 내용은 추측하지 않는다.
  • 질문과 무관한 Context는 무시한다.
  • 근거가 부족하면 답변하지 않는다.

이 규칙들은 틀리지 않았습니다. 실제로 어느 정도는 필요했습니다. 하지만 이것만으로는 충분하지 않았습니다.

특히 "질문과 무관한 Context는 무시하라"는 규칙은 기대만큼 작동하지 않았습니다. 모델에게 "무시하라"고 말해도, 모델 앞에 놓인 입력이 애초에 애매하면 답변도 애매해졌습니다.

좋은 문서 하나와 관련 없는 문서 여러 개가 섞이면, 모델은 그중 어느 것을 더 믿어야 하는지 흔들렸습니다. 아예 문서가 없을 때는 "답할 수 없다"고 해야 하지만, 질문의 일반 지식으로 답을 만들어내는 경우도 있었습니다.

여기서 질문이 조금 달라졌습니다. 프롬프트를 더 세게 쓰는 대신, 먼저 어떤 Context가 LLM 앞에 도착하는지 봐야 했습니다.

답변만으로는 원인을 나눌 수 없었다

답변만 보고 있으면 실패 원인을 분리하기 어렵습니다. 검색이 나빴는지, 프롬프트가 약했는지, 모델이 Context를 잘못 읽었는지 한꺼번에 섞여 보입니다.

text
답변 실패
  -> 검색 문제: 필요한 chunk가 안 들어왔다
  -> Context 조립 문제: 근거와 잡음이 같이 들어왔다
  -> 생성 문제: 같은 Context를 다르게 해석했다

실험 로그에서는 검색 결과를 더 자세히 봤습니다. 질문마다 어떤 chunk가 후보로 나왔는지, 유사도는 얼마였는지, threshold를 넘어서 실제 Context에 들어간 chunk는 몇 개인지 확인했습니다.

그래서 답변마다 네 가지를 같이 붙였습니다.

  • 질문이 어떤 유형인지: 단순 사실, 조건부 판단, 문서 밖 질문, 모호한 질문.
  • 어떤 설정으로 검색했는지: chunk size, overlap, top-k, threshold.
  • 어떤 chunk가 Context에 들어갔는지: 유사도와 통과 여부.
  • 최종 답변이 어디서 틀렸는지: 사실이 틀렸는지, 근거가 틀렸는지, 거절해야 할 때 답했는지.

질문과 설정, 검색 후보, 통과 여부, 최종 답변을 한 묶음으로 보니 프롬프트 문제와 검색 문제를 조금씩 나눌 수 있었습니다.

이 과정도 한 번에 정리된 것은 아니었습니다. 처음에는 프롬프트 규칙을 바꾼 뒤 검색 파라미터를 몇 개 비교했습니다. 나중에는 실험 JSON을 다시 묶어 통과한 chunk와 걸러진 chunk, similarity를 더 자세히 봤습니다.

처음 정리에서 먼저 눈에 들어온 것은 threshold였습니다. 유사도 기준을 높이면 관련 없는 문서를 걸러낼 수 있을 것 같았습니다. 하지만 기준을 0.7에서 0.9로 올렸을 때 정확도는 92%에서 67%로, 근거성은 75%에서 58%로 떨어졌습니다.

질문에 필요한 근거가 항상 0.9 이상의 유사도로 잡히는 것은 아니었습니다. 이후 로그를 다시 묶어 보니 질문은 조금 더 구체화됐습니다. 기준을 0.9까지 올리는 것이 문제였을 뿐 아니라, 당시 사용한 embedding 모델에서는 0.7도 꽤 높은 기준일 수 있었습니다.

통과한 chunk가 거의 없으면 이상적인 답변은 "근거가 부족하다"입니다. 하지만 모델은 항상 그렇게 행동하지 않았습니다. 질문 자체의 표현이나 일반 지식을 바탕으로 답을 만들어내기도 했습니다.

반대로 threshold를 낮추면 Context는 채워졌습니다. 0.7에서 0.5로 낮추자 탈락하던 chunk가 들어오기 시작했고, 0.4까지 낮추자 근거성은 더 좋아졌습니다.

하지만 이번에는 다른 문제가 남았습니다. 관련도가 낮은 chunk까지 함께 들어왔고, 조건부 질문에서는 LLM이 같은 검색 결과를 보고도 답을 다르게 내기도 했습니다. 검색 결과가 비어도 문제였고, 너무 넓게 채워져도 문제였습니다.

결국 threshold는 단순히 "높을수록 안전"하거나 "낮출수록 좋은" 값이 아니었습니다. 먼저 모델의 유사도 분포를 봐야 했고, 그다음 통과한 chunk와 걸러진 chunk가 최종 답변에 어떤 영향을 주는지 봐야 했습니다.

chunk 크기는 균형 문제였다

chunk 크기에서는 같은 문제가 다른 모습으로 나타났습니다. 문서를 크게 자르면 한 번에 더 많은 맥락이 들어갑니다. 조건이나 예외가 여러 문장에 걸쳐 있을 때는 큰 chunk가 유리할 수 있습니다.

하지만 큰 chunk에는 단점도 있었습니다. 질문에 필요한 문장과 주변 설명이 함께 묶이면서, embedding의 초점이 흐려질 수 있었습니다. 질문과 직접 관련된 문장은 들어 있지만, chunk 전체로 보면 다른 내용도 많이 섞인 상태가 됩니다.

반대로 chunk를 작게 자르면 검색 초점은 선명해질 수 있습니다. 질문과 정확히 맞는 조각을 찾기 쉬워집니다. 대신 조건이 앞뒤 문장에 나뉘어 있을 때는 필요한 맥락이 잘릴 수 있습니다.

초기 비교에서는 이 단점이 더 크게 보였습니다. chunk size를 500에서 300으로 줄였을 때는 문맥이 끊기면서 답변이 반대 방향으로 기울었습니다.

예를 들어 어떤 문장에서는 "대면"이라는 표현이 면접 방식에 대한 설명이었습니다. 그런데 문맥이 잘리자 모델은 그것을 근무 방식의 근거처럼 읽었습니다. 문서에 없는 질문에 "확인할 수 없다"고 멈춰야 했는데, 잘린 문장을 근거로 답을 만들어낸 셈입니다.

chunk size를 200까지 줄였을 때는 다른 문제가 생겼습니다. 같은 표현이 서로 다른 맥락에 등장했는데, 예외 조건과 단순 나열이 다른 chunk로 갈라졌습니다. 모델은 한쪽만 보고 조건을 반대로 해석했습니다. 정보가 없어서 모른다고 한 것이 아니라, 절반만 보고 확신 있게 틀린 답을 한 것이었습니다.

나중에 로그를 다시 보면 작은 chunk가 항상 나쁜 것도 아니었습니다. 어떤 질문에서는 300짜리 chunk가 더 정확한 조각을 잡아냈습니다.

대신 전체 안정성은 흔들렸습니다. 반대로 chunk를 800이나 1000까지 키우면 조건이 한 청크 안에 들어오는 경우도 있었지만, 잡음도 함께 커졌습니다.

처음에는 chunk size를 성능을 올리는 하이퍼파라미터처럼 봤습니다. 실험을 해보니 이것도 더 정확히는 책임 범위를 정하는 문제였습니다.

LLM에게 넘길 Context는 충분히 넓어야 하지만, 아무거나 넓어서는 안 됩니다. 문서의 의미 단위가 끊기지 않을 만큼은 커야 하고, 질문과 상관없는 내용이 많이 섞이지 않을 만큼은 작아야 합니다.

이때부터 chunk size는 하나의 정답값이라기보다 정밀도와 맥락 사이의 균형점으로 보이기 시작했습니다.

이 균형은 프롬프트가 대신 잡아줄 수 없었습니다. chunk를 어떻게 자를지, top-k를 몇 개로 둘지, threshold를 어디에 둘지에 따라 모델 앞에 놓이는 세계가 달라졌습니다. 같은 LLM을 써도, 그 앞에 놓인 Context가 다르면 답변의 성격도 달라졌습니다.

top-k: 많이 넣는다고 좋은 건 아니다

검색 결과를 더 많이 넣으면 답변이 좋아질 것처럼 느껴집니다. 모델에게 자료를 더 많이 주면 놓치는 내용이 줄어들 것 같기 때문입니다.

실제로는 RAG에서 더 많은 Context가 항상 더 좋은 Context는 아니었습니다. top-k를 늘리면 정답 근거가 포함될 가능성은 올라가지만, 동시에 관련 없는 근거도 함께 들어올 수 있습니다. 그러면 모델은 답변을 만들 때 더 많은 선택지를 받습니다.

이때 "관련 없는 Context는 무시하라"는 프롬프트가 다시 등장합니다. 하지만 검색 단계에서 이미 잡음이 많이 들어온 상태라면, LLM에게는 어려운 문제가 됩니다. 검색기가 걸러야 할 일을 생성 모델에게 미루는 셈입니다.

초기 비교에서는 chunk 크기와 threshold를 유지한 채 top-k를 5에서 3으로 줄였습니다. 정확도는 92%로 유지됐고, 근거성은 75%에서 83%로 올라갔습니다. 실패율은 50%에서 33%로 내려갔습니다.

더 적게 넣었는데 더 좋아진 셈입니다. 이 결과를 보고 top-k를 단순한 양의 문제가 아니라 품질과 책임의 문제로 보게 됐습니다.

이후 정리한 실험에서도 top-k 3은 다음 실험의 기본 설정처럼 쓰였습니다. 다만 그때는 threshold와 chunk size도 함께 다시 보고 있었기 때문에, top-k 하나만으로 모든 문제가 해결됐다고 보기는 어려웠습니다.

물론 top-k를 줄이는 것도 공짜는 아니었습니다. 질문이 여러 조건을 함께 요구하면 3개 chunk만으로는 필요한 근거가 빠질 수 있었습니다. 실제로 모호한 질문에 답하려면 여러 조건을 종합해야 했는데, top-k 3에서는 일부 조건이 빠질 수 있었습니다. 그래서 다음 후보는 top-k 4처럼 보였습니다.

중요한 것은 "많이 넣기"도 "적게 넣기"도 아니었습니다. 검색기가 답변에 필요한 재료와 잡음을 어디까지 나눌 수 있는지가 핵심이었습니다.

여기까지 실험을 정리하면, 조정한 값들은 모두 같은 질문으로 모였습니다.

레버 흔들린 지점 다시 잡은 기준
threshold 높이면 안전할 줄 알았지만, 필요한 근거도 같이 빠졌습니다. 유사도 분포와 통과/탈락 chunk를 같이 본다.
chunk size 작으면 문맥이 갈리고, 크면 잡음이 섞였습니다. 의미 단위를 보존하되 불필요한 내용을 줄인다.
top-k 많이 넣으면 정답 근거와 잡음이 함께 늘었습니다. 개수보다 검색 재료의 품질을 먼저 본다.

이 표에서 중요한 것은 세 값이 모두 같은 질문으로 돌아온다는 점이었습니다. 모델 앞에 놓이는 Context를 어디까지 좁히고 어디까지 열어둘 것인가였습니다.

그래서 답변을 잘하게 하려면 LLM에게 "잘 답하라"고 말하는 것만으로는 부족했습니다. LLM이 읽을 수 있는 재료를 먼저 정리해야 했습니다. 검색은 답변 이전의 문제처럼 보였지만, 실제로는 답변 품질의 일부였습니다.

같은 검색 결과, 다른 이름

프롬프트가 완전히 무의미했다는 뜻은 아닙니다. 오히려 이번 스프린트에서 프롬프트를 다시 보게 된 지점도 있었습니다.

검색 결과를 시간순으로 다시 정리하면서 이상한 장면도 하나 더 보였습니다. 같은 설정으로 다시 실행했는데, 조건부 질문에서 한 번은 "지원 불가"라고 답하고, 다른 한 번은 "지원 가능"이라고 답했습니다.

검색된 chunk와 similarity는 같았습니다. 재료가 같아도 LLM이 어느 표현에 더 무게를 두는지에 따라 답이 갈릴 수 있었습니다.

그래서 다음에는 검색 결과를 그대로 두고, 프롬프트 안에서 그 결과를 무엇이라고 부르는지만 바꿔봤습니다.

검색으로 가져온 내용을 "문서"라고 부를 때와 "Context"라고 부를 때 모델의 반응은 달라졌습니다. 같은 검색 결과에서 정확도는 67%에서 83%로 올라갔습니다.

처음에는 사소한 표현 차이처럼 보였습니다. 하지만 생각해보면 LLM에게 프롬프트는 입력의 인터페이스입니다. 개발자가 변수명을 보고 값의 역할을 추론하듯, 모델도 프롬프트 안의 이름과 지시를 통해 입력의 성격을 읽습니다.

"문서"라는 말은 독립적인 자료처럼 들릴 수 있고, "Context"는 답변을 만들기 위해 참고해야 하는 주변 근거처럼 들릴 수 있습니다.

이 차이가 항상 같은 방향으로 작동한다고 단정할 수는 없습니다. 단일 실행의 한계도 있었습니다. 그래도 적어도 검색 결과를 프롬프트에 붙이는 방식이 중립적이지는 않다는 것을 보여줬습니다.

이때의 차이는 프롬프트를 더 강하게 쓰는 문제가 아니었습니다.

text
문서: 참고할 수 있는 자료
Context: 답변을 만들 때 사용해야 하는 근거

이렇게 보니 프롬프트의 역할도 다시 나눠 볼 수 있었습니다.

프롬프트는 검색 실패를 마법처럼 고치는 장치가 아닙니다. 하지만 검색 결과가 어떤 역할로 들어왔는지 모델에게 알려주는 인터페이스는 됩니다. 따라서 프롬프트를 고친다는 것은 말투를 세게 하는 일이 아니라, 입력의 책임과 이름을 맞추는 일이었습니다. 프롬프트가 할 일과 검색이 할 일을 나눠야 했습니다.

거절과 환각의 분리

이 과정에서 하나 더 구분해야 했던 것이 있습니다. 답하지 않는 것과 거짓으로 답하는 것은 같은 실패가 아니었습니다.

문서에 근거가 없는데도 그럴듯한 답을 만들면 환각입니다. 반대로 근거가 부족해서 답하지 않는 것은 경우에 따라 올바른 동작입니다. 그런데 둘을 같은 실패로 묶으면 threshold를 낮춰서 어떻게든 답하게 만들고 싶어집니다. 그러면 검색 결과에는 더 많은 Context가 들어오지만, 그만큼 잡음도 같이 들어옵니다.

RAG에서는 "답을 못 했다"와 "없는 답을 만들었다"를 분리해서 봐야 했습니다. 하나는 검색 범위나 질문 설계의 문제일 수 있고, 다른 하나는 근거 없는 생성을 막는 문제입니다. 이 구분을 해야 근거가 없을 때 멈추는 기준을 세울 수 있었습니다.

답변 생성보다 Context 설계

이번 스프린트 전에는 RAG를 "문서를 붙여서 답변을 잘하게 만드는 방법" 정도로 이해했습니다. 틀린 말은 아니지만, 실제로 구현해보니 그 설명은 너무 뒤쪽만 보고 있었습니다.

답변은 마지막에 나옵니다. 하지만 답변이 의존하는 것은 그 전에 정해집니다.

  • 문서를 어떤 단위로 자를 것인가.
  • embedding은 어떤 모델로 만들 것인가.
  • 질문과 chunk의 유사도를 어떤 기준으로 볼 것인가.
  • threshold 아래의 후보를 버릴 것인가.
  • top-k를 몇 개로 둘 것인가.
  • 검색 결과를 프롬프트 안에서 무엇이라고 부를 것인가.
  • 근거가 없을 때 답변을 멈추는 기준을 어디에 둘 것인가.

이 선택들은 모두 LLM 바깥에 있습니다. 하지만 최종 답변을 크게 바꿉니다.

그래서 RAG를 하면서 가장 중요했던 변화는 "LLM이 왜 말을 이상하게 하지?"에서 "LLM 앞에 어떤 상태를 만들어 놓았지?"로 질문이 옮겨간 것이었습니다.

프론트엔드 작업에서 버그를 볼 때도 비슷한 감각이 있습니다. 화면에 잘못된 값이 보이면 렌더링 컴포넌트만 보지 않습니다. 서버 응답, 캐시, 상태 업데이트, 파생 데이터, UI 표시 규칙을 나눠 봅니다. RAG에서도 답변이라는 화면 뒤에 검색, 필터링, chunking, prompt assembly가 있었습니다.

처음에는 환각을 프롬프트로 잡으려 했습니다. 하지만 실험을 지나며 환각의 일부는 "잘못 말한 문제"가 아니라 "잘못 보여준 문제"라는 것을 알게 됐습니다.

로그가 남긴 기준

두 번째 스프린트에서 RAG를 완전히 해결한 것은 아니었습니다. 대신 문제를 더 많이 나누게 됐습니다.

검색 결과가 나쁘면 답변도 흔들립니다. 하지만 답변이 나쁘다고 해서 항상 검색만 문제인 것도 아닙니다. 검색 결과를 프롬프트에 어떻게 넣는지, 근거가 없을 때 어떻게 멈추는지, 어떤 로그로 실패 원인을 분리하는지도 같이 봐야 했습니다.

이 판단이 가능했던 것도 로그를 남기기 시작한 뒤였습니다. 초반에는 similarity 값을 제대로 남겨두지 않아 "아마 이 설정 때문일 것 같다"는 추측만 남은 적이 있었습니다. 이후에는 입력, 설정, 검색 결과, 생성 답변, 판단을 함께 기록했습니다.

답변만 남기면 RAG 실험은 금방 감상문이 됩니다. 어떤 Context가 들어갔는지 남겨야 실패 원인을 레이어별로 나눌 수 있었습니다.

첫 스프린트에서 accuracy만으로 실험을 읽지 않게 됐다면, 두 번째 스프린트에서는 프롬프트만으로 RAG를 읽지 않게 됐습니다. 답변은 마지막 문장이지만, 문제는 그 문장 앞에 놓인 Context에서 이미 시작되고 있었습니다.