[TIL-0310] LlamaIndex 도입 - 3. RAG 기반 매칭 분석 도구

jiny·2026년 3월 10일

AI Agent 실습

목록 보기
8/21

🌟 개요

인플루언서 추천 시스템에서 "왜 이 인플루언서를 추천하는가"에 대한 근거는 매우 중요하다. 단순히 카테고리나 팔로워 수가 맞다는 것만으로는 광고주를 설득하기 어렵다. 과거에 비슷한 캠페인을 진행했고, 실제로 성과가 좋았다는 실적 근거가 있어야 신뢰도가 높아진다.
이를 위해 Neo4j 그래프에 축적된 과거 매칭 이력광고 성과 데이터LlamaIndex 기반 RAG 인덱스로 구성했다. 새 캠페인 설명을 자연어로 입력하면, 카테고리를 정확히 지정하거나 Cypher 쿼리를 작성하지 않아도 의미적으로 유사한 과거 성공 사례를 검색해 에이전트의 추천 근거로 활용할 수 있다.


🌟 구현 구조와 검색 전략

📌 개요

기존 Cypher 직접 질의 방식의 매칭 이력 조회에 LlamaIndex 기반 RAG 검색을 추가했다. 과거 인플루언서-광고 매칭 이력과 성과 데이터를 임베딩 벡터로 변환해두고, 새 캠페인 설명을 자연어로 입력하면 의미적으로 유사한 과거 성공 사례를 찾아 추천 근거로 활용한다.

⚠️ 기존 방식의 문제

기존에는 매칭 이력을 조회할 때 카테고리명을 정확히 지정한 Cypher 쿼리를 직접 실행해야 했다.

MATCH (i:Influencer)-[:PROMOTED]->(ad:Advertisement)
MATCH (i)-[:SPECIALIZES_IN]->(c:Category {name: 'Beauty'})
RETURN i.name, ad.likes ORDER BY ad.likes DESC LIMIT 5

"뷰티 관련 광고 성공 사례"를 찾으려면 카테고리를 'Beauty'로 정확히 지정해야 하고, "20대 여성 타겟 봄 피부 케어 캠페인"처럼 컨셉의 의미로 유사한 이력을 찾는 건 불가능했다. 질문의 형태가 달라질 때마다 새로운 쿼리를 작성해야 한다는 근본적인 한계가 있었다.

✨ RAG 검색 적용 후

과거 매칭 이력 텍스트 전체를 임베딩 벡터로 변환하여 Neo4j Vector Index에 MatchRecord 노드로 저장한다. 새 캠페인 설명을 입력하면 쿼리를 벡터로 변환해 코사인 유사도로 가장 가까운 과거 이력을 검색한다. "테크 가젯 리뷰 캠페인"이라는 자연어 입력만으로도, 카테고리 지정 없이 관련 과거 사례를 찾을 수 있다.

🗂️ 두 가지 데이터 소스 통합

인덱스를 구성할 때 Neo4j 그래프에서 성격이 다른 두 종류의 이력을 함께 불러와 임베딩한다.

  1. Notion 프로모션 실적 (PROMOTED 관계)
    실제 인스타그램/유튜브에서 수집한 광고 집행 데이터이다. 좋아요, 댓글, 조회수 같은 실제 콘텐츠 성과 수치가 포함된다.

    인플루언서 '홍길동'(handle)이 광고주 '00브랜드'(업종: Beauty)의
    '선크림' 캠페인에 참여. 카테고리: Beauty, Fashion, 컨셉: 촉촉한 봄피부.
    성과: 좋아요 12000, 댓글 340, 조회수 95000. 팔로워: 120000명, 참여율: 4.2.
  2. 캠페인 매칭 계약 이력 (MATCHED_WITH 관계)
    플랫폼 내부의 매칭 계약 데이터이다. 예산, 계약금, 평점, 매칭 상태처럼 비즈니스 거래 관점의 정보가 포함된다.

    인플루언서 '홍길동'이 광고주 '00브랜드'의 '봄 피부 케어' 캠페인에 참여.
    컨셉: 촉촉한 봄피부, 아이템: 선크림. 카테고리: Beauty. 예산: 500000원,
    계약금: 450000원. 매칭 상태: completed. 평점: 4.8/5.0.

두 소스를 하나의 인덱스에 합침으로써, 콘텐츠 성과와 비즈니스 성과를 모두 고려한 RAG 검색이 가능해진다.

🔎 2가지 검색 방식

  1. query(): LLM 자연어 요약
    LlamaIndex의 as_query_engine(similarity_top_k, response_mode="tree_summarize")을 그대로 사용한다.
    검색 결과를 LLM이 자연어로 요약해서 반환하기 때문에 사람이 읽기는 좋지만, 요약 과정에서 수치 데이터(좋아요 수, 평점 등)가 손실될 수 있다.

  2. retrieve(): 유사도 점수 포함 JSON 반환 (최종 채택)
    as_retriever()로 검색 결과를 직접 받아 JSON으로 가공한다.
    각 이력마다 유사도 점수, 원문 텍스트, 메타 데이터(출처, 성과 수치, 평점)를 그대로 Agent에게 전달할 수 있어 Agent 도구로 활용하기에 적합하다.

🛠️ 에이전트 도구 구성

retrieve() 방식을 기반으로 두 가지 도구를 에이전트에 노출했다.

  • analyze_match_history(query)
    • 자연어 질문으로 과거 매칭 이력 전체를 검색한다.
    • "뷰티 브랜드 광고에서 성과가 좋았던 인플루언서"처럼 열린 질문에 적합하다.
  • search_similar_campaigns(ad_concept, ad_item)
    • 컨셉과 아이템을 분리해서 입력하면 "{ad_concept} - {ad_item}" 형태로 조합해 검색한다.
    • 광고주가 기획 중인 캠페인의 정보를 구조적으로 입력할 때 사용한다.

🌟 관련 파일 목록

파일역할
match_history_index.pyRAG 매칭 분석의 핵심 로직 (데이터 로드, 인덱스 빌드, retrieve)
build_match_history_index.py인덱스 빌드 + Cypher vs. RAG 비교 테스트 스크립트
llamaindex_tools.pyanalyze_match_history, search_similar_campaigns 에이전트 도구 정의
container.pymatch_history_index() 싱글톤 관리

match_history_index.pyRAG 매칭 분석의 핵심 로직이고, llamaindex_tools.py가 그걸 에이전트 도구로 감싸는 부분이다.


🌟 match_history_index.py 파일 분석

1. 파일 소개

