포트폴리오 사이트 v5를 개편하며 가장 큰 변화는 바로 RAG 기반 자기소개 AI 채팅 기능의 도입이었습니다!
이번 글에서는 LLM, RAG, Embedding을 활용하여 구현한 핵심 비즈니스 로직을 코드와 함께 상세히 공유합니다!
실제 동작은 듀듀 Dev에서 사용하실 수 있어요!🤨
들어가기 앞서,
포트폴리오 사이트 v5를 개편하면서 가장 큰 목표는 두 가지였어요!
- AI 기능 도입: 방문자와 직접 대화할 수 있는 자기소개 AI 채팅 기능 구현
- 간단한 포트폴리오 사이트로 변모: 복잡했던 이전 버전을 단순화하고, 핵심 기능에 집중
특히 첫 번째 목표인 AI 기능은 단순히 ChatGPT API를 호출하는 것이 아니라, 제 개인 데이터를 기반으로 정확한 답변을 제공할 수 있도록 RAG(Retrieval-Augmented Generation) 시스템을 직접 구현했습니다!
또한, vector DB와 Embedding AI를 활용해 질문자의 질문과 내 개인 데이터를 임베딩 기반으로 유사도 검색하여, 가장 관련성 높은 정보를 답변할 수 있도록 구현했습니다!
이번 글에서는 이러한 RAG 시스템의 핵심 비즈니스 로직을 코드와 함께 자세히 설명해드리려고 합니다!
프로젝트 아키텍처 개요
전체 시스템은 다음과 같은 구조로 구성되어 있어요!
dewdew_v5/
├── app/
│ ├── composables/chat/
│ │ └── useChat.ts # 클라이언트 스트리밍 처리
│ └── pages/ai/
│ └── index.vue # AI 채팅 페이지
├── server/
│ └── api/chat/
│ └── index.post.ts # Nuxt 서버 API (프록시)
└── supabase/
└── functions/
├── dewdew-rag-portfolio/ # RAG 메인 Edge Function
├── initialize-embeddings/ # 임베딩 초기화 Function
└── _shared/
├── rag.ts # RAG 핵심 로직
├── embeddings.ts # 임베딩 생성
├── embedding-manager.ts # 임베딩 관리
└── document-builder.ts # 문서 텍스트 변환
핵심 흐름
- 클라이언트에서 사용자 메시지 입력
Nuxt 서버 API가Supabase Edge Function으로 프록시Edge Function에서RAG시스템이 관련 데이터 검색- LLM이 검색된 데이터를 기반으로 답변 생성
스트리밍으로 실시간 응답 전달
RAG 시스템의 핵심: 하이브리드 검색 전략
RAG 시스템의 가장 중요한 부분은 바로 "어떻게 관련 데이터를 찾을 것인가"입니다!
저는 하이브리드 검색 전략을 채택했어요:
1차: 키워드 매칭(빠르고 정확)2차: 벡터 검색(의미 기반, 키워드 매칭 실패 시)
이렇게 하면 명확한 키워드가 있는 질문은 빠르게 처리하고, 추상적이거나 의미 기반 질문은 벡터 검색으로 처리할 수 있어요!
1. 키워드 매칭 로직
먼저 키워드 매칭부터 살펴볼게요!
// /supabase/functions/_shared/rag.ts
// 키워드 매칭 헬퍼
const matchKeywords = (text: string, keywords: string[]): boolean => {
const lowerText = text.toLowerCase()
return keywords.some(keyword => lowerText.includes(keyword))
}
// RAG: 질문 기반 데이터 검색 (하이브리드: 키워드 매칭 + 벡터 검색)
export const fetchRelevantData = async (query: string): Promise<RAGContext> => {
const supabase = getSupabaseClient()
const context: RAGContext = {}
const queryLower = query.toLowerCase()
// 1. 먼저 키워드 매칭 시도
const keywordMatched = await tryKeywordMatching(queryLower, context, supabase)
// 2. 키워드 매칭 성공하고 데이터가 충분하면 바로 반환
if (keywordMatched && hasRelevantData(context)) {
return context
}
// 3. 키워드 매칭 실패 또는 데이터 부족 → 벡터 검색 시도
try {
const queryEmbedding = await getEmbedding(query, 'openai')
const { data: matches, error } = await supabase.rpc('match_documents', {
query_embedding: `[${queryEmbedding.join(',')}]`,
match_threshold: 0.7,
match_count: 5,
})
if (error) {
console.error('Vector search error:', error)
}
else if (matches && matches.length > 0) {
// 벡터 검색 결과로 컨텍스트 보강
await enrichContextFromVectorMatches(context, matches, supabase)
}
}
catch (error) {
console.error('Vector search failed, using keyword matching results only:', error)
// 벡터 검색 실패해도 키워드 매칭 결과는 반환
}
return context
}
키워드 매칭은 다양한 질문 패턴을 감지하고 있어요.
// /supabase/functions/_shared/rag.ts
// 프로필
if (matchKeywords(queryLower, ['자기소개', '누구', '프로필', '소개', '이름', ...])) {
const { data } = await supabase
.schema('resume')
.from('profile')
.select('*')
.single<Profile>()
context.profile = data
matched = true
}
// 경력
if (matchKeywords(queryLower, ['경력', '회사', '일', '직장', ...])) {
const { data } = await supabase
.schema('resume')
.from('experience')
.select('*')
.order('order_index', { ascending: false })
.returns<Experience[]>()
context.experience = data
matched = true
}
// 스킬
if (matchKeywords(queryLower, ['스킬', '기여', '기술', '스택', ...])) {
const { data } = await supabase
.schema('resume')
.from('skills')
.select('*')
.order('order_index', { ascending: false })
.returns<Skill[]>()
context.skills = data
matched = true
}
이렇게 키워드 매칭을 먼저 시도하면, 명확한 질문에 대해서는 벡터 검색 비용 없이 빠르게 처리할 수 있어요!
2. 벡터 검색 로직
키워드 매칭이 실패하거나 데이터가 부족한 경우, 벡터 검색을 통해 의미 기반으로 관련 데이터를 찾아요!
// /supabase/functions/_shared/rag.ts
// 벡터 검색 결과를 컨텍스트로 변환
const enrichContextFromVectorMatches = async (
context: RAGContext,
matches: Array<{
document_type: string
document_id: string
similarity: number
metadata: any
}>,
supabase: ReturnType<typeof getSupabaseClient>,
): Promise<void> => {
// 유사도가 높은 순으로 정렬
const sortedMatches = matches.sort((a, b) => b.similarity - a.similarity)
// 이미 데이터가 있는지 확인하는 매핑
const hasDataMap: Record<string, () => boolean> = {
...,
experience: () => !!(context.experience && context.experience.length > 0),
skills: () => !!(context.skills && context.skills.length > 0),
project: () => !!(context.projects && context.projects.length > 0),
education: () => !!(context.education && context.education.length > 0),
...
}
// 필터링: 유사도 체크 및 이미 데이터가 있는지 확인
const validMatches = sortedMatches.filter((match) => {
// 유사도가 낮으면 스킵 (0.7 미만)
if (match.similarity < 0.7) {
return false
}
// 이미 키워드 매칭으로 데이터가 있으면 스킵
const hasData = hasDataMap[match.document_type]
if (hasData && hasData()) {
return false
}
return true
})
// 각 매치를 병렬로 처리
await Promise.all(validMatches.map(async (match) => {
try {
const handler = handlers[match.document_type]
if (handler) {
await handler(match)
}
}
catch (error) {
console.error(`Error enriching context for ${match.document_type}:`, error)
}
}))
}
핵심 포인트
- 유사도
0.7 이상만 사용 (너무 낮은 유사도는 노이즈) - 이미 키워드 매칭으로 데이터가 있으면
중복 방지 - 각 문서 타입별로
병렬 처리하여 성능 최적화
Embedding 생성 및 관리
벡터 검색을 위해서는 먼저 모든 문서를 임베딩으로 변환해야 해요!
1. 문서 텍스트 변환
데이터베이스의 구조화된 데이터를 임베딩 생성용 텍스트로 변환해요!
// /supabase/functions/_shared/document-builder.ts
/**
* 프로필 데이터를 텍스트로 변환
*/
export const buildProfileText = (profile: Profile): string => {
const fieldMap: Record<string, { label: string, value: string | null | undefined }> = {
full_name: { label: '이름', value: profile.full_name },
title: { label: '직책', value: profile.title },
bio: { label: '소개', value: profile.bio },
...
}
const parts: string[] = Object.entries(fieldMap)
.filter(([, { value }]) => value)
.map(([, { label, value }]) => `${label}: ${value}`)
if (profile.weaknesses && profile.weaknesses.length > 0) {
parts.push(`개선점: ${profile.weaknesses.join(', ')}`)
}
return parts.join('\n')
}
/**
* 경력 데이터를 텍스트로 변환
*/
export const buildExperienceText = (experiences: Experience[]): string => {
return experiences.map((exp) => {
const fieldMap: Record<string, { label: string, value: string | null | undefined }> = {
company_name: { label: '회사', value: exp.company_name },
position: { label: '직책', value: exp.position },
...
}
const parts: string[] = Object.entries(fieldMap)
.filter(([, { value }]) => !!value)
.map(([, { label, value }]) => `${label}: ${value}`)
parts.push(`기간: ${exp.start_date} ~ ${exp.end_date || '현재'}`)
return parts.join('\n')
}).join('\n\n')
}
2. 임베딩 생성 및 저장
변환된 텍스트를 OpenAI Embedding API로 벡터화하고 저장해요!
// /supabase/functions/_shared/embeddings.ts
/**
* OpenAI Embeddings API
*/
const getOpenAIEmbedding = async (text: string): Promise<number[]> => {
const apiKey = Deno.env.get('API_KEY')
if (!apiKey) {
throw new Error('API_KEY is required')
}
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
dimensions: 768, // 데이터베이스 벡터 차원에 맞춤
}),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`OpenAI API error: ${response.status} - ${error}`)
}
const data = await response.json()
return data.data[0].embedding
}
// /supabase/functions/_shared/embedding-manager.ts
/**
* 문서 임베딩 저장
*/
const saveDocumentEmbedding = async (
documentType: string,
documentId: string,
content: string,
embedding: number[],
metadata?: Record<string, any>,
): Promise<void> => {
const supabase = getSupabaseClient()
const { error } = await supabase
.schema('resume')
.from('document_embeddings')
.upsert({
document_type: documentType,
document_id: documentId,
content,
embedding: vectorToArray(embedding),
metadata: metadata || {},
updated_at: new Date().toISOString(),
}, {
onConflict: 'document_type,document_id',
})
if (error) {
console.error(`Failed to save embedding for ${documentType}:${documentId}`, error)
throw error
}
}
/**
* 프로필 임베딩 생성 및 저장
*/
export const createProfileEmbedding = async (profile: Profile): Promise<void> => {
const content = buildProfileText(profile)
const embedding = await getEmbedding(content, 'openai')
await saveDocumentEmbedding(
'profile',
profile.id,
content,
embedding,
{ full_name: profile.full_name, title: profile.title },
)
}
3. 임베딩 초기화
모든 문서의 임베딩을 한 번에 생성하는 Edge Function 기능이에요!
// /supabase/functions/embeddings/index.ts
import { initializeAllEmbeddings } from '../_shared/embedding-manager.ts'
serve(async (req: Request): Promise<Response> => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
if (req.method !== 'POST') {
return new Response(
JSON.stringify({ error: 'Method not allowed' }),
{ status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
)
}
try {
console.log('Starting embedding initialization...')
await initializeAllEmbeddings()
return new Response(
JSON.stringify({
success: true,
message: 'All embeddings initialized successfully',
}),
{
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
catch (error) {
console.error('Initialization error:', error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : 'Unknown error',
}),
{
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
},
)
}
})
중요한 점은, 개인 데이터가 변경될 때마다 반드시 embeddings Edge Function을 다시 실행해야 해요!
이유는 벡터 검색이 최신 데이터를 반영하려면 임베딩이 최신 상태여야 하기 때문입니다!
LLM 통합 및 스트리밍 응답
RAG로 검색한 데이터를 LLM에 전달하여 답변을 생성해요!
1. 시스템 프롬프트 구성
검색된 컨텍스트를 기반으로 시스템 프롬프트를 동적으로 생성해요!
// /supabase/functions/rag/index.ts
// 시스템 프롬프트 생성
const buildSystemPrompt = (
settings: AISettingsMap,
context: RAGContext,
componentType: ComponentType,
contextSummary: string = '',
): string => {
const ownerName = settings.owner_name ?? '이연주(듀듀)'
const personality = settings.personality ?? '친근하고 열정적인 Software Engineer'
const speakingStyle = settings.speaking_style ?? '존댓말이면서 전문적이고 친근하게'
const hasData = Object.values(context).some(v => v !== null && v !== undefined)
const dataContext = hasData
? `\n\n[내 정보 - 반드시 이 데이터 기반으로만 답변]\n${JSON.stringify(context, null, 2)}`
: ''
return `당신은 "${ownerName}"입니다. 포트폴리오 사이트에 방문한 사람과 직접 대화하고 있습니다.
═══════════════════════════════════════
[정체성]
═══════════════════════════════════════
...
═══════════════════════════════════════
[나의 성격 및 상세 소개]
═══════════════════════════════════════
...
═══════════════════════════════════════
[말투 스타일]
═══════════════════════════════════════
...
═══════════════════════════════════════
[응답 규칙]
═══════════════════════════════════════
1. 반드시 제공된 [내 정보] 데이터만 사용해서 답변하세요.
...
${dataContext}`
}
2. 스트리밍 응답 처리
실시간으로 답변을 스트리밍하여 사용자 경험을 향상시키고 있어요!
// /supabase/functions/dewdew-rag-portfolio/index.ts
// SSE 스트림 생성 (멀티 프로바이더 지원)
const createSSEStream = (
aiStream: ReadableStream<Uint8Array>,
componentType: ComponentType,
context: RAGContext,
provider: ModelProvider,
): ReadableStream<Uint8Array> => {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
return new ReadableStream({
async start(controller) {
// 1. 메타데이터 먼저 전송
const metadata: StreamMetadata = {
type: 'metadata',
componentType,
data: context,
}
const metadataStr = `data: ${JSON.stringify(metadata)}\n\n`
controller.enqueue(encoder.encode(metadataStr))
// 2. AI 스트림 처리 (프로바이더별)
const reader = aiStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk
.split('\n')
.filter(line => line.trim() !== '')
for (const line of lines) {
// OpenAI 형식
if (provider === 'openai' && line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr === '[DONE]') {
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
continue
}
try {
const parsed = JSON.parse(jsonStr)
const content = parsed.choices?.[0]?.delta?.content
if (content) {
const textChunk: StreamTextChunk = {
type: 'text',
content,
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(textChunk)}\n\n`),
)
}
}
catch {
// JSON 파싱 실패는 무시
}
}
}
}
}
catch (error) {
console.error('Stream processing error:', error)
controller.error(error)
}
finally {
reader.releaseLock()
controller.close()
}
},
})
}
3. 클라이언트 스트리밍 처리
클라이언트에서는 ReadableStream을 파싱하여 실시간으로 텍스트를 표시해요!
// /app/composables/chat/useChat.ts
// 스트리밍 파싱 공통 함수
const parseStreamResponse = async (
response: Response,
onText: (text: string) => void,
onMetadata?: (type: ComponentType, data: Record<string, any>) => void,
) => {
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('No reader available')
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
// 완전한 라인만 처리 (개행 문자로 분리)
const lines = buffer.split('\n')
// 마지막 불완전한 라인은 버퍼에 유지
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const jsonStr = line.slice(6).trim()
if (jsonStr === '' || jsonStr === '[DONE]') continue
try {
const parsed = JSON.parse(jsonStr) as StreamMetadata | StreamTextChunk
if (parsed.type === 'metadata' && onMetadata) {
const metadata = parsed as StreamMetadata
onMetadata(metadata.componentType, metadata.data)
}
if (parsed.type === 'text') {
onText((parsed as StreamTextChunk).content)
}
}
catch (error) {
// JSON 파싱 실패 시 로그 출력
console.warn('[useChat] ⚠️ JSON parse error:', error)
}
}
}
}
finally {
reader.releaseLock()
}
}
핵심 비즈니스 로직 요약
전체 시스템의 핵심 비즈니스 로직을 정리하면 다음과 같아요!
1. 하이브리드 검색 전략
| 단계 | 방법 | 목적 | 성능 |
|---|---|---|---|
| 1차 | 키워드 매칭 | 명확한 키워드가 있는 질문 빠르게 처리 | 빠름 (DB 쿼리만) |
| 2차 | 벡터 검색 | 의미 기반 질문 처리 | 느림 (임베딩 생성 + 벡터 검색) |
장점
- 명확한 질문은 키워드 매칭으로
빠르게처리 - 추상적인 질문은 벡터 검색으로
의미 기반처리 - 비용 최적화 (키워드 매칭 성공 시 벡터 검색 스킵)
2. 임베딩 관리 전략
| 작업 | 시점 | 방법 |
|---|---|---|
초기화 | 데이터 변경 시 | embeddings Edge Function 실행 |
저장 | 임베딩 생성 후 | document_embeddings 테이블에 upsert |
검색 | 질문 시 | PostgreSQL match_documents RPC 함수 |
주의사항
- 데이터 변경 시
반드시임베딩 재생성 필요 - 임베딩이 최신 상태가 아니면 벡터 검색 결과가 부정확할 수 있음
3. 스트리밍 응답 전략
| 단계 | 내용 | 목적 |
|---|---|---|
1. 메타데이터 전송 | 컴포넌트 타입 및 데이터 | UI 컴포넌트 렌더링 준비 |
2. 텍스트 스트리밍 | 실시간 텍스트 청크 | 사용자 경험 향상 |
3. 완료 신호 | [DONE] 전송 | 스트리밍 종료 처리 |
장점
- 사용자가 답변을 기다리는 시간 단축
- 실시간으로 텍스트가 표시되어 더 자연스러운 대화 느낌
마무리
이번 글에서는 포트폴리오 v5에 도입한 LLM + RAG + Embedding 기반 자기소개 AI의 핵심 비즈니스 로직을 코드와 함께 상세히 공유했습니다!
핵심 포인트
하이브리드 검색 전략: 키워드 매칭 + 벡터 검색으로 성능과 정확도 균형임베딩 관리: 데이터 변경 시 자동 재생성으로 최신성 유지스트리밍 응답: 실시간 텍스트 전달로 사용자 경험 향상
이러한 RAG 시스템을 통해 단순히 ChatGPT를 호출하는 것이 아니라, 제 개인 데이터를 기반으로 정확하고 자연스러운 답변을 제공할 수 있게 되었어요!
실제 동작은 듀듀 Dev에서 사용하실 수 있어요! 궁금한 점이 있으시면 언제든지 연락 주세요!
그럼 다음에도 좋은 글로 찾아뵐 수 있도록 할게요!
다음 글에서 봬요!

