[TIL-0328] LangGraph + LlamaIndex: 이중 LLM 아키텍처 설계

jiny·2026년 3월 28일

AI Agent 실습

목록 보기
12/21

🌟 개요: 프로젝트에 LLM이 2개?

🧐 코드를 분석하다 발견한 것

Claude Code화 함께 이 프로젝트에 LlamaIndex를 도입했다. 벡터 검색 기능을 추가하고, Knowledge Graph 인덱스를 붙이고, 인플루언서 매칭 이력을 RAG로 조회하는 기능까지 하나씩 구현해 나갔다.
그런데 어느 정도 구현이 끝나고 코드를 전체적으로 훑어보다가 이 프로젝트에 LLM이 두 개라는 것을 알게 되었다.

agent_factory.py를 보면 LangGraph 에이전트가 ChatOllama나 ChatOpenAI, ChatAnthropic를 쓰고 있다. 그런데 container.py를 보면 LlamaSetings.llm에도 별도의 LLM이 등록되어 있었다. 같은 프로바이더 설정을 바라보면서, 서로 다른 프레임워크가 각자의 LLM 인스턴스를 들고 있는 것이다.

나는 한 프로젝트 안에 LLM이 두 개인 구조가 생소하다 보니 처음에는 혼란스러웠는데, 막상 각각이 어떤 역할을 하는지 뜯어보니, 두 개가 공존하는 이유가 명확했다. 이 시스템이 해결해야 하는 작업을 구체적으로 나열해 보면 금방 이유가 보인다.

필요한 작업성격
광고 컨셉을 분석하고 어떤 인플루언서 유형이 필요한지 판단고수준 추론
"친환경 라이프스타일" 같은 자연어 설명으로 유사한 인플루언서 검색벡터 유사도 계산
"삼성전자가 과거에 함께한 인플루언서는?" 같은 관계 탐색그래프 + 자연어 해석
과거 성공 캠페인 사례를 참고해서 추천RAG (검색 증강 생성)
최종 결과를 종합해서 사용자에게 설명고수준 추론

이 중에서 벡터 유사도 계산, 그래프 탐색, RAG는 LLM이 직접 생각하는 게 아니라, 인덱스와 임베딩 모델을 통해 수치적으로 처리해야 한다. 이 역할에 특화된 프레임워크가 바로 LlamaIndex이다.
반대로 "지금 어떤 도구를 써야 하는가"를 판단하고, 여러 도구의 결과를 종합해서 최종 답변을 만드는 오케스트레이션LangGraph가 훨씬 적합하다.

결국 하나의 LLM으로 모든 걸 처리하려 하면, 검색의 정밀도를 희생하거나 추론의 유연성을 희생해야 하는 트레이드오프에 빠진다. 이 시스템은 그 트레이드오프를 피하기 위해 두 LLM의 역할을 명확히 분리했다.

🧐 메인 LLM vs. 보조 LLM

  • 메인 LLM (LangGraph): "무엇을 해야 하는가"를 결정하는 지휘관
  • 보조 LLM (LlamaIndex): "어떤 데이터가 관련 있는가"를 찾아오는 전문 검색관

메인 LLM은 사용자의 요청을 받아 전체 전략을 수립하고, 17개의 도구 중 어떤 것을 어떤 순서로 호출할지 판단한다.
반면 보조 LLM은 그 도구들 내부에 탑재되어, 벡터 검색 결과를 재순위하거나 Knowledge Graph를 자연어로 탐색하는 등 데이터 검색에 집중한다.

두 LLM은 서로를 인식하지 않는다. 메인 LLM 입장에서는 그냥 "도구를 호출했더니 결과가 왔다"이고, 보조 LLM 입장에서는 "쿼리가 들어왔으니 관련 데이터를 찾아서 반환한다"일 뿐이다. 이 느슨한 결합이 이 아키텍처의 핵심이다.


🌟 전체 아키텍처 다이어그램

✨ 한눈에 보는 전체 흐름

📍 메인 LLM이 개입하는 지점

LangGraph의 React Agent는 생각(Reasoning) → 행동(Action) → 관찰(Observation) 사이클을 반복한다. 메인 LLM은 이 사이클의 매 단계마다 개입한다.

  1. 사용자 입력 수신 직후
    • 광고 컨셉을 분석해서 어떤 카테고리/플랫폼의 인플루언서가 필요한지 판단
  1. 매 도구 흐름 전
    • 지금까지 수집된 정보를 바탕으로 "다음에 어떤 도구를 쓸 것인가"를 결정
  1. 매 도구 결과 수신 후
    • 결과를 해석하고, 충분한 정보가 모였는지 판단
    • 부족하면 다른 도구 추가 호출, 충분하면 최종 답변 생성 단계로 진입
  1. 최종 답변 생성
    • 모든 도구 결과를 종합하여 구조화된 한국어 추천 결과 작성

📍 보조 LLM이 개입하는 지점

보조 LLM(LlamaIndex)은 메인 LLM이 특정 도구를 호출했을 때, 그 도구 내부에서 조용히 작동한다. 메인 LLM은 이 존재를 알지 못한다.

  1. semantic_search_influencers 도구 내부
    • 자연어 쿼리를 임베딩으로 변환 (embed_model)
    • 벡터 유사도 검색으로 후보군 추출
    • LLM으로 후보군을 재순위(rerank) ⬅️ 보조 LLM 개입
  1. query_kg_natural_language 도구 내부
    • 자연어 질문을 받아 자동으로 Cypher 쿼리 생성 ⬅️ 보조 LLM 개입
    • Neo4j Knowledge Graph 실행 후 결과 해석
  1. analyze_match_history / search_similar_campaigns 도구 내부
    • 과거 매칭 이력을 RAG 방식으로 검색
    • 유사 사례 요약 텍스트 생성 ⬅️ 보조 LLM 개입
  1. 프로필/평판 생성 도구 내부 (profile_generator, reputation_tools)
    • LlamaSettings.llm.complete(prompt) 직접 호출 ⬅️ 보조 LLM 개입
    • 짧은 텍스트 생성 (프로필 요약, 평판 분석)