이 파일은 MatchHistoryIndex 클래스 하나로 구성되어 있으며, 크게 두 가지 역할을 한다.

  1. 인덱스 구축: Neo4j 그래프에 저장된 과거 매칭 이력광고 성과 데이터를 텍스트 문서로 변환하고 임베딩 벡터로 변환해 Neo4j에 저장한다.
  2. RAG 검색: 새 캠페인 설명을 벡터로 변환해 저장된 이력 벡터와 유사도를 비교, 의미적으로 가장 유사한 과거 성공 사례를 반환한다.

벡터 저장소로 Neo4j Vector Store를 사용한다. 기존에 Neo4j Knowledge Graph에 구축해둔 인플루언서(Influencer), 광고(Advertisement), 광고주(Advertiser) 노드와 같은 DB를 활용하되, 임베딩 전용 노드(MatchRecord)를 별도로 생성해 관리한다.
이를 통해 그래프 관계 탐색벡터 유사도 검색하나의 DB에서 함께 활용할 수 있다.

2. 전체 구조 한눈에 보기

class MatchHistoryIndex:
   def __init__(self, neo4j_uri, neo4j_username, neo4j_password)  # Neo4jVectorStore 초기화
   
   # 데이터 로드
   def _load_match_documents()   # Neo4j에서 두 소스 이력 로드 -> Document 변환
   
   # 인덱스 관리
   def build_index()  # 문서로 벡터 인덱스 신규 생성
   def get_index()    # 기존 인덱스 로드 (Lazy Loading)
   def reset()        # MatchRecord 노드 전체 삭제 후 재초기화
   
   # 검색
   def query(query_text, top_k)     # LLM 자연어 요약 반환
   def retrieve(query_text, top_k)  # 유사도 점수 포함 JSON 반환

메서드는 총 7개이며 데이터 로드, 인덱스 관리, 검색 순서로 구성된다.
_load_match_documents가 두 소스의 데이터를 병합해 Document를 만드는 핵심 전처리 단계라면, build_indexget_index는 그 결과를 Neo4j Vector Store에 저장하고 재사용하는 단계이다.
검색 메서드는 queryretrieve 두 가지가 있으며, 에이전트 도구로는 수치 데이터를 그대로 전달할 수 있는 retrieve를 채택했다.

3. 초기화 (__init__)

def __init__(
   self,
   neo4j_uri: str,
   neo4j_username: str,
   neo4j_password: str,
):
   self._neo4j_uri = neo4j_uri
   self._neo4j_username = neo4j_username
   self._neo4j_password = neo4j_password
   self._index: Optional[VectorStoreIndex] = None
   self._vector_store = Neo4jVectorStore(
      username=neo4j_username,
      password=neo4j_password,
      url=neo4j_uri,
      embedding_dimension=1024,
      index_name="match_record_vector_idx",
      node_label="MatchRecord",
   )

Neo4j 연결 정보를 받아 Neo4jVectorStore를 즉시 초기화한다. _indexNone으로 두는데, 실제로 인덱스가 필요한 시점(get_index() 호출 시)에 로드하는 Lazy Loading 패턴이다.
Neo4jVectorStore의 파라미터 중 주목할 부분은 세 가지이다.

  • embedding_dimension=1024
    • 임베딩 모델(nomic-embed-text)의 출력 벡터 차원 수
    • 저장할 때와 검색할 때 차원이 일치해야 하므로 고정값으로 지정한다.
  • index_name="match_record_vector_idx"
    • Neo4j에 생성될 벡터 인덱스 이름
    • InfluencerVectorIndexinfluencer_vector_idx와 별도로 분리되어 있어 두 인덱스가 충돌 없이 같은 DB에 공존한다.
  • node_label="MatchRecord"
    • 임베딩이 저장될 노드의 레이블
    • 기존 그래프의 Influencer, Advertisement 노드와 구분되는 전용 노드로 관리된다.

4. _load_match_documents(): 데이터 변환 전략

