RAG 실전 가이드: TypeScript와 AI SDK로 검색 증강 생성 구축하기
RAG의 핵심 원리부터 pgvector, AI SDK embed, Agentic RAG까지. TypeScript로 프로덕션급 RAG 파이프라인을 구축하는 단계별 가이드입니다.
LLM은 왜 틀릴까 — RAG가 필요한 이유
ChatGPT에게 "우리 회사의 최신 휴가 정책을 알려줘"라고 물어보면 어떻게 될까요? LLM은 학습 데이터에 없는 정보를 그럴듯하게 지어냅니다. 이것이 바로 환각(hallucination) 문제입니다.
RAG(Retrieval-Augmented Generation) 는 이 문제의 가장 실용적인 해답입니다. LLM에게 질문을 던지기 전에, 관련 문서를 먼저 검색해서 컨텍스트로 제공하는 것입니다. 마치 오픈북 시험처럼, LLM이 "참고자료를 보면서" 답변하게 만드는 기법입니다.
2026년 현재 RAG는 AI 애플리케이션의 기본 아키텍처 로 자리잡았습니다. 고객 지원 챗봇, 사내 문서 검색, 코드베이스 Q&A, 법률/의료 문서 분석 등 실무에서 가장 많이 쓰이는 AI 패턴입니다.
이 글에서는 TypeScript와 Vercel AI SDK를 사용해 RAG 파이프라인을 처음부터 구축하는 방법을 살펴보겠습니다. 기본 RAG에서 시작해 2026년의 표준인 Agentic RAG까지 단계별로 진행합니다.
RAG 파이프라인 흐름
RAG의 핵심 구성요소
RAG 시스템은 크게 세 단계로 구성됩니다.
1단계: 인덱싱 (Indexing)
문서를 작은 조각(청크)으로 나누고, 각 청크를 벡터 임베딩으로 변환하여 벡터 데이터베이스에 저장합니다.
2단계: 검색 (Retrieval)
사용자 질문을 같은 임베딩 모델로 벡터화하고, 벡터 데이터베이스에서 가장 유사한 청크를 찾습니다.
3단계: 생성 (Generation)
검색된 문서를 컨텍스트로 포함하여 LLM에게 답변을 요청합니다.
각 단계를 TypeScript로 구현해보겠습니다.
프로젝트 설정: pgvector + Drizzle + AI SDK
RAG 파이프라인에 필요한 기술 스택을 먼저 구성합니다.
# Next.js 프로젝트에 필요한 패키지 설치
pnpm add ai @ai-sdk/react drizzle-orm @neondatabase/serverless
pnpm add -D drizzle-kit
벡터 데이터베이스로는 pgvector 를 선택합니다. 별도 인프라 없이 기존 PostgreSQL에 벡터 검색을 추가할 수 있기 때문입니다. 최근 벤치마크에서 pgvectorscale은 50M 벡터 기준 471 QPS(초당 쿼리)를 99% recall로 달성하며, 전문 벡터 DB인 Qdrant 대비 11.4배 높은 처리량을 보여주었습니다.
Drizzle 스키마 정의
// src/db/schema.ts
import { pgTable, text, varchar, vector, index, serial, timestamp } from 'drizzle-orm/pg-core'
// 문서 원본 테이블
export const documents = pgTable('documents', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 256 }).notNull(),
content: text('content').notNull(),
source: varchar('source', { length: 512 }),
createdAt: timestamp('created_at').defaultNow(),
})
// 청크 + 임베딩 테이블
export const chunks = pgTable(
'chunks',
{
id: serial('id').primaryKey(),
documentId: serial('document_id').references(() => documents.id),
content: text('content').notNull(),
embedding: vector('embedding', { dimensions: 1536 }).notNull(),
metadata: text('metadata'), // JSON 문자열로 추가 정보 저장
},
(table) => [
// HNSW 인덱스로 벡터 검색 성능 최적화
index('chunks_embedding_idx').using(
'hnsw',
table.embedding.op('vector_cosine_ops')
),
]
)
pgvector의 HNSW 인덱스 는 근사 최근접 이웃(ANN) 검색을 O(log n) 시간에 수행합니다. 수십만 개의 청크에서도 밀리초 단위로 검색이 가능합니다.
인덱싱: 문서를 벡터로 변환하기
청킹 전략
문서를 어떻게 자르느냐는 RAG 성능의 핵심입니다. NAACL 2025에서 발표된 Vectara 연구에 따르면, 청킹 설정이 임베딩 모델 선택만큼 검색 품질에 영향 을 미칩니다.
2026년 현재 가장 안정적인 기본값은 재귀적 문자 분할(Recursive Character Splitting) 입니다.
// src/lib/chunking.ts
interface Chunk {
content: string
metadata: {
startIndex: number
endIndex: number
chunkIndex: number
}
}
const CHUNK_SIZE = 512 // 토큰 기준 400~512 추천
const CHUNK_OVERLAP = 64 // 10~20% 오버랩
const SEPARATORS = ['\n\n', '\n', '. ', ' ', '']
export function splitIntoChunks(text: string): Chunk[] {
const chunks: Chunk[] = []
function recursiveSplit(text: string, separatorIndex: number): string[] {
if (text.length <= CHUNK_SIZE) return [text]
const separator = SEPARATORS[separatorIndex] ?? ''
const parts = text.split(separator)
const result: string[] = []
let current = ''
for (const part of parts) {
const candidate = current ? current + separator + part : part
if (candidate.length > CHUNK_SIZE && current) {
result.push(current)
// 오버랩: 이전 청크의 마지막 부분을 다음 청크 시작에 포함
const overlapText = current.slice(-CHUNK_OVERLAP)
current = overlapText + separator + part
} else {
current = candidate
}
}
if (current) result.push(current)
return result
}
const parts = recursiveSplit(text, 0)
parts.forEach((content, index) => {
chunks.push({
content: content.trim(),
metadata: {
startIndex: text.indexOf(content),
endIndex: text.indexOf(content) + content.length,
chunkIndex: index,
},
})
})
return chunks
}
오버랩을 두는 이유는 문장이 청크 경계에서 잘리는 경우를 방지하기 위해서입니다. 10~20%의 오버랩이면 대부분의 경우 충분합니다.
임베딩 생성 및 저장
AI SDK의 embedMany 함수로 청크들을 배치 임베딩합니다.
// src/lib/indexing.ts
import { embedMany } from 'ai'
import { db } from '@/db'
import { chunks, documents } from '@/db/schema'
import { splitIntoChunks } from './chunking'
export async function indexDocument(title: string, content: string, source?: string) {
// 1. 문서 원본 저장
const [doc] = await db
.insert(documents)
.values({ title, content, source })
.returning({ id: documents.id })
// 2. 청킹
const textChunks = splitIntoChunks(content)
// 3. 배치 임베딩 생성
const { embeddings } = await embedMany({
model: 'openai/text-embedding-3-small', // AI Gateway 경유
values: textChunks.map((chunk) => chunk.content),
})
// 4. 벡터 DB에 저장
const chunkRecords = textChunks.map((chunk, i) => ({
documentId: doc.id,
content: chunk.content,
embedding: embeddings[i],
metadata: JSON.stringify(chunk.metadata),
}))
await db.insert(chunks).values(chunkRecords)
return {
documentId: doc.id,
chunksCreated: chunkRecords.length,
}
}
text-embedding-3-small은 1536 차원의 임베딩을 생성하며, 비용 대비 성능이 뛰어납니다. 더 높은 품질이 필요하면 text-embedding-3-large(3072 차원)를 사용할 수 있습니다.
검색: 벡터 유사도로 관련 문서 찾기
사용자 질문을 벡터로 변환하고, 코사인 유사도로 가장 가까운 청크를 검색합니다.
// src/lib/retrieval.ts
import { embed } from 'ai'
import { db } from '@/db'
import { chunks } from '@/db/schema'
import { cosineDistance, desc, sql, gt } from 'drizzle-orm'
export async function searchSimilarChunks(query: string, topK = 5) {
// 1. 질문을 벡터로 변환
const { embedding: queryEmbedding } = await embed({
model: 'openai/text-embedding-3-small',
value: query,
})
// 2. 코사인 유사도 기반 검색
const similarity = sql<number>`1 - (${cosineDistance(chunks.embedding, queryEmbedding)})`
const results = await db
.select({
id: chunks.id,
content: chunks.content,
similarity,
})
.from(chunks)
.where(gt(similarity, 0.3)) // 유사도 0.3 이상만
.orderBy(desc(similarity))
.limit(topK)
return results
}
유사도 임계값(0.3) 은 관련 없는 문서를 필터링하는 역할을 합니다. 이 값은 도메인에 따라 조정이 필요합니다. 기술 문서는 0.30.4, 일반 대화는 0.20.3이 적절합니다.
생성: RAG 챗봇 API 구현
검색된 문서를 컨텍스트로 LLM에 전달하여 답변을 생성합니다.
// src/app/api/chat/route.ts
import { streamText, convertToModelMessages, tool } from 'ai'
import { z } from 'zod'
import { searchSimilarChunks } from '@/lib/retrieval'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: 'anthropic/claude-sonnet-4.6',
system: `You are a helpful assistant that answers questions based on the provided context.
If the context doesn't contain relevant information, say so honestly.
Always cite which document your answer is based on.`,
messages: convertToModelMessages(messages),
tools: {
searchKnowledge: tool({
description: 'Search the knowledge base for relevant information',
inputSchema: z.object({
query: z.string().describe('The search query to find relevant documents'),
}),
execute: async ({ query }) => {
const results = await searchSimilarChunks(query, 5)
return results.map((r) => ({
content: r.content,
relevance: Math.round(r.similarity * 100) + '%',
}))
},
}),
},
stopWhen: stepCountIs(3),
})
return result.toUIMessageStreamResponse()
}
여기서 핵심은 검색을 도구(tool) 로 정의한다는 것입니다. LLM이 스스로 "지금 검색이 필요한가?"를 판단하고, 필요할 때만 검색을 실행합니다. 이것이 바로 2026년의 표준인 Agentic RAG 의 기본 형태입니다.
RAG 아키텍처의 진화
질문 → 검색 → 생성질문 → 쿼리 변환 → 하이브리드 검색 → 리랭킹 → 생성라우터 → 검색 → 그레이더 → 생성 → 환각 체커 ↻Agentic RAG: 2026년의 표준 아키텍처
기본 RAG는 "질문이 들어오면 무조건 검색"합니다. 하지만 "안녕하세요"라는 인사말에도 벡터 검색을 실행하는 것은 낭비입니다. 또한 검색된 문서가 실제로 관련 있는지, 생성된 답변이 문서에 근거하는지 검증하지 않습니다.
Agentic RAG 는 LLM을 단순한 텍스트 생성기가 아니라 추론 엔진 으로 활용합니다.
// src/lib/agentic-rag.ts
import { streamText, tool } from 'ai'
import { z } from 'zod'
import { searchSimilarChunks } from './retrieval'
// Agentic RAG: LLM이 검색 전략을 스스로 결정
export function createAgenticRAG() {
return {
// 1. 검색 도구 — 필요할 때만 호출
searchKnowledge: tool({
description:
'Search the knowledge base. Use this when the user asks about specific topics, policies, or technical details that require factual information.',
inputSchema: z.object({
query: z.string().describe('Search query - be specific and focused'),
}),
execute: async ({ query }) => {
const results = await searchSimilarChunks(query, 5)
return results
},
}),
// 2. 쿼리 재작성 도구 — 검색 결과가 부족할 때
rewriteQuery: tool({
description:
'Rewrite a search query to get better results. Use when initial search results are not relevant enough.',
inputSchema: z.object({
originalQuery: z.string(),
reason: z.string().describe('Why the original query needs rewriting'),
rewrittenQuery: z.string().describe('The improved query'),
}),
execute: async ({ rewrittenQuery }) => {
const results = await searchSimilarChunks(rewrittenQuery, 5)
return results
},
}),
// 3. 출처 인용 도구 — 답변의 근거 명시
citeSource: tool({
description: 'Cite a specific source that supports your answer',
inputSchema: z.object({
content: z.string().describe('The relevant excerpt from the source'),
relevance: z.string().describe('How this source supports the answer'),
}),
execute: async ({ content, relevance }) => {
return { cited: true, content, relevance }
},
}),
}
}
이 패턴의 핵심은 LLM이 루프를 돈다 는 것입니다. 첫 번째 검색 결과가 불충분하면 쿼리를 재작성하고 다시 검색합니다. stopWhen: stepCountIs(3)으로 최대 3번의 도구 호출을 허용하면, LLM은 검색 → 평가 → 재검색 → 생성의 지능적인 루프를 수행합니다.
하이브리드 검색: 키워드 + 시맨틱의 조합
벡터 검색만으로는 정확한 키워드 매칭이 어렵습니다. "에러 코드 E-4021"같은 쿼리는 시맨틱 유사도보다 정확한 텍스트 매칭이 중요합니다. 하이브리드 검색 은 키워드 검색(BM25)과 벡터 검색을 결합합니다.
// src/lib/hybrid-search.ts
import { embed } from 'ai'
import { db } from '@/db'
import { chunks } from '@/db/schema'
import { cosineDistance, desc, sql, or, ilike } from 'drizzle-orm'
export async function hybridSearch(query: string, topK = 5) {
const { embedding } = await embed({
model: 'openai/text-embedding-3-small',
value: query,
})
// 시맨틱 검색 점수
const semanticScore = sql<number>`1 - (${cosineDistance(chunks.embedding, embedding)})`
// 키워드 검색 점수 (PostgreSQL ts_rank)
const keywordScore = sql<number>`
ts_rank(
to_tsvector('simple', ${chunks.content}),
plainto_tsquery('simple', ${query})
)
`
// 하이브리드 점수: 시맨틱 70% + 키워드 30% (가중치 조절 가능)
const hybridScore = sql<number>`(${semanticScore} * 0.7 + ${keywordScore} * 0.3)`
const results = await db
.select({
id: chunks.id,
content: chunks.content,
semanticScore,
keywordScore,
hybridScore,
})
.from(chunks)
.orderBy(desc(hybridScore))
.limit(topK)
return results
}
시맨틱과 키워드 검색의 가중치(여기서는 7:3)는 사용 사례에 따라 조정합니다. 기술 문서에서는 키워드 비중을 높이고(6:4), 일반 대화에서는 시맨틱 비중을 높입니다(8:2).
벡터 데이터베이스 선택 가이드
| 데이터베이스 | 유형 | 추천 상황 | 장점 | 단점 |
|---|---|---|---|---|
| pgvector | PostgreSQL 확장 | 이미 Postgres 사용 중 | 별도 인프라 불필요, SQL 통합 | 전용 DB 대비 기능 제한 |
| Pinecone | 완전 관리형 | 운영 부담 최소화 | 제로 운영, 높은 확장성 | 비용, 벤더 종속 |
| Qdrant | 오픈소스 | 복잡한 메타데이터 필터링 | Rust 기반 고성능, 필터링 강점 | 자체 운영 필요 |
| Chroma | 오픈소스 | 빠른 프로토타이핑 | 간단한 API, 로컬 개발 | 대규모 프로덕션에 제한 |
실무 추천: 이미 PostgreSQL(Neon, Supabase 등)을 사용 중이라면 pgvector 로 시작하세요. 별도 벡터 DB를 운영할 필요 없이, 같은 데이터베이스에서 문서와 임베딩을 함께 관리할 수 있습니다. 최근 pgvectorscale 벤치마크는 전문 벡터 DB와 동등하거나 그 이상의 성능을 보여주었습니다.
프로덕션 체크리스트
RAG를 프로덕션에 배포할 때 고려해야 할 사항들을 정리했습니다.
1. 임베딩 캐싱
같은 문서를 반복 임베딩하지 않도록 해시 기반 캐싱을 적용합니다.
import { createHash } from 'crypto'
function contentHash(text: string): string {
return createHash('sha256').update(text).digest('hex')
}
// 이미 임베딩된 청크인지 해시로 확인
const existing = await db
.select()
.from(chunks)
.where(eq(chunks.contentHash, contentHash(newContent)))
.limit(1)
if (existing.length > 0) {
// 이미 인덱싱됨, 스킵
return existing[0]
}
2. 청크 크기 테스트
도메인에 맞는 최적 청크 크기를 실험으로 찾으세요. 일반적인 시작점은 400512 토큰이지만, 법률 문서는 더 크게(8001024), FAQ는 더 작게(200~300) 설정하는 것이 효과적입니다.
3. 메타데이터 활용
청크에 출처, 날짜, 카테고리 등의 메타데이터를 함께 저장하면 필터링과 인용에 활용할 수 있습니다.
4. 모니터링
검색 품질을 지속적으로 측정하세요. "검색된 문서 중 실제로 답변에 사용된 비율(precision)"과 "관련 문서를 놓치지 않는 비율(recall)"을 추적합니다.
벡터 데이터베이스 선택 가이드
pgvector
별도 인프라 불필요, SQL 통합
Pinecone
완전 관리형
Qdrant
고성능 필터링
마무리
RAG는 "LLM의 환각을 해결하는 가장 현실적인 방법"에서 "AI 애플리케이션의 기본 아키텍처"로 진화했습니다. 2026년의 Agentic RAG는 단순한 검색→생성 파이프라인이 아니라, LLM이 스스로 검색 전략을 결정하고, 결과를 평가하고, 필요하면 재검색하는 지능적인 루프입니다.
핵심을 정리하면:
- pgvector + Drizzle 로 시작하세요. 별도 벡터 DB 없이 기존 PostgreSQL에서 충분합니다.
- AI SDK의 embed/embedMany 로 임베딩을 생성하고, tool calling 으로 검색을 도구화하세요.
- 하이브리드 검색 (키워드 + 시맨틱)으로 검색 품질을 높이세요.
- 청킹은 400
512 토큰, 1020% 오버랩 에서 시작하고 도메인에 맞게 조정하세요.
가장 좋은 시작점은 Vercel의 AI SDK RAG Starter 템플릿입니다. pgvector, Drizzle, Next.js가 미리 설정되어 있어, 여기서 설명한 패턴을 바로 적용해볼 수 있습니다.