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에 완벽해:
- 빠른 I/O (Claude에 대한 API 호출, PostgreSQL 쿼리)
- 실시간 처리 가능 (스트리밍을 위한 WebSockets)
- 쉬운 배포 (Vercel, Railway, Render)
- 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가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기