이 메서드가 MatchHistoryIndex의 핵심이다. Neo4j 그래프에서 두 종류의 이력 데이터를 가져와 LlamaIndex Document 객체로 변환한다. LlamaIndex 래퍼를 거치지 않고 Neo4j Python 드라이버를 직접 생성해 Cypher 쿼리를 실행한다는 점이 특징이다.

  1. 쿼리 1: Notion 프로모션 실적 (PROMOTED 관계)
    MATCH (i:Influencer)-[:PROMOTED]->(ad:Advertisement)
    MATCH (adv:Advertiser)-[:CREATED_AD]->(ad)
    OPTIONAL MATCH (i)-[:SPECIALIZES_IN]->(c:Category)
    WITH i, adv, ad,
         collect(DISTINCT c.name) as categories
    RETURN i.name, i.instagram_handle, i.follower_count, i.engagement_rate,
           adv.name, adv.industry,
           ad.product, ad.product_category, ad.concept_keywords,
           ad.likes, ad.comments, ad.views,
           categories
    실제 인스타그램/유튜브에서 수집해 Notion을 통해 임포트된 광고 집행 데이터를 가져온다.
    OPTIONAL MATCH로 카테고리를 조인하고 collect(DISTINCT c.name)으로 중복을 제거한다. 카테고리가 없으면 "미분류"로 대체한다.
    Document 텍스트는 다음과 같은 형태로 생성된다.
    인플루언서 '홍길동'(handle)이 광고주 '00브랜드'(업종: Beauty)의
    '선크림' 캠페인에 참여. 카테고리: Beauty, Fashion, 컨셉: 촉촉한봄피부.
    성과: 좋아요 12000, 댓글 340, 조회수 95000.
    팔로워: 120000명, 참여율: 4.2.

    🤔 MATCHOPTIONAL MATCH

    SQL의 JOIN, LEFT JOIN과 동일한 관계이다.

    • MATCH: 조건을 만족하는 행만 통과한다. 해당 관계나 노드가 없으면 그 행 전체가 제거된다.
    • OPTIONAL MATCH: 조건을 만족하면 값을 가져오고, 없으면 null로 채워서 행을 유지한다.

    이 쿼리에서 카테고리를 OPTIONAL MATCH로 쓴 이유이다.

    MATCH (i:Influencer)-[:PROMOTED]->(ad:Advertisement)  -- 광고 집행 이력 없으면 제외
    MATCH (adv:Advertiser)-[:CREATED_AD]->(ad)            -- 광고주 없으면 제외
    OPTIONAL MATCTH (i)-[:SPECIALIZES_IN]->(c:Category)   -- 카테고리 없어도 행 유지

    만약 카테고리를 일반 MATCH로 쓰면

    MATCH (i)-[:SPECIALIZES_IN]->(c:Category)  -- 카테고리 미등록 인플루언서는 결과에서 통째로 사라짐

    카테고리가 없는 인플루언서의 광고 이력까지 가져오고 싶으니까 OPTIONAL MATCH를 쓴 것이다. 카테고리가 없으면 collect(null)이 실행되는데, 이건 자동으로 빈 리스트 []가 돼서 코드에서 "미분류"로 대체할 수 있다.

  1. 쿼리 2: 캠페인 매칭 계약 이력 (MATCHED_WITH 관계)
    MATCH (i:Influencer)-[m:MATCHED_WITH]->(ad:Advertisement)
    MATCH (adv:Advertiser)-[:CREATED_AD]->(ad)
    OPTIONAL MATCH (i)-[:SPECIALIZES_IN]->(c:Category)
    WITH i, adv, ad, m,
         collect(DISTINCT c.name) as categories
    RETURN i.name, adv.name,
           ad.title, ad.concept, ad.item, ad.budget,
           m.status, m.contract_amount, m.rating,
           categories
    플랫폼 내부에서 생성된 매칭 계약 데이터를 가져온다.
    쿼리 1과 달리 관계 [m:MATCHED_WITH]의 속성(status, contract_amount, rating)도 함께 조회한다.
    평점이 있는 경우에만 조건부로 텍스트에 추가한다.
    rating_text = ""
    if data.get("rating"):
       rating_text = f"평점: {data['rating']}/5.0."
    Document 텍스트는 다음과 같은 형태로 생성된다.
    인플루언서 '홍길동'이 광고주 '00브랜드'의 '봄 피부 케어' 캠페인에 참여.
    컨셉: 촉촉한 봄피부, 아이템: 선크림. 카테고리: Beauty.
    예산: 500000원, 계약금: 450000원. 매칭 상태: completed. 평점: 4.8/5.0.
  1. Document 구조: text vs. metadata
    각 레코드는 textmetadata 두 부분으로 나뉜다.
    • text
      • 자연어 서술 형태로 구성되며 임베딩 벡터로 변환되는 실제 내용이다.
      • 검색 쿼리와 유사도를 비교할 때 이 텍스트가 기준이 된다.
    • metadata
      • 검색 후 결과를 활용할 때 쓰는 구조화된 데이터이다.
      • source 필드로 두 소스를 구분("notion"/"repository")하고, 좋아요 수, 평점, 상태 같은 수치 데이터를 그대로 보존한다.
      쿼리 1 (Notion)쿼리 2 (Repository)
      source"notion""repository"
      핵심 수치likes, comments, viewsrating, status, contract_amount
      성격콘텐츠 성과 중심비즈니스 거래 중심
      두 소스를 하나의 documents 리스트에 합쳐서 반환하기 때문에, 이후 build_index()에서 단일 텍스트로 통합된다.

5. build_index() vs. get_index()

def build_index(self) -> VectorStoreIndex:
   documents = self._load_match_documents()
   storage_context = StorageContext.from_defaults(
      vector_store_self._vector_store
   )
   self._index = VectorStoreIndex.from_documents(
      documents, storage_context=storage_context
   )
   print(f"매칭 이력 인덱스 생성 완료: {len(documents)}개 Document")
   return self._index

def get_index(self) -> VectorStoreIndex:
   if self._index is None:
      storage_context = StorageContext.from_defaults(
         vector_store=self._vector_store
      )
      self._index = VectorStoreIndex.from_vector_store(
         vector_store=self._vector_store,
         storage_context=storage_context,
      )
   return self._index

두 메서드는 이름이 비슷하지만 하는 일이 완전히 다르다.

  • build_index()
    • _load_match_documents()로 데이터를 가져온 뒤 VectorStoreIndex.from_documents()를 호출한다.
    • 이 과정에서 각 Document의 text임베딩 모델을 거쳐 벡터로 변환되고 Neo4j에 MatchRecord 노드로 저장된다.
    • 임베딩 생성 비용이 발생하는 단계로, 최초 1회 또는 데이터 갱신 시에만 실행된다.
    • build_match_history_index.py 스크립트에서 reset() 직후에 호출하는 이유도 중복 저장을 막기 위해서이다.
  • get_index()
    • Neo4j에 이미 저장된 벡터를 그대로 참조만 한다.
    • VectorStoreIndex.from_vector_store()는 임베딩을 새로 생성하지 않고 기존 인덱스에 연결하는 것이므로 훨씬 빠르다.
    • _index is None 체크로 Lazy Loading 패턴을 구현했다.
    • 처음 호출될 때 한 번만 로드하고, 이후에는 메모리에 올라온 인스턴스를 재사용한다.
    • query()retrieve()는 내부적으로 모두 get_index()를 호출한다.
      query() / retrieve()
        └─→ get_index()  # 이미 로드됐으면 캐시 반환
                └─→ Neo4j Vector Store  # 없으면 연결 후 반환
    build_index()get_index()
    내부 호출from_documents()from_vector_store()
    임베딩 생성O (비용 발생)X (기존 벡터 참조)
    호출 시점최초 빌드 / 데이터 갱신 시검색할 때마다
    Lazy LoadingXO

6. query() vs. retrieve()

def query(self, query_text: str, top_k: int = 5) -> str:
   index = self.get_index()
   query_engine = index.as_query_engine(
      similarity_top_k=top_k,
      response_mode="tree_summarize",
   )
   response = query_engine.query(query_text)
   return str(response)

def retrieve(self, query_text: str, top_k: int = 5) -> str:
   index = self.get_index()
   retriever = index.as_retriever(similarity_top_k=top_k)
   nodes = retriever.retrieve(query_text)
   results = []
   for node in nodes:
      results.append({
         "text": node.get_text(),
         "score": round(node.get_score(), 4),
         "metadata": node.metadata,
      })
   return json.dumps(results, ensure_ascii=False, indent=2)

