Supabase 완전 정복 시리즈 15편 — 실전: AI 챗봇 서비스 (RAG + 스트리밍 + 문서 업로드)




시리즈 목차 1~12편 – Supabase 이론 완벽 가이드 13편 – 실전: 실시간 채팅 앱 14편 – 실전: SaaS 대시보드 15편 👉 실전: AI 챗봇 서비스 (현재 글) 16편 – 실전: Todo/협업툴


완성 기능 미리보기

✅ 인증 (Supabase Auth)
✅ 문서 업로드 → 자동 임베딩 (PDF / TXT / MD)
✅ pgvector 기반 RAG 검색
✅ GPT-4o 스트리밍 응답 (Vercel AI SDK)
✅ 대화 히스토리 저장 (Supabase DB)
✅ 멀티 챗봇 (봇별 지식베이스 분리)
✅ RLS — 본인 봇/문서만 접근
✅ 사용량 추적 (토큰 카운팅)
my-ai-chatbot/
├── src/
│   ├── app/
│   │   ├── (dashboard)/
│   │   │   ├── bots/
│   │   │   │   ├── page.tsx         ← 봇 목록
│   │   │   │   ├── new/page.tsx     ← 봇 생성
│   │   │   │   └── [botId]/
│   │   │   │       ├── page.tsx     ← 채팅 화면
│   │   │   │       └── docs/page.tsx← 문서 관리
│   │   ├── api/
│   │   │   ├── chat/route.ts        ← 스트리밍 채팅
│   │   │   └── ingest/route.ts      ← 문서 임베딩
│   │   └── auth/callback/route.ts
│   ├── components/chat/
│   │   ├── ChatInterface.tsx
│   │   ├── MessageBubble.tsx
│   │   └── DocumentUploader.tsx
│   └── lib/
│       ├── supabase/
│       ├── openai.ts
│       ├── embeddings.ts
│       └── database.types.ts
└── supabase/migrations/

1단계: 의존성 설치

npx create-next-app@latest my-ai-chatbot --typescript --tailwind --app
cd my-ai-chatbot

npm install @supabase/supabase-js @supabase/ssr
npm install ai @ai-sdk/openai openai
npm install pdf-parse
# .env.local
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-local-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-local-service-role-key
OPENAI_API_KEY=sk-...
NEXT_PUBLIC_APP_URL=http://localhost:3000

2단계: 데이터베이스 스키마

supabase migration new ai_chatbot_schema
-- supabase/migrations/[timestamp]_ai_chatbot_schema.sql

-- pgvector 확장 활성화
CREATE EXTENSION IF NOT EXISTS vector;

-- 챗봇 설정 테이블
CREATE TABLE bots (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  name          TEXT NOT NULL,
  description   TEXT,
  -- 시스템 프롬프트 (봇 개성 및 역할 정의)
  system_prompt TEXT NOT NULL DEFAULT
    '당신은 도움이 되는 AI 어시스턴트입니다. 제공된 문서 기반으로 정확하게 답변하세요. 문서에 없는 내용은 모른다고 솔직하게 말씀해주세요.',
  -- 모델 설정
  model         TEXT DEFAULT 'gpt-4o-mini',
  temperature   FLOAT DEFAULT 0.7,
  max_tokens    INTEGER DEFAULT 1000,
  created_at    TIMESTAMPTZ DEFAULT NOW()
);

