기존 SQLite 기반의 단순 테이블 구조를 Neo4j 그래프 DB로 교체했다. 인플루언서, 광고주, 광고, 카테고리, 플랫폼을 각각 노드로 표현하고, SPECIALIZES_IN, MATCHED_WITH, PROMOTED 같은 관계로 연결하여 엔티티 간의 맥락을 그래프로 구조화했다.
여기에 LlamaIndex KnowledgeGraphIndex를 래핑하여, Cypher를 직접 작성하지 않고 자연어로 그래프를 탐색할 수 있는 질의 레이어를 추가했다.
Neo4j에 구축한 Knowledge Graph 위에 LlamaIndex KnowledgeGraphIndex를 래핑하여, Cypher를 직접 작성하지 않고 자연어로 그래프를 탐색할 수 있도록 했다. "뷰티 인플루언서가 참여한 광고 성과는?" 같은 질문을 입력하면 LLM이 그래프 구조를 해석하고 관련 데이터를 요약해서 반환한다.
Agent가 Neo4j에서 복합적인 정보를 조회하려면 아래처럼 Cypher를 직접 작성해야 했다.
MATCH (i:Influencer)-[:SPECIALIZES_IN]->(c:Category {name: 'Beauty'})
MATCH (i)-[:PROMOTED]->(ad:Advertisement)
RETURN i.name, ad.product, ad.likes, ad.views
정확한 노드 레이블, 관계명, 속성명을 알아야만 쿼리를 작성할 수 있다. 특히 이 프로젝트는 두 가지 스키마가 Neo4j에 공존한다.
PROMOTED, TARGETS_CATEGORY 관계, likes/comments/views 속성MATCHED_WITH, BELONGS_TO, TARGETS_PLATFORM 관계, budget/status/rating 속성Agent가 이 두 스키마를 구분해서 올바른 Cypher를 생성하는 건 사실상 불가능에 가깝고, 프롬프트에 스키마 전체를 하드코딩하는 방식은 유지보수가 어렵다.
Neo4j의 그래프 데이터를 자연어 Document로 변환한 뒤, LlamaIndex LLM이 각 Document에서 (주어 - 관계 - 목적어) 형태의 트리플을 자동 추출한다. 추출된 트리플은 Neo4j의 Entity 노드로 저장되며, KnowledgeGraphIndex는 이 트리플 구조를 참조해 자연어 질의에 답변을 생성한다.
두 스키마의 차이는 Document 변환 단계에서 흡수되므로, 질의 시점에는 스키마를 몰라도 된다.
Neo4j 그래프 데이터
↓
자연어 Document 변환 (예: "인플루언서 '곽튜브'는 여행 카테고리 전문...")
↓
LLM이 트리플 추출 (예: 곽튜브 → 전문분야 → 여행)
↓
KnowledgeGraphIndex에 트리플로 저장
두 스키마가 공존하는 문제를 해결하기 위해 _load_graph_documents()에서 2개의 Cypher 쿼리로 각기 다른 데이터를 추출하고, 모두 동일한 자연어 Document 형식으로 변환한다.
| 쿼리 | 추출 데이터 | 변환 예시 |
|---|---|---|
| 쿼리 1 | 과거 광고 실적 (Notion) + 인플루언서 프로필 | "광고주 '삼성전자'가 '갤럭시' 제품 광고를 생성함. 성과: 좋아요 1200... 프로모션 참여 인플루언서: '홍길동'(카테고리: 테크, 팔로워: 50000명...)" |
| 쿼리 2 | 캠페인 매칭 이력 (Repository) | "광고주 'A사'가 '여름 캠페인'을 등록함. 인플루언서 '홍길동'과 매칭됨..." |
스키마가 달라도 텍스트로 평탄화되면 LlamaIndex 입장에서는 동일한 Document이다. 스키마 복잡성을 변환 레이어에서 격리하는 방식이다.