두 메서드 모두 get_index()로 인덱스를 가져온 뒤 검색을 수행하지만, 결과를 처리하는 방식이 완전히 다르다.

  • query(): LLM 요약 반환
    • as_query_engine()LlamaIndex의 RAG 파이프라인을 그대로 사용하는 방식이다.
    • 내부적으로 벡터 검색 → LLM 요약의 두 단계를 자동으로 처리한다.
    • response_mode="tree_summarize"는 검색된 여러 문서를 트리 구조로 계층적으로 요약해 하나의 자연어 답변을 만든다.
    • 사람이 읽기에는 자연스럽지만, LLM 요약 과정에서 좋아요 수, 평점 같은 수치 데이터가 손실되거나 변형될 수 있고, 반환값이 문자열이라 Agent가 구조화된 데이터로 파싱하기 어렵다.
  • retrieve(): 유사도 점수 포함 JSON 반환 (최종 채택)
    • as_retriever()LLM 요약 없이 벡터 검색 결과를 그대로 반환한다.
    • 각 검색 결과(NodeWithScore)에서 세 가지를 추출해 JSON으로 가공한다.
      • text: 임베딩됐던 원문 텍스트 그대로
      • score: 코사인 유사도 점수 (0~1, 소수점 4자리)
      • metadata: source, likes, rating, status 등 구조화된 수치 데이터
      [
        {
          "text": "인플루언서 '홍길동'이 광고주 'OO브랜드'의 ...",
          "score": 0.8923,
          "metadata": {
            "source": "notion",
            "influencer_name": "홍길동",
            "likes": 12000,
            "views": 95000
          }
        }
      ]
    • Agent는 이 JSON을 받아 유사도 점수와 실제 성과 수치를 함께 참고해 추천 근거를 구성할 수 있다.
query()retrieve()
LlamaIndex APIas_query_engine()as_retriever()
LLM 개입O (tree_summarize)X
반환 형식자연어 문자열JSON
수치 데이터 보존X (요약 중 손실 가능)O (metadata 그대로)
Agent 도구 적합성낮음높음
최종 채택XO

7. reset(): 인덱스 초기화

def reset(self) -> None:
   driver = GraphDatabase.driver(
      self._neo4j_uri,
      auth=(self._neo4j_username, self._neo4j_password)
   )
   with driver.session() as session:
      session.run("MATCH (n:MatchRecord) DETACH DELETE n")
   driver.close()
   self._vector_store = Neo4jVectorStore(
      username=self._neo4j_username,
      password=self._neo4j_password,
      url=self._neo4j_uri,
      embedding_dimension=1024,
      index_name="match_record_vector_idx",
      node_label="MatchRecord",
   )
   self._index = None
  • GraphDatabase.driver vs. Neo4jVectorStore

    • GraphDatabase.driver: Neo4j 공식 Python 드라이버

      • 순수하게 Cypher 쿼리를 실행하기 위한 연결이다.
      • _load_match_documents()에서 과거 이력 데이터를 가져올 때만 사용한다.
      • 쿼리 실행 후 driver.close()로 연결을 직접 닫아야 한다.
      • LlamaIndex와는 무관한 순수 Neo4j 라이브러리이다.
    • Neo4jVectorStore: LlamaIndex의 Neo4j 벡터 저장소

      • 임베딩 벡터를 저장하고 유사도 검색을 하기 위한 인터페이스이다.
      • 연결을 닫을 필요 없이 LlamaIndex가 내부적으로 관리한다.
      • 연결 정보 외에 벡터 인덱스 관련 파라미터가 추가로 필요하다.
        • embedding_dimension: 저장할 벡터의 차원 수
        • index_name: Neo4j 내부에 생성될 벡터 인덱스 이름
        • node_label: 벡터가 저장될 노드 레이블
    • 정리하자면, 같은 Neo4j DB에 접근하지만 하는 일이 달라서 두 가지를 모두 사용한다.

      GraphDatabase.driverNeo4jVectorStore
      라이브러리Neo4j Python DriverLlamaIndex
      용도Cypher 쿼리 실행벡터 저장 + 유사도 검색
      사용 위치_load_match_documents()build_index(), get_index()
      연결 관리직접 close()LlamaIndex가 자동 관리
  • build_match_history_index.py 스크립트에서 항상 reset()build_index() 순서로 호출하는 이유
    build_index()를 반복 실행하면 Neo4j에 MatchRecord 노드가 계속 누적된다. reset()은 이를 방지하기 위해 빌드 전에 기존 데이터를 완전히 비우는 역할을 한다.
    동작은 세 단계로 이루어진다.

    1. Neo4j에서 MatchRecord로 노드 전체 삭제

      MATCH (n:MatchRecord) DETACH DELETE n

      DETACH DELETE는 노드와 연결된 관계까지 함께 삭제한다.
      MatchRecord 테이블을 가진 노드만 삭제하므로 기존 Influencer, Advertisement 등 다른 노드에는 영향을 주지 않는다.

    2. Neo4jVectorStore 재초기화
      삭제 후 _vector_store를 새 인스턴스로 교체한다.
      기존 인스턴스가 삭제된 인덱스를 내부적으로 캐싱하고 있을 수 있기 때문에, 완전히 새로운 상태에서 시작하기 위해 재생성한다.

    3. _index = None 초기화
      메모리에 올라와 있던 VectorStoreIndex 참조를 해제한다.
      이후 get_index()가 호출되면 Lazy Loading 패턴에 의해 새로 빌드된 인덱스를 다시 로드하게 된다.


🌟 build_match_history_index.py 파일 분석

1. 파일 소개

이 파일은 MatchHistoryIndex를 실제로 실행하는 스크립트로, 두 가지 목적을 가진다.

  1. 인덱스 빌드: 기존 인덱스를 초기화하고 Neo4j에서 매칭 이력을 불러와 벡터 인덱스를 새로 생성
  2. 비교 검증: 동일한 질문을 기존 Cypher 직접 질의RAG 자연어 검색으로 각각 실행해 두 방식의 차이를 나란히 출력

단순히 인덱스를 빌드하는 것에 그치지 않고, 기존 방식과 RAG 방식을 3가지 케이스로 직접 비교해볼 수 있도록 구성했다.
Cypher 쿼리는 카테고리명을 정확히 지정해야 하는 반면, RAG는 같은 질문을 자연어 문장 하나로 처리한다는 차이를 코드 수준에서 확인할 수 있다.
빌드 스크립트는 다음 명령을 통해 실행할 수 있다.

uv run python scripts/build_match_history_index.py

2. 전체 구조 한눈에 보기

# 기존 방식 헬퍼 함수
def cypher_query(uri, username, password, query) # Cypher 직접 실행 래퍼

# 메인 함수
def main()
   # 1. 인덱스 초기화 및 빌드
   match_index.reset()
   match_index.build_index()
   
   # 2. 비교 테스트 (3개 케이스)
   comparisons = [
       {"question": "팔로워 대비 댓글 반응이 폭발적이었던 광고 사례는?", ...},
      {"question": "참여율이 높은 인플루언서의 광고 캠페인 사례는?", ...},
      {"question": "구독자 규모가 적어도 높은 바이럴 효과를 낸 광고 사례는?", ...},
   ]
   
   for comp in comparisons:
      cypher_query(..., comp["cypher"])               # 기존 방식: Cypher 직접 질의
      match_index.retrieve(comp["natural"], top_k=3)  # RAG 방식: 자연어 검색