-- 문서 원본 테이블
CREATE TABLE documents (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  bot_id      UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  file_path   TEXT,            -- Storage 경로
  file_type   TEXT,            -- 'pdf' | 'txt' | 'md' | 'url'
  source_url  TEXT,            -- URL 기반 문서인 경우
  char_count  INTEGER DEFAULT 0,
  status      TEXT DEFAULT 'processing' CHECK (status IN ('processing','ready','failed')),
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 문서 청크 + 임베딩 테이블 (핵심)
CREATE TABLE document_chunks (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  bot_id      UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
  content     TEXT NOT NULL,
  -- OpenAI text-embedding-3-small: 1536차원
  -- OpenAI text-embedding-3-large: 3072차원
  -- 절약을 위해 small 사용
  embedding   VECTOR(1536),
  -- 청크 메타데이터 (페이지 번호, 섹션 등)
  metadata    JSONB DEFAULT '{}',
  chunk_index INTEGER DEFAULT 0,
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 대화 세션 테이블
CREATE TABLE conversations (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  bot_id      UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
  user_id     UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  title       TEXT DEFAULT '새 대화',
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 메시지 테이블
CREATE TABLE chat_messages (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  role            TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
  content         TEXT NOT NULL,
  -- RAG에서 사용된 컨텍스트 청크 ID 목록
  sources         JSONB DEFAULT '[]',
  -- 토큰 사용량 추적
  prompt_tokens   INTEGER DEFAULT 0,
  completion_tokens INTEGER DEFAULT 0,
  created_at      TIMESTAMPTZ DEFAULT NOW()
);

-- 인덱스
-- HNSW: 고품질 근사 최근접 이웃 검색 (pgvector 0.5.0+)
CREATE INDEX idx_chunks_embedding ON document_chunks
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

CREATE INDEX idx_chunks_bot ON document_chunks(bot_id);
CREATE INDEX idx_messages_conversation ON chat_messages(conversation_id, created_at);
CREATE INDEX idx_conversations_user ON conversations(user_id, updated_at DESC);

-- updated_at 트리거
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER conversations_updated_at
  BEFORE UPDATE ON conversations
  FOR EACH ROW EXECUTE FUNCTION update_updated_at();

벡터 유사도 검색 함수

-- supabase/migrations/[timestamp]_vector_search_fn.sql

CREATE OR REPLACE FUNCTION match_document_chunks(
  query_embedding  VECTOR(1536),
  target_bot_id    UUID,
  match_threshold  FLOAT DEFAULT 0.7,
  match_count      INT   DEFAULT 5
)
RETURNS TABLE (
  id           UUID,
  content      TEXT,
  metadata     JSONB,
  document_id  UUID,
  similarity   FLOAT
)
LANGUAGE SQL STABLE
AS $$
  SELECT
    dc.id,
    dc.content,
    dc.metadata,
    dc.document_id,
    1 - (dc.embedding <=> query_embedding) AS similarity
  FROM document_chunks dc
  WHERE
    dc.bot_id = target_bot_id
    AND dc.embedding IS NOT NULL
    AND 1 - (dc.embedding <=> query_embedding) > match_threshold
  ORDER BY dc.embedding <=> query_embedding
  LIMIT match_count;
$$;

RLS 정책

-- supabase/migrations/[timestamp]_ai_chatbot_rls.sql

-- bots: 본인만 CRUD
ALTER TABLE bots ENABLE ROW LEVEL SECURITY;
CREATE POLICY "본인 봇 CRUD" ON bots FOR ALL TO authenticated
  USING ((SELECT auth.uid()) = user_id)
  WITH CHECK ((SELECT auth.uid()) = user_id);

-- documents
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "본인 문서 CRUD" ON documents FOR ALL TO authenticated
  USING ((SELECT auth.uid()) = user_id)
  WITH CHECK ((SELECT auth.uid()) = user_id);

-- document_chunks: 봇 소유자만 접근
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "봇 소유자 접근"
  ON document_chunks FOR ALL TO authenticated
  USING (
    EXISTS (SELECT 1 FROM bots WHERE id = bot_id AND user_id = (SELECT auth.uid()))
  );

-- conversations
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "본인 대화 CRUD" ON conversations FOR ALL TO authenticated
  USING ((SELECT auth.uid()) = user_id)
  WITH CHECK ((SELECT auth.uid()) = user_id);

-- chat_messages
ALTER TABLE chat_messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "본인 메시지 접근"
  ON chat_messages FOR ALL TO authenticated
  USING (
    EXISTS (
      SELECT 1 FROM conversations
      WHERE id = conversation_id AND user_id = (SELECT auth.uid())
    )
  );

3단계: 임베딩 유틸리티

// src/lib/openai.ts
import OpenAI from 'openai'

export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY!,
})

// 텍스트 → 임베딩 벡터
export async function generateEmbedding(text: string): Promise<number[]> {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',  // 1536차원, 저렴
    input: text.replace(/\n/g, ' '),  // 줄바꿈 제거
  })
  return response.data[0].embedding
}

