Supabase와 OpenAI를 활용한 AI 검색 시스템 구현하기

2025. 5. 17. 20:13·Study/Supabase

안녕하세요!

오늘은 Supabase와 OpenAI를 연동하여 텍스트 기반 AI 검색 시스템을 구현하는 방법에 대해 알아보겠습니다. 최근 벡터 데이터베이스를 활용한 의미 기반 검색이 주목받고 있는데요, Supabase는 PostgreSQL의 pgvector 확장을 통해 이러한 기능을 손쉽게 구현할 수 있도록 지원합니다.

1. 프로젝트 준비하기

먼저, 필요한 환경을 설정해 보겠습니다.

Supabase 프로젝트 설정

  1. Supabase 사이트(https://supabase.com/)에 접속하여 로그인합니다.
  2. 새 프로젝트를 생성하고 적절한 이름을 지정합니다.
  3. 프로젝트가 생성되면 프로젝트 대시보드로 이동합니다.

필요한 패키지 설치

새로운 Next.js 프로젝트를 생성하고 필요한 패키지를 설치합니다:

# Next.js 프로젝트 생성
npx create-next-app@latest ai-search-app --typescript

# 필요한 패키지 설치
cd ai-search-app
npm install @supabase/supabase-js openai react-markdown

환경 변수 설정

프로젝트 루트에 .env.local 파일을 생성하고 다음 변수를 설정합니다:

NEXT_PUBLIC_SUPABASE_URL=프로젝트URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=anon키
SUPABASE_SERVICE_ROLE_KEY=service_role키
OPENAI_API_KEY=OpenAI_API_키

Supabase 키는 프로젝트 설정의 API 섹션에서 확인할 수 있습니다.

2. pgvector 확장 활성화 및 테이블 생성

Supabase SQL 에디터에서 다음 명령을 실행하여 pgvector 확장을 활성화하고 필요한 테이블을 생성합니다:

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

-- 문서 및 임베딩을 저장할 테이블 생성
CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  content TEXT NOT NULL,
  metadata JSONB,
  embedding VECTOR(1536),  -- OpenAI의 text-embedding-3-small 모델은 1536차원 벡터 사용
  created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW())
);

-- 벡터 검색을 위한 인덱스 생성 (HNSW 인덱스 사용)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);

-- 문서 검색을 위한 함수 생성
CREATE OR REPLACE FUNCTION match_documents(
  query_embedding VECTOR(1536),
  match_threshold FLOAT,
  match_count INT
)
RETURNS TABLE (
  id UUID,
  content TEXT,
  metadata JSONB,
  similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  SELECT
    documents.id,
    documents.content,
    documents.metadata,
    1 - (documents.embedding <=> query_embedding) AS similarity
  FROM documents
  WHERE 1 - (documents.embedding <=> query_embedding) > match_threshold
  ORDER BY similarity DESC
  LIMIT match_count;
END;
$$;

3. Supabase 클라이언트 설정

lib/supabase.ts 파일을 생성하고 Supabase 클라이언트를 설정합니다:

typescript
import { createClient } from '@supabase/supabase-js';

// 환경 변수에서 Supabase URL과 API 키를 가져옵니다
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;

// 클라이언트 측에서 사용할 Supabase 클라이언트
export const supabaseClient = createClient(supabaseUrl, supabaseAnonKey);

// 서버 측에서 사용할 Supabase 클라이언트 (service role 권한 사용)
export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);

 

4. OpenAI 클라이언트 설정

lib/openai.ts 파일을 생성하고 OpenAI 클라이언트를 설정합니다:

 
typescript
import OpenAI from 'openai';

// OpenAI 클라이언트 초기화
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// 텍스트 임베딩 생성 함수
export async function createEmbedding(text: string): Promise<number[]> {
  try {
    const response = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: text,
    });
    
    return response.data[0].embedding;
  } catch (error) {
    console.error('임베딩 생성 중 오류 발생:', error);
    throw error;
  }
}

