Ops & Systems

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

벡터 검색만으로는 부족하다. 엔티티 간 관계를 이해하는 Knowledge Graph로 RAG 시스템을 한 단계 업그레이드하는 법.

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

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

벡터 검색만으로는 부족하다. 엔티티 간 관계를 이해하는 Knowledge Graph로 RAG 시스템을 한 단계 업그레이드하는 법.

TL;DR

  • RAG의 한계: 벡터 유사도만으로는 엔티티 간 관계, 계층 구조를 파악할 수 없음
  • 온톨로지: 개념과 관계를 정의하는 스키마 (RDF, OWL)
  • Knowledge Graph: 온톨로지 기반으로 실제 데이터를 트리플로 저장
  • 하이브리드 검색: 벡터 검색 + 그래프 쿼리로 더 정확한 컨텍스트 제공

1. RAG의 숨겨진 한계

벡터 검색의 맹점

일반적인 RAG 파이프라인:

  1. 문서를 청크로 분할
  2. 각 청크를 임베딩으로 변환
  3. 질문과 유사한 청크를 검색
  4. LLM에 컨텍스트로 전달

문제점:

  • 관계 정보 손실: "A가 B를 개발했다"는 관계가 청킹 과정에서 분리됨
  • 계층 구조 무시: 상위/하위 개념 관계를 파악하지 못함
  • 다중 홉 추론 불가: "A의 상사가 속한 팀의 프로젝트"를 한 번에 찾지 못함

실제 예시

질문: "김철수가 진행한 프로젝트의 사용 기술은?"

벡터 검색 결과:

  • 청크 1: "김철수는 백엔드 개발자입니다"
  • 청크 2: "프로젝트 A는 React를 사용합니다"
  • 청크 3: "김철수는 프로젝트 B에 참여했습니다"

→ 김철수가 어떤 프로젝트에 참여했고, 그 프로젝트가 어떤 기술을 사용하는지 연결이 안 됨

Knowledge Graph가 있다면:

text
김철수 --참여--> 프로젝트B --사용기술--> Python, FastAPI
김철수 --참여--> 프로젝트C --사용기술--> React, TypeScript

→ 한 번의 그래프 쿼리로 정확한 답 도출

2. 온톨로지 기초

온톨로지란?

온톨로지(Ontology): 특정 도메인의 개념과 그 관계를 형식적으로 정의한 것

구성 요소:

  • 클래스(Class): 개념의 유형 (예: Person, Project, Technology)
  • 프로퍼티(Property): 관계 정의 (예: worksOn, uses)
  • 인스턴스(Instance): 실제 데이터 (예: 김철수, 프로젝트A)

RDF 트리플

모든 지식은 주어-술어-목적어 트리플로 표현:

text
(김철수, 직책, 개발자)
(김철수, 참여, 프로젝트A)
(프로젝트A, 사용기술, Python)

스키마 정의 (OWL/RDFS)

turtle
# 클래스 정의
: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 설치

bash
pip install rdflib

기본 그래프 생성

python
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 쿼리

python
# 김철수가 참여한 프로젝트의 기술 스택 조회
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}")

출력:

text
김철수 → 추천 시스템 → Python
김철수 → 추천 시스템 → FastAPI

4. RAG + Knowledge Graph 통합

하이브리드 아키텍처

text
질문 입력
    │
    ├─→ [엔티티 추출] → Knowledge Graph 쿼리
    │                         │
    │                         ▼
    │                   관계 기반 컨텍스트
    │                         │
    └─→ [벡터 검색] ──────────┼─→ [컨텍스트 병합] → LLM → 답변
                              │
                        유사 청크들

구현 예제

python
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.content

5. 문서에서 Knowledge Graph 자동 생성

LLM 기반 트리플 추출

python
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

사용 예시

python
documents = [
    "김철수는 AI팀 소속 백엔드 개발자입니다. 현재 추천 시스템 프로젝트를 담당하고 있습니다.",
    "추천 시스템 프로젝트는 Python과 FastAPI를 사용하며, 2024년 3월에 시작되었습니다.",
    "이영희는 AI팀 팀장으로, 김철수의 상사입니다. 전체 ML 파이프라인을 관리합니다.",
]

graph = build_graph_from_documents(documents, client)

# 그래프 시각화
print(graph.serialize(format="turtle"))

6. 그래프 저장소 옵션

로컬/소규모

옵션장점단점
rdflib (인메모리)간단, 의존성 없음대용량 불가
SQLite + rdflib영속성, 간단동시성 한계

프로덕션

옵션장점단점
Neo4j성숙한 생태계, Cypher 쿼리라이선스 비용
Amazon Neptune완전관리형, SPARQL 지원AWS 종속
Apache Jena Fuseki오픈소스, 표준 SPARQL운영 복잡도

Neo4j 연동 예시

python
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. 실전 팁

온톨로지 설계 원칙

  1. 도메인 특화: 범용 온톨로지보다 도메인에 맞게 설계
  2. 단순하게 시작: 핵심 엔티티와 관계부터, 점진적 확장
  3. 네이밍 일관성: CamelCase, snake_case 등 규칙 통일
  4. 관계 방향성: "A가 B를 소유" vs "B가 A에 속함" 명확히

하이브리드 검색 튜닝

python
# 가중치 조절
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

캐싱 전략

python
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의 "맥락 단절" 문제를 해결하는 강력한 도구입니다.

접근 방식장점적합한 경우
벡터 검색만구현 간단일반 문서 Q&A
KG만정확한 관계 추론구조화된 데이터
하이브리드양쪽 장점복잡한 도메인 지식

시작은 간단하게:

  1. rdflib로 핵심 엔티티/관계 정의
  2. 기존 RAG에 그래프 쿼리 결과 추가
  3. 효과 측정 후 점진적 확장

참고 자료