Skip to content
BAEM1N.DEV — AI, RAG, LLMOps 개발 블로그
Go back

GraphRAG 파이프라인 실전 구축 — 벡터 검색에서 그래프 확장까지

Disclosure: 이 글의 저자는 langchain-age 메인테이너입니다.

TL;DR: GraphRAG는 “벡터 검색으로 관련 엔티티를 찾고, 그래프에서 관계를 확장해 LLM에 풍부한 컨텍스트를 제공”하는 패턴이다. langchain-agefrom_existing_graph()로 그래프 노드를 한 줄에 벡터화하고, AGEGraphCypherQAChain으로 자연어 질문을 Cypher로 변환해 답변까지 자동화할 수 있다. 모든 것이 PostgreSQL 하나에서 동작한다.

Table of contents

Open Table of contents

시리즈

이 글은 langchain-age 시리즈의 4편이다.

  1. GraphRAG를 PostgreSQL만으로 구축하기 — 개요 + 셋업
  2. Neo4j vs Apache AGE 실측 벤치마크 — 성능 데이터
  3. 벡터 검색 완전 정복 — Hybrid, MMR, 필터링
  4. GraphRAG 파이프라인 실전 구축 (현재 글)
  5. PostgreSQL 하나로 AI Agent 전체 스택 — LangGraph 연동

이 글을 읽고 나면

GraphRAG가 일반 RAG보다 나은 이유

일반 벡터 RAG는 질문과 유사한 텍스트 청크를 찾아서 LLM에 넘긴다. 이 방식의 한계:

GraphRAG는 지식 그래프의 관계를 활용해 이 문제를 해결한다:

![일반 RAG vs GraphRAG 파이프라인 비교](../../assets/images/langchain-age/rag-vs-graphrag-ko.png)

사전 준비

1편의 셋업이 완료되어 있다고 가정한다.

# 데이터베이스
cd langchain-age/docker && docker compose up -d

# 패키지
pip install "langchain-age[all]" langchain-openai

Step 1: 지식 그래프 구축

연구팀 조직과 프로젝트를 모델링하는 그래프를 만든다.

from langchain_age import AGEGraph

conn_str = "host=localhost port=5433 dbname=langchain_age user=langchain password=langchain"

graph = AGEGraph(conn_str, graph_name="research_kg")

# 연구원
graph.query("CREATE (:Researcher {name: 'Alice', role: 'Lead', specialty: 'Graph DB'})")
graph.query("CREATE (:Researcher {name: 'Bob', role: 'Senior', specialty: 'NLP'})")
graph.query("CREATE (:Researcher {name: 'Carol', role: 'Junior', specialty: 'Vector Search'})")
graph.query("CREATE (:Researcher {name: 'Dave', role: 'Senior', specialty: 'LLM'})")

# 프로젝트
graph.query("CREATE (:Project {name: 'GraphRAG', status: 'active', desc: 'Graph-enhanced RAG pipeline'})")
graph.query("CREATE (:Project {name: 'HybridSearch', status: 'active', desc: 'Vector + full-text fusion'})")
graph.query("CREATE (:Project {name: 'AgentMemory', status: 'planning', desc: 'Long-term memory for agents'})")

# 논문
graph.query("CREATE (:Paper {title: 'Efficient Graph Traversal with CTE', year: 2026})")
graph.query("CREATE (:Paper {title: 'RRF for Hybrid Search', year: 2025})")

# 관계: 팀 구조
graph.query(
    "MATCH (a:Researcher {name: 'Alice'}), (b:Researcher {name: 'Bob'}) "
    "CREATE (a)-[:MANAGES]->(b)"
)
graph.query(
    "MATCH (a:Researcher {name: 'Alice'}), (c:Researcher {name: 'Carol'}) "
    "CREATE (a)-[:MANAGES]->(c)"
)

# 관계: 프로젝트 참여
graph.query(
    "MATCH (a:Researcher {name: 'Alice'}), (p:Project {name: 'GraphRAG'}) "
    "CREATE (a)-[:LEADS]->(p)"
)
graph.query(
    "MATCH (b:Researcher {name: 'Bob'}), (p:Project {name: 'GraphRAG'}) "
    "CREATE (b)-[:WORKS_ON]->(p)"
)
graph.query(
    "MATCH (c:Researcher {name: 'Carol'}), (p:Project {name: 'HybridSearch'}) "
    "CREATE (c)-[:WORKS_ON]->(p)"
)
graph.query(
    "MATCH (d:Researcher {name: 'Dave'}), (p:Project {name: 'AgentMemory'}) "
    "CREATE (d)-[:LEADS]->(p)"
)