🆚 두 LLM의 개입 타이밍 비교

메인 LLM직렬로 전체 흐름을 제어하는 동안, 보조 LLM특정 도구 내부에서만 국소적으로 실행된다.
덕분에 두 LLM이 서로 간섭 없이 각자의 역할에만 집중할 수 있다.


🌟 메인 LLM: LangGraph React Agent

🤔 React Agent 패턴이란?

React Agent는 ReAct(Reasoning + Acting) 패턴을 구현한 에이전트이다. 단순히 LLM에 질문하고 답을 받는 것이 아니라, 아래 사이클을 목표를 달성할 때까지 반복한다.이 패턴의 핵심은 LLM이 직접 도구 호출 여부를 결정한다는 점이다. 어떤 도구를 몇 번 쓸지, 언제 멈출지 모두 LLM이 그때그때 판단한다. 개발자는 도구와 프롬프트만 제공하면 된다.
LangGraph는 이 사이클을 그래프 노드로 표현한다. agent 노드(LLM 판단)와 tools 노드(도구 실행)가 엣지로 연결되어, 종료 조건이 될 때까지 순환한다.

🛠️ agent_factory.py: 프로바이더 전환 구조

에이전트 생성은 AgentFactory.create_agent()가 담당한다. 내부는 크게 3단계로 나뉜다.

  • 1단계: 프로바이더에 따라 LLM 인스턴스 선택
    # adapters/outbound/agent/agent_factory.py
    
    if provider == "ollama":
       from langchain_ollama import ChatOllama
       llm = ChatOllama(
          model=model or "GPT-OSS-20B",
          base_url=base_url or "http://localhost:11434",
          temperature=temperature,
       )
    elif provider == "openai":
       from langchain_openai import ChatOpenAI
       llm = ChatOpenAI(
          api_key=api_key,
          model=model or "gpt-4-turbo-preview",
          temperature=temperature,
       )
    elif provider == "anthropic":
       from langchain_anthropic import ChatAnthropic
       llm = ChatAnthropic(
          api_key=api_key,
          model=model or "claude-sonnet-4-20250514",
          temperature=temperature,
       )
    세 프로바이더 모두 LangChain의 Chat 모델 인터페이스(BaseChatModel)를 구현하고 있어서, 이후 에이전트 생성 코드는 프로바이더에 무관하게 동일하다. .envLLM_PROVIDER 값 하나로 전체가 바뀐다.
  • 2단계: 도구 목록 조립
    tools = [
       # 기본 검색 도구 (5개) - Neo4j, InfluencerRepositoryPort 경유
       create_search_by_category_tool(influencer_repo),
       create_search_by_platform_tool(influencer_repo),
       create_search_by_followers_tool(influencer_repo),
       create_get_influencer_details_tool(influencer_repo),
       create_calculate_match_score_tool(influencer_repo),
       
       # Knowledge Graph 도구 (7개) - Neo4j Cypher 직접 실행
       *NEO4J_TOOLS,
       
       # LlamaIndex 도구 (5개) - 벡터 검색 + KG 자연어 쿼리
       *LLAMAINDEX_TOOLS,
       
       # 광고 이력 도구 - Instagram 스크래핑 데이터
       *(create_ad_history_tools(ad_post_repo) if ad_post_repo else []),
       
       # 평판/화제성 도구 - DuckDuckGo 웹 검색
       *REPUTATION_TOOLS,
    ]
    최대 17개 이상의 도구가 에이전트에 주입된다. 도구가 많을수록 에이전트가 더 정밀한 검색을 할 수 있지만, 그만큼 시스템 프롬프트에서 언제 어떤 도구를 써야 하는지 명확히 안내하는 것이 중요해진다.
  • 3단계: LangGraph React Agent 생성
    agent = create_agent(
       model=llm,
       tools=tools,
       system_prompt=AGENT_SYSTEM_PROMPT,
    )
    create_agent()는 LangGraph가 제공하는 함수이다. LLM, 도구 목록, 시스템 프롬프트를 받아서 React 사이클 그래프를 내부적으로 구성한다.

🤖 시스템 프롬프트 설계: 8단계 작업 프로세스

