[TIL-0301] 임베딩(Embedding)이란?

jiny·2026년 3월 1일

AI Agent 실습

목록 보기
6/21

🌟 개요

AI Agent 기반 인플루언서 매칭 플랫폼을 개발하던 중, 인플루언서 프로필 텍스트를 벡터로 변환해 Neo4j에 저장하는 작업을 진행했다. LlamaIndexbge-m3 모델을 활용해 1024차원 벡터 임베딩을 구현했고, 코드도 정상적으로 동작했다.
그런데 스터티 멘토님께서 코드 리뷰 중에 이런 질문을 하셨다.

"1024차원으로 임베딩한다고 했는데, 1024차원이 정확히 무슨 뜻인지 설명해볼 수 있어요?"

순간 말문이 막혔다. 코드는 짜고 있었고, 검색도 잘 동작하고 있었는데 정작 그게 왜 동작하는지, 1024차원이 실제로 무엇을 의미하는지 설명하지 못했다.
멘토님께서 임베딩은 요즘 AI 개발에서 핵심 개념이기 때문에, 확실히 알고 가는 게 좋다고 조언해 주셨다. 그 말이 계기가 되어 임베딩 개념에 대해서 자세히 정리하게 되었다.


🌟 임베딩이란?

임베딩(Embedding)이란 텍스트, 이미지, 소리 등의 데이터를 의미를 보존한 채로 숫자 배열(벡터)로 변환하는 것이다.

🤔 컴퓨터는 텍스트를 이해하지 못한다

임베딩이 왜 필요한지 이해하려면 먼저 이 사실에서 출발해야 한다.
우리는 "강아지"라는 단어를 보면 귀엽고 털 있는 동물(🐶)을 떠올린다.
그런데 컴퓨터에게 "강아지"는 그냥 \uac15\uc544\uc9c0라는 문자 코드 3개의 나열일 뿐이다.
그러면 컴퓨터가 텍스트의 의미를 다루게 하려면 어떻게 해야 할까?
답은 텍스트를 숫자로 바꾸는 것이다. 컴퓨터가 잘 하는 게 숫자 계산이기 때문이다.

🤔 숫자로 바꾸는 건 예전에도 있었다 (ASCII)

사실 텍스트를 숫자로 변환하는 개념 자체는 새로운 게 아니다. ASCII 코드가 그 예이다.

  • 'A' → 65
  • 'B' → 66
  • '강' → 44032
  • '아' → 44000
  • '지' → 51648

문자 하나에 숫자 하나를 1:1로 대응시켜서 단순하고 명확하다. 그런데 이 방식에는 치명적인 문제가 있다.

  • "강아지" → [44032, 44000, 51648]
  • "멍멍이" → [47549, 47549, 51060]

숫자가 완전히 다르다. 그런데 우리는 이 두 단어가 같은 대상(🐶)을 가리킨다는 걸 안다.
ASCII 코드는 단지 문자를 식별하는 번호일 뿐, 의미를 담지 않는다. "강아지"가 "고양이"보다 "멍멍이"에 더 가깝다는 사실을 숫자에서 전혀 알 수 없다.

🤓 임베딩: 의미까지 담긴 숫자 배열

임베딩은 텍스트를 숫자 하나가 아니라 여러 숫자의 배열(벡터)로 변환하되, 그 숫자들이 텍스트의 의미를 담도록 설계되어 있다.

  • "강아지" → [-0.21, 0.83, 0.45, -0.11, ...] (1024개 숫자)
  • "멍멍이" → [-0.19, 0.81, 0.47, -0.13, ...] (1024개 숫자)
  • "고양이" → [-0.18, 0.79, -0.22, 0.56, ...] (1024개 숫자)
  • "자동차" → [0.67, -0.34, 0.12, 0.88, ...] (1024개 숫자)

"강아지"와 "멍멍이"의 벡터는 비슷하고, "자동차"는 많이 다르다. 숫자 배열 자체에 의미적 거리가 담겨 있다.
이게 ASCII와 임베딩의 결정적 차이이다.

