Supabase 완전 정복 시리즈 8편 — AI & 벡터 검색: pgvector로 시맨틱 검색과 RAG 구현하기




시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 – RLS 보안 — Row Level Security 완벽 가이드 5편 – 실시간 기능(Realtime) — Broadcast, Presence, Postgres Changes 6편 – 스토리지(Storage) — 파일 업로드, 이미지 최적화, CDN 7편 – Edge Functions — Deno 기반 서버리스 함수 완벽 가이드 8편 👉 AI & 벡터 검색 — pgvector로 시맨틱 검색과 RAG 구현 (현재 글) 9편 – Cron Jobs & Queues — 백그라운드 작업 자동화 …


들어가며

“AI 검색”이라고 하면 별도의 벡터 데이터베이스(Pinecone, Weaviate, Qdrant 등)를 떠올리는 분이 많습니다. 하지만 Supabase는 PostgreSQL에 pgvector 확장을 내장해, 이미 사용 중인 DB에서 바로 벡터 검색을 구현할 수 있습니다.

이것이 의미하는 바는 큽니다.

  • 별도 벡터 DB 비용 없음
  • 기존 데이터와 벡터를 JOIN으로 결합 가능
  • RLS로 사용자별 벡터 검색 접근 제어
  • PostgreSQL의 모든 기능(트랜잭션, 인덱스, FTS)을 함께 활용

이번 편에서 다룰 내용:

  • 벡터 임베딩(Vector Embedding) 개념 정리
  • pgvector 활성화 & 스키마 설계
  • HNSW vs IVFFlat 인덱스 전략
  • 거리 연산자 (코사인 / L2 / 내적)
  • OpenAI Embeddings로 벡터 생성 & 저장
  • RPC 함수로 유사도 검색
  • RAG(검색 증강 생성) 파이프라인 구축
  • 자동 임베딩 갱신 (트리거 + Edge Function)
  • Next.js App Router 완성 예시
  • 하이브리드 검색 (벡터 + 키워드)

벡터 임베딩이란?

임베딩(Embedding)은 텍스트(또는 이미지, 음성)를 **숫자 배열(벡터)**로 변환한 것입니다. 의미가 비슷한 문장은 벡터 공간에서 가까운 위치에 놓입니다.

"강아지가 공원에서 뛴다"   → [0.23, -0.71, 0.45, ...]
"개가 들판에서 달린다"     → [0.25, -0.69, 0.43, ...]  ← 가까움
"내일 주식 시장 전망"      → [-0.82, 0.11, -0.63, ...] ← 멀리 있음

이 특성을 이용하면 키워드 매칭이 아닌 의미 기반 검색이 가능합니다.

주요 임베딩 모델 비교

모델차원특징
text-embedding-3-small (OpenAI)1,536저비용, 고품질, 권장
text-embedding-3-large (OpenAI)3,072최고 품질, 비용 높음
text-embedding-ada-002 (OpenAI)1,536구버전, 마이그레이션 권장
gte-small (오픈소스)384로컬 실행 가능, 무료
nomic-embed-text (오픈소스)768무료, 고성능

💡 2025년 권장: text-embedding-3-small이 비용 대비 성능이 가장 우수합니다. 차원이 적을수록 저장 비용이 낮고 검색이 빠릅니다.


pgvector 설정

확장 활성화

-- SQL 에디터에서 실행
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions;

또는 대시보드: Database → Extensions → vector 검색 후 활성화

스키마 설계