시스템 프롬프트는 에이전트의 행동 기준이다. 도구가 아무리 많아도 프롬프트가 빈약하면 에이전트는 엉뚱한 도구를 쓰거나 불필요한 호출을 반복한다.
이 프로젝트의 시스템 프롬프트는 크게 4개 섹션으로 구성되어 있다.

  • 섹션 1: 도구 목록과 번호 부여
    ### 기본 검색 도구 (Neo4j)
    1. `search_influencers_by_category`
    2. `search_influencers_by_platform`
    ...
    
    ### 시맨틱 검색 도구 (LlamaIndex)
    13. `semantic_search_influencers`
    14. `query_kg_natural_language`
    ...
    단순히 나열하는 게 아니라 번호를 붙이고 그룹화한다. 이후 도구 선택 가이드에서 "카테고리명을 모를 때는 13번"처럼 번호로 참조하면 LLM이 더 정확하게 도구를 선택한다.
  • 섹션 2: 도구 선택 가이드
     - 정확한 카테고리명을 알고 있을 때 → 기본 검색 도구 (1~3)
     - 자연어 설명으로 인플루언서를 찾고 싶을 때 → `semantic_search_influencers` (13)
     - 인플루언서-광고주-캠페인 간 관계를 자연어로 탐색 → `query_kg_natural_language` (14)
     - 복잡한 조건의 그래프 탐색이 필요할 때 → `query_knowledge_graph` (12, Cypher 직접 작성)
    유사한 도구가 여러 개 있을 때 에이전트가 헷갈리지 않도록 상황별 선택 기준을 명시한다. 특히 LlamaIndex 도구와 Neo4j 도구는 역할이 겹쳐 보일 수 있어서 구분 기준이 중요하다.
  • 섹션 3: 8단계 작업 프로세스
    에이전트가 임의로 탐색하는 게 아니라 정해진 순서를 따르도록 강제한다.
    1단계: 광고 컨셉과 아이템을 분석하여 관련 카테고리를 추론
    2단계: `search_similar_campaigns`로 과거 유사 캠페인 성공 사례 먼저 검색 → 맨땅에서 시작하지 않고 과거 데이터를 레퍼런스로 확보
    3단계: `semantic_search_influencers` 또는 `search_influencers_by_category`로 관련 인플루언서 검색
    4단계: 플랫폼, 팔로워 조건으로 추가 필터링
    5단계: `get_influencer_details`로 유망 후보 상세 정보 확인
    6단계: `calculate_match_score`로 각 후보 매칭 점수 계산
    7단계: (필요시) `analyze_match_history`, Knowledge Graph 도구로 심화 분석
    8단계: 최종 분석 종합 → 추천 결과 작성
    2단계에서 과거 캠페인 검색을 먼저 수행하도록 설계한 점이 눈에 띈다. 기존 데이터 없이 바로 인플루언서 검색부터 하면 근거가 약해지는데, 유사 사례를 먼저 확보하면 이후 추천의 신뢰도가 올라간다.
  • 섹션 4: 카테고리 매핑 가이드
    화장품/스킨케어/메이크업/코스메틱 → 뷰티
    의류/액세서리/브랜드/패션쇼 → 패션
    전자기기//소프트웨어/IT → 테크
    ...
    사용자는 "립스틱 광고"라고 입력하지만 DB에는 "뷰티"라는 카테고리만 있다. 이 매핑을 프롬프트에 포함시키면 에이전트가 1단계에서 카테고리를 잘못 추론하는 실수가 줄어든다.

🤖 프롬프트 설계의 핵심 원칙

이 시스템 프롬프트가 보여주는 설계 원칙을 정리하면 하나이다. 도구가 많을수록 프롬프트가 구체적이어야 한다.
도구가 3개일 때는 LLM이 알아서 잘 고른다. 하지만 17개가 되면 유사한 도구 사이에서 선택이 흔들린다. 번호 부여, 그룹화, 상황별 선택 가이드, 순서가 정해진 작업 프로세스 이 모두가 LLM이 틀릴 수 있는 지점을 미리 차단하기 위한 장치이다.


🌟 보조 LLM: LlamaIndex의 역할

🤔 왜 LlamaIndex가 필요한가?

메인 LLM(LangGraph)만으로는 해결하기 어려운 두 가지 문제가 있다.

  1. 자연어와 DB 키워드의 불일치
    사용자가 "친환경 라이프스타일 인플루언서"를 원한다고 해서 DB에 "친환경"이라는 카테고리가 있는 건 아니다. DB에는 "뷰티", "라이프스타일", "피트니스" 같은 정형화된 키워드만 존재한다. 단순 키워드 매칭으로는 사용자의 의도를 온전히 담을 수 없다.
    ➡️ LlamaIndex의 벡터 임베딩은 이 문제를 해결한다. "친환경 라이프스타일"이라는 표현과 "지속가능한 일상, 비건 뷰티" 같은 프로필이 의미적으로 가까운지를 수치로 계산하기 때문이다.
  1. Knowledge Graph를 자연어로 탐색
    "삼성전자와 과거에 협업한 뷰티 인플루언서 중 참여율 5% 이상인 사람은?"이라는 질문을 Cypher 쿼리로 매번 직접 작성하는 건 비효율적이다.
    ➡️ LlamaIndex의 KnowledgeGraphIndex자연어 질문을 받아 LLM이 Cypher를 자동으로 생성하고 실행한다.

정리하면 LlamaIndex가 담당하는 역할은 아래 두 축이다.

  • 벡터 검색: 자연어 ↔ 임베딩 ↔ 유사도 계산
  • LLM 활용: 자연어 → Cypher 자동 생성 / 검색 결과 재순위 / 텍스트 요약

🛠️ container.py: LlamaIndex LLM/임베딩 초기화 방법

LlamaIndex는 LlamaSettings라는 전역 설정 객체를 사용한다. 여기에 LLM과 임베딩 모델을 한 번만 등록하면, 이후 모든 인덱스가 자동으로 이 설정을 참조한다.
초기화는 Container.__init__()에서 앱 시작 시 딱 한 번 실행된다.

# infrastructure/container.py

from llama_index.core import Settings as LlamaSettings

class Container:
   def __init__(self, settings: Optional[Settings] = None):
      self.settings = settings or Settings()
      
      # LLM_PROVIDER에 따라 LlamaSettings 분기
      if self.settings.llm_provider == "openai":
         LlamaSettings.embed_model = OpenAIEmbedding(
            api_key=self.settings.openai_api_key,
         )
         LlamaSettings.llm = OpenAILLM(
            api_key=self.settings.openai_api_key,
            model=self.settings.llm_model,
         )
      elif self.settings.llm_provider == "anthropic":
         LlamaSettings.embed_model = OllamaEmbedding(
            model_name=self.settings.ollama_embed_model,
            base_url=self.settings.ollama_base_url,
         )
         LlamaSettings.llm = AnthropicLLM(
            api_key=self.settings.anthropic_api_key,
            model=self.settings.llm_model,
         )
      else:  # ollama (기본값)
         LlamaSettings.embed_model = OllamaEmbedding(
            model_name=self.settings.ollama_embed_model,
            base_url=self.settings.ollama_base_url,
         )
         LlamaSettings.llm = OllamaLLM(
            model=self.settings.llm_model,
            base_url=self.settings.ollama_base_url,
            request_timeout=600.0,
         )

