PROJECT/RAG 기반 희귀품종 무화과 챗봇

[AI/RAG] RAG 기반 희귀품종 무화과 LLM 챗봇 프로젝트(3): AI Agent를 통한 pure-RAG 챗봇 완성!

깃짱 2025. 10. 21. 17:45
반응형

관련 레포: https://github.com/AiResearch2025/FigVarietyRAGChat/tree/pure-RAG

 

GitHub - AiResearch2025/FigVarietyRAGChat: RAG 기반 희귀품종 무화과 챗봇 프로젝트

RAG 기반 희귀품종 무화과 챗봇 프로젝트. Contribute to AiResearch2025/FigVarietyRAGChat development by creating an account on GitHub.

github.com

 

 

🌏 AI Agent

✅ AI Agent가 해야 할 일

AI Agent는 사용자의 질문이 들어오면 곧바로 그 질문을 LLM에 돌리기 전에 우선 질문을 아래와 같이 분류하고, 각 분류에 따라서 우리가 정해놓은 로직에 따라서 처리한다.

 

  1. 사용자가 특정 품종 이름을 알고있는가?
  2. 그 품종이 실제 존재하는(또는 RAG 데이터에 존재하는) 품종인가?
  3. 현재 질문이 RAG 검색으로 해결 가능한가, 아니면 추가 질의가 필요한가?

🌏 AI Agent의 질문 분류 및 처리

목표

 

 

✅ CHITCHAT

  • 설명: "안녕", "고마워" 등과 같이 간단한 인사나 감사 표현이 포함된 일상적인 대화입니다.
  • 코드 로직: chitchat_keywords = ["안녕", "고마워", "감사", "땡큐"] 키워드가 있는지 확인합니다.
  • 예시
    • "안녕?"
    • "고마워"
    • "답변 감사합니다"
  • 처리
    • 만약 질문에 "고마워", "감사", "땡큐"가 포함되어 있다면, "천만에요! 더 궁금한 점이 있으신가요?"라는
      정해진 답변을 반환합니다.
    • 그 외의 경우(예: "안녕")는 "안녕하세요! 무화과에 대해 무엇이든 물어보세요."라는 인사말을 반환합니다.

✅ UNKNOWN_VARIETY_IMAGE_QUERY

  • 설명: "사진", "이미지" 등의 키워드를 포함하여 이미지 속 무화과 품종을 물어보는 질문입니다.
  • 코드 로직: image_keywords = ["사진", "이미지"] 키워드가 있는지 확인합니다.
  • 예시
    • "이 사진 속 무화과는 무슨 품종이야?"
    • "이미지를 보여줄테니 품종을 알려줘"
  • 처리: "현재는 이미지를 분석하여 품종을 식별하는 기능이 지원되지
    않습니다."라는 고정된 메시지를 반환합니다.
    • 추후에 LLM을 통해 이미지의 내용을 질문자의 쿼리에 포함하는 방식으로 개선 예정

✅ KNOWN_VARIETY_TYPO

  • 설명: 시스템에 미리 정의된, 사용자가 자주 틀리는 품종명 오타가 포함된 질문입니다.
  • 코드 로직: known_typos = ["ciccio vero"] 리스트에 포함된 오타가 있는지 확인합니다. (현재는 "Ciccio Vero"만 해당)
  • 예시
    • "Ciccio Vero는 키우기 쉬운가요?" (→ 'Ciccio Nero'의 오타로 인식)
  • 처리: 사용자에게 올바른 품종명을 되묻는 질문을 생성하여 반환합니다.

✅ KNOWN_VARIETY_NEEDS_CLARIFICATION

  • 설명: 특정 품종 이름이 언급되었지만, "어때?", "좋아?" 와 같이 질문이 너무 광범위하여 구체적인 답변을 주기 어려운 경우입니다. 에이전트는 더 구체적인 질문을 하도록 유도합니다.
  • 코드 로직: clarification_keywords = ["어때", "좋아", "키울만", "괜찮"] 키워드가 있는지 확인합니다.
  • 예시
    • "브런즈윅 품종 어때?"
    • "하디 시카고 키울만 한가요?"
  • 처리: data 딕셔너리에서 품종명(variety)을 가져온 뒤, "어떤 점이
    궁금하신가요? 맛, 생산성, 나무 크기 등 구체적인 기준을 알려주시면
    더 자세히 답변해 드릴 수 있습니다."라며 더 구체적인 정보를
    요구하는 응답을 반환합니다.

