본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 20. 19:48

RAG 파이프라인: 완전한 Node.js 구현 가이드

요약

Node.js 환경에서 프로덕션급 RAG(Retrieval-Augmented Generation) 시스템을 구축하는 전체 과정을 다룹니다. PostgreSQL의 pgvector를 활용한 벡터 저장, Claude API를 이용한 임베딩 및 재순위화(Reranking) 구현 방법을 상세히 설명합니다.

핵심 포인트

  • Node.js의 비동기 I/O를 활용한 효율적인 RAG 파이프라인 구축
  • pgvector를 이용한 PostgreSQL 기반 벡터 데이터베이스 설정
  • Claude API를 활용한 임베딩 및 고품질 재순위화 서비스 구현
  • 데이터 청킹 전략 및 하이브리드 검색 방식 적용 가이드

Node.js로 프로덕션급 RAG 시스템 구축하기 - 어디서 문제가 발생하는지, 왜 작동하는지, 그리고 언제 사용해야 하는지 알아봅시다

서론: 왜 RAG에 Node.js를 사용하는가?

👦 조카: 삼촌, 왜 RAG를 Node.js로 만들어야 하나요? 이건 AI 분야 아닌가요?

👨‍🦳 삼촌: 좋은 질문이야. Node.js는 다음과 같은 이유로 RAG에 완벽해:

  1. 빠른 I/O (Claude에 대한 API 호출, PostgreSQL 쿼리)
  2. 실시간 처리 가능 (스트리밍을 위한 WebSockets)
  3. 쉬운 배포 (Vercel, Railway, Render)
  4. Async/await를 통해 복잡한 흐름을 깔끔하게 관리

게다가 너는 아마 이미 백엔드를 Node.js로 운영하고 있을 거야. 왜 굳이 Python을 추가하겠니?

👦 조카: 그럼 전체를 JavaScript로 만들 수 있다는 말씀인가요?

👨‍🦳 삼촌: 응. 프론트엔드, 백엔드, RAG - 모두 JavaScript로 말이야. 그게 바로 묘미지.

하지만 한계점에 대해서도 솔직해질 필요가 있어. 그 부분에 대해서도 이야기해 보자.

PART 1: 프로젝트 설정

프로젝트 초기화

# 프로젝트 생성
mkdir rag-system
cd rag-system
...

프로젝트 구조

rag-system/
├── src/
│   ├── config/
...

TypeScript 설정

{
  "compilerOptions": {
    "target": "ES2020",
...

PART 2: 데이터베이스 설정 (PostgreSQL + pgvector)

1. pgvector를 사용한 PostgreSQL 데이터베이스 생성

👨‍🦳 삼촌: 이것이 너의 토대야. 여기서 틀리면 모든 것이 무너져.

-- PostgreSQL에 연결
psql -U postgres

...

2. 데이터베이스 연결 서비스

👨‍🦳 삼촌: 여기가 너의 첫 번째 실패 지점이 존재할 곳이야.

// src/config/database.ts

import pgPromise from 'pg-promise';
...

3. 환경 변수

# .env
DB_HOST=localhost
DB_PORT=5432
...

PART 3: 임베딩 (Embedding) 서비스

Claude를 이용한 임베딩 생성

👨‍🦳 삼촌: 여기서 첫 번째 실제 비용이 발생해. 여기서 무엇이 실패할 수 있는지 알아두렴.

// src/config/embedding.ts

import Anthropic from '@anthropic-ai/sdk';
...

청킹 (Chunking) 함수

👨‍🦳 삼촌: 기억해: 1000-1500 토큰, 200 토큰 중첩 (overlap).

// src/utils/chunking.ts

import logger from './logger';
...

PART 4: 검색 (Retrieval) 서비스

하이브리드(Vector + Keyword)를 이용한 벡터 검색 (Vector Search)

👨‍🦳 Uncle: 이것이 핵심입니다. 모든 것이 성공하거나 실패하는 지점이죠.

// src/services/retrieval.ts

import db from '../config/database';
...

PART 5: 재순위화 (RERANKING) 서비스

👨‍🦳 Uncle: 2단계(Two-stage) 방식에서 품질이 결정됩니다. 첫 번째 단계는 빠르고, 두 번째 단계는 정확합니다.

// src/services/reranking.ts

import Anthropic from '@anthropic-ai/sdk';
import logger from '../utils/logger';

interface RerankedResult {
  text: string;
  score: number;
  rank: number;
}

/**
 * Claude를 사용하여 청크(chunks)를 재순위화합니다 (더 정확하지만 더 느림).
 * 
 * ⚠️ 실패 지점 (FAILURE POINTS):
 * 1. Claude API 타임아웃 (타임아웃 래퍼로 해결)
 * 2. 청크가 너무 김 (전송 전 절단/truncate)
 * 3. 응답 파싱 실패
 * 4. 비용 폭발 (재순위화는 비용이 발생하므로 추적 필요)
 */
export async function rerank(
  query: string,
  chunks: string[],
  topK: number = 5
): Promise<RerankedResult[]> {
  const startTime = Date.now();

  try {
    if (chunks.length === 0) {
      return [];
    }

    const client = new Anthropic({
      apiKey: process.env.ANTHROPIC_API_KEY,
      timeout: 30 * 1000, // 30초 타임아웃
    });

    // 토큰 오버플로를 방지하기 위해 청크 절단 (Truncate)
    const truncatedChunks = chunks.map(c => 
      c.length > 2000 ? c.substring(0, 2000) + '...' : c
    );

    // 재순위화 프롬프트 구축
    const chunksFormatted = truncatedChunks
      .map((chunk, i) => `[${i}] ${chunk}`)
      .join('\n\n---\n\n');

    const prompt = `당신은 검색 관련성 전문가입니다. 다음 청크들을 질의(query)와의 관련성에 따라 순위를 매기세요.

질의: "${query}"

순위를 매길 청크들:
${chunksFormatted}

반드시 다음 형식의 유효한 JSON만 반환하세요:
{
  "rankings": [
    {"index": 0, "relevance_score": 0.95},
    {"index": 1, "relevance_score": 0.72}
  ]
}

관련성 점수(Relevance score): 0.0 (관련 없음) ~ 1.0 (매우 관련 있음)
relevance_score를 기준으로 내림차순 정렬하세요.`;

const response = await client.messages.create({
      model: 'claude-3-5-sonnet-20241022',
      max_tokens: 1024,
      messages: [{ 
        role: 'user',
        content: prompt
      }]
    });

    // 응답 파싱 (Parse response)
    const responseText = response.content[0].type === 'text'
      ? response.content[0].text
      : '';

    let rankings: any[];
    try {
      // 응답에서 JSON 추출 (마크다운으로 감싸져 있을 수 있음)
      const jsonMatch = responseText.match(/\{[\s\S]*\}/);
      const jsonStr = jsonMatch ? jsonMatch[0] : responseText;
      const parsed = JSON.parse(jsonStr);
      rankings = parsed.rankings || [];
    } catch (parseError) {
      logger.error('Failed to parse reranking response', { 
        response: responseText.substring(0, 500)
      });
      // 폴백 (Fallback): 원래 순서 반환
      return chunks.slice(0, topK).map((text, i) => ({
        text,
        score: 1.0 - (i * 0.1),
        rank: i + 1
      }));
    }

    // 결과로 변환 (Convert to results)
    const results = rankings
      .filter(r => r.index >= 0 && r.index < chunks.length)
      .map((r, rank) => ({
        text: chunks[r.index],
        score: r.relevance_score,
        rank: rank + 1
      }))
      .sl

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0