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_documentsRPC. OpenAI 임베딩으로 인덱싱. - 메타데이터 필터: 단순 유사도만이 아니라 카테고리 코드 · 방송 회차 · 태그(방송대본/Q&A) · priority(신해철 본인이 강조한 핵심 발언) 로 필터링. RPC가 벡터 검색 결과와 우선 반영 자료를 합쳐 반환합니다.
- 페르소나 RAG: 검색된 실제 발언을 근거로 "마왕(신해철)" 톤 답변을 생성 — 환각 없이 그 사람이 실제로 한 말에 뿌리내린 대화. (방송용 존댓말·청취자 호칭까지 RAG로 일관성 유지)
교훈: 700만 자 코퍼스에 in-memory 코사인을 쓰면 메모리·레이턴시가 폭발합니다. 반대로 강의 노트 146청크에 pgvector를 붙이면 과한 인프라죠. 규모가 방식을 결정합니다.
결정 가이드 — 둘 중 무엇을?
| 기준 | 방식 ① in-memory | 방식 ② 벡터 DB(pgvector) |
|---|---|---|
| 청크 규모 | ~1만 미만 | 1만 ~ 수백만+ |
| 데이터 갱신 | 고정 / 드묾 | 사용자 업로드 등 가변 |
| 인프라 · 비용 | 0 (파일 / Blob) | Supabase (무료 티어~) |
| 영속 · 동시접근 | 약함 | 강함 |
| 메타데이터 필터 | 직접 구현 | SQL where 로 자유 |
| 대표 사례 | 이 사이트 강사 Agent | RAG Lab · GHOSTSHIN |
마이그레이션 팁: 청크 스키마(
{ content, embedding, metadata })를 처음부터 동일하게 유지하면, ①로 시작했다가 데이터가 커질 때 ②로 옮기는 게 거의 복붙입니다.
환각 차단 — 두 방식 공통
저장 방식과 무관하게, production RAG의 신뢰도는 이 4가지에서 나옵니다.
- score 임계값: 위 threshold(0.15~0.2)로 약한 매치를 버린다.
- 출처 인용 의무화: 시스템 프롬프트에 "근거를
[1] [2]로 인용하라" 강제. - "찾을 수 없으면 솔직히": 검색 결과가 비면 지어내지 말고 "문서에서 답을 찾지 못했어요" 라고 답하게 한다.
- 근거 밖 답변 금지: "[검색 결과] 안의 내용만 사용하라"를 시스템 프롬프트 최상단에.
[규칙] 아래 [검색 결과]에 근거해서만 답한다. 문장 끝에 [n] 으로 출처를 인용한다.
검색 결과가 비었으면 "문서에서 답을 찾지 못했어요"라고만 답한다. 절대 지어내지 않는다.
평가 — Faithfulness 자체 측정
Ground Truth 질문 5건을 만들어, 답변이 (a) 검색된 근거에 충실한가(Faithfulness), (b) 질문과 관련 있는가(Relevance), (c) 정답 청크가 Top-K에 들어왔는가(Recall@K)를 사람이 채점합니다. 임계값·청크 사이즈·overlap을 바꿔가며 이 점수로 튜닝하세요.
실습 (Lab)
이 사이트가 두 방식을 동시에 시연합니다 — 직접 비교해 보세요.
- 방식 ① (in-memory) — 우측 하단 강사 Agent. 강의 노트 146청크를 메모리 코사인으로 검색해 답합니다.
- 방식 ② (pgvector) —
/labRAG Lab. 본인 노트/문서를 붙여넣어 Supabase pgvector에 임베딩 저장 → 질문 → 출처 인용 답변. - 같은 질문을 두 곳에 던져보고 score·출처·속도를 비교하면 "규모가 방식을 결정한다"가 몸으로 이해됩니다.
핵심 정리
- RAG는 필수. 진짜 결정은 저장·검색 백엔드(① in-memory ② 벡터 DB) 하나뿐이고, 나머지 파이프라인은 공유한다.
- ① in-memory 코사인: ~1만 청크 미만·고정 데이터에 최적. 인프라 0, 15줄.
- ② Supabase pgvector: 대규모·가변·영속에 필수.
vector확장 + HNSW 인덱스 +match_documentsRPC. - 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]을 인용하게 시스템 프롬프트에 강제해줘" → 환각 차단.
강사 Agent(in-memory) ↔ RAG Lab(pgvector) 비교 + 본인 문서 업로드 RAG