프로바이더별 LLM/임베딩 모델 조합을 정리하면 다음과 같다.

프로바이더LlamaSettings.llmLlamaSettings.embed_model
ollamaOllamaLLMOllamaEmbedding
openaiOpenAILLMOpenAIEmbedding
anthropicAnthropicLLMOllamaEmbedding

Anthropic은 현재 임베딩 전용 API를 제공하지 않기 때문에, LLM은 Anthropic을 쓰더라도 임베딩은 여전히 로컬 Ollama를 사용한다. 따라서 Anthropic 프로바이더를 선택하더라도 Ollama는 반드시 실행 중이어야 한다.

그리고 4개의 LlamaIndex 인덱스는 각각 싱글턴으로 관리한다.

def influencer_vector_index(self) -> InfluencerVectorIndex:
   if self._influencer_vector_index is None:
      self._influencer_vector_index = InfluencerVectorIndex(
         neo4j_database=self.neo4j_database,
      )
   return self._influencer_vector_index

첫 번째 호출 시 인스턴스를 생성하고, 이후에는 동일한 인스턴스를 반환한다. 에이전트가 동일한 도구를 여러 번 호출해도 인덱스를 중복으로 초기화하지 않는다.

📑 4개 인덱스 상세 소개

  1. 인플루언서 벡터 인덱스: 의미 기반 검색 + 재순위

    📎 관련 파일: influencer_index.py

    인플루언서 프로필 텍스트를 임베딩하여 Neo4j Vector Index(influencer_vector_idx)에 저장한다. Document를 만들 때 카테고리를 앞에 명시하는 방식을 쓴다.

     doc = Document(
        text=f"[전문분야: {', '.join(influencer.category_names)}] "
             f"{influencer.name}: {influencer.description} "
             f"카테고리: {', '.join(influencer.category_names)}. "
             f"플랫폼: {', '.join(influencer.platform_dict.keys())}. "
             f"팔로워: {influencer.total_followers}명. "
             f"참여율: {influencer.engagement_rate}",
        ...
    )

    [전문분야: 뷰티, 라이프스타일]을 텍스트 앞에 배치하는 이유가 있다. 임베딩 모델은 텍스트 앞부분에 더 큰 가중치를 두는 경향이 있어, 카테고리 벡터 방향을 강화하기 위한 의도적인 설계이다. 검색 메서드는 3가지이다.

    1. query(): LLM이 검색 결과를 요약해서 자연어로 반환
    2. retrieve(): 유사도 점수와 함께 원본 데이터 반환 (LLM 개입 없음)
    3. retrieve_with_rerank(): 벡터 검색 → LLM 재순위 → 스코어 보정 (가장 정확)

    retrieve_with_rerank()가 핵심이다. 3단계로 작동한다.
    벡터 유사도만으로는 "점수가 높지만 전혀 다른 카테고리"인 후보가 선택될 수 있다. LLM 재순위가 이를 보정하고, 스코어 보정이 LLM 판단의 맹점을 다시 메운다. 세 단계가 서로의 약점을 보완하는 구조이다.

    • 1단계 (벡터 검색)
      • 쿼리 → 임베딩 → Neo4j 벡터 인덱스에서 candidate_k(15명) 후보 추출
      • 그래프 카테고리 조인 (SPECIALIZES_IN 관계)
    • 2단계 (LLM 재순위): 후보 15명의 텍스트를 LLM에 전달
      • "이 캠페인에 가장 적합한 top_k(3명)의 번호를 쉼표로 답하세요"
      • 보조 LLM이 도메인 적합성 기준으로 최종 선택
    • 3단계 (스코어 보정): LLM이 같은 도메인에서 고득점 후보를 건너뛴 경우 자동 교체
      • 점수 차이 > 0.01이고 같은 카테고리면 교체
      • 점수 차이 > 0.04면 카테고리 무관하게 교체
  1. Knowledge Graph 인덱스: 자연어 → Cypher 자동 생성

    📎 관련 파일: kg_index.py

    Neo4j에 이미 저장된 그래프 데이터를 자연어로 탐색할 수 있게 해주는 인덱스이다. KnowledgeGraphIndexDocument를 받아 LLM이 자동으로 트리플(주어-관계-목적어)을 추출하고 그래프에 저장한다.
    로드하는 데이터는 2종류이다.

    # 쿼리 1: 과거 광고 데이터 (CollectedAdHistory) + 인플루언서 프로필 통합
    # PROMOTED 관계 - 광고주/제품/성과와 참여 인플루언서 정보를 한 번에 조인
    "광고주 'LG생활건강'(업종: 뷰티)'비욘드 샴푸' 제품 광고를 생성함.
     성과: 좋아요 3200, 댓글 145, 조회수 28000.
     프로모션 참여 인플루언서: 김민준, 이서연
     (카테고리: 뷰티, 플랫폼: Instagram, 팔로워: 152000, 참여율: 4.2)."
    
    # 쿼리 2: 플랫폼 매칭 데이터 (PlatformAdMatch)
    # MATCHED_WITH 관계 - 캠페인 계약 정보, 평점/상태
    "광고주 '아모레퍼시픽''봄 뷰티 캠페인'을 등록함.
     인플루언서 '이서연'과 매칭됨 (상태: completed, 계약금: 1500000). 평점 4.8."

    원래는 인플루언서 프로필 전용 쿼리가 별도로 있었지만, 리팩토링 과정에서 쿼리 1에 통합됐다.
    PROMOTED 관계를 타고 광고주 → 광고 → 인플루언서를 한 번에 조인하면서 팔로워, 참여율, 카테고리, 플랫폼 정보까지 함께 가져온다. 쿼리 수가 줄고 Document 하나에 더 풍부한 컨텍스트가 담기는 구조이다.

    이렇게 구조화된 텍스트로 Document를 만들면 LLM이 (김민준) -[PROMOTED]→ (비욘드 샴푸) 같은 트리플을 자동으로 추출해서 KG에 저장한다. 이후 자연어 질의는 단 한 줄이다.

    def query(self, query_text: str) -> str:
       index = self.get_index()
       query_engine = index.as_query_engine(
          include_text=True,
          response_mode="tree_summarize",
       )
       response = query_engine.query(query_text)
       return str(response)

    "LG생활건강 캠페인에 참여한 인플루언서는?"이라고 물으면 LLM이 내부적으로 Cypher를 생성하고, 결과를 tree_summarize 방식으로 요약해서 반환한다.

  1. 매칭 이력 인덱스: RAG로 과거 성공 사례 참조

    📎 관련 파일: match_history_index.py

    새 캠페인이 들어왔을 때 "이런 컨셉으로 과거에 성공한 사례가 있었나?"를 RAG(Retrieval-Augmented Generation) 방식으로 검색한다.
    데이터 소스는 두 가지이다.
    1. CollectedAdHistory (PROMOTED 관계): 실제 광고 집행 실적 (좋아요/댓글/조회수)
    2. PlatformAdMatch (MATCHED_WITH 관계): 계약 정보 (예산/계약금/평점/상태)
    두 소스를 병합하면 "어떤 인플루언서가 어떤 캠페인에 참여했고, 실제 성과가 어땠으며, 평점은 몇 점이었는가"를 하나의 Document에서 확인할 수 있다.
    검색 메서드도 두 가지이다.
    # 1. 자연어 요약 응답 (LLM이 검색 결과를 해석해서 요약)
    def query(self, query_text: str, top_k: int = 5) -> str:
       query_engine = index.as_query_engine(
          similarity_top_k=top_k,
          response_mode="tree_summarize",  # 여러 청크를 트리 구조로 요약
       )
       
    # 2. 원본 데이터 반환 (유사도 점수 포함)
    def retrieve(self, query_text: str, top_k: int = 5) -> str:
       retriever = index.as_retriever(similarity_top_k=top_k)
       nodes = retriever.retrieve(query_text)
    "친환경 뷰티 제품 캠페인에서 높은 참여율을 보인 사례"를 입력하면, 벡터 유사도 기준으로 과거 이력 중 가장 가까운 5건을 찾아서 LLM이 요약해준다. 맨땅에서 추천하는 것보다 근거 있는 사례에서 출발하기 때문에 추천의 신뢰도가 높아진다.
  1. 과거 광고 데이터 인덱스: 수집 데이터 직접 인덱싱

    📎 관련 파일: collected_ad_index.py

    앞의 세 인덱스가 Neo4j에 이미 저장된 데이터를 참조하는 것과 달리, 이 인덱스는 CSV 파일에서 직접 임베딩한다. Neo4j import 파이프라인을 거치지 않는 별도 경로이다.
    # 기본 CSV 경로
    csv_path: str = "./data/collected_ads_history.csv"
    
    # Neo4j Vector Index 저장 노드
    node_label="CollectedAdRecord"
    index_name="collected_ad_vector_idx"
    CSV 한 행 = 인플루언서 한 명의 광고 캠페인 데이터이다. 각 행을 Document로 변환할 때 instagram_handle이 없는 빈 행은 자동으로 스킵하고, "확실하지 않음", "없음" 같은 노이즈 값은 빈 문자열로 정리한다.
    Document 텍스트는 존재하는 필드만 조합해서 구성된다.
    text_parts = [
        f"인플루언서 '{influencer_name}'({instagram_handle})",
        f"광고주 '{advertiser}'(업종: {industry})의 '{product}' 캠페인에 참여",
        f"카테고리: {category}",
        f"컨셉: {concept_keywords}",
        f"콘텐츠 형식: {post_format}",
        f"성과: 좋아요 {likes}, 댓글 {comments}, 조회수 {views}",
        ...
    ]
    이 인덱스가 필요한 이유는 실제 인스타그램 스크래핑 데이터를 그대로 검색에 활용하기 위해서이다. DB 스키마에 맞게 정제하고 import하는 과정 없이, 수집한 CSV를 바로 벡터화해서 시맨틱 검색에 쓸 수 있다.

