Dewdew logo-mobile
Uses
Tech
방명록
포트폴리오

포트폴리오 v5 개편편 (LLM + RAG + Embedding AI를 곁들인...)

포트폴리오 v5 개편과 함께 도입한 RAG 기반 AI 채팅 시스템의 구현 과정을 공유합니다!

nuxt4 nuxt vue3 typescript rag llm embedding ai openai supabase vector search nuxt4 blog ai chat portfolio
dewdew

Dewdew

Dec 11, 2025

19 min read

cover

포트폴리오 사이트 v5를 개편하며 가장 큰 변화는 바로 RAG 기반 자기소개 AI 채팅 기능의 도입이었습니다!
이번 글에서는 LLM, RAG, Embedding을 활용하여 구현한 핵심 비즈니스 로직을 코드와 함께 상세히 공유합니다!
실제 동작은 듀듀 Dev에서 사용하실 수 있어요!🤨

들어가기 앞서,

포트폴리오 사이트 v5를 개편하면서 가장 큰 목표는 두 가지였어요!

  1. AI 기능 도입: 방문자와 직접 대화할 수 있는 자기소개 AI 채팅 기능 구현
  2. 간단한 포트폴리오 사이트로 변모: 복잡했던 이전 버전을 단순화하고, 핵심 기능에 집중

특히 첫 번째 목표인 AI 기능은 단순히 ChatGPT API를 호출하는 것이 아니라, 제 개인 데이터를 기반으로 정확한 답변을 제공할 수 있도록 RAG(Retrieval-Augmented Generation) 시스템을 직접 구현했습니다!
또한, vector DBEmbedding 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  # 문서 텍스트 변환

핵심 흐름

  1. 클라이언트에서 사용자 메시지 입력
  2. Nuxt 서버 APISupabase Edge Function으로 프록시
  3. Edge Function에서 RAG 시스템이 관련 데이터 검색
  4. LLM이 검색된 데이터를 기반으로 답변 생성
  5. 스트리밍으로 실시간 응답 전달

RAG 시스템의 핵심: 하이브리드 검색 전략

RAG 시스템의 가장 중요한 부분은 바로 "어떻게 관련 데이터를 찾을 것인가"입니다!

저는 하이브리드 검색 전략을 채택했어요:

  1. 1차: 키워드 매칭 (빠르고 정확)
  2. 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의 핵심 비즈니스 로직을 코드와 함께 상세히 공유했습니다!

핵심 포인트

  1. 하이브리드 검색 전략: 키워드 매칭 + 벡터 검색으로 성능과 정확도 균형
  2. 임베딩 관리: 데이터 변경 시 자동 재생성으로 최신성 유지
  3. 스트리밍 응답: 실시간 텍스트 전달로 사용자 경험 향상

이러한 RAG 시스템을 통해 단순히 ChatGPT를 호출하는 것이 아니라, 제 개인 데이터를 기반으로 정확하고 자연스러운 답변을 제공할 수 있게 되었어요!

실제 동작은 듀듀 Dev에서 사용하실 수 있어요! 궁금한 점이 있으시면 언제든지 연락 주세요!

그럼 다음에도 좋은 글로 찾아뵐 수 있도록 할게요!

다음 글에서 봬요!


참고 문서

Dewdew of the Internet © 2024