GraphRAG로 자연어 질의 풀기 — 자연어→Cypher→답변 합성

@JavaPark · May 24, 2026 · 15 min read

안녕하세요, 자바파커입니다.

"그래프는 만들었는데, 사용자가 Cypher를 모르잖아요."

지난 3편 적재4편 LLM 추출까지 거치며 지식 그래프가 거의 완성됐습니다. 이제 사용자 입장으로 돌아옵니다. "Claude Code 시리즈에서 첫 번째 글이 뭐야?"라고 자연어로 물었을 때, 그래프를 타고 답이 나와야 합니다.

결론부터 말씀드리면 — 자연어 → Cypher → 답변 합성 세 단계 파이프라인을 만듭니다. 벡터 검색만으로 부족하고 그래프만으로도 부족한 지점을 하이브리드로 메우면, 1편에서 약속한 "GraphRAG"가 비로소 완성됩니다.

이번 편에서는 (1) 왜 하이브리드인가 → (2) 자연어→Cypher LLM 변환 → (3) 벡터+그래프 합치기 → (4) 답변 합성 → (5) 2편의 질의 7개 자연어로 다시 풀기.


왜 하이브리드인가? — 둘 다 부족하기 때문

순수 벡터 검색과 순수 그래프 질의의 한계는 명확합니다.

접근 잘하는 것 못하는 것
벡터 검색만 "비슷한 의미의 글" 찾기 "X가 인용한 자료" 같은 관계 추적
그래프 질의만 정확한 관계 추적·집계 "이 주제 관련된 글" 같은 의미 매칭
하이브리드 둘 다 비용·복잡도 ↑

GraphRAG의 핵심은 질의의 성격에 따라 적절한 채널을 고른다는 점입니다. 모든 질의를 그래프에만 보내거나 벡터에만 보내는 게 아니라요.

라우팅 기준은 단순합니다.

  • 명확한 엔티티/관계가 있는 질의 → 그래프 우선 (예: "Claude Code 시리즈 1편")
  • 의미 기반 탐색이 필요한 질의 → 벡터 우선 (예: "RAG 정확도 올리는 방법")
  • 둘 다 → 양쪽 결과 합쳐 LLM이 답변 합성 (예: "GraphRAG 관련 글 중 신뢰할 만한 외부 자료")

단계 1: 자연어 → Cypher LLM 변환

자연어 질의를 받아 Cypher로 바꾸는 게 그래프 측의 첫 번째 일입니다. 4편의 Tool Use 패턴을 그대로 활용합니다.

LLM이 안전하게 Cypher를 쓰려면 스키마를 시스템 프롬프트에 박아두는 게 핵심입니다. 안 그러면 존재하지 않는 라벨·관계를 지어냅니다.

const SCHEMA = `
Labels:
- Post(slug, title, description, publishedAt, updatedAt)
- Tag(name)
- Series(name)
- Category(slug)
- Author(name, url)
- ExternalResource(url, title)

Relationships:
- (Post)-[:WRITTEN_BY]->(Author)
- (Post)-[:HAS_TAG]->(Tag)
- (Post)-[:IN_CATEGORY]->(Category)
- (Post)-[:BELONGS_TO_SERIES]->(Series)
- (Post)-[:CITES]->(ExternalResource)
- (Post)-[:RELATED_TO]->(Post)
- (Post)-[:PREREQUISITE_OF]->(Post)  // prereq -> 후속
`

const cypherTool = {
  name: "run_cypher",
  description: "Neo4j에서 Cypher를 실행한다. 읽기 전용 쿼리만 허용.",
  input_schema: {
    type: "object",
    properties: {
      cypher: { type: "string", description: "MATCH/WHERE/RETURN만 사용" },
      params: { type: "object", description: "$파라미터 바인딩 값" },
      explanation: {
        type: "string",
        description: "이 쿼리가 질의를 어떻게 푸는지 한 줄",
      },
    },
    required: ["cypher", "explanation"],
  },
}

async function naturalToCypher(question) {
  const response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    system: [
      {
        type: "text",
        text: `당신은 Neo4j Cypher 쿼리 생성기입니다.\n\n스키마:\n${SCHEMA}\n\n규칙:\n- 위 라벨·관계만 사용\n- 쓰기 명령(CREATE/MERGE/DELETE/SET) 금지\n- 결과는 명시적으로 LIMIT 25 이하`,
        cache_control: { type: "ephemeral" },
      },
    ],
    tools: [cypherTool],
    tool_choice: { type: "tool", name: "run_cypher" },
    messages: [{ role: "user", content: question }],
  })

  return response.content.find(b => b.type === "tool_use").input
}

여기서 안전 장치 두 가지가 들어가 있습니다.

  1. 읽기 전용을 시스템 프롬프트로 강제 — tool_choice로 도구 호출만 허용해도, LLM이 쓰기 쿼리를 만들 수 있습니다. 그래서 실행 직전에도 한 번 더 검사합니다.
  2. LIMIT 강제 — LLM이 잊고 LIMIT을 안 걸면 전체 그래프를 긁어올 수 있어서요.