-- 문서 임베딩 테이블
CREATE TABLE documents (
  id          BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  user_id     UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  title       TEXT NOT NULL,
  content     TEXT NOT NULL,                    -- 원본 텍스트
  metadata    JSONB DEFAULT '{}',               -- 추가 필드 (source, category 등)
  -- 임베딩 컬럼: text-embedding-3-small = 1536차원
  -- halfvec: float16으로 메모리 절반 절약 (정확도 거의 동일)
  embedding   HALFVEC(1536),
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- RLS 활성화
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- 본인 문서만 접근
CREATE POLICY "본인 문서 접근"
  ON documents FOR ALL
  TO authenticated
  USING ((SELECT auth.uid()) = user_id)
  WITH CHECK ((SELECT auth.uid()) = user_id);

-- HNSW 인덱스 생성 (코사인 유사도 기준)
CREATE INDEX ON documents
  USING hnsw (embedding halfvec_cosine_ops);

HNSW vs IVFFlat 인덱스

pgvector는 두 종류의 ANN(근사 최근접 이웃) 인덱스를 지원합니다.

항목HNSWIVFFlat
검색 속도⚡ 빠름보통
정확도(Recall)✅ 높음보통
메모리 사용높음낮음
데이터 추가 후 인덱스자동 갱신재구축 권장
빌드 시간느림빠름
권장일반적인 경우메모리 제약 시

HNSW 인덱스 생성

-- vector 타입 (최대 2,000 차원)
CREATE INDEX idx_documents_embedding
  ON documents
  USING hnsw (embedding vector_cosine_ops);

-- halfvec 타입 (최대 4,000 차원, 메모리 절반)
CREATE INDEX idx_documents_embedding_half
  ON documents
  USING hnsw (embedding halfvec_cosine_ops);

-- HNSW 파라미터 튜닝 (기본값: m=16, ef_construction=64)
CREATE INDEX idx_documents_embedding_tuned
  ON documents
  USING hnsw (embedding halfvec_cosine_ops)
  WITH (m = 16, ef_construction = 64);
-- m: 각 노드의 최대 연결 수 (높을수록 정확도↑, 메모리↑)
-- ef_construction: 인덱스 빌드 시 탐색 범위 (높을수록 품질↑, 빌드 시간↑)

거리 연산자

-- 코사인 거리 (0: 동일, 2: 완전 반대) — 텍스트 검색에 주로 사용
embedding <=> query_vec

-- L2(유클리드) 거리
embedding <-> query_vec

-- 내적(Negative inner product) — 정규화된 벡터에서 가장 빠름
embedding <#> query_vec

💡 텍스트 임베딩에는 **코사인 거리(<=>)**를 사용하는 것이 일반적입니다. 코사인 유사도 = 1 - 코사인 거리


OpenAI로 임베딩 생성 & 저장

lib/embeddings.ts

// lib/embeddings.ts
const OPENAI_API_KEY = process.env.OPENAI_API_KEY!

export async function generateEmbedding(text: string): Promise<number[]> {
  // 텍스트 전처리: 줄바꿈 제거, 공백 정규화
  const input = text.replace(/\n/g, ' ').trim()

  const response = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input,
      // dimensions 파라미터로 차원 축소 가능 (선택)
      // dimensions: 512,
    }),
  })

  if (!response.ok) {
    throw new Error(`OpenAI Embeddings API 오류: ${response.statusText}`)
  }

  const data = await response.json()
  return data.data[0].embedding as number[]
}

// 여러 텍스트 배치 처리 (API 요청 최소화)
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
  const inputs = texts.map((t) => t.replace(/\n/g, ' ').trim())

  const response = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input: inputs,
    }),
  })

  const data = await response.json()
  // 순서 보장: index 기준으로 정렬
  return data.data
    .sort((a: { index: number }, b: { index: number }) => a.index - b.index)
    .map((item: { embedding: number[] }) => item.embedding)
}

문서 저장 (Server Action)

// app/documents/actions.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/embeddings'
import { revalidatePath } from 'next/cache'

export async function saveDocument(title: string, content: string) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('로그인이 필요합니다.')

  // 1. 임베딩 생성 (OpenAI API 호출)
  const embedding = await generateEmbedding(`${title}\n\n${content}`)

  // 2. DB에 저장
  const { data, error } = await supabase
    .from('documents')
    .insert({
      user_id: user.id,
      title,
      content,
      // pgvector는 number[] → vector 타입 자동 변환
      embedding: JSON.stringify(embedding),
    })
    .select('id')
    .single()

  if (error) throw new Error(error.message)

  revalidatePath('/documents')
  return data
}

RPC 함수로 유사도 검색

PostgREST는 pgvector 거리 연산자를 직접 지원하지 않으므로, **PostgreSQL 함수(RPC)**로 래핑합니다.

-- 유사도 검색 함수
CREATE OR REPLACE FUNCTION match_documents(
  query_embedding  HALFVEC(1536),
  match_threshold  FLOAT    DEFAULT 0.5,   -- 최소 유사도 (0~1)
  match_count      INT      DEFAULT 5,     -- 반환할 문서 수
  filter_user_id   UUID     DEFAULT NULL   -- 사용자 필터 (선택)
)
RETURNS TABLE (
  id          BIGINT,
  title       TEXT,
  content     TEXT,
  metadata    JSONB,
  similarity  FLOAT
)
LANGUAGE SQL STABLE
AS $$
  SELECT
    d.id,
    d.title,
    d.content,
    d.metadata,
    1 - (d.embedding <=> query_embedding) AS similarity  -- 코사인 유사도
  FROM documents d
  WHERE
    (filter_user_id IS NULL OR d.user_id = filter_user_id)
    AND 1 - (d.embedding <=> query_embedding) > match_threshold
  ORDER BY d.embedding <=> query_embedding  -- 거리 기준 오름차순
  LIMIT match_count;
