RSS

RAG 챗봇이 "그건 언제 바뀐 거야?"에 답하게 만들기

왜 이 주제가 중요한가

사내 문서를 근거로 답하는 RAG 챗봇을 도입하면 대부분 비슷한 지점에서 막힙니다. 첫 질문에는 그럴듯하게 답하는데, 이어서 "그럼 그건 언제 바뀐 거야?" 같은 질문을 던지면 갑자기 엉뚱한 문서를 근거로 들고 옵니다. 사용자는 "AI가 헛소리를 한다"고 느끼고, 도입은 거기서 멈춥니다.

이 문제는 모델을 더 큰 것으로 바꾼다고 해결되지 않습니다. 원인이 답변 생성이 아니라 그 앞의 검색 단계에 있기 때문입니다. 이 글에서는 단발성 질의응답을 대화로 바꿀 때 실제로 무엇을 해야 하는지, 그리고 그 대가로 무엇을 내주게 되는지를 함께 정리했습니다. 잘 동작한 부분만 적으면 도입을 검토하는 입장에서는 판단할 근거가 부족하기 때문입니다.

후속 질문이 깨지는 진짜 이유는 검색입니다

멀티턴을 지원한다고 하면 보통 이전 대화를 LLM 프롬프트에 함께 넣는 것을 떠올립니다. 그것만으로는 절반만 해결됩니다. RAG에는 LLM 호출 앞에 검색 단계가 있고, 진짜 문제는 거기에서 발생합니다.

"그럼 그건 언제 바뀐 거야?"를 그대로 임베딩하면 어떤 벡터가 나올지 생각해 보시면 됩니다. 이 문장에는 검색에 쓸 만한 명사가 하나도 없습니다. 지시어와 시제만 남아 있습니다. 그런데 벡터 검색은 언제나 "가장 가까운 것"을 돌려주기 때문에 결과가 비어서 오지 않습니다. 대신 "변경 이력", "일정 변경"처럼 질문의 형태만 닮고 내용은 무관한 문단이 상위에 올라옵니다. 그리고 LLM은 그 엉뚱한 근거를 성실하게 요약해 줍니다.

정리하면, 대화 맥락은 답변 생성뿐 아니라 검색에도 주입되어야 합니다. 이 점을 놓치면 프롬프트를 아무리 다듬어도 후속 질문의 정확도가 올라가지 않습니다.

해결책: 질문을 독립 문장으로 다시 씁니다

검색을 실행하기 전에 작은 LLM 호출을 하나 끼워 넣어, 이전 대화를 참고해 후속 질문을 혼자서도 이해되는 한 문장으로 바꿉니다. 흔히 query rewriting 또는 condensation이라고 부르는 기법입니다.

[이전 대화]
사용자: 배포 절차 문서 요약해줘
AI: 변경 사항을 기본 브랜치에 반영하면 자동으로 검증과 배포가 진행됩니다 …

[마지막 질문]
그럼 그건 언제 바뀐 거야?

→ 재작성: 배포 절차 문서가 마지막으로 수정된 시점

여기서 중요한 점이 하나 있습니다. 재작성된 문장은 검색에만 사용하고, LLM에 최종적으로 건네는 질문은 사용자가 실제로 입력한 원문 그대로 두어야 합니다. 재작성문을 질문으로까지 대체하면 모델이 사용자의 어투와 미묘한 의도를 잃어버립니다.

프롬프트에서 효과가 컸던 규칙은 세 가지였습니다.

  • 지시어를 이전 대화에 등장한 실제 이름으로 치환하도록 지시합니다.
  • 질문의 범위를 넓히거나 없던 조건을 지어내지 않도록 못 박습니다.
  • 이미 혼자서도 이해되는 질문이면 그대로 돌려주도록 명시합니다.

세 번째 규칙이 없으면 모델이 멀쩡한 첫 질문까지 "개선"하려 들면서 원래 없던 조건을 덧붙입니다.

또한 이 단계는 실패해도 되도록 설계하는 편이 좋습니다. 타임아웃, 빈 응답, 설명을 곁들인 장황한 응답은 모두 원래 질문으로 폴백합니다. 재작성은 검색 품질을 높이는 보조 장치이지 필수 경로가 아닙니다. 여기에서 예외를 위로 던지면 부가 기능 때문에 대화 자체가 끊깁니다.

const rewritten = body.message?.content?.trim().split("\n")[0];
// 모델이 장황하게 답하거나 빈 문자열을 주면 원문이 더 안전합니다.
if (!rewritten || rewritten.length > 200) return question;

대가: 첫 응답이 늦어집니다

이 구조에는 공짜가 아닌 부분이 있습니다. 도입을 검토하신다면 이 절이 가장 중요합니다.

질문마다 LLM 왕복이 하나씩 늘어납니다. 재작성 응답 자체는 짧지만, 지연은 출력 토큰 수에 비례하지 않습니다. 모델 로딩과 프롬프트 처리가 고정비로 붙기 때문입니다. 게다가 이 호출은 검색보다 앞에 있어서 이후 파이프라인 전체가 그만큼 뒤로 밀립니다. 사용자가 체감하는 것은 "답변이 늦게 시작된다"입니다. 스트리밍 UI에서 첫 토큰이 늦어지는 것은 전체 소요 시간이 같은 폭으로 늘어나는 것보다 훨씬 답답하게 느껴집니다.

완화책은 이미 적용한 것과 아직 적용하지 않은 것을 구분해서 보시는 편이 좋습니다.

적용한 방법은 다음과 같습니다.

  • 대화 기록이 없는 첫 질문에서는 재작성을 건너뜁니다. 재작성할 맥락 자체가 없으므로 순수한 낭비입니다. 실제로 상당수의 대화가 한두 턴에서 끝나기 때문에 이것만으로도 많은 호출이 사라집니다.
  • 재작성에 넘기는 대화를 최근 몇 턴으로 제한합니다. 프롬프트가 길수록 처리 시간이 늘어나고, 오래된 턴은 지시어 해석에 도움이 되지도 않습니다.
  • 재작성된 검색어를 답변 위에 작게 표시합니다. 지연을 없애지는 못하지만 기다림의 이유가 보이면 체감이 달라지고, 왜 그 문서가 근거로 선택되었는지 확인하는 디버깅 수단으로도 그대로 쓰입니다.

아직 적용하지 않은 방법도 적어 둡니다.

  • 재작성만 더 작은 모델로 고정하는 방법이 있습니다. 이 작업은 요약이라기보다 대명사 치환에 가까워서 큰 모델이 필요하지 않습니다. 다만 모델을 하나 더 상주시키면 메모리를 추가로 사용합니다.
  • 지시어가 없는 질문은 규칙으로 걸러 재작성을 건너뛰는 방법도 있습니다. 문제는 특정 단어 목록으로 판별하는 방식이 생각보다 잘 맞지 않는다는 점입니다. 지시어 없이도 맥락에 의존하는 질문("가격은요?")이 많기 때문입니다.
  • 재작성과 원문 검색을 동시에 실행하고 먼저 도착한 쪽을 쓰는 방법도 있습니다. 지연은 줄지만 임베딩과 데이터베이스 질의가 두 배가 됩니다.

세 가지 모두 트레이드오프가 분명해서, 실제 사용 패턴에서 지연이 얼마나 거슬리는지 확인한 뒤에 선택하기로 했습니다. 측정 없이 최적화부터 하지 않는다는 원칙은 뻔하지만, 눈에 보이는 지연 앞에서는 유독 지키기 어렵습니다.

대화 기록은 서버가 단일 기준을 가져야 합니다

멀티턴을 붙이는 가장 손쉬운 방법은 클라이언트가 지금까지의 메시지 배열을 요청 본문에 실어 보내는 것입니다. 처음에는 그렇게 하려다가, 서버가 데이터베이스에서 직접 읽도록 바꿨습니다. 이유는 세 가지였습니다.

  • 같은 대화를 탭 두 개에서 열면 각 탭이 서로 다른 기록을 보냅니다. 어느 쪽이 진짜인지 서버가 판단할 방법이 없습니다.
  • 대화가 길어질수록 요청 본문이 계속 커집니다.
  • 클라이언트가 보낸 기록은 신뢰할 수 없습니다. 프런트엔드 버그 하나가 조용히 맥락을 오염시키는 경로가 됩니다.

서버가 대화 ID로 최근 메시지를 읽으면 기준이 하나로 고정되고 순서도 명확해집니다. 다만 사용자 메시지를 저장하기 전에 기록을 읽어야 한다는 점은 주의하셔야 합니다. 순서가 바뀌면 방금 입력한 질문이 "이전 대화"에 섞여 들어갑니다.

근거 선별에는 상대 기준이 필요합니다

처음에는 코사인 유사도 절대 임계값 하나로 근거를 걸렀습니다. 기준 미만이면 버리는 방식입니다. 이 방식의 약점은 질문마다 좋은 점수의 기준이 다르다는 점입니다.

딱 맞는 문서가 있어서 최상위 근거가 0.9로 나온 질문에서 0.45짜리 문단은 사실상 무관합니다. 그런데 절대 임계값은 그것을 통과시킵니다. 그 결과 프롬프트가 애매한 근거로 채워지고, 모델은 관련 없는 내용까지 답변에 엮습니다.

그래서 절대 기준에 상대 기준을 더했습니다. 최상위 근거보다 일정 폭 이상 유사도가 낮은 문단은 버립니다.

const topCosine = Math.max(...hits.map((hit) => hit.cosine ?? 0), 0);
const floor = Math.max(MIN_COSINE, topCosine - RELATIVE_COSINE_GAP);

강한 근거가 있으면 기준이 따라 올라가고, 전반적으로 애매한 질문에서는 절대 하한이 그대로 작동합니다. 다만 키워드가 실제로 일치한 결과는 유사도와 무관하게 살려 두는 편이 좋습니다. 고유명사나 제품 코드는 임베딩이 잘 잡지 못하지만 키워드 검색은 정확히 잡아내기 때문입니다.

멈출 수 있는 스트리밍 만들기

정지 버튼이 화면에 있는데 눌러도 아무 일이 일어나지 않는 상태였습니다. 버튼이 전송 버튼과 같은 submit 타입이었고 생성 중에는 비활성 상태였기 때문에, 아이콘만 정지 모양으로 바뀌고 클릭은 무시되고 있었습니다. 실제로 취소 신호를 연결하면서 두 가지를 함께 정리했습니다.

중단 지점까지의 답변은 남깁니다. 중간까지 읽던 답변이 화면에서 사라지면 사용자는 자신이 무엇을 잃었는지조차 알 수 없습니다. 서버도 부분 답변을 그대로 기록에 저장해야 합니다. 화면과 저장된 기록이 어긋나면 나중에 그 대화를 다시 열었을 때 내용이 이어지지 않습니다.

끊긴 연결에 쓰기를 시도하는 것은 정상 흐름입니다. 클라이언트가 중단하면 서버 스트림은 이미 닫혀 있습니다. 이때의 쓰기 실패를 예외로 취급하면 정작 기록을 저장하는 로직까지 건너뛰게 됩니다.

const event = (name, data) => {
  try { controller.enqueue(/* … */); }
  catch { /* 연결 종료됨 */ }
};

함께 드러난 경합 문제

정지 기능을 붙이자 숨어 있던 버그가 드러났습니다. 답변이 스트리밍되는 도중에 사이드바에서 다른 대화를 열면, 뒤늦게 도착한 조각이 새로 불러온 대화의 마지막 메시지에 붙었습니다. 이전 대화의 답변 꼬리가 관계없는 대화에 섞여 들어가는 현상입니다.

비동기 응답을 화면에 반영할 때 자주 나오는 실수입니다. 요청을 취소해도 이미 진행 중이던 콜백은 한 번 더 실행될 수 있습니다. 질문마다 실행 번호를 부여하고, 화면을 갱신하기 전에 그 번호가 아직 유효한지 확인하는 방식으로 막았습니다.

const runId = runIdRef.current + 1;
runIdRef.current = runId;
const current = () => runIdRef.current === runId;
// 대화를 새로 열거나 새 대화를 시작하면 번호를 올려 이전 실행을 무효화합니다.

여기에도 함정이 하나 있었습니다. "무효화된 실행은 아무것도 하지 않는다"고 단순하게 작성하면 진행 중 표시를 내리는 것까지 건너뜁니다. 그러면 플래그가 참으로 남아 다음 질문이 영원히 시작되지 않습니다. 상태를 되돌리는 정리 작업은 무효화 여부와 무관하게 항상 실행해야 합니다.

주의할 점

재생성 버튼에도 비슷한 종류의 함정이 있었습니다. 단순히 같은 질문을 한 번 더 보내면 대화 기록에 같은 질문이 두 번 쌓입니다. 그 상태에서 다음 후속 질문을 하면 재작성 단계가 중복된 맥락을 보게 됩니다. 재생성 요청은 서버에서 직전 질문·답변 쌍을 제거한 뒤 새로 진행하도록 처리했습니다. 답변이 저장되지 않은 채 중단되어 질문만 남은 경우도 있으므로, 마지막 두 개를 무조건 지우는 대신 뒤에서부터 역할을 확인하며 제거합니다.

전체를 요약하면 다음과 같습니다.

  • 멀티턴 RAG에서 진짜 어려운 부분은 프롬프트가 아니라 검색에 맥락을 주입하는 것입니다.
  • 검색어 재작성은 효과가 확실하지만 질문마다 LLM 왕복을 하나 더 얹습니다. 첫 질문 건너뛰기처럼 호출 자체를 없애는 완화책이 가장 효과가 큽니다.
  • 보조 단계는 실패해도 본 흐름이 굴러가도록 폴백을 둡니다.
  • 대화 기록의 단일 기준은 서버에 둡니다.
  • 근거 필터링은 절대 기준만으로 부족합니다. 상대 기준을 함께 사용합니다.
  • 중단이나 재생성 같은 제어 기능은 화면과 저장된 기록이 항상 같은 내용을 말하도록 맞춰야 합니다.

듀오랩스가 보는 관점

사내 문서 검색 도구를 도입할 때 성패를 가르는 지점은 모델의 크기가 아니라 이런 종류의 마감입니다. 후속 질문이 깨지고, 정지 버튼이 동작하지 않고, 새로고침하면 대화가 사라지는 도구는 아무리 답변 품질이 좋아도 두 번째 주에 쓰이지 않습니다.

그래서 저희는 RAG 도입을 검토하실 때 답변 정확도만 보는 대신, 실제 사용 흐름에서 다음 세 가지를 함께 확인하시기를 권합니다. 후속 질문이 앞 맥락을 이어받는지, 응답이 시작되기까지 얼마나 걸리는지, 그리고 중간에 멈추거나 다시 물었을 때 기록이 어긋나지 않는지입니다. 이 세 가지는 데모에서는 잘 드러나지 않지만 도입 이후의 실사용률을 좌우합니다.