Spaces:
Sleeping
Upgrade to OpenAI + Supabase RAG Chatbot with enhanced capabilities
Browse files## Major Features Added:
- OpenAI GPT-4o-mini integration for advanced responses
- Supabase pgvector cloud database for scalable vector storage
- Hybrid support: Choose between FAISS (local) or Supabase (cloud)
- OpenAI text-embedding-3-small for improved embeddings
- Environment-based configuration system
## New Files:
- openai_chatbot.py: OpenAI-powered RAG chatbot
- supabase_vector_store.py: Cloud vector database integration
- supabase_setup*.sql: Database setup scripts (3 variants)
- .env.example: Configuration template
- .gitignore: Python and application-specific ignores
## Enhanced Configuration:
- Support for multiple vector database backends
- OpenAI API key and Supabase credentials
- Flexible embedding model selection
- Environment variable-based settings
## Bug Fixes:
- Fixed LangChain import compatibility issues
- Resolved PyMuPDF import problems
- Updated to latest package versions
🚀 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- .gitignore +109 -0
- README.md +70 -17
- app.py +11 -6
- config.py +12 -0
- document_processor.py +4 -4
- documents/복무관리규정.txt +20 -0
- openai_chatbot.py +299 -0
- rag_chatbot.py +1 -1
- requirements.txt +4 -0
- supabase_setup.sql +99 -0
- supabase_setup_minimal.sql +25 -0
- supabase_setup_simple.sql +55 -0
- supabase_vector_store.py +324 -0
- vector_store.py +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
pip-wheel-metadata/
|
| 20 |
+
share/python-wheels/
|
| 21 |
+
*.egg-info/
|
| 22 |
+
.installed.cfg
|
| 23 |
+
*.egg
|
| 24 |
+
MANIFEST
|
| 25 |
+
|
| 26 |
+
# PyInstaller
|
| 27 |
+
*.manifest
|
| 28 |
+
*.spec
|
| 29 |
+
|
| 30 |
+
# Installer logs
|
| 31 |
+
pip-log.txt
|
| 32 |
+
pip-delete-this-directory.txt
|
| 33 |
+
|
| 34 |
+
# Unit test / coverage reports
|
| 35 |
+
htmlcov/
|
| 36 |
+
.tox/
|
| 37 |
+
.nox/
|
| 38 |
+
.coverage
|
| 39 |
+
.coverage.*
|
| 40 |
+
.cache
|
| 41 |
+
nosetests.xml
|
| 42 |
+
coverage.xml
|
| 43 |
+
*.cover
|
| 44 |
+
*.py,cover
|
| 45 |
+
.hypothesis/
|
| 46 |
+
.pytest_cache/
|
| 47 |
+
|
| 48 |
+
# Jupyter Notebook
|
| 49 |
+
.ipynb_checkpoints
|
| 50 |
+
|
| 51 |
+
# IPython
|
| 52 |
+
profile_default/
|
| 53 |
+
ipython_config.py
|
| 54 |
+
|
| 55 |
+
# pyenv
|
| 56 |
+
.python-version
|
| 57 |
+
|
| 58 |
+
# pipenv
|
| 59 |
+
Pipfile.lock
|
| 60 |
+
|
| 61 |
+
# PEP 582
|
| 62 |
+
__pypackages__/
|
| 63 |
+
|
| 64 |
+
# Celery stuff
|
| 65 |
+
celerybeat-schedule
|
| 66 |
+
celerybeat.pid
|
| 67 |
+
|
| 68 |
+
# SageMath parsed files
|
| 69 |
+
*.sage.py
|
| 70 |
+
|
| 71 |
+
# Environments
|
| 72 |
+
.env
|
| 73 |
+
.env.venv
|
| 74 |
+
.env.local
|
| 75 |
+
.env.*.local
|
| 76 |
+
|
| 77 |
+
# Spyder project settings
|
| 78 |
+
.spyderproject
|
| 79 |
+
.spyproject
|
| 80 |
+
|
| 81 |
+
# Rope project settings
|
| 82 |
+
.ropeproject
|
| 83 |
+
|
| 84 |
+
# mkdocs documentation
|
| 85 |
+
/site
|
| 86 |
+
|
| 87 |
+
# mypy
|
| 88 |
+
.mypy_cache/
|
| 89 |
+
.dmypy.json
|
| 90 |
+
dmypy.json
|
| 91 |
+
|
| 92 |
+
# Pyre type checker
|
| 93 |
+
.pyre/
|
| 94 |
+
|
| 95 |
+
# IDEs
|
| 96 |
+
.vscode/
|
| 97 |
+
.idea/
|
| 98 |
+
|
| 99 |
+
# OS
|
| 100 |
+
.DS_Store
|
| 101 |
+
Thumbs.db
|
| 102 |
+
|
| 103 |
+
# Application specific
|
| 104 |
+
faiss_index/
|
| 105 |
+
*.bin
|
| 106 |
+
*.pkl
|
| 107 |
+
|
| 108 |
+
# Vector databases
|
| 109 |
+
faiss_index/
|
|
@@ -16,9 +16,9 @@ pinned: false
|
|
| 16 |
## 📋 프로젝트 개요
|
| 17 |
|
| 18 |
- **목적**: 소방업무 복무관리 관련 문서들을 활용한 지능형 질의응답 시스템
|
| 19 |
-
- **기술**: RAG, Sentence-BERT,
|
| 20 |
- **문서 유형**: PDF, Word, TXT, Excel, CSV
|
| 21 |
-
- **특징**: 한국어 최적화, 실시간 검색, 웹 인터페이스
|
| 22 |
|
| 23 |
## 🎯 주요 기능
|
| 24 |
|
|
@@ -78,7 +78,28 @@ source venv/bin/activate
|
|
| 78 |
pip install -r requirements.txt
|
| 79 |
```
|
| 80 |
|
| 81 |
-
### 2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
```bash
|
| 84 |
# documents 폴더 생성
|
|
@@ -108,40 +129,72 @@ python app.py
|
|
| 108 |
|
| 109 |
```
|
| 110 |
119_chatbot/
|
| 111 |
-
├── app.py
|
| 112 |
-
├── config.py
|
| 113 |
-
├── requirements.txt
|
| 114 |
-
├── README.md
|
| 115 |
-
├──
|
| 116 |
-
├──
|
| 117 |
-
├──
|
| 118 |
-
├──
|
| 119 |
-
├──
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
│ ├── 복무관리규정.txt
|
| 121 |
│ ├── 인사평가규정.txt
|
| 122 |
│ └── ...
|
| 123 |
-
└── faiss_index/
|
| 124 |
```
|
| 125 |
|
| 126 |
## ⚙️ 설정 옵션
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
### config.py 주요 설정
|
| 129 |
|
| 130 |
```python
|
| 131 |
-
# 모델 설정
|
| 132 |
-
EMBEDDING_MODEL = "jhgan/ko-sbert-nli" # 한국어 임베딩 모델
|
| 133 |
-
LLM_MODEL = "beomi/Llama-3-Open-Ko-8B" # 한국어 LLM
|
| 134 |
-
|
| 135 |
# RAG 파라미터
|
| 136 |
CHUNK_SIZE = 500 # 문서 청크 크기
|
| 137 |
CHUNK_OVERLAP = 50 # 청크 중복 크기
|
| 138 |
MAX_RETRIEVE_DOCS = 3 # 검색할 문서 수
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
# 경로 설정
|
| 141 |
DOCS_FOLDER = "documents" # 문서 폴더
|
| 142 |
VECTOR_DB_PATH = "faiss_index" # 벡터 DB 경로
|
| 143 |
```
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
## 🧪 사용 예시
|
| 146 |
|
| 147 |
### 기본 질문
|
|
|
|
| 16 |
## 📋 프로젝트 개요
|
| 17 |
|
| 18 |
- **목적**: 소방업무 복무관리 관련 문서들을 활용한 지능형 질의응답 시스템
|
| 19 |
+
- **기술**: RAG, OpenAI GPT, Supabase pgvector, Sentence-BERT, Gradio
|
| 20 |
- **문서 유형**: PDF, Word, TXT, Excel, CSV
|
| 21 |
+
- **특징**: 한국어 최적화, 실시간 검색, 클라우드 벡터 DB, 웹 인터페이스
|
| 22 |
|
| 23 |
## 🎯 주요 기능
|
| 24 |
|
|
|
|
| 78 |
pip install -r requirements.txt
|
| 79 |
```
|
| 80 |
|
| 81 |
+
### 2. OpenAI API 설정 (권장)
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
# OpenAI API Key 발급: https://platform.openai.com/api-keys
|
| 85 |
+
# .env 파일에 설정
|
| 86 |
+
echo "OPENAI_API_KEY=sk-your-api-key-here" >> .env
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
### 3. Supabase 설정 (권장 - 클라우드 벡터 DB)
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
# 1. Supabase 프로젝트 생성: https://supabase.com
|
| 93 |
+
# 2. SQL Editor에서 supabase_setup.sql 실행
|
| 94 |
+
# 3. Settings > API에서 URL과 Key 확인
|
| 95 |
+
|
| 96 |
+
# .env 파일에 Supabase 설정 추가
|
| 97 |
+
echo "SUPABASE_URL=https://your-project.supabase.co" >> .env
|
| 98 |
+
echo "SUPABASE_KEY=your-supabase-anon-key" >> .env
|
| 99 |
+
echo "VECTOR_DB_TYPE=supabase" >> .env
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### 4. 문서 준비
|
| 103 |
|
| 104 |
```bash
|
| 105 |
# documents 폴더 생성
|
|
|
|
| 129 |
|
| 130 |
```
|
| 131 |
119_chatbot/
|
| 132 |
+
├── app.py # 허깅페이스 배포용 메인 파일
|
| 133 |
+
├── config.py # 시스템 설정
|
| 134 |
+
├── requirements.txt # 라이브러리 목록
|
| 135 |
+
├── README.md # 프로젝트 설명서
|
| 136 |
+
├── .env.example # 환경 변수 템플릿
|
| 137 |
+
├── supabase_setup.sql # Supabase 설정 SQL
|
| 138 |
+
├── document_processor.py # 문서 처리 모듈
|
| 139 |
+
├── vector_store.py # FAISS 벡터 데이터베이스
|
| 140 |
+
├── supabase_vector_store.py # Supabase 벡터 데이터베이스
|
| 141 |
+
├── rag_chatbot.py # 기존 RAG 챗봇
|
| 142 |
+
├── openai_chatbot.py # OpenAI 기반 RAG 챗봇
|
| 143 |
+
├── gradio_interface.py # Gradio 웹 인터페이스
|
| 144 |
+
├── documents/ # 문서 폴더
|
| 145 |
│ ├── 복무관리규정.txt
|
| 146 |
│ ├── 인사평가규정.txt
|
| 147 |
│ └── ...
|
| 148 |
+
└── faiss_index/ # 벡터 인덱스 캐시 (FAISS 사용시)
|
| 149 |
```
|
| 150 |
|
| 151 |
## ⚙️ 설정 옵션
|
| 152 |
|
| 153 |
+
### 환경 변수 설정 (.env 파일)
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
# OpenAI API (필수)
|
| 157 |
+
OPENAI_API_KEY=sk-your-openai-api-key
|
| 158 |
+
OPENAI_MODEL=gpt-4o-mini
|
| 159 |
+
OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
| 160 |
+
|
| 161 |
+
# Supabase (클라우드 벡터 DB 사용시 필수)
|
| 162 |
+
SUPABASE_URL=https://your-project.supabase.co
|
| 163 |
+
SUPABASE_KEY=your-supabase-anon-key
|
| 164 |
+
|
| 165 |
+
# 벡터 DB 타입 선택
|
| 166 |
+
VECTOR_DB_TYPE=supabase # "supabase" 또는 "faiss"
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
### config.py 주요 설정
|
| 170 |
|
| 171 |
```python
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
# RAG 파라미터
|
| 173 |
CHUNK_SIZE = 500 # 문서 청크 크기
|
| 174 |
CHUNK_OVERLAP = 50 # 청크 중복 크기
|
| 175 |
MAX_RETRIEVE_DOCS = 3 # 검색할 문서 수
|
| 176 |
|
| 177 |
+
# 기존 로컬 모델 설정 (FAISS 사용시)
|
| 178 |
+
EMBEDDING_MODEL = "jhgan/ko-sbert-nli" # 한국어 임베딩 모델
|
| 179 |
+
LLM_MODEL = "beomi/Llama-3-Open-Ko-8B" # 한국어 LLM
|
| 180 |
+
|
| 181 |
# 경로 설정
|
| 182 |
DOCS_FOLDER = "documents" # 문서 폴더
|
| 183 |
VECTOR_DB_PATH = "faiss_index" # 벡터 DB 경로
|
| 184 |
```
|
| 185 |
|
| 186 |
+
### 🔄 벡터 DB 옵션
|
| 187 |
+
|
| 188 |
+
#### 1. Supabase (권장) - 클라우드 벡터 DB
|
| 189 |
+
- **장점**: 클라우드 저장, 확장성, 동시성, 실시간 동기화
|
| 190 |
+
- **필요사항**: OpenAI API Key, Supabase 프로젝트
|
| 191 |
+
- **설정**: `VECTOR_DB_TYPE=supabase`
|
| 192 |
+
|
| 193 |
+
#### 2. FAISS (로컬) - 오프라인 벡터 DB
|
| 194 |
+
- **장점**: 로컬 실행, 무료, 빠른 초기 설정
|
| 195 |
+
- **단점**: 로컬 저장 공간 필요, 확장성 제한
|
| 196 |
+
- **설정**: `VECTOR_DB_TYPE=faiss`
|
| 197 |
+
|
| 198 |
## 🧪 사용 예시
|
| 199 |
|
| 200 |
### 기본 질문
|
|
@@ -19,13 +19,18 @@ sys.path.append(current_dir)
|
|
| 19 |
# 모듈 임포트
|
| 20 |
from config import Config
|
| 21 |
from rag_chatbot import RAGChatbot
|
|
|
|
| 22 |
from document_processor import DocumentProcessor
|
| 23 |
|
| 24 |
class HuggingFaceApp:
|
| 25 |
"""허깅페이스 Spaces 배포용 앱 클래스"""
|
| 26 |
|
| 27 |
def __init__(self):
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
self.is_initialized = False
|
| 30 |
|
| 31 |
# 예시 질문 (허깅페이스 환경 최적화)
|
|
@@ -44,7 +49,7 @@ class HuggingFaceApp:
|
|
| 44 |
def _initialize_app(self):
|
| 45 |
"""앱 초기화"""
|
| 46 |
try:
|
| 47 |
-
print("
|
| 48 |
|
| 49 |
# 문서 폴더 확인 및 샘플 데이터 생성
|
| 50 |
self._ensure_documents()
|
|
@@ -53,12 +58,12 @@ class HuggingFaceApp:
|
|
| 53 |
success = self.chatbot.initialize()
|
| 54 |
if success:
|
| 55 |
self.is_initialized = True
|
| 56 |
-
print("
|
| 57 |
else:
|
| 58 |
-
print("
|
| 59 |
|
| 60 |
except Exception as e:
|
| 61 |
-
print(f"
|
| 62 |
# 오류가 있어도 앱은 계속 실행
|
| 63 |
|
| 64 |
def _ensure_documents(self):
|
|
@@ -69,7 +74,7 @@ class HuggingFaceApp:
|
|
| 69 |
# 샘플 문서가 없으면 생성
|
| 70 |
sample_files = list(docs_folder.glob("*.txt"))
|
| 71 |
if not sample_files:
|
| 72 |
-
print("
|
| 73 |
self._create_sample_documents()
|
| 74 |
|
| 75 |
def _create_sample_documents(self):
|
|
|
|
| 19 |
# 모듈 임포트
|
| 20 |
from config import Config
|
| 21 |
from rag_chatbot import RAGChatbot
|
| 22 |
+
from openai_chatbot import OpenAIRAGChatbot
|
| 23 |
from document_processor import DocumentProcessor
|
| 24 |
|
| 25 |
class HuggingFaceApp:
|
| 26 |
"""허깅페이스 Spaces 배포용 앱 클래스"""
|
| 27 |
|
| 28 |
def __init__(self):
|
| 29 |
+
# 벡터 DB 타입에 따라 챗봇 선택
|
| 30 |
+
if Config.VECTOR_DB_TYPE == "supabase":
|
| 31 |
+
self.chatbot = OpenAIRAGChatbot()
|
| 32 |
+
else:
|
| 33 |
+
self.chatbot = RAGChatbot()
|
| 34 |
self.is_initialized = False
|
| 35 |
|
| 36 |
# 예시 질문 (허깅페이스 환경 최적화)
|
|
|
|
| 49 |
def _initialize_app(self):
|
| 50 |
"""앱 초기화"""
|
| 51 |
try:
|
| 52 |
+
print("Starting Fire Service Management RAG Chatbot...")
|
| 53 |
|
| 54 |
# 문서 폴더 확인 및 샘플 데이터 생성
|
| 55 |
self._ensure_documents()
|
|
|
|
| 58 |
success = self.chatbot.initialize()
|
| 59 |
if success:
|
| 60 |
self.is_initialized = True
|
| 61 |
+
print("Chatbot initialization completed")
|
| 62 |
else:
|
| 63 |
+
print("Chatbot initialization failed - running in template mode")
|
| 64 |
|
| 65 |
except Exception as e:
|
| 66 |
+
print(f"Initialization error: {str(e)}")
|
| 67 |
# 오류가 있어도 앱은 계속 실행
|
| 68 |
|
| 69 |
def _ensure_documents(self):
|
|
|
|
| 74 |
# 샘플 문서가 없으면 생성
|
| 75 |
sample_files = list(docs_folder.glob("*.txt"))
|
| 76 |
if not sample_files:
|
| 77 |
+
print("Creating sample documents...")
|
| 78 |
self._create_sample_documents()
|
| 79 |
|
| 80 |
def _create_sample_documents(self):
|
|
@@ -28,6 +28,18 @@ class Config:
|
|
| 28 |
# 토큰 설정 (필요시)
|
| 29 |
HUGGINGFACE_TOKEN = os.getenv("HUGGINGFACE_TOKEN", "")
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
# 시스템 프롬프트
|
| 32 |
SYSTEM_PROMPT = """
|
| 33 |
당신은 소방업무 복무관리 전문가입니다. 복무관리 규정, 인사운영, 근무절차 등에 대한 질문에 정확하고 친절하게 답변해 주세요.
|
|
|
|
| 28 |
# 토큰 설정 (필요시)
|
| 29 |
HUGGINGFACE_TOKEN = os.getenv("HUGGINGFACE_TOKEN", "")
|
| 30 |
|
| 31 |
+
# Supabase 설정
|
| 32 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
| 33 |
+
SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
|
| 34 |
+
|
| 35 |
+
# OpenAI 설정
|
| 36 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
| 37 |
+
OPENAI_MODEL = "gpt-4o-mini" # 또는 "gpt-3.5-turbo"
|
| 38 |
+
OPENAI_EMBEDDING_MODEL = "text-embedding-3-small"
|
| 39 |
+
|
| 40 |
+
# 벡터 DB 타입 설정
|
| 41 |
+
VECTOR_DB_TYPE = os.getenv("VECTOR_DB_TYPE", "supabase") # "faiss" 또는 "supabase"
|
| 42 |
+
|
| 43 |
# 시스템 프롬프트
|
| 44 |
SYSTEM_PROMPT = """
|
| 45 |
당신은 소방업무 복무관리 전문가입니다. 복무관리 규정, 인사운영, 근무절차 등에 대한 질문에 정확하고 친절하게 답변해 주세요.
|
|
@@ -2,11 +2,11 @@ import os
|
|
| 2 |
import re
|
| 3 |
from typing import List, Dict
|
| 4 |
from pathlib import Path
|
| 5 |
-
import PyMuPDF
|
| 6 |
import docx
|
| 7 |
import pandas as pd
|
| 8 |
-
from
|
| 9 |
-
from
|
| 10 |
|
| 11 |
class DocumentProcessor:
|
| 12 |
"""복무관리 문서 처리 클래스"""
|
|
@@ -63,7 +63,7 @@ class DocumentProcessor:
|
|
| 63 |
documents = []
|
| 64 |
|
| 65 |
try:
|
| 66 |
-
with
|
| 67 |
full_text = ""
|
| 68 |
|
| 69 |
for page_num in range(len(doc)):
|
|
|
|
| 2 |
import re
|
| 3 |
from typing import List, Dict
|
| 4 |
from pathlib import Path
|
| 5 |
+
import fitz # PyMuPDF
|
| 6 |
import docx
|
| 7 |
import pandas as pd
|
| 8 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 9 |
+
from langchain_core.documents import Document
|
| 10 |
|
| 11 |
class DocumentProcessor:
|
| 12 |
"""복무관리 문서 처리 클래스"""
|
|
|
|
| 63 |
documents = []
|
| 64 |
|
| 65 |
try:
|
| 66 |
+
with fitz.open(file_path) as doc:
|
| 67 |
full_text = ""
|
| 68 |
|
| 69 |
for page_num in range(len(doc)):
|
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
소방공무원 복무관리 규정
|
| 2 |
+
|
| 3 |
+
제1장 총칙
|
| 4 |
+
제1조 (목적)
|
| 5 |
+
이 규정은 소방공무원의 복무에 관한 기본사항을 규정하여 직무수행의 효율성을 높이고 조직의 발전에 기여함을 목적으로 한다.
|
| 6 |
+
|
| 7 |
+
제2조 (근무시간)
|
| 8 |
+
1. 정규근무시간은 09:00부터 18:00까지로 한다.
|
| 9 |
+
2. 점심시간은 12:00부터 13:00까지로 한다.
|
| 10 |
+
3. 토요일, 일요일 및 법정공휴일은 휴무일로 한다.
|
| 11 |
+
|
| 12 |
+
제3조 (연차휴가)
|
| 13 |
+
1. 연차휴가는 1년간 정상 근무한 자에게 15일을 부여한다.
|
| 14 |
+
2. 연차휴가 사용 시 3일 전까지 신청서를 제출해야 한다.
|
| 15 |
+
3. 부서장의 승인을 받아 사용하며, 긴급한 경우에는 사후 승인도 가능하다.
|
| 16 |
+
|
| 17 |
+
제4조 (당직근무)
|
| 18 |
+
1. 당직근무는 정규근무시간 외에 수행하는 근무를 말한다.
|
| 19 |
+
2. 당직자는 비상상황에 대비한 통신장비를 항시 점검해야 한다.
|
| 20 |
+
3. 당직 중에는 음주를 엄금하며, 직무 수행에 지장이 없는 행위만 가능하다.
|
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import time
|
| 3 |
+
from typing import List, Dict, Tuple
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
import openai
|
| 6 |
+
from supabase_vector_store import SupabaseVectorStore
|
| 7 |
+
from document_processor import DocumentProcessor
|
| 8 |
+
from config import Config
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class ChatResponse:
|
| 12 |
+
"""챗봇 응답 결과 클래스"""
|
| 13 |
+
answer: str
|
| 14 |
+
sources: List[Dict]
|
| 15 |
+
confidence: float
|
| 16 |
+
response_time: float
|
| 17 |
+
|
| 18 |
+
class OpenAIRAGChatbot:
|
| 19 |
+
"""OpenAI 기반 RAG 챗봇"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
self.document_processor = DocumentProcessor(
|
| 23 |
+
chunk_size=Config.CHUNK_SIZE,
|
| 24 |
+
chunk_overlap=Config.CHUNK_OVERLAP
|
| 25 |
+
)
|
| 26 |
+
self.vector_store = None
|
| 27 |
+
self.openai_client = None
|
| 28 |
+
self.is_initialized = False
|
| 29 |
+
|
| 30 |
+
def initialize(self, docs_folder: str = None, force_rebuild: bool = False) -> bool:
|
| 31 |
+
"""챗봇 초기화"""
|
| 32 |
+
print("🤖 OpenAI 기반 소방 복무관리 RAG 챗봇 초기화 중...")
|
| 33 |
+
|
| 34 |
+
# 1. OpenAI 클라이언트 초기화
|
| 35 |
+
if not Config.OPENAI_API_KEY:
|
| 36 |
+
print("❌ OpenAI API Key가 설정되지 않았습니다.")
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
self.openai_client = openai.OpenAI(api_key=Config.OPENAI_API_KEY)
|
| 40 |
+
|
| 41 |
+
# 2. 벡터 저장소 초기화
|
| 42 |
+
try:
|
| 43 |
+
self.vector_store = SupabaseVectorStore()
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"❌ 벡터 저장소 초기화 실패: {str(e)}")
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
# 3. 문서 로드 및 처리
|
| 49 |
+
docs_folder = docs_folder or Config.DOCS_FOLDER
|
| 50 |
+
documents = self._load_documents(docs_folder)
|
| 51 |
+
|
| 52 |
+
if not documents:
|
| 53 |
+
print("❌ 처리할 문서가 없습니다. documents 폴더에 파일을 넣어주세요.")
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
# 4. 벡터 데이터베이스 구축
|
| 57 |
+
success = self.vector_store.rebuild_index(documents, force_rebuild, use_openai=True)
|
| 58 |
+
if not success:
|
| 59 |
+
print("❌ 벡터 데이터베이스 구축 실패")
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
self.is_initialized = True
|
| 63 |
+
print("✅ OpenAI RAG 챗봇 초기화 완료")
|
| 64 |
+
return True
|
| 65 |
+
|
| 66 |
+
def _load_documents(self, docs_folder: str) -> List:
|
| 67 |
+
"""문서 로드 및 처리"""
|
| 68 |
+
if not os.path.exists(docs_folder):
|
| 69 |
+
print(f"⚠️ 문서 폴더가 존재하지 않습니다: {docs_folder}")
|
| 70 |
+
return []
|
| 71 |
+
|
| 72 |
+
print(f"📂 문서 폴더: {docs_folder}")
|
| 73 |
+
raw_documents = self.document_processor.load_documents_from_folder(docs_folder)
|
| 74 |
+
processed_documents = self.document_processor.process_documents(raw_documents)
|
| 75 |
+
|
| 76 |
+
print(f"✅ 총 {len(processed_documents)}개 문서 청크 생성 완료")
|
| 77 |
+
return processed_documents
|
| 78 |
+
|
| 79 |
+
def search_relevant_docs(self, query: str, k: int = 3) -> List[Tuple]:
|
| 80 |
+
"""관련 문서 검색"""
|
| 81 |
+
if not self.is_initialized:
|
| 82 |
+
print("⚠️ 챗봇이 초기화되지 않았습니다.")
|
| 83 |
+
return []
|
| 84 |
+
|
| 85 |
+
# 쿼리 전처리
|
| 86 |
+
processed_query = self._preprocess_query(query)
|
| 87 |
+
|
| 88 |
+
# 벡터 검색
|
| 89 |
+
results = self.vector_store.search_similar(processed_query, k, use_openai=True)
|
| 90 |
+
|
| 91 |
+
# 유사도 필터링
|
| 92 |
+
filtered_results = [
|
| 93 |
+
(doc, similarity) for doc, similarity in results
|
| 94 |
+
if similarity > 0.3 # 최소 유사도 임계값
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
return filtered_results
|
| 98 |
+
|
| 99 |
+
def _preprocess_query(self, query: str) -> str:
|
| 100 |
+
"""쿼리 전처리"""
|
| 101 |
+
import re
|
| 102 |
+
|
| 103 |
+
# 불필요한 공백 제거
|
| 104 |
+
query = re.sub(r'\s+', ' ', query.strip())
|
| 105 |
+
|
| 106 |
+
# 복무관리 관련 키워드 강화
|
| 107 |
+
keyword_mappings = {
|
| 108 |
+
"연차": "연차휴가",
|
| 109 |
+
"휴가": "휴가사용",
|
| 110 |
+
"근무": "근무시간",
|
| 111 |
+
"당직": "당직근무",
|
| 112 |
+
"인사": "인사평가",
|
| 113 |
+
"승진": "승진시험"
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
for keyword, enhanced in keyword_mappings.items():
|
| 117 |
+
if keyword in query and enhanced not in query:
|
| 118 |
+
query = query.replace(keyword, enhanced)
|
| 119 |
+
|
| 120 |
+
return query
|
| 121 |
+
|
| 122 |
+
def generate_answer(self, query: str) -> ChatResponse:
|
| 123 |
+
"""질문에 대한 답변 생성 (OpenAI 사용)"""
|
| 124 |
+
start_time = time.time()
|
| 125 |
+
|
| 126 |
+
if not self.is_initialized:
|
| 127 |
+
return ChatResponse(
|
| 128 |
+
answer="죄송합니다. 챗봇이 초기화되지 않았습니다. 관리자에게 문의해주세요.",
|
| 129 |
+
sources=[],
|
| 130 |
+
confidence=0.0,
|
| 131 |
+
response_time=time.time() - start_time
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# 1. 관련 문서 검색
|
| 135 |
+
relevant_docs = self.search_relevant_docs(query, k=Config.MAX_RETRIEVE_DOCS)
|
| 136 |
+
|
| 137 |
+
if not relevant_docs:
|
| 138 |
+
return ChatResponse(
|
| 139 |
+
answer="죄송합니다. 질문과 관련된 정보를 찾을 수 없습니다. 다른 방식으로 질문해주시거나 관련 부서에 문의해주시기 바랍니다.",
|
| 140 |
+
sources=[],
|
| 141 |
+
confidence=0.0,
|
| 142 |
+
response_time=time.time() - start_time
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# 2. OpenAI로 답변 생성
|
| 146 |
+
answer = self._generate_openai_answer(query, relevant_docs)
|
| 147 |
+
|
| 148 |
+
# 3. 출처 정보 준비
|
| 149 |
+
sources = [
|
| 150 |
+
{
|
| 151 |
+
"source": doc.metadata.get("source", "알 수 없음"),
|
| 152 |
+
"content": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content,
|
| 153 |
+
"similarity": f"{similarity:.4f}"
|
| 154 |
+
}
|
| 155 |
+
for doc, similarity in relevant_docs
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
# 4. 신뢰도 계산
|
| 159 |
+
confidence = min(sum(similarity for _, similarity in relevant_docs) / len(relevant_docs), 1.0)
|
| 160 |
+
|
| 161 |
+
return ChatResponse(
|
| 162 |
+
answer=answer,
|
| 163 |
+
sources=sources,
|
| 164 |
+
confidence=confidence,
|
| 165 |
+
response_time=time.time() - start_time
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
def _generate_openai_answer(self, query: str, relevant_docs: List[Tuple]) -> str:
|
| 169 |
+
"""OpenAI로 답변 생성"""
|
| 170 |
+
try:
|
| 171 |
+
# 문맥 구성
|
| 172 |
+
context = "\n\n".join([
|
| 173 |
+
f"[출처 {i+1}] {doc.page_content}"
|
| 174 |
+
for i, (doc, _) in enumerate(relevant_docs)
|
| 175 |
+
])
|
| 176 |
+
|
| 177 |
+
# OpenAI API 호출
|
| 178 |
+
messages = [
|
| 179 |
+
{
|
| 180 |
+
"role": "system",
|
| 181 |
+
"content": f"""{Config.SYSTEM_PROMPT}
|
| 182 |
+
|
| 183 |
+
답변 시 다음 지침을 따르세요:
|
| 184 |
+
1. 반드시 아래 참고자료를 기반으로 답변하세요
|
| 185 |
+
2. 규정 조문이나 구체적인 절차를 명시하세요
|
| 186 |
+
3. 단계별 설명이 필요한 경우 번호로 구분해서 설명하세요
|
| 187 |
+
4. 필요한 서류나 양식을 구체적으로 안내하세요
|
| 188 |
+
5. 주의사항이나 중요 사항은 강조해주세요
|
| 189 |
+
6. 답변 마지막에 참고한 출처를 표시하세요"""
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"role": "user",
|
| 193 |
+
"content": f"""[참고자료]
|
| 194 |
+
{context}
|
| 195 |
+
|
| 196 |
+
[질문]
|
| 197 |
+
{query}
|
| 198 |
+
|
| 199 |
+
위 참고자료를 바탕으로 질문에 답변해주세요. 정확하고 친절하게 설명해주세요."""
|
| 200 |
+
}
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
response = self.openai_client.chat.completions.create(
|
| 204 |
+
model=Config.OPENAI_MODEL,
|
| 205 |
+
messages=messages,
|
| 206 |
+
max_tokens=2000,
|
| 207 |
+
temperature=0.3, # 더 일관된 답변을 위해 낮은 온도
|
| 208 |
+
top_p=0.9
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
answer = response.choices[0].message.content.strip()
|
| 212 |
+
return answer
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
print(f"⚠️ OpenAI 답변 생성 실패: {str(e)}")
|
| 216 |
+
return self._generate_fallback_answer(query, relevant_docs)
|
| 217 |
+
|
| 218 |
+
def _generate_fallback_answer(self, query: str, relevant_docs: List[Tuple]) -> str:
|
| 219 |
+
"""OpenAI 실패 시 대체 답변 생성"""
|
| 220 |
+
top_doc, top_similarity = relevant_docs[0]
|
| 221 |
+
|
| 222 |
+
answer = f"""📋 소방 복무관리 안내
|
| 223 |
+
|
| 224 |
+
질문: {query}
|
| 225 |
+
|
| 226 |
+
관련 정보:
|
| 227 |
+
{top_doc.page_content[:800]}...
|
| 228 |
+
|
| 229 |
+
📖 더 자세한 정보는 관련 규정 파일을 확인하시거나 담당 부서에 문의해주시기 바랍니다.
|
| 230 |
+
|
| 231 |
+
*참고자료 유사도: {top_similarity:.2%}*"""
|
| 232 |
+
|
| 233 |
+
return answer
|
| 234 |
+
|
| 235 |
+
def get_stats(self) -> Dict:
|
| 236 |
+
"""챗봇 통계 정보"""
|
| 237 |
+
if not self.is_initialized:
|
| 238 |
+
return {"status": "not_initialized"}
|
| 239 |
+
|
| 240 |
+
vector_stats = self.vector_store.get_stats()
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
"status": "initialized",
|
| 244 |
+
"vector_store": vector_stats,
|
| 245 |
+
"llm_provider": "openai",
|
| 246 |
+
"llm_model": Config.OPENAI_MODEL,
|
| 247 |
+
"embedding_model": Config.OPENAI_EMBEDDING_MODEL
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
def add_documents(self, documents: List) -> bool:
|
| 251 |
+
"""새 문서 추가"""
|
| 252 |
+
if not self.is_initialized:
|
| 253 |
+
print("⚠️ 챗봇이 초기화되지 않았습니다.")
|
| 254 |
+
return False
|
| 255 |
+
|
| 256 |
+
return self.vector_store.add_documents(documents, use_openai=True)
|
| 257 |
+
|
| 258 |
+
# 테스트용 함수
|
| 259 |
+
def test_openai_chatbot():
|
| 260 |
+
"""OpenAI RAG 챗봇 테스트"""
|
| 261 |
+
# 환경 변수 확인
|
| 262 |
+
if not Config.OPENAI_API_KEY:
|
| 263 |
+
print("❌ OPENAI_API_KEY 환경 변수가 필요합니다.")
|
| 264 |
+
return
|
| 265 |
+
|
| 266 |
+
if not Config.SUPABASE_URL or not Config.SUPABASE_KEY:
|
| 267 |
+
print("❌ SUPABASE_URL, SUPABASE_KEY 환경 변수가 필요합니다.")
|
| 268 |
+
return
|
| 269 |
+
|
| 270 |
+
# 챗봇 초기화
|
| 271 |
+
chatbot = OpenAIRAGChatbot()
|
| 272 |
+
success = chatbot.initialize()
|
| 273 |
+
|
| 274 |
+
if not success:
|
| 275 |
+
return
|
| 276 |
+
|
| 277 |
+
# 테스트 질문
|
| 278 |
+
test_questions = [
|
| 279 |
+
"연차휴가는 어떻게 사용하나요?",
|
| 280 |
+
"정규근무시간은 어떻게 되나요?",
|
| 281 |
+
"당직근무가 무엇인가요?",
|
| 282 |
+
"인사평가 절차가 궁금합니다."
|
| 283 |
+
]
|
| 284 |
+
|
| 285 |
+
# 질문 테스트
|
| 286 |
+
for question in test_questions:
|
| 287 |
+
print(f"\n❓ 질문: {question}")
|
| 288 |
+
response = chatbot.generate_answer(question)
|
| 289 |
+
|
| 290 |
+
print(f"🤖 답변: {response.answer[:500]}...")
|
| 291 |
+
print(f"📊 신뢰도: {response.confidence:.4f}")
|
| 292 |
+
print(f"⏱️ 응답시간: {response.response_time:.4f}초")
|
| 293 |
+
print(f"📚 출처: {len(response.sources)}개")
|
| 294 |
+
|
| 295 |
+
# 통계 정보
|
| 296 |
+
print(f"\n📈 챗봇 통계: {chatbot.get_stats()}")
|
| 297 |
+
|
| 298 |
+
if __name__ == "__main__":
|
| 299 |
+
test_openai_chatbot()
|
|
@@ -4,7 +4,7 @@ from typing import List, Dict, Tuple
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
import torch
|
| 6 |
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
|
| 7 |
-
from
|
| 8 |
from document_processor import DocumentProcessor
|
| 9 |
from vector_store import VectorStore
|
| 10 |
from config import Config
|
|
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
import torch
|
| 6 |
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
|
| 7 |
+
from langchain_core.documents import Document
|
| 8 |
from document_processor import DocumentProcessor
|
| 9 |
from vector_store import VectorStore
|
| 10 |
from config import Config
|
|
@@ -24,6 +24,10 @@ python-docx>=1.1.0
|
|
| 24 |
openpyxl>=3.1.2
|
| 25 |
PyMuPDF>=1.23.8
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
# 유틸리티
|
| 28 |
python-dotenv>=1.0.0
|
| 29 |
tqdm>=4.66.0
|
|
|
|
| 24 |
openpyxl>=3.1.2
|
| 25 |
PyMuPDF>=1.23.8
|
| 26 |
|
| 27 |
+
# Supabase
|
| 28 |
+
supabase>=2.0.0
|
| 29 |
+
openai>=1.0.0
|
| 30 |
+
|
| 31 |
# 유틸리티
|
| 32 |
python-dotenv>=1.0.0
|
| 33 |
tqdm>=4.66.0
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Supabase pgvector 설정 SQL
|
| 2 |
+
-- 이 SQL을 Supabase Dashboard > SQL Editor에서 실행하세요
|
| 3 |
+
|
| 4 |
+
-- 1. pgvector 확장 활성화
|
| 5 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 6 |
+
|
| 7 |
+
-- 2. documents 테이블 생성
|
| 8 |
+
CREATE TABLE IF NOT EXISTS documents (
|
| 9 |
+
id SERIAL PRIMARY KEY,
|
| 10 |
+
content TEXT NOT NULL,
|
| 11 |
+
metadata JSONB,
|
| 12 |
+
embedding vector(1536), -- OpenAI text-embedding-3-small 차원
|
| 13 |
+
source_file VARCHAR(255),
|
| 14 |
+
chunk_index INTEGER,
|
| 15 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 16 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 17 |
+
);
|
| 18 |
+
|
| 19 |
+
-- 3. 벡터 유사도 검색을 위한 인덱스 생성
|
| 20 |
+
CREATE INDEX IF NOT EXISTS documents_embedding_idx
|
| 21 |
+
ON documents
|
| 22 |
+
USING ivfflat (embedding vector_cosine_ops)
|
| 23 |
+
WITH (lists = 100);
|
| 24 |
+
|
| 25 |
+
-- 4. 전문 검색을 위한 인덱스 생성 (기본 영어 설정)
|
| 26 |
+
CREATE INDEX IF NOT EXISTS documents_content_idx
|
| 27 |
+
ON documents
|
| 28 |
+
USING gin(to_tsvector('english', content));
|
| 29 |
+
|
| 30 |
+
-- 5. 소스 파일별 검색 인덱스
|
| 31 |
+
CREATE INDEX IF NOT EXISTS documents_source_file_idx
|
| 32 |
+
ON documents (source_file);
|
| 33 |
+
|
| 34 |
+
-- 6. 벡터 유사도 검색 함수 생성
|
| 35 |
+
CREATE OR REPLACE FUNCTION search_similar_documents(
|
| 36 |
+
query_embedding vector(1536),
|
| 37 |
+
match_threshold float DEFAULT 0.5,
|
| 38 |
+
match_count int DEFAULT 10
|
| 39 |
+
)
|
| 40 |
+
RETURNS TABLE (
|
| 41 |
+
id int,
|
| 42 |
+
content text,
|
| 43 |
+
metadata jsonb,
|
| 44 |
+
source_file varchar(255),
|
| 45 |
+
similarity float
|
| 46 |
+
) AS $$
|
| 47 |
+
BEGIN
|
| 48 |
+
RETURN QUERY
|
| 49 |
+
SELECT
|
| 50 |
+
d.id,
|
| 51 |
+
d.content,
|
| 52 |
+
d.metadata,
|
| 53 |
+
d.source_file,
|
| 54 |
+
1 - (d.embedding <=> query_embedding) as similarity
|
| 55 |
+
FROM documents d
|
| 56 |
+
WHERE 1 - (d.embedding <=> query_embedding) > match_threshold
|
| 57 |
+
ORDER BY similarity DESC
|
| 58 |
+
LIMIT match_count;
|
| 59 |
+
END;
|
| 60 |
+
$$ LANGUAGE plpgsql;
|
| 61 |
+
|
| 62 |
+
-- 7. 자동 타임스탬프 업데이트 함수
|
| 63 |
+
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
| 64 |
+
RETURNS TRIGGER AS $$
|
| 65 |
+
BEGIN
|
| 66 |
+
NEW.updated_at = NOW();
|
| 67 |
+
RETURN NEW;
|
| 68 |
+
END;
|
| 69 |
+
$$ LANGUAGE plpgsql;
|
| 70 |
+
|
| 71 |
+
-- 8. documents 테이블에 트리거 추가
|
| 72 |
+
CREATE TRIGGER update_documents_updated_at
|
| 73 |
+
BEFORE UPDATE ON documents
|
| 74 |
+
FOR EACH ROW
|
| 75 |
+
EXECUTE FUNCTION update_updated_at_column();
|
| 76 |
+
|
| 77 |
+
-- 9. RLS (Row Level Security) 설정 (선택사항)
|
| 78 |
+
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
|
| 79 |
+
|
| 80 |
+
-- 10. 읽기 권한 정책 (인증된 사용자에게 허용)
|
| 81 |
+
CREATE POLICY "Enable read access for all authenticated users" ON documents
|
| 82 |
+
FOR SELECT USING (auth.role() = 'authenticated');
|
| 83 |
+
|
| 84 |
+
-- 11. 쓰기 권한 정책 (서비스 롤에게 허용)
|
| 85 |
+
CREATE POLICY "Enable write access for service role" ON documents
|
| 86 |
+
FOR ALL USING (auth.role() = 'service_role')
|
| 87 |
+
WITH CHECK (auth.role() = 'service_role');
|
| 88 |
+
|
| 89 |
+
-- 12. 초기 데이터 확인용 쿼리
|
| 90 |
+
SELECT 'Setup completed successfully!' as status;
|
| 91 |
+
|
| 92 |
+
-- 13. 테이블 정보 확인
|
| 93 |
+
SELECT
|
| 94 |
+
column_name,
|
| 95 |
+
data_type,
|
| 96 |
+
is_nullable
|
| 97 |
+
FROM information_schema.columns
|
| 98 |
+
WHERE table_name = 'documents'
|
| 99 |
+
ORDER BY ordinal_position;
|
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 가장 기본적인 Supabase 설정
|
| 2 |
+
-- 모든 복잡한 기능을 제거하고 최소한의 기능만 포함
|
| 3 |
+
|
| 4 |
+
-- 1. pgvector 확장 활성화
|
| 5 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 6 |
+
|
| 7 |
+
-- 2. 가장 간단한 documents 테이블 생성
|
| 8 |
+
CREATE TABLE IF NOT EXISTS documents (
|
| 9 |
+
id SERIAL PRIMARY KEY,
|
| 10 |
+
content TEXT NOT NULL,
|
| 11 |
+
metadata JSONB DEFAULT '{}',
|
| 12 |
+
embedding vector(1536), -- OpenAI text-embedding-3-small 차원
|
| 13 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 14 |
+
);
|
| 15 |
+
|
| 16 |
+
-- 3. 성공 메시지
|
| 17 |
+
SELECT 'Minimal Supabase setup completed!' as status;
|
| 18 |
+
|
| 19 |
+
-- 4. 테이블 구조 확인
|
| 20 |
+
SELECT
|
| 21 |
+
column_name,
|
| 22 |
+
data_type
|
| 23 |
+
FROM information_schema.columns
|
| 24 |
+
WHERE table_name = 'documents'
|
| 25 |
+
ORDER BY ordinal_position;
|
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- 간단한 Supabase 설정 SQL
|
| 2 |
+
-- 이 버전은 기본 기능만 포함하여 호환성을 높입니다.
|
| 3 |
+
|
| 4 |
+
-- 1. pgvector 확장 활성화
|
| 5 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 6 |
+
|
| 7 |
+
-- 2. documents 테이블 생성 (필수 항목만)
|
| 8 |
+
CREATE TABLE IF NOT EXISTS documents (
|
| 9 |
+
id SERIAL PRIMARY KEY,
|
| 10 |
+
content TEXT NOT NULL,
|
| 11 |
+
metadata JSONB DEFAULT '{}',
|
| 12 |
+
embedding vector(1536), -- OpenAI text-embedding-3-small 차원
|
| 13 |
+
source_file VARCHAR(255),
|
| 14 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
-- 3. 기본 인덱스 생성 (테이블 생성 후)
|
| 18 |
+
CREATE INDEX IF NOT EXISTS documents_created_at_idx
|
| 19 |
+
ON documents (created_at);
|
| 20 |
+
|
| 21 |
+
-- 4. 벡터 유사도 검색 함수 (가장 간단한 버전)
|
| 22 |
+
CREATE OR REPLACE FUNCTION search_similar_documents(
|
| 23 |
+
query_embedding vector(1536),
|
| 24 |
+
match_count int DEFAULT 10
|
| 25 |
+
)
|
| 26 |
+
RETURNS TABLE (
|
| 27 |
+
id int,
|
| 28 |
+
content text,
|
| 29 |
+
metadata jsonb,
|
| 30 |
+
similarity float
|
| 31 |
+
) AS $$
|
| 32 |
+
BEGIN
|
| 33 |
+
RETURN QUERY
|
| 34 |
+
SELECT
|
| 35 |
+
d.id,
|
| 36 |
+
d.content,
|
| 37 |
+
d.metadata,
|
| 38 |
+
1 - (d.embedding <=> query_embedding) as similarity
|
| 39 |
+
FROM documents d
|
| 40 |
+
ORDER BY d.embedding <=> query_embedding
|
| 41 |
+
LIMIT match_count;
|
| 42 |
+
END;
|
| 43 |
+
$$ LANGUAGE plpgsql;
|
| 44 |
+
|
| 45 |
+
-- 5. 성공 메시지
|
| 46 |
+
SELECT 'Supabase setup completed successfully!' as status;
|
| 47 |
+
|
| 48 |
+
-- 6. 테이블 구조 확인
|
| 49 |
+
SELECT
|
| 50 |
+
column_name,
|
| 51 |
+
data_type,
|
| 52 |
+
is_nullable
|
| 53 |
+
FROM information_schema.columns
|
| 54 |
+
WHERE table_name = 'documents'
|
| 55 |
+
ORDER BY ordinal_position;
|
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
from typing import List, Dict, Tuple, Optional
|
| 5 |
+
from supabase import create_client, Client
|
| 6 |
+
from langchain_core.documents import Document
|
| 7 |
+
from sentence_transformers import SentenceTransformer
|
| 8 |
+
import openai
|
| 9 |
+
from config import Config
|
| 10 |
+
|
| 11 |
+
class SupabaseVectorStore:
|
| 12 |
+
"""Supabase pgvector 기반 벡터 데이터베이스 클래스"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, embedding_model: str = None):
|
| 15 |
+
self.embedding_model_name = embedding_model or Config.EMBEDDING_MODEL
|
| 16 |
+
self.model = None
|
| 17 |
+
self.supabase: Optional[Client] = None
|
| 18 |
+
self.table_name = "documents"
|
| 19 |
+
|
| 20 |
+
# Supabase 클라이언트 초기화
|
| 21 |
+
self._init_supabase()
|
| 22 |
+
|
| 23 |
+
def _init_supabase(self):
|
| 24 |
+
"""Supabase 클라이언트 초기화"""
|
| 25 |
+
try:
|
| 26 |
+
if not Config.SUPABASE_URL or not Config.SUPABASE_KEY:
|
| 27 |
+
raise ValueError("Supabase URL과 Key가 필요합니다.")
|
| 28 |
+
|
| 29 |
+
self.supabase = create_client(Config.SUPABASE_URL, Config.SUPABASE_KEY)
|
| 30 |
+
print("✅ Supabase 클라이언트 연결 성공")
|
| 31 |
+
|
| 32 |
+
# 테이블이 없으면 생성 (필요시)
|
| 33 |
+
self._create_table_if_not_exists()
|
| 34 |
+
|
| 35 |
+
except Exception as e:
|
| 36 |
+
print(f"❌ Supabase 연결 실패: {str(e)}")
|
| 37 |
+
raise
|
| 38 |
+
|
| 39 |
+
def _create_table_if_not_exists(self):
|
| 40 |
+
"""테이블 생성 (SQL 실행 필요시 관리자에서 직접 실행)"""
|
| 41 |
+
# 아래 SQL은 Supabase SQL 에디터에서 직접 실행해야 함
|
| 42 |
+
create_table_sql = f"""
|
| 43 |
+
-- Enable pgvector extension
|
| 44 |
+
CREATE EXTENSION IF NOT EXISTS vector;
|
| 45 |
+
|
| 46 |
+
-- Create documents table
|
| 47 |
+
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
| 48 |
+
id SERIAL PRIMARY KEY,
|
| 49 |
+
content TEXT NOT NULL,
|
| 50 |
+
metadata JSONB,
|
| 51 |
+
embedding vector(1536), -- OpenAI embedding 차원
|
| 52 |
+
source_file VARCHAR(255),
|
| 53 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
| 54 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
-- Create index for vector similarity search
|
| 58 |
+
CREATE INDEX IF NOT EXISTS documents_embedding_idx ON {self.table_name}
|
| 59 |
+
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
| 60 |
+
|
| 61 |
+
-- Create full-text search index
|
| 62 |
+
CREATE INDEX IF NOT EXISTS documents_content_idx ON {self.table_name}
|
| 63 |
+
USING gin(to_tsvector('korean', content));
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
print(f"📝 아래 SQL을 Supabase SQL 에디터에서 실행해주세요:")
|
| 67 |
+
print(create_table_sql)
|
| 68 |
+
|
| 69 |
+
def load_embedding_model(self, use_openai: bool = True):
|
| 70 |
+
"""임베딩 모델 로드"""
|
| 71 |
+
if self.model is not None:
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
if use_openai and Config.OPENAI_API_KEY:
|
| 75 |
+
print("📥 OpenAI 임베딩 모델 사용")
|
| 76 |
+
self.model = "openai"
|
| 77 |
+
else:
|
| 78 |
+
print(f"📥 임베딩 모델 로드: {self.embedding_model_name}")
|
| 79 |
+
try:
|
| 80 |
+
self.model = SentenceTransformer(self.embedding_model_name)
|
| 81 |
+
print("✅ 임베딩 모델 로드 완료")
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f"❌ 임베딩 모델 로드 실패: {str(e)}")
|
| 84 |
+
print("🔄 다국어 모델로 대체 시도...")
|
| 85 |
+
self.model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
|
| 86 |
+
|
| 87 |
+
def create_embeddings(self, texts: List[str], use_openai: bool = True) -> np.ndarray:
|
| 88 |
+
"""텍스트 목록에 대한 임베딩 생성"""
|
| 89 |
+
if self.model is None:
|
| 90 |
+
self.load_embedding_model(use_openai)
|
| 91 |
+
|
| 92 |
+
print(f"🔄 {len(texts)}개 텍스트 임베딩 생성 중...")
|
| 93 |
+
|
| 94 |
+
if self.model == "openai" and Config.OPENAI_API_KEY:
|
| 95 |
+
# OpenAI API 사용
|
| 96 |
+
client = openai.OpenAI(api_key=Config.OPENAI_API_KEY)
|
| 97 |
+
embeddings = []
|
| 98 |
+
|
| 99 |
+
for i in range(0, len(texts), 100): # 배치 사이즈 100
|
| 100 |
+
batch_texts = texts[i:i+100]
|
| 101 |
+
response = client.embeddings.create(
|
| 102 |
+
model=Config.OPENAI_EMBEDDING_MODEL,
|
| 103 |
+
input=batch_texts
|
| 104 |
+
)
|
| 105 |
+
batch_embeddings = [item.embedding for item in response.data]
|
| 106 |
+
embeddings.extend(batch_embeddings)
|
| 107 |
+
|
| 108 |
+
return np.array(embeddings)
|
| 109 |
+
else:
|
| 110 |
+
# 로컬 모델 사용
|
| 111 |
+
return self.model.encode(
|
| 112 |
+
texts,
|
| 113 |
+
batch_size=32,
|
| 114 |
+
show_progress_bar=True,
|
| 115 |
+
convert_to_numpy=True,
|
| 116 |
+
normalize_embeddings=True
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
def add_documents(self, documents: List[Document], use_openai: bool = True) -> bool:
|
| 120 |
+
"""문서 추가"""
|
| 121 |
+
if not documents:
|
| 122 |
+
print("⚠️ 추가할 문서가 없습니다.")
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
print(f"📝 {len(documents)}개 문서를 Supabase에 추가 중...")
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
# 임베딩 생성
|
| 129 |
+
texts = [doc.page_content for doc in documents]
|
| 130 |
+
embeddings = self.create_embeddings(texts, use_openai)
|
| 131 |
+
|
| 132 |
+
# 문서 데이터 준비
|
| 133 |
+
documents_data = []
|
| 134 |
+
for i, doc in enumerate(documents):
|
| 135 |
+
doc_data = {
|
| 136 |
+
'content': doc.page_content,
|
| 137 |
+
'metadata': doc.metadata,
|
| 138 |
+
'embedding': embeddings[i].tolist()
|
| 139 |
+
}
|
| 140 |
+
documents_data.append(doc_data)
|
| 141 |
+
|
| 142 |
+
# 배치로 데이터 삽입
|
| 143 |
+
batch_size = 100
|
| 144 |
+
for i in range(0, len(documents_data), batch_size):
|
| 145 |
+
batch = documents_data[i:i+batch_size]
|
| 146 |
+
result = self.supabase.table(self.table_name).insert(batch).execute()
|
| 147 |
+
|
| 148 |
+
if not result.data:
|
| 149 |
+
print(f"❌ 배치 삽입 실패 (배치 {i//batch_size + 1})")
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
print(f"✅ {len(documents)}개 문서 추가 완료")
|
| 153 |
+
return True
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
print(f"❌ 문서 추가 실패: {str(e)}")
|
| 157 |
+
return False
|
| 158 |
+
|
| 159 |
+
def search_similar(self, query: str, k: int = 5, use_openai: bool = True) -> List[Tuple[Document, float]]:
|
| 160 |
+
"""유사 문서 검색"""
|
| 161 |
+
if self.supabase is None:
|
| 162 |
+
print("⚠️ Supabase 클라이언트가 초기화되지 않았습니다.")
|
| 163 |
+
return []
|
| 164 |
+
|
| 165 |
+
if self.model is None:
|
| 166 |
+
self.load_embedding_model(use_openai)
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
# 쿼리 임베딩 생성
|
| 170 |
+
if self.model == "openai" and Config.OPENAI_API_KEY:
|
| 171 |
+
client = openai.OpenAI(api_key=Config.OPENAI_API_KEY)
|
| 172 |
+
response = client.embeddings.create(
|
| 173 |
+
model=Config.OPENAI_EMBEDDING_MODEL,
|
| 174 |
+
input=[query]
|
| 175 |
+
)
|
| 176 |
+
query_embedding = response.data[0].embedding
|
| 177 |
+
else:
|
| 178 |
+
query_embedding = self.model.encode([query], normalize_embeddings=True)[0]
|
| 179 |
+
query_embedding = query_embedding.tolist()
|
| 180 |
+
|
| 181 |
+
# 유사도 검색 SQL
|
| 182 |
+
match_threshold = 0.5
|
| 183 |
+
match_count = k
|
| 184 |
+
|
| 185 |
+
search_sql = f"""
|
| 186 |
+
SELECT content, metadata, source_file, 1 - (embedding <=> '[{','.join(map(str, query_embedding))}]') as similarity
|
| 187 |
+
FROM {self.table_name}
|
| 188 |
+
WHERE 1 - (embedding <=> '[{','.join(map(str, query_embedding))}]') > {match_threshold}
|
| 189 |
+
ORDER BY similarity DESC
|
| 190 |
+
LIMIT {match_count}
|
| 191 |
+
"""
|
| 192 |
+
|
| 193 |
+
# Supabase RPC 호출
|
| 194 |
+
result = self.supabase.rpc('search_similar_documents', {
|
| 195 |
+
'query_embedding': query_embedding,
|
| 196 |
+
'match_threshold': match_threshold,
|
| 197 |
+
'match_count': match_count
|
| 198 |
+
}).execute()
|
| 199 |
+
|
| 200 |
+
if not result.data:
|
| 201 |
+
# RPC가 없으면 직접 SQL 실행 (권한 필요)
|
| 202 |
+
result = self.supabase.table(self.table_name).select(
|
| 203 |
+
"content, metadata, source_file"
|
| 204 |
+
).execute()
|
| 205 |
+
|
| 206 |
+
# 클라이언트 측에서 유사도 계산
|
| 207 |
+
if result.data:
|
| 208 |
+
similarities = []
|
| 209 |
+
for row in result.data:
|
| 210 |
+
# 저장된 임베딩이 없으면 스킵
|
| 211 |
+
if not row.get('embedding'):
|
| 212 |
+
continue
|
| 213 |
+
similarity = self._cosine_similarity(query_embedding, row['embedding'])
|
| 214 |
+
if similarity > match_threshold:
|
| 215 |
+
similarities.append((row, similarity))
|
| 216 |
+
|
| 217 |
+
# 유사도로 정렬
|
| 218 |
+
similarities.sort(key=lambda x: x[1], reverse=True)
|
| 219 |
+
result.data = [item[0] for item in similarities[:k]]
|
| 220 |
+
|
| 221 |
+
# 결과 변환
|
| 222 |
+
results = []
|
| 223 |
+
for row in result.data[:k]:
|
| 224 |
+
doc = Document(
|
| 225 |
+
page_content=row['content'],
|
| 226 |
+
metadata=row.get('metadata', {}),
|
| 227 |
+
id=row.get('id')
|
| 228 |
+
)
|
| 229 |
+
similarity = row.get('similarity', 1.0) # 기본값 1.0
|
| 230 |
+
results.append((doc, float(similarity)))
|
| 231 |
+
|
| 232 |
+
return results
|
| 233 |
+
|
| 234 |
+
except Exception as e:
|
| 235 |
+
print(f"❌ 검색 실패: {str(e)}")
|
| 236 |
+
return []
|
| 237 |
+
|
| 238 |
+
def _cosine_similarity(self, vec1: List[float], vec2: List[float]) -> float:
|
| 239 |
+
"""코사인 유사도 계산"""
|
| 240 |
+
vec1 = np.array(vec1)
|
| 241 |
+
vec2 = np.array(vec2)
|
| 242 |
+
|
| 243 |
+
dot_product = np.dot(vec1, vec2)
|
| 244 |
+
norm1 = np.linalg.norm(vec1)
|
| 245 |
+
norm2 = np.linalg.norm(vec2)
|
| 246 |
+
|
| 247 |
+
if norm1 == 0 or norm2 == 0:
|
| 248 |
+
return 0.0
|
| 249 |
+
|
| 250 |
+
return dot_product / (norm1 * norm2)
|
| 251 |
+
|
| 252 |
+
def delete_all_documents(self) -> bool:
|
| 253 |
+
"""모든 문서 삭제"""
|
| 254 |
+
try:
|
| 255 |
+
result = self.supabase.table(self.table_name).delete().execute()
|
| 256 |
+
print("✅ 모든 문서 삭제 완료")
|
| 257 |
+
return True
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"❌ 문서 삭제 실패: {str(e)}")
|
| 260 |
+
return False
|
| 261 |
+
|
| 262 |
+
def get_stats(self) -> Dict:
|
| 263 |
+
"""벡터 데이터베이스 통계 정보"""
|
| 264 |
+
try:
|
| 265 |
+
result = self.supabase.table(self.table_name).select("count", count="exact").execute()
|
| 266 |
+
total_docs = result.count if hasattr(result, 'count') else 0
|
| 267 |
+
|
| 268 |
+
return {
|
| 269 |
+
"total_documents": total_docs,
|
| 270 |
+
"embedding_model": self.embedding_model_name,
|
| 271 |
+
"database_type": "supabase",
|
| 272 |
+
"table_name": self.table_name
|
| 273 |
+
}
|
| 274 |
+
except Exception as e:
|
| 275 |
+
print(f"❌ 통계 정보 조회 실패: {str(e)}")
|
| 276 |
+
return {"status": "error", "message": str(e)}
|
| 277 |
+
|
| 278 |
+
def rebuild_index(self, documents: List[Document], force_rebuild: bool = False, use_openai: bool = True) -> bool:
|
| 279 |
+
"""인덱스 재구축"""
|
| 280 |
+
if force_rebuild:
|
| 281 |
+
print("🔄 기존 데이터 삭제 후 재구축...")
|
| 282 |
+
self.delete_all_documents()
|
| 283 |
+
|
| 284 |
+
return self.add_documents(documents, use_openai)
|
| 285 |
+
|
| 286 |
+
# 테스트용 함수
|
| 287 |
+
def test_supabase_vector_store():
|
| 288 |
+
"""Supabase 벡터 데이터베이스 테스트"""
|
| 289 |
+
from document_processor import DocumentProcessor
|
| 290 |
+
|
| 291 |
+
# 문서 처리
|
| 292 |
+
processor = DocumentProcessor()
|
| 293 |
+
documents = processor.load_documents_from_folder("documents")
|
| 294 |
+
|
| 295 |
+
if not documents:
|
| 296 |
+
print("⚠️ 테스트할 문서가 없습니다.")
|
| 297 |
+
return
|
| 298 |
+
|
| 299 |
+
# 벡터 데이터베이스 생성
|
| 300 |
+
vector_store = SupabaseVectorStore()
|
| 301 |
+
|
| 302 |
+
# 문서 추가
|
| 303 |
+
success = vector_store.add_documents(documents[:5]) # 테스트용으로 5개만
|
| 304 |
+
if not success:
|
| 305 |
+
print("❌ 문서 추가 실패")
|
| 306 |
+
return
|
| 307 |
+
|
| 308 |
+
# 검색 테스트
|
| 309 |
+
test_queries = [
|
| 310 |
+
"연차휴가 사용 방법",
|
| 311 |
+
"근무시간은 어떻게 되나요?",
|
| 312 |
+
"당직근무 절차"
|
| 313 |
+
]
|
| 314 |
+
|
| 315 |
+
for query in test_queries:
|
| 316 |
+
print(f"\n🔍 검색: {query}")
|
| 317 |
+
results = vector_store.search_similar(query, k=3)
|
| 318 |
+
|
| 319 |
+
for i, (doc, similarity) in enumerate(results):
|
| 320 |
+
print(f" {i+1}. 유사도: {similarity:.4f}")
|
| 321 |
+
print(f" 내용: {doc.page_content[:100]}...")
|
| 322 |
+
|
| 323 |
+
if __name__ == "__main__":
|
| 324 |
+
test_supabase_vector_store()
|
|
@@ -5,7 +5,7 @@ from typing import List, Dict, Tuple
|
|
| 5 |
from pathlib import Path
|
| 6 |
from sentence_transformers import SentenceTransformer
|
| 7 |
import faiss
|
| 8 |
-
from
|
| 9 |
from config import Config
|
| 10 |
|
| 11 |
class VectorStore:
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
from sentence_transformers import SentenceTransformer
|
| 7 |
import faiss
|
| 8 |
+
from langchain_core.documents import Document
|
| 9 |
from config import Config
|
| 10 |
|
| 11 |
class VectorStore:
|