// 질문에 대한 답변 생성 함수
export async function generateAnswer(question: string, context: string): Promise<string> {
  try {
    const response = await openai.chat.completions.create({
      model: 'gpt-4o',
      messages: [
        {
          role: 'system',
          content: '당신은 문서 검색 결과를 바탕으로 질문에 답변하는 도우미입니다. 주어진 컨텍스트 내에서만 답변하세요.',
        },
        {
          role: 'user',
          content: `
            컨텍스트: ${context}
            
            질문: ${question}
            
            위 컨텍스트를 바탕으로 질문에 답변해주세요. 컨텍스트에 관련 정보가 없다면 "죄송합니다만, 관련 정보를 찾을 수 없습니다."라고 대답해주세요.
          `,
        },
      ],
      temperature: 0.3,
      max_tokens: 500,
    });
    
    return response.choices[0].message.content || '답변을 생성할 수 없습니다.';
  } catch (error) {
    console.error('답변 생성 중 오류 발생:', error);
    throw error;
  }
}

5. 문서 업로드 및 임베딩 생성 API

app/api/documents/route.ts 파일을 생성하여 문서 업로드 API를 구현합니다:

 
typescript
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
import { createEmbedding } from '@/lib/openai';

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const { content, metadata = {} } = body;
    
    if (!content || typeof content !== 'string') {
      return NextResponse.json({ error: '유효한 문서 내용이 필요합니다.' }, { status: 400 });
    }
    
    // 텍스트 임베딩 생성
    const embedding = await createEmbedding(content);
    
    // Supabase에 문서 및 임베딩 저장
    const { data, error } = await supabaseAdmin
      .from('documents')
      .insert([
        {
          content,
          metadata,
          embedding,
        },
      ])
      .select('id, content, metadata, created_at');
    
    if (error) {
      console.error('문서 저장 중 오류 발생:', error);
      return NextResponse.json({ error: '문서를 저장하는 중 오류가 발생했습니다.' }, { status: 500 });
    }
    
    return NextResponse.json({ success: true, document: data[0] });
  } catch (error) {
    console.error('문서 처리 중 오류 발생:', error);
    return NextResponse.json({ error: '문서 처리 중 오류가 발생했습니다.' }, { status: 500 });
  }
}

export async function GET() {
  try {
    // 모든 문서 조회 (임베딩 필드 제외)
    const { data, error } = await supabaseAdmin
      .from('documents')
      .select('id, content, metadata, created_at')
      .order('created_at', { ascending: false });
    
    if (error) {
      console.error('문서 조회 중 오류 발생:', error);
      return NextResponse.json({ error: '문서를 조회하는 중 오류가 발생했습니다.' }, { status: 500 });
    }
    
    return NextResponse.json({ documents: data });
  } catch (error) {
    console.error('문서 조회 중 오류 발생:', error);
    return NextResponse.json({ error: '문서 조회 중 오류가 발생했습니다.' }, { status: 500 });
  }
}
반응형

6. 문서 검색 API

app/api/search/route.ts 파일을 생성하여 의미 기반 검색 API를 구현합니다:

 
typescript
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
import { createEmbedding, generateAnswer } from '@/lib/openai';

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const { query, generateAnswerFromResults = false, threshold = 0.7, limit = 5 } = body;
    
    if (!query || typeof query !== 'string') {
      return NextResponse.json({ error: '유효한 검색어가 필요합니다.' }, { status: 400 });
    }
    
    // 검색어의 임베딩 생성
    const embedding = await createEmbedding(query);
    
    // 유사한 문서 검색
    const { data: documents, error } = await supabaseAdmin.rpc('match_documents', {
      query_embedding: embedding,
      match_threshold: threshold,
      match_count: limit,
    });
    
    if (error) {
      console.error('문서 검색 중 오류 발생:', error);
      return NextResponse.json({ error: '문서 검색 중 오류가 발생했습니다.' }, { status: 500 });
    }
    
    // 검색 결과만 반환하거나, 검색 결과를 바탕으로 답변 생성
    if (!generateAnswerFromResults || documents.length === 0) {
      return NextResponse.json({ results: documents });
    }
    
    // 검색된 문서들을 하나의 컨텍스트로 결합
    const context = documents
      .map((doc) => `--- 문서 내용 ---\n${doc.content}`)
      .join('\n\n');
    
    // OpenAI를 이용해 답변 생성
    const answer = await generateAnswer(query, context);
    
    return NextResponse.json({
      results: documents,
      answer,
    });
  } catch (error) {
    console.error('검색 처리 중 오류 발생:', error);
    return NextResponse.json({ error: '검색 처리 중 오류가 발생했습니다.' }, { status: 500 });
  }
}

7. 문서 업로드 컴포넌트 구현

components/DocumentUploader.tsx 파일을 생성하여 문서 업로드 컴포넌트를 구현합니다:

 
tsx
'use client';

import { useState } from 'react';

