학습 자료

RAG 챗봇 완성 — 내 문서에 답하는 대화형 CLI (Part 5 마무리)


Article

16강부터 지금까지 우리는 문서를 자르고, 벡터로 바꾸고, 저장해 빠르게 찾고, 검색 결과로 자연어 답을 만들었다. 그런데 19강까지 만든 answer.ts는 한 가지 질문에 한 번 답하고 끝나는 프로그램이었다. 실행할 때마다 npx tsx answer.ts "질문" 을 새로 쳐야 했고, 방금 뭘 물어봤는지 다음 호출은 전혀 기억하지 못했다.

오늘은 그걸 대화가 이어지는 CLI로 묶는다. 터미널에 질문을 치면 답이 오고, 이어서 또 물으면 앞의 대화를 기억한 채 답한다. 1강에서 약속했던 "내 문서에 답하는 AI 어시스턴트"가 오늘 완성된다.

오늘 만들 것
  1. answer.ts (19강)한 질문 → 한 답. 프로그램이 끝나면 대화가 사라진다
  2. chat.ts (오늘)readline 으로 계속 질문을 받는다
  3. 히스토리 관리지난 질문·답을 기억해 다음 질문에 반영한다
  4. 실측으로 버그 발견·수정후속 질문에서 검색이 실패하는 걸 잡아 고친다

lesson-19lib.ts, store.ts, docs, vectors.json 을 그대로 가져와 lesson-20 을 만든다.

대화형 루프를 만든다

Node 에는 터미널에서 한 줄씩 입력을 받는 readline 모듈이 있다. line 이벤트가 올 때마다 검색 + 생성을 한 번 실행하는 구조다.

const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "You> " });
rl.prompt();

rl.on("line", (line) => {
  queue.push(line.trim());
  void drainQueue();
});

큐를 다 처리하고 입력이 끝나면(exit 입력 또는 Ctrl-D) 종료한다. 전체 진입점은 이렇다.

async function main() {
  const store = await loadStore();
  const history: Message[] = [];

  console.log("사내 규정 챗봇입니다. 질문을 입력하세요. (종료: exit)\n");

  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "You> " });
  rl.prompt();

  const queue: string[] = [];
  let processing = false;
  let inputEnded = false;

  function exitIfDone() {
    if (inputEnded && !processing && queue.length === 0) {
      console.log("대화를 종료합니다.");
      process.exit(0);
    }
  }

  async function drainQueue() {
    if (processing) return;
    processing = true;
    while (queue.length > 0) {
      const question = queue.shift()!;
      if (!question || question === "exit") {
        console.log("대화를 종료합니다.");
        process.exit(0);
      }
      console.log();
      await askOnce(store, history, question);
      console.log();
      rl.prompt();
    }
    processing = false;
    exitIfDone();
  }

  rl.on("line", (line) => {
    queue.push(line.trim());
    void drainQueue();
  });

  rl.on("close", () => {
    inputEnded = true;
    exitIfDone();
  });
}

히스토리는 "깨끗하게" 쌓는다

7강에서 대화 히스토리를 배열에 쌓았던 걸 기억할 것이다. 오늘도 같은 원리인데, RAG 라서 조심할 게 하나 늘었다. 검색해서 찾은 문서 원문까지 히스토리에 같이 쌓으면 안 된다.

async function askOnce(store: StoredChunk[], history: Message[], question: string) {
  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 messages = [
    ...history.map((m) => ({ role: m.role, content: m.content })),
    { role: "user" as const, content: `참고 문서:\n${context}\n\n질문: ${question}` },
  ];

  const response = await anthropic.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 500,
    system: SYSTEM_PROMPT,
    messages,
  });

  const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");

  history.push({ role: "user", content: question });
  history.push({ role: "assistant", content: text });
}

마지막 두 줄을 보자. history 에 쌓는 건 question(질문 원문)과 text(답 원문)뿐이다. context(검색된 문서 조각)는 이번 턴의 메시지에만 끼워 넣고 히스토리에는 남기지 않는다.

히스토리에 뭘 남길까

실행 — 세 턴 대화, 그런데 두 번째 턴이 이상하다

store.tsvectors.json 을 만든 뒤 실제로 대화를 걸어본다. 세 개를 연달아 물었다.

