학습 자료

RAG 조립 — 검색한 청크로 Claude 가 답하게 만든다


Article

18강까지 우리는 질문에 관련된 청크를 빠르게 찾는 법을 만들었다. 그런데 검색 결과를 다시 보자.

0.3828  [연차-휴가-정책.md #2] 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사...

이게 사용자에게 그대로 나가면 어떨까. 사용자는 "네, 5일까지 이월할 수 있어요" 같은 답을 원했지, 점수와 문서 조각을 원한 게 아니다. 오늘은 이 간극을 없앤다. 검색된 청크를 Claude 에게 근거로 쥐어주고, 자연스러운 문장으로 답하게 만든다. RAG(Retrieval-Augmented Generation)라는 이름의 R(검색)에 드디어 G(생성)를 붙인다.

오늘 조립할 파이프라인
  1. 사용자 질문"연차를 못 쓰면 다음 해로 이월할 수 있나요?"
  2. 벡터 검색 (18강)vectors.json 에서 top-3 청크를 찾는다
  3. 프롬프트 조립찾은 청크를 "참고 문서"로 프롬프트에 끼워넣는다
  4. Claude 호출참고 문서만 근거로 답하도록 지시한다
  5. 자연어 답변"네, 5일까지 이월할 수 있습니다..."

lesson-18lib.ts, store.ts, docs 를 그대로 가져와 lesson-19 를 만든다.

근거 없이는 답하지 말라고 지시한다

RAG 에서 제일 중요한 규칙은 "모델이 아는 걸 말하게 하지 말고, 넘겨준 문서에 있는 것만 말하게 하라"는 것이다. 이걸 지키지 않으면 Claude 가 그럴듯하게 지어낸 답(환각)을 내놓을 수 있다. 시스템 프롬프트로 이 규칙을 못박는다.

const SYSTEM_PROMPT = `당신은 사내 규정을 안내하는 어시스턴트다.
아래 "참고 문서" 에 있는 내용만 근거로 답한다. 참고 문서에 답이 없으면 "문서에서 찾을 수 없습니다" 라고만 답하고, 절대로 추측하거나 지어내지 않는다.
답할 때는 어느 문서의 어느 부분을 근거로 했는지 짧게 밝힌다.`;

8강에서 배웠듯 시스템 프롬프트는 "어떻게 행동할지"를 정하는 자리다. 오늘은 그 안에 그라운딩(grounding) 규칙, 즉 넘겨준 근거 밖으로 나가지 말라는 규칙을 넣는다.

검색 결과를 프롬프트로 조립한다

answer 함수는 검색, 프롬프트 조립, 호출의 세 단계를 잇는다.

import Anthropic from "@anthropic-ai/sdk";
import { readFile } from "node:fs/promises";
import { embed, cosineSimilarity, type StoredChunk } from "./lib.ts";

const anthropic = new Anthropic();

async function loadStore(): Promise<StoredChunk[]> {
  const raw = await readFile("./vectors.json", "utf-8");
  return JSON.parse(raw);
}

function searchStore(store: StoredChunk[], query: string, topK = 3) {
  const queryVector = embed(query);
  return store
    .map((c) => ({ chunk: c, score: cosineSimilarity(queryVector, c.vector) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK);
}

async function answer(question: string) {
  const store = await loadStore();
  const hits = searchStore(store, question, 3);

  const context = hits
    .map((h, i) => `[문서 ${i + 1}: ${h.chunk.source} #${h.chunk.index}]\n${h.chunk.text}`)
    .join("\n\n");

  const response = await anthropic.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 500,
    system: SYSTEM_PROMPT,
    messages: [
      { role: "user", content: `참고 문서:\n${context}\n\n질문: ${question}` },
    ],
  });

  const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
  console.log(text);
  console.log(`[usage] input: ${response.usage.input_tokens}, output: ${response.usage.output_tokens}`);
}

context 를 만드는 줄을 눈여겨보자. searchStore 가 돌려준 top-3 청크를 [문서 1: 파일명 #번호] 형태로 번호 매겨 이어붙인다. 이 문자열이 그대로 사용자 메시지 앞에 붙어서 "이 문서들을 보고 이 질문에 답해"라는 요청이 되는 것이다.

실행 — 성공 사례

먼저 18강에서 잘 찾았던 질문으로 실행한다. vectors.json 이 없으면 먼저 store.ts 를 한 번 돌려야 한다(18강과 동일).

npx tsx store.ts
npx tsx answer.ts "연차를 못 쓰면 다음 해로 이월할 수 있나요?"
터미널
  • 질문: "연차를 못 쓰면 다음 해로 이월할 수 있나요?"
  • [검색된 청크]
  • 0.3828 [연차-휴가-정책.md #2]
  • 0.3473 [연차-휴가-정책.md #1]
  • 0.2541 [연차-휴가-정책.md #0]
  • [Claude 답변]
  • 네, 제한적으로 가능합니다.
  • [문서 1: 연차-휴가-정책.md #2] 에 따르면:
  • 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사용하지 못한 일수에 한해 다음 해로 최대 5일까지 이월할 수 있습니다.
  • 이월을 원하면 매년 12월 첫째 주까지 인사팀에 이월 신청을 제출해야 합니다.
  • 다만, 본인 사정으로 사용하지 않은 연차는 회사 귀책 사유가 없는 한 12월 31일자로 소멸합니다.
  • [usage] input: 759, output: 227

진짜로 자연스러운 문장이 나왔다. 청크 원문에는 "12월 첫째 주까지 이월 신청" 같은 표현이 있었는데, Claude 가 그걸 읽고 사람이 묻는 방식에 맞춰 재구성했다. 그리고 "[문서 1: 연차-휴가-정책.md #2]" 처럼 근거를 밝혔는데, 시스템 프롬프트에서 지시한 그대로다.

실행 — 검색이 실패하면 생성도 같이 실패한다

17강에서 정직하게 확인했던 실패 사례를 그대로 다시 던진다. "이월" 대신 "미루다"를 쓴 질문이다.

npx tsx answer.ts "연차를 미룰 수 있나요?"
터미널
  • 질문: "연차를 미룰 수 있나요?"
  • [검색된 청크]
  • 0.2477 [경비-처리-가이드.md #0]
  • 0.1895 [연차-휴가-정책.md #0]
  • 0.1478 [원격근무-정책.md #1]
  • [Claude 답변]
  • 참고 문서에서 연차를 미루는 것에 대한 내용을 찾을 수 없습니다.
  • [문서 2: 연차-휴가-정책.md #0]에는 연차 발생 기준, 사용 단위에 대해서만 명시되어 있으며, 연차 미루기(이월)에 관한 규정은 제공된 문서에 없습니다.
  • 정확한 답변을 위해 인사팀에 문의하시기를 권장합니다.
  • [usage] input: 686, output: 161

정답은 분명히 문서 안에 있는데(연차-휴가-정책.md #2, 5일 이월 규정), Claude 는 "찾을 수 없다"고 답했다. Claude 가 잘못한 게 아니다. top-3 검색 결과에 애초에 그 청크가 없었다. 17강에서 우리가 만든 임베딩은 "미루다"와 "이월하다"의 글자가 하나도 안 겹쳐서 낮은 순위를 줬고, 그 결과가 그대로 생성 단계까지 흘러들어왔다.

그라운딩이 지킨 것과 못 지킨 것

진짜 범위 밖 질문과 비교해본다

방금 실패는 "문서 어딘가엔 답이 있는데 검색이 못 찾은" 경우였다. 이번엔 애초에 어떤 문서에도 답이 없는 질문을 던져 비교해본다.

npx tsx answer.ts "회사 주차장은 몇 층에 있나요?"
터미널
  • 질문: "회사 주차장은 몇 층에 있나요?"
  • [검색된 청크]
  • 0.3189 [연차-휴가-정책.md #1]
  • 0.2730 [연차-휴가-정책.md #2]
  • 0.2640 [경비-처리-가이드.md #2]
  • [Claude 답변]
  • 문서에서 찾을 수 없습니다.
  • 제공된 참고 문서에는 연차·휴가 정책과 경비 처리 가이드만 포함되어 있으며, 회사 주차장 위치에 관한 내용은 없습니다.
  • [usage] input: 797, output: 88

겉으로 보이는 답("찾을 수 없습니다")은 "미루다" 사례와 똑같다. 하지만 원인은 다르다. 이번엔 애초에 세 문서 어디에도 주차장 얘기가 없다. 검색이 못 찾은 게 아니다. 애초에 찾을 게 없었다. 사용자에게 보이는 결과만으로는 두 실패를 구분할 수 없다는 것도 실무에서 중요한 포인트다.

겉으로 같은 "모른다", 원인은 다른 두 경우
연차를 미룰 수 있나요?있음 (연차-휴가-정책.md #2)아니오 — 6위로 밀림 (17강 실측)"찾을 수 없습니다" (틀린 실패)
회사 주차장은 몇 층?없음해당 없음"찾을 수 없습니다" (맞는 답)

지금까지 만든 것

my-ai-assistant/ (ai-lab-b, lesson-19)
  • my-ai-assistant
    • docs/16강과 동일한 사내 규정 문서 3개
    • lib.ts청킹 + 임베딩 + 코사인 유사도
    • store.ts벡터 저장소 생성 (18강)
    • answer.ts검색 + 그라운딩 프롬프트 + Claude 생성 (오늘)
    • vectors.json저장된 벡터
    • .env.example
    • package.json

정리하면

검색된 청크를 시스템 프롬프트로 그라운딩해 Claude 에게 넘기면, 근거 있는 자연어 답이 나온다. 하지만 그 답의 품질은 검색이 얼마나 정확한 청크를 찾아줬는지에 달려 있다. 오늘 두 가지 실패를 실측으로 나눠서 확인했다.

다시 짚어보기
  1. 01

    그라운딩 규칙을 시스템 프롬프트에 넣었다

    참고 문서 밖은 답하지 말고, 없으면 "찾을 수 없다"고 답하게 했다.

  2. 02

    검색 결과를 프롬프트로 조립해 실제로 호출했다

    top-3 청크를 "참고 문서"로 끼워넣고 claude-haiku-4-5 를 불렀다.

    npx tsx answer.ts "연차를 못 쓰면 다음 해로 이월할 수 있나요?"
  3. 03

    성공 사례에서 자연어 답과 실제 비용을 확인했다

    759+227토큰, 약 $0.0019. 문서 조각이 아닌 완결된 문장을 받았다.

  4. 04

    검색 실패가 생성 실패로 그대로 이어지는 걸 실측으로 확인했다

    "미루다" 질문은 정답이 문서에 있는데도 검색이 못 찾아 Claude 가 "모른다"고 답했다. 진짜 범위 밖 질문과 겉보기엔 똑같지만 원인이 다르다.

지금까지 우리는 문서를 자르고(16강), 벡터로 바꾸고(17강), 미리 계산해 빠르게 찾고(18강), 검색 결과로 답을 만드는 것(19강)까지 RAG 의 핵심 조각을 전부 손으로 만들었다. 다음 20강에서는 이 조각들을 하나의 대화형 CLI 로 묶어, Part 5(RAG)를 마무리한다.

RAG 조립 — 검색한 청크로 Claude 가 답하게 만든다 — 디코드랩(DCODELAB)