함수는 두 개로 단순하다. cypher_query()는 기존 방식을 비교 대상으로 실행하기 위한 헬퍼이고, 실질적인 내용은 모두 main() 안에 있다. main()은 크게 빌드 단계비교 테스트 단계 두 블록으로 나뉜다.

3. cypher_query(): 기존 방식 헬퍼

def cypher_query(uri: str, username: str, password: str, query: str):
   driver = GraphDatabase.driver(uri, auth=(username, password))  # 호출할 때마다 새로 연결
   try:
      records, _, _ = driver.execute_query(query)
      return [dict(r) for r in records]
   finally:
      driver.close()

Neo4jDatabase를 거치지 않고 매 호출마다 새 드라이버 연결을 직접 생성하고 닫는다. 이렇게 설계한 이유는 Neo4j Aura(클라우드)의 유휴 연결 끊김 문제 때문이다. (Neo4j Aura는 클라우드 무료 플랜이라 리소스를 아끼기 위해 일정 시간 동안 활동이 없으면 연결을 끊어버린다.)
LLM 트리플 추출이 오래 걸리는 동안 기존 연결이 Aura 서버에 의해 끊길 수 있다. 매 호출마다 새 연결을 생성하면 이 문제를 회피할 수 있다.

  • driver.execute_query(): neo4j 드라이버의 단순 쿼리 실행 메서드
  • records, _, _: 결과, 요약, 키 세 가지를 반환하는데 결과만 사용함
  • [dict(r) for r in records]: 각 레코드를 딕셔너리로 변환
  • finally: 정상/예외 여부와 무관하게 드라이버 연결 반드시 종료

4. main(): 인덱스 빌드

def main():
   container = Container.get_instance()
   
   print("매칭 이력 벡터 인덱스 생성 중... (Neo4j 연결)")
   match_index = container.match_history_index()
   match_index.reset()
   match_index.build_index()
   
   neo4j_db = container.neo4j_database
  • Container.get_instance(): DI(Dependency Injection) 컨테이너에서 가져오기
    KnowledgeGraphQueryIndex를 직접 생성하지 않고 Container에서 가져온다.
    # 직접 생성하는 방식
    match_index = KnowledgeGraphQueryIndex(
       neo4j_uri="bolt://localhost:7687",
       neo4j_username="neo4j",
       neo4j_password="password",
    )
    # Container에서 가져오는 방식 (실제 코드)
    container = Container.get_instance()
    match_index = container.match_history_index()
    • Container.env에서 연결 정보를 읽어 싱글턴으로 관리하기 때문에, 스크립트에서 연결 정보를 하드코딩할 필요가 없다.
    • match_history_index()MatchHistoryIndex 인스턴스를 얻는다.
    • 이때 인스턴스만 가져올 뿐 인덱스를 로드하거나 빌드하지는 않는다.
  • reset()build_index() 순서로 호출하는 이유
    • build_index()만 단독으로 실행하면 Neo4j에 MatchRecord 노드가 계속 누적된다.
    • reset()으로 기존 노드를 전부 삭제한 뒤 빌드해야 중복 없이 깨끗한 상태로 인덱스를 재생성할 수 있다.

5. 3가지 비교 테스트 케이스

3가지 케이스를 리스트로 구성해두고 루프를 돌며 Cypher와 RAG를 같은 질문에 대해 나란히 실행한다.
각 케이스는 question(사람 언어), cypher(기존 방식), natural(RAG 방식) 세 필드로 구성된다.

  1. 케이스 1: 팔로워 대비 댓글 반응
    "question": "팔로워 대비 댓글 반응이 폭발적이었던 광고 사례는?",
    "cypher": """
       MATCH (i:Influencer)-[:PROMOTED]->(ad:Advertisement)
       WHERE ad.comments IS NOT NULL AND i.follower_count > 0
       WITH i, ad,
            toFloat(ad.comments) / i.follower_count * 100 AS ratio
       WHERE ratio > 0.5
       RETURN i.name AS influencer, i.follower_count AS followers,
              ad.product AS product, ad.comments AS comments,
              round(ratio * 100) / 100 AS comment_ratio_pct
       ORDER BY ratio DESC LIMIT 5
    """,
    "natural": "팔로워 대비 댓글 반응이 폭발적이었던 광고 사례"
    방식특징
    Cypherratio > 0.5라는 임의 threshold로 "폭발적"을 수치로 정의
    RAG"팔로워 대비 댓글 반응이 폭발적이었던" 자연어 표현 그대로 사용
    Cypher는 "폭발적"이라는 상대적 표현을 ratio > 0.5처럼 임의의 수치 기준으로 변환해야 한다. 기준이 주관적이라 다른 개발자가 보면 왜 0.5인지 알기 어렵다.
    RAG는 "폭발적"이라는 표현의 상대적인 맥락을 의미적으로 이해해 유사한 패턴의 이력을 찾는다.
  1. 케이스 2: 참여율이 높은 인플루언서

    "question": "참여율이 높은 인플루언서의 광고 캠페인 사례는?",
    "cypher": """
       MATCH (i:Influencer)-[:PROMOTED]->(ad:Advertisement)
       WHERE i.engagement_rate IS NOT NULL AND i.engagement_rate > 0.05
       RETURN i.name AS influencer,
              i.engagement_rate AS engagement_rate,
              ad.product AS product,
              ad.likes AS likes, ad.comments AS comments
       ORDER BY i.engagement_rate DESC LIMIT 5
    """,
    "natural": "참여율이 높은 인플루언서의 광고 캠페인 사례"
    방식특징
    Cypherengagement_rate > 0.05라는 임의 threshold로 "높다"를 수치로 정의
    RAG"참여율이 높은"이라는 표현을 맥락과 실제 성과 패턴으로 의미적으로 이해

    케이스 1과 구조는 마찬가지로 Cypher는 "높다"를 0.05라는 임의 기준으로 변환해야 한다.
    Cypher기준값이 달라지면 쿼리를 수정해야 하지만, RAG자연어 표현만으로 유사한 성과 패턴을 찾는다.

  2. 케이스 3: 소규모 인플루언서의 바이럴 효과

    "question": "구독자 규모가 적어도 높은 바이럴 효과를 낸 광고 사례는?",
    "cypher": """
       MATCH (i:Influencer)-[:PROMOTED]->(ad:Advertisement)
       WHERE i.follower_count < 200000 AND ad.likes > 5000
       WITH i, ad,
            toFloat(ad.likes) / i.follower_count * 100 AS like_ratio
       RETURN i.name AS influencer, i.follower_count AS followers,
              ad.product AS product, ad.likes AS likes,
              round(like_ratio * 100) / 100 AS like_ratio_pct
       ORDER BY like_ratio DESC LIMIT 5
    """,
    "natural": "구독자 규모가 작아도 높은 바이럴 효과를 낸 광고 사례"
    방식특징
    Cypherfollower_count < 200000 AND likes > 5000으로 "소규모"와 "바이럴"을 각각 임의 수치로 분리 정의
    RAG"소규모인데 바이럴"이라는 복합적인 맥락을 통합적으로 이해

    가장 복잡한 케이스이다. "소규모인데 바이럴 효과가 높다"는 하나의 문장이지만 Cypher는 이를 두 가지 조건으로 분리해 각각 수치로 변환해야 한다. 팔로워 20만 이하가 "소규모"인지, 좋아요 5000이 "바이럴"인지도 모두 주관적 판단이다. RAG는 이 복합적인 맥락을 하나의 자연어 표현 그대로 처리한다.