ASCII임베딩
출력 형태숫자 1개숫자 배열 (벡터)
"강아지"와 "멍멍이"완전히 다른 숫자비슷한 벡터
의미 보존의미 보존 X의미 보존 O
용도문자 식별의미 비교, 검색

🤔 어떻게 의미가 담길까?

임베딩 모델은 수억 개의 문장 쌍으로 학습된다.

  • ("강아지는 귀엽다", "멍멍이는 귀엽다") ➡️ 비슷한 의미 ➡️ 가까운 벡터가 되도록 학습
  • ("강아지는 귀엽다", "자동차는 빠르다") ➡️ 다른 의미 ➡️ 먼 벡터가 되도록 학습

학습을 거치면서 모델은 "비슷한 의미 = 비슷한 방향의 벡터"라는 규칙을 스스로 체득한다. 우리가 의미를 직접 정의해준 게 아니라, 대규모 데이터에서 모델이 자연스럽게 습득한 것이다.
그 결과, 학습 과정에서 한 번도 본 적 없는 새로운 문장을 넣어도 의미적으로 적절한 위치의 벡터를 출력할 수 있다.

즉, 임베딩은 단순히 "텍스트를 숫자로 바꾸는 것"이 아니라 "텍스트의 의미를 숫자 공간에 옮겨놓는 것"이다.


🌟 차원(Dimension)이란?

차원(Dimension)이란 하나의 벡터를 구성하는 숫자의 개수이다.
임베딩에서 차원은 텍스트의 의미를 표현하는 특징(feature)의 수를 의미한다.

🤔 차원을 직관적으로 이해하기

차원이라는 말이 낯설게 느껴질 수 있다. 숫자 몇 개짜리 배열인지로 생각하면 간단하다.

  • 1차원: 숫자 1개
    [0.5]
    ➡️ 하나의 축 위에 놓인 점이다. 위치를 표현하려면 "얼마나 오른쪽인가" 하나만 알면 된다.

  • 3차원: 숫자 3개
    [x, y, z] → [0.3, -0.7, 0.9]
    ➡️ 우리가 사는 공간이다. 어떤 점의 위치를 표현하려면 가로(x), 세로(y), 높이(z) 세 값이 필요하다.

  • 1024차원: 숫자 1024개
    [-0.037, -0.040, 0.003, -0.030, -0.005, ...] (총 1024개)
    ➡️ 눈으로 볼 수는 없지만 원리는 같다. 1024개의 축이 있는 공간 위의 점이다. 위치를 표현하려면 1024개의 값이 필요하다.

🤔 임베딩에서 차원이 의미하는 것

3차원 공간에서 x, y, z 각각이 "가로 위치", "세로 위치", "높이"를 나타내듯이, 임베딩의 각 차원도 텍스트의 어떤 특징을 나타낸다.
다만 임베딩의 차원들은 "1번 차원 = 감정적 톤", "2번 차원 = 주제" 이런 식으로 사람이 직접 이름을 붙인 게 아니다. 모델이 학습 과정에서 텍스트를 잘 구분하기 위해 스스로 발견한 특징들이다.
그래서 각 차원이 정확히 무엇을 의미하는지는 알 수 없지만, 1024개의 특징을 조합하면 텍스트의 의미를 정밀하게 표현할 수 있다.
결국 차원 수 = 텍스트의 의미를 몇 가지 특징으로 쪼개서 표현하는가이다.

🤔 차원이 높을수록 좋을까?

직관적으로는 차원이 높을수록 더 세밀하게 의미를 표현할 수 있다. 그런데 마냥 높이기만 하면 되는 게 아니다.

차원의미 표현력저장 공간검색 속도
낮음 (384)거칢적게 차지빠름
중간 (768)적당보통보통
높음 (1024)세밀함많이 차지상대적으로 느림
매우 높음 (3072)매우 세밀함많이 차지느림

인플루언서 1명의 벡터를 저장한다고 하면

  • 1024차원 × 4바이트(float32) = 약 4KB
  • 인플루언서 100명 = 약 400KB ➡️ 감당 가능
  • 인플루언서 100만 명 = 약 4GB ➡️ 부담

차원을 높이면 정확도는 올라가지만, 저장 비용과 검색 속도가 함께 늘어난다.

🤔 bge-m3은 왜 1024차원일까?

이 프로젝트에서 사용하는 bge-m3 모델은 1024차원을 사용한다. 이는 정확도와 비용 사이의 균형점을 찾은 결과이다.

  • 384, 768차원: 가볍고 빠르지만 한국어처럼 문맥이 중요한 언어에서 의미 손실이 생길 수 있다.
  • 1024차원 (bge-m3): 한국어를 포함한 다국어를 정밀하게 표현하면서도 실용적인 저장 비용을 유지한다.
  • 3072차원 (OpenAI text-embedding-3-large): 정확도는 높지만 저장 공간이 3배이고, API 호출 비용도 발생한다.

특히 이 프로젝트에서는 Ollama로 로컬 실행을 하기 때문에, API 비용 없이 1024차원의 충분한 정확도를 얻을 수 있다는 점에서 bge-m3이 적합한 선택이었다.

정리하면, 1024차원이란 텍스트 하나를 1024개의 특징값으로 분해해 1024개의 축을 가진 공간 위의 좌표로 표현한 것이다.


🌟 임베딩 모델이 하는 일

임베딩 모델텍스트를 벡터로 변환하는 함수이다.
핵심은 단순 변환이 아니라 "의미가 비슷한 텍스트는 비슷한 벡터로" 변환한다는 것이다.

🤔 임베딩 모델의 학습 과정

임베딩 모델은 수억 개의 문장 쌍으로 학습된다.

  • 비슷한 의미 쌍: 벡터가 가까워지도록 학습
    • ("강아지는 귀엽다", "멍멍이는 귀엽다")
    • ("뷰티 크리에이터", "메이크업 유튜버")
    • ("오늘 날씨 좋다", "오늘 날씨가 맑네")
  • 다른 의미 쌍: 벡터가 멀어지도록 학습
    • ("강아지는 귀엽다", "자동차는 빠르다")
    • ("뷰티 크리에이터", "게임 스트리머")

수억 번의 이 과정을 반복하면서 모델은 스스로 "어떤 텍스트가 의미적으로 가까운지"를 학습한다. 우리가 직접 규칙을 정해준 게 아니라, 데이터에서 패턴을 자연스럽게 습득한 것이다.

🤔 저장할 때는 비교 대상이 없다

임베딩을 처음 접하면 이런 의문이 생길 수 있다.

"벡터를 저장할 때는 무엇과 비교해서 저장하는거지?"

답은, 저장할 때는 아무것과도 비교하지 않는다.

# 저장 단계: 변환만 함. 비교 없음.
"[전문분야: 뷰티] 이사배: 메이크업 전문 크리에이터..."
         ↓ bge-m3 모델 통과
[-0.037, -0.040, 0.003, ...] (1024개)
         ↓
Neo4j에 저장 (끝)

모델은 그냥 주어진 텍스트를 벡터 공간의 좌표로 변환할 뿐이다. 비교는 나중에 검색할 때 일어난다.

# 검색 단계: 그때 비로소 비교
"친환경 뷰티 크리에이터" → 벡터 변환 → 저장된 벡터 전체와 유사도 계산

🤔 같은 모델을 써야 하는 이유

저장할 때와 검색할 때 반드시 같은 임베딩 모델을 써야 한다. 이게 왜 중요한지는 비유로 설명하면 쉽다.
서울 지도(bge-m3)와 뉴욕 지도(text-embedding-ada)로 각각 좌표를 찍는다고 생각해 보자.

  • bge-m3으로 저장: "뷰티 크리에이터" → (37.5, 127.0) ➡️ 서울 기준 좌표
  • ada-002로 검색: "메이크업 유튜버" → (40.7, -74.0) ➡️ 뉴욕 기준 좌표

두 좌표의 거리를 계산해봤자 아무 의미가 없다. 기준 자체가 다르기 때문이다.
모델마다 벡터 공간을 구성하는 방식이 다르다. 같은 모델을 써야만 같은 공간 안에서 좌표가 찍히고, 그래야 비교가 의미를 가진다.
이 프로젝트에서 저장할 때도 bge-m3, 검색할 때도 bge-m3을 쓰는 이유가 바로 이것이다.

🤓 bge-m3 모델을 선택한 이유

임베딩 모델을 고를 때 이 프로젝트에서 중요했던 기준은 세 가지였다.

  1. 한국어 지원
    인플루언서 설명문, 카테고리, 검색 쿼리가 모두 한국어이다. 영어 특화 모델(nomic-embed-text, mxbai-embed-large 등)은 한국어 문맥을 제대로 표현하지 못할 수 있다. bge-m3은 100개 언어를 단일 모델로 처리하는 다국어 모델로, 한국어 임베딩 품질이 검증되어 있다.

  2. 로컬 실행 가능
    Ollama를 통해 로컬에서 무료로 실행할 수 있다. 인플루언서 수십 명의 프로필을 임베딩할 때마다 API 비용이 발생하지 않는다.

  3. 1024차원으로 충분한 정확도
    앞서 설명했듯 bge-m3은 1024차원으로 설계된 모델이며, 이 차원으로도 한국어 다국어 벤치마크에서 유료 모델과 비교해 크게 뒤쳐지지 않는 성능을 보인다. 비용 없이 충분한 정확도를 얻을 수 있다는 점에서 이 프로젝트에 적합한 선택이었다.


🌟 왜 의미가 비슷하면 벡터가 비슷할까? (코사인 유사도)

코사인 유사도두 벡터 사이의 각도로 유사도를 측정하는 방법이다.
각도가 작을수록(방향이 같을수록) 유사도가 1에 가까워진다.

🤔 방향이 같으면 의미가 비슷하다

임베딩 모델은 "비슷한 의미 = 비슷한 방향의 벡터"가 되도록 학습된다고 했다. 그렇다면 두 벡터가 얼마나 비슷한지는 어떻게 측정할까?
직관적으로 생각하면 두 점 사이의 거리를 재면 될 것 같지만, 실제로는 거리가 아닌 각도를 사용한다.
2차원으로 단순화해서 보면 이렇다."강아지"와 "멍멍이"는 비슷한 방향을 가리키고, "자동차"는 전혀 다른 방향을 가리킨다.

🤔 코사인 유사도 공식

  • A·B: 두 벡터의 내적 (방향이 얼마나 같은지)

  • ||A||, ||B||: 각 벡터의 크기(길이)

  • 결과값: -1~1 (임베딩에서는 보통 0~1)

    각도코사인 값의미
    1.0완전히 같은 방향 (동일한 의미)
    45°0.7어느 정도 비슷
    90°0.0전혀 관계 없음
    180°-1.0정반대 의미

🤔 왜 거리가 아닌 각도일까?

유클리드 거리(두 점 사이의 직선 거리)를 쓰지 않는 데는 이유가 있다.
긴 문장과 짧은 문장을 비교하는 상황을 생각해 보자.

  • A: "뷰티"
  • B: "뷰티 크리에이터 스킨케어 메이크업 화장품 리뷰 유튜브"

B는 A보다 훨씬 많은 단어를 담고 있어서 벡터의 크기(길이)가 훨씬 크다. 유클리드 거리로 재면 두 벡터는 멀리 떨어져 보인다. 그런데 방향은 비슷하다. 둘 다 뷰티 영역을 가리키고 있기 때문이다.

  • 유클리드 거리: A와 B가 멀다 (텍스트 길이 차이 때문) ➡️ 부정확
  • 코사인 유사도: A와 B가 비슷하다 (방향이 같으니까) ➡️ 정확

코사인 유사도는 벡터의 크기를 분모로 나눠서 제거하기 때문에, 텍스트 길이와 무관하게 순수하게 의미의 방향만 비교한다.


🌟 실제 프로젝트에서의 임베딩 흐름

앞서 설명한 개념들이 실제 코드에서 어떻게 구현되는지 살펴본다.
이 프로젝트는 인플루언서 매칭 플랫폼으로, 인플루언서 프로필을 임베딩해 자연어 쿼리로 검색할 수 있도록 구현했다.

💾 저장 흐름: 텍스트 → 벡터 → Neo4j

  1. 인플루언서 데이터를 텍스트 문서로 변환

    # /adapters/outbound/persistence/llamaindex/influencer_index.py
    
    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}",
        metadata={
            "influencer_id": str(influencer.id),
            "base_price": float(influencer.base_price.amount),
            "total_followers": influencer.total_followers,
            "platforms": list(influencer.platform_dict.keys()),
            "categories": influencer.category_names,
        },
    )

    인플루언서 객체를 그대로 임베딩하는 게 아니라, 의미 있는 정보만 골라 자연어 문장 형태의 텍스트로 만든다.
    카테고리를 문서 앞에 [전문분야: 뷰티] 형태로 명시한 이유는 임베딩 벡터가 카테고리 방향으로 강하게 형성되도록 유도하기 위해서이다.

  1. bge-m3 모델 통과 → Neo4j 저장
    storage_context = StorageContext.from_defaults(vector_store=self._vector_store)
    self._index = VectorStoreIndex.from_documents(
    	documents, storage_context=storage_context
    )
    LlamaIndex가 내부적으로 각 Document의 text를 bge-m3에 통과시켜 1024개의 float 값을 생성하고, Neo4j의 InfluencerEmbedding 노드에 저장한다.
    실제 Neo4j Aura에서 조회하면 이렇게 저장된 것을 확인할 수 있다.이 숫자들이 1024차원 벡터 공간에서 각 인플루언서의 좌표이다.

🔎 검색 흐름: 쿼리 → 벡터 → 유사도 계산 → 반환

# /adapters/outbound/persistence/llamaindex/influencer_index.py

def retrieve(self, query_text: str, top_k: int = 10) -> str:

	# 1. 검색 쿼리를 동일한 bge-m3으로 임베딩
    query_embedding = LlamaSettings.embed_model.get_query_embedding(query_text)
    
    # 2. Neo4j Cypher로 벡터 유사도 검색
    with driver.session() as session:
    	records = session.run(
        	"CALL db.index.vector.queryNodes($index_name, $top_k, $embedding) "
            "YIELD node, score "
            "RETURN node.text AS text, node.influencer_id AS influencer_id, score",
            index_name="influencer_vector_idx",
            top_k=top_k,
            embedding=query_embedding,
        )

LlamaIndex의 래퍼를 거치지 않고 Cypher를 직접 호출한다.
이렇게 한 이유는 LlamaIndex 내부의 조건 분기에서 쿼리 임베딩이 제대로 적용되지 않는 경우가 있었기 때문이다. 직접 임베딩을 생성하고 직접 Neo4j에 넘기면 이 문제를 완전히 우회할 수 있다.

전체 흐름을 정리하면 다음과 같다.

🤓 metadata를 함께 저장한 이유

influencer_id, categories, platforms 같은 메타데이터를 벡터 노드에 함께 저장한 것도 중요한 설계 결정이다.
벡터 검색 결과로는 유사도 점수와 텍스트만 반환된다. 이 결과로 실제 인플루언서를 특정하거나 카테고리 정보를 활용하려면 추가 조회가 필요하다. influencer_id를 메타데이터에 담아두면 검색 결과에서 바로 원본 인플루언서를 찾아올 수 있다.
categories를 함께 저장한 이유는 더 중요하다. 이후 하이브리드 검색에서 Neo4j 그래프의 카테고리 관계를 벡터 검색 결과와 조인할 때 활용하기 위해서이다.


🌟 Neo4j Vector Store와 임베딩의 결합

🤔 일반 DB에서는 벡터 검색이 안 된다

일반적인 관계형 DB(MySQL, PostgreSQL 등)에서 텍스트 검색은 이런 식이다.

SELECT * FROM influencers
WHERE description LIKE '%뷰티%'

단어가 정확히 포함되어 있는지만 본다. "메이크업 유튜버"를 검색하면 "뷰티 크리에이터"는 찾을 수 없다. 의미가 비슷해도 문자열이 다르면 결과에서 누락된다.
벡터 검색은 다르다. 벡터 인덱스에 저장된 1024차원 좌표들 중에서 쿼리 벡터와 방향이 가장 비슷한 것을 찾아온다. 문자열 일치가 아니라 의미적 거리로 검색하기 때문에 "메이크업 유튜버"로 검색해도 "뷰티 크리에이터"가 결과에 나온다.

일반 DB (LIKE)벡터 DB
검색 기준문자열 일치의미적 유사도
"뷰티" → "메이크업"못 찾음찾음
인덱스 구조B-TreeHNSW 등 근사 최근접 탐색

🤓 Neo4j는 그래프 DB이면서 벡터 스토어이다

이 프로젝트에서 Neo4j를 선택한 핵심 이유는 그래프 DB와 벡터 스토어를 동시에 쓸 수 있다는 것이다.
Neo4j에는 두 종류의 데이터가 함께 저장되어 있다.

  • 그래프 구조
    (Influencer {id: "ac05..."})-[:SPECIALIZES_IN]->(Category {name: "뷰티"})
    (Influencer {id: "ac05..."})-[:ACTIVE_ON]->(Platform {name: "instagram"})
  • 벡터 인덱스
    (InfluencerEmbedding {
    	influencer_id: "ac05...",
       embedding: [-0.037, -0.040, ...],1024차원 벡터
       text: "[전문분야: 뷰티] 임민수: ..."
    })
    관계형 데이터(누가 어떤 카테고리에 속하고, 어떤 플랫폼에서 활동하는지)와 벡터 데이터(텍스트의 의미)가 같은 DB 안에 공존한다.

🤔 CALL db.index.vector.queryNodes() 동작 원리

벡터 검색 쿼리는 이렇게 생겼다.

CALL db.index.vector.queryNodes($index_name, $top_k, $embedding)
YIELD node, score
RETURN node.text AS text, node.influencer_id AS influencer_id, score

Neo4j는 내부적으로 HNSW(Hierarchical Navigable Small World) 알고리즘을 사용해 전체 벡터를 순회하지 않고도 빠르게 가장 가까운 벡터를 찾는다. 수천 개의 벡터가 있어도 전수 비교가 아니라 근사 탐색으로 효율적으로 처리한다.
결과로 node(벡터 노드)와 score(코사인 유사도)가 반환된다.

🪄 HNSW(Hierarchical Navigable Small World)이란?

벡터 검색에서 "1000명 중 가장 유사한 3명을 찾아라"라는 요청이 들어왔을 때, 가장 단순한 방법은 1000명 전부와 유사도를 계산하는 것이다. 이를 전수 탐색(Brute Force)이라고 한다.
1000명이면 괜찮지만 100만 명이 되면 매 검색마다 100만 번 계산이 필요하다. HNSW는 이 문제를 해결하기 위한 근사 최근접 탐색(ANN) 알고리즘이다.

  • 핵심 아이디어: 계층적 그래프
    HNSW는 벡터들을 여러 층(Layer)으로 구성된 그래프로 만든다.
    • 위층: 멀리 있는 노드끼리만 연결 → 빠르게 대략적인 위치 파악
    • 아래층: 가까운 노드끼리 촘촘하게 연결 → 정밀하게 최근접 탐색
  • 탐색 과정
    마치 지도에서 목적지를 찾을 때 먼저 국가 → 도시 → 동네 → 건물 순으로 좁혀가는 것과 같다.
  • 전수 탐색 vs. HNSW

    전수 탐색HNSW
    100만 벡터 검색100만 번 계산수백~수천 번 계산
    정확도100% 정확약 95~99% 정확 (근사치)
    속도느림매우 빠름
    메모리적음그래프 구조 추가 필요

    "Hierarchical"은 계층 구조, "Navigable Small World"는 몇 번의 도약으로 어떤 노드에도 도달할 수 있는 그래프 특성을 의미한다.

  • 이 프로젝트에서는?
    CALL db.index.vector.queryNodes('influencer_vector_idx', 10, $embedding)
    이 Cypher를 실행하면 Neo4j 내부에서 HNSW로 구축된 influencer_vector_idx 인덱스를 탐색한다. 인플루언서가 수천 명이 되어도 전수 비교 없이 빠르게 상위 10명을 찾아올 수 있는 이유이다.

