안녕하세요, 자바파커입니다.
"비교는 알겠고, 그래서 뭘 깔면 되는데요?"
지난 2편에서 블로그 검색용 온톨로지를 클래스 6개·관계 7개로 설계했습니다. 이제 머릿속에 있던 설계를 실제로 돌아가는 그래프 DB로 옮길 차례입니다.
결론부터 말씀드리면 — 이번 편에서는 Neo4j Community Edition으로 확정하고, 도커로 띄운 뒤 블로그 글을 그래프에 적재합니다. 적재 끝나면 2편에서 정한 질의 7개를 Cypher로 실제로 돌려봅니다.
선택 이유와 다른 후보를 미리 알아두면 본인 도메인에 맞는 결정을 내리기 쉬워지니, 비교부터 짧게 보겠습니다.
그래프 DB 후보 비교 — 1인 운영 기준
선정 기준을 먼저 적어둡니다. 사람마다 가중치가 다르니 본인 상황에 맞춰 조정하시면 됩니다.
- 무료/오픈소스 — 1인 운영에 라이선스 비용은 부담
- 로컬 도커 실행 — 인프라 자체에 시간을 쓰고 싶지 않음
- LLM 생태계 통합 — LangChain/LlamaIndex에서 바로 쓸 수 있어야 함
- 질의어 진입 장벽 — 며칠 안에 익숙해질 수 있어야 함
- 시각화 도구 — 그래프는 눈으로 봐야 신뢰가 생김
이 기준으로 본 후보 4종 비교:
| 후보 | 모델 | 질의어 | LLM 통합 | 시각화 | 1인 운영 비용 |
|---|---|---|---|---|---|
| Neo4j Community | LPG (라벨 속성 그래프) | Cypher | LangChain·LlamaIndex 1급 시민 | Neo4j Browser 내장 | 무료, Docker 5분 |
| GraphDB Free | RDF (트리플) | SPARQL | 일부 어댑터 존재 | Workbench 내장 | 무료(소규모), 학습 가파름 |
| Memgraph | LPG | Cypher | 통합 진행 중 | Memgraph Lab 별도 | 무료, 생태계 작음 |
| Apache Jena Fuseki | RDF | SPARQL | 거의 없음 | 별도 도구 필요 | 무료, 운영 손많음 |
가장 큰 갈림길은 LPG vs RDF입니다. RDF는 W3C 표준이고 OWL 추론기와 결합하면 강력하지만, LLM 도구 통합은 LPG 진영이 압도적으로 앞서 있습니다. 우리는 GraphRAG가 목적이라 LPG로 갑니다.
LPG 안에서 Neo4j vs Memgraph는 사실 Cypher 호환이라 코드가 거의 같습니다. 다만 LangChain의 Neo4j 통합이 가장 두텁고, 학습 자료가 압도적이라 Neo4j로 결정합니다.
만약 본인 도메인이 학술 인용망·온톨로지 추론 중심이라면 RDF(GraphDB) 쪽이 더 맞을 수 있습니다. 이 시리즈는 "검색·RAG"에 초점이라 LPG가 답이라는 점만 강조드립니다.
Neo4j Community를 5분 만에 띄우기
Neo4j Community Edition은 도커 이미지 한 줄이면 끝입니다. 프로젝트 루트에 docker-compose.yml을 만듭니다.
services:
neo4j:
image: neo4j:5-community
container_name: ontology-search-neo4j
ports:
- "7474:7474" # Browser
- "7687:7687" # Bolt protocol (드라이버용)
environment:
- NEO4J_AUTH=neo4j/please-change-me
- NEO4J_PLUGINS=["apoc"]
volumes:
- ./neo4j/data:/data
- ./neo4j/logs:/logsdocker compose up -d브라우저에서 http://localhost:7474로 접속하면 Neo4j Browser가 뜹니다. 위 비밀번호로 로그인하면 빈 그래프가 보입니다.
APOC 플러그인은 데이터 적재·문자열 처리에 자주 쓰이는 표준 확장팩이라 미리 켜뒀습니다.
온톨로지를 Neo4j 스키마로 옮기기
2편에서 그린 온톨로지를 Neo4j 용어로 매핑합니다.
| 온톨로지 | Neo4j |
|---|---|
| 클래스(Class) | Label (예: :Post, :Tag) |
| 속성(Property) | Property (노드/관계의 키-값) |
| 관계(Relation) | Relationship Type (예: :HAS_TAG) |
| 식별자 | Unique constraint |
Neo4j 관례상 라벨은 PascalCase, 관계는 UPPER_SNAKE_CASE입니다. 2편의 7개 관계를 옮기면:
// 식별자 제약 (먼저 정의)
CREATE CONSTRAINT post_slug IF NOT EXISTS
FOR (p:Post) REQUIRE p.slug IS UNIQUE;
CREATE CONSTRAINT tag_name IF NOT EXISTS
FOR (t:Tag) REQUIRE t.name IS UNIQUE;
CREATE CONSTRAINT series_name IF NOT EXISTS
FOR (s:Series) REQUIRE s.name IS UNIQUE;
CREATE CONSTRAINT category_slug IF NOT EXISTS
FOR (c:Category) REQUIRE c.slug IS UNIQUE;
CREATE CONSTRAINT author_name IF NOT EXISTS
FOR (a:Author) REQUIRE a.name IS UNIQUE;
CREATE CONSTRAINT external_url IF NOT EXISTS
FOR (e:ExternalResource) REQUIRE e.url IS UNIQUE;제약을 먼저 거는 이유는 중복 적재를 막기 위해서입니다. MERGE 절이 식별자로 매칭하기 때문에, 제약 없이 적재하면 같은 태그가 여러 번 생기는 사고가 납니다.
인덱스는 Unique constraint 생성 시 자동으로 따라오므로 별도로 만들 필요 없습니다.
블로그 글에서 데이터 뽑아내기
이제 contents/posts/**/index.md를 파싱해 그래프에 적재합니다. 이번 편에서는 frontmatter 기반 추출만 합니다. 본문 인용·관련글 같은 깊은 관계는 4편에서 LLM으로 자동 추출할 예정이에요.
frontmatter 예시(이 시리즈 1편)는 이런 모양입니다.
title: "온톨로지 기반 검색이란? — 벡터 검색만으로 부족했던 이유"
description: "..."
date: 2026-05-20 09:00
update: 2026-05-20
series: "온톨로지로 똑똑한 검색 만들기"
tags:
- 온톨로지
- 지식 그래프
- GraphRAG
- RAG
- LLM 검색여기서 뽑을 수 있는 정보:
- Post: title, description, publishedAt, updatedAt, slug(폴더명), seriesOrder(파일경로에서)
- Tag: tags 배열
- Series: series 문자열
- Category: 경로 첫 segment (
ai,claude-code, …) - Author: 일단 "자바파커" 하드코딩
본문에서 추출하면 좋을 것들(이번 편 범위 밖):
- 외부 인용 → 마크다운 링크 파싱 → ExternalResource·cites
- 관련글 → 본문 의미 분석 → relatedTo (4편에서 LLM 사용)
- 선수글 → 별도 frontmatter 필드 도입 검토
적재 스크립트 — Node.js + neo4j-driver
블로그가 Gatsby 기반이라 Node 환경이 이미 있어, 같은 환경에서 스크립트를 작성합니다.
npm install --save-dev neo4j-driver gray-matter globscripts/ingest-graph.mjs를 만듭니다.
import neo4j from "neo4j-driver"
import matter from "gray-matter"
import { globSync } from "glob"
import fs from "node:fs"
import path from "node:path"
const driver = neo4j.driver(
"bolt://localhost:7687",
neo4j.auth.basic("neo4j", "please-change-me")
)
const AUTHOR = { name: "자바파커", url: "https://blog.javapark.kr" }
function parsePost(filePath) {
const raw = fs.readFileSync(filePath, "utf-8")
const { data, content } = matter(raw)
const rel = path.relative("contents/posts", filePath)
const segments = rel.split(path.sep)
const category = segments[0]
const slug = segments[1]
return {
slug,
title: data.title,
description: data.description ?? "",
publishedAt: new Date(data.date).toISOString(),
updatedAt: new Date(data.update ?? data.date).toISOString(),
series: data.series ?? null,
tags: data.tags ?? [],
category,
body: content,
}
}
async function ingest(session, post) {
await session.executeWrite(async tx => {
// Post 노드
await tx.run(
`MERGE (p:Post {slug: $slug})
SET p.title = $title, p.description = $description,
p.publishedAt = datetime($publishedAt),
p.updatedAt = datetime($updatedAt)`,
post
)
// Author
await tx.run(
`MERGE (a:Author {name: $name}) SET a.url = $url
WITH a
MATCH (p:Post {slug: $slug})
MERGE (p)-[:WRITTEN_BY]->(a)`,
{ ...AUTHOR, slug: post.slug }
)
// Category
await tx.run(
`MERGE (c:Category {slug: $category})
WITH c
MATCH (p:Post {slug: $slug})
MERGE (p)-[:IN_CATEGORY]->(c)`,
{ category: post.category, slug: post.slug }
)
// Tags
for (const name of post.tags) {
await tx.run(
`MERGE (t:Tag {name: $name})
WITH t
MATCH (p:Post {slug: $slug})
MERGE (p)-[:HAS_TAG]->(t)`,
{ name, slug: post.slug }
)
}
// Series (옵션)
if (post.series) {
await tx.run(
`MERGE (s:Series {name: $series})
WITH s
MATCH (p:Post {slug: $slug})
MERGE (p)-[:BELONGS_TO_SERIES]->(s)`,
{ series: post.series, slug: post.slug }
)
}
})
}
const files = globSync("contents/posts/**/index.md")
const session = driver.session()
try {
for (const file of files) {
const post = parsePost(file)
await ingest(session, post)
console.log(`✓ ${post.slug}`)
}
} finally {
await session.close()
await driver.close()
}node scripts/ingest-graph.mjs핵심은 모두 MERGE로 작성한 것입니다. CREATE를 쓰면 재실행할 때마다 중복이 생기지만, MERGE는 식별자 기준 upsert라 멱등(idempotent)합니다. 운영하다 보면 글 수정 후 재적재가 잦아서 이게 큰 차이를 만듭니다.
적재 확인 — 2편의 질의 7개 돌려보기
Neo4j Browser(http://localhost:7474)에서 직접 Cypher를 쳐봅니다.
Q1. Claude Code 시리즈의 첫 번째 글
MATCH (p:Post)-[:BELONGS_TO_SERIES]->(s:Series {name: "Claude Code 사용 가이드"})
RETURN p ORDER BY p.publishedAt ASC LIMIT 1;Q2. 4월에 쓴 GraphRAG 관련 글
MATCH (p:Post)-[:HAS_TAG]->(t:Tag {name: "GraphRAG"})
WHERE p.publishedAt >= datetime("2026-04-01")
AND p.publishedAt < datetime("2026-05-01")
RETURN p;Q3. AI 카테고리 최신 5개
MATCH (c:Category {slug: "ai"})<-[:IN_CATEGORY]-(p:Post)
RETURN p ORDER BY p.publishedAt DESC LIMIT 5;Q6. "Claude Code"와 "GraphRAG" 태그를 둘 다 갖는 글이 속한 시리즈
MATCH (s:Series)<-[:BELONGS_TO_SERIES]-(p:Post)
WHERE EXISTS { (p)-[:HAS_TAG]->(:Tag {name: "Claude Code"}) }
AND EXISTS { (p)-[:HAS_TAG]->(:Tag {name: "GraphRAG"}) }
RETURN DISTINCT s;이 정도면 frontmatter만으로 4개 질의를 다 표현합니다. Q4(인용 자료), Q5(후속/관련), Q7(선수글)은 본문 분석이 필요해서 4편에서 LLM으로 자동 추출한 뒤에 다시 검증하겠습니다.
운영 팁 몇 가지
직접 적재해보면서 부딪힌 작은 함정들입니다.
1) datetime은 반드시 ISO 문자열로
Cypher의 datetime() 함수는 JS Date 객체를 그대로 못 받습니다. 반드시 .toISOString()으로 문자열 변환 후 넘기세요. 안 그러면 무성의한 에러가 납니다.
2) MERGE의 SET은 분리해서 생각
MERGE (p:Post {slug: $slug})
SET p.title = $title // 매칭됐든 새로 만들었든 SET은 항상 실행됨매칭됐을 때와 새로 만들 때 동작을 달리하고 싶으면 ON CREATE SET / ON MATCH SET을 쓰세요.
3) 트랜잭션은 글 단위로
executeWrite 안에 한 글의 모든 적재(Post + Author + Category + Tags + Series)를 묶었습니다. 글 전체가 atomic하게 들어가거나 안 들어가도록 — 중간에 실패하면 그 글만 깔끔하게 롤백됩니다.
4) 시각화로 검증
Neo4j Browser에서 MATCH (n) RETURN n LIMIT 100 한 번 돌려보세요. 시각화로 보면 설계 결함이 한눈에 보입니다. 외톨이 노드, 잘못된 방향, 누락된 관계가 즉시 드러나요.
다음 편 예고 — LLM으로 본문에서 자동 추출
frontmatter는 깔끔하지만 한계가 명확합니다. 본문 안의 인용·관련·선수 관계는 사람이 일일이 frontmatter에 적기 어렵죠.
다음 편(4편)에서는 Microsoft GraphRAG 식 접근으로 갑니다.
- 본문을 LLM에 넣고 엔티티·관계를 구조화 JSON으로 추출
- 추출 결과를 위 적재 스크립트에 합치는 파이프라인
- 비용·정확도 트레이드오프
- 사람 검수가 필요한 지점
이걸 해야 비로소 Q4·Q5·Q7까지 답할 수 있는 진짜 "GraphRAG 준비"가 완성됩니다.
자주 묻는 질문 (FAQ)
Q1. Neo4j AuraDB(클라우드 무료 티어)를 쓰는 게 더 낫지 않나요? 프로토타입엔 좋습니다. 다만 무료 티어는 노드 수 제한이 있고 일정 시간 미사용 시 슬립 상태로 들어갑니다. 로컬 도커는 제약이 없고 LLM 호출 같은 시행착오 단계에서 빠릅니다. 서비스 단계에서는 AuraDB·Memgraph Cloud 같은 매니지드로 옮기는 게 합리적입니다.
Q2. Cypher 처음 보는데 무엇부터 배워야 하나요?
MATCH, MERGE, WHERE, RETURN 4개로 80%는 됩니다. Neo4j 공식 무료 강의 GraphAcademy에 1~2시간짜리 입문 코스가 잘 만들어져 있어 추천합니다. 어차피 5편에서 LLM이 Cypher를 대신 써주는 부분도 다루니, 처음부터 깊이 팔 필요는 없습니다.
Q3. 글이 1000개 넘어가도 이 적재 스크립트가 버틸까요?
1000개 정도면 단순 직렬 적재로 수십 초 안에 끝납니다. 그 이상이거나 한 글당 임베딩까지 만들면 UNWIND + 배치로 묶거나 apoc.periodic.iterate를 쓰는 게 좋습니다. 1인 블로그 규모라면 현재 스크립트로 충분합니다.
여러분이 만들 그래프 DB에는 어떤 데이터가 들어갈지 댓글로 알려주세요. 도메인이 달라도 적재 패턴은 거의 비슷해서, 참고가 될 수 있습니다.
다음 편에서 LLM 자동 추출 들어갑니다.