$$;

TypeScript에서 RPC 호출

// lib/search.ts
import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/embeddings'

export interface SearchResult {
  id: number
  title: string
  content: string
  metadata: Record<string, unknown>
  similarity: number
}

export async function semanticSearch(
  query: string,
  options: {
    threshold?: number
    limit?: number
    userId?: string
  } = {}
): Promise<SearchResult[]> {
  const { threshold = 0.5, limit = 5, userId } = options
  const supabase = await createClient()

  // 검색어 임베딩 생성
  const queryEmbedding = await generateEmbedding(query)

  // RPC 호출
  const { data, error } = await supabase.rpc('match_documents', {
    query_embedding: JSON.stringify(queryEmbedding),
    match_threshold: threshold,
    match_count: limit,
    filter_user_id: userId ?? null,
  })

  if (error) throw new Error(error.message)
  return (data as SearchResult[]) ?? []
}

RAG 파이프라인 구축

RAG(Retrieval-Augmented Generation)는 다음 3단계로 구성됩니다.

1. 사용자 질문 입력
      ↓
2. 질문을 임베딩 → 유사 문서 검색 (Retrieval)
      ↓
3. 검색 결과를 컨텍스트로 LLM에 전달 → 답변 생성 (Generation)

Edge Function으로 RAG API 구현

// supabase/functions/rag-chat/index.ts
import { corsHeaders } from '../_shared/cors.ts'
import { createAdminClient } from '../_shared/supabase.ts'

const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')!

Deno.serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  // 인증 확인
  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response(JSON.stringify({ error: '인증 필요' }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      status: 401,
    })
  }

  const { question } = await req.json()
  if (!question) {
    return new Response(JSON.stringify({ error: '질문이 필요합니다.' }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      status: 400,
    })
  }

  const supabase = createAdminClient()

  // Step 1: 질문 임베딩 생성
  const embeddingRes = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input: question,
    }),
  })

  const embeddingData = await embeddingRes.json()
  const queryEmbedding = embeddingData.data[0].embedding

  // Step 2: 유사 문서 검색
  const { data: matchedDocs, error: searchError } = await supabase.rpc(
    'match_documents',
    {
      query_embedding: JSON.stringify(queryEmbedding),
      match_threshold: 0.5,
      match_count: 5,
    }
  )

  if (searchError) {
    console.error('검색 오류:', searchError)
    return new Response(JSON.stringify({ error: searchError.message }), {
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      status: 500,
    })
  }

  // Step 3: 컨텍스트 구성
  const contextText = matchedDocs
    .map((doc: { title: string; content: string; similarity: number }) =>
      `[문서: ${doc.title} (유사도: ${(doc.similarity * 100).toFixed(1)}%)]\n${doc.content}`
    )
    .join('\n\n---\n\n')

  const systemPrompt = `당신은 주어진 문서를 기반으로 정확하게 답변하는 AI 어시스턴트입니다.
아래 문서들을 참고하여 사용자의 질문에 답변하세요.
문서에 없는 내용은 모른다고 솔직하게 말하세요.

=== 참고 문서 ===
${contextText || '관련 문서가 없습니다.'}
================`

  // Step 4: ChatGPT로 답변 생성 (스트리밍)
  const chatRes = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: question },
      ],
      stream: true,
    }),
  })

  // 스트리밍 응답 그대로 전달
  return new Response(chatRes.body, {
    headers: {
      ...corsHeaders,
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
    },
  })
})

Next.js에서 RAG 채팅 UI

// app/chat/page.tsx
'use client'

