RAG의 한계를 Knowledge Graph로 극복하기: 온톨로지 기반 검색 시스템
SOTAAZ·

RAG의 한계를 Knowledge Graph로 극복하기: 온톨로지 기반 검색 시스템
벡터 검색만으로는 부족하다. 엔티티 간 관계를 이해하는 Knowledge Graph로 RAG 시스템을 한 단계 업그레이드하는 법.
TL;DR
- RAG의 한계: 벡터 유사도만으로는 엔티티 간 관계, 계층 구조를 파악할 수 없음
- 온톨로지: 개념과 관계를 정의하는 스키마 (RDF, OWL)
- Knowledge Graph: 온톨로지 기반으로 실제 데이터를 트리플로 저장
- 하이브리드 검색: 벡터 검색 + 그래프 쿼리로 더 정확한 컨텍스트 제공
1. RAG의 숨겨진 한계
벡터 검색의 맹점
일반적인 RAG 파이프라인:
- 문서를 청크로 분할
- 각 청크를 임베딩으로 변환
- 질문과 유사한 청크를 검색
- LLM에 컨텍스트로 전달
문제점:
- 관계 정보 손실: "A가 B를 개발했다"는 관계가 청킹 과정에서 분리됨
- 계층 구조 무시: 상위/하위 개념 관계를 파악하지 못함
- 다중 홉 추론 불가: "A의 상사가 속한 팀의 프로젝트"를 한 번에 찾지 못함
실제 예시
질문: "김철수가 진행한 프로젝트의 사용 기술은?"
벡터 검색 결과:
- 청크 1: "김철수는 백엔드 개발자입니다"
- 청크 2: "프로젝트 A는 React를 사용합니다"
- 청크 3: "김철수는 프로젝트 B에 참여했습니다"
→ 김철수가 어떤 프로젝트에 참여했고, 그 프로젝트가 어떤 기술을 사용하는지 연결이 안 됨
Knowledge Graph가 있다면:
김철수 --참여--> 프로젝트B --사용기술--> Python, FastAPI
김철수 --참여--> 프로젝트C --사용기술--> React, TypeScript→ 한 번의 그래프 쿼리로 정확한 답 도출
2. 온톨로지 기초
온톨로지란?
온톨로지(Ontology): 특정 도메인의 개념과 그 관계를 형식적으로 정의한 것
구성 요소:
- 클래스(Class): 개념의 유형 (예: Person, Project, Technology)
- 프로퍼티(Property): 관계 정의 (예: worksOn, uses)
- 인스턴스(Instance): 실제 데이터 (예: 김철수, 프로젝트A)
RDF 트리플
모든 지식은 주어-술어-목적어 트리플로 표현:
(김철수, 직책, 개발자)
(김철수, 참여, 프로젝트A)
(프로젝트A, 사용기술, Python)스키마 정의 (OWL/RDFS)
# 클래스 정의
:Person a owl:Class .
:Project a owl:Class .
:Technology a owl:Class .
# 프로퍼티 정의
:worksOn a owl:ObjectProperty ;
rdfs:domain :Person ;
rdfs:range :Project .
:usesTechnology a owl:ObjectProperty ;
rdfs:domain :Project ;
rdfs:range :Technology .3. Python으로 Knowledge Graph 구축
rdflib 설치
pip install rdflib기본 그래프 생성
from rdflib import Graph, Namespace, Literal, RDF, RDFS, OWL
from rdflib.namespace import XSD
# 네임스페이스 정의
EX = Namespace("http://example.org/")
g = Graph()
g.bind("ex", EX)
# 클래스 정의
g.add((EX.Person, RDF.type, OWL.Class))
g.add((EX.Project, RDF.type, OWL.Class))
g.add((EX.Technology, RDF.type, OWL.Class))
# 인스턴스 추가
g.add((EX.Kim, RDF.type, EX.Person))
g.add((EX.Kim, EX.name, Literal("김철수")))
g.add((EX.Kim, EX.role, Literal("백엔드 개발자")))
g.add((EX.ProjectA, RDF.type, EX.Project))
g.add((EX.ProjectA, EX.name, Literal("추천 시스템")))
g.add((EX.Python, RDF.type, EX.Technology))
g.add((EX.FastAPI, RDF.type, EX.Technology))
# 관계 추가
g.add((EX.Kim, EX.worksOn, EX.ProjectA))
g.add((EX.ProjectA, EX.usesTechnology, EX.Python))
g.add((EX.ProjectA, EX.usesTechnology, EX.FastAPI))SPARQL 쿼리
# 김철수가 참여한 프로젝트의 기술 스택 조회
query = """
PREFIX ex: <http://example.org/>
SELECT ?personName ?projectName ?techName
WHERE {
?person ex:name ?personName .
?person ex:worksOn ?project .
?project ex:name ?projectName .
?project ex:usesTechnology ?tech .
?tech ex:name ?techName .
FILTER (?personName = "김철수")
}
"""
results = g.query(query)
for row in results:
print(f"{row.personName} → {row.projectName} → {row.techName}")출력:
김철수 → 추천 시스템 → Python
김철수 → 추천 시스템 → FastAPI4. RAG + Knowledge Graph 통합
하이브리드 아키텍처
질문 입력
│
├─→ [엔티티 추출] → Knowledge Graph 쿼리
│ │
│ ▼
│ 관계 기반 컨텍스트
│ │
└─→ [벡터 검색] ──────────┼─→ [컨텍스트 병합] → LLM → 답변
│
유사 청크들구현 예제
from openai import OpenAI
import numpy as np
class HybridRAG:
def __init__(self, graph, vector_store, llm_client):
self.graph = graph
self.vector_store = vector_store
self.llm = llm_client
def extract_entities(self, question: str) -> list:
"""LLM으로 질문에서 엔티티 추출"""
response = self.llm.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"다음 질문에서 주요 엔티티(사람, 프로젝트, 기술 등)를 추출하세요:\n{question}\n\nJSON 형식으로 반환: {{\"entities\": [...]}}"
}],
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)["entities"]
def query_graph(self, entities: list) -> str:
"""Knowledge Graph에서 관련 트리플 조회"""
context_parts = []
for entity in entities:
query = f"""
PREFIX ex: <http://example.org/>
SELECT ?s ?p ?o
WHERE {{
{{ ?s ?p ?o . FILTER(CONTAINS(LCASE(STR(?s)), "{entity.lower()}")) }}
UNION
{{ ?s ?p ?o . FILTER(CONTAINS(LCASE(STR(?o)), "{entity.lower()}")) }}
}}
LIMIT 20
"""
results = self.graph.query(query)
for row in results:
context_parts.append(f"{row.s} --{row.p}--> {row.o}")
return "\n".join(context_parts)
def vector_search(self, question: str, k: int = 5) -> str:
"""벡터 유사도 검색"""
results = self.vector_store.similarity_search(question, k=k)
return "\n\n".join([doc.page_content for doc in results])
def answer(self, question: str) -> str:
"""하이브리드 RAG 실행"""
# 1. 엔티티 추출
entities = self.extract_entities(question)
# 2. 그래프 쿼리
graph_context = self.query_graph(entities)
# 3. 벡터 검색
vector_context = self.vector_search(question)
# 4. 컨텍스트 병합 및 답변 생성
combined_context = f"""
## 관계 정보 (Knowledge Graph)
{graph_context}
## 관련 문서
{vector_context}
"""
response = self.llm.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "주어진 컨텍스트를 기반으로 질문에 답하세요."},
{"role": "user", "content": f"컨텍스트:\n{combined_context}\n\n질문: {question}"}
]
)
return response.choices[0].message.content5. 문서에서 Knowledge Graph 자동 생성
LLM 기반 트리플 추출
def extract_triples_from_text(text: str, llm_client) -> list:
"""문서에서 트리플 자동 추출"""
prompt = """다음 텍스트에서 지식 그래프 트리플을 추출하세요.
형식: (주어, 관계, 목적어)
예시:
- (김철수, 직책, 백엔드 개발자)
- (프로젝트A, 사용기술, Python)
- (김철수, 참여, 프로젝트A)
텍스트:
{text}
JSON 형식으로 반환:
{{"triples": [["주어", "관계", "목적어"], ...]}}
"""
response = llm_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt.format(text=text)}],
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)["triples"]
def build_graph_from_documents(documents: list, llm_client) -> Graph:
"""문서 리스트에서 Knowledge Graph 구축"""
g = Graph()
EX = Namespace("http://example.org/")
g.bind("ex", EX)
for doc in documents:
triples = extract_triples_from_text(doc, llm_client)
for subj, pred, obj in triples:
# URI 생성 (공백 제거, 소문자화)
subj_uri = EX[subj.replace(" ", "_")]
pred_uri = EX[pred.replace(" ", "_")]
# 목적어가 엔티티인지 리터럴인지 판단
if any(keyword in pred for keyword in ["이름", "값", "수치", "날짜"]):
g.add((subj_uri, pred_uri, Literal(obj)))
else:
obj_uri = EX[obj.replace(" ", "_")]
g.add((subj_uri, pred_uri, obj_uri))
return g사용 예시
documents = [
"김철수는 AI팀 소속 백엔드 개발자입니다. 현재 추천 시스템 프로젝트를 담당하고 있습니다.",
"추천 시스템 프로젝트는 Python과 FastAPI를 사용하며, 2024년 3월에 시작되었습니다.",
"이영희는 AI팀 팀장으로, 김철수의 상사입니다. 전체 ML 파이프라인을 관리합니다.",
]
graph = build_graph_from_documents(documents, client)
# 그래프 시각화
print(graph.serialize(format="turtle"))6. 그래프 저장소 옵션
로컬/소규모
프로덕션
Neo4j 연동 예시
from neo4j import GraphDatabase
class Neo4jKnowledgeGraph:
def __init__(self, uri, user, password):
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def add_triple(self, subject, predicate, obj):
with self.driver.session() as session:
session.run("""
MERGE (s:Entity {name: $subject})
MERGE (o:Entity {name: $object})
MERGE (s)-[r:RELATION {type: $predicate}]->(o)
""", subject=subject, predicate=predicate, object=obj)
def query(self, entity_name):
with self.driver.session() as session:
result = session.run("""
MATCH (s:Entity {name: $name})-[r]->(o)
RETURN s.name, type(r), o.name
""", name=entity_name)
return [(record[0], record[1], record[2]) for record in result]7. 실전 팁
온톨로지 설계 원칙
- 도메인 특화: 범용 온톨로지보다 도메인에 맞게 설계
- 단순하게 시작: 핵심 엔티티와 관계부터, 점진적 확장
- 네이밍 일관성: CamelCase, snake_case 등 규칙 통일
- 관계 방향성: "A가 B를 소유" vs "B가 A에 속함" 명확히
하이브리드 검색 튜닝
# 가중치 조절
def hybrid_score(graph_results, vector_results, alpha=0.6):
"""
alpha: 그래프 결과 가중치 (0~1)
- 관계 중심 질문: alpha 높게
- 의미 유사도 중심: alpha 낮게
"""
graph_score = len(graph_results) / max_graph_results
vector_score = np.mean([r.score for r in vector_results])
return alpha * graph_score + (1 - alpha) * vector_score캐싱 전략
from functools import lru_cache
@lru_cache(maxsize=1000)
def cached_graph_query(entity: str) -> tuple:
"""자주 조회되는 엔티티는 캐싱"""
results = graph.query(sparql_query.format(entity=entity))
return tuple(results) # hashable하게 변환마무리
Knowledge Graph는 RAG의 "맥락 단절" 문제를 해결하는 강력한 도구입니다.
시작은 간단하게:
- rdflib로 핵심 엔티티/관계 정의
- 기존 RAG에 그래프 쿼리 결과 추가
- 효과 측정 후 점진적 확장