// 배치 임베딩 (Rate Limit 고려)
export async function generateEmbeddingsBatch(
  texts: string[],
  batchSize = 100
): Promise<number[][]> {
  const embeddings: number[][] = []

  for (let i = 0; i < texts.length; i += batchSize) {
    const batch = texts.slice(i, i + batchSize)
    const response = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: batch.map(t => t.replace(/\n/g, ' ')),
    })
    embeddings.push(...response.data.map(d => d.embedding))

    // Rate Limit 방지: 배치 사이 50ms 대기
    if (i + batchSize < texts.length) {
      await new Promise(r => setTimeout(r, 50))
    }
  }

  return embeddings
}
// src/lib/embeddings.ts
// 텍스트 청킹 유틸리티

interface Chunk {
  content: string
  chunkIndex: number
  metadata: Record<string, unknown>
}

// 재귀적 텍스트 분할 (문단 → 문장 → 단어 순서로 분할)
export function splitTextIntoChunks(
  text: string,
  options: { chunkSize?: number; overlap?: number } = {}
): Chunk[] {
  const { chunkSize = 1000, overlap = 200 } = options

  // 단락 기준으로 먼저 분할
  const paragraphs = text.split(/\n\n+/)
  const chunks: Chunk[] = []
  let currentChunk = ''
  let chunkIndex = 0

  for (const paragraph of paragraphs) {
    const combined = currentChunk ? `${currentChunk}\n\n${paragraph}` : paragraph

    if (combined.length <= chunkSize) {
      currentChunk = combined
    } else {
      // 현재 청크 저장
      if (currentChunk) {
        chunks.push({
          content: currentChunk.trim(),
          chunkIndex: chunkIndex++,
          metadata: { chars: currentChunk.length },
        })
        // 오버랩: 이전 청크의 끝 overlap 글자를 다음 청크 시작에 포함
        const words = currentChunk.split(' ')
        const overlapWords = words.slice(
          Math.max(0, words.length - Math.floor(overlap / 5))
        )
        currentChunk = overlapWords.join(' ') + '\n\n' + paragraph
      } else {
        // 단락 자체가 청크 크기 초과 → 문장 단위로 분할
        const sentences = paragraph.match(/[^.!?]+[.!?]+/g) ?? [paragraph]
        for (const sentence of sentences) {
          if ((currentChunk + sentence).length <= chunkSize) {
            currentChunk += sentence
          } else {
            if (currentChunk) {
              chunks.push({
                content: currentChunk.trim(),
                chunkIndex: chunkIndex++,
                metadata: { chars: currentChunk.length },
              })
            }
            currentChunk = sentence
          }
        }
      }
    }
  }

  if (currentChunk.trim()) {
    chunks.push({
      content: currentChunk.trim(),
      chunkIndex: chunkIndex++,
      metadata: { chars: currentChunk.length },
    })
  }

  return chunks.filter(c => c.content.length > 50) // 너무 짧은 청크 제거
}

// PDF에서 텍스트 추출 (서버 사이드)
export async function extractTextFromPdf(buffer: Buffer): Promise<string> {
  const pdfParse = (await import('pdf-parse')).default
  const data = await pdfParse(buffer)
  return data.text
}

4단계: 문서 인제스트 API

// src/app/api/ingest/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { generateEmbeddingsBatch } from '@/lib/openai'
import { splitTextIntoChunks, extractTextFromPdf } from '@/lib/embeddings'