import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export default function RagChat() {
  const [question, setQuestion] = useState('')
  const [answer, setAnswer] = useState('')
  const [loading, setLoading] = useState(false)
  const supabase = createClient()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!question.trim() || loading) return

    setLoading(true)
    setAnswer('')

    try {
      const { data: { session } } = await supabase.auth.getSession()

      // Edge Function 호출 (스트리밍)
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/rag-chat`,
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${session?.access_token}`,
          },
          body: JSON.stringify({ question }),
        }
      )

      if (!response.body) throw new Error('스트리밍 지원 안 됨')

      // SSE 스트림 읽기
      const reader = response.body.getReader()
      const decoder = new TextDecoder()

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        const chunk = decoder.decode(value)
        const lines = chunk.split('\n').filter((l) => l.startsWith('data: '))

        for (const line of lines) {
          const data = line.slice(6)
          if (data === '[DONE]') break

          try {
            const json = JSON.parse(data)
            const delta = json.choices?.[0]?.delta?.content
            if (delta) setAnswer((prev) => prev + delta)
          } catch {
            // JSON 파싱 실패 무시
          }
        }
      }
    } catch (error) {
      console.error('RAG 오류:', error)
      setAnswer('오류가 발생했습니다. 다시 시도해주세요.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="max-w-2xl mx-auto p-6 space-y-6">
      <h1 className="text-2xl font-bold">문서 기반 AI 채팅</h1>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          placeholder="문서에 대해 질문하세요..."
          className="flex-1 border rounded-lg px-4 py-2"
          disabled={loading}
        />
        <button
          type="submit"
          disabled={loading || !question.trim()}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
        >
          {loading ? '답변 중...' : '질문'}
        </button>
      </form>

      {answer && (
        <div className="bg-gray-50 rounded-xl p-4 whitespace-pre-wrap leading-relaxed">
          {answer}
          {loading && <span className="animate-pulse">▌</span>}
        </div>
      )}
    </div>
  )
}

자동 임베딩 갱신

문서가 수정될 때 임베딩을 자동으로 재생성하는 패턴입니다. Supabase의 pg_net + Edge Function을 조합합니다.

-- pg_net 확장 활성화
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;

-- 임베딩 갱신 트리거 함수
CREATE OR REPLACE FUNCTION trigger_generate_embedding()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  -- 컨텐츠가 변경된 경우에만 임베딩 재생성
  IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND OLD.content IS DISTINCT FROM NEW.content) THEN
    -- Edge Function 비동기 호출 (pg_net)
    PERFORM net.http_post(
      url     := current_setting('app.settings.edge_function_url') || '/generate-embedding',
      headers := jsonb_build_object(
        'Content-Type', 'application/json',
        'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
      ),
      body    := jsonb_build_object('document_id', NEW.id, 'content', NEW.content)
    );
  END IF;
  RETURN NEW;
END;
$$;

-- 트리거 연결
CREATE TRIGGER on_document_change
  AFTER INSERT OR UPDATE ON documents
  FOR EACH ROW
  EXECUTE FUNCTION trigger_generate_embedding();
// supabase/functions/generate-embedding/index.ts
import { createAdminClient } from '../_shared/supabase.ts'

const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')!

Deno.serve(async (req: Request) => {
  const { document_id, content } = await req.json()

  // 임베딩 생성
  const res = await fetch('https://api.openai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'text-embedding-3-small',
      input: content.replace(/\n/g, ' '),
    }),
  })

  const data = await res.json()
  const embedding = data.data[0].embedding

  // DB 업데이트
  const supabase = createAdminClient()
  await supabase
    .from('documents')
    .update({ embedding: JSON.stringify(embedding) })
    .eq('id', document_id)

  return new Response(JSON.stringify({ success: true }), {
    headers: { 'Content-Type': 'application/json' },
    status: 200,
  })
})

하이브리드 검색 (벡터 + 키워드 FTS)

순수 벡터 검색은 의미 기반이지만, 정확한 단어/고유명사 매칭이 필요할 때는 PostgreSQL의 **전문 검색(Full-Text Search)**과 결합한 하이브리드 검색이 효과적입니다.

-- 하이브리드 검색 함수 (RRF: Reciprocal Rank Fusion)
CREATE OR REPLACE FUNCTION hybrid_search(
  query_text       TEXT,
  query_embedding  HALFVEC(1536),
  match_count      INT     DEFAULT 5,
  full_text_weight FLOAT   DEFAULT 1.0,  -- 키워드 검색 가중치
  semantic_weight  FLOAT   DEFAULT 1.0   -- 벡터 검색 가중치
)
RETURNS TABLE (
  id         BIGINT,
  title      TEXT,
  content    TEXT,
  rrf_score  FLOAT
)
LANGUAGE SQL
AS $$
WITH
  -- 키워드 FTS 검색
  fts AS (
    SELECT
      id,
      ROW_NUMBER() OVER (
        ORDER BY ts_rank_cd(
          to_tsvector('simple', title || ' ' || content),
          websearch_to_tsquery('simple', query_text)
        ) DESC
      ) AS rank_ix
    FROM documents
    WHERE to_tsvector('simple', title || ' ' || content)
          @@ websearch_to_tsquery('simple', query_text)
    ORDER BY rank_ix
    LIMIT LEAST(match_count, 30) * 2
  ),
  -- 벡터 유사도 검색
  semantic AS (
    SELECT
      id,
      ROW_NUMBER() OVER (ORDER BY embedding <=> query_embedding) AS rank_ix
    FROM documents
    ORDER BY embedding <=> query_embedding
    LIMIT LEAST(match_count, 30) * 2
  )