✨ 그래프 + 벡터를 결합한 하이브리드 검색

Neo4j에 벡터를 저장한 진짜 이점은 벡터 검색 결과에 그래프 쿼리를 바로 이어 붙일 수 있다는 것이다.

CALL db.index.vector.queryNodes('influencer_vector_idx', 15, $embedding)
YIELD node, score
OPTIONAL MATCH (i:Influencer {id: node.influencer_id})-[:SPECIALIZES_IN]->(c:Category)
WITH node, score, collect(DISTINCT c.name) AS categories
RETURN node.text, node.influencer_id, score, categories

벡터 검색으로 후보 15명을 뽑고, 바로 이어서 Neo4j 그래프에서 각 인플루언서의 카테고리 관계를 조인한다. 별도의 API 호출이나 중간 처리 없이 하나의 쿼리로 처리된다.
이 카테고리 정보는 이후 LLM 재순위 단계에서 활용한다.

후보 15명 (벡터 유사도) + 각 인플루언서의 카테고리 (그래프 관계)
           ↓
LLM: "캠페인 도메인과 인플루언서 전문분야가 일치하는 상위 3명 선택"
           ↓
최종 추천 3명

순수 벡터 검색만 쓰면 의미적으로 비슷한 텍스트를 찾을 수 있지만, 도메인이 혼동되는 경우가 있다.
그래프 관계 정보를 결합하면 "이 인플루언서가 실제로 뷰티 카테고리에 속하는지"를 구조적으로 검증할 수 있다.

🤓 ChromaDB 대신 Neo4j를 선택한 이유

처음에는 벡터 저장소로 ChromaDB를 사용했다. 로컬 파일로 저장되는 구조였다.

  • 기존 구조: Neo4j (그래프 데이터) + ChromaDB (벡터 데이터, ./chroma_db/)
  • 현재 구조: Neo4j (그래프 데이터 + 벡터 데이터 통합)

ChromaDB를 제거한 이유는 단순하다. 외부 저장소를 하나로 줄이기 위해서이다.
ChromaDB를 유지하면 배포할 때 Neo4j와 ChromaDB 두 곳을 관리해야 하고, ./chroma_db/ 폴더를 서버에 함께 올려야 한다. Neo4j는 이미 클라우드(Aura)에 올라가 있는데 벡터만 로컬 파일로 따로 관리하는 건 비효율적이었다.
Neo4j 5.x부터는 벡터 인덱스를 네이티브로 지원하기 때문에, 그래프 데이터가 있는 Neo4j에 벡터 인덱스까지 함께 저장하면 단일 저장소로 통합이 가능하다. 코드도 단순해지고, 앞서 설명한 하이브리드 검색도 자연스럽게 구현된다.


🌟 회고

이제 임베딩과 차원에 대해서 공부했으니, 1024차원이 무엇을 의미하는지 설명할 수 있다.

"1024차원 임베딩이란, 텍스트의 의미를 1024개의 특징값으로 분해해 고차원 공간의 좌표로 표현한 것입니다. 같은 모델을 거친 벡터끼리는 코사인 유사도로 의미적 거리를 계산할 수 있고, 방향이 비슷할수록 의미가 비슷한 텍스트입니다."

요즘은 AI 툴의 도움을 받아 코드를 작성하는 게 자연스러운 개발 방식이 됐다. 이 프로젝트도 마찬가지였다. 임베딩 관련 코드도 AI의 도움을 받아 빠르게 구성했고, 실제로 잘 돌아갔다.
문제는 거기서 멈춘 것이었다. AI 툴을 활용하는 건 분명히 생산성을 높여준다. 하지만 AI가 작성한 코드라도 그 코드가 정확히 어떤 역할을 하는지, 왜 이렇게 동작하는지는 내가 알고 있어야 한다. 특히 임베딩처럼 AI 개발의 근간이 되는 개념은 코드로 가져다 쓰는 것에서 그치지 않고, 반드시 직접 이해하고 넘어가야 한다는 걸 이번에 깨달았다.

0개의 댓글