✅ KNOWN_VARIETY_EXISTS

  • 설명: 특정 품종 이름이 명확하게 포함된 구체적인 질문입니다. 에이전트는 이 품종에 대한 정보를 찾아 답변합니다.
  • 코드 로직: 위의 TYPO나 NEEDS_CLARIFICATION에 해당하지 않으면서, 알려진 품종명(또는 별명)이 질문에 포함된 경우입니다.
  • 예시
    • "브런즈윅의 내한성은 어떤가요?"
    • "스트로베리 베르테(SV) 품종의 특징 알려줘"
  • 처리
    1. data 딕셔너리에서 전체 질문(query)과 품종명(variety)을
      가져옵니다.
    2. 벡터 데이터베이스에서 사용자의 전체
      질문과 가장 유사한 정보 3개를 검색합니다. (self.vector_db.similarity_search(data['query'], k=3)를 호출)
    3. 검색 결과가 없으면 "관련 정보를 찾지 못했습니다."라는
      메시지를 반환합니다.
    4. 결과가 있으면, 각 결과의 내용을 합쳐 가장 관련성 높은 정보를 모아 컨텍스트를 구성하고, RAG를 통해 좋은 품질의 답변을 제공합니다.

✅ UNKNOWN_VARIETY_GENERAL_QUERY

  • 설명: 특정 품종명이 언급되지 않은, 일반적인 무화과 관련 질문입니다. 에이전트는 질문의 키워드를 바탕으로 가장 관련성 높은 품종 정보를 추천해 줍니다.
  • 코드 로직: 위 5가지 분류에 모두 해당하지 않는 모든 질문입니다.
  • 예시
    • "달콤한 품종 추천해줘"
    • "추위에 강한 무화과는 뭐가 있어?"
  • 처리
    1. 특징(features) 벡터 데이터베이스에서 질문 내용과
      가장 유사한 정보 2개를 검색합니다.
    2. 검색 결과가 없으면 "관련 품종을 찾지 못했습니다."라는
      메시지를 반환합니다.
    3. 결과가 있으면, 이를 바탕으로 RAG를 통해 높은 품질의 답변을 제공합니다.

Fig Agent는 사용자의 질문을 6가지 카테고리로 분류하여 각 상황에 맞는 최적의 답변을 제공하도록 설계했습니다.

🌏 질문 처리 결과

 

내한성과 같이 feature_db에 포함되지 않았던 내용은 솔직히 정보가 없다고 응답했습니다.

또한 정보를 충분히 구할 수 있는 당도 기반 품종 추천 역시 정확하게 응답했습니다.

🌏 AI Agent 전체 코드

# === Fig Variety AI Agent (v4.0 - Direct Gemini SDK) ===

import os
from enum import Enum, auto
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
import google.generativeai as genai
from dotenv import load_dotenv

# .env 파일에서 환경 변수를 로드합니다.
load_dotenv()

class QueryCategory(Enum):
    """사용자 질문의 의도를 나타내는 분류 Enum"""
    KNOWN_VARIETY_EXISTS = auto()
    KNOWN_VARIETY_TYPO = auto()
    KNOWN_VARIETY_NEEDS_CLARIFICATION = auto()
    UNKNOWN_VARIETY_GENERAL_QUERY = auto()
    UNKNOWN_VARIETY_IMAGE_QUERY = auto()
    CHITCHAT = auto()