# 관계: 논문 저술
graph.query(
    "MATCH (a:Researcher {name: 'Alice'}), (p:Paper {title: 'Efficient Graph Traversal with CTE'}) "
    "CREATE (a)-[:AUTHORED]->(p)"
)
graph.query(
    "MATCH (c:Researcher {name: 'Carol'}), (p:Paper {title: 'RRF for Hybrid Search'}) "
    "CREATE (c)-[:AUTHORED]->(p)"
)

스키마를 확인한다:

graph.refresh_schema()
print(graph.schema)
# Node labels and properties:
#   :Researcher {name, role, specialty}
#   :Project {name, status, desc}
#   :Paper {title, year}
# Relationship types and properties:
#   [:MANAGES] {}
#   [:LEADS] {}
#   [:WORKS_ON] {}
#   [:AUTHORED] {}

Step 2: 그래프 노드 벡터화

from_existing_graph()는 지정한 라벨의 노드를 읽어서, 텍스트 프로퍼티를 결합하고, 임베딩을 생성해서 벡터 테이블에 저장한다. 한 줄이면 된다.

from langchain_age import AGEVector
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 연구원 노드 벡터화
researcher_store = AGEVector.from_existing_graph(
    embedding=embeddings,
    connection_string=conn_str,
    graph_name="research_kg",
    node_label="Researcher",
    text_node_properties=["name", "role", "specialty"],  # 이 프로퍼티들을 결합해 임베딩
    collection_name="researcher_vectors",
)

# 프로젝트 노드 벡터화
project_store = AGEVector.from_existing_graph(
    embedding=embeddings,
    connection_string=conn_str,
    graph_name="research_kg",
    node_label="Project",
    text_node_properties=["name", "desc"],
    collection_name="project_vectors",
)

내부적으로 생성되는 텍스트 (Researcher 예시):

name: Alice
role: Lead
specialty: Graph DB

각 벡터 레코드의 메타데이터에는 node_labelage_node_id가 자동으로 포함된다. 이것이 벡터 검색 결과를 그래프로 다시 연결하는 열쇠다.

이 벡터화 단계가 완료되면, 각 그래프 노드는 의미 기반 유사도 검색이 가능한 벡터 표현을 갖게 된다. 다음 단계에서는 이 벡터 검색 결과를 시작점으로 삼아, 그래프의 관계를 따라 컨텍스트를 확장하는 GraphRAG의 핵심 패턴을 구현한다.

Step 3: 벡터 검색 → 그래프 확장

GraphRAG의 핵심 패턴: 벡터로 시작점을 찾고, 그래프로 컨텍스트를 확장한다.

def graphrag_search(query: str, store: AGEVector, graph: AGEGraph, k: int = 2):
    """벡터 검색 후 그래프 관계로 컨텍스트를 확장한다."""

    # 1단계: 벡터 검색으로 관련 엔티티 찾기
    docs = store.similarity_search(query, k=k)

    enriched_results = []
    for doc in docs:
        entity = {
            "text": doc.page_content,
            "metadata": doc.metadata,
            "neighbors": [],
        }

        # 2단계: 그래프에서 나가는 관계 확장
        node_label = doc.metadata["node_label"]
        outgoing = graph.query(
            f"MATCH (n:{node_label})-[r]->(m) "
            f"WHERE n.name = %s "
            f"RETURN type(r) AS rel, m.name AS name",
            params=(doc.metadata.get("name", ""),),
        )

        # 3단계: 들어오는 관계도 확장
        incoming = graph.query(
            f"MATCH (m)-[r]->(n:{node_label}) "
            f"WHERE n.name = %s "
            f"RETURN type(r) AS rel, m.name AS name",
            params=(doc.metadata.get("name", ""),),
        )

        entity["neighbors"] = {
            "outgoing": outgoing,
            "incoming": incoming,
        }
        enriched_results.append(entity)

    return enriched_results


# 실행
results = graphrag_search(
    "그래프 데이터베이스 전문가",
    researcher_store,
    graph,
)

for r in results:
    print(f"\n=== {r['text']} ===")
    for o in r["neighbors"]["outgoing"]:
        print(f"  → [{o['rel']}] → {o['name']}")
    for i in r["neighbors"]["incoming"]:
        print(f"  ← [{i['rel']}] ← {i['name']}")

예상 출력:

=== name: Alice / role: Lead / specialty: Graph DB ===
  → [MANAGES] → Bob
  → [MANAGES] → Carol
  → [LEADS] → GraphRAG
  → [AUTHORED] → Efficient Graph Traversal with CTE

벡터 검색만으로는 “Alice는 Graph DB 전문가”만 알 수 있다. 그래프 확장으로 “Alice는 Bob과 Carol을 관리하며, GraphRAG 프로젝트를 이끌고, CTE 논문을 저술했다”까지 알게 된다.

