초안 "에이전트 디자인 패턴 - 지식 검색 (RAG)"
이 가이드는 TypeScript, Next.js 15, LangChain, LangGraph를 사용하여 Vercel 플랫폼에서 정교한 RAG 시스템을 구현하는 방법을 보여줍니다. 기본 검색부터 자체 수정, 쿼리의 지능형 라우팅, 복잡한 다단계 추론을 처리하는 고급 에이전트 RAG 패턴까지 구축해 나갈 것입니다.
멘털 모델: 지능형 연구 보조원으로서의 RAG
RAG를 단순히 문서를 가져오는 것이 아니라 컨텍스트를 이해하고, 소스 품질을 평가하며, 정보를 종합하는 연구 보조원이라고 생각해보세요. 전통적인 RAG는 도서관 카탈로그 시스템과 같습니다 - 쿼리하면 검색 결과를 반환합니다. 에이전트 RAG는 언제 검색할지, 무엇을 검색할지 알고, 소스를 교차 참조하고, 모순을 식별하며, "다른 곳을 찾아봐야 한다"고 판단할 수 있는 박사 과정 학생과 같습니다. 서버리스 환경에서 이 보조원은 짧은 시간(777초 이내)으로 작동하지만 상호작용 간에 대화 상태를 유지합니다.
기본 예제: 간단한 벡터 기반 RAG
1. RAG 종속성 설치
npm install @langchain/pinecone @pinecone-database/pinecone
npm install @langchain/textsplitters
npm install es-toolkit es-toolkit/compat
벡터 저장소용 Pinecone, 임베딩용 Google의 embedding-001, 청킹용 텍스트 분할기, 유틸리티 함수용 es-toolkit을 설치합니다.
2. 벡터 스토어 초기화
// lib/vector-store.ts
import { Pinecone } from '@pinecone-database/pinecone';
import { PineconeStore } from '@langchain/pinecone';
import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai';
import { memoize } from 'es-toolkit/compat';
// 서버리스 효율성을 위해 클라이언트 생성을 메모이제이션
const getPineconeClient = memoize(() =>
new Pinecone({
apiKey: process.env.PINECONE_API_KEY!,
})
);
export async function getVectorStore() {
const pinecone = getPineconeClient();
const index = pinecone.index(process.env.PINECONE_INDEX_NAME!);
const embeddings = new GoogleGenerativeAIEmbeddings({
modelName: "embedding-001",
taskType: "RETRIEVAL_DOCUMENT",
});
return PineconeStore.fromExistingIndex(embeddings, {
pineconeIndex: index,
maxConcurrency: 5, // 서버리스용 최적화
});
}
각 서버리스 호출에서 재초기화를 피하기 위해 메모이제이션된 Pinecone 클라이언트를 생성하고, 비용 최적화를 위해 Google의 임베딩을 사용합니다.
3. 청킹을 통한 문서 수집
// lib/ingestion.ts
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { Document } from '@langchain/core/documents';
import { getVectorStore } from './vector-store';
import { chunk, map } from 'es-toolkit';
interface ChunkingConfig {
chunkSize: number;
chunkOverlap: number;
separators?: string[];
}
export async function ingestDocuments(
texts: string[],
metadata: Record<string, any>[] = [],
config: ChunkingConfig = {
chunkSize: 1500,
chunkOverlap: 200,
}
) {
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: config.chunkSize,
chunkOverlap: config.chunkOverlap,
separators: config.separators || ['\n\n', '\n', '.', '!', '?'],
});
// 병렬 배치로 문서 처리
const documents = await Promise.all(
map(texts, async (text, index) => {
const chunks = await splitter.splitText(text);
return chunks.map((chunk, chunkIndex) =>
new Document({
pageContent: chunk,
metadata: {
...metadata[index],
chunkIndex,
originalIndex: index,
},
})
);
})
);
const flatDocs = documents.flat();
const vectorStore = await getVectorStore();
// 효율적인 배치 삽입
const batches = chunk(flatDocs, 100);
for (const batch of batches) {
await vectorStore.addDocuments(batch);
}
return flatDocs.length;
}
컨텍스트를 유지하기 위한 오버랩이 있는 스마트한 문서 청킹을 구현하고, 서버리스 타임아웃 제한에 최적화된 병렬 배치로 문서를 처리합니다.
4. 기본 RAG 체인
// lib/rag/basic-rag.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { PromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnablePassthrough, RunnableSequence } from '@langchain/core/runnables';
import { getVectorStore } from '../vector-store';
import { formatDocumentsAsString } from 'langchain/util/document';
export async function createBasicRAGChain() {
const vectorStore = await getVectorStore();
const retriever = vectorStore.asRetriever({
k: 4, // 상위 4개 관련 청크 검색
searchType: 'similarity',
});
const prompt = PromptTemplate.fromTemplate(`
다음 컨텍스트만을 기반으로 질문에 답하세요:
{context}
질문: {question}
간결하게 답하고 컨텍스트의 관련 부분을 인용하세요.
`);
const model = new ChatGoogleGenerativeAI({
modelName: 'gemini-2.5-flash',
temperature: 0.3,
maxOutputTokens: 1024,
});
const chain = RunnableSequence.from([
{
context: async (input: { question: string }) => {
const docs = await retriever.invoke(input.question);
return formatDocumentsAsString(docs);
},
question: new RunnablePassthrough(),
},
prompt,
model,
new StringOutputParser(),
]);
return chain;
}
컨텍스트를 검색하고 질문과 함께 형식을 지정하며 근거 있는 응답을 생성하는 기본 RAG 체인을 생성합니다.
5. 기본 RAG용 API 라우트
// app/api/rag/basic/route.ts
import { createBasicRAGChain } from '@/lib/rag/basic-rag';
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const maxDuration = 60;
export async function POST(req: Request) {
try {
const { question } = await req.json();
const chain = await createBasicRAGChain();
const response = await chain.invoke({ question });
return NextResponse.json({ answer: response });
} catch (error) {
console.error('RAG 오류:', error);
return NextResponse.json(
{ error: '쿼리 처리 실패' },
{ status: 500 }
);
}
}
질문을 받아 기본 쿼리에 대해 60초 타임아웃으로 RAG 증강된 답변을 반환하는 간단한 API 엔드포인트입니다.
고급 예제: 자체 수정 기능이 있는 에이전트 RAG
1. CRAG 패턴을 사용한 자체 수정 RAG
// lib/rag/corrective-rag.ts
import { StateGraph, END } from '@langchain/langgraph';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { BaseMessage, HumanMessage, AIMessage } from '@langchain/core/messages';
import { Document } from '@langchain/core/documents';
import { getVectorStore } from '../vector-store';
import { WebBrowser } from '@langchain/community/tools/webbrowser';
import { GoogleGenerativeAIEmbeddings } from '@langchain/google-genai';
import { filter, map, some } from 'es-toolkit';
interface CRAGState {
question: string;
documents: Document[];
relevanceScores: number[];
finalAnswer: string;
needsWebSearch: boolean;
webResults: Document[];
}
export function createCorrectiveRAG() {
const model = new ChatGoogleGenerativeAI({
modelName: 'gemini-2.5-pro',
temperature: 0,
});
const relevanceModel = new ChatGoogleGenerativeAI({
modelName: 'gemini-2.5-flash',
temperature: 0,
});
const workflow = new StateGraph<CRAGState>({
channels: {
question: null,
documents: null,
relevanceScores: null,
finalAnswer: null,
needsWebSearch: null,
webResults: null,
},
});
// 노드: 문서 검색
workflow.addNode('retrieve', async (state) => {
const vectorStore = await getVectorStore();
const retriever = vectorStore.asRetriever({ k: 5 });
const documents = await retriever.invoke(state.question);
return { documents };
});
// 노드: 관련성 평가
workflow.addNode('evaluate_relevance', async (state) => {
const relevancePrompt = `
이 문서의 질문에 대한 관련성을 점수로 매기세요 (0-10):
질문: {question}
문서: {document}
0-10 사이의 숫자만 반환하세요.
`;
const relevanceScores = await Promise.all(
map(state.documents, async (doc) => {
const response = await relevanceModel.invoke([
new HumanMessage(
relevancePrompt
.replace('{question}', state.question)
.replace('{document}', doc.pageContent)
),
]);
return parseFloat(response.content as string) || 0;
})
);
// 웹 검색이 필요한지 확인 (모든 점수가 7 미만)
const needsWebSearch = !some(relevanceScores, score => score >= 7);
return { relevanceScores, needsWebSearch };
});
// 노드: 웹 검색 폴백
workflow.addNode('web_search', async (state) => {
if (!state.needsWebSearch) {
return { webResults: [] };
}
const embeddings = new GoogleGenerativeAIEmbeddings({
modelName: "embedding-001",
});
const browser = new WebBrowser({ model, embeddings });
const searchResult = await browser.invoke(state.question);
// 검색 결과를 문서로 파싱
const webResults = [
new Document({
pageContent: searchResult,
metadata: { source: 'web_search' },
}),
];
return { webResults };
});
// 노드: 답변 생성
workflow.addNode('generate', async (state) => {
// 높은 관련성 문서 필터링
const relevantDocs = filter(
state.documents,
(_, index) => state.relevanceScores[index] >= 7
);
// 필요한 경우 웹 결과와 결합
const allDocs = [...relevantDocs, ...state.webResults];
const context = map(allDocs, doc => doc.pageContent).join('\n\n');
const response = await model.invoke([
new HumanMessage(`
제공된 컨텍스트를 사용하여 이 질문에 답하세요:
컨텍스트:
${context}
질문: ${state.question}
인용을 포함하여 포괄적인 답변을 제공하세요.
`),
]);
return { finalAnswer: response.content as string };
});
// 워크플로우 엣지 정의
workflow.setEntryPoint('retrieve');
workflow.addEdge('retrieve', 'evaluate_relevance');
workflow.addEdge('evaluate_relevance', 'web_search');
workflow.addEdge('web_search', 'generate');
workflow.addEdge('generate', END);
return workflow.compile();
}
문서 관련성을 평가하고, 필요시 웹 검색으로 폴백하며, 검증된 소스에서 답변을 생성하는 CRAG 패턴을 구현합니다.
2. 복잡한 질문을 위한 멀티 쿼리 RAG
// lib/rag/multi-query-rag.ts
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { getVectorStore } from '../vector-store';
import { uniqBy, flatten, take } from 'es-toolkit';
import { Document } from '@langchain/core/documents';
interface MultiQueryConfig {
numQueries: number;
maxDocsPerQuery: number;
temperature: number;
}
export class MultiQueryRAG {
private model: ChatGoogleGenerativeAI;
private queryGenerator: ChatGoogleGenerativeAI;
constructor() {
this.model = new ChatGoogleGenerativeAI({
modelName: 'gemini-2.5-pro',
temperature: 0.3,
});
this.queryGenerator = new ChatGoogleGenerativeAI({
modelName: 'gemini-2.5-flash',
temperature: 0.7, // 쿼리 다양성을 위한 높은 온도
});
}
async generateQueries(
originalQuery: string,
config: MultiQueryConfig = {
numQueries: 3,
maxDocsPerQuery: 3,
temperature: 0.7,
}
): Promise<string[]> {
const prompt = `
다음에 대한 정보를 찾기 위해 ${config.numQueries}개의 다른 검색 쿼리를 생성하세요:
"${originalQuery}"
다음과 같은 쿼리를 만드세요:
1. 다른 키워드와 표현 사용
2. 질문의 다른 측면에 초점
3. 구체적인 것부터 일반적인 것까지 다양하게
쿼리만 반환하고, 한 줄에 하나씩 작성하세요.
`;
const response = await this.queryGenerator.invoke([
new HumanMessage(prompt),
]);
const queries = (response.content as string)
.split('\n')
.filter(q => q.trim())
.slice(0, config.numQueries);
return [originalQuery, ...queries];
}
async retrieveWithMultiQuery(
query: string,
config?: MultiQueryConfig
): Promise<Document[]> {
const queries = await this.generateQueries(query, config);
const vectorStore = await getVectorStore();
// 각 쿼리에 대해 병렬로 검색
const allResults = await Promise.all(
queries.map(q =>
vectorStore.similaritySearch(q, config?.maxDocsPerQuery || 3)
)
);
// 컨텐츠별로 중복 제거
const uniqueDocs = uniqBy(
flatten(allResults),
doc => doc.pageContent
);
// 상위 문서 반환
return take(uniqueDocs, 10);
}
async answer(query: string): Promise<string> {
const documents = await this.retrieveWithMultiQuery(query);
const context = documents
.map((doc, idx) => `[${idx + 1}] ${doc.pageContent}`)
.join('\n\n');
const response = await this.model.invoke([
new HumanMessage(`
다음 컨텍스트를 기반으로 답하세요:
${context}
질문: ${query}
소스에 대한 참조 번호 [1], [2] 등을 포함하세요.
`),
]);
return response.content as string;
}
}
검색 범위를 개선하기 위해 여러 쿼리 변형을 생성하고, 결과 중복을 제거하며, 참조가 포함된 답변을 제공합니다.
3. 적응형 RAG 라우터
// lib/rag/adaptive-rag.ts
import { StateGraph, END } from '@langchain/langgraph';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { BaseMessage } from '@langchain/core/messages';
import { createBasicRAGChain } from './basic-rag';
import { MultiQueryRAG } from './multi-query-rag';
import { createCorrectiveRAG } from './corrective-rag';
interface AdaptiveRAGState {
query: string;
complexity: 'simple' | 'medium' | 'complex';
answer: string;
confidence: number;
}
export function createAdaptiveRAG() {
const classifier = new ChatGoogleGenerativeAI({
modelName: 'gemini-2.5-flash',
temperature: 0,
});
const workflow = new StateGraph<AdaptiveRAGState>({
channels: {
query: null,
complexity: null,
answer: null,
confidence: null,
},
});
// 노드: 쿼리 복잡도 분류
workflow.addNode('classify', async (state) => {
const prompt = `
이 쿼리의 복잡도를 분류하세요:
"${state.query}"
Simple: 사실적, 단일 홉 질문
Medium: 종합이 필요한 다면적 질문
Complex: 추론, 검증 또는 여러 소스가 필요한 질문
simple, medium, 또는 complex만 응답하세요
`;
const response = await classifier.invoke([
new HumanMessage(prompt),
]);
const complexity = (response.content as string).trim().toLowerCase() as
'simple' | 'medium' | 'complex';
return { complexity };
});
// 노드: 단순 RAG
workflow.addNode('simple_rag', async (state) => {
if (state.complexity !== 'simple') return {};
const chain = await createBasicRAGChain();
const answer = await chain.invoke({ question: state.query });
return { answer, confidence: 0.9 };
});
// 노드: 멀티 쿼리 RAG
workflow.addNode('multi_query_rag', async (state) => {
if (state.complexity !== 'medium') return {};
const multiRAG = new MultiQueryRAG();
const answer = await multiRAG.answer(state.query);
return { answer, confidence: 0.8 };
});
// 노드: 수정 RAG
workflow.addNode('corrective_rag', async (state) => {
if (state.complexity !== 'complex') return {};
const crag = createCorrectiveRAG();
const result = await crag.invoke({
question: state.query,
documents: [],
relevanceScores: [],
finalAnswer: '',
needsWebSearch: false,
webResults: [],
});
return {
answer: result.finalAnswer,
confidence: 0.7
};
});
// 복잡도에 따른 조건부 라우팅
workflow.setEntryPoint('classify');
workflow.addConditionalEdges('classify', (state) => {
switch (state.complexity) {
case 'simple':
return 'simple_rag';
case 'medium':
return 'multi_query_rag';
case 'complex':
return 'corrective_rag';
default:
return 'simple_rag';
}
});
workflow.addEdge('simple_rag', END);
workflow.addEdge('multi_query_rag', END);
workflow.addEdge('corrective_rag', END);
return workflow.compile();
}
복잡도 분류를 기반으로 쿼리를 적절한 RAG 전략으로 라우팅하여 속도와 정확도를 모두 최적화합니다.
4. 진행 상황 업데이트가 포함된 스트리밍 RAG API
// app/api/rag/adaptive/route.ts
import { createAdaptiveRAG } from '@/lib/rag/adaptive-rag';
export const runtime = 'nodejs';
export const maxDuration = 300;
export async function POST(req: Request) {
const { query } = await req.json();
const encoder = new TextEncoder();
const stream = new TransformStream();
const writer = stream.writable.getWriter();
const workflow = createAdaptiveRAG();
(async () => {
try {
// 진행 상황 이벤트 전송
await writer.write(
encoder.encode(`data: ${JSON.stringify({
type: 'status',
message: '쿼리 복잡도 분석 중...'
})}\n\n`)
);
const events = await workflow.stream({
query,
complexity: 'simple',
answer: '',
confidence: 0,
});
for await (const event of events) {
// 중간 업데이트 전송
if (event.complexity) {
await writer.write(
encoder.encode(`data: ${JSON.stringify({
type: 'complexity',
complexity: event.complexity,
message: `${event.complexity} RAG 전략 사용`
})}\n\n`)
);
}
if (event.answer) {
await writer.write(
encoder.encode(`data: ${JSON.stringify({
type: 'answer',
content: event.answer,
confidence: event.confidence
})}\n\n`)
);
}
}
await writer.write(
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
);
} catch (error) {
await writer.write(
encoder.encode(`data: ${JSON.stringify({
type: 'error',
error: String(error)
})}\n\n`)
);
} finally {
await writer.close();
}
})();
return new Response(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
복잡도 분석, 전략 선택, 신뢰도 점수가 포함된 최종 답변을 포함한 RAG 실행 진행 상황을 스트리밍합니다.
5. 적응형 RAG용 React 컴포넌트
// components/AdaptiveRAGInterface.tsx
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { groupBy } from 'es-toolkit';
interface RAGEvent {
type: 'status' | 'complexity' | 'answer' | 'error' | 'done';
message?: string;
complexity?: string;
content?: string;
confidence?: number;
error?: string;
}
export default function AdaptiveRAGInterface() {
const [query, setQuery] = useState('');
const [events, setEvents] = useState<RAGEvent[]>([]);
const [answer, setAnswer] = useState('');
const ragMutation = useMutation({
mutationFn: async (userQuery: string) => {
setEvents([]);
setAnswer('');
const response = await fetch('/api/rag/adaptive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: userQuery }),
});
if (!response.ok) throw new Error('RAG 실패');
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6)) as RAGEvent;
setEvents(prev => [...prev, event]);
if (event.type === 'answer') {
setAnswer(event.content || '');
}
} catch (e) {
// 파싱 오류 무시
}
}
}
}
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
ragMutation.mutate(query);
}
};
// 표시를 위해 이벤트를 유형별로 그룹화
const eventGroups = groupBy(events, event => event.type);
return (
<div className="w-full max-w-4xl mx-auto">
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title">적응형 RAG 시스템</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text">질문</span>
</label>
<textarea
className="textarea textarea-bordered h-24"
placeholder="질문을 입력하세요..."
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={ragMutation.isPending}
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={ragMutation.isPending || !query.trim()}
>
{ragMutation.isPending ? (
<>
<span className="loading loading-spinner"></span>
처리 중...
</>
) : '답변 받기'}
</button>
</form>
{/* 진행 상황 표시기 */}
{events.length > 0 && (
<div className="mt-6 space-y-4">
{eventGroups.complexity && (
<div className="alert alert-info">
<span>
쿼리 복잡도:
<span className="badge badge-primary ml-2">
{eventGroups.complexity[0].complexity}
</span>
</span>
</div>
)}
{eventGroups.status && (
<div className="mockup-code">
{eventGroups.status.map((event, idx) => (
<pre key={idx} data-prefix={`${idx + 1}`}>
<code>{event.message}</code>
</pre>
))}
</div>
)}
</div>
)}
{/* 답변 표시 */}
{answer && (
<div className="mt-6">
<div className="divider">답변</div>
<div className="prose max-w-none">
<div className="p-4 bg-base-200 rounded-lg">
{answer}
</div>
{events.find(e => e.confidence) && (
<div className="mt-2">
<progress
className="progress progress-success w-full"
value={events.find(e => e.confidence)?.confidence || 0}
max="1"
/>
<p className="text-sm text-center mt-1">
신뢰도: {((events.find(e => e.confidence)?.confidence || 0) * 100).toFixed(0)}%
</p>
</div>
)}
</div>
</div>
)}
{ragMutation.isError && (
<div className="alert alert-error mt-4">
<span>오류: {ragMutation.error?.message}</span>
</div>
)}
</div>
</div>
</div>
);
}
RAG 실행 진행 상황, 복잡도 분류, 신뢰도 점수가 있는 답변을 표시하는 대화형 UI 컴포넌트입니다.
결론
이 구현은 기본 RAG에서 쿼리를 지능적으로 라우팅하고, 웹 검색 폴백으로 자체 수정하며, 복잡도에 따라 전략을 조정하는 정교한 에이전트 패턴으로의 진화를 보여줍니다. Vercel의 서버리스 아키텍처는 비용 효율적인 확장을 보장하고 LangGraph의 상태 머신은 777초 제한 내에서 복잡한 워크플로우를 가능하게 합니다. 주요 패턴에는 자체 수정을 위한 CRAG, 포괄적인 검색을 위한 멀티 쿼리, 최적 성능을 위한 적응형 라우팅이 포함됩니다. es-toolkit의 사용은 깨끗하고 기능적인 코드 패턴을 보장하며 스트리밍 응답은 복잡한 쿼리에도 뛰어난 사용자 경험을 제공합니다.