class FigAgent:
    """
    사용자 질문을 분류하고 RAG 파이프라인을 관리하는 AI 에이전트 (Gemini SDK 직접 호출 버전)
    """
    def __init__(self, varieties_db_path="./vector_db", features_db_path="./features_db", model_name="intfloat/multilingual-e5-base"):
        print("🤖 Fig Agent (Direct Gemini SDK)를 초기화하는 중입니다...")

        self.embedding = HuggingFaceEmbeddings(model_name=model_name)

        try:
            self.varieties_db = FAISS.load_local(varieties_db_path, self.embedding, allow_dangerous_deserialization=True)
            self.features_db = FAISS.load_local(features_db_path, self.embedding, allow_dangerous_deserialization=True)
        except Exception as e:
            print(f"DB 로드 실패: {e}")
            raise

        # --- LLM 설정 (google-generativeai SDK 직접 사용) ---
        google_api_key = os.getenv("GEMINI_API_KEY")
        if not google_api_key:
            raise ValueError("GEMINI_API_KEY 환경 변수를 찾을 수 없습니다. .env 파일을 확인해주세요.")

        genai.configure(api_key=google_api_key)
        self.llm = genai.GenerativeModel('gemini-2.5-flash') 

        # --- Retriever 설정 (LangChain 사용) ---
        self.retriever = self.features_db.as_retriever(search_kwargs={'k': 3})

        # --- 별명 및 오타 처리 시스템 ---
        self.variety_aliases = {
            "Brunswick": ["brunswick", "브런즈윅"],
            "Ciccio_Nero": ["ciccio nero", "씨씨오 네로", "ciccio vero"],
            "Coll_De_Dama_Rimada": ["coll de dama rimada", "콜 드 다마 리마다"],
            "Hardy_Chicago": ["hardy chicago", "하디 시카고", "시카고"],
            "Horaishi": ["horaishi", "호래시", "봉래시"],
            "Strawberry_Verte": ["strawberry verte", "스트로베리 베르테", "sv"]
        }
        self.known_typos = ["ciccio vero"]

        self.sorted_aliases = []
        for canonical, alias_list in self.variety_aliases.items():
            for alias in alias_list:
                self.sorted_aliases.append((alias, canonical))
        self.sorted_aliases.sort(key=lambda x: len(x[0]), reverse=True)

        print("✅ Agent 초기화 완료.")

    def _generate_rag_response(self, query: str) -> str:
        """RAG 파이프라인을 수동으로 실행하여 답변을 생성합니다."""
        print(f"  🔍 '{query}'에 대한 관련 문서 검색 중...")
        docs = self.retriever.get_relevant_documents(query)

        if not docs:
            return "관련 정보를 찾지 못했습니다. 질문을 조금 더 구체적으로 해주시겠어요?"

        # 검색된 문서 내용을 하나의 컨텍스트 문자열로 합칩니다.
        context = "\n".join([doc.page_content for doc in docs])

        prompt_template = f"""
        당신은 무화과 품종 전문가입니다. 사용자의 질문에 대해 아래의 '검색된 정보'를 바탕으로 친절하고 명확하게 답변해주세요.
        답변은 반드시 한국어로 작성해야 합니다. 정보가 부족하여 답변할 수 없는 경우, "정보가 부족하여 답변하기 어렵습니다."라고 솔직하게 말해주세요.

        [검색된 정보]
        {context}

        [사용자 질문]
        {query}

        [전문가 답변]
        """

        print("  🧠 LLM이 답변 생성 중...")
        try:
            response = self.llm.generate_content(prompt_template)
            return response.text
        except Exception as e:
            print(f"  ❗️ LLM 호출 중 오류 발생: {e}")
            return "답변을 생성하는 중에 오류가 발생했습니다."

    def _extract_variety_from_query(self, query: str) -> tuple[str, str] | None:
        """질문에서 (매칭된 별명, 표준 품종명) 튜플을 추출합니다."""
        query_lower = query.lower()
        for alias, canonical in self.sorted_aliases:
            if alias in query_lower:
                return (alias, canonical)
        return None

    def _classify_query(self, query: str) -> tuple[QueryCategory, dict]:
        """질문을 분석하여 카테고리와 관련 데이터를 반환합니다."""
        chitchat_keywords = ["안녕", "고마워", "감사", "땡큐"]
        if any(keyword in query for keyword in chitchat_keywords):
            return QueryCategory.CHITCHAT, {}

        image_keywords = ["사진", "이미지", "이 무화과"]
        if any(keyword in query for keyword in image_keywords):
            return QueryCategory.UNKNOWN_VARIETY_IMAGE_QUERY, {}

        variety_info = self._extract_variety_from_query(query)
        if variety_info:
            matched_alias, canonical_name = variety_info
            canonical_name_display = canonical_name.replace('_', ' ')

            if matched_alias in self.known_typos:
                return QueryCategory.KNOWN_VARIETY_TYPO, {"suggestion": canonical_name_display}

            clarification_keywords = ["어때", "좋아", "키울만", "괜찮"]
            if any(keyword in query for keyword in clarification_keywords):
                return QueryCategory.KNOWN_VARIETY_NEEDS_CLARIFICATION, {"variety": canonical_name_display}

            return QueryCategory.KNOWN_VARIETY_EXISTS, {"query": query, "variety": canonical_name_display}

        return QueryCategory.UNKNOWN_VARIETY_GENERAL_QUERY, {"query": query}

    def handle_query(self, query: str) -> str:
        """질문을 받아 분류하고, 각 케이스에 맞는 응답을 생성합니다."""
        category, data = self._classify_query(query)

        print(f"🧠 Agent 분류 결과: {category.name}")

        if category == QueryCategory.CHITCHAT:
            return "천만에요! 더 궁금한 점이 있으신가요?" if any(k in query for k in ["고마워", "감사", "땡큐"]) else "안녕하세요! 무화과에 대해 무엇이든 물어보세요."

        elif category == QueryCategory.UNKNOWN_VARIETY_IMAGE_QUERY:
            return "현재는 이미지를 분석하여 품종을 식별하는 기능이 지원되지 않습니다."

        elif category == QueryCategory.KNOWN_VARIETY_TYPO:
            return f"혹시 '{data['suggestion']}' 품종을 말씀하신 건가요?"

        elif category == QueryCategory.KNOWN_VARIETY_NEEDS_CLARIFICATION:
            return f"'{data['variety']}' 품종에 대해 어떤 점이 궁금하신가요? 맛, 생산성, 나무 크기 등 구체적인 기준을 알려주시면 더 자세히 답변해 드릴 수 있습니다."

        elif category == QueryCategory.KNOWN_VARIETY_EXISTS:
            print(f"  ▶️ '{data['variety']}'에 대한 정보 검색 및 답변 생성 시작...")
            return self._generate_rag_response(data['query'])

        elif category == QueryCategory.UNKNOWN_VARIETY_GENERAL_QUERY:
            print("  ▶️ 특징 DB에서 추천 품종 검색 및 답변 생성 시작...")
            return self._generate_rag_response(data['query'])

        else:
            return "죄송합니다. 질문을 이해하지 못했습니다."