실행 직전 검증:

function isReadOnly(cypher) {
  const upper = cypher.toUpperCase()
  return !/\b(CREATE|MERGE|DELETE|SET|REMOVE|DROP|CALL\s+APOC\.PERIODIC)\b/.test(
    upper
  )
}

async function executeCypher(session, { cypher, params }) {
  if (!isReadOnly(cypher)) throw new Error("쓰기 쿼리 거부")
  const result = await session.executeRead(tx => tx.run(cypher, params ?? {}))
  return result.records.map(r => r.toObject())
}

LLM 출력은 의심하고 검증하는 게 GraphRAG 운영의 첫 번째 규칙입니다.


단계 2: 벡터 검색 결합 — 의미 매칭 보강

순수 그래프 질의로는 "RAG 정확도 올리는 방법" 같은 의미 기반 질의가 약합니다. 같은 그래프 위에 벡터 인덱스를 얹어 보강합니다.

Neo4j 5+는 벡터 인덱스를 기본 지원합니다. Post 본문을 임베딩해 인덱스로 만들어둡니다.

CREATE VECTOR INDEX post_body_idx IF NOT EXISTS
FOR (p:Post) ON (p.embedding)
OPTIONS {indexConfig: {
  `vector.dimensions`: 1536,
  `vector.similarity_function`: 'cosine'
}};

임베딩 적재는 3편 적재 스크립트에 한 단계 추가합니다. OpenAI나 Voyage AI 같은 임베딩 모델을 호출해 Post 노드의 embedding 속성에 저장합니다.

질의 시:

async function vectorSearch(session, question, topK = 5) {
  const embedding = await embed(question) // 임베딩 함수
  const result = await session.executeRead(tx =>
    tx.run(
      `CALL db.index.vector.queryNodes('post_body_idx', $topK, $embedding)
       YIELD node, score
       RETURN node.slug AS slug, node.title AS title, score`,
      { embedding, topK }
    )
  )
  return result.records.map(r => r.toObject())
}

이렇게 하면 그래프 안에서 벡터 유사도 검색이 한 번에 됩니다. 별도 벡터 DB가 필요 없습니다.


단계 3: 라우팅 — 그래프 vs 벡터 vs 둘 다

질의를 받았을 때 어느 채널로 보낼지 LLM에 한 번 묻습니다.

const routerTool = {
  name: "route_query",
  input_schema: {
    type: "object",
    properties: {
      mode: { type: "string", enum: ["graph", "vector", "hybrid"] },
      reason: { type: "string" },
    },
    required: ["mode", "reason"],
  },
}

규칙은 LLM에 한국어로 설명해도 잘 따라줍니다.

mode 선택:
- graph: 명확한 엔티티/관계가 보이는 질의 (시리즈, 태그, 인용, 선수 글 등)
- vector: 의미적 유사성 기반 탐색 ("~한 글", "비슷한 내용")
- hybrid: 둘 다 필요할 때

라우팅이 정해지면 해당 채널을 호출하고, 결과를 다음 단계로 넘깁니다.


단계 4: 답변 합성 — LLM이 결과를 자연어로

그래프 결과는 객체 배열, 벡터 결과도 객체 배열입니다. 그대로 사용자에게 보여주면 "그래서 답이 뭐냐"가 안 보이죠. LLM에 다시 넘겨 답변을 합성합니다.

async function synthesize(question, graphRows, vectorRows) {
  const context = JSON.stringify({ graphRows, vectorRows }, null, 2)
  const response = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    system:
      "주어진 결과만으로 답하세요. 결과에 없는 사실은 만들지 마세요. 마지막에 근거가 된 글 slug 목록을 [refs: ...] 형태로 첨부합니다.",
    messages: [
      {
        role: "user",
        content: `질문: ${question}\n\n검색 결과:\n${context}`,
      },
    ],
  })
  return response.content[0].text
}

합성 단계에서 가장 중요한 건 "결과에 없는 사실은 만들지 말라" 한 줄입니다. 이게 GraphRAG가 순수 LLM 답변보다 hallucination이 적은 결정적 이유예요. 그래프가 사실 기반(triple)이라 합성용 컨텍스트가 거짓을 포함할 가능성이 낮고, LLM도 "주어진 것만" 쓰도록 묶어둡니다.


파이프라인 합치기

지금까지의 단계를 한 함수로 묶습니다.

async function ask(session, question) {
  // 1) 라우팅
  const { mode } = await route(question)

  // 2) 채널별 검색
  let graphRows = [],
    vectorRows = []
  if (mode === "graph" || mode === "hybrid") {
    const { cypher, params } = await naturalToCypher(question)
    graphRows = await executeCypher(session, { cypher, params })
  }
  if (mode === "vector" || mode === "hybrid") {
    vectorRows = await vectorSearch(session, question)
  }

  // 3) 답변 합성
  return synthesize(question, graphRows, vectorRows)
}