export async function POST(req: Request) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const formData = await req.formData()
  const file   = formData.get('file') as File | null
  const botId  = formData.get('botId') as string
  const docUrl = formData.get('url') as string | null

  let text = ''
  let fileName = ''
  let fileType = ''
  let filePath: string | null = null

  // ─── 파일 처리 ───────────────────────────────────────────────
  if (file) {
    fileName = file.name
    fileType = file.name.endsWith('.pdf') ? 'pdf' : 'txt'
    const buffer = Buffer.from(await file.arrayBuffer())

    // Storage에 원본 저장
    const storagePath = `${user.id}/${botId}/${Date.now()}_${file.name}`
    const { error: uploadError } = await supabase.storage
      .from('bot-documents')
      .upload(storagePath, buffer, { contentType: file.type })

    if (uploadError) {
      return NextResponse.json({ error: uploadError.message }, { status: 500 })
    }
    filePath = storagePath

    // 텍스트 추출
    if (fileType === 'pdf') {
      text = await extractTextFromPdf(buffer)
    } else {
      text = buffer.toString('utf-8')
    }

  } else if (docUrl) {
    // URL에서 텍스트 추출
    fileName = docUrl
    fileType = 'url'
    const html = await fetch(docUrl).then(r => r.text())
    // 간단한 HTML 태그 제거 (프로덕션에서는 cheerio 등 사용 권장)
    text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
  } else {
    return NextResponse.json({ error: '파일 또는 URL이 필요합니다.' }, { status: 400 })
  }

  // ─── DB에 문서 레코드 생성 ────────────────────────────────────
  const { data: document, error: docError } = await supabase
    .from('documents')
    .insert({
      bot_id: botId,
      user_id: user.id,
      name: fileName,
      file_path: filePath,
      file_type: fileType,
      source_url: docUrl,
      char_count: text.length,
      status: 'processing',
    })
    .select()
    .single()

  if (docError) {
    return NextResponse.json({ error: docError.message }, { status: 500 })
  }

  try {
    // ─── 청킹 ─────────────────────────────────────────────────
    const chunks = splitTextIntoChunks(text, {
      chunkSize: 1000,
      overlap: 200,
    })

    console.log(`[ingest] ${chunks.length}개 청크 생성됨 (문서: ${fileName})`)

    // ─── 배치 임베딩 생성 ─────────────────────────────────────
    const embeddings = await generateEmbeddingsBatch(
      chunks.map(c => c.content)
    )

    // ─── DB에 청크 + 임베딩 저장 ──────────────────────────────
    const chunkRecords = chunks.map((chunk, i) => ({
      document_id: document.id,
      bot_id: botId,
      content: chunk.content,
      embedding: JSON.stringify(embeddings[i]),  // pgvector 형식
      metadata: { ...chunk.metadata, document_name: fileName },
      chunk_index: chunk.chunkIndex,
    }))

    // 50개씩 배치 삽입
    for (let i = 0; i < chunkRecords.length; i += 50) {
      const batch = chunkRecords.slice(i, i + 50)
      const { error: insertError } = await supabase
        .from('document_chunks')
        .insert(batch)

      if (insertError) throw insertError
    }

    // 문서 상태 업데이트
    await supabase
      .from('documents')
      .update({ status: 'ready' })
      .eq('id', document.id)

    return NextResponse.json({
      success: true,
      documentId: document.id,
      chunksCreated: chunks.length,
    })

  } catch (error) {
    // 실패 시 상태 업데이트
    await supabase
      .from('documents')
      .update({ status: 'failed' })
      .eq('id', document.id)

    return NextResponse.json({ error: String(error) }, { status: 500 })
  }
}

5단계: 스트리밍 채팅 API (RAG 파이프라인)

// src/app/api/chat/route.ts
import { openai as aiSdkOpenai } from '@ai-sdk/openai'
import { streamText, type CoreMessage } from 'ai'
import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/openai'

export const runtime = 'nodejs'  // Edge 런타임도 가능