Neo4jGraphStore를 StorageContext로 주입하기 때문에, LlamaIndex가 추출한 트리플이 기존 Neo4j DB에 직접 누적 저장된다. 별도 벡터 DB 없이 Neo4j 하나로 그래프와 임베딩을 함께 관리할 수 있는 구조이다.
build_kg_index.py에서 동일한 질문으로 두 방식을 나란히 비교 테스트했다.
| 질문 | Cypher 방식 | KG Index 방식 |
|---|---|---|
| "뷰티 카테고리 인플루언서는?" | 정확한 레이블/관계명 필요 | 자연어 그대로 입력 |
| "패션 광고 성과는?" | 두 스키마 중 어떤 관계를 쓸지 선택 필요 | 두 스키마 통합 탐색 |
| "삼성전자 광고 캠페인은?" | 광고주명 정확 일치 필요 | "삼성"처럼 유사 표현도 대응 |
| 파일 | 역할 |
|---|---|
database.py | Neo4j 드라이버 연결 관리자 |
neo4j_repository.py | 그래프 노드/관계 설계 (핵심 스키마) |
kg_index.py | LlamaIndex KG Index 래핑 (핵심 로직) |
build_kg_index.py | 빌드 + Cypher vs. KG Index 비교 테스트 |
llamaindex_tools.py | query_kg_natural_language 에이전트 도구 정의 |
container.py | 의존성 주입 (Neo4jGraphStore, KG Index 연결) |
kg_index.py가 KG Index의 핵심 로직이고, neo4j_repository.py가 Neo4j에 저장되는 그래프 스키마(노드/관계)를 설계하는 부분이다.
database.py 파일 분석이 파일은 Neo4jDatabase 클래스 하나로 구성되어 있으며, 크게 두 가지 역할을 한다.
execute_query)와 쓰기 트랜잭션(execute_write)을 분리해서 제공한다.프로젝트 전체에서 Neo4j에 접근하는 모든 코드(neo4j_repository.py, neo4j_influencer_repository.py 등)는 이 클래스를 통해서만 DB와 통신한다. 직접 드라이버를 다루는 코드가 여러 곳에 흩어지지 않도록 Neo4j 접근을 한 곳에서 집중 관리하는 역할이다.
드라이버 초기화는 생성자에서 바로 실행하지 않고, 실제로 처음 사용하는 시점에 생성하는 Lazy Loading 방식을 채택했다. 또한 with 문으로 사용할 수 있도록 컨텍스트 매니저 프로토콜(__enter__, __exit__)을 구현해, 사용이 끝난 뒤 연결이 자동으로 닫히도록 설계되어 있다.
class Neo4jDatabase:
def __init__(self, uri, username, password, database) # 연결 정보 초기화
# 연결 관리
def driver (property) # Lazy Loading 드라이버 생성
def verify_connectivity() # 연결 상태 확인
def get_session() # 세션 열기 (컨텍스트 매니저)
def close() # 드라이버 연결 종료
# 쿼리 실행
def execute_query() # 읽기 전용 Cypher 실행 (세션 직접 실행)
def execute_read() # 읽기 전용 Cypher 실행 (DB 레벨 트랜잭션)
def execute_write() # 쓰기 트랜잭션 실행
# 컨텍스트 매니저 프로토콜
def __enter__() # with문 진입
def __exit__() # with문 종료 → close() 자동 호출
메서드는 총 10개이며 연결 관리, 쿼리 실행, 컨텍스트 매니저 세 그룹으로 나뉜다.
핵심은 get_session()을 중심으로 execute_query(), execute_read(), execute_write() 세 메서드가 읽기/쓰기를 분리해서 실행한다는 점이다.
🤔
with문이란?파이썬에서 "사용 전 준비 → 사용 → 사용 후 정리" 패턴을 자동으로 처리해주는 문법이다.
with문이 없는 경우db = Neo4jDatabase(uri, username, password) try: db.execute_query("MATCH (i:Influencer) RETURN i.name") finally: db.close() # 직접 닫아야 함, 까먹으면 연결 누수
with문을 쓰는 경우➡️ 기능은 완전히 동일하지만with Neo4jDatabase(uri, username, password) as db: db.execute_query("MATCH (i:Influencer) RETURN i.name") # 블록이 끝나면 close() 자동 호출with문을 쓰면 정리 코드를 직접 작성하지 않아도 된다.
- 예시: 파일 읽기
with문 없이 쓰는 경우f = open("file.txt") content = f.read() f.close() # 직접 닫아야 함with문을 사용하는 경우➡️with open("file.txt") as f: content = f.read() # 자동으로 f.close() 호출open()이 파일을 여는 것처럼,Neo4jDatabase()는 DB 연결을 여는 것이고, 둘 다with문으로 자동 처리가 가능하다.
- 프로젝트에서
with문의 쓰임
실제로 프로젝트에서with Neo4jDatabase()로 사용하는 코드는 없다. 이 프로젝트에서는container.py가Neo4jDatabase를 싱글턴으로 관리하기 때문에with문을 쓸 필요가 없다. 애플리케이션이 시작할 때 한 번 생성하고, 종료할 때까지 계속 재사용하는 방식이라 직접close()를 호출할 일 자체가 없다.
__enter__와__exit__는 "혹시 이 클래스를 단독으로 쓸 일이 생기면 안전하게 쓸 수 있도록" 미리 구현해둔 것에 가깝다. 예를 들어, 테스트 코드나 스크립트에서 임시로 DB에 접근할 때 유용하다.
__init__, driver 프로퍼티)def __init__(self, uri, username, password, database="neo4j"):
self.uri = uri
self.database = database
self._driver: Optional[Driver] = None # 연결 안 함
self._username = username
self._password = password
생성자에서는 연결 정보만 저장하고 실제 Neo4j 연결은 하지 않는다. _driver를 None으로 초기화해두는 것이 핵심이다. 앞에 _가 붙은 것은 외부에서 직접 접근하지 말고 아래의 driver 프로퍼티를 통해서만 사용하라는 의미이다.
@property
def driver(self) -> Driver:
if self._driver is None:
self._driver = GraphDatabase.driver(
self.uri,
auth=(self._username, self._password)
)
return self._driver
driver 프로퍼티에 처음 접근하는 순간 드라이버를 생성하고, 이후에는 이미 만들어진 드라이버를 재사용한다. 이것이 Lazy Loading(지연 초기화) 패턴이다.
굳이 이렇게 하는 이유는 DB 연결이 비용이 큰 작업이기 때문이다.
객체를 생성하는 시점과 실제로 DB를 사용하는 시점이 다를 수 있고, 애플리케이션 시작 시 모든 의존성을 한꺼번에 연결하면 불필요한 리소스를 점유하게 된다. Lazy Loading을 쓰면 실제로 쿼리가 필요한 순간에만 연결이 맺어진다.
@property 덕분에 호출하는 쪽에서는 self.driver.session()처럼 일반 속성처럼 쓸 수 있고, 내부에서 None 체크가 일어나는 걸 신경 쓸 필요가 없다.
🤔
@property란?Python에서 메서드를 속성처럼 접근할 수 있게 해주는 데코레이터이다.
- 기본 동작
class Circle: def __init__(self, radius): self.radius = radius @property def area(self): return 3.14 * self.radius ** 2➡️c = Circle(5) print(c.area) # () 없이 접근, 결과: 78.5 print(c.area()) # TypeError. 메서드가 아님!area는 분명def로 정의된 메서드인데,@property덕분에c.area처럼 속성처럼 읽힌다.
- 사용하는 이유 - 직접 속성과의 차이
- 직접 속성에서는 아무 값이나 들어올 수 있다.
class User: def __init__(self, age): self.age = ageu = User(25) u.age = -5 # 말이 안 되는 값도 그냥 저장됨@property를 쓰면 읽기/쓰기 시점에 로직을 끼워넣을 수 있다.class User: def __init__(self, age): self._age = age @property def age(self): # getter: 읽을 때 실행 return self._age @age.setter def age(self, value) # setter: 쓸 때 실행 if value < 0: raise ValueError("나이는 0 이상이어야 합니다") self._age = value➡️ 외부에서는u = User(25) print(u.age) # 25 u.age = -5 # ValueError 발생 u.age = 30 # 정상 저장u.age처럼 일반 속성처럼 쓰지만, 내부에서는 검증 로직이 실행된다.
database.py에서의 활용➡️ 이 경우 getter만 있고 setter는 없다. 즉 읽기 전용이다.@property def driver(self) -> Driver: if self._driver is None: self._driver = GraphDatabase.driver(...) return self._driver➡️db.driver = something # AttributeError! 외부에서 바꿀 수 없음_driver는 오직 내부 로직(Lazy Loading)을 통해서만 설정되도록 보호하는 셈이다.
get_session())@contextmanager
def get_session(self):
session = self.driver.session(database=self.database)
try:
yield session
finally:
session.close()
@contextmanager와 yield@contextmanager는 with문을 지원하는 컨텍스트 매니저를 제너레이터 함수로 간단하게 만들어주는 데코레이터이다.🤔 제너레이터 함수란?
일반 함수는
return으로 값을 한 번에 반환하고 끝나지만, 제너레이터 함수는yield로 값을 하나씩 내보내고 일시 정지한다.
- 일반 함수
def normal(): return [1, 2, 3] # 한 번에 전부 반환- 제너레이터 함수
def generator(): yield 1 # 여기서 일시 정지 yield 2 # 다음 호출 때 여기서 재개 yield 3 gen = generator() next(gen) # 1 next(gen) # 2 next(gen) # 3get_session()에서yield session이 하는 일@contextmanager def get_session(self): session = self.driver.session(database=self.database) try: yield session # with 블록에 session을 넘기고 일시 저지 finally: session.close() # with 블록이 끝나면 여기서 재개➡️# get_session()을 사용하는 코드 with db.get_session() as session: # yield 전까지 실행 session.run(...) # with 블록 내용 # 블록 끝나면 finally 실행yield앞 = 준비,yield= 넘겨주고 대기,finally= 정리
➡️@contextmanager가 이 흐름을with문과 연결해준다.
yield를 기준으로 코드가 두 부분으로 나뉜다.@contextmanager
def get_session(self):
session = self.driver.session(...) # with문 진입 시 실행
try:
yield session # with 블록에 session 전달
finally:
session.close() # with문 종료 시 항상 실행with self.get_session() as session:
result = session.run(query)
# 블록이 끝나면 자동으로 session.close() 호출try/finally가 보장하는 것finally는 예외가 발생하든 안 하든 반드시 실행된다.try:
yield session # 쿼리 실행 중 예외가 터져도
finally:
session.close() # 여기는 무조건 실행됨execute_query()와 execute_write()를 호출하는 쪽에서는 세션 닫는 것을 신경 쓸 필요가 없다.def execute_query(self, query: str, parameters: Dict[str, Any] = None) -> List[Dict[str, Any]]:
with self.get_session() as session:
result = session.run(query, parameters or {})
return [record.data() for record in result]
def execute_read(self, query: str, parameters: Dict[str, Any] = None) -> List[Dict[str, Any]]:
with self.get_session() as session:
result = session.execute_read(
lambda tx: list(tx.run(query, parameters or {}))
)
return [record.data() for record in result]
def execute_write(self, query: str, parameters: Dict[str, Any] = None) -> List[Dict[str, Any]]:
with self.get_session() as session:
result = session.execute_write(
lambda tx: list(tx.run(query, parameters or {}))
)
return [record.data() for record in result]
🤔 리스트 컴프리헨션:
[record.data() for record in result]
- 풀어서 이해하기
➡️# 리스트 컴프리헨션 [record.data() for record in result] # 위와 완전히 동일한 코드 records = [] for record in result: records.append(record.data())result는 Neo4j가 반환한 여러 행(Record)의 모음이다.
반복문으로 하나씩 꺼내면서 각각.data()를 호출해 딕셔너리로 변환하고, 그 딕셔너리들을 리스트에 담는다.
- 단계별로 보기
- 1단계: Neo4j가 반환한 여러 행(Record)의 모음
result = [ <Record name='홍길동' follower_count=120000>, # record 1 <Record name='김철수' follower_count=85000>, # record 2 <Record name='이영희' follower_count=200000>, # record 3 ]- 2단계:
for record in result로 하나씩 꺼냄record = <Record name='홍길동' follower_count=120000> record.data() # {"name": "홍길동", "follower_count": 120000} record = <Record name='김철수' follower_count=85000> record.data() # {"name": "김철수", "follower_count": 85000} record = <Record name='이영희' follower_count=200000> record.data() # {"name": "이영희", "follower_count": 200000}
- 3단계: 결과를 리스트로 묶음
[ {"name": "홍길동", "follower_count": 120000}, {"name": "김철수", "follower_count": 85000}, {"name": "이영희", "follower_count": 200000}, ]- 읽는 방법
for뒤부터 먼저 읽으면 이해하기 쉽다.[record.data() for record in result] # ↑ 먼저 읽기: result에서 record를 하나씩 꺼내 # ↑ 나중에 읽기: 각 record에 .data()를 호출한 결과를 리스트로
구조는 같지만 실행 방식이 다르다
세 메서드 모두 get_session()으로 세션을 열고 결과를 List[Dict]로 반환한다. 차이는 쿼리를 실행하는 방식에 있다.
| 구분 | execute_query | execute_read | execute_write |
|---|---|---|---|
| 실행 방식 | session.run() | session.execute_read() | session.execute_write() |
| 트랜잭션 | 없음 | 읽기 전용 트랜잭션 | 쓰기 트랜잭션 |
| 실패 시 | 그냥 실패 | 자동 재시도 | 자동 롤백 |
| 쓰기 차단 | 없음 | DB가 직접 차단 | - (해당 없음) |
| 용도 | 단순 조회 | 안전한 조회 | MERGE / SET / CREATE (변경) |
execute_query는 세션에서 바로 쿼리를 실행한다. 빠르고 단순하지만 중간에 실패해도 되돌아가지 않고, 실수로 쓰기 쿼리를 넣어도 막아주지 않는다.execute_read는 session.execute_read()를 통해 Neo4j가 읽기 전용 트랜잭션을 열고 관리한다. 트랜잭션 안에서 CREATE, DELETE 같은 쓰기 쿼리를 실행하면 DB가 직접 에러를 발생시켜 차단한다.execute_write는 session.execute_write()를 통해 Neo4j가 쓰기 트랜잭션을 열고 관리한다. 쿼리 실행 중 오류가 나면 자동으로 롤백되어 DB가 변경되기 전 상태로 복구된다.session.execute_read(
lambda tx: list(tx.run(query, parameters or {}))
)
session.execute_write(
lambda tx: list(tx.run(query, parameters or {}))
)session.execute_read()와 session.execute_write()는 함수를 인자로 받는다. Neo4j 드라이버가 트랜잭션 객체(tx)를 만들어서 이 함수에 직접 전달하는 방식이다.list()로 감싼 이유는 tx.run()이 반환하는 결과가 이터레이터라서, 트랜잭션이 닫히기 전에 미리 다 읽어둬야 하기 때문이다.execute_read는 읽기 전용 트랜잭션으로 쓰기를 차단하고, execute_write는 쓰기 트랜잭션으로 롤백을 보장한다는 점뿐이다.record.data()와 parameters or {}record.data()로 변환한다. Neo4j가 반환하는 Record 객체를 일반 Python 딕셔너리로 변환하는 메서드이다. 호출하는 쪽에서 Neo4j 전용 타입을 몰라도 되도록 내부에서 처리한다.paramters or {}는 파라미터가 None으로 들어왔을 때 빈 딕셔너리로 대체한다. session.run(query, None)처럼 넘기면 오류가 나기 때문에 방어 처리이다.__enter__, __exit__)def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
with문이 동작하는 원리with문은 내부적으로 __enter__와 __exit__를 순서대로 호출한다.with Neo4jDatabase(uri, name, password) as db:
db.execute_query("MATCH (i:Influencer) RETURN i.name")__enter__() 호출 → self(객체 자신)를 반환 → db에 할당with 블록 안의 코드 실행__exit__() 호출 → close() 실행__exit__의 파라미터
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
파라미터가 3개인 이유는 with 블록 안에서 예외가 발생했을 때 그 정보를 전달받기 위해서이다.
| 파라미터 | 의미 | 정상 종료 시 |
|---|---|---|
exc_type | 예외 클래스 | None |
exc_val | 예외 메시지 | None |
exc_tb | 예외 발생 위치 | None |
이 코드에서는 예외 정보를 활용하지 않고 무조건 close()만 호출한다. 예외가 발생했든 아니든 연결을 닫는 게 목적이기 때문이다.
get_session()의 try/finally와 차이
앞서 본 get_session()도 비슷한 역할을 하는데, 둘의 적용 대상이 다르다.
| 구분 | 대상 | 종료 시 |
|---|---|---|
get_session(), try/finally | 세션 | session.close() |
__exit__ | 드라이버 전체 | driver.close() |
세션은 쿼리 한 번 실행할 때마다 열고 닫는 단위이고, 드라이버는 애플리케이션 전체 생명주기 동안 유지되다가 완전히 종료할 때 닫는 단위이다.
neo4j_repository.py 파일 분석이 파일은 Neo4jRepository 클래스 하나로 구성되어 있으며, 크게 세 가지 역할을 한다.
SPECIALIZES_IN, MATCHED_WITH, PROMOTED 같은 관계로 연결한다.database.py가 Neo4j와의 연결을 담당한다면, neo4j_repository.py는 그 연결 위에서 실제 그래프 구조를 설계하고 조작하는 역할이다. 모든 쿼리 실행은 Neo4jDatabase에 위임하고, 이 파일은 어떤 Cypher 쿼리를 실행할지에만 집중한다.
이 파일에서 작성한 Cypher 쿼리들이 곧 Neo4j에 저장되는 그래프 스키마 자체이다. 관계형 DB(MySQL 등)는 테이블을 먼저 CREATE TABLE로 정의해야 데이터를 넣을 수 있지만, Neo4j는 별도의 스키마 정의 파일 없이, MERGE (i:Influencer)나 MERGE (i)-[:SPECIALIZES_IN]->(c:Category) 같은 쿼리를 실행하는 순간 노드와 관계 타입이 Neo4j에 정의된다.
class Neo4jRepository:
def __init__(self, database: Neo4jDatabase) # Neo4jDatabase 주입
# 노드 생성
def create_influencer_node(influencer) # Influencer 노드 생성/업데이트
def create_advertiser_node(advertiser) # Advertiser 노드 생성/업데이트
def create_advertisement_node(ad) # Advertisement 노드 생성 + 광고주 연결
# 관계 생성
def create_matching_relationship(matching) # Influencer -[MATCHED_WITH]-> Advertisement
def connect_influencer_to_categories(id, categories) # Influencer -[SPECIALIZES_IN]-> Category
def connect_influencer_to_platform(id, platforms) # Influencer -[ACTIVE_ON]-> Platform
def connect_ad_to_categories(id, categories) # Advertisement -[BELONGS_TO]-> Category
def connect_ad_to_platforms(id, platforms) # Advertisement -[TARGETS_PLATFORM]-> Platform
# 그래프 탐색
def find_similar_influencers(id, limit) # 같은 카테고리/플랫폼 공유 인플루언서 추천
def find_matching_influencers_for_ad(id, limit) # 광고 조건에 맞는 인플루언서 탐색
def get_influencer_ad_history(id) # 인플루언서 과거 광고 이력 조회
def get_advertiser_preference_pattern(id) # 광고주 선호 패턴 분석
# 유틸리티
def execute_cypher_query(query, parameters) # 범용 Cypher 직접 실행
def get_all_categories() # 전체 카테고리 목록
def get_all_platforms() # 전체 플랫폼 목록
메서드는 총 14개이며 노드 생성, 관계 생성, 그래프 탐색, 유틸리티 순서로 구성된다.
노드 생성과 관계 생성이 데이터를 쌓는 단계라면, 그래프 탐색은 쌓인 관계를 따라가며 의미 있는 정보를 추출하는 단계이다.
유틸리티의 execute_cypher_query는 Agent가 자유롭게 Cypher를 실행할 수 있도록 열어둔 범용 창구이다.
노드 생성 메서드는 create_influencer_node, create_advertiser_node, create_advertisement_node 총 3개이다.
def create_influencer_node(self, influencer: Influencer) -> bool:
query = """
MERGE (i:Influencer {id: $id})
SET i.name = $name,
i.email = $email,
...
RETURN i
"""
MERGE vs. CREATECREATE 대신 MERGE를 사용한다.-- CREATE: 무조건 새 노드 생성 -> 같은 id로 두 번 실행하면 노드가 2개 생김
CREATE (i:Influencer {id: $id})
-- MERGE: 없으면 생성, 있으면 기존 노드 반환 -> 항상 1개 보장
MERGE (i:Influencer {id: $id}) ➡️ MERGE는 "없으면 만들고, 있으면 찾아서 반환"하는 upsert 동작이다. 같은 인플루언서를 두 번 저장해도 노드가 중복 생성되지 않는다.SET으로 속성 업데이트MERGE로 노드를 찾거나 생성한 뒤, SET으로 속성을 덮어쓴다.MERGE (i:Influencer {id: $id}) -- id로 노드를 특정
SET i.name = $name, -- 이후 속성은 항상 최신값으로 업데이트
i.email = $email,
i.engagement_rate = $engagement_rate,
... ➡️ id는 노드를 식별하는 키로만 쓰고, 나머지 속성은 SET으로 매번 갱신한다. 인플루언서 정보가 바뀌어도 노드를 새로 만들지 않고 기존 노드의 속성만 업데이트된다.create_advertisement_node()는 노드 생성과 광고주 연결을 한 쿼리에서 처리한다.MATCH (adv:Advertiser {id: $advertiser_id}) -- 광고주 노드를 먼저 찾고
MERGE (ad:Advertisement {id: $id}) -- 광고 노드를 생성/조회한 뒤
SET ad.title = $title, ...
MERGE (adv)-[:CREATED_AD]->(ad) -- 관계까지 한 번에 연결 ➡️ create_influencer_node()와 create_advertise_node()는 노드만 생성하는 반면, create_advertisement_node()는 광고주(Advertiser)와의 CREATED_AD 관계까지 같은 쿼리 안에서 연결한다. 광고는 반드시 광고주가 있어야 의미가 있기 때문에, 노드 생성과 관계 연결을 분리하지 않고 하나의 트랜잭션으로 묶은 것이다.bool을 반환하는 이유bool을 반환한다.result = self.db.execute_write(query, params)
return len(result) > 0 # 결과가 있으면 True, 없으면 False ➡️ RETURN i/a/ad로 노드를 반환받아 결과가 존재하면 True, 없으면 False를 반환한다. 호출하는 쪽에서 저장 성공 여부를 간단히 확인할 수 있도록 하는 설계이다.관계 생성 메서드는 def create_matching_relationship, def connect_influencer_to_categories, def connect_influencer_to_platform, def connect_ad_to_categories, def connect_ad_to_platforms 총 5개이다.
def connect influencer_to_categories(self, influencer_id, categories):
query = """
MATCH (i:Influencer {id: $influencer_id})
UNWIND $categories AS category_name
MERGE (c:Category {name: category_name})
MERGE (i)-[:SPECIALIZES_IN]->(c)
RETURN count(*) as count
"""
UNWIND: 리스트를 행으로 펼치기UNWIND이다. 카테고리나 플랫폼이 여러 개일 때 리스트를 한 번에 처리한다.-- UNWIND 없이 하나씩 처리하는 경우
MERGE (c:Category {name: 'Beauty'})
MERGE (i)-[:SPECIALIZES_IN]->(c)
MERGE (c:Category {name: 'Fashion'})
MERGE (i)-[:SPECIALIZES_IN]->(c)
-- UNWIND로 한 번에 처리하는 경우
UNWIND ['Beauty', 'Fashion'] AS category_name
MERGE (c:Category {name: category_name})
MERGE (i)-[:SPECIALIZES_IN]->(c) ➡️ UNWIND는 리스트의 각 요소를 행으로 펼쳐서 이후 절이 각 요소마다 반복 실행되게 한다. Python의 for문과 동일한 역할이다.connect_influencer_to_platforms()는 관계 자체에 팔로워 수를 저장한다.MERGE (i)-[r:ACTIVE_ON]->(p)
SET r.follower_count = platform_data.followers -- 관계에 속성 추가
(Influencer) -[ACTIVE_ON {follower_count: 120000}]-> (Platform) ➡️ 노드가 아닌 관계(r)에 속성(follower_count)을 붙이는 것이 Neo4j의 특징이다. 같은 인플루언서가 Instagram에서는 12만 명, YouTube에서는 5만 명의 팔로워를 가질 수 있기 때문에, 팔로워 수를 인플루언서 노드와 플랫폼 노드 사이의 관계(ACTIVE_ON)에 저장하는 것이 정확하다.create_matching_relationship: 관계가 곧 데이터MERGE (i)-[m:MATCHED_WITH {id: $id}]->(ad)
SET m.contract_amount = $contract_amount,
m.status = $status
m.matched_at = datetime($matched_at),
m.notes = $notes ➡️ 매칭 정보(계약금, 상태, 날짜)를 별도 노드로 만들지 않고 MATCHED_WITH 관계(m) 자체에 속성(contract_amount, status, matched_at)으로 저장한다. RDB(Relational Database)였다면 matching 테이블을 따로 만들어야 할 데이터를 Neo4j에서는 관계의 속성으로 표현할 수 있다.
그래프 탐색 쿼리 메서드는 def find_similar_influencers, def find_matching_influencers_for_ad, def get_influencer_ad_history, def get_advertiser_preference_pattern 총 4개이다.
그래프 탐색은 이 파일에서 가장 핵심적인 부분이다. 노드에서 관계를 따라 이동하며 연결된 데이터를 수집하는 방식이 RDB의 JOIN과 다르게 동작한다.
WITH: 중간 결과를 다음 절로 넘기기WITH는 SQL의 서브쿼리와 비슷한 역할이다. 중간 계산 결과를 변수에 담아 다음 절로 전달한다.MATCH (similar:Influencer)-[:SPECIALIZES_IN]->(c:Category)
MATCH (similar)-[sa:ACTIVE_ON]->(p:Platform)
WITH similar, -- i를 다음 절로 넘김
count(DISTINCT c) as shared_categories, -- 집계 결과도 변수로 넘김
avg(sa.follower_count) as avg_followers
RETURN similar.name, shared_categories, avg_followers -- WITH에서 넘긴 것만 사용 가능 ➡️ WITH 이후에는 WITH에서 명시한 변수만 사용할 수 있다. 이전 MATCH에서 쓴 변수라도 WITH에 포함하지 않으면 이후 절에서 접근할 수 없다.collect(DISTINCT ...): 관계를 리스트로 수집MATCH (i:Influencer)-[:SPECIALIZES_IN]->(c:Category)
WITH i, collect(DISTINCT c.name) as categories
RETURN i.name, categories
-- 결과: {"name": "홍길동", "categories": ["Beauty", "Fashion"]} ➡️ 인플루언서 한 명이 여러 카테고리와 연결되어 있으면 MATCH 결과가 여러 행으로 나온다. collect()는 그 여러 행을 하나의 리스트로 묶어준다. DISTINCT를 붙이면 중복을 제거한다.find_similar_influencers: 공유 관계 기반 유사도MATCH (target:Influencer {id: $influencer_id})-[:SPECIALIZES_IN]->(c:Category)
MATCH (target)-[ta:ACTIVE_ON]->(p:Platform)
MATCH (similar:Influencer)-[:SPECIALIZES_IN]->(c) -- 같은 카테고리를 가진 다른 인플루언서
MATCH (similar)-[sa:ACTIVE_ON]->(p) -- 같은 플랫폼도 공유
WHERE similar.id <> $influencer_id
WITH similar,
count(DISTINCT c) as shard_categories,
count(DISTINCT p) as shard_platforms
ORDER BY shared_categories DESC, shared_platforms DESC
LIMIT $limit ➡️ 기준 인플루언서에서 카테고리 노드와 플랫폼 노드를 거쳐 같은 카테고리와 같은 플랫폼을 모두 공유하는 다른 인플루언서를 찾는다. 카테고리로 먼저 후보를 좁히고, 그 안에서 플랫폼까지 일치하는 것만 최종 선택한다. RDB라면 자기 자신 테이블을 JOIN하는 셀프 조인이 필요하지만, Neo4j에서는 공통 노드를 경유하는 경로를 자연스럽게 표현할 수 있다.(target) -[:SPECIALIZES_IN]-> (Category) <-[:SPECIALIZES_IN]- (similar)find_matching_influencers_for_ad: 다중 조건 필터링MATCH (ad:Advertisement {id: $advertisement_id})-[:BELONGS_TO]->(c:Category)
MATCH (ad)-[:TARGETS_PLATFORM]->(p:Platform)
MATCH (i:Influencer)-[:SPECIALIZES_IN]->(c)
MATCH (i)-[a:ACTIVE_ON]->(p)
WHERE i.is_active = true
AND a.follower_count >= ad.target_follower_min
AND i.base_price_amount <= ad.budget ➡️ 4번의 MATCH를 체이닝하며 광고 → 카테고리/플랫폼 → 인플루언서 순서로 그래프를 탐색한다. WHERE에서 팔로워 수와 예산 조건을 추가로 필터링한다. 관계를 따라 탐색하기 때문에 조건에 맞는 인플루언서만 자연스럽게 좁혀진다.get_advertiser_preference_pattern: 집계로 패턴 추출MATCH (adv:Advertiser {id: $advertiser_id})-[:CREATED_AD]->(ad:Advertisement)
MATCH (i:Influencer)-[m:MATCHED_WITH]->(ad)
WHERE m.status = '완료' AND m.rating >= 4.0 -- 성공한 매칭만 필터링
MATCH (i)-[:SPECIALIZES_IN]->(c:Category)
WITH adv,
collect(DISTINCT c.name) as preferred_categories,
avg(a.follower_count) as avg_followers,
avg(m.contract_amout) as avg_contract ➡️ 광고주가 높은 평점을 준 매칭만 필터링한 뒤, 그 인플루언서들의 공통 카테고리와 평균 팔로워, 평균 계약금을 집계한다. 과거 성공 패턴에서 광고주의 선호도를 역추적하는 방식이다.def execute_cypher_query(self, query: str, parameters: Dict[str, Any] = None):
return self.db.execute_query(query, parameters or {})
def get_all_categories(self) -> List[str]:
query = "MATCH (c:Category) RETURN c.name as name ORDER BY c.name"
results = self.db.execute_query(query)
return [r["name"] for r in results]
def get_all_platforms(self) -> List[str]:
query = "MATCH (p:Platform) RETURN p.name as name ORDER BY p.name"
return [r["name"] for r in results]
execute_cypher_query: 범용 쿼리 창구db.execute_query()를 그대로 위임하는 단순한 메서드이다. 존재 이유는 Agent가 자유롭게 Cypher를 실행할 수 있도록 열어두기 위해서이다.find_*, get_* 메서드들은 미리 정해진 패턴의 쿼리만 실행할 수 있다. Agent가 예상치 못한 질문을 받았을 때 이 메서드를 통해 임의의 Cypher를 직접 실행할 수 있다.get_all_categories, get_all_platforms: 메타데이터 조회results = self.db.execute_query(query)
return [r["name"] for r in results]
# ["Beauty", "Education", "Fashion", "Fitness", ...] ➡️ List[Dict]로 받은 결과에서 "name" 값만 추출해 List[str]로 변환한다. Agent가 검색 가능한 카테고리 목록을 확인하거나, 시스템 프롬프트에 동적으로 주입할 때 활용한다.kg_index.py 파일 분석이 파일은 KnowledgeGraphQueryIndex 클래스 하나로 구성되어 있으며, 크게 두 가지 역할을 한다.
KnowledgeGraphIndex로 인덱싱한다.neo4j_repository.py가 Cypher로 그래프를 직접 조작하는 레이어라면, kg_index.py 파일은 그 위에서 자연어로 그래프를 탐색할 수 있도록 LlamaIndex를 래핑하는 레이어이다.
핵심 아이디어는 두 가지이다.
첫째, Neo4j에 공존하는 두 가지 스키마(Notion 임포트 스키마, Repository 스키마)를 2개의 Cypher 쿼리로 각각 추출한 뒤 동일한 자연어 Document 형식으로 평탄화한다. 스키마 차이를 변환 단계에서 흡수하므로 질의 시점에는 스키마를 몰라도 된다.
둘째, Neo4jGraphStore를 StorageContext로 주입해 LLM이 추출한 트리플을 기존 Neo4j DB에 직접 누적 저장한다. 별도 벡터 DB 없이 Neo4j 하나로 도메인 그래프와 KG 트리플을 함께 관리한다.
class KnowledgeGraphQueryIndex:
def __init__(self, neo4j_uri, neo4j_username, neo4j_password) # Neo4jGraphStore 초기화
# 인덱스 구축
def _load_graph_documents() # Neo4j 데이터 → Document 변환 (2개 Cypher 쿼리)
def build_index() # Document → KnowledgeGraphIndex 생성
def get_index() # 인덱스 반환 (없으면 build_index() 호출)
# 질의
def query(query_text) # 자연어 질의 → LLM 요약 응답 반환
메서드는 총 5개로 database.py나 neo4j_repository.py에 비해 단순한 구조이다. 인덱스 구축과 질의 두 흐름으로 나뉜다.
_load_graph_documents() → build_index() → Neo4j에 트리플 저장query() → get_index() → build_index() (최초 1회) → 자연어 응답 반환_로 시작하는 _load_graph_documents()는 외부에서 직접 호출하지 않는 내부 메서드이다. build_index()가 호출될 때만 내부적으로 실행된다.
__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._graph_store = Neo4jGraphStore(
username=neo4j_username,
password=neo4j_password
url=neo4j_uri,
)
self._index: Optional[KnowledgeGraphIndex] = None
Neo4jGraphStore란?
Neo4jGraphStore는 LlamaIndex가 제공하는 Neo4j 어댑터이다. LlamaIndex가 트리플을 저장하고 읽을 때 내부적으로 이 객체를 통해 Neo4j와 통신한다.
database.py의 Neo4jDatabase와 역할이 겹쳐 보이지만 목적이 다르다.
| 구분 | Neo4jDatabase | Neo4jGraphStore |
|---|---|---|
| 제공 주체 | 직접 작성 | LlamaIndex 라이브러리 |
| 용도 | Cypher 쿼리 직접 실행 | LlamaIndex 트리플 저장/조회 |
| 사용처 | neo4j_repository.py | KnowledgeGraphIndex 내부 |
Neo4jDatabase는 우리가 원하는 Cypher를 자유롭게 실행하기 위한 연결이고, Neo4jGraphStore는 LlamaIndex가 트리플을 관리하기 위해 필요한 연결이다. 같은 Neo4j DB에 접근하지만 역할이 분리되어 있어 두 연결이 따로 존재한다.
_index Lazy Loadingself._index: Optional[KnowledgeGraphIndex] = None ➡️ database.py의 _driver와 동일한 패턴이다. 생성자에서 인덱스를 바로 빌드하지 않고 None으로 초기화해둔다.query()가 호출되는 시점까지 미룬다._load_graph_documents(): 데이터 변환 전략def _load_graph_documents(self) -> List[Document]:
driver = GraphDatabase.driver(
self._neo4j_uri,
auth=(self.neo4j_username, self._neo4j_password),
)
documents = []
with driver.session() as session:
# 쿼리 1, 2 실행...
driver.close()
return documents
Neo4jDatabase를 쓰지 않고 드라이버를 직접 만들까?Neo4jDatabase는 neo4j_repository.py에 주입되는 객체이다. KnowledgeGraphQueryIndex는 Neo4jDatabase를 생성자에서 받지 않고 연결 정보(uri, username, password)만 받는다.driver.close()로 명시적으로 닫아 사용 후 연결을 정리한다.-- 쿼리 1: 과거 광고 실적 (Notion 임포트 스키마) + 인플루언서 프로필
MATCH (adv:Advertiser)-[:CREATED_AD]->(ad:Advertisement)
OPTIONAL MATCH (i:Influencer)-[:PROMOTED]->(ad)
-- 쿼리 2: 캠페인 매칭 이력 (Repository 스키마)
MATCH (i:Influencer)-[m:MATCHED_WITH]->(ad:Advertisement)
MATCH (adv:Advertiser)-[:CREATED_AD]->(ad)PROMOTED 관계를, 쿼리 2는 MATCHED_WITH 관계를 사용한다.KnowledgeGraphIndex는 텍스트 Document를 입력으로 받는다. Neo4j의 구조화된 데이터를 그대로 넘길 수 없기 때문에 자연어 문장으로 변환한다.# 쿼리 1 변환 결과
Document(
text="광고주 '삼성전자'(업종: 전자)이 '갤럭시 S24' 제품 광고를 생성함. "
"카테고리: Tech, 컨셉: 혁신, 성능. 성과: 좋아요 1200, 댓글 340. "
"프로모션 참여 인플루언서: 홍길동"
"(카테고리: Beauty, Fashion, 플랫폼: Instagram, 팔로워: 120000명, 참여율: 4.2).",
)
# 쿼리 2 반환 결과
Document(
text="광고주 'A사'이 '여름 캠페인'을 등록함. 컨셉: 청량함, 아이템: 음료. "
"인플루언서 '홍길동'과 매칭됨 (상태: 완료, 계약금: 500000원). 평점: 4.5점.",
) 이 자연어 텍스트에서 LLM이 트리플을 추출한다."삼성전자가 갤럭시 S24 광고를 생성"
➡️ (삼성전자) -[광고_생성]-> (갤럭시 S24)
"홍길동은 Beauty 카테고리 전문, Instagram 활동"
➡️ (홍길동) -[전문_카테고리]-> (Beauty)
➡️ (홍길동) -[활동_플랫폼]-> (Instagram)OPTIONAL MATCH를 쓰는 이유OPTIONAL MATCH (i:Influencer)-[:PROMOTED]->(ad)MATCH는 조건에 맞는 데이터가 없으면 해당 행 전체를 결과에서 제외한다.OPTIONAL MATCH는 데이터가 없으면 null을 반환하고 나머지 행은 유지한다.OPTIONAL MATCH를 사용한다.metadata를 붙이는 이유Document(
text="...",
metadata={"node_type": "advertisement", "advertiser": data["advertiser"]}
)metadata는 Document의 본문에는 포함되지 않지만, 검색 결과와 함께 반환된다.node_type으로 이 Document가 광고 실적인지, 매칭 이력인지 구분할 수 있다.build_index(): KG 인덱스 생성def build_index(self) -> KnowledgeGraphIndex:
documents = self._load_graph_documents()
storage_context = StorageContext.from_defaults(
graph_store=self._graph_store
)
self._index = KnowledgeGraphIndex.from_documents(
documents,
storage_context=storage_context,
include_embeddings=True,
max_triplets_per_chunk=10
)
return self._index

