← Lecture Note
M8실습90분 · ~22 슬라이드

RAG 구축 — 임베딩·검색·환각 차단

두 방식 다 — in-memory 코사인 & 벡터 DB(pgvector)

학습 목표

공통 파이프라인 위에 ① in-memory 코사인과 ② Supabase pgvector 를 모두 구현하고, '규모가 방식을 결정한다'를 체득한다.

RAG는 선택이 아니라 필수입니다. LLM은 학습 시점 이후를 모르고(Knowledge Cutoff), 모르는 걸 그럴듯하게 지어내니까(Hallucination) — 내 데이터를 근거로 박아 넣는 RAG 없이는 production 서비스가 성립하지 않습니다. 그리고 RAG에서 진짜 갈리는 결정은 딱 하나, "벡터를 어디에 저장하고 어떻게 검색하느냐" 입니다. 이 모듈은 두 가지 방식을 모두 구현하고, 언제 무엇을 쓸지까지 정리합니다.

RAG 파이프라인 — 백엔드와 무관한 공통 골격

저장 방식이 무엇이든 흐름은 같습니다.

문서 → 청킹(600자+overlap) → 임베딩(text-embedding-3-small, 1536d)
     → 저장(① 메모리/Blob  또는  ② 벡터 DB)
질문 → 임베딩 → 코사인 유사도 검색(Top-K) → 근거를 프롬프트에 주입 → LLM 답변 + 출처 인용

바뀌는 건 가운데 저장·검색 한 칸뿐입니다. 나머지(청킹·임베딩·근거 주입·환각 차단)는 완전히 동일하니, 한 번 만들면 두 방식에 그대로 재사용합니다.

// 두 방식이 공유하는 임베딩 헬퍼 (lib/embed.ts)
export async function embed(text: string): Promise<number[]> {
  const r = await fetch("https://api.openai.com/v1/embeddings", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
    body: JSON.stringify({ model: "text-embedding-3-small", input: text.slice(0, 8000) }),
  });
  return (await r.json()).data[0].embedding; // number[1536]
}

임계값 함정: text-embedding-3-small은 한국어에서 코사인 유사도가 낮게 나옵니다. 관련 있는 매치도 0.2~0.4 수준이에요. threshold를 0.3 이상으로 잡으면 정답까지 다 걸러집니다. 0.15~0.2 로 시작하세요. (이 강의 사이트도 이 값을 씁니다.)


방식 ① 벡터 DB 없이 — in-memory 코사인

임베딩을 JSON 파일(또는 Vercel Blob)에 저장하고, 요청 시 메모리에 올려 코사인을 직접 계산합니다. 인프라가 0이고, 핵심 코드는 15줄입니다.

// 코사인 유사도 — 라이브러리 없이 직접
export function cosine(a: number[], b: number[]): number {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
  return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
}

// 검색 — 빌드 때 만들어 둔 embeddings.json 을 메모리에서 정렬
import EMB from "@/content/embeddings.json"; // [{ text, embedding }]
export async function searchTopK(query: string, k = 5, threshold = 0.2) {
  const q = await embed(query);
  return EMB.map(r => ({ ...r, score: cosine(q, r.embedding) }))
    .filter(h => h.score >= threshold)
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

언제 쓰나 — 데이터가 고정·소규모(대략 1만 청크 미만) 일 때. 예: 이 사이트의 강사 Agent는 강의 노트 13개(146청크)를 이 방식으로 검색합니다. 별도 서비스·비용·운영 부담이 전혀 없고, Cold start 포함 수백 ms 안에 끝납니다.


방식 ② 벡터 DB — Supabase pgvector

데이터가 크거나, 계속 쌓이거나, 영속·공유가 필요하면 진짜 벡터 DB를 씁니다. Postgres 확장인 pgvector를 Supabase에서 켜면, SQL 한 번으로 벡터 검색 인프라가 생깁니다.

-- 1) 확장 + 테이블 + 인덱스
create extension if not exists vector;

create table documents (
  id bigint generated always as identity primary key,
  content text,
  embedding vector(1536),
  created_at timestamptz default now()
);
create index on documents using hnsw (embedding vector_cosine_ops); -- HNSW: 학습 불필요, 정확/빠름

-- 2) 코사인 검색 RPC (<=> 는 코사인 거리, 1 - 거리 = 유사도)
create or replace function match_documents(query_embedding vector(1536), match_count int, match_threshold float)
returns table (id bigint, content text, similarity float)
language sql stable as $$
  select id, content, 1 - (embedding <=> query_embedding) as similarity
  from documents
  where 1 - (embedding <=> query_embedding) >= match_threshold
  order by embedding <=> query_embedding
  limit match_count;
$$;
// 3) 앱에서 저장 / 검색 (supabase-js)
import { createClient } from "@supabase/supabase-js";
const sb = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);

// 저장: 임베딩은 '[0.1,0.2,...]' 텍스트 형태로 insert
await sb.from("documents").insert({ content, embedding: `[${vec.join(",")}]` });

// 검색: match_documents RPC 호출
const { data } = await sb.rpc("match_documents", {
  query_embedding: await embed(question), match_count: 5, match_threshold: 0.15,
});

언제 쓰나 — 데이터가 수만~수백만 청크, 또는 사용자가 계속 업로드해 양이 가변적일 때. 영속 저장·동시 접근·메타데이터 필터(카테고리·태그·날짜)가 필요할 때. 이 사이트의 RAG Lab(/lab) 이 정확히 이 방식입니다 — 학습자가 올린 문서를 pgvector에 넣고 검색해요.


케이스 스터디 — GHOSTSHIN (신해철 고스트스테이션)