세 케이스를 통해 공통적으로 드러나는 차이는 하나이다. Cypher는 모호한 표현을 반드시 수치 기준으로 변환해야 하고, 그 기준이 주관적일 수밖에 없다. RAG는 "폭발적", "높은", "소규모" 같은 상태적 표현을 자연어 그대로 처리한다. 조건이 복잡하고 모호해질수록 Cypher의 작성 비용은 늘어나지만 RAG의 자연어 표현은 거의 달라지지 않는다.


🌟 llamaindex_tools.py 파일 분석

1. 파일 소개

이 파일은 LlamaIndex 인덱스 클래스들을 LangChain @tool 데코레이터로 감싸 Agent가 직접 호출할 수 있는 도구로 변환하는 역할을 한다. 크게 두 가지 역할을 한다.

  1. 도구 정의: 각 인덱스 클래스의 검색 메서드를 Agent 도구로 래핑한다.
  2. 도구 노출: LLAMAINDEX_TOOLS 리스트로 묶어 agent_factory.py에 일괄 전달한다.

파일 안에 도구가 5개 정의되어 있지만, 이 글에서는 매칭 이력 RAG와 직접 연결된 analyze_match_historysearch_similar_campaigns 두 가지에 집중한다.

이 파일의 전체 구조나 도구 공통 구조를 알고 싶다면, 이 글을 참고하면 된다.

2. analyze_match_history() 상세 분석

@tool
def analyze_match_history(query: str) -> str:
   """새로운 광고 캠페인과 유사한 과거 성공 사례를 검색하고 분석합니다.
   
   과거 인플루언서-광고 매칭 이력과 성과 데이터(좋아요, 댓글, 조회수, 평점)를 기반으로
   새 캠페인에 가장 적합한 인플루언서를 과거 실적 근거와 함께 추천합니다.
   예: "테크 가젯 리뷰 캠페인에서 높은 참여율을 보인 인플루언서",
       "뷰티 브랜드 광고에서 좋아요가 많았던 성공 사례",
       "패션 카테고리에서 조회수가 높았던 캠페인"
       
   Args:
      query: 새 캠페인 설명이나 찾고 싶은 과거 성공 사례를 자연어로 설명
             (예: "20대 여성 타겟 화장품 광고에서 성과가 좋았던 인플루언서")
             
   Returns:
      유사한 과거 매칭 이력과 성과 데이터 (JSON 형식)
   """
   from infrastructure.container from Container
   
   try:
      container = Container.get_instance()
      match_index = container.match_history_index()
      results = match_index.retrieve(query, top_k=5)
      return results
   except Exception as e:
      return f"매칭 이력 분석 오류: {str(e)}"

파라미터가 query 하나로, 자연어 문장을 자유롭게 입력받는다. "뷰티 브랜드 광고에서 좋아요가 많았던 성공 사례"처럼 원하는 조건을 자연어로 설명하면 된다.
카테고리명을 정확히 지정하거나 조건을 구조적으로 나눌 필요가 없어서, 광고주가 입력한 광고 컨셉 텍스트를 그대로 넘겨도 동작한다.

내부에서는 match_index.retrieve(query, top_k=5)를 호출한다. top_k=5로 고정되어 있어 항상 유사도 상위 5개의 과거 이력을 반환한다.
retrieve()가 JSON 문자열을 반환하기 때문에 Agent는 유사도 점수, 원문 텍스트, 성과 수치를 구조화된 형태로 받아 추천 근거로 활용할 수 있다.

docstring에서 주목할 부분은 예시 문장 3개이다. Agent는 docstring을 읽고 도구 사용 여부를 판단하기 때문에, 구체적인 예시가 있을수록 적절한 상황에서 도구를 선택할 확률이 높아진다.
"테크 가젯 리뷰", "뷰티 브랜드", "패션 카테고리"처럼 도메인별 예시를 나열한 것도 이 때문이다.

3. search_similar_campaigns() 상세 분석

@tool
def search_similar_campaigns(ad_concept: str, ad_item: str) -> str:
   """과거 유사 캠페인의 성공 사례를 검색합니다.
   
   광고주가 기획 중인 캠페인의 컨셉과 아이템을 입력하면,
   과거에 비슷한 캠페인에서 좋은 성과를 냈던 인플루언서와 실적 데이터를 찾아줍니다.
   어떤 인플루언서가 비슷한 캠페인에서 좋은 성과를 냈는지 확인할 때 사용하세요.
   예: ad_concept="촉촉한 봄 피부 케어", ad_item="리뉴얼 크림"
   
   Args:
      ad_concept: 광고 캠페인의 컨셉 (예: "MZ세대 타겟 건강한 라이프스타일")
      ad_item: 광고할 제품/아이템 (예: "프로틴 쉐이크")
   
   Returns:
      유사한 과거 캠페인 성공 사례와 성과 데이터 (JSON 형식)
   """
   from infrastructure.container import Container
   
   try:
      container = Container.get_instance()
      match_index = container.match_history_index()
      results = match_index.retrieve(f"{ad_concept} - {ad_item}", top_k=5)
      return results
   except Exception as e:
      return f"유사 캠페인 검색 오류: {str(e)}"

analyze_match_history와 가장 큰 차이는 파라미터가 두 개라는 점이다. 자유로운 자연어 문장 대신 ad_conceptad_item을 분리해서 받는다. 이는 이 시스템의 Agent 입력 구조와 맞닿아 있다.