export async function POST(req: Request) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return new Response('Unauthorized', { status: 401 })

  const {
    messages,
    botId,
    conversationId,
  }: {
    messages: CoreMessage[]
    botId: string
    conversationId: string | null
  } = await req.json()

  // ─── 봇 설정 조회 ─────────────────────────────────────────────
  const { data: bot } = await supabase
    .from('bots')
    .select('*')
    .eq('id', botId)
    .eq('user_id', user.id)
    .single()

  if (!bot) return new Response('Bot not found', { status: 404 })

  // ─── 최신 유저 메시지 ──────────────────────────────────────────
  const lastUserMessage = messages.filter(m => m.role === 'user').pop()
  const query = typeof lastUserMessage?.content === 'string'
    ? lastUserMessage.content
    : ''

  // ─── RAG: 관련 청크 검색 ───────────────────────────────────────
  let contextText = ''
  let sources: Array<{ id: string; content: string; documentId: string }> = []

  if (query) {
    const queryEmbedding = await generateEmbedding(query)

    const { data: chunks } = await supabase.rpc('match_document_chunks', {
      query_embedding: queryEmbedding,
      target_bot_id: botId,
      match_threshold: 0.65,
      match_count: 5,
    })

    if (chunks && chunks.length > 0) {
      sources = chunks.map((c: any) => ({
        id: c.id,
        content: c.content,
        documentId: c.document_id,
      }))

      contextText = chunks
        .map((c: any, i: number) => `[문서 ${i + 1}]\n${c.content}`)
        .join('\n\n---\n\n')
    }
  }

  // ─── 시스템 프롬프트 구성 ──────────────────────────────────────
  const systemPrompt = contextText
    ? `${bot.system_prompt}

다음은 관련 문서 내용입니다:

${contextText}

위 문서를 참고하여 사용자의 질문에 답변하세요. 문서에 없는 내용은 "문서에서 확인할 수 없습니다"라고 명시하세요.`
    : bot.system_prompt

  // ─── 대화 세션 생성 또는 조회 ──────────────────────────────────
  let convId = conversationId
  if (!convId) {
    const { data: conv } = await supabase
      .from('conversations')
      .insert({
        bot_id: botId,
        user_id: user.id,
        title: query.slice(0, 50) || '새 대화',
      })
      .select()
      .single()
    convId = conv?.id
  }

  // ─── 유저 메시지 저장 ─────────────────────────────────────────
  if (convId && query) {
    await supabase.from('chat_messages').insert({
      conversation_id: convId,
      role: 'user',
      content: query,
    })
  }

  // ─── Vercel AI SDK 스트리밍 응답 ───────────────────────────────
  const result = streamText({
    model: aiSdkOpenai(bot.model),
    system: systemPrompt,
    messages: messages.slice(-10),  // 최근 10개 메시지로 컨텍스트 윈도우 제한
    temperature: bot.temperature,
    maxTokens: bot.max_tokens,
    onFinish: async ({ text, usage }) => {
      // 응답 완료 후 DB에 어시스턴트 메시지 저장
      if (convId) {
        await supabase.from('chat_messages').insert({
          conversation_id: convId,
          role: 'assistant',
          content: text,
          sources: sources.map(s => ({ id: s.id, documentId: s.documentId })),
          prompt_tokens: usage.promptTokens,
          completion_tokens: usage.completionTokens,
        })

        // 대화 updated_at 갱신
        await supabase
          .from('conversations')
          .update({ updated_at: new Date().toISOString() })
          .eq('id', convId)
      }
    },
  })

  // 헤더로 conversationId와 sources 전달
  const response = result.toDataStreamResponse()
  const headers = new Headers(response.headers)
  headers.set('X-Conversation-Id', convId ?? '')
  headers.set('X-Sources', JSON.stringify(sources.map(s => ({
    id: s.id,
    preview: s.content.slice(0, 100),
  }))))

  return new Response(response.body, {
    status: response.status,
    headers,
  })
}

6단계: 채팅 인터페이스 컴포넌트

// src/components/chat/ChatInterface.tsx
'use client'
import { useChat } from 'ai/react'
import { useRef, useState, useEffect } from 'react'
import { MessageBubble } from './MessageBubble'

interface Props {
  botId: string
  botName: string
  initialConversationId?: string
}