export default function DocumentUploader() {
  const [content, setContent] = useState('');
  const [title, setTitle] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState('');
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!content.trim()) {
      setMessage('문서 내용을 입력해주세요.');
      return;
    }
    
    try {
      setIsLoading(true);
      setMessage('');
      
      const response = await fetch('/api/documents', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          content: content.trim(),
          metadata: {
            title: title.trim() || '제목 없음',
          },
        }),
      });
      
      const result = await response.json();
      
      if (!response.ok) {
        throw new Error(result.error || '문서 업로드 중 오류가 발생했습니다.');
      }
      
      setMessage('문서가 성공적으로 업로드되었습니다!');
      setContent('');
      setTitle('');
    } catch (error) {
      console.error('문서 업로드 중 오류 발생:', error);
      setMessage(`오류: ${error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'}`);
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <div className="p-4 bg-white rounded shadow-md">
      <h2 className="text-xl font-bold mb-4">새 문서 업로드</h2>
      <form onSubmit={handleSubmit}>
        <div className="mb-4">
          <label htmlFor="title" className="block text-sm font-medium mb-1">
            문서 제목 (선택사항)
          </label>
          <input
            type="text"
            id="title"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full p-2 border rounded"
            placeholder="문서 제목을 입력하세요"
          />
        </div>
        <div className="mb-4">
          <label htmlFor="content" className="block text-sm font-medium mb-1">
            문서 내용
          </label>
          <textarea
            id="content"
            value={content}
            onChange={(e) => setContent(e.target.value)}
            className="w-full p-2 border rounded h-40"
            placeholder="문서 내용을 입력하세요"
            required
          />
        </div>
        <button
          type="submit"
          disabled={isLoading}
          className={`px-4 py-2 rounded text-white ${
            isLoading ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600'
          }`}
        >
          {isLoading ? '처리 중...' : '문서 업로드'}
        </button>
      </form>
      {message && (
        <div
          className={`mt-4 p-2 rounded ${
            message.startsWith('오류') ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'
          }`}
        >
          {message}
        </div>
      )}
    </div>
  );
}

8. 검색 컴포넌트 구현

components/SearchComponent.tsx 파일을 생성하여 검색 컴포넌트를 구현합니다:

 
tsx
'use client';

import { useState } from 'react';
import ReactMarkdown from 'react-markdown';

interface SearchResult {
  id: string;
  content: string;
  metadata: {
    title?: string;
  };
  similarity: number;
}

