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

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

가장 비중 큰 모듈 · 별도 vector DB 없이

학습 목표

임베딩→청킹→코사인 검색→근거 강제까지, 실제 동작하는 RAG Q&A 를 완성한다.

RAG(Retrieval-Augmented Generation)는 "모델이 모르는 것을 알게 만드는" 핵심 기술입니다. 이 모듈을 마치면 여러분 자신의 노트·문서를 업로드하고, LLM이 그 내용만을 근거로 정확하게 답변하는 시스템을 직접 구동할 수 있습니다.


왜 RAG인가

LLM은 학습 시점 이후의 정보를 모릅니다. 회사 내부 문서, 개인 노트, 최신 정책 — 이런 지식을 모델에 주입하는 방법은 두 가지입니다: Fine-tuning(비용 높음, 정적) 또는 RAG(비용 낮음, 동적). 우리는 RAG를 선택합니다. 검색으로 관련 청크를 뽑아 컨텍스트에 끼워 넣고, 모델은 그것만 보고 대답합니다.


텍스트 청킹 — chunkPlainText()

원본 문서를 통째로 넣으면 컨텍스트 창이 터집니다. 600자 단위로 자르되, 앞 청크의 마지막 100자를 다음 청크 앞에 중첩(overlap)합니다. 이 overlap이 없으면 문장이 청크 경계에서 잘려 의미가 사라집니다.

export function chunkPlainText(text: string, size = 600, overlap = 100): string[] {
  const chunks: string[] = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + size, text.length);
    chunks.push(text.slice(start, end));
    if (end === text.length) break;
    start += size - overlap;
  }
  return chunks;
}

: 청크 크기는 임베딩 모델의 토큰 한도(text-embedding-3-small은 8191 토큰)와 무관하게, 검색 정밀도를 위해 작게 유지하는 것이 원칙입니다. 600자(≈약 150토큰)는 실무에서 검증된 시작점입니다.


임베딩 생성과 Vercel Blob 저장

각 청크를 text-embedding-3-small(1536차원)로 임베딩한 뒤, Vercel Blob에 JSON으로 저장합니다. 별도 vector DB는 사용하지 않습니다 — 문서 수백 건 규모에서는 in-memory 코사인 검색이 더 빠르고 운영 비용이 0입니다.

import OpenAI from "openai";
import { put } from "@vercel/blob";

const openai = new OpenAI();

export async function embedAndStore(docId: string, chunks: string[]) {
  const res = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: chunks,
  });

  const records = chunks.map((text, i) => ({
    text,
    vector: res.data[i].embedding, // number[1536]
  }));

  await put(`embeddings/${docId}.json`, JSON.stringify(records), {
    access: "private",
  });

  return records.length;
}

저장 형식은 { text: string, vector: number[] }[]의 단순 배열입니다. 로드할 때는 Blob URL에서 fetch → JSON.parse하면 됩니다.


코사인 유사도 직접 구현 — 15줄

코사인 유사도는 두 벡터가 "같은 방향을 가리키는 정도"입니다. 값은 -1~1이며, 의미가 유사할수록 1에 가깝습니다.