export function ChatInterface({ botId, botName, initialConversationId }: Props) {
  const [conversationId, setConversationId] = useState(initialConversationId ?? null)
  const [sources, setSources] = useState<{ id: string; preview: string }[]>([])
  const bottomRef = useRef<HTMLDivElement>(null)

  const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
    api: '/api/chat',
    body: { botId, conversationId },
    onResponse: (response) => {
      // 응답 헤더에서 conversationId 추출
      const newConvId = response.headers.get('X-Conversation-Id')
      if (newConvId && !conversationId) {
        setConversationId(newConvId)
      }
      const sourcesHeader = response.headers.get('X-Sources')
      if (sourcesHeader) {
        try { setSources(JSON.parse(sourcesHeader)) }
        catch {}
      }
    },
  })

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages.length])

  return (
    <div className="flex flex-col h-full">
      {/* 헤더 */}
      <div className="px-6 py-4 border-b bg-white">
        <h2 className="font-semibold">🤖 {botName}</h2>
        {conversationId && (
          <p className="text-xs text-gray-400 mt-0.5">대화 ID: {conversationId.slice(0, 8)}...</p>
        )}
      </div>

      {/* 메시지 목록 */}
      <div className="flex-1 overflow-y-auto p-6 space-y-4 bg-gray-50">
        {messages.length === 0 && (
          <div className="text-center text-gray-400 mt-16">
            <div className="text-4xl mb-3">🤖</div>
            <p className="font-medium">무엇이든 물어보세요!</p>
            <p className="text-sm mt-1">업로드한 문서 기반으로 답변합니다.</p>
          </div>
        )}

        {messages.map((msg) => (
          <MessageBubble key={msg.id} message={msg} />
        ))}

        {isLoading && (
          <div className="flex gap-3">
            <div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center text-white text-sm">
              🤖
            </div>
            <div className="bg-white rounded-2xl px-4 py-3 shadow-sm">
              <div className="flex gap-1">
                {[0,1,2].map(i => (
                  <div key={i} className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
                    style={{ animationDelay: `${i * 0.15}s` }} />
                ))}
              </div>
            </div>
          </div>
        )}

        {error && (
          <div className="bg-red-50 text-red-600 rounded-xl px-4 py-3 text-sm">
            오류가 발생했습니다: {error.message}
          </div>
        )}

        <div ref={bottomRef} />
      </div>

      {/* 참조 소스 표시 */}
      {sources.length > 0 && (
        <div className="px-6 py-3 bg-blue-50 border-t border-blue-100">
          <p className="text-xs font-medium text-blue-600 mb-1">📎 참조 문서 ({sources.length}개)</p>
          <div className="flex gap-2 flex-wrap">
            {sources.map((s, i) => (
              <span key={s.id} className="text-xs bg-white border border-blue-200 rounded px-2 py-1 text-blue-700">
                [{i + 1}] {s.preview}...
              </span>
            ))}
          </div>
        </div>
      )}

      {/* 입력창 */}
      <form onSubmit={handleSubmit} className="p-4 border-t bg-white">
        <div className="flex gap-3">
          <input
            value={input}
            onChange={handleInputChange}
            placeholder="질문을 입력하세요..."
            disabled={isLoading}
            className="flex-1 border border-gray-200 rounded-xl px-4 py-3 text-sm outline-none focus:border-indigo-400 disabled:bg-gray-50"
          />
          <button
            type="submit"
            disabled={isLoading || !input.trim()}
            className="bg-indigo-500 text-white rounded-xl px-6 py-3 text-sm font-medium hover:bg-indigo-600 disabled:opacity-50 transition-colors"
          >
            전송
          </button>
        </div>
      </form>
    </div>
  )
}
// src/components/chat/MessageBubble.tsx
import type { Message } from 'ai'
import ReactMarkdown from 'react-markdown'