# === Agent 실행 예시 ===
if __name__ == '__main__':
    try:
        agent = FigAgent()

        queries_to_test = [
            "안녕?",
            "브런즈윅의 내한성은 어떤가요?",
            "Ciccio Vero는 키우기 쉬운가요?",
            "브런즈윅은 키울만 한가요?",
            "달콤한 품종 추천해줘",
            "이 사진 속 무화과는 무슨 품종이야?",
            "고마워"
        ]

        for q in queries_to_test:
            print(f"\n👤 사용자 질문: {q}")
            response = agent.handle_query(q)
            print(f"🤖 Agent 응답: {response}")

    except Exception as e:
        print(f"Agent 실행 중 오류 발생: {e}")

🌏 Client로 대화형 즐겨보기(?)

(.venv) (base) ➜  FigVarietyRAGChat git:(fig_agent) ✗ python fig_client.py
🤖 Fig Agent (Direct Gemini SDK)를 초기화하는 중입니다...
✅ Agent 초기화 완료.

--- 무화과 품종 챗봇 ---
안녕하세요! 무화과에 대해 궁금한 점을 물어보세요.
(종료하시려면 'exit' 또는 'quit'을 입력하세요)

👤 나: 안녕?? 난 깃짱이야 ㅎㅎ
🧠 Agent 분류 결과: CHITCHAT
🤖 Agent: 안녕하세요! 무화과에 대해 무엇이든 물어보세요.

