// server/supabase/functions/embed-chunks/index.ts // 특정 document의 청크들을 OpenAI 임베딩으로 변환하여 knowledge_chunks.embedding 컬럼 업데이트. // // 요청: // POST { document_id: "uuid" } // 응답: // { embedded: number, errors: string[] } // // 환경변수: // OPENAI_API_KEY (text-embedding-3-small 사용) import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' interface EmbedRequest { document_id: string } interface OpenAIEmbeddingResponse { data: Array<{ embedding: number[]; index: number }> model: string usage: { prompt_tokens: number; total_tokens: number } } // @ts-expect-error — Deno 런타임 전역 Deno.serve(async (req: Request) => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight if (req.method !== 'POST') { return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } try { const user = await requireUser(req) const body = (await req.json()) as EmbedRequest if (!body.document_id) { return new Response(JSON.stringify({ error: 'document_id 필요' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } // @ts-expect-error — Deno.env const openaiKey = Deno.env.get('OPENAI_API_KEY') ?? '' if (!openaiKey) { return new Response( JSON.stringify({ error: 'openai_not_configured', message: 'OPENAI_API_KEY 미설정' }), { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const serviceClient = createServiceRoleClient() // 문서 소유권 확인 const { data: doc } = await serviceClient .from('knowledge_documents') .select('id, user_id') .eq('id', body.document_id) .maybeSingle() if (!doc || (doc.user_id as string) !== user.id) { return new Response( JSON.stringify({ error: 'forbidden' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // 임베딩 대상 청크 조회 (embedding IS NULL인 것만) const { data: chunks, error: chunksErr } = await serviceClient .from('knowledge_chunks') .select('id, content') .eq('document_id', body.document_id) .is('embedding', null) if (chunksErr) { throw new Error(`청크 조회 실패: ${chunksErr.message}`) } if (!chunks || chunks.length === 0) { return new Response(JSON.stringify({ embedded: 0, errors: [] }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } // OpenAI 임베딩 API 배치 호출 (한 번에 최대 100개) const errors: string[] = [] let embedded = 0 const batchSize = 100 for (let i = 0; i < chunks.length; i += batchSize) { const batch = chunks.slice(i, i + batchSize) as Array<{ id: string; content: string }> try { const resp = await fetch('https://api.openai.com/v1/embeddings', { method: 'POST', headers: { Authorization: `Bearer ${openaiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: 'text-embedding-3-small', input: batch.map((c) => c.content), dimensions: 1536 }) }) if (!resp.ok) { const errText = await resp.text() errors.push(`OpenAI ${resp.status}: ${errText.slice(0, 200)}`) continue } const data = (await resp.json()) as OpenAIEmbeddingResponse // 각 청크에 embedding 업데이트 for (const item of data.data) { const chunk = batch[item.index] const { error: updateErr } = await serviceClient .from('knowledge_chunks') .update({ embedding: item.embedding }) .eq('id', chunk.id) if (updateErr) { errors.push(`chunk ${chunk.id}: ${updateErr.message}`) } else { embedded++ } } } catch (e) { errors.push(e instanceof Error ? e.message : String(e)) } } // 문서 indexed 플래그 업데이트 await serviceClient .from('knowledge_documents') .update({ indexed: true, indexed_at: new Date().toISOString() }) .eq('id', body.document_id) return new Response(JSON.stringify({ embedded, errors }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } catch (err) { if (err && typeof err === 'object' && 'status' in err && 'message' in err) { return authErrorResponse(err as AuthError, corsHeaders) } const message = err instanceof Error ? err.message : 'Unknown error' return new Response(JSON.stringify({ error: message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } })