Big Project · AI's EYE 27
RAG 기반 정책 검색 및 Fallback 구현
매장 정책이 많지 않다면 모두 Gemini에게 넘기는 편이 단순하고 빠를 것 같다. 검색과 별도 임베딩 호출이 왜 필요한지, 검색 실패 때 무엇을 해야 하는지 애매하다.
근거 · 프로젝트 문서 · 코드 · 테스트
1장: 왜 LLM에 모든 정책을 넘기지 않는가: RAG의 필요성
솔라가 노트북을 들고 거실로 나왔다. 화면에는 고객 질문에 답변하는 챗봇 시스템의 아키텍처 초안으로 보이는 다이어그램이 떠 있었다. 솔라는 자신감 있는 걸음으로 다가와 소파에 앉은 루나에게 화면을 내밀었다.
“언니, 이 부분 말이야. 고객 질문이 들어오면 매장 정책을 찾아서 LLM에 넘겨주잖아. 그런데 이 검색(retrieval) 단계가 너무 복잡해 보여. 임베딩이니, 벡터 검색이니… 그냥 정책이 아주 많은 것도 아닌데, 전부 LLM에 한 번에 넘겨주면 안 돼?”
솔라의 손가락은 다이어그램의 ‘Policy Retriever’라는 상자를 가리키고 있었다. 그녀의 제안은 명쾌했다. 복잡한 중간 단계를 없애고, 사용자 질문과 전체 정책 목록을 통째로 Gemini 같은 대형 언어 모델에 전달하자는 것이었다. 단순하고 빠른 길을 발견했다는 듯 솔라의 목소리에는 확신이 차 있었다.
“매장 정책이 수만 개인 것도 아니고, 많아봐야 몇백 개 아닐까? 그럼 그냥 전체 정책을 컨텍스트로 주고 ‘이 정책들을 바탕으로 이 질문에 답해줘’라고 하는 게 훨씬 직관적이잖아. 그럼 LLM이 알아서 제일 관련 있는 내용을 찾아 답할 거고.”
루나는 잠시 솔라가 가리키는 화면을 들여다보다가, 고개를 들어 솔라를 보았다. 루나는 비판 대신 차분한 질문을 던졌다.
“재미있는 생각이네. 그럼 우리 그 방법을 한번 시뮬레이션해 볼까?”
루나는 테이블 위에 놓인 태블릿을 가져와 새 노트를 열었다.
“단순하게 생각해서, 매장 정책이 1000개라고 가정해 보자. 그렇게 많은 건 아니지?”
“응, 1000개. 그 정도면 충분히 가능성 있는 숫자 같아.” 솔라는 고개를 끄덕였다.
“좋아. 그럼 정책 하나당 길이가 평균적으로 얼마나 될까? 짧은 것도 있고 긴 것도 있겠지만, 대략 문장 서너 개 정도라고 치면… 한 200 단어, 토큰으로 따지면 300 토큰 정도 되려나?”
솔라가 잠시 생각에 잠기더니 답했다. “그 정도면 적당할 것 같아.”
루나는 태블릿에 숫자를 입력하며 말했다. “자, 그럼 정책 1000개에 각각 300 토큰이야. 고객 한 명의 질문이 들어올 때마다 우리가 LLM에 전달해야 하는 총 토큰은 얼마지?”
솔라는 암산이 빠른 편이었다. “1000 곱하기 300이니까… 30만 토큰. 거기다 고객 질문 토큰도 더해야겠지. 와, 꽤 크네.”
“그렇지. 매번 질문 하나에 30만 토큰 이상을 처리해야 하는 거야.” 루나는 태블릿 화면을 솔라에게 보여주었다. 화면에는 1,000 policies * 300 tokens/policy = 300,000 tokens 라고 적혀 있었다. “이걸로 API를 호출하면 세 가지 문제가 생겨. 첫째는 비용이야.”
루나는 웹브라우저를 열어 Gemini API의 가격 정책 페이지를 보여주었다. 토큰 수에 따라 과금되는 요금표를 본 솔라의 표정이 미묘하게 변했다. 질문 하나에 몇 원, 몇십 원이 아니라 몇백, 몇천 원이 될 수도 있는 금액이었다.
“고객 한 명당 비용이 아니라, 고객의 질문 하나당 비용이라는 게 중요해. 하루에 질문이 수천 개씩 들어온다면?”
“음… 비용이 엄청나겠네.” 솔라는 자신의 ‘단순한’ 아이디어가 만들어낼 청구서를 상상하며 작게 중얼거렸다.
“둘째는 시간, 즉 지연 시간이야.” 루나가 말을 이었다. “모델이 30만 토큰이나 되는 긴 컨텍스트를 전부 읽고 이해한 다음에 답변을 생성하려면 얼마나 걸릴까? 마치 우리가 책 한 권을 전부 읽고 특정 질문에 답하는 것과 같아. 고객은 답변을 받기 위해 한참을 기다려야 할 거야. 챗봇 창에서 로딩 아이콘만 뱅글뱅글 돌고 있겠지.”
고객이 질문을 입력하고 답이 나올 때까지 하염없이 기다리는 모습이 그려지자, 솔라는 자신의 생각이 얼마나 비현실적이었는지 깨닫기 시작했다.
“마지막으로 가장 중요한 문제. 바로 답변의 품질이야.”
“품질? 정보가 많을수록 더 정확한 답변을 하는 거 아니었어?” 솔라가 반문했다. 이것만큼은 양보할 수 없다는 듯한 표정이었다.
“반드시 그렇지는 않아.” 루나는 태블릿에 큰 사각형을 그리고, 그 안에 작은 점 두 개를 찍었다. “이 사각형이 우리가 준 30만 토큰의 컨텍스트 전체라고 해보자. 그리고 이 두 점이 ‘선물 받은 상품도 환불되나요?‘라는 질문에 대한 실제 정답이 담긴 정책이야. 이걸 ‘바늘’이라고 부를게. 나머지 거대한 정보는 ‘건초더미’고.”
루나는 설명을 이어갔다. “LLM에 이렇게 거대한 건초더미 속에서 바늘 두 개를 찾아오라고 하면, 모델은 혼란을 겪을 수 있어. 너무 많은 정보에 압도당해서 정작 중요한 두 정책을 놓치거나, 관련 없는 다른 정책의 내용을 섞어서 답할 수도 있지. 이걸 ‘컨텍스트 안에서 길을 잃는다(Lost in the Middle)‘고 표현하기도 해. 최악의 경우, 있지도 않은 내용을 지어내서 답할 수도 있고.”
솔라는 루나가 그린 그림과 가격표, 그리고 ‘로딩 중’인 고객의 얼굴을 번갈아 떠올렸다. 처음의 자신감은 온데간데없었다. 단순하고 빠를 거라 생각했던 길이, 실제로는 비싸고 느리며 심지어 부정확할 수 있는 길이었다. 그녀는 자신의 노트북 화면 속 다이어그램을 다시 보았다. 복잡해 보이기만 했던 ‘Policy Retriever’라는 상자가 이제는 다르게 보였다.
“알겠어, 언니. 모든 정책을 다 넘기는 건 말도 안 되는 생각이었네.” 솔라는 고개를 저으며 말했다. “비용, 속도, 정확도… 어느 것 하나 좋은 게 없구나.”
그녀는 자신의 초기 아이디어가 틀렸음을 분명히 인정했다. 문제를 진단하고 나니, 진짜 질문이 보이기 시작했다.
“그럼… 저 복잡해 보이던 검색 장치가 필요한 이유를 알겠어. 건초더미 전체를 주는 게 아니라, 처음부터 바늘 몇 개만 콕 집어서 LLM에 줘야 하는 거구나. 그런데 시스템이 어떻게 그 많은 정책 중에서 사용자의 질문과 관련된 ‘바늘’만 정확히 찾아내는 거지?“
2장: 질문에 맞는 정책을 선별하는 방법: 임베딩과 유사도 검색
솔라는 전날 언니와 나눴던 대화의 흔적이 남아있는 태블릿 옆에 새 노트를 펼쳤다. ‘건초더미에서 바늘 찾기’라는 비유가 머릿속을 맴돌았다. 거대한 정책 더미를 통째로 LLM에 넘기는 것이 왜 나쁜 생각인지는 이제 분명히 알았다. 문제는 ‘어떻게 바늘을 찾을 것인가’였다. 그녀는 노트에 ‘Policy Retriever’라는 상자를 그리고, 그 안에 ‘키워드 검색’이라고 적었다. 가장 직관적인 방법이었다.
‘환불’이라는 단어가 포함된 고객 질문이 들어오면, ‘환불’이라는 키워드가 들어간 정책 문서를 모두 찾아서 LLM에 넘겨주면 되지 않을까? 솔라는 자신의 아이디어가 꽤 그럴듯하다고 생각하며, 상자 주변에 몇 가지 예시를 적기 시작했다. ‘배송 문의’ -> ‘배송’ 키워드 검색. ‘영업시간’ -> ‘영업시간’ 키워드 검색. 간단하고 명확했다.
“언니, 이 검색기(Retriever) 말이야. 이렇게 만들면 어떨까?”
솔라가 자신만만하게 노트를 내밀자, 소파에서 책을 읽던 루나가 고개를 들었다. 그녀는 솔라의 노트를 잠시 들여다보더니, 잠시 생각에 잠겼다.
“단순해서 좋네.” 루나가 입을 열었다. “그럼 이 질문은 어떨까? ‘선물로 받은 옷인데, 다른 걸로 바꿀 수 있나요?’”
솔라는 잠시 멈칫했다. “음… ‘교환’이라는 키워드로 찾으면 되겠지.”
“만약 고객이 ‘바꾸다’라고 표현했다면? 그리고 ‘선물’이라는 단어가 중요하잖아. ‘환불’ 정책과 ‘선물 교환’ 정책은 다를 수 있으니까.”
루나의 지적에 솔라는 자신의 ‘키워드 검색’ 아이디어에 금이 가는 것을 느꼈다. ‘선물’, ‘교환’, ‘바꾸다’, ‘증정품’… 사용자가 어떤 단어를 쓸지 예측해서 모든 유의어를 등록해두지 않는 한, 중요한 정책을 놓칠 가능성이 컸다. 키워드 매칭은 생각보다 똑똑하지 않았다.
“정확히 일치하는 단어가 없으면 중요한 정보를 놓치게 되는구나… 그럼 도대체 어떻게 의미를 알아듣고 찾아내는 거지?” 솔라가 막막하다는 듯 중얼거렸다.
“좋은 질문이야. 단어의 표면이 아니라 의미의 근접성으로 찾는 거지.”
루나는 태블릿을 가져와 빈 페이지를 열었다. 그녀는 페이지 위에 점 몇 개를 찍고, 각각의 점 옆에 짧은 문장을 적었다.
- A: “모든 환불은 30일 이내에 영수증 지참 시 가능합니다.”
- B: “선물 상품 교환은 동일 가격대 상품으로만 가능합니다.”
- C: “배송은 주말을 제외하고 3일이 소요됩니다.”
“이 점들이 우리 매장의 정책들이라고 생각해 봐.” 루나가 말했다. “그리고 이 정책 문장들을 ‘임베딩’이라는 과정을 통해 의미 공간의 좌표로 바꾸는 거야. 비슷한 의미를 가진 문장일수록 지도 위에서 서로 가까운 곳에 위치하게 돼.”
루나는 태… 이어서 새로운 점을 하나 더 찍었다. 그 점은 B 정책 점과 아주 가까웠고, A 정책 점과는 조금 떨어져 있었으며, C 정책 점과는 아주 멀리 떨어져 있었다.
- Q: “선물 받은 옷 환불 가능한가요?”
“이건 고객의 질문이야. 이 질문 문장도 마찬가지로 좌표로 바꾸는 거지. 이제 우리가 할 일은 간단해. 질문(Q) 좌표에서 가장 가까운 정책 좌표들을 찾는 거야. 어느 게 제일 가깝지?”
“B 정책이네. ‘선물’이라는 단어가 똑같이 들어있으니까.” 솔라가 즉시 대답했다.
“단어 때문만은 아니야. ‘선물’, ‘교환’, ‘환불’ 같은 단어들의 의미적 관계를 모델이 이해하고 있기 때문에, 질문 Q와 정책 B의 ‘의미’가 가장 가깝다고 판단해서 가까운 좌표를 부여한 거지. 이게 바로 의미 기반 검색이야.”
루나는 질문 Q와 가장 가까운 정책 점들을 중심으로 원을 그렸다.
“자, 여기서 첫 번째 규칙이 등장해. top_k.” 루나는 B와 A를 포함하는 작은 원을 그렸다. “가장 가까운 후보 하나만 고르는 대신, 상위 k개, 예를 들어 가장 가까운 3개(top_k=3)를 후보로 뽑는 거야. 혹시 모를 중요한 정보가 2등이나 3등 후보에 있을 수 있으니까.”
솔라는 고개를 끄덕였다. “일단 가능성 있는 것들을 넓게 모으는 거구나.”
“맞아. 그런데 여기서 문제가 생길 수 있어.” 루나는 C 정책처럼 아주 동떨어진 점을 가리켰다. “만약 질문과 전혀 상관없는 정책들만 늘어놓게 되면 어떡할까? top_k=3이라고 해서 무조건 3개를 뽑았는데, 2등과 3등이 1등에 비해 터무니없이 관련 없는 내용일 수도 있잖아. 그러면 오히려 LLM이 혼란스러워하겠지.”
루나는 1등 후보인 B를 중심으로 더 작은 원을 하나 더 그렸다.
“그래서 두 번째 규칙, margin이 필요해. 최고 점수를 받은 후보(B)를 기준으로 ‘이 점수와의 차이가 이 정도(margin) 이내인 후보들만 인정하겠다’는 울타리를 치는 거야. 예를 들어 최고 점수가 0.9인데 마진을 0.1로 설정하면, 0.8점 이상인 후보들만 최종적으로 선택되는 거지. 아무리 top_k 안에 들었어도 점수 차이가 마진보다 크면 가차없이 탈락이야.”
솔라의 눈이 빛났다. 복잡해 보이기만 했던 검색 과정의 그림이 명확하게 그려졌다.
“아! 알겠다! 단순히 키워드를 찾는 게 아니라, 문장의 의미를 좌표로 바꿔서 ‘거리’를 재는 거구나. top_k로 일단 유력한 후보들을 모으고, margin이라는 기준으로 관련성이 떨어지는 후보들을 다시 한번 걸러내서 최종 ‘바늘’ 목록을 만드는 거네. 정말 정교한 필터구나.”
솔라는 자신의 노트에 그렸던 ‘키워드 검색’이라는 단어를 지우고, 그 자리에 ‘의미 좌표 검색 (top_k + margin)’이라고 새로 적었다. 건초더미에서 바늘을 찾는 기계의 첫 번째 톱니바퀴가 어떻게 돌아가는지, 이제는 분명히 설명할 수 있었다.
그녀는 만족스럽게 고개를 끄덕이다가, 문득 한 가지 의문이 들었다. 태블릿 위에 그려진 정교한 의미의 지도. 그 지도를 만드는 과정 자체에 문제가 생긴다면?
“언니, 그런데 이 좌표… 즉, 임베딩을 얻어오는 과정이 항상 성공할 거라고 보장할 수 있어? 만약 이 지도 서비스를 호출하는 데 실패하거나, 너무 많이 호출해서 사용량이 꽉 차버리면 어떻게 해? 그럼 바늘을 찾기도 전에 시스템이 멈춰버리는 거 아냐?”
3장: 검색 실패 시 시스템 견고성 확보: Fallback과 자원 분리
솔라는 전날 밤 완성했던 ‘의미 좌표 검색’ 다이어그램을 앞에 두고 깊은 고민에 빠져 있었다. 정교한 필터의 원리를 깨달았을 때의 기쁨은 사라지고, 이제는 그 정교함이 만들어낼 수 있는 새로운 취약점이 눈에 들어왔다. 그녀는 붉은 펜을 들어 다이어그램의 한가운데를 차지한 ‘임베딩 서비스’라는 상자에서 선을 하나 길게 뽑았다. 그리고 그 선으로 시스템 전체를 가로지르는 커다란 X자를 그렸다.
그녀의 그림 속에서, 의미의 좌표를 찍어주던 외부 서비스가 멈추는 순간, 바늘을 찾기 위한 모든 여정은 시작도 전에 중단되었다. 고객의 질문은 좌표를 얻지 못해 허공을 떠돌고, 챗봇은 아무런 답변도 내놓지 못한 채 침묵할 것이다. 솔라의 눈에는 이 하나의 의존성이 전체 시스템을 무너뜨릴 수 있는 치명적인 ‘단일 실패 지점(Single Point of Failure)’으로 보였다.
루나가 조용히 다가와 솔라의 어깨너머로 그녀가 그린 다이어그램을 보았다. 붉은 X자로 가차없이 지워진 시스템. 루나는 잠시 침묵하다가, 솔라의 노트를 가리키는 대신 테이블 위의 리모컨을 집어 들었다.
“솔라, TV가 갑자기 안 나온다고 가정해 보자. 리모컨의 전원이 다 됐을 수도 있고, TV 자체가 고장 났을 수도 있겠지. 그때 우리가 어떻게 하지?”
“음… 일단 리모컨을 손으로 탁탁 쳐보고, 안 되면 배터리를 갈아보겠지. 그래도 안 되면 TV 본체의 전원 버튼을 눌러볼 거고.”
“그래. 바로 그거야. 우리는 멈추지 않아. 어떻게든 다른 방법을 시도하지.” 루나는 말을 이었다. “시스템도 마찬가지야. 가장 이상적인 A안이 실패했을 때, 시스템 전체가 멈추는 대신 차선책인 B안으로 부드럽게 넘어갈 수 있어야 해. 그걸 ‘Fallback’이라고 불러.”
루나는 솔라가 그려놓은 다이어그램의 ‘임베딩 서비스’ 상자를 손가락으로 가리켰다.
“이 의미 좌표 지도를 만드는 데 실패했다면, 우리가 할 수 있는 가장 안전한 차선책은 뭘까? 고객에게 ‘시스템 오류입니다’라는 메시지를 보여주는 것보다 더 나은 선택은?”
솔라의 머릿속에 지난 대화가 스쳐 지나갔다. 건초더미에서 바늘을 찾는 일. 바늘을 못 찾겠다고 건초더미 전체를 불태울 수는 없는 노릇이었다.
“…건초더미를 그냥 다 주는 거?” 솔라가 조심스럽게 말했다. “그러니까… 임베딩 검색에 실패하면, 그냥 전체 정책 목록을 LLM에 넘겨주는 거지. 우리가 첫 번째 장에서 비효율적이라고 결론 내렸던 바로 그 방법으로.”
“정확해.”
“하지만 그건 느리고, 비싸고, 부정확할 수 있잖아!” 솔라는 자신의 깨달음을 스스로 부정하는 것 같아 혼란스러웠다.
“맞아. 평상시에는 절대 쓰면 안 되는 방법이지.” 루나가 고개를 끄덕였다. “하지만 지금은 비상 상황이야. 완벽한 답변을 5초 안에 주지 못하더라도, 조금 느리고 부정확할지언정 어떻게든 답변을 생성하려는 시도 자체가 중요해. 아무것도 안 하는 것보다 훨씬 나으니까. Fallback은 최선을 위한 계획이 아니라, 최악을 피하기 위한 안전망이야.”
솔라는 그제야 고개를 끄덕였다. 그녀는 붉은 X자를 지우개로 지우고, 대신 ‘임베딩 서비스’ 상자에서 ‘전체 정책 목록’으로 향하는 점선을 그렸다. 그리고 그 점선 위에 ‘Fallback’이라고 적었다. 시스템은 이제 쉽게 무너지지 않는 유연성을 갖게 되었다.
“좋아, 그럼 서비스 다운 문제는 해결됐어.” 솔라는 한숨 돌리며 말했다. “그런데 다른 문제가 있어. 서비스가 다운된 건 아니지만, 우리가 너무 많이 호출해서 API 할당량을 다 써버리면 어떻게 해? 예를 들어 팀에서 쓰는 유료 Vertex API를 다 같이 쓰는데, 우리 챗봇이 분당 호출 제한에 걸려서 검색을 못 하게 되는 거야. 이것도 결국 시스템이 멈추는 거잖아.”
이것은 또 다른 종류의 실패였다. 시스템의 외부 의존성이 아니라, 내부 자원 관리의 문제였다.
“그것도 좋은 지적이야.” 루나는 빈 종이를 가져와 두 개의 상자를 나란히 그렸다. 하나는 ‘챗봇 답변 생성’, 다른 하나는 ‘정책 임베딩 검색’이라고 적었다. 그리고 두 상자가 모두 ‘팀 공유 API Key’라는 하나의 수도꼭지에서 물을 쓰는 그림을 그렸다.
“솔라 네 말대로, ‘답변 생성’ 팀이 물을 많이 쓰면 ‘임베딩 검색’ 팀이 쓸 물이 부족해지고, 반대의 경우도 마찬가지지. 서로가 서로의 발목을 잡는 병목 현상이 생기는 거야.”
루나는 ‘임베딩 검색’ 상자 아래에 작은 글씨로 덧붙였다. ‘중요하지만, 답변 생성만큼 치명적이진 않음.’
“해결책은 간단해. 수도꼭지를 분리하는 거야.” 루나는 ‘임베딩 검색’ 상자를 지우고, 그 옆에 새로운 ‘무료 Tier API Key’라는 수도꼭지를 그렸다. 이제 ‘임베딩 검색’은 새로운 수도꼭지에 연결되었다.
“답변을 최종 생성하는 핵심 로직은 안정적인 유료 API를 그대로 쓰고, 그보다 덜 치명적이지만 호출 빈도가 높은 임베딩 검색은 별도의 무료 API나 저렴한 API 경로로 분리하는 거야. 이렇게 하면 임베딩 검색 작업이 아무리 많아져도 핵심 기능인 답변 생성에 영향을 주지 않아. 설령 무료 API의 할당량을 다 써서 임베딩 검색이 실패하더라도, 우리에겐 이미 만들어둔 Fallback 안전망이 있잖아?”
솔라의 눈이 다시 한번 빛났다. 모든 문제를 하나의 거대한 기술로 해결하려는 대신, 문제의 성격에 맞게 자원을 분리하고 각 단계에 맞는 안전장치를 마련하는 설계 철학이 보였다.
그녀는 자신의 노트 마지막 페이지를 펼쳤다. 그리고 견고한 RAG 시스템 설계 원칙이라고 제목을 적었다. 이것은 더 이상 언니의 설명이나 책의 내용이 아니었다. 지난 며칠간의 고민과 깨달음이 담긴, 솔라 자신만의 원칙이었다.
- 선별과 집중: LLM에 모든 정보를 주지 말고, 질문과 관련된 핵심 정보(바늘)만 정확히 선별하여 전달한다. (비용, 속도, 정확도를 위해)
- 안전한 후퇴(Fallback): 가장 이상적인 방법(의미 검색)이 실패할 경우, 시스템이 멈추는 대신 차선책(전체 전달)으로 자동 전환되어야 한다. 완벽한 실패보다 불완전한 성공이 낫다.
- 자원 분리: 핵심 기능(답변 생성)이 보조 기능(검색)의 부하 때문에 마비되지 않도록, API 사용 경로 등을 분리하여 서로의 병목이 되는 것을 막는다.
솔라는 자신이 적은 세 가지 원칙을 조용히 소리 내어 읽어보았다. 처음 복잡해 보이기만 했던 ‘Policy Retriever’는 이제 그녀에게 비용 효율적이고, 정확하며, 무엇보다 ‘잘 부서지지 않는’ 견고한 시스템의 핵심으로 보였다. 그녀는 이제 고객의 어떤 질문에도 자신 있게 답할 수 있는 챗봇을 설계할 수 있을 것 같았다.