👤 나: 내가 키우는 무화과가 있는데 진짜 열매가 겁나  크거든?? 이게 뭔지 알겠어?
🧠 Agent 분류 결과: UNKNOWN_VARIETY_GENERAL_QUERY
  ▶️ 특징 DB에서 추천 품종 검색 및 답변 생성 시작...
  🔍 '내가 키우는 무화과가 있는데 진짜 열매가 겁나 크거든?? 이게 뭔지 알겠어?'에 대한 관련 문서 검색 중...
/Users/gitchan/Projects/FigVarietyRAGChat/fig_agent.py:71: LangChainDeprecationWarning: The method `BaseRetriever.get_relevant_documents` was deprecated in langchain-core 0.1.46 and will be removed in 1.0. Use :meth:`~invoke` instead.
  docs = self.retriever.get_relevant_documents(query)
  🧠 LLM이 답변 생성 중...
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1761035138.036196 11995233 alts_credentials.cc:93] ALTS creds ignored. Not running on GCP and untrusted ALTS is not enabled.
🤖 Agent: 안녕하세요! 무화과 품종 전문가입니다. 열매가 '겁나게 크다'는 말씀에 어떤 품종인지 궁금하시군요.

제가 가진 정보에 따르면, **브런즈윅(Brunswick)** 품종이 **대형 ~ 초대형**의 열매 크기를 자랑합니다. 따라서 고객님의 무화과가 아주 큰 열매를 맺는다면 브런즈윅 품종일 가능성이 높습니다.

👤 나:  브레바 먹을 수 있는 품종??
🧠 Agent 분류 결과: UNKNOWN_VARIETY_GENERAL_QUERY
  ▶️ 특징 DB에서 추천 품종 검색 및 답변 생성 시작...
  🔍 '브레바 먹을 수 있는 품종??'에 대한 관련 문서 검색 중...
  🧠 LLM이 답변 생성 중...
🤖 Agent: 안녕하세요! 무화과 품종 전문가입니다. 고객님의 질문에 대해 검색된 정보를 바탕으로 답변해 드리겠습니다.

브레바(Breba)를 생산하여 드실 수 있는 품종으로는 **브런즈윅(Brunswick)**이 있습니다. 브런즈윅은 풍부한 단맛(브라운 슈가 타입)을 자랑하는 품종이기도 합니다.

콜 드 다마 리마다(Col de Dame Rimada)의 경우, 브레바 생산 여부가 "X 또는 불명확"으로 표시되어 있어, 현재 정보만으로는 브레바 생산 품종으로 추천해 드리기 어렵습니다.

👤 나:  한국이랑 가까운 나라에서 유래한 무화과는?
🧠 Agent 분류 결과: UNKNOWN_VARIETY_GENERAL_QUERY
  ▶️ 특징 DB에서 추천 품종 검색 및 답변 생성 시작...
  🔍 '한국이랑 가까운 나라에서 유래한 무화과는?'에 대한 관련 문서 검색 중...
  🧠 LLM이 답변 생성 중...
🤖 Agent: 제공된 정보에 따르면, 한국과 가까운 나라에서 유래한 무화과 품종은 없습니다. 검색된 정보에는 이탈리아, 미국, 포르투갈에서 유래한 품종들만 명시되어 있어, 사용자님의 질문에 직접적으로 답변하기는 어렵습니다.

👤 나: ㅋㅋ 그러면 미국에서 유래한거는?
🧠 Agent 분류 결과: UNKNOWN_VARIETY_GENERAL_QUERY
  ▶️ 특징 DB에서 추천 품종 검색 및 답변 생성 시작...
  🔍 'ㅋㅋ 그러면 미국에서 유래한거는?'에 대한 관련 문서 검색 중...
  🧠 LLM이 답변 생성 중...