Agent는 광고주로부터 광고 컨셉, 광고 아이템, 광고주명 세 가지를 입력받아 동작하기 때문에, 컨셉과 아이템이 이미 분리된 상태로 Agent에게 전달된다. 이를 그대로 활용할 수 있도록 파라미터를 분리해둔 것이다.
내부에서는 두 값을 " - "로 이어붙여 하나의 쿼리 문자열로 만든다.

match_index.retrieve(f"{ad_concept} - {ad_item}", top_k=5)
# 예: "촉촉한 봄 피부 케어 - 리뉴얼 크림"

결국 MatchHistoryIndex.retrieve()를 호출하는 것은 analyze_match_history와 동일하다. 차이는 누가 쿼리 문자열을 조합하느냐뿐이다.
analyze_match_history는 Agent가 자연어 문장을 직접 만들어서 넘기고, search_similar_campaigns는 이미 분리된 컨셉과 아이템을 도구 내부에서 조합한다.

analyze_match_historysearch_similar_campaigns
파라미터query 1개ad_concept, ad_item 2개
쿼리 조합Agent가 자연어로 작성도구 내부에서 " - "로 결합
적합한 상황열린 질문, 자유로운 탐색컨셉/아이템이 명확히 정해진 경우
내부 호출retrieve(query)retrieve(f"{ad_concept} - {ad_item}")

🌟 container.py 파일 분석

1. 파일 소개

이 파일은 Container 클래스 하나로 구성된 의존성 주입(DI) 컨테이너이다. Neo4j 연결, LlamaIndex 인덱스, Agent 서비스 등 모든 주요 인스턴스를 한 곳에서 생성하고 관리한다.
Container 클래스 자체싱글톤으로 동작해 애플리케이션 전반에서 동일한 인스턴스를 공유한다.
이 글에서는 MatchHistoryIndex와 직접 연관된 두 부분만 살펴본다.

  1. LlamaIndex 글로벌 설정: MatchHistoryIndex를 포함한 모든 LlamaIndex 인덱스가 공유하는 임베딩 모델과 LLM을 설정한다.
  2. match_history_index() 싱글톤: settings에서 Neo4j 연결 정보를 가져와 MatchHistoryIndex에 주입한다.

2. LlamaIndex 글로벌 설정

def __init__(self, settings=None):
   self.settings = settings or Settings()
   ...
   if self.settings.llm_provider == "openai":
      LlamaSettings.embed_model = OpenAIEmbedding(...)
      LlamaSettings.llm = OpenAILLM(...)
   elif self.settings.llm_provider == "anthropic":
      LlamaSettings.embed_model = OllamaEmbedding(...) # 임베딩은 Ollama 유지
      LlamaSettings.llm = AnthropicLLM(...)
   else: # ollama (기본값)
      LlamaSettings.embed_model = OllamaEmbedding(...)
      LlamaSettings.llm = OllamaLLM(...)
  • LlamaSettings란?
    LlamaSettings는 LlamaIndex의 전역 설정 객체이다. 여기에서 임베딩 모델(embed_model)과 LLM(llm)을 한 번 설정해두면 InfluencerVectorIndex, MatchHistoryIndex모든 인덱스 클래스가 별도 설정 없이 자동으로 이 값을 사용한다. 각 인덱스 클래스에서 임베딩 모델을 따로 주입받지 않아도 되는 이유가 여기에 있다.

  • __init__에서 설정하는 이유
    LlamaSettings는 전역 객체라 어디서든 접근할 수 있다.
    각 인덱스 클래스에서 설정할 수도 있지만, Container 생성 시점에 한 번만 초기화하면 이후 모든 LlamaIndex 컴포넌트가 동일한 설정을 공유한다. 싱글턴과 결합되어 애플리케이션 실행 중 설정이 중복 초기화되거나 변경되는 것을 막는다.
    Container.__init__에서 설정하기 때문에 컨테이너 인스턴스가 생성되는 시점에 즉시 적용된다.

  • llm_provider에 따라 세 가지로 분기

    providerembed_modelllm
    openaiOpenAiEmbeddingOpenAILLM
    anthropicOllamaEmbeddingAnthropicLLM
    ollama (기본값)OllamaEmbeddingOllamaLLM

    anthropic 분기에서 임베딩 모델로 OllamaEmbedding을 사용하는 점이 눈에 띈다. Anthropic은 별도의 임베딩 API를 제공하지 않기 때문에, LLM은 Anthropic을 쓰더라도 임베딩은 Ollama로 유지하는 혼합 구성을 택했다.

3. match_history_index() 싱글톤

def match_history_index(self) -> MatchHistoryIndex:
   """매칭 이력 벡터 인덱스 (싱글턴)"""
   if self._match_history_index is None:
      self._match_history_index = MatchHistoryIndex(
         neo4j_uri=self.settings.neo4j_uri,
         neo4j_username=self.settings.neo4j_username,
         neo4j_password=self.settings.neo4j_password,
      )
   return self._match_history_index
  • settings에서 Neo4j 연결 정보를 읽어 MatchHistoryIndex에 주입
    MatchHistoryIndex연결 정보만 받아 내부에서 드라이버를 직접 생성하는 구조이기 때문에, neo4j_database 인스턴스가 아닌 URI와 계정 정보를 개별적으로 전달한다.
    influencer_vector_index()neo4j_database 인스턴스를 통째로 넘기는 것과 대비된다.

  • if None 체크로 lazy 초기화
    _match_history_indexNone이면 인스턴스를 생성하고, 이후에는 같은 인스턴스를 재사용한다.
    Container 자체가 싱글턴이므로 이 if None 체크만으로도 MatchHistoryIndex가 사실상 싱글턴처럼 동작한다.


🌟 회고

📝 두 데이터 소스를 하나의 인덱스로 합치는 과정

PROMOTED 관계(과거 광고 데이터)MATCHED_WITH 관계(이 플랫폼에서 매칭된 광고 데이터) 두 소스를 하나의 MatchHistoryIndex로 통합하면서 데이터 품질 문제가 드러났다. 광고주명이 "확실하지 않음"으로 입력된 저품질 데이터가 섞여 있었고, 제품명이 없는 레코드도 있었다. 이런 데이터가 그대로 임베딩되면 검색 품질을 오염시키기 때문에 _load_match_documents() 내부에 필터링 로직을 추가했다.

if not data.get("product") or "확실하지 않음" in str(data.get("advertiser_name", "")):
   continue

RAG 검색 품질은 결국 인덱스에 넣는 데이터 품질에 직결된다는 걸 체감했다.

📝 비교 테스트 케이스 재설계