npx tsx store.ts
npx tsx chat.ts
You> 연차를 못 쓰면 다음 해로 이월할 수 있나요?
You> 그럼 언제까지 신청해야 돼?
You> 원격근무는 주에 며칠까지 가능해?
터미널 — 1턴
  • You> 연차를 못 쓰면 다음 해로 이월할 수 있나요?
  • [검색된 청크]
  • 0.3828 [연차-휴가-정책.md #2]
  • 0.3473 [연차-휴가-정책.md #1]
  • 0.2541 [연차-휴가-정책.md #0]
  • [Claude 답변]
  • 네, 제한적으로 가능합니다.
  • [문서 1: 연차-휴가-정책.md #2] 에 따르면: 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사용하지 못한 일수에 한해 다음 해로 최대 5일까지 이월할 수 있습니다. 이월 신청은 매년 12월 첫째 주까지 인사팀에 제출해야 합니다.
  • [usage] input: 759, output: 240

여기까지는 19강과 똑같이 잘 나왔다. 문제는 다음 턴이다. "그럼 언제까지 신청해야 돼?"라는 질문을 사람이라면 당연히 방금 물은 이월 신청 마감일을 묻는 거라고 알아챈다.

터미널 — 2턴 (문제 발생)
  • You> 그럼 언제까지 신청해야 돼?
  • [검색된 청크]
  • 0.3255 [원격근무-정책.md #2]
  • 0.2956 [원격근무-정책.md #1]
  • 0.2527 [연차-휴가-정책.md #0]
  • [Claude 답변]
  • 죄송하지만, 현재 제공된 참고 문서에는 연차 이월 신청 마감 시기에 대한 정보가 없습니다.
  • 앞서 제가 답변한 "12월 첫째 주까지" 라는 내용은 제공되지 않은 문서를 근거로 한 것으로, 정확하지 않습니다.
  • 연차 이월 신청 마감일에 대해서는 인사팀에 직접 문의해 주시기 바랍니다.
  • [usage] input: 985, output: 166

검색이 완전히 엉뚱한 문서(원격근무-정책)를 가져왔다. 그런데 흥미로운 지점은 Claude 의 반응이다. 지어내지 않고, 심지어 "방금 전 내 답변이 틀렸을 수 있다"고 스스로 정정까지 했다. 19강에서 넣은 그라운딩 규칙이 정확히 의도대로 작동한 것이다. 잘못은 생성이 아니라 검색에 있다.

고친다 — 직전 질문을 검색어에 더한다

대화의 맥락은 이미 history 에 있다. 검색할 때 이번 질문 하나만 임베딩하지 말고, 직전에 내가 한 질문을 붙여서 임베딩하면 어떨까.

async function askOnce(store: StoredChunk[], history: Message[], question: string) {
  // 직전 내 질문을 검색어에 덧붙인다. "그럼 언제까지?" 처럼 대명사로 줄인 후속 질문은
  // 그 자체로는 임베딩할 단어가 부족해 검색이 엉뚱한 문서를 찾아온다.
  const lastQuestion = [...history].reverse().find((m) => m.role === "user")?.content;
  const searchQuery = lastQuestion ? `${lastQuestion} ${question}` : question;

  const hits = searchStore(store, searchQuery, 3);
  // ... 이하 동일
}

바뀐 건 딱 세 줄이다. 검색할 때만 "직전 질문 + 이번 질문"을 합친 문자열을 쓰고, Claude 에게 실제로 물어보는 question 자체는 그대로 둔다. 같은 세 질문을 다시 던져본다.

터미널 — 2턴 (수정 후)
  • You> 그럼 언제까지 신청해야 돼?
  • [검색된 청크]
  • 0.4397 [연차-휴가-정책.md #2]
  • 0.4136 [연차-휴가-정책.md #1]
  • 0.3743 [원격근무-정책.md #1]
  • [Claude 답변]
  • [문서 1: 연차-휴가-정책.md #2] 에 따르면: 이월 신청은 매년 12월 첫째 주까지 인사팀에 제출해야 합니다.
  • [usage] input: 1076, output: 72

정답 문서(연차-휴가-정책.md #2)가 1위로 올라왔고, 답도 짧고 정확해졌다. 이어서 세 번째 질문("원격근무는 주에 며칠까지 가능해?")도 확인한다. 이번엔 완전히 새 주제라 직전 질문을 더해도 헷갈리지 않아야 한다.

터미널 — 3턴 (수정 후)
  • You> 원격근무는 주에 며칠까지 가능해?
  • [검색된 청크]
  • 0.4530 [원격근무-정책.md #1]
  • 0.4477 [원격근무-정책.md #0]
  • 0.3773 [연차-휴가-정책.md #2]
  • [Claude 답변]
  • [문서 2: 원격근무-정책.md #0] 에 따르면: 전 직원은 주 3일까지 원격 근무를 신청할 수 있습니다.
  • [usage] input: 1174, output: 64

주제를 완전히 바꿨는데도 검색이 정확히 원격근무 문서를 찾아왔다. "원격근무" 라는 단어 자체가 이번 질문에 들어 있어서, 직전 질문(연차 관련)을 더해도 점수가 밀리지 않은 것이다.

대화가 길어지면 토큰은 어떻게 느나

세 턴의 usage 를 모아보면 흥미로운 패턴이 보인다.

턴이 늘수록 입력 토큰이 느는 이유
1연차 이월 가능한가요?759243기준값 — 검색된 문서 3개 + 질문
2그럼 언제까지 신청해야 돼?1076721턴의 질문·답변(히스토리) + 이번 턴 문서 3개
3원격근무는 주에 며칠?1174641·2턴 히스토리 + 이번 턴 문서 3개

히스토리에 문서 원문은 안 쌓지만, 질문·답변 자체는 계속 쌓인다. 게다가 매 턴 새로 검색한 문서 3개가 그때그때 추가된다. 그래서 대화가 길어질수록 input 토큰은 계속 늘어나는데, 다만 문서 원문을 반복해서 안 보내기 때문에 만약 문서까지 히스토리에 쌓았다면 이보다 훨씬 가파르게 늘었을 것이다. 7강에서 배운 "히스토리는 언젠가 잘라야 한다"는 원칙이 RAG 챗봇에도 그대로 적용된다. 지금은 3턴이라 괜찮지만, 대화가 수십 턴 이어지는 프로덕션 앱이라면 오래된 히스토리를 자르는 로직이 필요하다. 그건 24강(프로덕션 배포)에서 다시 다룬다.

지금까지 만든 것

my-ai-assistant/ (ai-lab-b, lesson-20)
  • my-ai-assistant
    • docs/사내 규정 문서 3개 (16강과 동일)
    • lib.ts청킹 + 임베딩 + 코사인 유사도
    • store.ts벡터 저장소 생성 (18강)
    • chat.ts대화형 CLI — 검색 + 그라운딩 + 히스토리 (오늘)
    • vectors.json저장된 벡터
    • .env.example
    • package.json

정리하면

검색·생성을 대화 루프로 묶고, 질문·답변만 히스토리에 남겨 비용을 관리했다. 그리고 후속 질문에서 검색이 실제로 실패하는 걸 실측으로 잡아내, 직전 질문을 검색어에 더하는 세 줄로 고쳤다.

다시 짚어보기
  1. 01

    readline 으로 대화형 루프를 만들었다

    질문을 큐에 쌓고 순서대로 처리해, 답을 기다리는 동안 다음 질문이 와도 꼬이지 않게 했다.

  2. 02

    히스토리는 질문·답변만 깨끗하게 쌓았다

    검색된 문서 원문은 히스토리에 남기지 않고, 매 턴 새로 검색해 그 턴의 메시지에만 끼워 넣었다.

  3. 03

    후속 질문에서 검색 실패를 실측으로 발견했다

    "그럼 언제까지 신청해야 돼?" 만으로는 검색이 엉뚱한 문서를 가져왔다. Claude 는 그라운딩 규칙대로 정직하게 반응했다.

    npx tsx chat.ts
  4. 04

    직전 질문을 검색어에 더해 고쳤다

    코드 세 줄로 정답 문서가 1위로 올라왔고, 같은 방법이 완전히 다른 주제의 질문은 헷갈리게 하지 않는다는 것도 확인했다.

    1076, 72 → 정답 청크가 1위

Part 5 가 끝났다. 16강에서 문서를 자르는 것부터 시작해, 임베딩(17강)·벡터 저장(18강)·생성 조립(19강)·대화형 완성(20강)까지 RAG 파이프라인을 정말로 손으로, 실측하며 끝까지 만들었다. 검색과 생성이 각각 어디서 실패할 수 있는지, 그 실패가 겉보기엔 똑같아 보여도 원인이 다를 수 있다는 것까지 실제 코드로 확인했다.

다음 21강부터는 Part 6, 에이전트다. 지금까지 우리 챗봇은 질문 하나에 검색 한 번, 답 한 번이었다. 21강에서는 Claude 가 스스로 "지금 뭘 해야 하는지"를 판단해 여러 도구를 순서대로 이어 쓰는 다단계 에이전트를 만든다. 12~15강에서 배운 도구 사용(tool use)과 오늘까지 만든 RAG 가 여기서 합쳐진다.

RAG 챗봇 완성 — 내 문서에 답하는 대화형 CLI (Part 5 마무리) — 디코드랩(DCODELAB)