RAG 챗봇 완성 — 내 문서에 답하는 대화형 CLI (Part 5 마무리)
Article
16강부터 지금까지 우리는 문서를 자르고, 벡터로 바꾸고, 저장해 빠르게 찾고, 검색 결과로 자연어 답을 만들었다. 그런데 19강까지 만든 answer.ts는 한 가지 질문에 한 번 답하고 끝나는 프로그램이었다. 실행할 때마다 npx tsx answer.ts "질문" 을 새로 쳐야 했고, 방금 뭘 물어봤는지 다음 호출은 전혀 기억하지 못했다.
오늘은 그걸 대화가 이어지는 CLI로 묶는다. 터미널에 질문을 치면 답이 오고, 이어서 또 물으면 앞의 대화를 기억한 채 답한다. 1강에서 약속했던 "내 문서에 답하는 AI 어시스턴트"가 오늘 완성된다.
- answer.ts (19강)한 질문 → 한 답. 프로그램이 끝나면 대화가 사라진다
- chat.ts (오늘)readline 으로 계속 질문을 받는다
- 히스토리 관리지난 질문·답을 기억해 다음 질문에 반영한다
- 실측으로 버그 발견·수정후속 질문에서 검색이 실패하는 걸 잡아 고친다
lesson-19 의 lib.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.ts 로 vectors.json 을 만든 뒤 실제로 대화를 걸어본다. 세 개를 연달아 물었다.
npx tsx store.ts
npx tsx chat.ts
You> 연차를 못 쓰면 다음 해로 이월할 수 있나요?
You> 그럼 언제까지 신청해야 돼?
You> 원격근무는 주에 며칠까지 가능해?
- 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강과 똑같이 잘 나왔다. 문제는 다음 턴이다. "그럼 언제까지 신청해야 돼?"라는 질문을 사람이라면 당연히 방금 물은 이월 신청 마감일을 묻는 거라고 알아챈다.
- 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 자체는 그대로 둔다. 같은 세 질문을 다시 던져본다.
- 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위로 올라왔고, 답도 짧고 정확해졌다. 이어서 세 번째 질문("원격근무는 주에 며칠까지 가능해?")도 확인한다. 이번엔 완전히 새 주제라 직전 질문을 더해도 헷갈리지 않아야 한다.
- 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 | 연차 이월 가능한가요? | 759 | 243 | 기준값 — 검색된 문서 3개 + 질문 |
| 2 | 그럼 언제까지 신청해야 돼? | 1076 | 72 | 1턴의 질문·답변(히스토리) + 이번 턴 문서 3개 |
| 3 | 원격근무는 주에 며칠? | 1174 | 64 | 1·2턴 히스토리 + 이번 턴 문서 3개 |
히스토리에 문서 원문은 안 쌓지만, 질문·답변 자체는 계속 쌓인다. 게다가 매 턴 새로 검색한 문서 3개가 그때그때 추가된다. 그래서 대화가 길어질수록 input 토큰은 계속 늘어나는데, 다만 문서 원문을 반복해서 안 보내기 때문에 만약 문서까지 히스토리에 쌓았다면 이보다 훨씬 가파르게 늘었을 것이다. 7강에서 배운 "히스토리는 언젠가 잘라야 한다"는 원칙이 RAG 챗봇에도 그대로 적용된다. 지금은 3턴이라 괜찮지만, 대화가 수십 턴 이어지는 프로덕션 앱이라면 오래된 히스토리를 자르는 로직이 필요하다. 그건 24강(프로덕션 배포)에서 다시 다룬다.
지금까지 만든 것
- my-ai-assistant
- docs/
- lib.ts
- store.ts
- chat.ts
- vectors.json
- .env.example
- package.json
정리하면
검색·생성을 대화 루프로 묶고, 질문·답변만 히스토리에 남겨 비용을 관리했다. 그리고 후속 질문에서 검색이 실제로 실패하는 걸 실측으로 잡아내, 직전 질문을 검색어에 더하는 세 줄로 고쳤다.
- 01
readline 으로 대화형 루프를 만들었다
질문을 큐에 쌓고 순서대로 처리해, 답을 기다리는 동안 다음 질문이 와도 꼬이지 않게 했다.
- 02
히스토리는 질문·답변만 깨끗하게 쌓았다
검색된 문서 원문은 히스토리에 남기지 않고, 매 턴 새로 검색해 그 턴의 메시지에만 끼워 넣었다.
- 03
후속 질문에서 검색 실패를 실측으로 발견했다
"그럼 언제까지 신청해야 돼?" 만으로는 검색이 엉뚱한 문서를 가져왔다. Claude 는 그라운딩 규칙대로 정직하게 반응했다.
npx tsx chat.ts - 04
직전 질문을 검색어에 더해 고쳤다
코드 세 줄로 정답 문서가 1위로 올라왔고, 같은 방법이 완전히 다른 주제의 질문은 헷갈리게 하지 않는다는 것도 확인했다.
1076, 72 → 정답 청크가 1위
Part 5 가 끝났다. 16강에서 문서를 자르는 것부터 시작해, 임베딩(17강)·벡터 저장(18강)·생성 조립(19강)·대화형 완성(20강)까지 RAG 파이프라인을 정말로 손으로, 실측하며 끝까지 만들었다. 검색과 생성이 각각 어디서 실패할 수 있는지, 그 실패가 겉보기엔 똑같아 보여도 원인이 다를 수 있다는 것까지 실제 코드로 확인했다.
다음 21강부터는 Part 6, 에이전트다. 지금까지 우리 챗봇은 질문 하나에 검색 한 번, 답 한 번이었다. 21강에서는 Claude 가 스스로 "지금 뭘 해야 하는지"를 판단해 여러 도구를 순서대로 이어 쓰는 다단계 에이전트를 만든다. 12~15강에서 배운 도구 사용(tool use)과 오늘까지 만든 RAG 가 여기서 합쳐진다.