초기에는 Category {name: 'Beauty'}처럼 카테고리명으로 필터링하는 쿼리를 Cypher 비교 대상으로 썼는데, 실제 데이터의 카테고리명이 한국어로 바뀌면서 쿼리가 동작하지 않았다. 이를 계기로 비교 케이스 자체를 재설계했다.
카테고리 기반 비교는 "Cypher도 한국어 카테고리명으로 바꾸면 동작한다"는 점에서 RAG의 강점이 잘 드러나지 않았다. 대신 "폭발적", "높은", "소규모"처럼 Cypher로는 임의 threshold 없이 표현할 수 없는 상대적 개념을 비교 대상으로 바꾸니 두 방식의 차이가 훨씬 명확해졌다.

# 변경 전: 카테고리 기반 (카테고리명만 맞추면 Cypher도 동작)
MATCH (i)-[:SPECIALIZES_IN]->(c:Category {name: 'Beauty'})

# 변경 후: 상대적 표현 (Cypher는 반드시 수치 기준이 필요)
WHERE ratio > 0.5  # "폭발적"을 0.5%로 임의 정의

📝 비교 테스트의 한계

build_match_history_index.py의 비교 테스트는 Cypher 직접 질의와 RAG 자연어 검색을 나란히 실행해 결과를 출력한다. 그런데 막상 결과를 보면 "어느 쪽이 더 잘 나왔는가"를 판단하는 것 자체가 쉽지 않다.
Cypher는 수치 기준이 명확하지만 그 기준 자체가 주관적이다. ratio > 0.5가 "폭발적"인지, engagement_rate > 0.05가 "높은"인지는 보는 사람마다 다르다. RAG는 자연어로 유사한 이력을 찾아주지만, 반환된 결과가 실제로 "의미적으로 적합한 사례"인지를 판단하는 것도 결국 사람의 주관이다.
결국 두 방식 모두 출력 결과를 사람이 주관적으로 평가할 수밖에 없어서, 비교 테스트가 어느 쪽이 우월하다는 결론을 내리기 어렵다.
다만 RAG의 의미는 다른 곳에 있다. Cypher는 질문이 달라질 때마다 쿼리를 새로 작성해야 하지만, RAG는 Agent가 광고주 입력을 받아 스스로 질문을 만들고 과거 이력에서 맥락에 맞는 사례를 찾아낸다. 정확한 수치 기준 없이도 "이 캠페인과 비슷한 상황에서 잘 됐던 사례"를 알아서 찾아준다는 것, 그리고 그 결과를 Agent가 추천 근거로 자연스럽게 활용할 수 있다는 것이 핵심이다.

📝 data['key'] vs. data.get()

Document 텍스트를 구성할 때 일부 속성은 data['key']로 직접 접근하고, 일부는 data.get('key', 기본값)으로 접근하는 방식이 혼재되어 있었다. 현재는 seed 데이터 기준이라 모든 속성이 존재하지만, 실제 데이터로 전환하면 없는 속성에서 KeyError가 발생할 수 있다. seed 데이터 삭제 후 실제 데이터로 재빌드할 때 없어질 수 있는 속성을 전부 data.get()으로 방어하는 작업이 필요하다.

📝 프로젝트 데이터 구성

이 프로젝트에서 매칭 이력 인덱스는 두 가지 성격이 다른 데이터 소스를 함께 활용한다.

  1. CollectedAdHistory (PROMOTED 관계)

    인스타그램에서 실제로 집행된 광고 게시물을 수집한 데이터이다. 인플루언서가 어떤 광고주의 어떤 제품을 홍보했는지, 좋아요/댓글/조회수 같은 콘텐츠 성과 수치가 포함된다. 현재 MatchRecord 노드는 이 데이터를 기반으로 빌드되어 있지만, 데이터 양이 충분하지 않아 검색 품질이 제한적이다. RAG 검색 정확도를 높이려면 더 많은 과거 광고 이력 데이터를 수집해 추가적으로 적재한 뒤 인덱스를 재빌드해야 한다.

    초기에는 팀 노션에서 각자 광고 게시물을 조사해 붙여넣고 CSV로 내보내는 방식으로 데이터를 수집했기 때문에 'Notion 데이터'라고 불렀다. 이후 자동화 수집 스크립트가 생기면서 Notion을 거치지 않아도 동일한 형태의 CSV를 만들 수 있게 됐다. 수집 방식과 무관하게 "과거에 실제로 집행된 광고 이력"이라는 본질이 중요하기 때문에 CollectedAdHistory로 이름을 바꿨다.

  1. PlatformAdMatch (MATCHED_WITH 관계)
    이 플랫폼을 통해 실제로 매칭이 성사된 광고 계약 데이터이다. 예산, 계약금, 매칭 상태, 평점처럼 비즈니스 거래 관점의 정보가 포함된다. 개발 단계에서는 seed 데이터로 구성했지만 seed 데이터는 이미 삭제된 상태이다. 앞으로 플랫폼이 실제로 운영되면서 광고주-인플루언서 매칭이 성사될 때마다 이 관계 데이터가 쌓이게 된다.

    원래는 플랫폼 내부 Repository(neo4j_repository.py)를 통해 저장되는 데이터라는 의미에서 'Repository 데이터'라고 불렀다. 1번 데이터 이름을 CollectedAdHistory로 바꾸면서 두 이름이 (수식어)Ad(종류) 형태로 일관성을 갖도록 PlatformAdMatch로 함께 변경했다.

CollectedAdHistoryPlatformAdMatch
관계PROMOTEDMATCHED_WITH
성격콘텐츠 성과 중심비즈니스 거래 중심
수집 방법인스타그램 광고글 수집플랫폼 매칭 성사 시 생성
현재 상태추가 적재 필요실제 데이터로 채워야 함

📝 남은 과제

현재 MatchRecord 노드는 Notion 데이터(PROMOTED 관계) 기반으로만 구성되어 있다. 쿼리 2인 Repository 데이터(MATCHED_WITH 관계)플랫폼을 통해 실제 매칭이 성사되어야 쌓이는 데이터라 현재는 비어있는 상태이다.
플랫폼이 운영되면서 MATCHED_WITH 관계 데이터가 누적되면 build_match_history_index.py를 재실행해 인덱스에 반영해야 한다. "이 플랫폼을 통해 성공한 캠페인 사례"를 근거로 한 추천은 이 데이터가 쌓일수록 고도화된다. RAG 매칭 분석의 완성도는 결국 플랫폼 운영 데이터가 얼마나 축적되느냐에 달려 있다.

0개의 댓글