100줄도 안 되는 코드로 GraphRAG 파이프라인이 완성됩니다. LangChain의 GraphCypherQAChain을 써도 비슷한 일을 하지만, 직접 짜면 채널 라우팅·검수·로깅을 자기 입맛대로 끼우기 쉽습니다.


2편의 질의 7개 — 자연어로 다시 풀어보기

이제 2편에서 적었던 질의 7개를 자연어로 던져봅니다.

# 자연어 질의 라우팅 결과
Q1 "Claude Code 시리즈 첫 번째 글이 뭐야?" graph 시리즈 매칭 + seriesOrder ASC LIMIT 1
Q2 "4월에 쓴 GraphRAG 관련 글 보여줘" graph 날짜 범위 + 태그 매칭
Q3 "AI 카테고리에서 최신 글 5개" graph 카테고리 매칭 + 최신순
Q4 "1편이 인용한 외부 자료들" graph :CITES 관계
Q5 "이 글이랑 비슷한 다른 글 추천해줘" hybrid 벡터 유사도 + RELATED_TO 관계
Q6 "Claude Code와 GraphRAG를 모두 다룬 시리즈" graph 두 태그 교차 + 시리즈
Q7 "이 글 이해하려면 먼저 읽어야 할 글" graph :PREREQUISITE_OF 역추적

순수 그래프가 Q1·Q2·Q3·Q4·Q6·Q7을, 하이브리드가 Q5를 담당합니다. 순수 벡터로만 풀리는 질의가 없는 점이 흥미롭습니다. 도메인 특성상 관계 정보가 핵심이라 그렇고요, e-commerce처럼 자유 텍스트 검색이 큰 도메인은 비율이 달라집니다.


운영하면서 챙겨야 할 것

직접 돌려보며 챙겼던 운영 관점 네 가지:

1) 모든 LLM 호출에 trace 남기기

라우팅 결정, 생성된 Cypher, 실행 결과, 합성된 답변을 한 줄로 묶어 로그로 남기세요. 답이 이상하게 나왔을 때 어느 단계 잘못이었는지 즉시 보입니다.

2) Cypher 캐시

같은 질문이 반복되면 LLM 호출을 건너뛰고 캐시된 Cypher만 실행. 단순 해시 캐시로 충분합니다.

3) 빈 결과 처리

그래프가 빈 결과를 돌려주면 LLM이 종종 "데이터에 따르면 X입니다" 식으로 지어냅니다. 빈 결과면 답변 합성을 건너뛰고 "관련 정보를 찾지 못했어요"로 단축하세요.

4) 사람 답변과 비교

핵심 질의 10개를 골라 사람 답변과 LLM 답변을 나란히 비교하는 페이지를 만드세요. 회귀 테스트가 됩니다. 프롬프트나 모델을 바꿀 때마다 이 페이지로 검증.


다음 편 예고 — 운영하며 배운 것

GraphRAG 파이프라인이 완성됐습니다. 마지막 6편은 이 시리즈를 운영하며 솔직히 느낀 것들을 정리하려 합니다.

  • 효과는 좋았나? 어디서 좋았고 어디서 별로였나
  • 비용은 얼마나 들었나
  • 어떤 도메인엔 추천하고 어떤 도메인엔 비추천인가
  • 다시 한다면 무엇을 다르게 할 것인가

자주 묻는 질문 (FAQ)

Q1. LangChain의 GraphCypherQAChain을 쓰는 게 더 낫지 않나요? 빠르게 프로토타입을 만들기엔 LangChain이 편합니다. 다만 한국어 도메인에서 프롬프트 미세 조정, 라우팅 분기, 검수 단계 같은 걸 자기 입맛대로 끼우기에는 직접 구현이 자유롭습니다. LangChain으로 시작 → 한계 부딪히면 직접 구현으로 옮기는 게 자연스러운 진화 경로입니다.

Q2. Cypher를 LLM이 잘못 생성하면 어떡하나요? 실패 시 한 번 더 시도하는 fallback을 두세요. 두 번 다 실패하면 "정확한 답을 만들지 못했어요"로 단축. 무한정 재시도는 비용만 늘고 답이 안 좋아집니다. 시스템 프롬프트의 스키마를 가다듬는 게 근본 처방이에요.

Q3. 벡터 임베딩 비용이 부담스러운데 꼭 필요한가요? 도메인이 관계 중심이면 (이 시리즈처럼) 벡터는 옵션입니다. 글이 100개 미만이라면 벡터 인덱스 없이 그래프만으로도 대부분 풀립니다. 글이 1000개 넘어가고 의미 검색 비중이 커지면 그때 도입해도 늦지 않습니다.


여러분의 도메인에서는 어떤 질의가 가장 풀기 어려우신가요? 댓글로 알려주시면 다음 편 회고에서 한 번 더 짚어보겠습니다.

다음 편은 시리즈 마무리, 운영 회고로 갑니다.

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다