StorageContext: 저장 백엔드 교체storage_context = StorageContext.from_defaults(
graph_store=self._graph_store # Neo4jGraphStore 주입
)StorageContext에 Neo4jGraphStore를 주입하면 LLM이 추출한 트리플이 Neo4j DB에 직접 저장된다.build_index()를 처음부터 다시 실행할 필요가 없다.max_triplets_per_chunk=10: 청크당 트리플 수 제한Document: "홍길동은 Beauty, Fashion 전문이며 Instagram에서 활동. 팔로워 12만명, 참여율 4.2"
추출되는 트리플 (최대 10개):
(홍길동) -[전문_카테고리]-> (Beauty)
(홍길동) -[전문_카테고리]-> (Fashion)
(홍길동) -[활동_플랫폼]-> (Instagram)
(홍길동) -[팔로워수]-> (120000명)
(홍길동) -[참여율]-> (4.2)include_embeddings=True: 트리플에 벡터 추가query()가 들어왔을 때 키워드 매칭뿐만 아니라 벡터 유사도로 관련 트리플을 찾을 수 있게 된다.get_index(): Lazy Loadingdef get_index(self) -> KnowledgeGraphIndex:
if self._index is None:
self.build_index()
return self._index
database.py의 driver 프로퍼티와 완전히 동일한 패턴이다.
database.py의 driver 프로퍼티@property
def driver(self) -> Driver:
if self._driver is None:
self._driver = GraphDatabase.driver(...)
return self._driverkg_index.py의 get_index()def get_index(self) -> KnowledgeGraphIndex:
if self._index is None:
self.build_index() # 처음 호출될 때만 실행
return self._indexget_index()는 @property로 만들지 않았다.
driver는 Neo4jDatabase 내부 여러 메서드에서 self.driver.session(), self.driver.verity_connectivity()처럼 속성처럼 자연스럽게 접근하기 때문에 @property가 의미 있다.
반면 get_index()는 KnowledgeGraphQueryIndex 내부에서만 호출되고, get_index()라는 이름 자체가 "처음 호출 시 빌드가 발생될 수 있다"는 의도를 명확히 전달하기 때문에 일반 메서드로 두는 것이 더 적절하다.
build_index()는 Neo4j에서 데이터를 읽고, LLM으로 트리플을 추출하고, 벡터 인덱스까지 생성하는 무거운 작업이다. get_index()를 통해 이 작업을 query()가 실제로 호출되는 시점까지 미룬다.
두 번째 호출부터는 self._index가 이미 존재하므로 build_index()를 다시 실행하지 않고 저장된 인덱스를 바로 반환한다.
query(): 자연어 질의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)
as_query_engine(): 인덱스를 질의 가능한 엔진으로 변환KnowledgeGraphIndex 자체는 데이터를 저장하는 구조체이다. as_query_engine()을 호출하면 자연어 질문을 받아 응답을 생성하는 엔진으로 변환된다.index # 데이터 저장소
query_engine # 질의 처리기 (index를 감싼 래퍼)include_text=True: 원본 텍스트 포함include_text=False (기본값)include_text=Trueresponse_mode="tree_summarize": 다단계 요약관련 트리플 20개 검색됨
↓
[1단계] 5개씩 묶어서 LLM이 각각 요약 → 4개의 중간 요약
↓
[2단계] 4개의 중간 요약을 다시 LLM이 최종 요약
↓
최종 응답 반환compact 모드보다 컨텍스트 길이 제한에 덜 민감하고, 많은 양의 트리플에서도 정보 손실 없이 요약할 수 있다.
➡️ str(response)로 감싸는 이유는 LlamaIndex의 Response 객체를 일반 문자열로 변환하기 위해서이다. 호출하는 쪽에서 LlamaIndex 전용 타입을 몰라도 바로 사용할 수 있다.build_kg_index.py 파일 분석이 파일은 두 가지 역할을 한다.
KnowledgeGraphQueryIndex.build_index()를 실행해 Neo4j 데이터를 KG 인덱스로 구축한다.애플리케이션 실행과는 별개로 독립적으로 실행하는 일회성 스크립트이다. KG 인덱스를 처음 구축하거나, Cypher와 자연어 질의의 응답 차이를 직접 눈으로 확인하고 싶을 때 사용한다.
빌드 스크립트는 다음 명령을 통해 실행할 수 있다.
uv run python scripts/build_kg_index.py
container.py의 DI 컨테이너를 통해 KnowledgeGraphQueryIndex 인스턴스를 가져오기 때문에 연결 설정을 별도로 작성할 필요 없이 .env 설정만 맞춰두면 바로 실행할 수 있다.
# 헬퍼 함수
def cypher_query(uri, username, password, query) # Cypher 직접 실행 래퍼
# 메인 함수
def main():
# 1. KG 인덱스 빌드
container = Container.get_instance()
kg_index = container.kg_query_index()
kg_index.build_index()
# 2. 비교 테스트 (4개 케이스)
comparisons = [
{"question": "뷰티 카테고리 인플루언서는 누가 있나?", ...},
{"question": "패션 인플루언서가 참여한 광고의 성과는?", ...},
{"question": "가장 많은 광고에 참여한 인플루언서는?", ...},
{"question": "삼성전자가 진행한 광고는?", ...},
]
for comp in comparisons:
cypher_query(..., comp["cypher"]) # 기존 방식: Cypher 직접 질의
kg_index.query(comp["natural"]) # LlamaIndex 방식: 자연어 검색
함수는 cypher_query()와 main() 두 개뿐이다.
핵심은 comparisons 리스트에 정의된 4개의 비교 케이스이다. 각 케이스는 동일한 질문을 cypher(정형 쿼리)와 natural(자연어 질의) 두 가지 형태로 가지고 있으며, 루프를 돌며 두 결과를 나란히 출력한다.
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: 정상/예외 여부와 무관하게 드라이버 연결 반드시 종료main(): 인덱스 빌드def main():
container = Container.get_instance()
kg_index = container.kg_query_index()
kg_index.build_index()
neo4j_db = container.neo4j_database
Container.get_instance(): DI(Dependency Injection) 컨테이너에서 가져오기KnowledgeGraphQueryIndex를 직접 생성하지 않고 Container에서 가져온다.# 직접 생성하는 방식
kg_index = KnowledgeGraphQueryIndex(
neo4j_uri="bolt://localhost:7687",
neo4j_username="neo4j",
neo4j_password="password",
)
# Container에서 가져오는 방식 (실제 코드)
container = Container.get_instance()
kg_index = container.kg_query_index()Container가 .env에서 연결 정보를 읽어 싱글턴으로 관리하기 때문에, 스크립트에서 연결 정보를 하드코딩할 필요가 없다.kg_query_index()로 KnowledgeGraphQueryIndex 인스턴스를 얻는다.build_index()를 명시적으로 호출하는 이유kg_index.query()를 바로 호출해도 내부에서 get_index() → build_index()가 자동으로 실행된다. 그런데 여기서는 build_index()를 명시적으로 먼저 호출한다.kg_index.build_index() # 명시적으로 먼저 실행
print("KG 인덱스 생성 완료!")
# 이후 query() 호출
kg_index.query(...)이유는 두 가지이다.query() 안에서 자동으로 빌드되면 완료 시점을 알 수 없다."question": "뷰티 카테고리 인플루언서는 누가 있나?",
"cypher": """
MATCH (i:Influencer)-[:SPECIALIZES_IN]->(c:Category)
WHERE toLower(c.name) IN ['뷰티', 'beauty']
RETURN i.name, i.instagram_handle, i.follower_count
""",
"natural": "뷰티 카테고리 인플루언서는 누가 있나?",가장 기본적인 조회이다.WHERE toLower(c.name) IN ['뷰티', 'beauty']로 한국어/영어 둘 다 처리한다."question": "패션 인플루언서가 참여한 광고의 성과는?",
"cypher": """
MATCH (i:Influencer)-[:SPECIALIZES_IN]->(c:Category)
WHERE toLower(c.name) IN ['패션', 'fashion']
MATCH (i)-[:PROMOTED]->(ad:Advertisement)
RETURN i.name, ad.product, ad.likes, ad.comments, ad.views
""",
"natural": "패션 카테고리 인플루언서들이 참여한 광고의 성과는?",Cypher는 Notion 임포트 스키마의 PROMOTED 관계와 likes/views 속성을 정확히 알아야 작성할 수 있다. Repository 스키마의 MATCHED_WITH를 쓰면 전혀 다른 결과가 나온다."question": "가장 많은 광고에 참여한 인플루언서는?",
"cypher": """
MATCH (i:Influencer)-[:PROMOTED]->(ad:Advertisement)
RETURN i.name, count(ad) as ad_count
ORDER BY ad_count DESC LIMIT 3
""",
"natural": "가장 많은 광고 캠페인에 참여한 인플루언서는?",count(), ORDER BY, LIMIT 같은 집계와 정렬이 필요한 케이스이다."question": "삼성전자가 진행한 광고는?",
"cypher": """
MATCH (adv:Advertiser {name: '삼성전자'})-[:CREATED_AD]->(ad:Advertisement)
OPTIONAL MATCH (i:Influencer)-[:PROMOTED]->(ad)
RETURN ad.product, ad.concept_keywords, ad.likes, i.name
""",
"natural": "삼성전자가 진행한 광고 캠페인은 어떤 것들이 있나?",광고주명 검색 케이스이다.{name: '삼성전자'}로 정확히 일치해야 한다. "삼성"이나 "Samsung"으로 쓰면 결과가 없다.4가지 케이스가 보여주는 것
| 케이스 | Cypher 강점 | KG Index 강점 |
|---|---|---|
| 카테고리 조회 | 정확한 카테고리명 대응 | 유사 표현 대응 |
| 복합 조회 | 스키마 알면 정확 | 스키마 몰라도 탐색 |
| 집계/정렬 | 정확한 수치/순위 | 자연어 요약 |
| 특정 이름 검색 | 정확하게 일치하는 이름만 탐색 | 유사 표현도 탐색 |
두 방식은 대체 관계가 아니라 상호 보완 관계이다. 정확한 수치나 집계가 필요하면 Cypher, 유연한 자연어 탐색이 필요하면 KG Index가 적합하다.
이 스크립트의 목적이 "Cypher 직접 질의 vs. KG Index 자연어 질의를 나란히 비교"하는 것이기 때문이다.
KG Index가 자연어 질문을 얼마나 잘 이해하는지 검증하려면 정답 기준이 필요하다. Cypher 쿼리가 그 정답 기준 역할을 한다.
"뷰티 카테고리 인플루언서는 누가 있나?"
Cypher 결과: KG Index 결과:
[ "뷰티 카테고리에서 활동하는
{"name": "홍길동", ...}, 인플루언서로는 홍길동, 김철수,
{"name": "김철수", ...}, 이영희가 있습니다..."
{"name": "이영희", ...}
]
↑ 정확한 정답 ↑ KG Index가 이걸 제대로 찾는지 확인
즉 "KG Index가 Cypher만큼 정확한 결과를 자연어로 찾아내는가"를 눈으로 비교하기 위해 Cypher를 직접 작성한 것이다. Cypher 없이 KG Index 결과만 보면 그게 맞는 답인지 틀린 답인지 판단할 수 없다.
"뷰티 카테고리 인플루언서는 누가 있나?"
↓
우리가 직접 Cypher로 번역해서 실행 → 정답 데이터
↓
KG Index query()가 같은 질문으로 탐색 → 자연어 응답
↓
둘을 비교 → query()가 정답을 제대로 찾는지 검증
실제 서비스에서는 사용자가 자연어로 질문하면 query()가 알아서 처리한다. (정확히는 query() 메서드 내부의 LlamaIndex의 query_engine.query()가 자연어 질의를 처리한다.) 하지만 그게 제대로 작동하는지 검증하려면 "이 질문의 정답이 뭔지"를 알아야 하고, 그 정답을 구하는 유일한 방법이 Cypher를 직접 작성하는 것이다.
즉, Cypher는 서비스용이 아니라 테스트 정답지용이다.
llamaindex_tools.py 파일 분석이 파일은 LlamaIndex 인덱스 클래스들을 LangChain @tool 데코레이터로 감싸 Agent가 직접 호출할 수 있는 도구로 변환하는 역할을 한다. 크게 두 가지 역할을 한다.
LLAMAINDEX_TOOLS 리스트로 묶어 agent_factory.py에 일괄 전달한다.파일 안에 도구가 5개 정의되어 있지만, 이 글에서는 Knowledge Graph 자연어 탐색과 직접 연결된 query_kg_natural_language에 집중한다.
이 파일의 전체 구조나 도구 공통 구조를 알고 싶다면, 이 글을 참고하면 된다.
query_kg_natural_language() 상세 분석@tool
def query_kg_natural_language(query: str) -> str:
"""Knowledge Graph를 자연어로 탐색합니다. Cypher 쿼리를 작성할 필요 없이 자연어 질문을 입력하세요.
인플루언서와 광고주, 광고 캠페인 간의 관계를 탐색할 때 사용합니다.
예: "삼성전자가 과거에 협업했던 인플루언서는?"
"뷰티 카테고리 인플루언서들의 과거 광고 캠페인 성과는?",
"인스타그램에서 활동하는 인플루언서 중 패션 광고를 진행한 사람은?"
Args:
query: Knowledge Graph에서 탐색하고 싶은 내용을 자연어로 설명
(예: "패션 카테고리 인플루언서들이 과거에 진행한 광고 캠페인의 성과는?")
Returns:
Knowledge Graph 탐색 결과 (자연어 요약)
"""
from infrastructure.container import Container
try:
container = Container.get_instance()
kg_index = container.kg_query_index()
result = kg_index.query(query)
return result
except Exception as e:
return f"Knowledge Graph 질의 오류: {str(e)}"
다른 도구들이 retrieve()를 호출하는 것과 달리 query()를 호출한다는 점이 핵심 차이이다. retrieve()는 유사도 점수와 원문 텍스트를 JSON으로 반환하지만, query()는 LLM이 검색 결과를 자연어로 요약해서 반환한다.
Knowledge Graph 탐색 결과는 노드와 관계로 이루어진 구조화된 데이터라 그대로 반환하면 Agent가 해석하기 어렵기 때문에, LLM 요약을 거쳐 자연어로 전달하는 방식을 선택했다.
docstring 예시 문장이 관계 탐색 중심으로 구성되어 있다는 점도 특징이다. "삼성전자가 협업했던 인플루언서", "패션 광고를 진행한 인플루언서"처럼 특정 노드 간의 연결 관계를 묻는 질문들이다.
벡터 유사도로 찾기 어려운 명시적인 관계 정보를 조회할 때 이 도구를 선택하도록 유도하는 장치이다.
container.py 파일 분석이 파일은 Container 클래스 하나로 구성된 의존성 주입(DI) 컨테이너이다. 프로젝트에서 사용하는 모든 객체(Neo4jDatabase, Neo4jRepository, KnowledgeGraphQueryIndex 등)를 이 클래스 한 곳에서 생성하고 관리한다.
이번 구현과 관련해서 이 파일이 하는 역할은 두 가지이다.
KnowledgeGraphIndex가 트리플 추출과 질의에 사용할 LLM과 임베딩 모델을 __init__에서 전역으로 초기화한다. LLM 프로바이더(Ollama/OpenAI/Anthropic)에 따라 분기해서 설정한다.neo4j_database, neo4j_repository, kg_query_index 등을 싱글턴으로 생성하고 반환한다. 각 클래스가 직접 의존성을 만들지 않고 Container에서 주입받는다.build_kg_index.py에서 Container.get_instance()로 인스턴스를 가져오는 것도 이 파일이 관리하는 싱글턴을 사용하는 것이다.
class Container:
_instance: Optional["Container"] = None
...
@classmethod
def get_instance(cls, settings=None) -> "Container":
if cls._instance is None:
cls._instance = cls(settings)
return cls._instance
싱글턴이란?
클래스의 인스턴스가 애플리케이션 전체에서 딱 하나만 존재하도록 보장하는 패턴이다.
a = Container.get_instance()
b = Container.get_instance()
print(a is b) # True -> 같은 객체
Container()를 직접 호출하면 매번 새 인스턴스가 생기지만, get_instance()를 통하면 항상 동일한 인스턴스를 반환한다.
_instance 클래스 변수로 관리
class Container:
_instance: Optional["Container"] = None # 클래스 변수
_instance는 인스턴스 변수가 아닌 클래스 변수이다. 클래스 자체에 하나만 존재하기 때문에 어디서 get_instance()를 호출해도 동일한 _instance를 참조한다.
# 첫 번째 호출: _instance가 None -> 새로 생성
Container.get_instance() # Container 객체 생성, _instance에 저장
# 이후 호출: _instance가 존재 -> 그대로 반환
Container.get_instance() # 저장된 _instance 반환
왜 싱글턴이 필요한가?
Container가 생성될 때 __init__에서 LlamaIndex 글로벌 설정을 초기화한다. 이 작업이 여러 번 실행되면 설정이 덮어씌워지거나 불필요한 리소스를 낭비한다. 또한 Neo4jDatabase, KnowledgeGraphQueryIndex 같은 객체는 DB 연결을 포함하기 때문에 매번 새로 만들면 연결이 중복 생성된다. 싱글턴으로 관리하면 초기화는 한 번, 이후엔 재사용이 보장된다.
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의 전역 설정 객체이다. 여기에 LLM과 임베딩 모델을 설정하면 이후 KnowledgeGraphIndex, VectorStoreIndex 등 LlamaIndex의 모든 컴포넌트가 별도 설정 없이 이 모델을 자동으로 사용한다. 각 컴포넌트마다 LLM을 따로 주입하지 않아도 되는 이유가 이것이다.
__init__에서 설정하는 이유
LlamaSettings는 전역 객체라 어디서든 접근할 수 있다.
kg_index.py나 influencer_index.py 각각에서 설정할 수도 있지만, Container 생성 시점에 한 번만 초기화하면 이후 모든 LlamaIndex 컴포넌트가 동일한 설정을 공유한다. 싱글턴과 결합되어 애플리케이션 실행 중 설정이 중복 초기화되거나 변경되는 것을 막는다.
@property
def neo4j_database(self) -> Neo4jDatabase:
if self._neo4j_database is None:
self._neo4j_database = Neo4jDatabase(
uri=self.settings.neo4j_uri,
username=self.settings.neo4j_username,
password=self.settings.neo4j_password,
)
return self._neo4j_database
def neo4j_repository(self) -> Neo4jRepository:
return Neo4jRepository(self.neo4j_database)
@property + None 체크 (Lazy Loading)
neo4j_database는 database.py의 driver 프로퍼티와 동일한 Lazy Loading 패턴이다. Container가 생성되는 시점이 아니라 처음 neo4j_database에 접근하는 시점에 Neo4jDatabase 객체를 생성한다.
container = Container.get_instance() # Neo4jDatabase 생성 안 됨
container.neo4j_database # 이 시점에 처음 생성됨
container.neo4j_database # 두 번째부터는 저장된 객체 반환
neo4j_repository에 neo4j_database를 주입하는 방식
def neo4j_repository(self) -> Neo4jRepository:
return Neo4jRepository(self.neo4j_database) # 이미 생성된 객체 전달
Neo4jRepository는 Neo4jDatabase를 생성자에서 받는다. Container가 neo4j_database를 싱글턴으로 관리하기 때문에, neo4j_repository에 주입되는 Neo4jDatabase 객체도 항상 동일한 인스턴스가 된다. DB 연결이 하나만 유지되는 것이 보장된다.
def kg_query_index(self) -> KnowledgeGraphQueryIndex:
if self._kg_query_index is None:
self._kg_query_index = KnowledgeGraphQueryIndex(
neo4j_uri=self.settings.neo4j_uri,
neo4j_username=self.settings.neo4j_username,
neo4j_password=self.settings.neo4j_password,
)
return self._kg_query_index
Neo4jDatabase 객체가 아닌 연결 정보를 넘기는 이유
neo4j_repository()와 비교하면 차이가 명확하다.
neo4j_repository: Neo4jDatabase 객체를 직접 주입def neo4j_repository(self) -> Neo4jRepository:
return Neo4jRepository(self.neo4j_database) # 객체 전달kg_query_index: 연결 정보(문자열)만 전달def kg_query_index(self) -> KnowledgeGraphQueryIndex:
self._kg_query_index = KnowledgeGraphQueryIndex(
neo4j_uri=self.settings.neo4j_uri, # 문자열 전달
neo4j_username=self.settings.neo4j_username,
neo4j_password=self.settings.neo4j_password,
)kg_index.py에서 설명했듯이 KnowledgeGraphQueryIndex는 Neo4jDatabase를 생성자에서 받지 않도록 설계되어 있다. (이 설계는 의도적인 의존성 분리라기보다는, LlamaIndex 라이브러리의 인터페이스에 맞추다 보니 생긴 구조이다.)Neo4jGraphStore를 내부에서 직접 생성하고, _load_graph_documents()에서도 드라이버를 직접 만들기 때문이다. Container는 그 설계를 그대로 따라 연결 정보만 전달한다.if None 체크로 lazy 초기화
_kg_query_index가 None이면 인스턴스를 생성하고, 이후에는 같은 인스턴스를 재사용한다.
Container 자체가 싱글턴이므로 이 if None 체크만으로도 Index가 사실상 싱글턴처럼 동작한다.
build_index()를 처음 실행했을 때 DNS 해석 실패 에러가 떴다. 코드 문제라고 생각해서 한참 디버깅했는데, 원인은 Neo4j Aura 인스턴스가 유휴 상태로 일시정지된 것이었다. 앞으로는 빌드 스크립트 실행 전에 Aura 콘솔에서 인스턴스가 켜져 있는지 먼저 확인하는 습관이 필요하다는 걸 느꼈다.
build_index()가 20분 넘게 걸렸다. 처음엔 코드 문제인 줄 알았는데, 원인은 Ollama 로컬 LLM이 GPU가 아닌 CPU로 실행되고 있었기 때문이었다.
우리가 사용하는 qwen2.5:7b-instruct는 7B 파라미터 모델로 GPU 실행에 최소 4~8GB VRAM이 필요한데, 내 노트북의 VRAM이 2GB라 모델이 GPU에 올라가지 못하고 CPU로 실행된 것으로, 내 노트북에서만 유독 느렸던 이유였다.
결국 Entity 노드가 이미 있으면 재빌드를 건너뛰는 load_existing()을 추가해서 회피했지만, 이는 처음 빌드하거나 데이터가 추가되어 재빌드가 필요한 상황에서는 여전히 느리다. 근본 해결책은 GPT, Claude 같은 클라우드 LLM(API로 호출하는 외부 서버 모델)으로 전환하는 것이다.
KG Index가 작동하면 자연어로 질문하면 자연어로 답변이 나올 거라고 기대했다. 그런데, "None에 대해 [] 관계를 가진다"와 같은 이상한 답변이 나왔다. 원인은 두 가지였다.
첫째, qwen2.5이 한국어 문장에서 조사와 어미를 predicate로 추출하는 트리플 품질 문제이다.
(곽튜브) -[전문_카테고리]-> (여행)(곽튜브) -[는]-> (여행 카테고리 전문이다)
(곽튜브) -[이]-> (여행)
(카테고리) -[에서]-> (활동)이 이상한 트리플이 답변에 그대로 출력된 이유는 당시 _query_entity_nodes_direct()라는 fallback 코드가 Neo4j에서 트리플을 직접 꺼내 날것으로 출력하고 있었기 때문이다. 이 fallback 코드를 제거하고 LLM 합성(tree_summarize) 방식만 유지하면서 이상한 출력은 사라졌다.둘째, 로컬 LLM의 타임아웃으로 LLM 합성 단계 자체가 실패하는 문제이다. query() 자연어 질의 시 답변 생성 단계에서 httpx.ReadTimeout이 발생했고, request_timeout=600.0으로 늘려서 타임아웃 자체는 해결됐다. 하지만 답변이 중국어로 나오거나 hallucination이 발생하는 등 품질 문제는 여전히 남아 있다.
이 과정을 통해서 KG Index는 LLM 품질에 직접적으로 의존한다는 걸 체감했다.
seed 데이터를 삭제한 후 Entity 노드가 876개에서 15개로 줄었다. 커버하는 정보가 너무 적다 보니 질문과 관련된 트리플 자체를 찾지 못하고, LLM이 Neo4j 데이터 기반으로 답변하지 못한 채 학습 데이터에서 hallucination을 하는 상황이 됐다. 심지어 중국어로 답변하거나 프롬프트 태그(<|im_start|>user)까지 출력하는 경우도 있었다.
노드 수 문제뿐 아니라 qwen2.5이 한국어 문장에서 조사와 어미를 predicate로 추출하는 품질 문제도 있어, 15개마저도 의미 있는 트리플인지 불확실하다. GPT/Claude로 전환하면 트리플 추출 품질과 자연어 답변 품질은 개선되겠지만, Notion 데이터 자체가 적어 Entity 노드 수 부족 문제는 근본적으로 해결되지 않는다. 결국 LLM 전환과 함께 데이터 확보가 선행되어야 KG Index가 제대로 작동할 수 있다.
KG Index는 현재 Ollama 로컬 모델의 한계로 트리플 품질이 낮고 자연어 답변도 불안정하다. GPT나 Claude로 전환하면 트리플 추출 품질과 답변 품질이 동시에 개선될 것으로 예상되지만 아직 검증하지 못했다. seed 데이터를 삭제했으니 실제 Notion 데이터만으로 재빌드한 결과를 GPT로 테스트해보는 것이 다음 과제이다.