안녕하세요, 자바파커입니다.
"본문에 박힌 인용·관련 글까지 사람이 frontmatter에 일일이 적으라고요?"
지난 3편에서 frontmatter로 4개 질의(Q1·Q2·Q3·Q6)를 풀었습니다. 남은 세 개 — Q4(인용 자료), Q5(관련 글), Q7(선수 글) — 은 본문을 읽지 않고는 답이 안 나옵니다.
결론부터 말씀드리면 — 본문을 LLM에 넣고 Tool Use로 구조화된 JSON을 받아 그래프에 합칩니다. 이게 Microsoft GraphRAG가 자동으로 지식 그래프를 만드는 핵심 원리이기도 합니다.
이번 편에서는 Claude API를 사용해 다음을 다룹니다. (1) 무엇을 추출할지 명세 → (2) Tool Use 스키마 설계 → (3) 프롬프트 캐싱으로 비용 절감 → (4) 그래프에 합치기 → (5) 검수 전략.
무엇을 추출할까? — 추출 명세부터 못 박기
LLM에 "본문에서 중요한 거 뽑아줘"라고 던지면 결과가 매번 다릅니다. 추출할 항목을 정확히 명세해야 안정적인 그래프가 나옵니다.
3편 설계 기준으로 본문에서 추출할 세 가지:
| 추출 대상 | 그래프 표현 | 신호 |
|---|---|---|
| 외부 인용 자료 | (:Post)-[:CITES]->(:ExternalResource) |
본문의 외부 URL 마크다운 링크 |
| 관련 블로그 글 | (:Post)-[:RELATED_TO]->(:Post) |
같은 블로그 내 다른 글 언급(/category/slug/) |
| 선수 글 | (:Post)<-[:PREREQUISITE_OF]-(:Post) |
"먼저 읽으면 좋은 글", "이전 편" 같은 명시적 안내 |
각 항목마다 확실한 신호가 있어야 LLM이 헛것을 보지 않습니다. 신호를 모호하게 두면 hallucination이 그래프로 그대로 새어 들어옵니다.
Tool Use로 구조화 출력 받기
Claude의 Tool Use는 원래 함수 호출용이지만, 출력 형식을 강제하는 용도로도 가장 안정적입니다. JSON Schema로 정의한 구조 그대로 응답이 돌아옵니다.
스키마는 이렇게 잡습니다.
const extractionTool = {
name: "record_post_extractions",
description: "본문에서 인용·관련 글·선수 글을 추출하여 기록한다.",
input_schema: {
type: "object",
properties: {
cites: {
type: "array",
description: "본문이 인용한 외부 자료(블로그 외부 URL)",
items: {
type: "object",
properties: {
url: { type: "string" },
title: {
type: "string",
description: "링크 텍스트 또는 추론된 제목",
},
context: { type: "string", description: "인용된 문맥 한 줄" },
},
required: ["url", "title"],
},
},
related: {
type: "array",
description: "본문이 언급한 같은 블로그 내 다른 글의 slug",
items: {
type: "object",
properties: {
slug: { type: "string" },
reason: { type: "string", description: "왜 관련되는지 한 줄" },
},
required: ["slug", "reason"],
},
},
prerequisites: {
type: "array",
description: "본문이 '먼저 읽으면 좋다'고 명시한 글의 slug",
items: {
type: "object",
properties: {
slug: { type: "string" },
evidence: { type: "string", description: "원문 인용" },
},
required: ["slug", "evidence"],
},
},
},
required: ["cites", "related", "prerequisites"],
},
}세 필드 모두 required로 두는 게 핵심입니다. 추출할 게 없으면 빈 배열을 강제해야 LLM이 "관련 글이 있는 척"하지 않습니다.
context / reason / evidence 같은 근거 필드도 같이 받으세요. 검수 단계에서 진위를 빠르게 확인할 수 있고, 사람이 그래프를 직접 보며 신뢰도를 평가할 때 결정적입니다.
시스템 프롬프트 + 프롬프트 캐싱
이제 Claude API 호출. 시스템 프롬프트가 길어지므로 프롬프트 캐싱으로 비용을 줄입니다. 글 100개를 처리한다면 시스템 프롬프트는 1번만 비용을 내고 99번은 캐시 히트입니다.
import Anthropic from "@anthropic-ai/sdk"
const client = new Anthropic()
const SYSTEM_PROMPT = `당신은 블로그 글에서 그래프 관계를 추출하는 어시스턴트입니다.
규칙:
1) cites: 본문에 마크다운 링크로 등장한 외부 URL만. 블로그 자체(blog.javapark.kr)는 제외.
2) related: 본문이 명시적으로 가리킨 같은 블로그의 다른 글 slug만. 추측 금지.
3) prerequisites: "먼저 읽어라", "이전 편", "전제 지식" 같은 명시적 표현이 있을 때만.
규칙 외 항목은 절대 만들지 마세요. 빈 배열은 정상입니다.`
async function extract(post) {
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 2048,
system: [
{
type: "text",
text: SYSTEM_PROMPT,
cache_control: { type: "ephemeral" },
},
],
tools: [extractionTool],
tool_choice: { type: "tool", name: "record_post_extractions" },
messages: [
{
role: "user",
content: `[글 slug] ${post.slug}\n[카테고리] ${post.category}\n\n${post.body}`,
},
],
})
const toolUse = response.content.find(b => b.type === "tool_use")
return toolUse.input
}세 가지 포인트:
tool_choice로 사용 강제 — LLM이 텍스트 답변을 섞지 않고 무조건 도구 호출만 반환합니다.cache_control: ephemeral— 시스템 프롬프트를 5분간 캐시. 연속 호출의 비용이 급감합니다.- 모델은 Sonnet 4.6 — 추출 작업은 Sonnet으로 충분합니다. Opus는 비용 대비 이득이 작아요.
그래프에 합치기
3편의 적재 스크립트에 한 단계만 더합니다.
async function ingestExtractions(session, post, extractions) {
await session.executeWrite(async tx => {
// 외부 인용
for (const c of extractions.cites) {
await tx.run(
`MERGE (e:ExternalResource {url: $url})
SET e.title = coalesce(e.title, $title)
WITH e
MATCH (p:Post {slug: $slug})
MERGE (p)-[r:CITES]->(e)
SET r.context = $context`,
{ ...c, slug: post.slug, context: c.context ?? "" }
)
}
// 관련 글
for (const r of extractions.related) {
await tx.run(
`MATCH (a:Post {slug: $from}), (b:Post {slug: $to})
MERGE (a)-[rel:RELATED_TO]->(b)
SET rel.reason = $reason`,
{ from: post.slug, to: r.slug, reason: r.reason }
)
}
// 선수 글
for (const pre of extractions.prerequisites) {
await tx.run(
`MATCH (a:Post {slug: $from}), (b:Post {slug: $to})
MERGE (b)-[rel:PREREQUISITE_OF]->(a)
SET rel.evidence = $evidence`,
{ from: post.slug, to: pre.slug, evidence: pre.evidence }
)
}
})
}3편의 적재 루프 끝에 호출합니다.
for (const file of files) {
const post = parsePost(file)
await ingest(session, post)
const extractions = await extract(post)
await ingestExtractions(session, post, extractions)
console.log(
`✓ ${post.slug} — cites:${extractions.cites.length}, related:${extractions.related.length}, prereq:${extractions.prerequisites.length}`
)
}이제 Q4·Q5·Q7도 그래프에 살아 있습니다.
비용 — 진짜로 얼마 나오나
블로그 글 평균 1500 토큰 기준으로 거칠게 계산해보면:
| 항목 | 입력 | 출력 | 글 1개당 |
|---|---|---|---|
| 시스템(첫 호출) | ~300 | — | $0.0009 |
| 시스템(캐시 히트) | ~300 | — | $0.00009 |
| 본문 입력 | ~1500 | — | $0.0045 |
| Tool Use 출력 | — | ~300 | $0.0045 |
| 합 (캐시 히트 시) | 약 $0.009 |
100개 글이면 캐싱 효과 포함해 약 $0.9. 한 번 적재해두면 글이 바뀐 것만 재추출하면 되니, 운영 비용도 잡힙니다.
입력이 큰 작업에서 캐싱은 사실상 필수입니다. 캐싱 없이 100개 글을 처리하면 같은 시스템 프롬프트를 100번 모두 풀값으로 결제하게 됩니다.
검수 — LLM이 환각을 그래프에 흘려보내지 않으려면
LLM 추출은 매번 100% 맞지 않습니다. 그래서 그래프에 적재하기 전 자동 검수 단계를 둡니다.
1) URL 실재 여부 확인
cites의 URL은 실제 도달 가능한지 HEAD 요청으로 검증. 404면 드롭하고 사람이 보게 표시.
async function validateUrl(url) {
try {
const r = await fetch(url, { method: "HEAD", redirect: "follow" })
return r.ok
} catch {
return false
}
}2) related/prereq의 slug 존재 확인
추출된 slug가 실제 블로그에 있는지 체크. 없으면 LLM이 가상의 글을 지어낸 것 — 즉시 드롭.
const knownSlugs = new Set(allPosts.map(p => p.slug))
extractions.related = extractions.related.filter(r => knownSlugs.has(r.slug))
extractions.prerequisites = extractions.prerequisites.filter(p =>
knownSlugs.has(p.slug)
)3) evidence/reason 길이 sanity check
evidence가 너무 짧거나("~" 한 글자) 본문에 그 문자열이 안 나오면 의심. 본문 부분문자열 매칭으로 거릅니다.
이 세 단계만 둬도 사람 검수 부담이 10분의 1로 줄어듭니다.
다음 편 예고 — GraphRAG로 자연어 질의 답하기
이제 그래프가 frontmatter + 본문 정보로 가득 찼습니다. 사용자는 Cypher를 모르니, 자연어 질의를 받아 Cypher로 바꾸고 답을 합성해야 합니다.
다음 편에서는:
- 자연어 → Cypher LLM 변환 (LangChain
GraphCypherQAChainvs 직접 구현) - 벡터 + 그래프 하이브리드 검색
- Cypher 결과를 자연스러운 답변으로 합성
- 2편의 질의 7개 전체를 자연어로 다시 풀어보기
여기까지 와야 비로소 "온톨로지 기반 검색"이 완성됩니다.
자주 묻는 질문 (FAQ)
Q1. OpenAI Structured Output을 써도 되나요?
됩니다. 동일한 JSON Schema를 response_format에 넣으면 동작 원리는 같습니다. 다만 긴 본문 처리에서는 Claude 쪽이 일관성이 좋고, 프롬프트 캐싱 단가도 유리합니다. 환경에 맞춰 고르세요.
Q2. 추출 결과를 그대로 믿어도 되나요?
부분적으로만요. 위의 3단계 자동 검수를 통과한 항목은 거의 안전하지만, RELATED_TO처럼 주관적 판단이 들어가는 관계는 LLM별 편차가 큽니다. 처음 10개 글은 사람이 직접 확인한 뒤 프롬프트를 조정하는 단계를 거치시길 권합니다.
Q3. 추출 결과를 그래프가 아니라 frontmatter에 역으로 적어두면 안 되나요?
좋은 운영 패턴입니다. 추출 결과를 글의 frontmatter에 related: [...]처럼 적어두면, 다음 적재부터는 LLM을 다시 호출하지 않아도 됩니다. "LLM 추출 → 사람 검수 → frontmatter 확정 → 그래프 적재" 흐름이 가장 안정적이에요.
여러분이 LLM으로 본문 정보를 뽑는다면 어떤 항목부터 시작하시겠어요? 댓글로 도메인을 알려주시면, 다음 편 GraphRAG 예제에 참고하겠습니다.
다음 편에서 GraphRAG로 자연어 질의를 풀어보겠습니다.