-- RRF 스코어 통합
SELECT
  d.id,
  d.title,
  d.content,
  COALESCE(1.0 / (60 + fts.rank_ix), 0.0) * full_text_weight
  + COALESCE(1.0 / (60 + semantic.rank_ix), 0.0) * semantic_weight AS rrf_score
FROM documents d
LEFT JOIN fts     ON d.id = fts.id
LEFT JOIN semantic ON d.id = semantic.id
WHERE fts.id IS NOT NULL OR semantic.id IS NOT NULL
ORDER BY rrf_score DESC
LIMIT match_count;
$$;

성능 최적화 팁

1. halfvec으로 메모리 절약

-- vector (float32, 4 bytes/dimension)
embedding VECTOR(1536)    -- 6KB/문서

-- halfvec (float16, 2 bytes/dimension)
embedding HALFVEC(1536)   -- 3KB/문서 ← 메모리 절반, 정확도 거의 동일

2. 검색 시 ef_search 조정

-- 쿼리 시 HNSW 탐색 범위 조정 (기본값: 40)
-- 높을수록 정확도↑, 속도↓
SET hnsw.ef_search = 100;
SELECT ... FROM documents ORDER BY embedding <=> $1 LIMIT 10;
RESET hnsw.ef_search;

3. 차원 축소 (text-embedding-3-small)

// OpenAI text-embedding-3 모델은 dimensions 파라미터로 차원 축소 지원
const response = await fetch('https://api.openai.com/v1/embeddings', {
  body: JSON.stringify({
    model: 'text-embedding-3-small',
    input: text,
    dimensions: 512,  // 1536 → 512 (저장 비용 1/3, 정확도 소폭 감소)
  }),
})

4. 필터링 주의사항

-- ❌ WHERE 절 필터 + 벡터 인덱스 → 인덱스가 충분한 결과를 못 찾을 수 있음
SELECT * FROM documents
WHERE user_id = $1
ORDER BY embedding <=> $2
LIMIT 5;

-- ✅ 필터를 RPC 함수 내에서 처리하거나, 파티셔닝 고려
-- 또는 match_count를 크게 잡고 애플리케이션 레벨에서 필터링

자주 하는 실수

1. 다른 모델의 임베딩 비교

❌ text-embedding-ada-002로 생성한 벡터와
   text-embedding-3-small 벡터를 같은 테이블에서 비교
→ 무의미한 결과

✅ 반드시 동일 모델로 생성한 임베딩만 비교
   모델 변경 시 전체 재임베딩 필요

2. 인덱스 없이 대용량 검색

-- ❌ 인덱스 없으면 순차 스캔 (수십만 건 이상에서 심각하게 느림)
-- ✅ HNSW 또는 IVFFlat 인덱스 반드시 생성
CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops);

3. 너무 긴 청크 임베딩

❌ 문서 전체(수천 단어)를 하나의 벡터로 임베딩
→ 의미가 희석되어 검색 정확도 급락

✅ 문서를 300~500 토큰 단위로 청크 분할 후 각각 임베딩
   오버랩(중복 구간)을 두면 컨텍스트 연속성 유지

4. 임베딩 생성 비용 과다

✅ 동일 텍스트는 임베딩 캐싱 (Redis, DB 캐시)
✅ 배치 API로 한 번에 여러 텍스트 처리
✅ 차원 수 줄이기 (1536 → 512)
✅ 오픈소스 모델 사용 (Edge Function에서 Transformers.js)

마치며

Supabase + pgvector 조합은 별도의 벡터 DB 없이 시맨틱 검색과 RAG를 구현할 수 있는 강력한 솔루션입니다. 특히 RLS와 결합하면 사용자별 격리된 벡터 검색이 가능하고, 하이브리드 검색으로 키워드와 의미 기반 검색을 동시에 활용할 수 있습니다.

다음 편에서는 Cron Jobs & Queues를 다룹니다. pg_cron으로 정기적인 DB 작업을 예약하고, pgmq로 안정적인 메시지 큐 기반 백그라운드 작업을 처리하는 방법을 살펴봅니다.




댓글 남기기