export function MessageBubble({ message }: { message: Message }) {
  const isUser = message.role === 'user'

  return (
    <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
      <div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm shrink-0 ${
        isUser ? 'bg-gray-600 text-white' : 'bg-indigo-500 text-white'
      }`}>
        {isUser ? '나' : '🤖'}
      </div>

      <div className={`max-w-[75%] rounded-2xl px-4 py-3 text-sm shadow-sm ${
        isUser
          ? 'bg-gray-700 text-white rounded-tr-none'
          : 'bg-white text-gray-800 rounded-tl-none'
      }`}>
        {isUser ? (
          <p className="whitespace-pre-wrap">{message.content as string}</p>
        ) : (
          <div className="prose prose-sm max-w-none">
            <ReactMarkdown>{message.content as string}</ReactMarkdown>
          </div>
        )}
      </div>
    </div>
  )
}

7단계: 문서 업로더 컴포넌트

// src/components/chat/DocumentUploader.tsx
'use client'
import { useState, useRef } from 'react'
import { useRouter } from 'next/navigation'

interface Props { botId: string }

interface UploadedDoc {
  name: string
  status: 'uploading' | 'ready' | 'failed'
  chunksCreated?: number
}

export function DocumentUploader({ botId }: Props) {
  const [docs, setDocs] = useState<UploadedDoc[]>([])
  const [urlInput, setUrlInput] = useState('')
  const fileRef = useRef<HTMLInputElement>(null)
  const router = useRouter()

  const ingest = async (formData: FormData) => {
    const fileName = (formData.get('file') as File)?.name || (formData.get('url') as string)
    const doc: UploadedDoc = { name: fileName, status: 'uploading' }
    setDocs(prev => [...prev, doc])

    try {
      const res = await fetch('/api/ingest', { method: 'POST', body: formData })
      const data = await res.json()

      if (data.success) {
        setDocs(prev => prev.map(d =>
          d.name === fileName
            ? { ...d, status: 'ready', chunksCreated: data.chunksCreated }
            : d
        ))
        router.refresh()
      } else {
        setDocs(prev => prev.map(d =>
          d.name === fileName ? { ...d, status: 'failed' } : d
        ))
      }
    } catch {
      setDocs(prev => prev.map(d =>
        d.name === fileName ? { ...d, status: 'failed' } : d
      ))
    }
  }

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files ?? [])
    files.forEach(file => {
      const formData = new FormData()
      formData.append('file', file)
      formData.append('botId', botId)
      ingest(formData)
    })
    if (fileRef.current) fileRef.current.value = ''
  }

  const handleUrlSubmit = () => {
    if (!urlInput.trim()) return
    const formData = new FormData()
    formData.append('url', urlInput.trim())
    formData.append('botId', botId)
    ingest(formData)
    setUrlInput('')
  }

  return (
    <div className="p-6 space-y-6">
      <h2 className="font-semibold text-lg">📄 문서 업로드</h2>

      {/* 파일 드롭존 */}
      <div
        onClick={() => fileRef.current?.click()}
        className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-indigo-400 transition-colors"
      >
        <div className="text-3xl mb-2">📁</div>
        <p className="font-medium text-gray-700">파일을 클릭하거나 드래그하여 업로드</p>
        <p className="text-sm text-gray-400 mt-1">PDF, TXT, MD 지원</p>
        <input
          ref={fileRef}
          type="file"
          accept=".pdf,.txt,.md"
          multiple
          className="hidden"
          onChange={handleFileChange}
        />
      </div>

      {/* URL 입력 */}
      <div className="flex gap-2">
        <input
          value={urlInput}
          onChange={e => setUrlInput(e.target.value)}
          placeholder="웹 페이지 URL 입력..."
          className="flex-1 border border-gray-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:border-indigo-400"
          onKeyDown={e => e.key === 'Enter' && handleUrlSubmit()}
        />
        <button
          onClick={handleUrlSubmit}
          className="bg-indigo-500 text-white rounded-xl px-4 py-2.5 text-sm hover:bg-indigo-600"
        >
          추가
        </button>
      </div>

      {/* 업로드 진행 상황 */}
      {docs.length > 0 && (
        <div className="space-y-2">
          <h3 className="text-sm font-medium text-gray-600">업로드 현황</h3>
          {docs.map((doc, i) => (
            <div key={i} className="flex items-center gap-3 bg-gray-50 rounded-lg px-4 py-3">
              <span className="text-lg">
                {doc.status === 'uploading' ? '⏳' :
                 doc.status === 'ready'     ? '✅' : '❌'}
              </span>
              <div className="flex-1">
                <p className="text-sm font-medium truncate">{doc.name}</p>
                <p className="text-xs text-gray-500">
                  {doc.status === 'uploading' ? '처리 중...' :
                   doc.status === 'ready'     ? `완료 (${doc.chunksCreated}개 청크)` : '실패'}
                </p>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

8단계: 봇 채팅 페이지 조립

// src/app/(dashboard)/bots/[botId]/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { ChatInterface } from '@/components/chat/ChatInterface'
import Link from 'next/link'

interface Props {
  params: Promise<{ botId: string }>
}

export default async function BotChatPage({ params }: Props) {
  const { botId } = await params
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) redirect('/login')

  const { data: bot } = await supabase
    .from('bots')
    .select('*, documents(count)')
    .eq('id', botId)
    .eq('user_id', user.id)
    .single()

  if (!bot) notFound()

  const docCount = (bot.documents as any)?.[0]?.count ?? 0

  return (
    <div className="flex h-screen">
      {/* 봇 정보 사이드바 */}
      <div className="w-64 bg-gray-50 border-r p-4 flex flex-col">
        <div className="mb-6">
          <h3 className="font-bold">{bot.name}</h3>
          {bot.description && (
            <p className="text-xs text-gray-500 mt-1">{bot.description}</p>
          )}
        </div>

        <div className="bg-white rounded-xl border p-3 mb-4">
          <p className="text-xs text-gray-500">학습된 문서</p>
          <p className="text-2xl font-bold">{docCount}개</p>
        </div>

        <Link
          href={`/bots/${botId}/docs`}
          className="text-center py-2 bg-indigo-500 text-white rounded-xl text-sm hover:bg-indigo-600"
        >
          📄 문서 관리
        </Link>
      </div>

      {/* 채팅 영역 */}
      <div className="flex-1">
        <ChatInterface botId={botId} botName={bot.name} />
      </div>
    </div>
  )
}

9단계: Storage 버킷 설정

-- supabase/migrations/[timestamp]_bot_storage.sql

INSERT INTO storage.buckets (id, name, public)
VALUES ('bot-documents', 'bot-documents', false);

-- 본인 봇의 문서만 업로드/조회
CREATE POLICY "봇 소유자 업로드"
  ON storage.objects FOR INSERT TO authenticated
  WITH CHECK (
    bucket_id = 'bot-documents'
    AND (storage.foldername(name))[1] = (SELECT auth.uid())::TEXT
  );

CREATE POLICY "봇 소유자 조회"
  ON storage.objects FOR SELECT TO authenticated
  USING (
    bucket_id = 'bot-documents'
    AND (storage.foldername(name))[1] = (SELECT auth.uid())::TEXT
  );

10단계: 운영 팁

임베딩 비용 최적화

// text-embedding-3-small vs text-embedding-3-large 비교
// small:  1536차원, $0.020/1M tokens  ← 대부분의 경우 충분
// large:  3072차원, $0.130/1M tokens  ← 고정밀 필요 시

// 실제 비용 계산 예시
// 100페이지 PDF ≈ 50,000 토큰
// small 임베딩 비용: 50,000 / 1,000,000 * $0.020 = $0.001 (약 1원)

청크 크기 가이드

짧은 문서 (FAQ, 제품 사양)  → chunkSize: 500,  overlap: 100
일반 문서 (블로그, 매뉴얼)  → chunkSize: 1000, overlap: 200  ← 기본값
긴 기술 문서 (API 문서)     → chunkSize: 1500, overlap: 300

검색 정확도 향상

// match_threshold 조정 가이드
// 0.5: 관련성이 낮아도 포함 (할루시네이션 위험 증가)
// 0.7: 기본값 (균형)
// 0.8: 높은 정확도 (관련 문서 없으면 빈 컨텍스트)

로컬 실행

# Edge Function 시크릿 설정
echo "OPENAI_API_KEY=sk-..." > supabase/functions/.env

# 마이그레이션 적용
supabase db reset

# 타입 재생성
npm run types

# 개발 서버
npm run dev

마치며

약 600줄의 코드로 프로덕션급 RAG 챗봇 서비스를 완성했습니다. pgvector의 HNSW 인덱스로 수백만 청크에서도 밀리초 단위 검색이 가능하고, Vercel AI SDK의 useChat 훅과 streamText로 스트리밍 응답을 간단하게 구현했습니다.

마지막 16편에서는 Todo/협업툴을 구축합니다. Realtime, RLS, 낙관적 업데이트(Optimistic Update), Drag & Drop 등 협업 앱의 핵심 패턴을 다룹니다.




댓글 남기기