📑 4개 인덱스 한눈에 비교

인덱스저장 노드데이터 소스LLM 개입 지점
InfluencerVectorIndexInfluencerEmbeddingNeo4j influencer 노드rerank 시 후보 재순위
KnowledgeGraphQueryIndexNeo4j 트리플Neo4j 그래프 3종트리플 추출 + Cypher 생성 + 결과 요약
MatchHistoryIndexMatchRecordPROMOTED + MATCHED_WITH 관계검색 결과 tree_summarize
CollectedAdIndexCollectedAdRecordCSV 파일 직접검색 결과 tree_summarize

🌟 두 LLM을 연결하는 핵심: 도구(Tool) 계층

두 LLM은 직접 대화하지 않는다

메인 LLM(LangGraph)과 보조 LLM(LlamaIndex)은 서로를 인식하지 못한다. 직접 메시지를 주고받거나 호출하지도 않는다. 두 LLM 사이에는 도구(Tool) 계층이 있고, 이것이 두 세계를 연결하는 유일한 접점이다.메인 LLM 입장에서는 그냥 "도구를 호출했더니 JSON 결과가 왔다"일 뿐이다. 내부에서 보조 LLM이 뭘 했는지는 알 수 없다.

💡 도구 하나의 구조

llamaindex_tools.py에 있는 LlamaIndex 도구 5개는 모두 같은 패턴으로 구성된다.

