졸업 프로젝트로 Brain Trace라는 온프레미스 기반 Graph-RAG 질의응답 시스템을 개발했다.
나는 해당 프로젝트에서 백엔드 개발을 맡았고 Graph-RAG관련 부분 로직을 전적으로 맡아서 개발했다.
따라서 이번 글에서는 이 프로젝트에서 Graph-RAG를 어떻게 구현했는지, 사용한 기술과 주요 로직 중심으로 정리해보고자 한다.
전체 프로젝트에 대한 자세한 설명은 아래 링크를 참고하면 된다.
https://github.com/Kimdonghyuk0/BrainTrace_OnDeviceAi/tree/main
GitHub - Kimdonghyuk0/BrainTrace_OnDeviceAi
Contribute to Kimdonghyuk0/BrainTrace_OnDeviceAi development by creating an account on GitHub.
github.com
Text chunking
지식 그래프를 생성하기에 앞서, 각 소스(pdf, mp3 등)를 텍스트로 변환한 뒤, LLM이 처리할 수 있는 크기로 텍스트를 청킹(chunking) 하는 과정이 필요하다.
나는 텍스트가 2000자를 초과하는 경우에만 청킹을 진행했다.
청킹은 langchain 라이브러리의 RecursiveCharacterTextSplitter 클래스를 사용했다.
def chunk_text(text: str, chunk_size: int = 1000, chunk_overlap: int = 200) -> list[str]:
try:
print("start chunking")
# RecursiveCharacterTextSplitter 초기화
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=len,
separators=["\n\n", "\n", ".", " ", ""]
)
# 텍스트 분할
chunks = text_splitter.split_text(text)
logging.info(f"✅ 텍스트가 {len(chunks)}개의 청크로 분할되었습니다.")
return chunks
except Exception as e:
logging.error(f"❌ 텍스트 청킹 중 오류 발생: {str(e)}")
raise RuntimeError("텍스트 청킹 중 오류가 발생했습니다.")
LLM을 이용한 Node, Edge 생성
각 청크들을 프롬프트와 함께 LLM에게 보내 JSON 타입으로 Node와 Edge를 받는다.
LLM에게 "사과를 좋아하는 강아지의 이름은 뽀삐이다." 라는 문장을 주면 아래와 같은 json을 반환한다.
{
"nodes": [
{
"label": "사과",
"name": "사과",
"description": "강아지가 좋아하는 과일이다."
},
{
"label": "강아지",
"name": "강아지",
"description": "사과를 좋아하는 동물이다."
},
{
"label": "뽀삐",
"name": "뽀삐",
"description": "강아지의 이름이다."
}
],
"edges": [
{
"source": "강아지",
"target": "사과",
"relation": "좋아함"
},
{
"source": "뽀삐",
"target": "강아지",
"relation": "이름이다"
}
]
}
위 Node, Edge는 gpt-4o가 생성했지만 llama-3b 모델로도 준수하게 Node, Edge를 생성할 수 있었다.
Graph DB에 Node, Edge 저장
나는 Graph DB로 Neo4j Community Edition을 사용했다.
Community 버전을 선택한 이유는 로컬 환경에서 동작하기 위함이다. (Community 버전은 하나의 데이터베이스만 사용 가능하다.
나는 이를 극복하기 위해, 각 노드에 고유한 id를 부여하여 여러 개의 데이터베이스를 사용하는 것처럼 구성했다.)
앞서 생성한 Node와 Edge에는 출처 소스 ID 등 다양한 정보를 메타데이터 형태로 포함시켰고, 이를 Cypher 쿼리를 통해 Neo4j에 저장했다.
쓰기 작업은 session.write_transaction()을 사용해 트랜잭션 단위로 한 번에 처리했다.
이때 두가지의 어려운 문제를 직면했다.
1. 동일한 이름의 Node를 어떻게 Merge할 것인가?
2. 만일 특정 소스를 삭제했는데 해당 소스로부터 파생된 Node가 다른 Node와 Merge된 상태이면 어떻게 할 것인가?
먼저 1번 문제는 Node 안에 배열을 만들어서 해결했다.
Merge를 수행할 때, 기존의 description 필드를 descriptions라는 배열 형태로 확장하여, 여러 출처에서 동일한 이름의 노드가 생성되더라도 정보를 누락하지 않도록 구성했다.
이를 통해 Merge 시에도 각 노드의 정보를 모두 유지할 수 있었다.
2번 문제는 특정 소스를 삭제할 때 해당 소스에서 파생된 description만 제거하고, 이후 해당 노드의 descriptions 배열이 비게 되면 노드를 삭제하는 방식으로 해결했다.
Node embedding
이후, 생성된 노드를 임베딩하여 Vector DB에 저장했다.
나는 Node의 name, label, description 필드를 임베딩하여 DB에 저장했다.
처음에는 단순히 "{label}인 {name}에 대한 설명: {description}" 이라는 문장을 임베딩 하여 저장했는데, 검색성능이 좋지않아 Node필드를 다음과 같은 4가지 형태로 조합하고 각각 임베딩하여 DB에 저장했다.
"{name}는 {label}이다. {description}"
"{name} ({label}): {description}"
"{label}인 {name}에 대한 설명: {description}"
"{description}"
이렇게 하니까 실제로 코사인 유사도 기반 검색성능이 많이 좋아졌다.
# Vector DB로는 qdrant를 사용했고 임베딩 모델은 KoE5(E5를 한국어에 맞게 파인튜닝한 모델)를 사용했다.
이제 Graph-RAG를 사용할 준비가 끝났다.
컨텍스트 생성
RAG에서 가장 중요한 부분은 어떤 컨텍스트를 추가하느냐이다.
나는 다음과 같은 단계로 컨텍스트를 생성했다.
1. 사용자의 질문을 임베딩한다.
2. Qdrant를 이용해, 질문 임베딩과 일정 유사도 이상인 벡터들을 검색한다.
3. 검색된 벡터가 가리키는 노드의 name을 기준으로, Neo4j에서 해당 노드 및 연결된 이웃 노드들을 조회한다.
4. 조회된 노드와 엣지 정보를 LLM이 이해할 수 있는 자연어 문장 형태로 변환한다.
5. 생성된 컨텍스트를 사용자의 질문과 함께 LLM에게 전달하여 최종 응답을 생성한다.
'RAG' 카테고리의 다른 글
RAG vs Graph-RAG (5) | 2025.07.09 |
---|