GHOSTSHIN은 방식 ②가 왜 필요한지를 보여주는 실제 사례입니다.

  • 규모: 신해철의 고스트스테이션 라디오 403개 방송 대본 ≈ 700만 자. 청킹하면 수만~수십만 벡터. → 메모리에 다 올리는 건 비현실적, 벡터 DB가 필수.
  • 스택: Supabase pgvector + match_rag_documents RPC. OpenAI 임베딩으로 인덱싱.
  • 메타데이터 필터: 단순 유사도만이 아니라 카테고리 코드 · 방송 회차 · 태그(방송대본/Q&A) · priority(신해철 본인이 강조한 핵심 발언) 로 필터링. RPC가 벡터 검색 결과와 우선 반영 자료를 합쳐 반환합니다.
  • 페르소나 RAG: 검색된 실제 발언을 근거로 "마왕(신해철)" 톤 답변을 생성 — 환각 없이 그 사람이 실제로 한 말에 뿌리내린 대화. (방송용 존댓말·청취자 호칭까지 RAG로 일관성 유지)

교훈: 700만 자 코퍼스에 in-memory 코사인을 쓰면 메모리·레이턴시가 폭발합니다. 반대로 강의 노트 146청크에 pgvector를 붙이면 과한 인프라죠. 규모가 방식을 결정합니다.


결정 가이드 — 둘 중 무엇을?

기준방식 ① in-memory방식 ② 벡터 DB(pgvector)
청크 규모~1만 미만1만 ~ 수백만+
데이터 갱신고정 / 드묾사용자 업로드 등 가변
인프라 · 비용0 (파일 / Blob)Supabase (무료 티어~)
영속 · 동시접근약함강함
메타데이터 필터직접 구현SQL where 로 자유
대표 사례이 사이트 강사 AgentRAG Lab · GHOSTSHIN

마이그레이션 팁: 청크 스키마({ content, embedding, metadata })를 처음부터 동일하게 유지하면, ①로 시작했다가 데이터가 커질 때 ②로 옮기는 게 거의 복붙입니다.


환각 차단 — 두 방식 공통

저장 방식과 무관하게, production RAG의 신뢰도는 이 4가지에서 나옵니다.

  1. score 임계값: 위 threshold(0.15~0.2)로 약한 매치를 버린다.
  2. 출처 인용 의무화: 시스템 프롬프트에 "근거를 [1] [2] 로 인용하라" 강제.
  3. "찾을 수 없으면 솔직히": 검색 결과가 비면 지어내지 말고 "문서에서 답을 찾지 못했어요" 라고 답하게 한다.
  4. 근거 밖 답변 금지: "[검색 결과] 안의 내용만 사용하라"를 시스템 프롬프트 최상단에.
[규칙] 아래 [검색 결과]에 근거해서만 답한다. 문장 끝에 [n] 으로 출처를 인용한다.
검색 결과가 비었으면 "문서에서 답을 찾지 못했어요"라고만 답한다. 절대 지어내지 않는다.

평가 — Faithfulness 자체 측정

Ground Truth 질문 5건을 만들어, 답변이 (a) 검색된 근거에 충실한가(Faithfulness), (b) 질문과 관련 있는가(Relevance), (c) 정답 청크가 Top-K에 들어왔는가(Recall@K)를 사람이 채점합니다. 임계값·청크 사이즈·overlap을 바꿔가며 이 점수로 튜닝하세요.

실습 (Lab)

이 사이트가 두 방식을 동시에 시연합니다 — 직접 비교해 보세요.

  1. 방식 ① (in-memory) — 우측 하단 강사 Agent. 강의 노트 146청크를 메모리 코사인으로 검색해 답합니다.
  2. 방식 ② (pgvector)/lab RAG Lab. 본인 노트/문서를 붙여넣어 Supabase pgvector에 임베딩 저장 → 질문 → 출처 인용 답변.
  3. 같은 질문을 두 곳에 던져보고 score·출처·속도를 비교하면 "규모가 방식을 결정한다"가 몸으로 이해됩니다.

핵심 정리

  • RAG는 필수. 진짜 결정은 저장·검색 백엔드(① in-memory ② 벡터 DB) 하나뿐이고, 나머지 파이프라인은 공유한다.
  • ① in-memory 코사인: ~1만 청크 미만·고정 데이터에 최적. 인프라 0, 15줄.
  • ② Supabase pgvector: 대규모·가변·영속에 필수. vector 확장 + HNSW 인덱스 + match_documents RPC.
  • GHOSTSHIN(700만 자, 403방송) 은 ②가 필요한 규모의 실제 사례 — 메타데이터 필터·priority까지 활용.
  • 규모가 방식을 결정한다. 스키마를 통일해 두면 ①→② 마이그레이션은 거의 복붙.
  • 한국어 임베딩은 유사도가 낮으니 threshold 0.15~0.2. 환각 차단 4종은 두 방식 공통.

🤖 Claude Code 프롬프트 (이렇게 시키면 됩니다)

이 모듈은 손코딩이 아니라 Claude Code에 프롬프트로 지시해서 만듭니다.

"lib/embed.ts에 text-embedding-3-small 임베딩 + 코사인 유사도(직접 구현)를 만들어줘." "노트를 600자 청킹·임베딩해 content/embeddings.json으로 저장하는 스크립트와, 메모리 코사인 검색 searchTopK()를 만들어줘. 한국어라 임계값 0.15~0.2." "대규모용으로 Supabase pgvector 버전도 — vector 확장·HNSW 인덱스·match 함수까지."

· "검색 결과 없으면 ‘못 찾음’이라 답하고 출처 [n]을 인용하게 시스템 프롬프트에 강제해줘" → 환각 차단.

LAB · 실습

강사 Agent(in-memory) ↔ RAG Lab(pgvector) 비교 + 본인 문서 업로드 RAG