# adapters/outbound/agent/tools/llamaindex_tools.py

from langchain_core.tools import tool  # LangChain 인터페이스

@tool  # 메인 LLM(LangGraph)이 인식할 수 있는 형태로 등록
def semantic_search_influencers(query: str) -> str:
   """광고 컨셉을 자연어로 입력하면 의미적으로 가장 적합한 인플루언서를 검색합니다.
   ...
   """
   from infrastructure.container import Container  # 지연 import
   
   try:
      container = Container.get_instance()  # 싱글턴 컨테이너
      influencer_index = container.influencer_vector_index()  # LlamaIndex 인덱스
      results = influencer_index.retrieve(query, top_k=10)  # 보조 LLM 실행
      return results  # JSON 결과 반환
   except Exception as e:
      return f"시맨틱 검색 오류: {str(e)}"

구조를 뜯어보면 세 가지 역할이 층층이 쌓여 있다.

  1. @tool 데코레이터: LangChain 도구로 등록 (메인 LLM이 호출 가능)
  2. Container.get_instance(): 싱글턴을 통해 LlamaIndex 인덱스 접근
  3. influencer_index.retrieve(): 보조 LLM 실행 (임베딩 + 벡터 검색 + 재순위)

@tool 데코레이터가 핵심이다. 이것 하나로 일반 Python 함수가 LangGraph가 인식하는 도구가 된다. 메인 LLM은 함수의 docstring을 읽고 언제 이 도구를 써야 하는지 판단한다.

💡 5개 LlamaIndex 도구의 역할 분담

LLAMAINDEX_TOOLS = [
   semantic_search_influencers,  # 인플루언서 벡터 검색
   query_kg_natural_language,    # KG 자연어 탐색
   analyze_match_history,        # 매칭 이력 RAG
   search_similar_campaigns      # 유사 캠페인 검색
   search_collected_ad_data,     # 수집 광고 데이터 검색
]

표로 정리하면 아래와 같다.

도구내부 인덱스언제 쓰는가?
semantic_search_influencersInfluencerVectorIndex자연어 설명으로 인플루언서 자체를 찾을 때
query_kg_natural_languageKnowledgeGraphQueryIndex인플루언서-광고주-캠페인 관계를 탐색할 때
analyze_match_historyMatchHistoryIndex성과/평점 중심으로 과거 이력을 넓게 탐색할 때
search_similar_campaignsMatchHistoryIndex특정 컨셉+아이템으로 유사 캠페인을 찾을 때
search_collected_ad_dataCollectedAdIndex실제 수집된 광고 원본 데이터를 검색할 때

analyze_match_historysearch_similar_campaigns는 같은 인덱스(MatchHistoryIndex)를 쓴다. 차이는 인터페이스이다.
analyze_match_history쿼리 문자열 하나를 받고, search_similar_campaignsad_conceptad_item을 별도 파라미터로 받아서 내부에서 조합한다. 메인 LLM이 어떤 정보를 갖고 있느냐에 따라 더 적합한 도구를 선택할 수 있다.

💡 Container가 연결 고리인 이유

도구 내부에서 Container.get_instance()를 호출하는 방식이 중요하다. 직접 인덱스를 import하거나 생성하지 않는다.

# 인덱스를 호출마다 새로 생성하는 방식
from adapters.outbound.persistence.llamaindex.influencer_index import InfluencerVectorIndex
index = InfluencerVectorIndex(...)  # 매 호출마다 새로 생성

# 싱글턴 반환 (우리 프로젝트 방식)
container = Container.get_instance()
influencer_index = container.influencer_vector_index()  # 싱글턴 반환

에이전트가 같은 도구를 여러 번 호출하더라도 인덱스는 최초 한 번만 초기화된다. 그리고 LlamaSettings(보조 LLM 설정)도 Container 초기화 시점에 이미 세팅되어 있기 때문에, 도구가 호출되는 순간 보조 LLM은 언제든 실행 준비가 된 상태이다.

결국 도구 계층이 하는 일은 단순하다. LangChain의 언어로 포장해서, LlamaIndex의 기능을 메인 LLM이 쓸 수 있게 중계하는 것이다. 두 프레임워크가 서로를 몰라도 되는 이유가 여기에 있다.


🌟 설정 하나로 두 LLM 동시 전환

🪄 .env 한 줄로 전환

.env 파일 한 줄로 이 시스템의 프로바이더를 바꿀 수 있다.

# .env
LLM_PROVIDER=openai  # 이것만 바꾸면 됨

메인 LLM(LangGraph)와 보조 LLM(LlamaIndex) 모두 이 값 하나를 보고 동시에 전환된다.

📦 Container의 역할

위와 같이 LLM 프로바이더를 전환하는 방법이 간단한 이유는 Container.__init__() 덕분이다. 앱이 시작되면 Container 싱글턴이 딱 한 번 생성되고, 그 시점에 두 LLM 설정이 동시에 처리된다.

# infrastructure/container.py