function cosine(a: number[], b: number[]): number {
  let dot = 0, normA = 0, normB = 0;
  for (let i = 0; i < a.length; i++) {
    dot   += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}

export function searchTopK(
  queryVec: number[],
  records: { text: string; vector: number[] }[],
  k = 3,
  threshold = 0.65
) {
  return records
    .map(r => ({ text: r.text, score: cosine(queryVec, r.vector) }))
    .filter(r => r.score >= threshold)
    .sort((a, b) => b.score - a.score)
    .slice(0, k);
}

임계값 0.65: 이 값 미만의 청크는 질문과 무관한 것으로 간주해 제거합니다. 너무 낮으면 관련 없는 청크가 컨텍스트에 끼어들고, 너무 높으면 답변 가능한 질문도 "정보 없음"으로 처리됩니다. 0.65는 실험적으로 검증된 기본값입니다.


환각 차단 — 시스템 프롬프트 설계

검색 결과가 있어도, LLM은 자신의 사전 지식을 섞어 답변하려 합니다. 이를 막으려면 시스템 프롬프트로 근거 강제를 명시해야 합니다.

시스템 프롬프트 (temperature 0.2 사용):

당신은 주어진 [CONTEXT] 안의 정보만을 사용해 질문에 답합니다.
답변의 모든 사실 주장에는 반드시 [1], [2] 형식으로 출처 번호를 인용하세요.
[CONTEXT]에 없는 내용은 "제공된 문서에서 해당 정보를 찾을 수 없습니다."라고만 답하세요.
절대로 추측하거나 외부 지식을 사용하지 마세요.

경고: temperature를 0.2로 낮추는 것만으로는 환각을 막을 수 없습니다. 반드시 시스템 프롬프트에 "컨텍스트 외 답변 금지" 조항을 명시적으로 포함하세요.


Faithfulness 측정

RAG 시스템의 품질은 Faithfulness(생성된 답변이 검색된 청크에 얼마나 충실한가)로 평가합니다. Ground Truth 5건을 준비하고 다음 기준으로 측정합니다:

항목측정 방법
출처 인용 여부답변에 [숫자] 패턴 포함 여부
근거 청크 일치인용된 청크 텍스트가 실제 검색 결과에 있는지
헛소리(hallucination)컨텍스트에 없는 사실 주장 건수
거절 정확도답할 수 없는 질문에 "정보 없음" 응답 비율

5건 기준으로 Faithfulness ≥ 80%를 목표로 합니다.


실습 (Lab)

목표: 본인 노트/문서를 업로드해 검색되는 RAG Q&A 시스템 구동

Step 1 — 문서 업로드 API 구현

  • POST /api/ingest 엔드포인트 작성
  • formData.txt 또는 .md 파일 수신
  • chunkPlainText()로 청크 분리 → embedAndStore()로 임베딩 후 Blob 저장

Step 2 — 검색 API 구현

  • POST /api/rag-query 엔드포인트 작성
  • 쿼리 텍스트를 text-embedding-3-small로 임베딩
  • Blob에서 해당 문서의 벡터 JSON 로드 → searchTopK() 실행
  • 임계값 미달 시 "관련 정보 없음" 즉시 반환

Step 3 — LLM 연결

  • callLLM("anthropic/claude-sonnet-4-5", messages, { temperature: 0.2 })
  • 시스템 프롬프트에 환각 차단 지시 + [CONTEXT] 블록 주입
  • 답변에서 [1][2] 인용 패턴 파싱해 프론트엔드에 출처 표시

Step 4 — Faithfulness 검증

  • 본인 문서에서 답 가능한 질문 3개, 답 불가능한 질문 2개 준비
  • 5건 모두 실행 후 인용 여부·거절 정확도 직접 기록

체크포인트: searchTopK()가 빈 배열을 반환할 때 LLM을 호출하지 않고 즉시 "정보 없음"을 반환하는 분기가 구현되어 있어야 합니다. 이 분기가 없으면 빈 컨텍스트로 LLM이 호출되어 환각이 발생합니다.


핵심 정리

  • chunkPlainText(600, 100) — 600자 청크, 100자 overlap으로 경계 손실 방지
  • text-embedding-3-small 1536차원 벡터를 Vercel Blob JSON에 저장, vector DB 불필요
  • 코사인 유사도 15줄 직접 구현 + score ≥ 0.65 임계값으로 노이즈 청크 차단
  • 시스템 프롬프트에 "컨텍스트 외 금지 + [1][2] 인용 의무" 명시해야 환각 차단 완성
  • temperature 0.2 — 결정적 답변 생성, 추측성 표현 억제
  • Ground Truth 5건 Faithfulness 측정으로 시스템 신뢰도 수치화
LAB · 실습

본인 노트/문서 업로드 → 검색되는 RAG Q&A 시스템