export default function SearchComponent() {
  const [query, setQuery] = useState('');
  const [isSearching, setIsSearching] = useState(false);
  const [results, setResults] = useState<SearchResult[]>([]);
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState('');
  const [generateAnswer, setGenerateAnswer] = useState(true);
  
  const handleSearch = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!query.trim()) {
      setError('검색어를 입력해주세요.');
      return;
    }
    
    try {
      setIsSearching(true);
      setError('');
      setResults([]);
      setAnswer('');
      
      const response = await fetch('/api/search', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          query: query.trim(),
          generateAnswerFromResults: generateAnswer,
          threshold: 0.7,
          limit: 5,
        }),
      });
      
      const data = await response.json();
      
      if (!response.ok) {
        throw new Error(data.error || '검색 중 오류가 발생했습니다.');
      }
      
      setResults(data.results || []);
      if (generateAnswer) {
        setAnswer(data.answer || '');
      }
      
      if (data.results?.length === 0) {
        setError('검색 결과가 없습니다. 다른 검색어를 시도해보세요.');
      }
    } catch (error) {
      console.error('검색 중 오류 발생:', error);
      setError(`오류: ${error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'}`);
    } finally {
      setIsSearching(false);
    }
  };
  
  return (
    <div className="p-4 bg-white rounded shadow-md">
      <h2 className="text-xl font-bold mb-4">의미 기반 검색</h2>
      <form onSubmit={handleSearch} className="mb-6">
        <div className="flex gap-2 mb-3">
          <input
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            className="flex-1 p-2 border rounded"
            placeholder="검색어를 입력하세요"
          />
          <button
            type="submit"
            disabled={isSearching}
            className={`px-4 py-2 rounded text-white ${
              isSearching ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600'
            }`}
          >
            {isSearching ? '검색 중...' : '검색'}
          </button>
        </div>
        <div className="flex items-center gap-2">
          <input
            type="checkbox"
            id="generateAnswer"
            checked={generateAnswer}
            onChange={(e) => setGenerateAnswer(e.target.checked)}
          />
          <label htmlFor="generateAnswer" className="text-sm">
            검색 결과를 바탕으로 AI 답변 생성하기
          </label>
        </div>
      </form>
      
      {error && (
        <div className="mb-4 p-2 bg-red-100 text-red-700 rounded">{error}</div>
      )}
      
      {answer && (
        <div className="mb-6">
          <h3 className="text-lg font-semibold mb-2">AI 답변:</h3>
          <div className="bg-blue-50 p-3 rounded">
            <ReactMarkdown>{answer}</ReactMarkdown>
          </div>
        </div>
      )}
      
      {results.length > 0 && (
        <div>
          <h3 className="text-lg font-semibold mb-2">검색 결과 ({results.length}개):</h3>
          <div className="space-y-4">
            {results.map((result) => (
              <div key={result.id} className="border p-3 rounded">
                <div className="flex justify-between items-center mb-2">
                  <h4 className="font-medium">
                    {result.metadata?.title || '제목 없음'}
                  </h4>
                  <span className="text-sm text-gray-500">
                    유사도: {(result.similarity * 100).toFixed(1)}%
                  </span>
                </div>
                <p className="text-sm text-gray-700">
                  {result.content.length > 200
                    ? `${result.content.substring(0, 200)}...`
                    : result.content}
                </p>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

9. 메인 페이지 구현

app/page.tsx 파일을 수정하여 메인 페이지를 구현합니다:

 
tsx
import DocumentUploader from '@/components/DocumentUploader';
import SearchComponent from '@/components/SearchComponent';

export default function Home() {
  return (
    <main className="container mx-auto p-4 max-w-5xl">
      <h1 className="text-3xl font-bold text-center my-8">
        Supabase + OpenAI 의미 기반 검색 시스템
      </h1>
      
      <div className="grid md:grid-cols-2 gap-6">
        <div>
          <DocumentUploader />
        </div>
        <div>
          <SearchComponent />
        </div>
      </div>
    </main>
  );
}

10. 시스템 테스트하기

  1. 개발 서버를 실행합니다:
    $ npm run dev
  2. 웹 브라우저에서 http://localhost:3000에 접속합니다.
  3. 문서 업로드 폼을 사용하여 여러 문서를 업로드합니다.
  4. 검색 폼을 사용하여 의미 기반 검색을 테스트합니다.
 

시스템의 작동 방식

  1. 문서 처리 과정:
    • 사용자가 문서를 업로드하면 OpenAI API를 사용하여 텍스트 임베딩(벡터 표현)을 생성합니다.
    • 문서 내용과 생성된 임베딩을 Supabase 데이터베이스에 저장합니다.
  2. 검색 과정:
    • 사용자가 검색어를 입력하면, 해당 검색어의 임베딩을 생성합니다.
    • 생성된 임베딩과 데이터베이스에 저장된 문서 임베딩 간의 코사인 유사도를 계산합니다.
    • 유사도 점수가 임계값보다 높은 문서를 유사도 순으로 반환합니다.
    • 옵션에 따라 검색 결과를 바탕으로 OpenAI API를 사용하여 답변을 생성합니다.

성능 최적화 팁

  1. 배치 처리: 대량의 문서를 업로드할 때는 배치 처리를 사용하여 API 호출을 최소화합니다.
  2. 캐싱: 자주 사용되는 쿼리의 결과를 캐싱하여 응답 시간을 단축합니다.
  3. 임베딩 모델 선택: 용도에 맞는 적절한 임베딩 모델을 선택합니다 (text-embedding-3-small 또는 text-embedding-3-large).
  4. 인덱스 최적화: 대규모 데이터셋의 경우 HNSW 인덱스 매개변수를 조정하여 검색 성능을 최적화합니다.

확장 가능성

  1. 다국어 지원: 다양한 언어로 된 문서를 처리하고 검색할 수 있습니다.
  2. 파일 업로드: PDF, Word 등의 파일 업로드 및 처리 기능을 추가할 수 있습니다.
  3. RAG 시스템 구축: Retrieval-Augmented Generation 시스템으로 확장하여 더 정확한 AI 응답을 생성할 수 있습니다.
  4. 사용자 피드백 통합: 검색 결과에 대한 사용자 피드백을 수집하여 시스템을 개선할 수 있습니다.

마무리

이번 포스팅에서는 Supabase의 pgvector와 OpenAI API를 활용하여 의미 기반 검색 시스템을 구현해 보았습니다. 이 시스템은 단순한 키워드 검색을 넘어 텍스트의 의미를 이해하고 유사한 내용을 검색할 수 있는 강력한 기능을 제공합니다.

벡터 데이터베이스와 임베딩 기술을 활용하면 다음과 같은 장점이 있습니다:

  1. 의미 기반 검색: 정확한 키워드가 아니더라도 의미적으로 유사한 내용을 찾을 수 있습니다.
  2. 자연어 쿼리: 사용자는 자연어로 질문하고 관련된 정보를 검색할 수 있습니다.
  3. 맥락 이해: AI는 문서의 맥락을 이해하고 이를 바탕으로 적절한 답변을 제공합니다.
  4. 확장성: 대규모 문서 컬렉션에서도 효율적인 검색이 가능합니다.

이 프로젝트는 Supabase와 OpenAI의 강력한 기능을 결합하여 개발자가 손쉽게 AI 기반 검색 시스템을 구축할 수 있음을 보여줍니다. 특히 pgvector 확장을 통해 PostgreSQL이 벡터 연산을 지원하게 되어, 별도의 벡터 데이터베이스 없이도 기존 데이터베이스 내에서 의미 기반 검색을 구현할 수 있게 되었습니다.

앞으로 더 발전된 형태로, 이 시스템에 다음과 같은 기능을 추가해 볼 수 있습니다:

  1. 문서 분할 및 청킹: 긴 문서를 의미 있는 단위로 나누어 더 정확한 검색과 응답 생성
  2. 메타데이터 필터링: 날짜, 카테고리 등 메타데이터를 기반으로 한 필터링 기능 추가
  3. 하이브리드 검색: 키워드 검색과 의미 기반 검색을 결합하여 검색 정확도 향상
  4. 멀티모달 검색: 텍스트뿐만 아니라 이미지, 오디오 등 다양한 형태의 데이터 검색 지원

저희는 앞으로도 Supabase와 AI를 활용한 다양한 프로젝트를 소개해 드리겠습니다. 다음 포스팅에서는 이 시스템을 기반으로 한 실시간 챗봇 구현 방법에 대해 알아보도록 하겠습니다.

질문이나 피드백이 있으시면 언제든지 댓글로 남겨주세요! 여러분의 의견이 더 나은 콘텐츠를 만드는 데 큰 도움이 됩니다.

감사합니다!

 

 

반응형
저작자표시 비영리 변경금지 (새창열림)

'Study > Supabase' 카테고리의 다른 글

매우 심플한 Node.js (Typescript) - Supabase DB (PostgresQL) 연동해서 데이터 넣기!  (3) 2025.05.04
'Study/Supabase' 카테고리의 다른 글
  • 매우 심플한 Node.js (Typescript) - Supabase DB (PostgresQL) 연동해서 데이터 넣기!
모리군
모리군
    반응형
  • 모리군
    나의 일상 그리고 취미
    모리군
  • 전체
    오늘
    어제
    • 분류 전체보기 (23)
      • 독백 (0)
      • Study (11)
        • Authentication (4)
        • Supabase (2)
      • Javascript (3)
        • node.js (0)
        • react.js (0)
        • vue.js (0)
        • LeetCode (3)
      • AI (0)
      • PHP (0)
      • HTML, CSS (0)
      • 툴, 플러그인 (0)
      • 취미 (1)
        • 보드게임 (1)
      • 교통 (0)
        • 철도 (0)
        • 도로 (0)
      • 부동산 (2)
        • 서울 (0)
        • 경기 성남 (0)
        • 경기 수원 (0)
        • 경기 화성 (2)
        • 경기 남양주 (0)
        • 광주 (0)
      • 역사 (4)
        • 이 주의 역사 (1)
      • 영어기사 읽기 (1주일 1번) (0)
        • 스포츠 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    대리운전
    LeetCode
    웹 서비스
    supabase
    광주민주화운동
    백엔드
    typescript
    OpenAI
    algorithm
    java
    PostgreSQL
    백엔드개발
    임베딩
    초기 영화
    한국발명
    JWT
    카카오T 대리
    FastAPI
    javascript
    ChatGPT
    벡터데이터베이스
    윌리엄 딕슨
    토큰관리
    node.js
    Spring Boot
    REST API
    express
    슈파베이스
    프롬프트엔지니어링
    Rag
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
모리군
Supabase와 OpenAI를 활용한 AI 검색 시스템 구현하기
상단으로

티스토리툴바