class Container:
   def __init__(self, settings: Optional[Settings] = None):
      self.settings = settings or Settings()  # .env 읽기
      
      # 1. 보조 LLM 설정 (LlamaIndex 전역)
      if self.settings.llm_provider == "openai":
         LlamaSettings.embed_model = OpenAIEmbedding(...)
         LlamaSettings.llm = OpenAILLM(...)
      elif self.settings.llm_provider == "anthropic":
         LlamaSettings.embed_model = OllamaEmbedding(...)
         LlamaSettings.llm = AnthropicLLM(...)
      else:  # ollama
         LlamaSettings.embed_model = OllamaEmbedding(...)
         LlamaSettings.llm = OllamaLLM(...)
         
   def agent_service(self, provider=None) -> AgentService:
      # 2. 메인 LLM 설정 (LangGraph)
      provider = provider or self.settings.llm_provider
      
      if provider == "ollama":
         base_url = self.settings.ollama_base_url
      elif provider == "openai":
         api_key = self.settings.openai_api_key
      elif provider == "anthropic":
         api_key = self.settings.anthropic_api_key
      
      return AgentService(provider=provider, api_key=api_key, ...)

__init__에서 보조 LLM(LlamaSettings)을 세팅하고, agent_service()에서 메인 LLM(LangGraph 에이전트)을 생성한다.
둘 다 self.settings.llm_provider라는 같은 값을 참조한다.

💡 전환이 깔끔한 이유

두 LLM이 완전히 분리된 인터페이스를 쓰기 때문이다.

  • 메인 LLM은 langchain_core.language_models.BaseChatModel을 구현한다.
  • 보조 LLM은 llama_index.core.Settings(전역 객체)에 등록된다.

두 인터페이스는 서로를 전혀 모른다. Container가 같은 .env 값을 읽어서 각자의 방식으로 초기화할 뿐이다. 새 프로바이더를 추가하고 싶다면 agent_factory.py에 LangChain 케이스 하나, container.py에 LlamaSettings 케이스 하나를 추가하면 끝이다. 나머지 코드는 손댈 필요가 없다.

🤔 두 LLM은 반드시 같은 프로바이더여야 할까?

현재 코드 기준으로는 같아야 한다. 두 LLM 모두 self.settings.llm_provider라는 값 하나를 같이 보기 때문이다.
하지만 기술적으로는 달라도 전혀 문제 없다. 두 LLM은 완전히 분리된 인터페이스를 쓰고 있어서 서로 의존성이 없다. 지금 같은 값을 쓰는 건 설계 선택이지 기술적 제약이 아니다.

분리하려면 .env에 설정을 하나 더 추가하고 container.py에서 분기만 나누면 된다.

# .env
LLM_PROVIDER=anthropic          # 메인 LLM (LangGraph 에이전트)
LLAMAINDEX_LLM_PROVIDER=openai  # 보조 LLM (벡터 검색, 재순위)
# contaier.py
# 보조 LLM은 llamaindex_llm_provider를 참조
if self.settings.llamaindex_llm_provider == "openai":
   LlamaSettings.llm = OpenAILLM(...)
   LlamaSettings.embed_model = OpenAIEmbedding(...)
...

def agent_service(self):
   # 메인 LLM은 여전히 llm_provider 기준
   provider = self.settings.llm_provider

오히려 실무에서는 두 LLM을 다르게 쓰는 게 흔한 패턴이다. 역할이 다르기 때문이다.

메인 LLM보조 LLM
역할추론, 도구 선택, 최종 답변 생성임베딩, 재순위, KG 트리플 추출
요구사항높은 추론 능력빠른 속도, 낮은 비용
적합한 모델Claude Opus, GPT-4oGPT-4o-mini, 로컬 임베딩 모델

예를 들어 메인 LLM은 높은 추론 능력을 필요로 하므로 claude-opus(고성능), 보조 LLM은 text-embedding-3-small + gpt-4o-mini(저비용) 조합으로 구성하면 품질은 유지하면서 비용을 줄일 수 있다.
현재 구조에서 두 LLM이 같은 프로바이더를 쓰는 건 설정을 단순하게 유지하기 위한 선택이지, 아키텍처상 강제는 아닌 셈이다.


🌟 역할 분담 정리

🔍 전체 흐름으로 보기

사용자가 아래 세 필드에 내용을 각각 입력하면 내부에서 이런 일이 일어난다.

  • 광고 컨셉: 친환경 뷰티
  • 광고 아이템: 에코 크림
  • 광고주명: 코스메틱브랜드A

메인 LLM은 전체 흐름을 조율하는 지휘자이다. 어떤 도구를 어떤 순서로 쓸지, 결과를 어떻게 해석해서 다음 행동을 결정할지 판단하는 것이 전부 메인 LLM의 몫이다.

보조 LLM은 도구 안에서 조용히 실행된다. 메인 LLM이 도구를 호출할 때마다 임베딩을 만들고, 벡터 유사도를 계산하고, 후보를 재순위 매기는 일을 한다.
결과는 JSON으로 돌아올 뿐이고, 메인 LLM은 내부에서 보조 LLM이 뭘 했는지 알 필요가 없다.

⚖️ 두 LLM 역할 비교

메인 LLM보조 LLM
프레임워크LangGraphLlamaIndex
LangChain 인터페이스BaseChatModelLlamaSettings (전역)
실행 시점사용자 입력마다도구가 호출될 때마다
하는 일추론, 도구 선택, 답변 생성임베딩, 벡터 검색, 재순위, kG 트리플 추출
결과 형식자연어JSON
인덱스-4개 (벡터 3 + KG 1)

🤔 왜 이렇게 나눴는가?

