Spaces:
Sleeping
Sleeping
Muyeong Kim
Claude
commited on
Commit
·
8211554
0
Parent(s):
초기 버전: 소방 복무관리 RAG 챗봇
Browse files주요 기능:
- RAG 기반 질의응답 시스템 구현
- 다양한 문서 형식 지원 (PDF, Word, TXT, Excel)
- 한국어 임베딩 모델 최적화 (jhgan/ko-sbert-nli)
- FAISS 벡터 데이터베이스 활용한 고성능 검색
- Gradio 기반 웹 인터페이스
- 허깅페이스 Spaces 배포 최적화
- 샘플 복무관리 문서 포함
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
- README.md +276 -0
- app.py +395 -0
- config.py +43 -0
- document_processor.py +291 -0
- gradio_interface.py +329 -0
- rag_chatbot.py +376 -0
- requirements.txt +30 -0
- vector_store.py +266 -0
README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚒 소방 복무관리 RAG 챗봇
|
| 2 |
+
|
| 3 |
+
소방공무원 복무관리 규정 및 절차에 대한 질문에 답변해 드리는 RAG(Retrieval-Augmented Generation) 기반 챗봇입니다.
|
| 4 |
+
|
| 5 |
+
## 📋 프로젝트 개요
|
| 6 |
+
|
| 7 |
+
- **목적**: 소방업무 복무관리 관련 문서들을 활용한 지능형 질의응답 시스템
|
| 8 |
+
- **기술**: RAG, Sentence-BERT, FAISS, Gradio, Hugging Face
|
| 9 |
+
- **문서 유형**: PDF, Word, TXT, Excel, CSV
|
| 10 |
+
- **특징**: 한국어 최적화, 실시간 검색, 웹 인터페이스
|
| 11 |
+
|
| 12 |
+
## 🎯 주요 기능
|
| 13 |
+
|
| 14 |
+
### ✅ RAG 검색 시스템
|
| 15 |
+
- 문서 내용 자동 청킹
|
| 16 |
+
- 의미 기반 검색
|
| 17 |
+
- 유사도 측정
|
| 18 |
+
- 다중 문서 검색
|
| 19 |
+
|
| 20 |
+
### ✅ 인터페이스
|
| 21 |
+
- Gradio 기반 웹 채팅
|
| 22 |
+
- 실시간 응답
|
| 23 |
+
- 출처 정보 표시
|
| 24 |
+
- 신뢰도 점수 제공
|
| 25 |
+
|
| 26 |
+
### ✅ 문서 처리
|
| 27 |
+
- 다양한 파일 형식 지원 (PDF, DOCX, TXT, XLSX, CSV)
|
| 28 |
+
- 한국어 텍스트 전처리
|
| 29 |
+
- 소방 용어 최적화
|
| 30 |
+
- 동적 문서 추가
|
| 31 |
+
|
| 32 |
+
## 🏗️ 시스템 아키텍처
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
📂 문서 폴더
|
| 36 |
+
↓
|
| 37 |
+
📄 문서 처리기 (DocumentProcessor)
|
| 38 |
+
↓
|
| 39 |
+
🧠 임베딩 모델 (Sentence-BERT)
|
| 40 |
+
↓
|
| 41 |
+
🔍 벡터 DB (FAISS)
|
| 42 |
+
↓
|
| 43 |
+
💬 RAG 챗봇 (Chatbot)
|
| 44 |
+
↓
|
| 45 |
+
🌐 웹 인터페이스 (Gradio)
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## 🚀 시작하기
|
| 49 |
+
|
| 50 |
+
### 1. 환경 설정
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
# 1. 저장소 복제
|
| 54 |
+
git clone <repository-url>
|
| 55 |
+
cd 119_chatbot
|
| 56 |
+
|
| 57 |
+
# 2. Python 가상환경 생성
|
| 58 |
+
python -m venv venv
|
| 59 |
+
|
| 60 |
+
# 3. 가상환경 활성화
|
| 61 |
+
# Windows
|
| 62 |
+
venv\Scripts\activate
|
| 63 |
+
# Linux/Mac
|
| 64 |
+
source venv/bin/activate
|
| 65 |
+
|
| 66 |
+
# 4. 라이브러리 설치
|
| 67 |
+
pip install -r requirements.txt
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### 2. 문서 준비
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# documents 폴더 생성
|
| 74 |
+
mkdir documents
|
| 75 |
+
|
| 76 |
+
# 복무관리 관련 문서 추가
|
| 77 |
+
# 지원 형식: .pdf, .docx, .txt, .xlsx, .csv
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
### 3. 로컬 실행
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
# 방법 1: 간단한 테스트
|
| 84 |
+
python document_processor.py
|
| 85 |
+
|
| 86 |
+
# 방법 2: 챗봇 테스트
|
| 87 |
+
python rag_chatbot.py
|
| 88 |
+
|
| 89 |
+
# 방법 3: 웹 인터페이스 실행
|
| 90 |
+
python gradio_interface.py
|
| 91 |
+
|
| 92 |
+
# 방법 4: 허깅페이스 배포용 실행
|
| 93 |
+
python app.py
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
## 📁 프로젝트 구조
|
| 97 |
+
|
| 98 |
+
```
|
| 99 |
+
119_chatbot/
|
| 100 |
+
├── app.py # 허깅페이스 배포용 메인 파일
|
| 101 |
+
├── config.py # 시스템 설정
|
| 102 |
+
├── requirements.txt # 라이브러리 목록
|
| 103 |
+
├── README.md # 프로젝트 설명서
|
| 104 |
+
├── document_processor.py # 문서 처리 모듈
|
| 105 |
+
├── vector_store.py # 벡터 데이터베이스
|
| 106 |
+
├── rag_chatbot.py # RAG 챗봇 핵심 로직
|
| 107 |
+
├── gradio_interface.py # Gradio 웹 인터페이스
|
| 108 |
+
├── documents/ # 문서 폴더
|
| 109 |
+
│ ├── 복무관리규정.txt
|
| 110 |
+
│ ├── 인사평가규정.txt
|
| 111 |
+
│ └── ...
|
| 112 |
+
└── faiss_index/ # 벡터 인덱스 캐시
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
## ⚙️ 설정 옵션
|
| 116 |
+
|
| 117 |
+
### config.py 주요 설정
|
| 118 |
+
|
| 119 |
+
```python
|
| 120 |
+
# 모델 설정
|
| 121 |
+
EMBEDDING_MODEL = "jhgan/ko-sbert-nli" # 한국어 임베딩 모델
|
| 122 |
+
LLM_MODEL = "beomi/Llama-3-Open-Ko-8B" # 한국어 LLM
|
| 123 |
+
|
| 124 |
+
# RAG 파라미터
|
| 125 |
+
CHUNK_SIZE = 500 # 문서 청크 크기
|
| 126 |
+
CHUNK_OVERLAP = 50 # 청크 중복 크기
|
| 127 |
+
MAX_RETRIEVE_DOCS = 3 # 검색할 문서 수
|
| 128 |
+
|
| 129 |
+
# 경로 설정
|
| 130 |
+
DOCS_FOLDER = "documents" # 문서 폴더
|
| 131 |
+
VECTOR_DB_PATH = "faiss_index" # 벡터 DB 경로
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
## 🧪 사용 예시
|
| 135 |
+
|
| 136 |
+
### 기본 질문
|
| 137 |
+
|
| 138 |
+
```
|
| 139 |
+
Q: 연차휴가 사용 방법을 알려주세요
|
| 140 |
+
A: 연차휴가는 1년간 정상 근무한 자에게 15일을 부여합니다.
|
| 141 |
+
사용 시 3일 전까지 신청서를 제출하고 부서장의 승인을 받아야 합니다.
|
| 142 |
+
|
| 143 |
+
📚 출처: 복무관리규정.txt (신뢰도: 92%)
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
### 고급 기능
|
| 147 |
+
|
| 148 |
+
```python
|
| 149 |
+
# 직접 코드로 사용
|
| 150 |
+
from rag_chatbot import RAGChatbot
|
| 151 |
+
|
| 152 |
+
# 챗봇 초기화
|
| 153 |
+
chatbot = RAGChatbot()
|
| 154 |
+
chatbot.initialize()
|
| 155 |
+
|
| 156 |
+
# 질문하고 답변 받기
|
| 157 |
+
response = chatbot.generate_answer("연차휴가 절차를 알려주세요")
|
| 158 |
+
print(response.answer)
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
## 🌐 배포 방법
|
| 162 |
+
|
| 163 |
+
### 1. 허깅페이스 Spaces
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
# 1. 허깅페이스 계정으로 로그인
|
| 167 |
+
huggingface-cli login
|
| 168 |
+
|
| 169 |
+
# 2. 새로운 Space 생성
|
| 170 |
+
# - Space 이름: fire-service-rag-chatbot
|
| 171 |
+
# - SDK: Gradio
|
| 172 |
+
# - Hardware: CPU Basic (무료)
|
| 173 |
+
|
| 174 |
+
# 3. 파일 업로드
|
| 175 |
+
git add .
|
| 176 |
+
git commit -m "초기 버전 배포"
|
| 177 |
+
git push origin main
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### 2. 로컬 서버
|
| 181 |
+
|
| 182 |
+
```bash
|
| 183 |
+
# 포트 7860으로 실행
|
| 184 |
+
python app.py --port 7860
|
| 185 |
+
|
| 186 |
+
# 공유 링크 생성
|
| 187 |
+
python app.py --share
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
## 📊 성능 최적화
|
| 191 |
+
|
| 192 |
+
### 🔍 검색 성능 향상
|
| 193 |
+
- **Chunk 크기**: 500-1000 토큰으로 최적화
|
| 194 |
+
- **검색 문서 수**: 3-5개로 설정하여 속도와 정확도 균형
|
| 195 |
+
- **임베딩 모델**: 한국어 전용 모델 사용
|
| 196 |
+
|
| 197 |
+
### 💾 메모리 관리
|
| 198 |
+
- **캐싱**: 벡터 인덱스 로컬 저장
|
| 199 |
+
- **동적 로드**: 필요 시에만 LLM 모델 로드
|
| 200 |
+
- **청크 최적화**: 너무 긴 문서 분리
|
| 201 |
+
|
| 202 |
+
### ⚡ 응답 속도
|
| 203 |
+
- **템플릿 모드**: LLM 없이 빠른 응답
|
| 204 |
+
- **캐싱**: 자�� 묻는 질문 응답 저장
|
| 205 |
+
- **비동기 처리**: 대용량 문서 처리 시 배치 작업
|
| 206 |
+
|
| 207 |
+
## 🔧 개발 가이드
|
| 208 |
+
|
| 209 |
+
### 새로운 문서 추가
|
| 210 |
+
|
| 211 |
+
```python
|
| 212 |
+
from document_processor import DocumentProcessor
|
| 213 |
+
|
| 214 |
+
# 문서 처리기 생성
|
| 215 |
+
processor = DocumentProcessor()
|
| 216 |
+
|
| 217 |
+
# 새 문서 로드
|
| 218 |
+
new_docs = processor.load_documents_from_folder("new_documents")
|
| 219 |
+
|
| 220 |
+
# 기존 문서에 추가
|
| 221 |
+
from vector_store import VectorStore
|
| 222 |
+
vector_store = VectorStore()
|
| 223 |
+
vector_store.add_documents(new_docs)
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
### 커스텀 프롬프트
|
| 227 |
+
|
| 228 |
+
```python
|
| 229 |
+
# config.py에서 시스템 프롬프트 수정
|
| 230 |
+
SYSTEM_PROMPT = """
|
| 231 |
+
당신은 소방 복무관리 전문가입니다...
|
| 232 |
+
[원하는 프롬프트 내용]
|
| 233 |
+
"""
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
## 🚨 주의사항
|
| 237 |
+
|
| 238 |
+
### 보안
|
| 239 |
+
- 민감한 개인정보가 포함된 문서는 업로드하지 마세요
|
| 240 |
+
- 법적 효력이 있는 최신 규정만 사용하세요
|
| 241 |
+
- 정기적으로 문서를 업데이트해야 합니다
|
| 242 |
+
|
| 243 |
+
### 성능
|
| 244 |
+
- 대용량 문서 처리 시 시간이 오래 걸릴 수 있습니다
|
| 245 |
+
- LLM 모델 로드 시 충분한 메모리가 필요합니다
|
| 246 |
+
- 무료 플랜의 경우 리소스 제한이 있을 수 있습니다
|
| 247 |
+
|
| 248 |
+
### 정확성
|
| 249 |
+
- 생성된 답변은 반드시 실제 규정과 교차 확인하세요
|
| 250 |
+
- 중요한 결정 시 반드시 담당자와 상담하세요
|
| 251 |
+
- 챗봇 답변을 법적 효력으로 사용하지 마세요
|
| 252 |
+
|
| 253 |
+
## 🤝 기여 방법
|
| 254 |
+
|
| 255 |
+
1. 이슈 등록: 버그나 개선사항 등록
|
| 256 |
+
2. 코드 제출: Pull Request를 통한 기여
|
| 257 |
+
3. 문서 개선: README나 코드 주석 개선
|
| 258 |
+
4. 테스트: 다양한 문서로 테스트 및 피드백
|
| 259 |
+
|
| 260 |
+
## 📞 문의
|
| 261 |
+
|
| 262 |
+
- 개발자: Claude (AI Assistant)
|
| 263 |
+
- 이메일: [개발자 이메일]
|
| 264 |
+
- 라이선스: MIT License
|
| 265 |
+
|
| 266 |
+
## 🙏 감사
|
| 267 |
+
|
| 268 |
+
- **Hugging Face**: 오픈소스 모델 및 플랫폼 제공
|
| 269 |
+
- **LangChain**: RAG 프레임워크 제공
|
| 270 |
+
- **Sentence-Transformers**: 한국어 임베딩 모델 제공
|
| 271 |
+
- **FAISS**: 고성능 벡터 검색 라이브러리 제공
|
| 272 |
+
- **Gradio**: 간편한 웹 인터페이스 제공
|
| 273 |
+
|
| 274 |
+
---
|
| 275 |
+
|
| 276 |
+
**⚠️ 본 챗봇은 보조 도구입니다. 중요한 업무 결정 시 반드시 관련 규정 원본과 담당자의 확인을 받으세요!**
|
app.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
허깅페이스 Spaces 배포용 메인 파일
|
| 4 |
+
소방 복무관리 RAG 챗봇
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import argparse
|
| 10 |
+
import gradio as gr
|
| 11 |
+
import pandas as pd
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import List, Dict, Tuple
|
| 14 |
+
|
| 15 |
+
# 현재 디렉토리를 Python 경로에 추가
|
| 16 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 17 |
+
sys.path.append(current_dir)
|
| 18 |
+
|
| 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 |
+
self.chatbot = RAGChatbot()
|
| 29 |
+
self.is_initialized = False
|
| 30 |
+
|
| 31 |
+
# 예시 질문 (허깅페이스 환경 최적화)
|
| 32 |
+
self.example_questions = [
|
| 33 |
+
"연차휴가 사용 방법을 알려주세요",
|
| 34 |
+
"정규근무시간은 어떻게 되나요?",
|
| 35 |
+
"당직근무 절차가 궁금합니다",
|
| 36 |
+
"인사평가는 언제 진행되나요?",
|
| 37 |
+
"파견근무 신청 방법",
|
| 38 |
+
"복무규정 위반 시 처리"
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
# 앱 초기화
|
| 42 |
+
self._initialize_app()
|
| 43 |
+
|
| 44 |
+
def _initialize_app(self):
|
| 45 |
+
"""앱 초기화"""
|
| 46 |
+
try:
|
| 47 |
+
print("🚀 소방 복무관리 RAG 챗봇 시작 중...")
|
| 48 |
+
|
| 49 |
+
# 문서 폴더 확인 및 샘플 데이터 생성
|
| 50 |
+
self._ensure_documents()
|
| 51 |
+
|
| 52 |
+
# 챗봇 초기화
|
| 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"❌ 초기화 오류: {str(e)}")
|
| 62 |
+
# 오류가 있어도 앱은 계속 실행
|
| 63 |
+
|
| 64 |
+
def _ensure_documents(self):
|
| 65 |
+
"""문서 폴더 및 샘플 데이터 확인"""
|
| 66 |
+
docs_folder = Path("documents")
|
| 67 |
+
docs_folder.mkdir(exist_ok=True)
|
| 68 |
+
|
| 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):
|
| 76 |
+
"""샘플 복무관리 문서 생성"""
|
| 77 |
+
sample_docs = [
|
| 78 |
+
{
|
| 79 |
+
"filename": "복무관리규정.txt",
|
| 80 |
+
"content": """소방공무원 복무관리 규정
|
| 81 |
+
|
| 82 |
+
제1장 총칙
|
| 83 |
+
제1조 (목적)
|
| 84 |
+
이 규정은 소방공무원의 복무에 관한 기본사항을 규정하여 직무수행의 효율성을 높이고 조직의 발전에 기여함을 목적으로 한다.
|
| 85 |
+
|
| 86 |
+
제2조 (근무시간)
|
| 87 |
+
1. 정규근무시간은 09:00부터 18:00까지로 한다.
|
| 88 |
+
2. 점심시간은 12:00부터 13:00까지로 한다.
|
| 89 |
+
3. 토요일, 일요일 및 법정공휴일은 휴무일로 한다.
|
| 90 |
+
|
| 91 |
+
제3조 (연차휴가)
|
| 92 |
+
1. 연차휴가는 1년간 정상 근무한 자에게 15일을 부여한다.
|
| 93 |
+
2. 연차휴가 사용 시 3일 전까지 신청서를 제출해야 한다.
|
| 94 |
+
3. 부서장의 승인을 받아 사용하며, 긴급한 경우에는 사후 승인도 가능하다.
|
| 95 |
+
|
| 96 |
+
제4조 (당직근무)
|
| 97 |
+
1. 당직근무는 정규근무시간 외에 수행하는 근무를 말한다.
|
| 98 |
+
2. 당직자는 비상상황에 대비한 통신장비를 항시 점검해야 한다.
|
| 99 |
+
3. 당직 중에는 음주를 엄금하며, 직무 수행에 지장이 없는 행위만 가능하다.
|
| 100 |
+
"""
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"filename": "인사평가규정.txt",
|
| 104 |
+
"content": """소방공무원 인사평가 규정
|
| 105 |
+
|
| 106 |
+
제1장 평가의 기본원칙
|
| 107 |
+
제1조 (평가목적)
|
| 108 |
+
소방공무원의 직무수행 능력과 성과를 객관적으로 평가하여 능력위주의 인사관리를 정립하고 공정한 보상 및 승진의 기초 자료로 활용한다.
|
| 109 |
+
|
| 110 |
+
제2조 (평가주기)
|
| 111 |
+
1. 정기평가는 연 1회 실시하며, 평가기간은 매년 1월 1일부터 12월 31일까지로 한다.
|
| 112 |
+
2. 수시평가는 특별한 사유가 있을 경우 실시할 수 있다.
|
| 113 |
+
|
| 114 |
+
제3조 (평가항목)
|
| 115 |
+
1. 직무수행 능력 (40점)
|
| 116 |
+
2. 업무 성과 (30점)
|
| 117 |
+
3. 근무 태도 (20점)
|
| 118 |
+
4. 협업 능력 (10점)
|
| 119 |
+
|
| 120 |
+
제4조 (평가등급)
|
| 121 |
+
- 수 (90점 이상)
|
| 122 |
+
- 우 (80점 이상 90점 미만)
|
| 123 |
+
- 양 (70점 이상 80점 미만)
|
| 124 |
+
- 가 (60점 이상 70점 미만)
|
| 125 |
+
- 미 (60점 미만)
|
| 126 |
+
"""
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"filename": "교육훈련.txt",
|
| 130 |
+
"content": """소방공무원 교육훈련 안내
|
| 131 |
+
|
| 132 |
+
제1조 (교육목적)
|
| 133 |
+
소방공무원의 전문성 향상과 직무능력 개발을 위한 체계적인 교육훈련을 실시한다.
|
| 134 |
+
|
| 135 |
+
제2조 (필수교육)
|
| 136 |
+
1. 신임교육: 신규 임용자 대상 2주간 집체교육
|
| 137 |
+
2. 직무연수: 매년 1회, 직무별 전문교육
|
| 138 |
+
3. 안전교육: 분기별 1회, 안전사고 예방 교육
|
| 139 |
+
|
| 140 |
+
제3조 (선택교육)
|
| 141 |
+
1. 외국어 교육
|
| 142 |
+
2. 정보통신 기술 교육
|
| 143 |
+
3. 리더십 교육
|
| 144 |
+
4. 전문 자격증 취득 지원 교육
|
| 145 |
+
|
| 146 |
+
제4조 (교육신청)
|
| 147 |
+
1. 교육 희망자는 소속 기관을 통해 신청한다.
|
| 148 |
+
2. 신청 시기는 교육 시작일 1개월 전까지이다.
|
| 149 |
+
3. 업무에 지장이 없는 경우 우선 선발한다.
|
| 150 |
+
"""
|
| 151 |
+
}
|
| 152 |
+
]
|
| 153 |
+
|
| 154 |
+
for doc in sample_docs:
|
| 155 |
+
file_path = Path("documents") / doc["filename"]
|
| 156 |
+
try:
|
| 157 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 158 |
+
f.write(doc["content"])
|
| 159 |
+
print(f"✅ {doc['filename']} 생성 완료")
|
| 160 |
+
except Exception as e:
|
| 161 |
+
print(f"❌ {doc['filename']} 생성 실패: {str(e)}")
|
| 162 |
+
|
| 163 |
+
def format_response(self, response) -> str:
|
| 164 |
+
"""응답을 Gradio 형식으로 변환"""
|
| 165 |
+
try:
|
| 166 |
+
# 메시지 형식으로 변환
|
| 167 |
+
answer = response.answer
|
| 168 |
+
|
| 169 |
+
# 신뢰도 표시
|
| 170 |
+
confidence = getattr(response, 'confidence', 0.0)
|
| 171 |
+
if confidence >= 0.8:
|
| 172 |
+
confidence_emoji = "🟢"
|
| 173 |
+
elif confidence >= 0.5:
|
| 174 |
+
confidence_emoji = "🟡"
|
| 175 |
+
else:
|
| 176 |
+
confidence_emoji = "🔴"
|
| 177 |
+
|
| 178 |
+
# 응답 시간
|
| 179 |
+
response_time = getattr(response, 'response_time', 0.0)
|
| 180 |
+
|
| 181 |
+
# 출처 정보
|
| 182 |
+
sources = getattr(response, 'sources', [])
|
| 183 |
+
source_text = ""
|
| 184 |
+
if sources:
|
| 185 |
+
source_text = "\n\n📚 **참고자료:**\n"
|
| 186 |
+
for i, source in enumerate(sources[:3], 1): # 최대 3개만 표시
|
| 187 |
+
source_name = source.get('source', '알 수 없음')
|
| 188 |
+
source_text += f"{i}. {source_name}\n"
|
| 189 |
+
|
| 190 |
+
# 전체 응답
|
| 191 |
+
full_response = f"""{answer}
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
{confidence_emoji} 신뢰도: {confidence:.1%}
|
| 195 |
+
⏱️ 응답시간: {response_time:.2f}초
|
| 196 |
+
📄 참고문서: {len(sources)}개{source_text}"""
|
| 197 |
+
|
| 198 |
+
return full_response
|
| 199 |
+
|
| 200 |
+
except Exception as e:
|
| 201 |
+
return f"응답 형식 변환 중 오류 발생: {str(e)}"
|
| 202 |
+
|
| 203 |
+
def chat_function(self, message: str, history: List[List[str]]) -> List[List[str]]:
|
| 204 |
+
"""채팅 함수"""
|
| 205 |
+
if not message.strip():
|
| 206 |
+
return history
|
| 207 |
+
|
| 208 |
+
try:
|
| 209 |
+
# 챗봇 응답 생성
|
| 210 |
+
if self.is_initialized:
|
| 211 |
+
response = self.chatbot.generate_answer(message, use_llm=False)
|
| 212 |
+
answer = self.format_response(response)
|
| 213 |
+
else:
|
| 214 |
+
answer = "죄송합니다. 챗봇이 초기화되지 않았습니다. 페이지를 새로고침해주세요."
|
| 215 |
+
|
| 216 |
+
# 히스토리에 추가
|
| 217 |
+
history.append([message, answer])
|
| 218 |
+
|
| 219 |
+
except Exception as e:
|
| 220 |
+
error_msg = f"답변 생성 중 오류 발생: {str(e)}\n\n관리자에게 문의해주세요."
|
| 221 |
+
history.append([message, error_msg])
|
| 222 |
+
|
| 223 |
+
return history
|
| 224 |
+
|
| 225 |
+
def create_demo(self):
|
| 226 |
+
"""Gradio 데모 생성"""
|
| 227 |
+
# 커스텀 CSS
|
| 228 |
+
custom_css = """
|
| 229 |
+
.gradio-container {
|
| 230 |
+
max-width: 900px !important;
|
| 231 |
+
margin: auto !important;
|
| 232 |
+
}
|
| 233 |
+
.message.user {
|
| 234 |
+
background-color: #e3f2fd;
|
| 235 |
+
border-radius: 15px 15px 0 15px;
|
| 236 |
+
}
|
| 237 |
+
.message.assistant {
|
| 238 |
+
background-color: #f1f8e9;
|
| 239 |
+
border-radius: 15px 15px 15px 0;
|
| 240 |
+
}
|
| 241 |
+
"""
|
| 242 |
+
|
| 243 |
+
with gr.Blocks(
|
| 244 |
+
title="소방 복무관리 RAG 챗봇",
|
| 245 |
+
theme=gr.themes.Soft(),
|
| 246 |
+
css=custom_css
|
| 247 |
+
) as demo:
|
| 248 |
+
|
| 249 |
+
gr.Markdown("""
|
| 250 |
+
# 🚒 소방 복무관리 RAG 챗봇
|
| 251 |
+
|
| 252 |
+
소방공무원 복무관리 규정, 인사평가, 교육훈련 등 업무 관련 질문에 답변해 드립니다.
|
| 253 |
+
|
| 254 |
+
💡 **사용 방법**: 아래에 복무관리 관련 질문을 입력하고 Enter 키를 누르세요.
|
| 255 |
+
""")
|
| 256 |
+
|
| 257 |
+
# 상태 표시
|
| 258 |
+
with gr.Row():
|
| 259 |
+
status_text = gr.HTML(
|
| 260 |
+
"✅ **챗봇 준비 완료** - 복무관리 관련 질문을 입력해주세요"
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# 채팅 인터페이스
|
| 264 |
+
chatbot = gr.Chatbot(
|
| 265 |
+
height=500,
|
| 266 |
+
show_copy_button=True,
|
| 267 |
+
bubble_full_width=False,
|
| 268 |
+
placeholder="안녕하세요! 소방 복무관리에 대해 무엇이 궁금하신가요?",
|
| 269 |
+
avatar_images=["👤", "🤖"]
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# 입력 영역
|
| 273 |
+
with gr.Row():
|
| 274 |
+
msg = gr.Textbox(
|
| 275 |
+
placeholder="복무관리 관련 질문을 입력해주세요 (예: 연차휴가 사용 방법)",
|
| 276 |
+
container=False,
|
| 277 |
+
scale=7
|
| 278 |
+
)
|
| 279 |
+
submit_btn = gr.Button("전송", scale=1, variant="primary")
|
| 280 |
+
clear_btn = gr.Button("초기화", scale=1)
|
| 281 |
+
|
| 282 |
+
# 예시 질문
|
| 283 |
+
gr.Markdown("### 💡 예시 질문 (클릭하면 자동 입력됩니다)")
|
| 284 |
+
|
| 285 |
+
with gr.Row():
|
| 286 |
+
with gr.Column():
|
| 287 |
+
gr.Button("연차휴가 사용 방법", size="sm").click(
|
| 288 |
+
fn=lambda: "연차휴가 사용 방법을 알려주세요",
|
| 289 |
+
outputs=msg
|
| 290 |
+
)
|
| 291 |
+
gr.Button("정규근무시간", size="sm").click(
|
| 292 |
+
fn=lambda: "정규근무시간은 어떻게 되나요?",
|
| 293 |
+
outputs=msg
|
| 294 |
+
)
|
| 295 |
+
gr.Button("당직근무 절차", size="sm").click(
|
| 296 |
+
fn=lambda: "당직근무 절차가 궁금합니다",
|
| 297 |
+
outputs=msg
|
| 298 |
+
)
|
| 299 |
+
with gr.Column():
|
| 300 |
+
gr.Button("인사평가 기준", size="sm").click(
|
| 301 |
+
fn=lambda: "인사평가는 어떤 기준으로 이루어지나요?",
|
| 302 |
+
outputs=msg
|
| 303 |
+
)
|
| 304 |
+
gr.Button("교육훈련 안내", size="sm").click(
|
| 305 |
+
fn=lambda: "교육훈련 종류와 신청 방법을 알려주세요",
|
| 306 |
+
outputs=msg
|
| 307 |
+
)
|
| 308 |
+
gr.Button("파견근무 신청", size="sm").click(
|
| 309 |
+
fn=lambda: "파견근무 신청 방법을 알려주세요",
|
| 310 |
+
outputs=msg
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
# 정보 섹션
|
| 314 |
+
with gr.Accordion("📊 시스템 정보", open=False):
|
| 315 |
+
if self.is_initialized:
|
| 316 |
+
stats = self.chatbot.get_stats()
|
| 317 |
+
vector_stats = stats.get("vector_store", {})
|
| 318 |
+
gr.Markdown(f"""
|
| 319 |
+
- **문서 수**: {vector_stats.get('total_documents', 0)}개
|
| 320 |
+
- **임베딩 모델**: {vector_stats.get('embedding_model', 'N/A')}
|
| 321 |
+
- **응답 모드**: 템플릿 기반
|
| 322 |
+
- **최대 검색 문서**: {Config.MAX_RETRIEVE_DOCS}개
|
| 323 |
+
""")
|
| 324 |
+
else:
|
| 325 |
+
gr.Markdown("⚠️ 챗봇이 초기화되지 않았습니다.")
|
| 326 |
+
|
| 327 |
+
# 이벤트 핸들러
|
| 328 |
+
def user_input(user_message, history):
|
| 329 |
+
"""사용자 입력 처리"""
|
| 330 |
+
return "", history + [[user_message, None]]
|
| 331 |
+
|
| 332 |
+
def bot_response(history):
|
| 333 |
+
"""봇 응답 처리"""
|
| 334 |
+
if history and history[-1][1] is None:
|
| 335 |
+
user_message = history[-1][0]
|
| 336 |
+
bot_message = self.chat_function(user_message, history[:-1])
|
| 337 |
+
if bot_message:
|
| 338 |
+
history[-1][1] = bot_message[-1][1] # 마지막 응답만 가져오기
|
| 339 |
+
else:
|
| 340 |
+
history[-1][1] = "죄송합니다. 응답을 생성할 수 없습니다."
|
| 341 |
+
return history
|
| 342 |
+
|
| 343 |
+
# 메시지 전송 이벤트
|
| 344 |
+
msg.submit(
|
| 345 |
+
user_input,
|
| 346 |
+
[msg, chatbot],
|
| 347 |
+
[msg, chatbot],
|
| 348 |
+
queue=False
|
| 349 |
+
).then(
|
| 350 |
+
bot_response,
|
| 351 |
+
chatbot,
|
| 352 |
+
chatbot
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
submit_btn.click(
|
| 356 |
+
user_input,
|
| 357 |
+
[msg, chatbot],
|
| 358 |
+
[msg, chatbot],
|
| 359 |
+
queue=False
|
| 360 |
+
).then(
|
| 361 |
+
bot_response,
|
| 362 |
+
chatbot,
|
| 363 |
+
chatbot
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
clear_btn.click(
|
| 367 |
+
lambda: ([], ""),
|
| 368 |
+
outputs=[chatbot, msg]
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
return demo
|
| 372 |
+
|
| 373 |
+
def main():
|
| 374 |
+
"""메인 실행 함수"""
|
| 375 |
+
parser = argparse.ArgumentParser(description="소방 복무관리 RAG 챗봇")
|
| 376 |
+
parser.add_argument("--share", action="store_true", help="공유 링크 생성")
|
| 377 |
+
parser.add_argument("--port", type=int, default=7860, help="서버 포트")
|
| 378 |
+
args = parser.parse_args()
|
| 379 |
+
|
| 380 |
+
# 앱 생성
|
| 381 |
+
app = HuggingFaceApp()
|
| 382 |
+
demo = app.create_demo()
|
| 383 |
+
|
| 384 |
+
# 실행
|
| 385 |
+
print("🚀 허깅페이스 Spaces 앱 시작 중...")
|
| 386 |
+
demo.launch(
|
| 387 |
+
share=args.share,
|
| 388 |
+
server_port=args.port,
|
| 389 |
+
server_name="0.0.0.0",
|
| 390 |
+
show_error=True,
|
| 391 |
+
show_tips=False
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
if __name__ == "__main__":
|
| 395 |
+
main()
|
config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 복무관리 RAG 챗봇 설정
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
# 환경 변수 로드
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
class Config:
|
| 9 |
+
# 모델 설정
|
| 10 |
+
EMBEDDING_MODEL = "jhgan/ko-sbert-nli" # 한국어 임베딩 모델
|
| 11 |
+
LLM_MODEL = "beomi/Llama-3-Open-Ko-8B" # 한국어 LLM
|
| 12 |
+
|
| 13 |
+
# RAG 설정
|
| 14 |
+
CHUNK_SIZE = 500
|
| 15 |
+
CHUNK_OVERLAP = 50
|
| 16 |
+
MAX_RETRIEVE_DOCS = 3
|
| 17 |
+
|
| 18 |
+
# 벡터 DB 설정
|
| 19 |
+
VECTOR_DB_PATH = "faiss_index"
|
| 20 |
+
|
| 21 |
+
# 문서 폴더 설정
|
| 22 |
+
DOCS_FOLDER = "documents"
|
| 23 |
+
|
| 24 |
+
# 웹 인터페이스 설정
|
| 25 |
+
APP_NAME = "소방 복무관리 RAG 챗봇"
|
| 26 |
+
APP_DESCRIPTION = "소방업무 복무관리 규정 및 절차에 대한 질문에 답변해 드립니다."
|
| 27 |
+
|
| 28 |
+
# 토큰 설정 (필요시)
|
| 29 |
+
HUGGINGFACE_TOKEN = os.getenv("HUGGINGFACE_TOKEN", "")
|
| 30 |
+
|
| 31 |
+
# 시스템 프롬프트
|
| 32 |
+
SYSTEM_PROMPT = """
|
| 33 |
+
당신은 소방업무 복무관리 전문가입니다. 복무관리 규정, 인사운영, 근무절차 등에 대한 질문에 정확하고 친절하게 답변해 주세요.
|
| 34 |
+
|
| 35 |
+
답변 시 다음 사항을 준수해 주세요:
|
| 36 |
+
1. 관련 규정 조문이나 근거를 명확히 제시
|
| 37 |
+
2. 절차가 있는 경우 단계별로 설명
|
| 38 |
+
3. 필요한 서류나 양식을 안내
|
| 39 |
+
4. 주의사항이나 중요한 사항은 강조 표시
|
| 40 |
+
5. 모든 답변은 한국어로 제공
|
| 41 |
+
|
| 42 |
+
사용자의 질문에 최대한 상세하고 정확한 정보를 제공하세요.
|
| 43 |
+
"""
|
document_processor.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
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 langchain.text_splitter import RecursiveCharacterTextSplitter
|
| 9 |
+
from langchain.schema import Document
|
| 10 |
+
|
| 11 |
+
class DocumentProcessor:
|
| 12 |
+
"""복무관리 문서 처리 클래스"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, chunk_size: int = 500, chunk_overlap: int = 50):
|
| 15 |
+
self.chunk_size = chunk_size
|
| 16 |
+
self.chunk_overlap = chunk_overlap
|
| 17 |
+
self.text_splitter = RecursiveCharacterTextSplitter(
|
| 18 |
+
chunk_size=chunk_size,
|
| 19 |
+
chunk_overlap=chunk_overlap,
|
| 20 |
+
separators=["\n\n", "\n", " ", ""]
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
def load_documents_from_folder(self, folder_path: str) -> List[Document]:
|
| 24 |
+
"""폴더에서 모든 문서 로드"""
|
| 25 |
+
documents = []
|
| 26 |
+
folder = Path(folder_path)
|
| 27 |
+
|
| 28 |
+
if not folder.exists():
|
| 29 |
+
print(f"⚠️ 폴더가 존재하지 않습니다: {folder_path}")
|
| 30 |
+
return documents
|
| 31 |
+
|
| 32 |
+
# 지원하는 파일 형식
|
| 33 |
+
supported_extensions = ['.pdf', '.docx', '.txt', '.xlsx', '.csv']
|
| 34 |
+
|
| 35 |
+
for file_path in folder.rglob('*'):
|
| 36 |
+
if file_path.suffix.lower() in supported_extensions:
|
| 37 |
+
try:
|
| 38 |
+
print(f"📄 문서 로드: {file_path.name}")
|
| 39 |
+
docs = self.load_single_document(str(file_path))
|
| 40 |
+
documents.extend(docs)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"❌ 문서 로드 실패 ({file_path.name}): {str(e)}")
|
| 43 |
+
|
| 44 |
+
return documents
|
| 45 |
+
|
| 46 |
+
def load_single_document(self, file_path: str) -> List[Document]:
|
| 47 |
+
"""단일 문서 로드"""
|
| 48 |
+
file_ext = Path(file_path).suffix.lower()
|
| 49 |
+
|
| 50 |
+
if file_ext == '.pdf':
|
| 51 |
+
return self._load_pdf(file_path)
|
| 52 |
+
elif file_ext == '.docx':
|
| 53 |
+
return self._load_docx(file_path)
|
| 54 |
+
elif file_ext == '.txt':
|
| 55 |
+
return self._load_txt(file_path)
|
| 56 |
+
elif file_ext in ['.xlsx', '.csv']:
|
| 57 |
+
return self._load_table(file_path)
|
| 58 |
+
else:
|
| 59 |
+
raise ValueError(f"지원하지 않는 파일 형식: {file_ext}")
|
| 60 |
+
|
| 61 |
+
def _load_pdf(self, file_path: str) -> List[Document]:
|
| 62 |
+
"""PDF 파일 로드"""
|
| 63 |
+
documents = []
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
with PyMuPDF.open(file_path) as doc:
|
| 67 |
+
full_text = ""
|
| 68 |
+
|
| 69 |
+
for page_num in range(len(doc)):
|
| 70 |
+
page = doc[page_num]
|
| 71 |
+
page_text = page.get_text()
|
| 72 |
+
|
| 73 |
+
# 페이지 정제
|
| 74 |
+
page_text = self._clean_text(page_text)
|
| 75 |
+
|
| 76 |
+
if page_text.strip():
|
| 77 |
+
full_text += f"\n\n--- 페이지 {page_num + 1} ---\n\n{page_text}"
|
| 78 |
+
|
| 79 |
+
if full_text.strip():
|
| 80 |
+
chunks = self.text_splitter.split_text(full_text)
|
| 81 |
+
|
| 82 |
+
for i, chunk in enumerate(chunks):
|
| 83 |
+
metadata = {
|
| 84 |
+
"source": Path(file_path).name,
|
| 85 |
+
"page": "multiple",
|
| 86 |
+
"chunk_id": i,
|
| 87 |
+
"file_type": "pdf"
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
documents.append(Document(page_content=chunk, metadata=metadata))
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"PDF 로드 중 오류: {str(e)}")
|
| 94 |
+
raise
|
| 95 |
+
|
| 96 |
+
return documents
|
| 97 |
+
|
| 98 |
+
def _load_docx(self, file_path: str) -> List[Document]:
|
| 99 |
+
"""Word 문서 로드"""
|
| 100 |
+
documents = []
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
doc = docx.Document(file_path)
|
| 104 |
+
paragraphs = []
|
| 105 |
+
|
| 106 |
+
for para in doc.paragraphs:
|
| 107 |
+
if para.text.strip():
|
| 108 |
+
paragraphs.append(para.text)
|
| 109 |
+
|
| 110 |
+
full_text = "\n\n".join(paragraphs)
|
| 111 |
+
|
| 112 |
+
if full_text.strip():
|
| 113 |
+
chunks = self.text_splitter.split_text(full_text)
|
| 114 |
+
|
| 115 |
+
for i, chunk in enumerate(chunks):
|
| 116 |
+
metadata = {
|
| 117 |
+
"source": Path(file_path).name,
|
| 118 |
+
"chunk_id": i,
|
| 119 |
+
"file_type": "docx"
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
documents.append(Document(page_content=chunk, metadata=metadata))
|
| 123 |
+
|
| 124 |
+
except Exception as e:
|
| 125 |
+
print(f"DOCX 로드 중 오류: {str(e)}")
|
| 126 |
+
raise
|
| 127 |
+
|
| 128 |
+
return documents
|
| 129 |
+
|
| 130 |
+
def _load_txt(self, file_path: str) -> List[Document]:
|
| 131 |
+
"""텍스트 파일 로드"""
|
| 132 |
+
documents = []
|
| 133 |
+
|
| 134 |
+
try:
|
| 135 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 136 |
+
text = f.read()
|
| 137 |
+
|
| 138 |
+
text = self._clean_text(text)
|
| 139 |
+
|
| 140 |
+
if text.strip():
|
| 141 |
+
chunks = self.text_splitter.split_text(text)
|
| 142 |
+
|
| 143 |
+
for i, chunk in enumerate(chunks):
|
| 144 |
+
metadata = {
|
| 145 |
+
"source": Path(file_path).name,
|
| 146 |
+
"chunk_id": i,
|
| 147 |
+
"file_type": "txt"
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
documents.append(Document(page_content=chunk, metadata=metadata))
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"TXT 로드 중 오류: {str(e)}")
|
| 154 |
+
raise
|
| 155 |
+
|
| 156 |
+
return documents
|
| 157 |
+
|
| 158 |
+
def _load_table(self, file_path: str) -> List[Document]:
|
| 159 |
+
"""엑셀/CSV 파일 로드"""
|
| 160 |
+
documents = []
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
if file_path.endswith('.xlsx'):
|
| 164 |
+
df = pd.read_excel(file_path)
|
| 165 |
+
else:
|
| 166 |
+
df = pd.read_csv(file_path, encoding='utf-8')
|
| 167 |
+
|
| 168 |
+
# 데이터프레임을 텍스트로 변환
|
| 169 |
+
text_parts = []
|
| 170 |
+
text_parts.append(f"파일: {Path(file_path).name}")
|
| 171 |
+
text_parts.append(f"컬럼: {', '.join(df.columns.tolist())}")
|
| 172 |
+
|
| 173 |
+
for index, row in df.iterrows():
|
| 174 |
+
row_text = " | ".join([f"{col}: {val}" for col, val in row.items() if pd.notna(val)])
|
| 175 |
+
text_parts.append(f"행 {index + 1}: {row_text}")
|
| 176 |
+
|
| 177 |
+
full_text = "\n\n".join(text_parts)
|
| 178 |
+
|
| 179 |
+
if full_text.strip():
|
| 180 |
+
chunks = self.text_splitter.split_text(full_text)
|
| 181 |
+
|
| 182 |
+
for i, chunk in enumerate(chunks):
|
| 183 |
+
metadata = {
|
| 184 |
+
"source": Path(file_path).name,
|
| 185 |
+
"chunk_id": i,
|
| 186 |
+
"file_type": "table",
|
| 187 |
+
"total_rows": len(df)
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
documents.append(Document(page_content=chunk, metadata=metadata))
|
| 191 |
+
|
| 192 |
+
except Exception as e:
|
| 193 |
+
print(f"테이블 로드 중 오류: {str(e)}")
|
| 194 |
+
raise
|
| 195 |
+
|
| 196 |
+
return documents
|
| 197 |
+
|
| 198 |
+
def _clean_text(self, text: str) -> str:
|
| 199 |
+
"""텍스트 정제"""
|
| 200 |
+
# 불필요한 공백 제거
|
| 201 |
+
text = re.sub(r'\s+', ' ', text)
|
| 202 |
+
|
| 203 |
+
# 특수문자 정리
|
| 204 |
+
text = re.sub(r'[^\w\s\.\,\?\!\:\;\-\(\)\/\&\@]', ' ', text)
|
| 205 |
+
|
| 206 |
+
# 연속된 공백 제거
|
| 207 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 208 |
+
|
| 209 |
+
return text
|
| 210 |
+
|
| 211 |
+
def process_documents(self, documents: List[Document]) -> List[Document]:
|
| 212 |
+
"""문서 후처리"""
|
| 213 |
+
processed_docs = []
|
| 214 |
+
|
| 215 |
+
for doc in documents:
|
| 216 |
+
content = doc.page_content.strip()
|
| 217 |
+
|
| 218 |
+
if content and len(content) > 20: # 너무 짧은 청크는 제외
|
| 219 |
+
# 복무관리 특화 키워드 강화
|
| 220 |
+
content = self._enhance_fire_service_terms(content)
|
| 221 |
+
|
| 222 |
+
processed_doc = Document(
|
| 223 |
+
page_content=content,
|
| 224 |
+
metadata=doc.metadata
|
| 225 |
+
)
|
| 226 |
+
processed_docs.append(processed_doc)
|
| 227 |
+
|
| 228 |
+
return processed_docs
|
| 229 |
+
|
| 230 |
+
def _enhance_fire_service_terms(self, text: str) -> str:
|
| 231 |
+
"""소방 용어 강화"""
|
| 232 |
+
# 복무관리 관련 키워드 매핑
|
| 233 |
+
term_mappings = {
|
| 234 |
+
"연차": "연차휴가",
|
| 235 |
+
"연장": "연장근무",
|
| 236 |
+
"당직": "당직근무",
|
| 237 |
+
"파견": "파견근무",
|
| 238 |
+
"인사": "인사평가",
|
| 239 |
+
"승진": "승진시험",
|
| 240 |
+
"교육": "교육훈련",
|
| 241 |
+
"휴가": "휴가사용",
|
| 242 |
+
"상벌": "상벌규정",
|
| 243 |
+
"징계": "징계절차"
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
enhanced_text = text
|
| 247 |
+
for standard_term, enhanced_term in term_mappings.items():
|
| 248 |
+
enhanced_text = enhanced_text.replace(standard_term, enhanced_term)
|
| 249 |
+
|
| 250 |
+
return enhanced_text
|
| 251 |
+
|
| 252 |
+
# 테스트용 함수
|
| 253 |
+
def test_document_processor():
|
| 254 |
+
"""문서 처리기 테스트"""
|
| 255 |
+
processor = DocumentProcessor()
|
| 256 |
+
|
| 257 |
+
# 샘플 documents 폴더 생성
|
| 258 |
+
docs_folder = "documents"
|
| 259 |
+
os.makedirs(docs_folder, exist_ok=True)
|
| 260 |
+
|
| 261 |
+
# 샘플 문서 생성
|
| 262 |
+
sample_text = """
|
| 263 |
+
복무관리 규정
|
| 264 |
+
|
| 265 |
+
제1장 총칙
|
| 266 |
+
제1조 (목적)
|
| 267 |
+
이 규정은 소방공무원의 복무에 관한 사항을 규정하여 직무수행의 효율성을 높이고
|
| 268 |
+
조직의 발전에 기여함을 목적으로 한다.
|
| 269 |
+
|
| 270 |
+
제2조 (근무시간)
|
| 271 |
+
1. 정규근무시간은 09:00부터 18:00까지로 한다.
|
| 272 |
+
2. 점심시간은 12:00부터 13:00까지로 한다.
|
| 273 |
+
3. 당직근무는 정규근무시간 외에 수행하는 근무를 말한다.
|
| 274 |
+
|
| 275 |
+
제3조 (연차휴가)
|
| 276 |
+
1. 연차휴가는 1년간 정상 근무한 자에게 15일을 부여한다.
|
| 277 |
+
2. 연차휴가 사용 시 3일 전까지 신청서를 제출해야 한다.
|
| 278 |
+
3. 부서장의 승인을 받아 사용한다.
|
| 279 |
+
"""
|
| 280 |
+
|
| 281 |
+
with open(os.path.join(docs_folder, "sample_policy.txt"), "w", encoding="utf-8") as f:
|
| 282 |
+
f.write(sample_text)
|
| 283 |
+
|
| 284 |
+
# 문서 로드 테스트
|
| 285 |
+
documents = processor.load_documents_from_folder(docs_folder)
|
| 286 |
+
print(f"✅ {len(documents)}개 문서 청크 생성 완료")
|
| 287 |
+
|
| 288 |
+
return documents
|
| 289 |
+
|
| 290 |
+
if __name__ == "__main__":
|
| 291 |
+
test_document_processor()
|
gradio_interface.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import time
|
| 3 |
+
from typing import List, Dict, Tuple
|
| 4 |
+
import os
|
| 5 |
+
from rag_chatbot import RAGChatbot, ChatResponse
|
| 6 |
+
|
| 7 |
+
class GradioInterface:
|
| 8 |
+
"""Gradio 웹 인터페이스 클래스"""
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.chatbot = RAGChatbot()
|
| 12 |
+
self.is_initialized = False
|
| 13 |
+
|
| 14 |
+
# 예시 질문
|
| 15 |
+
self.example_questions = [
|
| 16 |
+
"연차휴가 사용 방법을 알려주세요",
|
| 17 |
+
"정규근무시간은 어떻게 되나요?",
|
| 18 |
+
"당직근무 절차가 궁금합니다",
|
| 19 |
+
"인사평가는 언제 어떻게 진행되나요?",
|
| 20 |
+
"파견근무 신청 방법을 알려주세요",
|
| 21 |
+
"복무규정 위반 시 어떻게 되나요?"
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
def initialize_chatbot(self, docs_folder: str = None, force_rebuild: bool = False) -> str:
|
| 25 |
+
"""챗봇 초기화"""
|
| 26 |
+
try:
|
| 27 |
+
success = self.chatbot.initialize(docs_folder, force_rebuild)
|
| 28 |
+
if success:
|
| 29 |
+
self.is_initialized = True
|
| 30 |
+
return "✅ RAG 챗봇이 성공적으로 초기화되었습니다!"
|
| 31 |
+
else:
|
| 32 |
+
return "❌ 챗봇 초기화에 실패했습니다. documents 폴더에 파일이 있는지 확인해주세요."
|
| 33 |
+
except Exception as e:
|
| 34 |
+
return f"❌ 초기화 중 오류 발생: {str(e)}"
|
| 35 |
+
|
| 36 |
+
def format_chat_response(self, response: ChatResponse) -> Tuple[str, str]:
|
| 37 |
+
"""챗봇 응답을 채팅 형식으로 변환"""
|
| 38 |
+
# 메인 답변
|
| 39 |
+
answer_html = response.answer.replace('\n', '<br>')
|
| 40 |
+
|
| 41 |
+
# 신뢰도 색상
|
| 42 |
+
if response.confidence >= 0.8:
|
| 43 |
+
confidence_color = "green"
|
| 44 |
+
confidence_text = "높음"
|
| 45 |
+
elif response.confidence >= 0.5:
|
| 46 |
+
confidence_color = "orange"
|
| 47 |
+
confidence_text = "보통"
|
| 48 |
+
else:
|
| 49 |
+
confidence_color = "red"
|
| 50 |
+
confidence_text = "낮음"
|
| 51 |
+
|
| 52 |
+
# 정보 메시지
|
| 53 |
+
info_html = f"""
|
| 54 |
+
<div style="background-color: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 10px;">
|
| 55 |
+
<strong>📊 답변 정보</strong><br>
|
| 56 |
+
• 신뢰도: <span style="color: {confidence_color}; font-weight: bold;">{confidence_text} ({response.confidence:.2%})</span><br>
|
| 57 |
+
• 응답시간: {response.response_time:.2f}초<br>
|
| 58 |
+
• 참고문서: {len(response.sources)}개
|
| 59 |
+
</div>
|
| 60 |
+
"""
|
| 61 |
+
|
| 62 |
+
# 출처 정보
|
| 63 |
+
if response.sources:
|
| 64 |
+
sources_html = "<br><strong>📚 참고자료:</strong><ul>"
|
| 65 |
+
for i, source in enumerate(response.sources, 1):
|
| 66 |
+
sources_html += f"<li><strong>{source['source']}</strong> (유사도: {source['similarity']})<br><small>{source['content'][:150]}...</small></li>"
|
| 67 |
+
sources_html += "</ul>"
|
| 68 |
+
info_html += sources_html
|
| 69 |
+
|
| 70 |
+
return answer_html, info_html
|
| 71 |
+
|
| 72 |
+
def chat_interface(self, message: str, history: List[List[str]]) -> List[List[str]]:
|
| 73 |
+
"""채팅 인터페이스 핸들러"""
|
| 74 |
+
if not self.is_initialized:
|
| 75 |
+
history.append([message, "⚠️ 챗봇이 초기화되지 않았습니다. 먼저 초기화를 눌러주세요."])
|
| 76 |
+
return history
|
| 77 |
+
|
| 78 |
+
if not message.strip():
|
| 79 |
+
return history
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
# 답변 생성
|
| 83 |
+
response = self.chatbot.generate_answer(message, use_llm=False) # 템플릿 모드로 가볍게
|
| 84 |
+
answer, info = self.format_chat_response(response)
|
| 85 |
+
|
| 86 |
+
# 전체 응답
|
| 87 |
+
full_response = f"{answer}<br><br>{info}"
|
| 88 |
+
|
| 89 |
+
history.append([message, full_response])
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
error_response = f"❌ 답변 생성 중 오류 발생: {str(e)}"
|
| 93 |
+
history.append([message, error_response])
|
| 94 |
+
|
| 95 |
+
return history
|
| 96 |
+
|
| 97 |
+
def test_question(self, question: str) -> str:
|
| 98 |
+
"""예시 질문 테스트"""
|
| 99 |
+
if not self.is_initialized:
|
| 100 |
+
return "⚠️ 먼저 챗봇 초기화를 진행해주세요."
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
response = self.chatbot.generate_answer(question, use_llm=False)
|
| 104 |
+
answer, info = self.format_chat_response(response)
|
| 105 |
+
return f"❓ 질문: {question}\n\n🤖 답변:\n{answer}\n\n{info}"
|
| 106 |
+
except Exception as e:
|
| 107 |
+
return f"❌ 테스트 중 오류 발생: {str(e)}"
|
| 108 |
+
|
| 109 |
+
def get_chatbot_stats(self) -> str:
|
| 110 |
+
"""챗봇 통계 정보"""
|
| 111 |
+
if not self.is_initialized:
|
| 112 |
+
return "챗봇이 초기화되지 않았습니다."
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
stats = self.chatbot.get_stats()
|
| 116 |
+
stats_html = "<h3>🤖 챗봇 상태 정보</h3>"
|
| 117 |
+
|
| 118 |
+
if stats.get("status") == "initialized":
|
| 119 |
+
vector_stats = stats.get("vector_store", {})
|
| 120 |
+
stats_html += f"""
|
| 121 |
+
<ul>
|
| 122 |
+
<li>상태: <span style="color: green;">✅ 정상 작동 중</span></li>
|
| 123 |
+
<li>총 문서 수: {vector_stats.get('total_documents', 0)}개</li>
|
| 124 |
+
<li>임베딩 모델: {vector_stats.get('embedding_model', 'N/A')}</li>
|
| 125 |
+
<li>벡터 차원: {vector_stats.get('index_dimension', 'N/A')}</li>
|
| 126 |
+
<li>LLM 사용: {'✅' if stats.get('llm_available') else '❌ (템플릿 모드)'}</li>
|
| 127 |
+
</ul>
|
| 128 |
+
"""
|
| 129 |
+
else:
|
| 130 |
+
stats_html += "<p>⚠️ 챗봇이 초기화되지 않았습니다.</p>"
|
| 131 |
+
|
| 132 |
+
return stats_html
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
return f"❌ 통계 정보 조회 실패: {str(e)}"
|
| 136 |
+
|
| 137 |
+
def create_interface(self):
|
| 138 |
+
"""Gradio 인터페이스 생성"""
|
| 139 |
+
# 커스텀 CSS
|
| 140 |
+
custom_css = """
|
| 141 |
+
.chat-message {
|
| 142 |
+
padding: 15px;
|
| 143 |
+
border-radius: 10px;
|
| 144 |
+
margin: 10px 0;
|
| 145 |
+
}
|
| 146 |
+
.user-message {
|
| 147 |
+
background-color: #e3f2fd;
|
| 148 |
+
border-left: 4px solid #2196f3;
|
| 149 |
+
}
|
| 150 |
+
.assistant-message {
|
| 151 |
+
background-color: #f1f8e9;
|
| 152 |
+
border-left: 4px solid #4caf50;
|
| 153 |
+
}
|
| 154 |
+
.info-box {
|
| 155 |
+
background-color: #fff3e0;
|
| 156 |
+
border: 1px solid #ffb74d;
|
| 157 |
+
border-radius: 8px;
|
| 158 |
+
padding: 12px;
|
| 159 |
+
margin: 10px 0;
|
| 160 |
+
}
|
| 161 |
+
.stats-box {
|
| 162 |
+
background-color: #f5f5f5;
|
| 163 |
+
border-radius: 8px;
|
| 164 |
+
padding: 15px;
|
| 165 |
+
margin: 10px 0;
|
| 166 |
+
}
|
| 167 |
+
"""
|
| 168 |
+
|
| 169 |
+
with gr.Blocks(
|
| 170 |
+
title="소방 복무관리 RAG 챗봇",
|
| 171 |
+
theme=gr.themes.Soft(),
|
| 172 |
+
css=custom_css
|
| 173 |
+
) as interface:
|
| 174 |
+
|
| 175 |
+
gr.Markdown("# 🚒 소방 복무관리 RAG 챗봇")
|
| 176 |
+
gr.Markdown("소방업무 복무관리 규정 및 절차에 대한 질문에 답변해 드립니다.")
|
| 177 |
+
|
| 178 |
+
with gr.Tab("💬 채팅"):
|
| 179 |
+
with gr.Row():
|
| 180 |
+
with gr.Column(scale=4):
|
| 181 |
+
chatbot = gr.Chatbot(
|
| 182 |
+
height=500,
|
| 183 |
+
show_copy_button=True,
|
| 184 |
+
bubble_full_width=False,
|
| 185 |
+
avatar_images=["👤", "🤖"]
|
| 186 |
+
)
|
| 187 |
+
msg = gr.Textbox(
|
| 188 |
+
placeholder="복무관리 관련 질문을 입력해주세요 (예: 연차휴가 사용 방법)",
|
| 189 |
+
label="질문 입력",
|
| 190 |
+
submit_btn="전송"
|
| 191 |
+
)
|
| 192 |
+
with gr.Row():
|
| 193 |
+
submit_btn = gr.Button("💬 전송", variant="primary")
|
| 194 |
+
clear_btn = gr.Button("🗑️ 대화 초기화")
|
| 195 |
+
|
| 196 |
+
with gr.Column(scale=1):
|
| 197 |
+
gr.Markdown("### 🚀 빠른 시작")
|
| 198 |
+
init_btn = gr.Button("🔧 챗봇 초기화", variant="secondary")
|
| 199 |
+
init_status = gr.HTML("⏳ 초기화를 눌러주세요.")
|
| 200 |
+
|
| 201 |
+
gr.Markdown("### 💡 예시 질문")
|
| 202 |
+
for question in self.example_questions:
|
| 203 |
+
gr.Button(question, size="sm").click(
|
| 204 |
+
fn=lambda q=question: q,
|
| 205 |
+
outputs=msg
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
gr.Markdown("### 📊 상태 정보")
|
| 209 |
+
stats_info = gr.HTML("상태 정보를 확인해주세요.")
|
| 210 |
+
|
| 211 |
+
with gr.Tab("🔍 테스트"):
|
| 212 |
+
gr.Markdown("### 예시 질문 테스트")
|
| 213 |
+
with gr.Row():
|
| 214 |
+
with gr.Column():
|
| 215 |
+
test_question = gr.Textbox(
|
| 216 |
+
placeholder="테스트할 질문을 입력하세요",
|
| 217 |
+
label="질문"
|
| 218 |
+
)
|
| 219 |
+
test_btn = gr.Button("🧪 테스트 실행", variant="primary")
|
| 220 |
+
with gr.Column():
|
| 221 |
+
test_result = gr.HTML("테스트 결과가 여기에 표시됩니다.")
|
| 222 |
+
|
| 223 |
+
gr.Markdown("### 통계 정보")
|
| 224 |
+
stats_display = gr.HTML(self.get_chatbot_stats())
|
| 225 |
+
|
| 226 |
+
with gr.Tab("⚙️ 설정"):
|
| 227 |
+
gr.Markdown("### 시스템 설정")
|
| 228 |
+
with gr.Row():
|
| 229 |
+
with gr.Column():
|
| 230 |
+
docs_folder = gr.Textbox(
|
| 231 |
+
value="documents",
|
| 232 |
+
label="문서 폴더 경로"
|
| 233 |
+
)
|
| 234 |
+
force_rebuild = gr.Checkbox(
|
| 235 |
+
label="강제 재구축",
|
| 236 |
+
info="체크 시 기존 인덱스를 새로构建"
|
| 237 |
+
)
|
| 238 |
+
with gr.Column():
|
| 239 |
+
rebuild_btn = gr.Button("🔄 인덱스 재구축", variant="primary")
|
| 240 |
+
|
| 241 |
+
gr.Markdown("### 시스템 정보")
|
| 242 |
+
system_info = gr.HTML("""
|
| 243 |
+
<div class="stats-box">
|
| 244 |
+
<h4>🤖 시스템 사양</h4>
|
| 245 |
+
<ul>
|
| 246 |
+
<li>임베딩 모델: jhgan/ko-sbert-nli</li>
|
| 247 |
+
<li>LLM 모델: beomi/Llama-3-Open-Ko-8B</li>
|
| 248 |
+
<li>청크 크기: 500</li>
|
| 249 |
+
<li>검색 문서 수: 3개</li>
|
| 250 |
+
</ul>
|
| 251 |
+
</div>
|
| 252 |
+
""")
|
| 253 |
+
|
| 254 |
+
# 이벤트 핸들러
|
| 255 |
+
def init_chatbot_wrapper():
|
| 256 |
+
status = self.initialize_chatbot()
|
| 257 |
+
return status, self.get_chatbot_stats()
|
| 258 |
+
|
| 259 |
+
init_btn.click(
|
| 260 |
+
fn=init_chatbot_wrapper,
|
| 261 |
+
outputs=[init_status, stats_info]
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
def rebuild_wrapper(docs_path, force):
|
| 265 |
+
return self.initialize_chatbot(docs_path, force)
|
| 266 |
+
|
| 267 |
+
rebuild_btn.click(
|
| 268 |
+
fn=rebuild_wrapper,
|
| 269 |
+
inputs=[docs_folder, force_rebuild],
|
| 270 |
+
outputs=stats_display
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# 채팅 이벤트
|
| 274 |
+
def chat_wrapper(message, history):
|
| 275 |
+
return self.chat_interface(message, history), ""
|
| 276 |
+
|
| 277 |
+
msg.submit(
|
| 278 |
+
fn=chat_wrapper,
|
| 279 |
+
inputs=[msg, chatbot],
|
| 280 |
+
outputs=[chatbot, msg]
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
submit_btn.click(
|
| 284 |
+
fn=chat_wrapper,
|
| 285 |
+
inputs=[msg, chatbot],
|
| 286 |
+
outputs=[chatbot, msg]
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
clear_btn.click(
|
| 290 |
+
fn=lambda: ([], ""),
|
| 291 |
+
outputs=[chatbot, msg]
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
# 테스트 이벤트
|
| 295 |
+
test_btn.click(
|
| 296 |
+
fn=self.test_question,
|
| 297 |
+
inputs=test_question,
|
| 298 |
+
outputs=test_result
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
# 자동 새로고침 (통계 정보)
|
| 302 |
+
interface.load(
|
| 303 |
+
fn=self.get_chatbot_stats,
|
| 304 |
+
outputs=stats_info,
|
| 305 |
+
every=30 # 30초마다 새로고침
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
return interface
|
| 309 |
+
|
| 310 |
+
def launch(self, share: bool = False, server_port: int = 7860):
|
| 311 |
+
"""인터페이스 실행"""
|
| 312 |
+
interface = self.create_interface()
|
| 313 |
+
|
| 314 |
+
print("🚀 Gradio 웹 인터페이스 시작 중...")
|
| 315 |
+
print(f"📍 접속 주소: http://localhost:{server_port}")
|
| 316 |
+
|
| 317 |
+
interface.launch(
|
| 318 |
+
share=share,
|
| 319 |
+
server_port=server_port,
|
| 320 |
+
show_error=True,
|
| 321 |
+
show_tips=True,
|
| 322 |
+
inbrowser=True
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
# 메인 실행
|
| 326 |
+
if __name__ == "__main__":
|
| 327 |
+
# 웹 인터페이스 실행
|
| 328 |
+
gradio_app = GradioInterface()
|
| 329 |
+
gradio_app.launch(share=False, server_port=7860)
|
rag_chatbot.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
from typing import List, Dict, Tuple
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
import torch
|
| 6 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
|
| 7 |
+
from langchain.schema import Document
|
| 8 |
+
from document_processor import DocumentProcessor
|
| 9 |
+
from vector_store import VectorStore
|
| 10 |
+
from config import Config
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class ChatResponse:
|
| 14 |
+
"""챗봇 응답 결과 클래스"""
|
| 15 |
+
answer: str
|
| 16 |
+
sources: List[Dict]
|
| 17 |
+
confidence: float
|
| 18 |
+
response_time: float
|
| 19 |
+
|
| 20 |
+
class RAGChatbot:
|
| 21 |
+
"""소방 복무관리 RAG 챗봇"""
|
| 22 |
+
|
| 23 |
+
def __init__(self):
|
| 24 |
+
self.document_processor = DocumentProcessor(
|
| 25 |
+
chunk_size=Config.CHUNK_SIZE,
|
| 26 |
+
chunk_overlap=Config.CHUNK_OVERLAP
|
| 27 |
+
)
|
| 28 |
+
self.vector_store = VectorStore()
|
| 29 |
+
self.llm = None
|
| 30 |
+
self.llm_tokenizer = None
|
| 31 |
+
self.is_initialized = False
|
| 32 |
+
|
| 33 |
+
def initialize(self, docs_folder: str = None, force_rebuild: bool = False):
|
| 34 |
+
"""챗봇 초기화"""
|
| 35 |
+
print("🤖 소방 복무관리 RAG 챗봇 초기화 중...")
|
| 36 |
+
|
| 37 |
+
# 1. 문서 로드 및 처리
|
| 38 |
+
docs_folder = docs_folder or Config.DOCS_FOLDER
|
| 39 |
+
documents = self._load_documents(docs_folder)
|
| 40 |
+
|
| 41 |
+
if not documents:
|
| 42 |
+
print("❌ 처리할 문서가 없습니다. documents 폴더에 파일을 넣어주세요.")
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
# 2. 벡터 데이터베이스 구축
|
| 46 |
+
success = self.vector_store.rebuild_if_needed(documents, force_rebuild)
|
| 47 |
+
if not success:
|
| 48 |
+
print("❌ 벡터 데이터베이스 구축 실패")
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
# 3. LLM 모델 로드 (선택적 - 메모리 부족 시 스킵)
|
| 52 |
+
try:
|
| 53 |
+
self._load_llm()
|
| 54 |
+
except Exception as e:
|
| 55 |
+
print(f"⚠️ LLM 모델 로드 실패: {str(e)}")
|
| 56 |
+
print("📝 템플릿 기반 응답 모드로 동작합니다.")
|
| 57 |
+
|
| 58 |
+
self.is_initialized = True
|
| 59 |
+
print("✅ RAG 챗봇 초기화 완료")
|
| 60 |
+
return True
|
| 61 |
+
|
| 62 |
+
def _load_documents(self, docs_folder: str) -> List[Document]:
|
| 63 |
+
"""문서 로드 및 처리"""
|
| 64 |
+
if not os.path.exists(docs_folder):
|
| 65 |
+
print(f"⚠️ 문서 폴더가 존재하지 않습니다: {docs_folder}")
|
| 66 |
+
return []
|
| 67 |
+
|
| 68 |
+
print(f"📂 문서 폴더: {docs_folder}")
|
| 69 |
+
raw_documents = self.document_processor.load_documents_from_folder(docs_folder)
|
| 70 |
+
processed_documents = self.document_processor.process_documents(raw_documents)
|
| 71 |
+
|
| 72 |
+
print(f"✅ 총 {len(processed_documents)}개 문서 청크 생성 완료")
|
| 73 |
+
return processed_documents
|
| 74 |
+
|
| 75 |
+
def _load_llm(self):
|
| 76 |
+
"""LLM 모델 로드"""
|
| 77 |
+
print(f"🧠 LLM 모델 로드: {Config.LLM_MODEL}")
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
self.llm_tokenizer = AutoTokenizer.from_pretrained(
|
| 81 |
+
Config.LLM_MODEL,
|
| 82 |
+
trust_remote_code=True
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# 패딩 토큰 설정
|
| 86 |
+
if self.llm_tokenizer.pad_token is None:
|
| 87 |
+
self.llm_tokenizer.pad_token = self.llm_tokenizer.eos_token
|
| 88 |
+
|
| 89 |
+
self.llm = AutoModelForCausalLM.from_pretrained(
|
| 90 |
+
Config.LLM_MODEL,
|
| 91 |
+
torch_dtype=torch.float16,
|
| 92 |
+
device_map="auto",
|
| 93 |
+
trust_remote_code=True
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
print("✅ LLM 모델 로드 완료")
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
raise Exception(f"LLM 모델 로드 실패: {str(e)}")
|
| 100 |
+
|
| 101 |
+
def search_relevant_docs(self, query: str, k: int = 3) -> List[Tuple[Document, float]]:
|
| 102 |
+
"""관련 문서 검색"""
|
| 103 |
+
if not self.is_initialized:
|
| 104 |
+
print("⚠️ 챗봇이 초기화되지 않았습니다.")
|
| 105 |
+
return []
|
| 106 |
+
|
| 107 |
+
# 쿼리 전처리
|
| 108 |
+
processed_query = self._preprocess_query(query)
|
| 109 |
+
|
| 110 |
+
# 벡터 검색
|
| 111 |
+
results = self.vector_store.search_similar(processed_query, k)
|
| 112 |
+
|
| 113 |
+
# 유사도 필터링
|
| 114 |
+
filtered_results = [
|
| 115 |
+
(doc, similarity) for doc, similarity in results
|
| 116 |
+
if similarity > 0.3 # 최소 유사도 임계값
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
return filtered_results
|
| 120 |
+
|
| 121 |
+
def _preprocess_query(self, query: str) -> str:
|
| 122 |
+
"""쿼리 전처리"""
|
| 123 |
+
# 불필요한 공백 제거
|
| 124 |
+
query = re.sub(r'\s+', ' ', query.strip())
|
| 125 |
+
|
| 126 |
+
# 복무관리 관련 키워드 강화
|
| 127 |
+
keyword_mappings = {
|
| 128 |
+
"연차": "연차휴가",
|
| 129 |
+
"휴가": "휴가사용",
|
| 130 |
+
"근무": "근무시간",
|
| 131 |
+
"당직": "당직근무",
|
| 132 |
+
"인사": "인사평가",
|
| 133 |
+
"승진": "승진시험"
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
for keyword, enhanced in keyword_mappings.items():
|
| 137 |
+
if keyword in query and enhanced not in query:
|
| 138 |
+
query = query.replace(keyword, enhanced)
|
| 139 |
+
|
| 140 |
+
return query
|
| 141 |
+
|
| 142 |
+
def generate_answer(self, query: str, use_llm: bool = True) -> ChatResponse:
|
| 143 |
+
"""질문에 대한 답변 생성"""
|
| 144 |
+
import time
|
| 145 |
+
start_time = time.time()
|
| 146 |
+
|
| 147 |
+
if not self.is_initialized:
|
| 148 |
+
return ChatResponse(
|
| 149 |
+
answer="죄송합니다. 챗봇이 초기화되지 않았습니다. 관리자에게 문의해주세요.",
|
| 150 |
+
sources=[],
|
| 151 |
+
confidence=0.0,
|
| 152 |
+
response_time=time.time() - start_time
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
# 1. 관련 문서 검색
|
| 156 |
+
relevant_docs = self.search_relevant_docs(query, k=Config.MAX_RETRIEVE_DOCS)
|
| 157 |
+
|
| 158 |
+
if not relevant_docs:
|
| 159 |
+
return ChatResponse(
|
| 160 |
+
answer="죄송합니다. 질문과 관련된 정보를 찾을 수 없습니다. 다른 방식으로 질문해주시거나 관련 부서에 문의해주시기 바랍니다.",
|
| 161 |
+
sources=[],
|
| 162 |
+
confidence=0.0,
|
| 163 |
+
response_time=time.time() - start_time
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
# 2. 답변 생성
|
| 167 |
+
if use_llm and self.llm is not None:
|
| 168 |
+
answer = self._generate_llm_answer(query, relevant_docs)
|
| 169 |
+
else:
|
| 170 |
+
answer = self._generate_template_answer(query, relevant_docs)
|
| 171 |
+
|
| 172 |
+
# 3. 출처 정보 준비
|
| 173 |
+
sources = [
|
| 174 |
+
{
|
| 175 |
+
"source": doc.metadata.get("source", "알 수 없음"),
|
| 176 |
+
"content": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content,
|
| 177 |
+
"similarity": f"{similarity:.4f}"
|
| 178 |
+
}
|
| 179 |
+
for doc, similarity in relevant_docs
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
# 4. 신뢰도 계산
|
| 183 |
+
confidence = min(sum(similarity for _, similarity in relevant_docs) / len(relevant_docs), 1.0)
|
| 184 |
+
|
| 185 |
+
return ChatResponse(
|
| 186 |
+
answer=answer,
|
| 187 |
+
sources=sources,
|
| 188 |
+
confidence=confidence,
|
| 189 |
+
response_time=time.time() - start_time
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
def _generate_llm_answer(self, query: str, relevant_docs: List[Tuple[Document, float]]) -> str:
|
| 193 |
+
"""LLM으로 답변 생성"""
|
| 194 |
+
try:
|
| 195 |
+
# 문맥 구성
|
| 196 |
+
context = "\n\n".join([
|
| 197 |
+
f"[출처 {i+1}] {doc.page_content}"
|
| 198 |
+
for i, (doc, _) in enumerate(relevant_docs)
|
| 199 |
+
])
|
| 200 |
+
|
| 201 |
+
# 프롬프트 구성
|
| 202 |
+
prompt = f"""{Config.SYSTEM_PROMPT}
|
| 203 |
+
|
| 204 |
+
[참고자료]
|
| 205 |
+
{context}
|
| 206 |
+
|
| 207 |
+
[질문]
|
| 208 |
+
{query}
|
| 209 |
+
|
| 210 |
+
위 참고자료를 바탕으로 질문에 답변해주세요. 정확하고 친절하게 설명해주세요."""
|
| 211 |
+
|
| 212 |
+
# 토크나이징
|
| 213 |
+
inputs = self.llm_tokenizer(
|
| 214 |
+
prompt,
|
| 215 |
+
return_tensors="pt",
|
| 216 |
+
max_length=2048,
|
| 217 |
+
truncation=True
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# 생성
|
| 221 |
+
with torch.no_grad():
|
| 222 |
+
outputs = self.llm.generate(
|
| 223 |
+
inputs.input_ids,
|
| 224 |
+
max_new_tokens=512,
|
| 225 |
+
temperature=0.7,
|
| 226 |
+
do_sample=True,
|
| 227 |
+
pad_token_id=self.llm_tokenizer.eos_token_id
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# 결과 디코딩
|
| 231 |
+
answer = self.llm_tokenizer.decode(
|
| 232 |
+
outputs[0][inputs.input_ids.shape[1]:],
|
| 233 |
+
skip_special_tokens=True
|
| 234 |
+
).strip()
|
| 235 |
+
|
| 236 |
+
return answer
|
| 237 |
+
|
| 238 |
+
except Exception as e:
|
| 239 |
+
print(f"⚠️ LLM 답변 생성 실패: {str(e)}")
|
| 240 |
+
return self._generate_template_answer(query, relevant_docs)
|
| 241 |
+
|
| 242 |
+
def _generate_template_answer(self, query: str, relevant_docs: List[Tuple[Document, float]]) -> str:
|
| 243 |
+
"""템플릿 기반 답변 생성"""
|
| 244 |
+
# 쿼리 분석
|
| 245 |
+
query_lower = query.lower()
|
| 246 |
+
|
| 247 |
+
# 가장 관련성 높은 문서
|
| 248 |
+
top_doc, top_similarity = relevant_docs[0]
|
| 249 |
+
|
| 250 |
+
# 기본 답변 형식
|
| 251 |
+
if "연차" in query_lower or "휴가" in query_lower:
|
| 252 |
+
return self._format_leave_answer(top_doc, query)
|
| 253 |
+
elif "근무시간" in query_lower or "시간" in query_lower:
|
| 254 |
+
return self._format_work_hours_answer(top_doc, query)
|
| 255 |
+
elif "당직" in query_lower:
|
| 256 |
+
return self._format_duty_answer(top_doc, query)
|
| 257 |
+
elif "인사" in query_lower or "평가" in query_lower:
|
| 258 |
+
return self._format_evaluation_answer(top_doc, query)
|
| 259 |
+
else:
|
| 260 |
+
return self._format_general_answer(top_doc, query)
|
| 261 |
+
|
| 262 |
+
def _format_leave_answer(self, doc: Document, query: str) -> str:
|
| 263 |
+
"""휴가 관련 답변 형식"""
|
| 264 |
+
content = doc.page_content
|
| 265 |
+
|
| 266 |
+
answer = f"📅 연차휴가 안내\n\n"
|
| 267 |
+
|
| 268 |
+
# 숫자와 관련된 내용 추출
|
| 269 |
+
import re
|
| 270 |
+
days = re.findall(r'(\d+)일', content)
|
| 271 |
+
periods = re.findall(r'(\d+)일 전', content)
|
| 272 |
+
|
| 273 |
+
if days:
|
| 274 |
+
answer += f"• 연차휴가 일수: {days[0]}일\n"
|
| 275 |
+
if periods:
|
| 276 |
+
answer += f"• 신청 기한: {periods[0]}일 전\n"
|
| 277 |
+
|
| 278 |
+
answer += f"\n{content[:300]}..."
|
| 279 |
+
|
| 280 |
+
if len(content) > 300:
|
| 281 |
+
answer += "\n\n📋 자세한 내용은 관련 규정을 확인하시거나 인사담당자에게 문의해주세요."
|
| 282 |
+
|
| 283 |
+
return answer
|
| 284 |
+
|
| 285 |
+
def _format_work_hours_answer(self, doc: Document, query: str) -> str:
|
| 286 |
+
"""근무시간 관련 답변 형식"""
|
| 287 |
+
content = doc.page_content
|
| 288 |
+
|
| 289 |
+
answer = f"⏰ 근무시간 안내\n\n"
|
| 290 |
+
answer += f"{content[:400]}..."
|
| 291 |
+
|
| 292 |
+
# 시간 정보 추출
|
| 293 |
+
import re
|
| 294 |
+
times = re.findall(r'\d{2}:\d{2}', content)
|
| 295 |
+
if times:
|
| 296 |
+
answer += f"\n\n🕐 주요 시간: {', '.join(times)}"
|
| 297 |
+
|
| 298 |
+
return answer
|
| 299 |
+
|
| 300 |
+
def _format_duty_answer(self, doc: Document, query: str) -> str:
|
| 301 |
+
"""당직 관련 답변 형식"""
|
| 302 |
+
answer = f"🌙 당직근무 안내\n\n"
|
| 303 |
+
answer += f"{doc.page_content[:400]}..."
|
| 304 |
+
answer += "\n\n📞 당직 관련 추가 문의는 관리부서로 연락주세요."
|
| 305 |
+
return answer
|
| 306 |
+
|
| 307 |
+
def _format_evaluation_answer(self, doc: Document, query: str) -> str:
|
| 308 |
+
"""인사평가 관련 답변 형식"""
|
| 309 |
+
answer = f"📊 인사평가 안내\n\n"
|
| 310 |
+
answer += f"{doc.page_content[:400]}..."
|
| 311 |
+
answer += "\n\n💡 평가 관련 구체적인 문의는 인사담당자에게 문의해주세요."
|
| 312 |
+
return answer
|
| 313 |
+
|
| 314 |
+
def _format_general_answer(self, doc: Document, query: str) -> str:
|
| 315 |
+
"""일반 답변 형식"""
|
| 316 |
+
answer = f"📋 복무관리 안내\n\n"
|
| 317 |
+
answer += f"질문: {query}\n\n"
|
| 318 |
+
answer += f"관련 정보:\n{doc.page_content[:400]}..."
|
| 319 |
+
|
| 320 |
+
if len(doc.page_content) > 400:
|
| 321 |
+
answer += "\n\n📖 더 자세한 정보는 관련 규정 파일을 확인해주세요."
|
| 322 |
+
|
| 323 |
+
return answer
|
| 324 |
+
|
| 325 |
+
def get_stats(self) -> Dict:
|
| 326 |
+
"""챗봇 통계 정보"""
|
| 327 |
+
if not self.is_initialized:
|
| 328 |
+
return {"status": "not_initialized"}
|
| 329 |
+
|
| 330 |
+
vector_stats = self.vector_store.get_stats()
|
| 331 |
+
|
| 332 |
+
return {
|
| 333 |
+
"status": "initialized",
|
| 334 |
+
"vector_store": vector_stats,
|
| 335 |
+
"llm_available": self.llm is not None,
|
| 336 |
+
"system_prompt": Config.SYSTEM_PROMPT[:100] + "..."
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
# 테스트용 함수
|
| 340 |
+
def test_rag_chatbot():
|
| 341 |
+
"""RAG 챗봇 테스트"""
|
| 342 |
+
# 샘플 문서 폴더 확인
|
| 343 |
+
if not os.path.exists("documents"):
|
| 344 |
+
print("⚠️ documents 폴더가 없습니다. document_processor.py를 먼저 실행해주세요.")
|
| 345 |
+
return
|
| 346 |
+
|
| 347 |
+
# 챗봇 초기화
|
| 348 |
+
chatbot = RAGChatbot()
|
| 349 |
+
success = chatbot.initialize()
|
| 350 |
+
|
| 351 |
+
if not success:
|
| 352 |
+
return
|
| 353 |
+
|
| 354 |
+
# 테스트 질문
|
| 355 |
+
test_questions = [
|
| 356 |
+
"연차휴가는 어떻게 사용하나요?",
|
| 357 |
+
"정규근무시간은 어떻게 되나요?",
|
| 358 |
+
"당직근무가 무엇인가요?",
|
| 359 |
+
"인사평가 절차가 궁금합니다."
|
| 360 |
+
]
|
| 361 |
+
|
| 362 |
+
# 질문 테스트
|
| 363 |
+
for question in test_questions:
|
| 364 |
+
print(f"\n❓ 질문: {question}")
|
| 365 |
+
response = chatbot.generate_answer(question, use_llm=False) # 템플릿 모드로 테스트
|
| 366 |
+
|
| 367 |
+
print(f"🤖 답변: {response.answer[:300]}...")
|
| 368 |
+
print(f"📊 신뢰도: {response.confidence:.4f}")
|
| 369 |
+
print(f"⏱️ 응답시간: {response.response_time:.4f}초")
|
| 370 |
+
print(f"📚 출처: {len(response.sources)}개")
|
| 371 |
+
|
| 372 |
+
# 통계 정보
|
| 373 |
+
print(f"\n📈 챗봇 통계: {chatbot.get_stats()}")
|
| 374 |
+
|
| 375 |
+
if __name__ == "__main__":
|
| 376 |
+
test_rag_chatbot()
|
requirements.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RAG 챗봇을 위한 필수 라이브러리
|
| 2 |
+
langchain>=0.1.0
|
| 3 |
+
langchain-community>=0.0.20
|
| 4 |
+
langchain-huggingface>=0.0.3
|
| 5 |
+
langchain-text-splitters>=0.0.1
|
| 6 |
+
|
| 7 |
+
# 허깅페이스 관련
|
| 8 |
+
transformers>=4.36.0
|
| 9 |
+
torch>=2.1.0
|
| 10 |
+
sentence-transformers>=2.2.2
|
| 11 |
+
huggingface-hub>=0.20.0
|
| 12 |
+
|
| 13 |
+
# 벡터 데이터베이스
|
| 14 |
+
faiss-cpu>=1.7.4
|
| 15 |
+
chromadb>=0.4.18
|
| 16 |
+
|
| 17 |
+
# 웹 인터페이스
|
| 18 |
+
gradio>=4.7.1
|
| 19 |
+
streamlit>=1.28.0
|
| 20 |
+
|
| 21 |
+
# 문서 처리
|
| 22 |
+
pypdf>=3.17.0
|
| 23 |
+
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
|
| 30 |
+
pandas>=2.1.0
|
vector_store.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pickle
|
| 3 |
+
import numpy as np
|
| 4 |
+
from typing import List, Dict, Tuple
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
import faiss
|
| 8 |
+
from langchain.schema import Document
|
| 9 |
+
from config import Config
|
| 10 |
+
|
| 11 |
+
class VectorStore:
|
| 12 |
+
"""FAISS 기반 벡터 데이터베이스 클래스"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, embedding_model: str = None, cache_dir: str = None):
|
| 15 |
+
self.embedding_model_name = embedding_model or Config.EMBEDDING_MODEL
|
| 16 |
+
self.cache_dir = cache_dir or Config.VECTOR_DB_PATH
|
| 17 |
+
self.model = None
|
| 18 |
+
self.index = None
|
| 19 |
+
self.documents = []
|
| 20 |
+
self.doc_ids = []
|
| 21 |
+
|
| 22 |
+
# 캐시 디렉토리 생성
|
| 23 |
+
Path(self.cache_dir).mkdir(parents=True, exist_ok=True)
|
| 24 |
+
|
| 25 |
+
def load_embedding_model(self):
|
| 26 |
+
"""임베딩 모델 로드"""
|
| 27 |
+
if self.model is None:
|
| 28 |
+
print(f"📥 임베딩 모델 로드: {self.embedding_model_name}")
|
| 29 |
+
try:
|
| 30 |
+
self.model = SentenceTransformer(self.embedding_model_name)
|
| 31 |
+
print("✅ 임베딩 모델 로드 완료")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"❌ 임베딩 모델 로드 실패: {str(e)}")
|
| 34 |
+
print("🔄 다국어 모델로 대체 시도...")
|
| 35 |
+
self.model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
|
| 36 |
+
|
| 37 |
+
def create_embeddings(self, texts: List[str]) -> np.ndarray:
|
| 38 |
+
"""텍스트 목록에 대한 임베딩 생성"""
|
| 39 |
+
if self.model is None:
|
| 40 |
+
self.load_embedding_model()
|
| 41 |
+
|
| 42 |
+
print(f"🔄 {len(texts)}개 텍스트 임베딩 생성 중...")
|
| 43 |
+
embeddings = self.model.encode(
|
| 44 |
+
texts,
|
| 45 |
+
batch_size=32,
|
| 46 |
+
show_progress_bar=True,
|
| 47 |
+
convert_to_numpy=True,
|
| 48 |
+
normalize_embeddings=True
|
| 49 |
+
)
|
| 50 |
+
return embeddings
|
| 51 |
+
|
| 52 |
+
def build_vector_index(self, documents: List[Document]) -> bool:
|
| 53 |
+
"""문서 목록으로부터 벡터 인덱스 구축"""
|
| 54 |
+
if not documents:
|
| 55 |
+
print("⚠️ 처리할 문서가 없습니다.")
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
print(f"🏗️ {len(documents)}개 문서로 벡터 인덱스 구축 시작...")
|
| 59 |
+
|
| 60 |
+
# 문서 저장
|
| 61 |
+
self.documents = documents
|
| 62 |
+
|
| 63 |
+
# 텍스트 추출
|
| 64 |
+
texts = [doc.page_content for doc in documents]
|
| 65 |
+
|
| 66 |
+
# 임베딩 생성
|
| 67 |
+
embeddings = self.create_embeddings(texts)
|
| 68 |
+
|
| 69 |
+
# FAISS 인덱스 생성
|
| 70 |
+
dimension = embeddings.shape[1]
|
| 71 |
+
self.index = faiss.IndexFlatIP(dimension) # 내적 기반 유사도 검색
|
| 72 |
+
|
| 73 |
+
# 임베딩 추가
|
| 74 |
+
self.index.add(embeddings.astype('float32'))
|
| 75 |
+
|
| 76 |
+
# 문서 ID 생성
|
| 77 |
+
self.doc_ids = list(range(len(documents)))
|
| 78 |
+
|
| 79 |
+
print(f"✅ 벡터 인덱스 구축 완료 (차원: {dimension}, 문서: {len(documents)})")
|
| 80 |
+
|
| 81 |
+
# 인덱스 저장
|
| 82 |
+
self.save_index()
|
| 83 |
+
|
| 84 |
+
return True
|
| 85 |
+
|
| 86 |
+
def search_similar(self, query: str, k: int = 5) -> List[Tuple[Document, float]]:
|
| 87 |
+
"""유사 문서 검색"""
|
| 88 |
+
if self.index is None:
|
| 89 |
+
print("⚠️ 벡터 인덱스가 생성되지 않았습니다.")
|
| 90 |
+
return []
|
| 91 |
+
|
| 92 |
+
if self.model is None:
|
| 93 |
+
self.load_embedding_model()
|
| 94 |
+
|
| 95 |
+
# 쿼리 임베딩 생성
|
| 96 |
+
query_embedding = self.model.encode([query], normalize_embeddings=True)
|
| 97 |
+
query_embedding = query_embedding.astype('float32')
|
| 98 |
+
|
| 99 |
+
# 검색
|
| 100 |
+
k = min(k, len(self.documents))
|
| 101 |
+
similarities, indices = self.index.search(query_embedding, k)
|
| 102 |
+
|
| 103 |
+
# 결과 변환
|
| 104 |
+
results = []
|
| 105 |
+
for i in range(k):
|
| 106 |
+
idx = indices[0][i]
|
| 107 |
+
similarity = similarities[0][i]
|
| 108 |
+
|
| 109 |
+
if 0 <= idx < len(self.documents):
|
| 110 |
+
doc = self.documents[idx]
|
| 111 |
+
results.append((doc, float(similarity)))
|
| 112 |
+
|
| 113 |
+
return results
|
| 114 |
+
|
| 115 |
+
def save_index(self):
|
| 116 |
+
"""벡터 인덱스 및 문서 저장"""
|
| 117 |
+
if self.index is None:
|
| 118 |
+
return
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
# FAISS 인덱스 저장
|
| 122 |
+
index_path = os.path.join(self.cache_dir, "faiss_index.bin")
|
| 123 |
+
faiss.write_index(self.index, index_path)
|
| 124 |
+
|
| 125 |
+
# 문서 및 메타데이터 저장
|
| 126 |
+
metadata_path = os.path.join(self.cache_dir, "metadata.pkl")
|
| 127 |
+
metadata = {
|
| 128 |
+
'documents': self.documents,
|
| 129 |
+
'doc_ids': self.doc_ids,
|
| 130 |
+
'embedding_model': self.embedding_model_name,
|
| 131 |
+
'total_documents': len(self.documents)
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
with open(metadata_path, 'wb') as f:
|
| 135 |
+
pickle.dump(metadata, f)
|
| 136 |
+
|
| 137 |
+
print(f"💾 벡터 인덱스 저장 완료: {self.cache_dir}")
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
print(f"❌ 인덱스 저장 실패: {str(e)}")
|
| 141 |
+
|
| 142 |
+
def load_index(self) -> bool:
|
| 143 |
+
"""저장된 벡터 인덱스 로드"""
|
| 144 |
+
try:
|
| 145 |
+
index_path = os.path.join(self.cache_dir, "faiss_index.bin")
|
| 146 |
+
metadata_path = os.path.join(self.cache_dir, "metadata.pkl")
|
| 147 |
+
|
| 148 |
+
if not os.path.exists(index_path) or not os.path.exists(metadata_path):
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
# FAISS 인덱스 로드
|
| 152 |
+
self.index = faiss.read_index(index_path)
|
| 153 |
+
|
| 154 |
+
# 메타데이터 로드
|
| 155 |
+
with open(metadata_path, 'rb') as f:
|
| 156 |
+
metadata = pickle.load(f)
|
| 157 |
+
|
| 158 |
+
self.documents = metadata['documents']
|
| 159 |
+
self.doc_ids = metadata['doc_ids']
|
| 160 |
+
self.embedding_model_name = metadata.get('embedding_model', Config.EMBEDDING_MODEL)
|
| 161 |
+
|
| 162 |
+
# 임베딩 모델 로드
|
| 163 |
+
self.load_embedding_model()
|
| 164 |
+
|
| 165 |
+
print(f"📖 벡터 인덱스 로드 완료 (문서: {len(self.documents)}개)")
|
| 166 |
+
return True
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
print(f"❌ 인덱스 로드 실패: {str(e)}")
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
def get_stats(self) -> Dict:
|
| 173 |
+
"""벡터 데이터베이스 통계 정보"""
|
| 174 |
+
if self.index is None:
|
| 175 |
+
return {"status": "no_index"}
|
| 176 |
+
|
| 177 |
+
return {
|
| 178 |
+
"total_documents": len(self.documents),
|
| 179 |
+
"embedding_model": self.embedding_model_name,
|
| 180 |
+
"index_dimension": self.index.d,
|
| 181 |
+
"cache_directory": self.cache_dir,
|
| 182 |
+
"is_trained": self.index.is_trained if hasattr(self.index, 'is_trained') else True
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
def rebuild_if_needed(self, documents: List[Document], force_rebuild: bool = False) -> bool:
|
| 186 |
+
"""필요시 인덱스 재구축"""
|
| 187 |
+
# 기존 인덱스가 있고 강제 재구축이 없는 경우
|
| 188 |
+
if not force_rebuild and self.load_index():
|
| 189 |
+
# 문서 개수가 크게 변경되지 않았으면 재사용
|
| 190 |
+
if abs(len(self.documents) - len(documents)) / max(len(self.documents), 1) < 0.1:
|
| 191 |
+
print("📦 기존 인덱스 재사용")
|
| 192 |
+
return True
|
| 193 |
+
|
| 194 |
+
print("🔄 벡터 인덱스 재구축")
|
| 195 |
+
return self.build_vector_index(documents)
|
| 196 |
+
|
| 197 |
+
def add_documents(self, new_documents: List[Document]) -> bool:
|
| 198 |
+
"""새 문서 추가 (동적 업데이트)"""
|
| 199 |
+
if not new_documents:
|
| 200 |
+
return False
|
| 201 |
+
|
| 202 |
+
# 임베딩 생성
|
| 203 |
+
new_texts = [doc.page_content for doc in new_documents]
|
| 204 |
+
new_embeddings = self.create_embeddings(new_texts)
|
| 205 |
+
|
| 206 |
+
if self.index is None:
|
| 207 |
+
# 인덱스가 없으면 새로 생성
|
| 208 |
+
return self.build_vector_index(new_documents)
|
| 209 |
+
|
| 210 |
+
# 기존 인덱스에 추가
|
| 211 |
+
self.index.add(new_embeddings.astype('float32'))
|
| 212 |
+
|
| 213 |
+
# 문서 목록 업데이트
|
| 214 |
+
start_id = len(self.documents)
|
| 215 |
+
self.documents.extend(new_documents)
|
| 216 |
+
self.doc_ids.extend(range(start_id, start_id + len(new_documents)))
|
| 217 |
+
|
| 218 |
+
print(f"➕ {len(new_documents)}개 문서 추가 완료")
|
| 219 |
+
|
| 220 |
+
# 저장
|
| 221 |
+
self.save_index()
|
| 222 |
+
return True
|
| 223 |
+
|
| 224 |
+
def delete_document(self, doc_id: int) -> bool:
|
| 225 |
+
"""문서 삭제 (실제로는 인덱스 재구축 필요)"""
|
| 226 |
+
if doc_id < 0 or doc_id >= len(self.documents):
|
| 227 |
+
return False
|
| 228 |
+
|
| 229 |
+
# 해당 문서 제외하고 재구축
|
| 230 |
+
remaining_docs = [doc for i, doc in enumerate(self.documents) if i != doc_id]
|
| 231 |
+
return self.build_vector_index(remaining_docs)
|
| 232 |
+
|
| 233 |
+
# 테스트용 함수
|
| 234 |
+
def test_vector_store():
|
| 235 |
+
"""벡터 데이터베이스 테스트"""
|
| 236 |
+
from document_processor import DocumentProcessor
|
| 237 |
+
|
| 238 |
+
# 문서 처리
|
| 239 |
+
processor = DocumentProcessor()
|
| 240 |
+
documents = processor.load_documents_from_folder("documents")
|
| 241 |
+
|
| 242 |
+
if not documents:
|
| 243 |
+
print("⚠️ 테스트할 문서가 없습니다.")
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
# 벡터 데이터베이스 생성
|
| 247 |
+
vector_store = VectorStore()
|
| 248 |
+
vector_store.build_vector_index(documents)
|
| 249 |
+
|
| 250 |
+
# 검색 테스트
|
| 251 |
+
test_queries = [
|
| 252 |
+
"연차휴가 사용 방법",
|
| 253 |
+
"근무시간은 어떻게 되나요?",
|
| 254 |
+
"당직근무 절차"
|
| 255 |
+
]
|
| 256 |
+
|
| 257 |
+
for query in test_queries:
|
| 258 |
+
print(f"\n🔍 검색: {query}")
|
| 259 |
+
results = vector_store.search_similar(query, k=3)
|
| 260 |
+
|
| 261 |
+
for i, (doc, similarity) in enumerate(results):
|
| 262 |
+
print(f" {i+1}. 유사도: {similarity:.4f}")
|
| 263 |
+
print(f" 내용: {doc.page_content[:100]}...")
|
| 264 |
+
|
| 265 |
+
if __name__ == "__main__":
|
| 266 |
+
test_vector_store()
|