개요
Mealio의 레시피 추천 챗봇을 설계하면서 RAG 파이프라인에 대해 고민을 많이 했습니다. 유저는 단순히 레시피를 검색할 수도 있고, 보유 재료로 레시피를 추천해 달라고 하거나 혹은 그냥 잡담을 할 때도 있습니다. 매번 재료 목록과 레시피 데이터를 다 끌어와서 프롬프트에 박아 넣을 수는 없었습니다.
이 글은 모델이 스스로 필요한 도구를 골라 호출하는 Agentic RAG를 구현하면서 겪은 문제들과 최종 파이프라인에 대한 회고입니다.
Agentic RAG
전통적인 RAG의 파이프라인은 질문 → 검색 → 컨텍스트 주입 → 생성 입니다. 그러나 레시피 추천 챗봇은 질문의 성격에 따라 다르게 처리할 필요가 있습니다.
- "닭가슴살 요리법 알려줘" 같은 질문은 보유 재료와 무관하게 자연어로만 탐색하면 됩니다.
- "집에 있는 재료로 뭐 만들 수 있어?"는 보유 재료를 조회하고 검색까지 해야 합니다.
- "요리할 때 소금은 언제 넣는 게 좋아?"의 경우 검색도, 재료 조회도 필요 없습니다.
고정된 파이프라인에서는 필요 없는 검색을 실행하거나, 반대로 필요한 순간에 컨텍스트가 없을 수 있습니다. 그래서 검색/재료 조회 같은 행위를 모델이 선택적으로 호출하는 도구로 만들고, 모델이 질문을 분석하여 컨텍스트에 필요한 도구를 직접 판단하도록 했습니다.
여기서 중요한 포인트는 search_recipes의 재료 ID 인자를 optional로 둔 것입니다. LLM 입장에서는 재료 기반 추천과 순수 자연어 검색을 같은 도구로 보면서도, 필요할 때만 재료 컨텍스트를 가져오게 됩니다.
Producer/Consumer 구조
Mealio의 백엔드는 Producer가 발행한 메시지를 Consumer가 처리해서 DB나 캐시에 반영하는 구조입니다. 챗봇 채팅 전송 요청도 마찬가지입니다. 클라이언트 SSE 연결은 Producer에게 있지만, Consumer가 LLM 응답을 Producer에 직접 콜백으로 보낼 수 있는 경로는 없습니다.
결국 역방향으로 이어줄 실시간 채널이 필요했고, 캐시/메시지큐/DB 정도가 후보였습니다. DB는 폴링이 필요해 지연이 크고, 메시지큐를 이중으로 쓰는 것은 과했습니다. 그래서 빠르고 단순한 Redis Pub/Sub 메시징을 활용했습니다.
재료 스키마와 컨텍스트
유저의 보유 재료 스키마는 재료를 ID 배열로만 저장합니다. DB 입장에선 당연한 정규화이지만, LLM은 '감자'나 '대파' 같은 사람이 읽을 수 있는 이름이 필요합니다. 따라서 get_user_inventory 도구는 ID → 이름 변환을 위해 별도의 조인 과정이 반드시 필요했습니다.
게다가 유저 인벤토리와 재료 테이블은 완전히 다른 DB(SQL/NoSQL)에 있었기 때문에 커넥션 비용도 2배였습니다. 매 호출마다 재료 테이블을 조회하는 낭비를 막기 위해 변환 결과를 캐싱해주었습니다.
tool에 캐시를 붙이는 결정은 성능과 복잡도 사이의 트레이드오프를 가져올 수밖에 없었습니다. 프로젝트를 하면서 이런 종류의 고민을 특히 많이 했던 것 같습니다.
완성된 아키텍처
최종 구조는 다음과 같습니다.
- Producer: 클라이언트 요청을 받고 Kafka에 메시지 발행 후 Redis 스트림 구독
- Kafka: 채팅 요청을 비동기로 전달
- Consumer: Responses API 호출 및 tool 수행 후 Redis로 응답 발행
- OpenAI Responses API: 추론 및 응답 생성
- Redis:
Consumer → Producer스트림 중계, 재료ID → 이름변환 결과 캐시 - PostgreSQL: 재료 및 레시피 데이터
- MongoDB: 채팅 메타데이터 및 로그
RAG 파이프라인
레시피 검색 도구는 자연어 질의에서 추출한 조건(재료명, 조리시간, 카테고리 등)을 그대로 필터로 쓰지 않고, 임베딩 기반 해상과 재랭킹을 거칩니다.
mustHaveIngredients나cookTime같은 조건은 soft signal로 보고, 검색 결과에 점수를 부여하는 방식으로 반영하였습니다. 재료 하나가 없거나 조리시간이 살짝 넘는다고 후보에서 배제할 수 없었고, 이러한 점수가 더 정확한 검색 결과를 만들어주었습니다.
Responses API 마이그레이션
프로젝트 초기에는 Completions API를 써도 아무런 문제가 없었는데, GPT-5.6으로 모델을 올리면서 reasoning_effort나 verbosity 같은 옵션을 쓸 수 없게 되었습니다. 결국 더 나은 응답 품질을 위해 Responses API로 마이그레이션해야만 했습니다.
Completions API를 쓸 때는 conversationId로 DB에서 이전 대화 기록을 직접 모아 메시지 배열로 넘기는 방식으로 컨텍스트를 유지해야 했습니다. 훌륭하게도 Responses API는 서버 사이드 상태 관리를 지원하였기 때문에, 페이로드를 경량화할 수 있는 여지가 있었습니다.
Responses API는 대화 컨텍스트를 30일만 보관합니다. 마침 프로젝트의 채팅 대화 기록 보관 정책도 30일이라, 스키마에 lastResponseId만 추가하면 previous_response_id로 자연스럽게 체이닝할 수 있었습니다. 만약 컨텍스트 보관 기간이 더 짧았다면 이전처럼 DB에서 히스토리를 모아 전달하는 방식을 유지할 수밖에 없었을 것입니다.
중요한 포인트는
previous_response_id를 사용해도instructions와tools를 매 요청마다 다시 보내야 한다는 점입니다. 대화 히스토리가 아닌 API 요청 설정 자체는 체이닝으로 이어지지 않기 때문입니다.
ChatbotLog는 이제 데이터 분석이나 목록 표시 용도이고, 컨텍스트 재구성에는 사용하지 않습니다. 덕분에 OpenAI API 요청 페이로드를 획기적으로 절감할 수 있었습니다.
회고

프롬프트를 잘 짜는 것보다도 LLM과 도메인의 통합이 가장 어려웠습니다. LLM이 프로젝트의 도메인을 완벽하게 이해하고 필요한 데이터를 적재적소에서 가져다 쓰길 원했습니다. Tool Call이 모델에게 자율성을 부여하고 컨텍스트를 최적화해주었으며, 결과적으로 나름 에이전트라고 불릴 만한 물건을 개발할 수 있었습니다.
Redis를 끼워 넣는 것도 처음엔 어려운 결정이었습니다. 백엔드 아키텍처를 설계할 때 Kafka를 Producer와 Consumer 사이의 유일한 브릿지로 생각했기 때문입니다. 하지만 실시간 스트림을 위해 결국 콜백 채널이 필요했고, 완성된 아키텍처에서 의존 방향을 놓고 보니 나름 각 컴포넌트가 명확한 책임을 가지고 있는 것을 확인하였습니다.
이 프로젝트에서의 경험이 새로운 도메인에서의 챗봇 개발에 도움을 줄 수 있을 것이라 생각합니다.