시리즈 목차 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 등 협업 앱의 핵심 패턴을 다룹니다.