그래프 확장이 추가하는 핵심 가치는 엔티티 간의 구조적 관계다. 벡터 검색이 “누구”를 찾아준다면, 그래프 확장은 “그 사람이 누구와 어떤 관계이며, 무엇을 했는지”까지 보여준다. 이 관계 정보가 LLM에 전달되어야 멀티홉 질문에 정확한 답변이 가능해진다.

Step 4: LLM에 풍부한 컨텍스트 제공

확장된 컨텍스트를 LLM에 전달해 답변을 생성한다.

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

def format_graphrag_context(results: list[dict]) -> str:
    """GraphRAG 결과를 LLM 컨텍스트 문자열로 변환."""
    context_parts = []
    for r in results:
        part = f"엔티티: {r['text']}\n"
        for rel in r["neighbors"].get("outgoing", []):
            part += f"  → [{rel['rel']}] → {rel['name']}\n"
        for rel in r["neighbors"].get("incoming", []):
            part += f"  ← [{rel['rel']}] ← {rel['name']}\n"
        context_parts.append(part)
    return "\n".join(context_parts)


prompt = ChatPromptTemplate.from_template(
    "다음은 지식 그래프에서 검색한 정보입니다.\n\n"
    "{context}\n\n"
    "위 정보를 바탕으로 질문에 답변하세요.\n"
    "질문: {question}"
)

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# GraphRAG 체인 실행
results = graphrag_search("그래프 DB 관련 프로젝트를 이끄는 사람", researcher_store, graph)
context = format_graphrag_context(results)

chain = prompt | llm | StrOutputParser()
answer = chain.invoke({"context": context, "question": "그래프 DB 관련 프로젝트를 이끄는 사람은 누구이며, 어떤 연구를 했나?"})
print(answer)
# Alice가 GraphRAG 프로젝트를 이끌고 있으며, 'Efficient Graph Traversal with CTE' 논문을 저술했습니다.
# Alice는 Bob과 Carol을 관리하며, Graph DB를 전문으로 합니다.

Step 5: AGEGraphCypherQAChain — 자동화된 GraphRAG

위 과정을 수동으로 구성하지 않고, LLM이 직접 Cypher를 생성해서 그래프를 조회하는 방식도 있다.

from langchain_age import AGEGraphCypherQAChain
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

chain = AGEGraphCypherQAChain.from_llm(
    llm,
    graph=graph,
    allow_dangerous_requests=True,
    return_intermediate_steps=True,
    verbose=True,
)

result = chain.invoke({"query": "Alice가 관리하는 사람들이 참여하는 프로젝트는?"})
print(result["result"])
print(result["intermediate_steps"][0]["query"])
# MATCH (a:Researcher {name: 'Alice'})-[:MANAGES]->(r:Researcher)-[:WORKS_ON]->(p:Project)
# RETURN p.name AS project, r.name AS researcher

스키마 필터링으로 정확도 높이기

그래프가 커지면 LLM에 전체 스키마를 노출하면 Cypher 생성 정확도가 떨어진다. 필요한 타입만 화이트리스트로 제한한다.

# 연구원-프로젝트 관계만 노출
chain = AGEGraphCypherQAChain.from_llm(
    llm,
    graph=graph,
    include_types=["Researcher", "Project", "MANAGES", "LEADS", "WORKS_ON"],
    allow_dangerous_requests=True,
)

# 또는 블랙리스트로 제외
chain = AGEGraphCypherQAChain.from_llm(
    llm,
    graph=graph,
    exclude_types=["Paper", "AUTHORED"],  # 논문 관련 제외
    allow_dangerous_requests=True,
)

스키마 필터링의 효과:

접근법LLM에 노출되는 스키마Cypher 정확도
전체 노출모든 라벨, 관계보통
화이트리스트필요한 것만높음
블랙리스트불필요한 것 제외높음

Step 6: 딥 트래버셜로 멀티홉 확장

1-2홉 확장으로 부족한 경우, traverse()로 깊은 관계까지 탐색할 수 있다. 2편에서 다뤘듯이 traverse()는 Cypher *N보다 10-22배 빠르다.

# Alice에서 MANAGES 관계를 따라 3홉 이내 도달 가능한 모든 노드
reachable = graph.traverse(
    start_label="Researcher",
    start_filter={"name": "Alice"},
    edge_label="MANAGES",
    max_depth=3,
    direction="outgoing",
    return_properties=True,
)

for node in reachable:
    print(f"  depth={node['depth']}{node['properties']}")
# depth=1 → {'name': 'Bob', 'role': 'Senior', 'specialty': 'NLP'}
# depth=1 → {'name': 'Carol', 'role': 'Junior', 'specialty': 'Vector Search'}

두 가지 GraphRAG 패턴 비교

위 연구팀 그래프(4개 Researcher, 3개 Project, 2개 Paper, 8개 관계)에서 10개 질문을 반복 실행한 실측 비교:

항목수동 파이프라인CypherQAChain
정답률 (10개 질문)9/107/10
평균 응답 시간850ms620ms
멀티홉 정답률 (3개)3/31/3
구현 시간2시간15분
유연성높음보통

CypherQAChain은 “Alice가 관리하는 사람이 참여하는 프로젝트는?”(2홉) 같은 멀티홉 질문에서 잘못된 Cypher를 생성하는 경우가 있었다. 특히 관계 방향(->vs<-)을 혼동하는 패턴이 반복됐다. 수동 파이프라인은 벡터로 시작점을 정확히 잡고, 그래프 확장 로직을 직접 제어하므로 이런 오류가 없었다.

결론: CypherQAChain은 15분 만에 프로토타입을 만들 수 있어 초기 검증에 탁월하다. 하지만 멀티홉 정확도가 중요한 프로덕션에서는 수동 파이프라인이 안전하다. 프로토타입은 CypherQAChain → 프로덕션은 수동 파이프라인이 추천 경로다.

구축 과정에서 배운 것

GraphRAG 파이프라인을 실제로 조립하면서 겪은 시행착오:

  1. 그래프 스키마가 검색 품질을 결정한다. 처음에 모든 프로퍼티를 text_node_properties에 넣었더니 임베딩이 희석됐다. namespecialty만 넣었을 때 검색 정확도가 올라갔다. 벡터화할 프로퍼티는 “사람이 이 엔티티를 설명할 때 쓸 단어”만 선택하라.

  2. CypherQAChain의 스키마 필터링은 선택이 아니라 필수다. 전체 스키마를 노출하면 LLM이 존재하지 않는 관계 타입을 만들어내는 hallucination이 발생한다. include_types로 필요한 타입만 화이트리스트하니 Cypher 생성 정확도가 7/10 → 9/10으로 올라갔다.

  3. 벡터 검색 → 그래프 확장의 순서가 중요하다. 처음에 그래프 먼저 탐색하고 벡터로 필터링하는 방식을 시도했는데, 그래프 탐색 범위가 너무 넓어져 느려졌다. 벡터로 후보를 좁히고 그래프로 확장하는 순서가 성능과 정확도 모두에서 우위였다.

자주 묻는 질문

from_existing_graph()는 그래프가 변경되면 벡터도 자동 업데이트되나?

아니다. from_existing_graph()는 호출 시점의 그래프 스냅샷을 벡터화한다. 그래프가 변경되면 다시 호출해야 한다. 프로덕션에서는 그래프 변경 이벤트에 맞춰 주기적으로 재벡터화하는 파이프라인을 구성하는 것이 좋다.

CypherQAChain에서 allow_dangerous_requests는 왜 필요한가?

LLM이 생성하는 Cypher는 예측할 수 없으므로, CREATEDELETE같은 쓰기 쿼리가 생성될 수 있다. allow_dangerous_requests=True는 이 위험을 인지했다는 명시적 동의다. 프로덕션에서는 읽기 전용 DB 커넥션을 사용하거나, Cypher 검증 로직을 추가하는 것을 권장한다.

벡터 검색과 Cypher QA 중 어떤 것이 더 정확한가?

벡터 검색은 의미적 유사도 기반이므로 “비슷한 것”을 잘 찾는다. Cypher는 구조적 관계를 정확하게 따라간다. “Alice가 관리하는 사람의 프로젝트”같은 구조적 질문은 Cypher가 정확하고, “그래프 DB 전문가”같은 의미적 질문은 벡터 검색이 낫다. 둘을 조합하는 것이 가장 강력하다.

GraphDocument로 LLM이 자동 추출한 엔티티를 그래프에 넣을 수 있나?

가능하다. LangChain의 LLMGraphTransformer로 텍스트에서 엔티티/관계를 추출하고, graph.add_graph_documents()로 일괄 삽입하면 된다. 이 패턴으로 비정형 문서에서 자동으로 지식 그래프를 구축할 수 있다.

다음 편 미리보기

이번 편에서 GraphRAG 파이프라인을 완성했다. 5편에서는 여기에 LangGraph Agent를 연동해 “대화하면서 지식그래프를 점진적으로 구축하는 에이전트”를 만든다. 그래프, 벡터, 체크포인트, 장기 메모리가 모두 같은 PostgreSQL에서 동작한다.

핵심 정리

관련 포스트

참고 자료


langchain-age는 MIT 라이선스. Apache AGE는 Apache 2.0. pgvector는 PostgreSQL License.


AI-assisted content
Share this post on:

Previous Post
DeepCoWork: DeepAgents SDK 기반 AI 에이전트 데스크톱 앱을 만들었다
Next Post
langchain-age 벡터 검색 완전 정복 — Hybrid Search, MMR, 메타데이터 필터링