🤖 Agent: 미국에서 유래한 무화과 품종은 "하디 시카고"입니다.

👤 나: exit
🤖 Agent: 이용해주셔서 감사합니다.

 

겁나,, 기특하자나,,?

RAG을 활용하니 LLM만 있었다면 모른다고 절대 안할텐데 모르는건 모른다고 딱 강제할 수도 있었다.

 

도감을 다 먹이면 정말 쓸만 하겠어요 ㅎㅎ

 

 

 

🌏 앞으로는?

✅ LLM Configuration

현재 Fig Agent는 Gemini SDK의 기본 설정을 사용하고 있어, 응답이 다소 장황한 경우가 있다. 물론 RAG를 통해서 데이터를 제공할 수 없는 경우에는 백엔드에서 자동으로 모른다는 답을 내놓기에 할루시네이션을 억제하고는 있지만, LLM 수준에서 이를 조절하기 위해서는 Config 기반 제어를 추가할 수 있다.

  • Temperature(창의성) 제어: 단답형 정보 중심 질문에서는 temperature=0로 낮추어 일관된 사실 위주로, 품종 추천이나 조언형 질문에서는 temperature=0.8 정도로 높여 다양성을 확보한다.
  • Top-K / Top-P 설정: top_p나 top_k를 조정해, LLM이 불필요한 맥락을 덜 생성하고 더 압축된 답변을 하도록 유도할 수 있다.
  • max_output_tokens 제한: 응답 길이를 300자 내외로 제한해, 실제 사용자 경험에서 피로감을 줄일 수 있다.

✅ 최신 데이터 우선 검색 구조

현재 RAG 검색은 FAISS 기반의 벡터 유사도만 사용하고 있어, 최신 데이터 반영이 어렵다는 한계가 있다. 동일한 내용이 있을 때 최신 데이터를 더 우선하기 위해서는 아래와 같은 방법을 사용해볼 수 있다!

 

  • 타임스탬프 기반 가중치 추가
    search_kwargs={'k':3, 'filter': lambda doc: doc.metadata['timestamp'] > threshold}
    
  • 문서 저장 시 timestamp 메타데이터를 함께 저장하고, 검색 시 최신 데이터에 더 높은 가중치를 준다.
  • 주기적 재임베딩 파이프라인
  • 자동 크롤링 또는 사용자가 추가 입력한 품종 데이터를 주기적으로 재임베딩하여 최신 벡터 DB로 갱신한다.

 

✅ 멀티 에이전트(Multi-Agent) 구조로 확장

현재 Fig Agent는 단일 파이프라인에서 모든 질문을 분류하고 처리하고 있다. 하지만 RAG가 확장되면 좀더 역할에 특화한 LLM 구조를 사용할 수 있다.

 

[Agent 역할 예시]

 

Classifier Agent 질문을 6가지 카테고리로 분류 현재 FigAgent의 _classify_query 역할
Retriever Agent variety_db, feature_db 중 어디서 검색할지 결정 품종명 중심인지, 특징 중심인지 판단
Answering Agent 검색된 결과 기반으로 LLM에게 프롬프트 구성 및 응답 생성 temperature 조절 포함
Feedback Agent 사용자의 후속 질문(clarification)을 학습해 RAG 개선 “좀 더 구체적으로 알려줘” → context 강화

 

 

이 구조를 LangGraph 등의 스타일로 구성하면, 각 에이전트가 병렬/직렬로 협력하며 질문 분류, 문서 검색, 답변 생성, 피드백 반영까지 완전히 자동화된 챗봇 시스템으로 발전할 수 있을 것이다.

 

🌏 마무리

RAG를 직접 구현해 보면서 느낀건 복잡하다,,?

 

랭체인/랭그래프 바탕으로 프로젝트를 개선시켜보겠다 향후에

 

 

 

 

 

 

 

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟
비밀댓글과 메일을 통해 오는 개인적인 질문은 받지 않고 있습니다. 꼭 공개댓글로 남겨주세요!

 

반응형