본문 바로가기
최신 AI

Node.js로 RAG 앱 구축하기 — 백엔드 개발자를 위한 간단 가이드

by 구라100단 2025. 10. 13.

RAG(검색 증강 생성)란 무엇인가?

RAG는 Retrieval-Augmented Generation의 약자이다. 간단히 말해, LLM(대규모 언어 모델)에 검색 가능한 지식 저장소(벡터 DB)를 결합하여, LLM이 '기억하는' 내용뿐만 아니라 당신의 데이터를 사용하여 답변을 생성할 수 있도록 하는 기술이다.

왜 RAG를 사용해야 하는가?

LLM은 훌륭하지만, 사실을 잊어버리거나 환각(Hallucination, 사실이 아닌 내용을 그럴듯하게 지어내는 현상)을 일으키기도 한다. RAG는 문서, 매뉴얼, 제품 데이터 또는 DB에서 가져온 최신 사실 정보를 모델에 제공한다. 그 결과, 더 나은 답변을 얻을 수 있고, 환각을 줄일 수 있으며, 전달할 컨텍스트를 작게 유지하여 토큰 비용을 절감할 수 있다.

일반적인 RAG 스택 (간단 버전)

  • LLM (대규모 언어 모델): 생성기 역할(OpenAI, Gemini, Claude 등)을 한다.
  • Embeddings (임베딩): 텍스트를 벡터로 변환한다.
  • Vector DB (벡터 DB): 임베딩을 저장하고 검색한다 (Pinecone, Milvus, Weaviate, pgvector 등).
  • Retriever (검색기): 상위 k개의 일치하는 구절을 찾는다.
  • Prompt & LLM (프롬프트 & LLM): 검색된 구절과 사용자 쿼리를 결합하여 LLM을 호출한다.
  • Memory / Cache (선택 사항): 세션 기록, 요약 등을 저장하는 DB(Mongo/Postgres)이다.
  • Tools (도구): 내부 API를 호출하거나, 코드를 실행하거나, 실시간 데이터를 가져오는 역할(사용자 정의 Node.js 도구 사용)을 한다.
  • Orchestration (오케스트레이션): 단일 에이전트 또는 다중 에이전트 흐름을 관리한다 (LangChain 또는 사용자 정의 코드 사용).

핵심 아이디어를 한 문장으로 요약한다.

문서를 임베딩으로 변환하여 벡터 DB에 저장한다. 사용자 쿼리가 들어오면, 상위 일치 항목을 찾아 이 항목과 사용자 쿼리를 LLM에 공급하여 답변을 반환한다.

Node.js로 구축하기 — 주요 단계

1. 문서를 준비하고 텍스트를 추출한다.

  • PDF, HTML, 문서 등을 200~800 토큰 크기의 청크(chunk)로 변환한다.
  • 메타데이터(소스, URL, 날짜)를 추가한다.

2. 임베딩을 얻어 벡터 DB에 저장한다.

예시 흐름:

  • 각 청크에 대해 → 임베딩 API를 호출한다 → {id, text, embedding, metadata}를 벡터 DB에 저장한다.

3. 쿼리 단계이다.

  • 사용자 쿼리를 임베딩으로 변환한다 → 상위 k개 유사성 검색을 수행한다.
  • 프롬프트를 구축한다: 검색된 텍스트 스니펫과 사용자 질문을 포함한다.
  • LLM을 호출하여 최종 답변을 생성한다.

4. 선택적 메모리이다.

  • 대화(또는 짧은 요약)를 MongoDB나 Postgres에 저장한다.
  • 개인화 또는 후속 질문을 위한 추가 컨텍스트로 메모리를 사용한다.

예시: 최소한의 Node.js 코드 스니펫

(참고: API 키와 DB 클라이언트 구성은 사용자 환경에 맞게 교체해야 한다.)

1) 임베딩 가져오기 (OpenAI 스타일 API 사용)

// embeddings.js
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function getEmbedding(text) {
  const resp = await client.embeddings.create({
    model: "text-embedding-3-small", // 예시
    input: text,
  });
  return resp.data[0].embedding;
}

 

2) 벡터 DB에 임베딩 저장 (Pinecone 의사 코드)

import Pinecone from "@pinecone-database/pinecone";

const pinecone = new Pinecone.Client({ apiKey: process.env.PINECONE_KEY });
const index = pinecone.Index("my-rag-index");

export async function saveDoc(id, text, embedding, metadata = {}) {
  await index.upsert({
    vectors: [{ id, values: embedding, metadata: { text, ...metadata } }],
  });
}

 

3) 쿼리 및 상위 k개 검색

export async function retrieve(queryEmbedding, topK = 5) {
  const results = await index.query({
    vector: queryEmbedding,
    topK,
    includeMetadata: true,
  });
  return results.matches.map(m => ({ id: m.id, score: m.score, text: m.metadata.text }));
}

 

4) 프롬프트 구축 및 LLM에게 질문

import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function answerWithRAG(userQuery, retrievedChunks) {
  // 검색된 청크들을 컨텍스트 문자열로 만든다.
  const context = retrievedChunks.map((c,i) => `SOURCE ${i+1}:\n${c.text}\n`).join("\n---\n");
  
  const prompt = `
  You are a helpful assistant. Use the context below to answer the question.
  If context is insufficient, say "I don't know."
  
  CONTEXT:
  ${context}
  
  QUESTION:
  ${userQuery}
  
  Answer concisely:
  `;

  const res = await client.chat.completions.create({
    model: "gpt-4o-mini", // 예시
    messages: [{ role: "user", content: prompt }],
  });
  return res.choices[0].message.content;
}

 

5) 간단한 서버 엔드포인트 (Express)

import express from "express";
import { getEmbedding } from "./embeddings.js";
import { retrieve } from "./pinecone-client.js";
import { answerWithRAG } from "./llm.js";

const app = express();
app.use(express.json());

app.post("/ask", async (req, res) => {
  const { question } = req.body;
  const qEmb = await getEmbedding(question);
  const top = await retrieve(qEmb, 5);
  const answer = await answerWithRAG(question, top);
  
  res.json({ answer, sources: top.map(t => ({ id: t.id, score: t.score })) });
});

app.listen(3000);

메모리 추가 (MongoDB) — 간단한 예시

나중에 검색할 수 있도록 대화나 짧은 요약을 저장한다.

import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGO_URL);
await client.connect();
const db = client.db("rag");
const conv = db.collection("conversations");

export async function saveConversation(sessionId, userMsg, assistantMsg) {
  await conv.insertOne({ sessionId, userMsg, assistantMsg, at: new Date() });
}

이 메모리를 사용하여 마지막 n개 메시지 또는 요약을 프롬프트에 포함하여 컨텍스트로 사용한다.

사용자 정의 Node.js 도구 구축 (내부 API 호출)

'도구(Tool)'는 에이전트가 실시간 데이터가 필요할 때 호출할 수 있는 단순한 함수이다.

// tools/productApi.js
export async function getProductInfo(productId) {
  // 내부 서비스 또는 DB를 호출한다.
  const resp = await fetch(`https://internal-api/products/${productId}`);
  return resp.json();
}

쿼리 시점에 오케스트레이션이 관련성이 있을 때 이 도구를 호출하도록 허용하고, 도구 출력을 프롬프트에 포함할 수 있다.

다중 에이전트 시스템 — 한 문단 아이디어

하나의 거대한 에이전트 대신, 전문화된 에이전트(검색기, 요약기, 도구 실행기, 검증기)를 생성한다. 에이전트들은 작은 메시지 버스(Redis, RabbitMQ 또는 DB)를 통해 통신한다. 이는 책임 분산을 통해 확장성을 돕고 디버깅을 쉽게 만든다. 간단하게는 하나의 LLM 에이전트와 검증 에이전트로 시작한다.

메모리 전략 — 무엇을 어디에 저장해야 하는가?

  • 단기 세션: 최근 메시지를 보관한다 (MongoDB 또는 인메모리 Redis에 보관한다).
  • 장기 메모리: 임베딩과 요약을 DB(Postgres/Mongo + 벡터 인덱스 또는 벡터 DB)에 저장한다.
  • 정기적인 요약: 대화가 길어지면 토큰 사용량을 줄이기 위해 요약본을 저장한다. 요약본을 다시 임베딩할 수도 있다.

토큰 / 비용 최적화 방안

  • 전체 문서가 아닌 상위 k개의 관련 구절만 전송한다.
  • 검색 요약에는 더 작은 LLM을 사용하고, 최종 생성에만 더 큰 LLM을 사용한다.
  • 긴 컨텍스트를 요약하고 압축한다.

실용적인 팁과 함정

  • 청크 크기가 중요하다: 너무 크면 토큰이 낭비되고, 너무 작으면 컨텍스트가 손실된다. 200~800 토큰이 자주 사용된다.
  • 임베딩 전에 데이터를 정리하고 필터링해야 한다 (필요한 경우 PII 제거).
  • 출처(source) 메타데이터를 추가하여 인용하거나 출처를 표시할 수 있도록 한다.
  • 정확도를 모니터링한다: RAG는 환각을 줄이지만 완전히 제거하지는 못한다. 중요한 부분에는 검증 과정을 추가해야 한다.
  • 확장성을 계획한다: 벡터 인덱스는 튜닝이 필요하다 (인덱스 유형, 거리 측정 기준 등).

프로덕션 전 빠른 체크리스트

  • 키와 엔드포인트를 안전하게 보호한다.
  • 요율 제한(rate limiting)과 캐싱을 추가한다.
  • 출력에 메타데이터(소스, 날짜, 신뢰도)를 추가한다.
  • 임베딩과 문서에 대한 버전 관리를 추가한다.
  • 평가 파이프라인(예시 QA 쌍; 정확도 측정)을 생성한다.

마지막으로 — Node.js를 사용하는 이유

Node.js는 API 호출, 검색 오케스트레이션, 엔드포인트 제공 등 '연결고리(glue)' 역할을 구축하는 데 탁월하다. langchainjs, pinecone-client, openai, mongodb, pgvector 드라이버와 같은 기존 라이브러리를 활용한다. 간단하고 테스트 가능한 구성 요소(검색기 → 생성기 → 도구 → 메모리 → 다중 에이전트)로 시작하여 점차 확장해 나가야 한다.

 

요약

RAG = LLM + 벡터 DB 검색이다. 문서를 업로드하고 임베딩하여 벡터 DB에 저장한다. 쿼리 시점에 상위 일치 항목을 검색하고 LLM에 전달하여 정확한 답변을 반환한다. MongoDB(또는 Postgres)를 세션 메모리로 사용하고, 실시간 데이터를 위한 사용자 정의 Node 도구를 추가한다. 작게 시작하고, 반복하며, 정확도를 모니터링해야 한다.