하나의 LLM으로도 구현할 수 있지만, 그렇게 하면 두 가지 문제가 생긴다.

  1. 키워드 한계
    LLM에게 "친환경 뷰티에 맞는 인플루언서 찾아줘"라고 시켜도, DB에는 "뷰티"라는 카테고리만 있다. 의미적으로 유사한 데이터를 찾는 건 자연어 생성 능력이 아니라 벡터 공간에서의 거리 계산 문제이다. 보조 LLM이 임베딩과 유사도 검색을 전담하는 이유이다.

  2. 맥락 한계
    수십 명의 인플루언서 프로필, 과거 캠페인 이력, 광고 성과 수치를 전부 메인 LLM의 컨텍스트에 밀어 넣으면 토큰 비용이 급증하고 집중도가 떨어진다. 보조 LLM이 미리 관련성 높은 것만 추려서 JSON으로 건네주면 메인 LLM은 최종 판단에만 집중할 수 있다.

결국 두 LLM의 분업은 각자가 잘하는 것에 집중하게 만드는 구조이다. 메인 LLM은 언어 이해와 추론에, 보조 LLM은 데이터 검색과 수치 계산에 집중한다.


🌟 이 패턴의 장단점

👍 장점

  1. 각자 잘하는 것에 집중한다
    LLM은 추론과 언어 생성에 강하지만, 벡터와 가장 가까운 데이터를 찾는 작업에는 적합하지 않다.
    반대로 벡터 검색은 의미 유사도 계산에는 탁월하지만, 그 결과를 바탕으로 어떤 인플루언서를 추천해야 할지 판단하는 작업은 못한다.
    역할을 나누면 각자 잘하는 영역에서만 일한다.

  2. 컨텍스트 오염을 막는다
    만약 메인 LLM 하나로 모든 걸 처리하려면 DB 검색 결과 전체, 과거 캠페인 이력, 인플루언서 프로필 수십 개를 전부 컨텍스트에 넣어야 한다. 토큰이 폭발적으로 늘어나고, 중요한 정보가 노이즈에 묻힌다.
    보조 LLM이 미리 관련도 높은 것만 추려서 JSON으로 건네주면 메인 LLM의 컨텍스트는 깔끔하게 유지된다.

  3. 각 LLM을 독립적으로 교체할 수 있다
    메인 LLM을 GPT-4o에서 Claude Opus로 바꾸고 싶다면 LLM_PROVIDER만 변경하면 된다. 보조 LLM(LlamaIndex)의 임베딩 모델을 더 정밀한 것으로 업그레이드하고 싶다면 container.pyLlamaSettings.embed_model만 바꾸면 된다.
    두 LLM이 분리된 인터페이스를 쓰기 때문에 한쪽을 바꿔도 다른 쪽에 영향이 없다.

  4. 비용 최적화 여지가 생긴다
    메인 LLM과 보조 LLM을 다른 모델로 조합할 수 있다. 추론이 필요한 메인 LLM은 고성능 모델을 쓰고, 임베딩과 재순위를 담당하는 보조 LLM은 빠르고 저렴한 모델을 쓰는 식이다.
    하나의 LLM으로 모든 걸 처리할 때보다 비용 대비 성능을 더 세밀하게 조정할 수 있다.

👎 단점

  1. 시스템이 복잡해진다
    LLM이 하나일 때는 "API 키 하나, 모델 이름 하나"로 끝난다. 두 개가 되면 두 프레임워크(LangGraph, LlamaIndex)의 설정을 각각 관리해야 하고, 두 LLM이 제대로 초기화됐는지 확인해야 할 지점도 두 배가 된다. 처음 구조를 파악하는 사람 입장에서는 진입 장벽이 높아진다.

  2. 디버깅이 어렵다
    결과가 이상할 때 원인이 어디에 있는지 추적하기 어렵다. 메인 LLM이 잘못된 도구를 선택한 건지, 보조 LLM의 벡터 검색이 엉뚱한 결과를 돌려준 건지, 재순위 단계에서 좋은 후보가 탈락한 건지, 각 단계를 따로 들여다봐야 한다.
    LLM 하나짜리 시스템에서는 컨텍스트 하나만 보면 됐던 것이 레이어가 늘어난 만큼 추적 경로도 늘어난다.

  3. Anthropic 프로바이더 선택 시 Ollama 의존성이 생긴다
    현재 구조에서 LLM_PROVIDER=anthropic으로 설정하면 메인 LLM은 Anthropic API를 쓰지만, 임베딩은 Ollama가 담당한다. Anthropic이 임베딩 API를 제공하지 않기 때문이다. 클라우드 API만 쓰려고 anthropic을 선택했는데 로컬에 Ollama도 실행해야 하는 상황이 된다.
    완전한 클라우드 전용 구성이 되지 않는다는 점은 실제 배포 환경에서 제약이 될 수 있다.

  4. 인덱스 빌드 비용이 선불이다
    벡터 인덱스 4개는 사용 전에 미리 빌드해야 한다. 인플루언서 데이터가 바뀌거나 새 캠페인 이력이 쌓이면 인덱스를 다시 빌드해야 하고, 그 시점에 임베딩 비용과 시간이 한꺼번에 발생한다. 따라서 인덱스를 항상 최신 상태로 유지해야 하는 운영 부담이 추가된다.

🤔 어떤 상황에 이 패턴이 적합할까?

장단점을 종합하면, 이 패턴이 적합한 상황은 검색 대상 데이터가 많고, 의미 기반 검색이 필요하고, 과거 이력을 활용해야 할 때이다.
인플루언서가 50명이고 단순 키워드 매칭으로 충분하다면 LLM 하나로 충분하다. 하지만 수천 명의 인플루언서, 수년치 캠페인 이력, 그래프 관계 탐색이 필요하다면 메인 LLM 하나가 감당하기 어렵다. 규모가 커질수록 이 패턴의 장점이 단점을 압도한다.

0개의 댓글