Commit ·
4739096
0
Parent(s):
Initial commit for HF Spaces deployment
Browse files- .devcontainer/devcontainer.json +33 -0
- .dockerfile +68 -0
- .env.example +15 -0
- .gitattributes +8 -0
- .gitignore +244 -0
- .vscode/settings.json +4 -0
- EDA_report.md +614 -0
- LICENSE +21 -0
- README.md +145 -0
- chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/data_level0.bin +3 -0
- chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/header.bin +3 -0
- chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/index_metadata.pickle +3 -0
- chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/length.bin +3 -0
- chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/link_lists.bin +3 -0
- chroma_db/chroma.sqlite3 +3 -0
- data/rag_chunks_final.csv +3 -0
- main.py +237 -0
- notebooks/export_v2.py +47 -0
- notebooks/llama.ipynb +887 -0
- notebooks/train.py +116 -0
- project_setting.md +295 -0
- pyproject.toml +68 -0
- requirements.txt +52 -0
- src/__init__.py +0 -0
- src/embedding/rag_data_processing.py +230 -0
- src/evaluation/__init__.py +0 -0
- src/evaluation/experiment_tracker.py +427 -0
- src/evaluation/run_experiment.py +535 -0
- src/generator/generator.py +335 -0
- src/generator/generator_gguf.py +580 -0
- src/generator/generator_lee.py +377 -0
- src/loader/__init__.py +0 -0
- src/loader/preprocess_pipeline.py +553 -0
- src/prompts/dynamic_prompts.py +87 -0
- src/prompts/dynamic_prompts_jiyunpark.py +357 -0
- src/retriever/main.py +67 -0
- src/retriever/retriever.py +313 -0
- src/router/query_router.py +65 -0
- src/router/query_router_lee.py +77 -0
- src/utils/__init__.py +0 -0
- src/utils/config.py +177 -0
- src/utils/conversation_manager.py +233 -0
- src/visualization/chatbot_app.py +545 -0
- src/visualization/dimensionality_reduction.py +246 -0
- src/visualization/streamlit_app.py +404 -0
- src/visualization/vector_db_loader.py +296 -0
.devcontainer/devcontainer.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Python 3",
|
| 3 |
+
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
| 4 |
+
"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
|
| 5 |
+
"customizations": {
|
| 6 |
+
"codespaces": {
|
| 7 |
+
"openFiles": [
|
| 8 |
+
"README.md",
|
| 9 |
+
"src/visualization/streamlit_app.py"
|
| 10 |
+
]
|
| 11 |
+
},
|
| 12 |
+
"vscode": {
|
| 13 |
+
"settings": {},
|
| 14 |
+
"extensions": [
|
| 15 |
+
"ms-python.python",
|
| 16 |
+
"ms-python.vscode-pylance"
|
| 17 |
+
]
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
"updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y <packages.txt; [ -f requirements.txt ] && pip3 install --user -r requirements.txt; pip3 install --user streamlit; echo '✅ Packages installed and Requirements met'",
|
| 21 |
+
"postAttachCommand": {
|
| 22 |
+
"server": "streamlit run src/visualization/streamlit_app.py --server.enableCORS false --server.enableXsrfProtection false"
|
| 23 |
+
},
|
| 24 |
+
"portsAttributes": {
|
| 25 |
+
"8501": {
|
| 26 |
+
"label": "Application",
|
| 27 |
+
"onAutoForward": "openPreview"
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"forwardPorts": [
|
| 31 |
+
8501
|
| 32 |
+
]
|
| 33 |
+
}
|
.dockerfile
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===== GPU 지원 Dockerfile for Hugging Face Spaces =====
|
| 2 |
+
# CUDA 지원 Python 베이스 이미지
|
| 3 |
+
FROM nvidia/cuda:12.1.0-devel-ubuntu22.04
|
| 4 |
+
|
| 5 |
+
# ===== Python 3.12.3 설치 =====
|
| 6 |
+
# deadsnakes PPA를 통해 Python 3.12 설치
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
software-properties-common \
|
| 9 |
+
&& add-apt-repository ppa:deadsnakes/ppa \
|
| 10 |
+
&& apt-get update
|
| 11 |
+
|
| 12 |
+
# Python 3.12 및 필수 패키지 설치
|
| 13 |
+
RUN apt-get install -y \
|
| 14 |
+
python3.12 \
|
| 15 |
+
python3.12-dev \
|
| 16 |
+
python3.12-distutils \
|
| 17 |
+
python3.12-venv \
|
| 18 |
+
build-essential \
|
| 19 |
+
cmake \
|
| 20 |
+
git \
|
| 21 |
+
curl \
|
| 22 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 23 |
+
|
| 24 |
+
# Python 3.12를 기본 python으로 설정
|
| 25 |
+
RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1 \
|
| 26 |
+
&& update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1
|
| 27 |
+
|
| 28 |
+
# pip 설치 (Python 3.12용)
|
| 29 |
+
RUN curl -sS https://bootstrap.pypa.io/get-pip.py | python3.12
|
| 30 |
+
RUN python -m pip install --upgrade pip setuptools wheel
|
| 31 |
+
|
| 32 |
+
# 작업 디렉토리 설정
|
| 33 |
+
WORKDIR /app
|
| 34 |
+
|
| 35 |
+
# 의존성 파일 복사
|
| 36 |
+
COPY requirements.txt .
|
| 37 |
+
|
| 38 |
+
# ===== llama-cpp-python CUDA 빌드 =====
|
| 39 |
+
# CUDA 지원으로 llama-cpp-python 설치 (먼저 설치)
|
| 40 |
+
ENV CMAKE_ARGS="-DLLAMA_CUBLAS=on"
|
| 41 |
+
ENV FORCE_CMAKE=1
|
| 42 |
+
RUN pip install --no-cache-dir llama-cpp-python==0.3.16
|
| 43 |
+
|
| 44 |
+
# ===== 나머지 의존성 설치 =====
|
| 45 |
+
# llama-cpp-python 제외하고 설치
|
| 46 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 47 |
+
|
| 48 |
+
# ===== 프로젝트 파일 복사 =====
|
| 49 |
+
COPY . .
|
| 50 |
+
|
| 51 |
+
# ===== 환경변수 설정 =====
|
| 52 |
+
# CUDA 가시성 (GPU 사용)
|
| 53 |
+
ENV CUDA_VISIBLE_DEVICES=0
|
| 54 |
+
|
| 55 |
+
# ===== Streamlit 설정 =====
|
| 56 |
+
# HF Spaces는 포트 7860 사용
|
| 57 |
+
EXPOSE 7860
|
| 58 |
+
|
| 59 |
+
# ===== 헬스체크 (선택) =====
|
| 60 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 61 |
+
CMD curl -f http://localhost:7860/_stcore/health || exit 1
|
| 62 |
+
|
| 63 |
+
# ===== 실행 명령 =====
|
| 64 |
+
CMD ["streamlit", "run", "src/visualization/chatbot_app.py", \
|
| 65 |
+
"--server.port=7860", \
|
| 66 |
+
"--server.address=0.0.0.0", \
|
| 67 |
+
"--server.headless=true", \
|
| 68 |
+
"--server.fileWatcherType=none"]
|
.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
OPENAI_API_KEY = "OPENAI_API_KEY"
|
| 2 |
+
|
| 3 |
+
# Wandb 설정(선택)
|
| 4 |
+
WANDB_API_KEY = "WANDB_API_KEY"
|
| 5 |
+
|
| 6 |
+
# LangSmith 설정(선택)
|
| 7 |
+
LANGCHAIN_TRACING_V2=true
|
| 8 |
+
LANGSMITH_API_KEY = "LANGSMITH_API_KEY"
|
| 9 |
+
LANGCHAIN_PROJECT = "LANGCHAIN_PROJECT"
|
| 10 |
+
|
| 11 |
+
# 로컬 개발 (로컬 파일 사용)
|
| 12 |
+
# USE_MODEL_HUB=false
|
| 13 |
+
|
| 14 |
+
# Hugging Face Spaces 배포 (Model Hub 사용)
|
| 15 |
+
USE_MODEL_HUB=true
|
.gitattributes
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git LFS로 추적할 대용량 파일
|
| 2 |
+
chroma_db/** filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
data/rag_chunks_final.csv filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
|
| 5 |
+
# 바이너리 파일 명시
|
| 6 |
+
*.db filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.sqlite filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.sqlite3 filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[codz]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py.cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
cover/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
.pybuilder/
|
| 76 |
+
target/
|
| 77 |
+
|
| 78 |
+
# Jupyter Notebook
|
| 79 |
+
.ipynb_checkpoints
|
| 80 |
+
|
| 81 |
+
# IPython
|
| 82 |
+
profile_default/
|
| 83 |
+
ipython_config.py
|
| 84 |
+
|
| 85 |
+
# pyenv
|
| 86 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 88 |
+
# .python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# UV
|
| 98 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 100 |
+
# commonly ignored for libraries.
|
| 101 |
+
#uv.lock
|
| 102 |
+
|
| 103 |
+
# poetry
|
| 104 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 105 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 106 |
+
# commonly ignored for libraries.
|
| 107 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 108 |
+
#poetry.lock
|
| 109 |
+
#poetry.toml
|
| 110 |
+
|
| 111 |
+
# pdm
|
| 112 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 113 |
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 114 |
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 115 |
+
#pdm.lock
|
| 116 |
+
#pdm.toml
|
| 117 |
+
.pdm-python
|
| 118 |
+
.pdm-build/
|
| 119 |
+
|
| 120 |
+
# pixi
|
| 121 |
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
| 122 |
+
#pixi.lock
|
| 123 |
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
| 124 |
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
| 125 |
+
.pixi
|
| 126 |
+
|
| 127 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 128 |
+
__pypackages__/
|
| 129 |
+
|
| 130 |
+
# Celery stuff
|
| 131 |
+
celerybeat-schedule
|
| 132 |
+
celerybeat.pid
|
| 133 |
+
|
| 134 |
+
# SageMath parsed files
|
| 135 |
+
*.sage.py
|
| 136 |
+
|
| 137 |
+
# Environments
|
| 138 |
+
.env
|
| 139 |
+
.envrc
|
| 140 |
+
.venv
|
| 141 |
+
env/
|
| 142 |
+
venv/
|
| 143 |
+
ENV/
|
| 144 |
+
env.bak/
|
| 145 |
+
venv.bak/
|
| 146 |
+
|
| 147 |
+
# Spyder project settings
|
| 148 |
+
.spyderproject
|
| 149 |
+
.spyproject
|
| 150 |
+
|
| 151 |
+
# Rope project settings
|
| 152 |
+
.ropeproject
|
| 153 |
+
|
| 154 |
+
# mkdocs documentation
|
| 155 |
+
/site
|
| 156 |
+
|
| 157 |
+
# mypy
|
| 158 |
+
.mypy_cache/
|
| 159 |
+
.dmypy.json
|
| 160 |
+
dmypy.json
|
| 161 |
+
|
| 162 |
+
# Pyre type checker
|
| 163 |
+
.pyre/
|
| 164 |
+
|
| 165 |
+
# pytype static type analyzer
|
| 166 |
+
.pytype/
|
| 167 |
+
|
| 168 |
+
# Cython debug symbols
|
| 169 |
+
cython_debug/
|
| 170 |
+
|
| 171 |
+
# PyCharm
|
| 172 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 173 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 174 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 175 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 176 |
+
#.idea/
|
| 177 |
+
|
| 178 |
+
# Abstra
|
| 179 |
+
# Abstra is an AI-powered process automation framework.
|
| 180 |
+
# Ignore directories containing user credentials, local state, and settings.
|
| 181 |
+
# Learn more at https://abstra.io/docs
|
| 182 |
+
.abstra/
|
| 183 |
+
|
| 184 |
+
# Visual Studio Code
|
| 185 |
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
| 186 |
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
| 187 |
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
| 188 |
+
# you could uncomment the following to ignore the entire vscode folder
|
| 189 |
+
# .vscode/
|
| 190 |
+
|
| 191 |
+
# Ruff stuff:
|
| 192 |
+
.ruff_cache/
|
| 193 |
+
|
| 194 |
+
# PyPI configuration file
|
| 195 |
+
.pypirc
|
| 196 |
+
|
| 197 |
+
# Cursor
|
| 198 |
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
| 199 |
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
| 200 |
+
# refer to https://docs.cursor.com/context/ignore-files
|
| 201 |
+
.cursorignore
|
| 202 |
+
.cursorindexingignore
|
| 203 |
+
|
| 204 |
+
# Marimo
|
| 205 |
+
marimo/_static/
|
| 206 |
+
marimo/_lsp/
|
| 207 |
+
__marimo__/
|
| 208 |
+
|
| 209 |
+
# 의존성
|
| 210 |
+
.python-version
|
| 211 |
+
install-pyenv-win.ps1
|
| 212 |
+
|
| 213 |
+
# poetry
|
| 214 |
+
poetry.lock
|
| 215 |
+
|
| 216 |
+
# data
|
| 217 |
+
data/files/
|
| 218 |
+
data/data_list.csv
|
| 219 |
+
|
| 220 |
+
# wandb
|
| 221 |
+
wandb/
|
| 222 |
+
|
| 223 |
+
# 테스트 파일
|
| 224 |
+
test.py
|
| 225 |
+
src/evaluation/results/ragas_results.json
|
| 226 |
+
src/evaluation/results/synthetic_testset.json
|
| 227 |
+
src/evaluation/results/ragas_results_detail.csv
|
| 228 |
+
src/evaluation/results/ragas_results.json
|
| 229 |
+
src/evaluation/results/synthetic_testset.csv
|
| 230 |
+
src/evaluation/synthetic_testset.csv
|
| 231 |
+
src/evaluation/synthetic_testset.json
|
| 232 |
+
|
| 233 |
+
# results
|
| 234 |
+
src/evaluation/results/
|
| 235 |
+
|
| 236 |
+
# models
|
| 237 |
+
models/
|
| 238 |
+
# 가상환경
|
| 239 |
+
myenv/
|
| 240 |
+
|
| 241 |
+
# 에셋 파일
|
| 242 |
+
asset/
|
| 243 |
+
*.gif
|
| 244 |
+
*.mov
|
.vscode/settings.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"python-envs.defaultEnvManager": "ms-python.python:system",
|
| 3 |
+
"python-envs.pythonProjects": []
|
| 4 |
+
}
|
EDA_report.md
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 문서 요약 챗봇 프로젝트 EDA 보고서
|
| 2 |
+
|
| 3 |
+
**작성일**: 2024년 11월 11일
|
| 4 |
+
**분석자**: 1팀(이유노, 김진욱, 박지윤, 지동진)
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 📋 목차
|
| 9 |
+
1. [데이터 개요](#1-데이터-개요)
|
| 10 |
+
2. [기본 통계 분석](#2-기본-통계-분석)
|
| 11 |
+
3. [파일 형식별 비교](#3-파일-형식별-비교)
|
| 12 |
+
4. [문장 구조 분석](#4-문장-구조-분석)
|
| 13 |
+
5. [품질 이슈 및 이상치](#5-품질-이슈-및-이상치)
|
| 14 |
+
6. [주요 인사이트 및 활용 방안](#6-주요-인사이트-및-활용-방안)
|
| 15 |
+
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
## 1. 데이터 개요
|
| 19 |
+
|
| 20 |
+
### 1.1 데이터셋 구성
|
| 21 |
+
- **총 파일 수**: 100개
|
| 22 |
+
- **PDF 파일**: 4개 (4%)
|
| 23 |
+
- **HWP 파일**: 96개 (96%)
|
| 24 |
+
|
| 25 |
+
### 1.2 데이터 출처
|
| 26 |
+
- 공공기관의 정보시스템 구축 사업 제안요청서
|
| 27 |
+
- 주요 발주기관: 대학교, 지방자치단체, 공공기관 등
|
| 28 |
+
|
| 29 |
+
### 1.3 데이터 구조
|
| 30 |
+
```
|
| 31 |
+
1팀 중급 프로젝트/
|
| 32 |
+
├── data/
|
| 33 |
+
│ ├── data_list.xlsx
|
| 34 |
+
│ ├── data_list.csv
|
| 35 |
+
│ └── files/
|
| 36 |
+
│ ├── 문서1.hwp
|
| 37 |
+
│ ├── 문서2.pdf
|
| 38 |
+
│ └── ...
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
> **💡 활용 방안**
|
| 42 |
+
> 데이터 불균형(96:4)으로 인해 HWP 파일만 사용하는 전략 고려. PDF는 추가 수집 또는 제외 검토 필요.
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## 2. 기본 통계 분석
|
| 47 |
+
|
| 48 |
+
### 2.1 문자 수 통계
|
| 49 |
+
|
| 50 |
+
| 통계량 | 값 |
|
| 51 |
+
|--------|-----|
|
| 52 |
+
| 평균 | 3,835자 |
|
| 53 |
+
| 중앙값 | 2,583자 |
|
| 54 |
+
| 최소값 | 80자 |
|
| 55 |
+
| 최대값 | 18,328자 |
|
| 56 |
+
| 표준편차 | 3,692자 |
|
| 57 |
+
| 1사분위수 (25%) | 1,188자 |
|
| 58 |
+
| 3사분위수 (75%) | 5,827자 |
|
| 59 |
+
|
| 60 |
+
**분포 특성**:
|
| 61 |
+
- 대부분의 문서가 1,000~6,000자 범위에 분포
|
| 62 |
+
- 소수의 긴 문서(10,000자 이상) 존재
|
| 63 |
+
- 극단적으로 짧은 문서(500자 미만) 약 25% 존재
|
| 64 |
+
|
| 65 |
+
> **💡 활용 방안**
|
| 66 |
+
> - **RAG 시스템**: 평균 문서 길이(3,835자 ≈ 2,000 토큰)를 고려하여 청크 크기 512 토큰, 문서당 4-5개 청크 예상
|
| 67 |
+
> - **Fine-tuning**: 극단적으로 짧거나 긴 문서는 품질 필터링 대상
|
| 68 |
+
> - **API 모델**: 평균 길이 기준으로 프롬프트 최적화 및 비용 산정
|
| 69 |
+
|
| 70 |
+
### 2.2 문장 수 통계
|
| 71 |
+
|
| 72 |
+
| 통계량 | 값 |
|
| 73 |
+
|--------|-----|
|
| 74 |
+
| 평균 | 215.5문장 |
|
| 75 |
+
| 중앙값 | 161.5문장 |
|
| 76 |
+
| 최소값 | 5문장 |
|
| 77 |
+
| 최대값 | 1,107문장 |
|
| 78 |
+
| 표준편차 | 201.2문장 |
|
| 79 |
+
| 1사분위수 (25%) | 76.5문장 |
|
| 80 |
+
| 3사분위수 (75%) | 295.8문장 |
|
| 81 |
+
|
| 82 |
+
**분포 특성**:
|
| 83 |
+
- 중앙값(161.5)이 평균(215.5)보다 낮음 → 우편향 분포
|
| 84 |
+
- 소수의 매우 긴 문서가 평균을 끌어올림
|
| 85 |
+
- 대부분 100-300문장 범위에 분포
|
| 86 |
+
|
| 87 |
+
> **💡 활용 방안**
|
| 88 |
+
> - **청크 분할**: 문장 단위 청크 분할 시 기준 설정 (예: 10-15문장당 1개 청크)
|
| 89 |
+
> - **품질 필터링**: 5문장 미만 문서는 내용이 부족할 가능성 → 제외 고려
|
| 90 |
+
> - **배치 처리**: 문장 수 기준으로 배치 크기 조정 가능
|
| 91 |
+
|
| 92 |
+
### 2.3 문장 길이 통계
|
| 93 |
+
|
| 94 |
+
| 통계량 | 평균 문장 길이 | 최대 문장 길이 |
|
| 95 |
+
|--------|--------------|--------------|
|
| 96 |
+
| 평균 | 16.3자 | 104.5자 |
|
| 97 |
+
| 중앙값 | 15.8자 | 106.5자 |
|
| 98 |
+
| 최소값 | 7.3자 | 13자 |
|
| 99 |
+
| 최대값 | 52.6자 | 259자 |
|
| 100 |
+
| 표준편차 | 5.6자 | 51.3자 |
|
| 101 |
+
|
| 102 |
+
**한국어 문장 특성**:
|
| 103 |
+
- 평균 문장 길이 16.3자는 한국어로서 적절한 범위 (일반적으로 15-20자)
|
| 104 |
+
- 최대 문장 길이의 편차가 큼 (표준편차 51.3자) → 일부 비정상적인 긴 문장 존재
|
| 105 |
+
|
| 106 |
+
> **💡 활용 방안**
|
| 107 |
+
> - **이상치 탐지**: 평균 문장 길이 > 30자인 파일은 문장 분할 실패 가능성 → 재검토 필요
|
| 108 |
+
> - **전처리 전략**: 최대 문장 길이 > 150자인 경우 추가 분할 로직 적용
|
| 109 |
+
> - **품질 평가**: 정상적인 문장 길이 분포는 양질의 데이터 지표
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## 3. 파일 형식별 비교
|
| 114 |
+
|
| 115 |
+
### 3.1 형식별 주요 지표
|
| 116 |
+
|
| 117 |
+
| 지표 | HWP (96개) | PDF (4개) | 차이 | 비고 |
|
| 118 |
+
|------|-----------|----------|------|------|
|
| 119 |
+
| 평균 문자 수 | 3,930자 | 1,548자 | HWP가 2.5배 많음 | ⚠️ PDF가 짧음 |
|
| 120 |
+
| 평균 문장 수 | 221문장 | 63문장 | HWP가 3.5배 많음 | ⚠️ PDF가 적음 |
|
| 121 |
+
| 평균 문장 길이 | **15.9자** | **26.8자** | PDF가 69% 더 김 | 🚨 비정상 |
|
| 122 |
+
| 최대 문장 길이 | 105자 | 84자 | 비슷 | ✅ 정상 |
|
| 123 |
+
|
| 124 |
+
### 3.2 시각적 비교
|
| 125 |
+
|
| 126 |
+
**파일 형식 분포**:
|
| 127 |
+
```
|
| 128 |
+
HWP: ████████████████████████████████████████████ 96개 (96%)
|
| 129 |
+
PDF: ██ 4개 (4%)
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
**문장 길이 박스플롯 해석**:
|
| 133 |
+
- HWP: 중앙값 약 16자, IQR(사분위 범위) 좁음 → 일관된 품질
|
| 134 |
+
- PDF: 중앙값 약 27자, 편차 큼 → 불안정한 품질
|
| 135 |
+
|
| 136 |
+
### 3.3 핵심 발견사항
|
| 137 |
+
|
| 138 |
+
#### 🚨 PDF 문장 길이 이상
|
| 139 |
+
- PDF의 평균 문장 길이(26.8자)가 HWP(15.9자)보다 **69% 더 김**
|
| 140 |
+
- 정상적인 한국어 문장 평균(15-20자)을 크게 벗어남
|
| 141 |
+
|
| 142 |
+
**추정 원인**:
|
| 143 |
+
1. 문장 분할 실패 (줄바꿈이 문장 구분으로 인식되지 않음)
|
| 144 |
+
2. 표나 목차가 한 문장으��� 추출됨
|
| 145 |
+
3. PDF 내부 구조 문제 (이미지 기반 PDF일 가능성)
|
| 146 |
+
|
| 147 |
+
#### ✅ HWP 추출 품질 우수
|
| 148 |
+
- 평균 문장 길이 15.9자 → 한국어 자연어로서 적절
|
| 149 |
+
- 96개 중 95개가 정상 범위 → 추출 성공률 98.9%
|
| 150 |
+
|
| 151 |
+
> **💡 활용 방안**
|
| 152 |
+
> - **PDF 처리 전략**:
|
| 153 |
+
> 1. 다른 추출 라이브러리 시도 (pdfplumber, PyMuPDF)
|
| 154 |
+
> 2. OCR 적용 (이미지 기반 PDF 대응) - 시간 관계상 제외
|
| 155 |
+
> 3. 개선 실패 시 학습 데이터에서 제외
|
| 156 |
+
> - **HWP 중심 전략**: 추출 품질이 우수한 HWP 96개만으로 프로젝트 진행 가능
|
| 157 |
+
> - **데이터 균형**: PDF 추가 수집 또는 무시 결정 필요
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## 4. 문장 구조 분석
|
| 162 |
+
|
| 163 |
+
### 4.1 평균 문장 길이 분포
|
| 164 |
+
|
| 165 |
+
**분포 특성**:
|
| 166 |
+
- **최빈 구간**: 15-20자
|
| 167 |
+
- **분포 형태**: 정규분포에 가까움
|
| 168 |
+
- **중앙값**: 15.8자
|
| 169 |
+
- **이상치**: 50자 이상인 경우 소수 존재 (약 1%)
|
| 170 |
+
|
| 171 |
+
**히스토그램 해석**:
|
| 172 |
+
```
|
| 173 |
+
7-10자: ▓▓▓▓▓░░░░░ (약 10개)
|
| 174 |
+
11-15자: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░ (약 30개)
|
| 175 |
+
16-20자: ▓▓▓▓▓▓▓▓▓▓▓░░░░░░░ (약 25개)
|
| 176 |
+
21-25자: ▓▓▓▓▓░░░░░ (약 15개)
|
| 177 |
+
26-30자: ▓▓░░░░░░░░ (약 8개)
|
| 178 |
+
31자 이상: ▓░░░░░░░░░ (약 5개)
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
> **💡 활용 방안**
|
| 182 |
+
> - **품질 필터링**: 평균 문장 길이 > 30자인 파일은 문장 분할 로직 재검토 필요
|
| 183 |
+
> - **프롬프트 설계**: 대부분 15-20자 문장 → 간결한 응답 스타일 학습 가능
|
| 184 |
+
> - **모델 평가**: 생성된 요약문의 문장 길이도 15-20자 범위 유지 시 자연스러움
|
| 185 |
+
|
| 186 |
+
### 4.2 최대 문장 길이 분포
|
| 187 |
+
|
| 188 |
+
**분포 특성**:
|
| 189 |
+
- **중앙값**: 약 107자
|
| 190 |
+
- **분포**: 우편향 (대부분 100자 이하)
|
| 191 |
+
- **이상치**: 250자 이상 2개 (상위 1%)
|
| 192 |
+
|
| 193 |
+
**구간별 분포**:
|
| 194 |
+
```
|
| 195 |
+
0-50자: ▓░░░░░░░░░ (약 7개)
|
| 196 |
+
51-100자: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ (약 40개)
|
| 197 |
+
101-150자: ▓▓▓▓▓▓▓▓▓▓▓░░░░░░ (약 30개)
|
| 198 |
+
151-200자: ▓▓▓░░░░░░░ (약 12개)
|
| 199 |
+
201-250자: ▓░░░░░░░░░ (약 4개)
|
| 200 |
+
250자 이상: ▓░░░░░░░░░ (약 2개)
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
**이상치 파일**:
|
| 204 |
+
1. 인천광역시_도시계획위원회 통합관리시스템 구축용역.hwp (259자)
|
| 205 |
+
2. 고려대학교_차세대 포털·학사 정보시스템 구축사업.pdf (176자)
|
| 206 |
+
|
| 207 |
+
> **💡 활용 방안**
|
| 208 |
+
> - **추가 분할 필요**: 최대 문장 길이 > 150자인 경우 추가 문장 분할 로직 적용
|
| 209 |
+
> - **Fine-tuning**: 비정상적으로 긴 문장은 학습 데이터 품질 저하 원인 → 필터링 고려
|
| 210 |
+
> - **RAG 청크 분할**: 긴 문장도 포함할 수 있도록 청크 크기 여유 있게 설정
|
| 211 |
+
|
| 212 |
+
### 4.3 특수문자 및 구두점 비율
|
| 213 |
+
|
| 214 |
+
#### 구두점 비율
|
| 215 |
+
|
| 216 |
+
| 통계량 | 값 |
|
| 217 |
+
|--------|-----|
|
| 218 |
+
| 평균 | 6.5% |
|
| 219 |
+
| 중앙값 | 5.6% |
|
| 220 |
+
| 최소값 | 1.0% |
|
| 221 |
+
| 최대값 | 67.8% |
|
| 222 |
+
| 표준편차 | 7.0% |
|
| 223 |
+
|
| 224 |
+
**분포 특성**:
|
| 225 |
+
- 대부분의 문서: 0-10% 범위에 집중
|
| 226 |
+
- 정상 범위로 판단 (한국어 문서 일반적으로 5-10%)
|
| 227 |
+
- 극단 이상치 1개 (67.8%) → 문제 파일
|
| 228 |
+
|
| 229 |
+
#### 특수문자 비율
|
| 230 |
+
|
| 231 |
+
| 통계량 | 값 |
|
| 232 |
+
|--------|-----|
|
| 233 |
+
| 평균 | 2.0% |
|
| 234 |
+
| 중앙값 | 0.5% |
|
| 235 |
+
| 최소값 | 0.0% |
|
| 236 |
+
| 최대값 | 73.1% |
|
| 237 |
+
| 표준편차 | 9.7% |
|
| 238 |
+
|
| 239 |
+
**분포 특성**:
|
| 240 |
+
- 대부분의 문서: 0-5% 범위에 극도로 집중
|
| 241 |
+
- 매우 낮은 특수문자 비율 → 깨끗한 텍스트
|
| 242 |
+
- 극단 이상치 1개 (73.1%) → 심각한 추출 오류
|
| 243 |
+
|
| 244 |
+
**히스토그램 해석**:
|
| 245 |
+
```
|
| 246 |
+
특수문자 비율:
|
| 247 |
+
0-5%: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ (약 95개) ← 정상
|
| 248 |
+
5-10%: ▓░░░░░░░░░ (약 3개)
|
| 249 |
+
10-15%: ░░░░░░░░░░ (0개)
|
| 250 |
+
15%+: ▓░░░░░░░░░ (약 2개) ← 이상
|
| 251 |
+
|
| 252 |
+
구두점 비율:
|
| 253 |
+
0-10%: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░ (약 90개) ← 정상
|
| 254 |
+
10-20%: ▓▓░░░░░░░░ (약 8개)
|
| 255 |
+
20%+: ▓░░░░░░░░░ (약 2개) ← 이상
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
> **💡 활용 방안**
|
| 259 |
+
> - **품질 필터링 기준**:
|
| 260 |
+
> - 특수문자 비율 > 15% → 추출 실패로 간주, 제외
|
| 261 |
+
> - 구두점 비율 > 20% → 비정상 문서, 검토 필요
|
| 262 |
+
> - **전처리 전략**: 대부분 특수문자가 적어 전처리 부담이 적음
|
| 263 |
+
> - **모델 학습**: 깨끗한 텍스트 → 고품질 학습 데이터
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## 5. 품질 이슈 및 이상치
|
| 268 |
+
|
| 269 |
+
### 5.1 이상치 탐지 결과
|
| 270 |
+
|
| 271 |
+
**전체 요약**:
|
| 272 |
+
- **긴 문장 이상치**: 1개 (1.0%)
|
| 273 |
+
- **특수문자 과다**: 1개 (1.0%)
|
| 274 |
+
- **총 문제 파일**: 2개 (2.0%)
|
| 275 |
+
- **정상 파일**: 98개 (98.0%)
|
| 276 |
+
|
| 277 |
+
### 5.2 심각한 문제 파일 (학습 데이터 제외 필수)
|
| 278 |
+
|
| 279 |
+
#### 🚨 파일 #1: 고려대학교 PDF
|
| 280 |
+
|
| 281 |
+
**파일명**: `고려대학교_차세대 포털·학사 정보시스템 구축사업.pdf`
|
| 282 |
+
|
| 283 |
+
| 지표 | 값 | 정상 범위 | 평가 | 심각도 |
|
| 284 |
+
|------|-----|----------|------|--------|
|
| 285 |
+
| 평균 문장 길이 | 52.6자 | 15-20자 | ❌ 정상의 3배 | 🚨 심각 |
|
| 286 |
+
| 특수문자 비율 | 73.1% | 0-5% | ❌ 14배 초과 | 🚨 심각 |
|
| 287 |
+
| 문장 수 | 42개 | 100+ | ❌ 비정상적으로 적음 | 🚨 심각 |
|
| 288 |
+
| 문자 수 | 2,450자 | 3,000+ | ⚠️ 짧음 | ⚠️ 주의 |
|
| 289 |
+
|
| 290 |
+
**문제 진단**:
|
| 291 |
+
- 텍스트 추출 완전 실패
|
| 292 |
+
- 표/목차/특수기호만 추출된 것으로 추정
|
| 293 |
+
- 실제 본문 내용이 거의 포함되지 않음
|
| 294 |
+
|
| 295 |
+
**예상 원인**:
|
| 296 |
+
1. 이미지 기반 PDF (스캔 문서)
|
| 297 |
+
2. 복잡한 PDF 레이아웃 (다단 구성, 표 중심)
|
| 298 |
+
3. PDF 암호화 또는 보안 설정
|
| 299 |
+
|
| 300 |
+
**조치 방안**:
|
| 301 |
+
- ❌ **학습 데이터에서 완전 제외 (필수)**
|
| 302 |
+
- 재추출 시도 불필요 (품질 회복 불가능 판단)
|
| 303 |
+
- 대체 데이터 확보 고려
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
### 5.3 경미한 문제 파일 (수동 검토 필요)
|
| 308 |
+
|
| 309 |
+
#### ⚠️ 파일 #2: 인천광역시 HWP
|
| 310 |
+
|
| 311 |
+
**파일명**: `인천광역시_도시계획위원회 통합관리시스템 구축용역.hwp`
|
| 312 |
+
|
| 313 |
+
| 지표 | 값 | 정상 범위 | 평가 | 심각도 |
|
| 314 |
+
|------|-----|----------|------|--------|
|
| 315 |
+
| 최대 문장 길이 | 259자 | 100자 이하 | ⚠️ 상위 1% | ⚠️ 주의 |
|
| 316 |
+
| 평균 문장 길이 | 22.1자 | 15-20자 | ✅ 약간 높지만 양호 | ✅ 정상 |
|
| 317 |
+
| 특수문자 비율 | 0.5% | 0-5% | ✅ 정상 | ✅ 정상 |
|
| 318 |
+
| 문자 수 | 3,132자 | 1,000+ | ✅ 적절 | ✅ 정상 |
|
| 319 |
+
|
| 320 |
+
**문제 진단**:
|
| 321 |
+
- 전체적으로 정상이나 특정 문장만 비정상적으로 김
|
| 322 |
+
- 문단 분할 실패 가능성 (여러 문장이 하나로 합쳐짐)
|
| 323 |
+
- 리스트나 긴 설명문이 마침표 없이 작성되었을 가능성
|
| 324 |
+
|
| 325 |
+
**조치 방안**:
|
| 326 |
+
- ✅ **학습 데이터로 사용 가능 (기본)**
|
| 327 |
+
- 옵션 1: 해당 문장(259자) 수동 확인 후 분할
|
| 328 |
+
- 옵션 2: 그대로 사용 (나머지 품질이 양호하므로)
|
| 329 |
+
- 권장: **그대로 사용** (1개 문장만의 문제이므로 전체 품질에 영향 미미)
|
| 330 |
+
|
| 331 |
+
---
|
| 332 |
+
|
| 333 |
+
### 5.4 품질 필터링 기준
|
| 334 |
+
|
| 335 |
+
다음 기준으로 데이터를 필터링하면 고품질 데이터셋 확보 가능:
|
| 336 |
+
|
| 337 |
+
```python
|
| 338 |
+
# 품질 필터링 기준
|
| 339 |
+
quality_criteria = {
|
| 340 |
+
'min_chars': 200, # 최소 200자 이상
|
| 341 |
+
'max_avg_sentence_len': 30, # 평균 문장 길이 30자 이하
|
| 342 |
+
'max_special_ratio': 0.15, # 특수문자 15% 이하
|
| 343 |
+
'max_punct_ratio': 0.20, # 구두점 20% 이하
|
| 344 |
+
'min_sentences': 5, # 최소 5문장 이상
|
| 345 |
+
'max_sentence_len': 200 # 최대 문장 200자 이하 (선택)
|
| 346 |
+
}
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
**필터링 결과 예상**:
|
| 350 |
+
- 제외 예상: 2-3개
|
| 351 |
+
- 유지 예상: 97-98개
|
| 352 |
+
- 유지율: **97-98%**
|
| 353 |
+
|
| 354 |
+
> **💡 활용 방안**
|
| 355 |
+
> - **자동 필터링**: 위 기준으로 자동 품질 검사 스크립트 작성
|
| 356 |
+
> - **수동 검토**: 경계선상의 파일(3-5개)만 수동으로 최종 판단
|
| 357 |
+
> - **버전 관리**: 원본(100개), 필터링 후(97개) 버전 모두 보관
|
| 358 |
+
|
| 359 |
+
---
|
| 360 |
+
|
| 361 |
+
## 6. 주요 인사이트 및 활용 방안
|
| 362 |
+
|
| 363 |
+
### 6.1 데이터 품질 인사이트
|
| 364 |
+
|
| 365 |
+
| # | 인사이트 | 근거 | 활용 방안 |
|
| 366 |
+
|---|---------|------|----------|
|
| 367 |
+
| 1 | **HWP 추출 품질 우수** | 96개 중 95개 정상 (98.9%) | HWP 파일을 주요 학습 데이터로 사용 |
|
| 368 |
+
| 2 | **PDF 추출 품질 불량** | 4개 중 1개 심각한 문제 (25%) | PDF는 재추출 시도 후 개선 없으면 제외 |
|
| 369 |
+
| 3 | **문장 길이 분포 양호** | 평균 15.9자, 표준편차 5.6 | 한국어 자연어 처리에 적합한 데이터 |
|
| 370 |
+
| 4 | **특수문자 비율 낮음** | 평균 2.0%, 95개가 5% 이하 | 깨끗한 텍스트, 전처리 부담 적음 |
|
| 371 |
+
| 5 | **데이터 불균형 심각** | HWP:PDF = 96:4 비율 | PDF 추가 수집 또는 HWP만 사용 |
|
| 372 |
+
| 6 | **문서 길이 적절** | 평균 3,835자 ≈ 2,000 토큰 | LLM 컨텍스트 윈도우에 적합 |
|
| 373 |
+
| 7 | **이상치 최소** | 98% 정상, 2% 이상치 | 높은 데이터 품질, 소수 파일만 처리 |
|
| 374 |
+
|
| 375 |
+
### 6.2 모델별 활용 전략
|
| 376 |
+
|
| 377 |
+
#### 🔍 RAG 시스템
|
| 378 |
+
|
| 379 |
+
**데이터 특성 기반 전략**:
|
| 380 |
+
```python
|
| 381 |
+
rag_config = {
|
| 382 |
+
# 청크 설정
|
| 383 |
+
'chunk_size': 512, # 평균 문서 ≈ 2,000 토큰 고려
|
| 384 |
+
'chunk_overlap': 50, # 10% 오버랩
|
| 385 |
+
'chunking_method': 'semantic', # 문장 단위 분할
|
| 386 |
+
|
| 387 |
+
# 예상 청크 수
|
| 388 |
+
'chunks_per_doc': 4-5, # 평균 문서 길이 기준
|
| 389 |
+
'total_chunks': 400-500, # 100개 문서 기준
|
| 390 |
+
|
| 391 |
+
# 메타데이터
|
| 392 |
+
'metadata_fields': [
|
| 393 |
+
'filename',
|
| 394 |
+
'organization', # 발주기관
|
| 395 |
+
'category', # 사업 분류
|
| 396 |
+
'year' # 연도
|
| 397 |
+
],
|
| 398 |
+
|
| 399 |
+
# 검색 설정
|
| 400 |
+
'top_k': 5, # 상위 5개 청크 검색
|
| 401 |
+
'similarity_metric': 'cosine'
|
| 402 |
+
}
|
| 403 |
+
```
|
| 404 |
+
|
| 405 |
+
**최적화 포인트**:
|
| 406 |
+
- 평균 문서 길이가 적절 → 과도한 잘라내기 불필요
|
| 407 |
+
- 문장 구조 양호 → 문장 단위 청크 분할 효과적
|
| 408 |
+
- 특수문자 적음 → 임베딩 품질 우수 예상
|
| 409 |
+
|
| 410 |
+
> **💡 실제 적용**
|
| 411 |
+
> 1. 98개 정상 문서로 Vector DB 구축
|
| 412 |
+
> 2. 문서당 4-5개 청크 → 총 약 490개 청크
|
| 413 |
+
> 3. 한국어 임베딩 모델 사용 (KoSimCSE, multilingual-e5 등)
|
| 414 |
+
|
| 415 |
+
---
|
| 416 |
+
|
| 417 |
+
#### 🎓 Fine-tuning
|
| 418 |
+
|
| 419 |
+
**데이터 현황 평가**:
|
| 420 |
+
- **사용 가능 데이터**: 97-98개 (문제 파일 제외 후)
|
| 421 |
+
- **데이터 분할**: Train 78개, Val 10개, Test 10개
|
| 422 |
+
- **규모 평가**: 프로토타입용으로는 적절, 프로덕션용으로는 부족
|
| 423 |
+
|
| 424 |
+
**전처리 전략**:
|
| 425 |
+
```python
|
| 426 |
+
finetuning_config = {
|
| 427 |
+
# 품질 필터링
|
| 428 |
+
'min_chars': 200,
|
| 429 |
+
'max_avg_sentence_len': 30,
|
| 430 |
+
'max_special_ratio': 0.15,
|
| 431 |
+
|
| 432 |
+
# 토큰 길이 제한
|
| 433 |
+
'max_input_tokens': 2048, # 평균 문서 포함 가능
|
| 434 |
+
'max_output_tokens': 512, # 요약문
|
| 435 |
+
|
| 436 |
+
# 데이터 분할
|
| 437 |
+
'train_ratio': 0.8, # 78-80개
|
| 438 |
+
'val_ratio': 0.1, # 10개
|
| 439 |
+
'test_ratio': 0.1, # 10개
|
| 440 |
+
|
| 441 |
+
# 포맷
|
| 442 |
+
'format': 'chat', # Chat format 사용
|
| 443 |
+
'system_prompt': '당신은 공공기관 문서 요약 전문가입니다.'
|
| 444 |
+
}
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
**한계점 및 대응**:
|
| 448 |
+
- ⚠️ **데이터 부족**: 100개는 소규모 (이상적으로 500-1,000개 필요)
|
| 449 |
+
- ✅ **대응 방안**:
|
| 450 |
+
1. 데이터 증강 (역번역, 패러프레이즈)
|
| 451 |
+
2. Few-shot Learning 활용
|
| 452 |
+
3. 기존 모델에서 계속 학습 (Continued Pre-training)
|
| 453 |
+
4. 추가 데이터 수집
|
| 454 |
+
|
| 455 |
+
> **💡 실제 적용**
|
| 456 |
+
> - Phase 1: 현재 데이터로 프로토타입 Fine-tuning
|
| 457 |
+
> - Phase 2: 성능 평가 후 데이터 확장 여부 결정
|
| 458 |
+
> - 목표: 데이터 300개 이상 확보 시 본격 Fine-tuning
|
| 459 |
+
|
| 460 |
+
---
|
| 461 |
+
|
| 462 |
+
#### 🤖 API 기반 모델
|
| 463 |
+
|
| 464 |
+
**데이터 특성 활용**:
|
| 465 |
+
```python
|
| 466 |
+
api_config = {
|
| 467 |
+
# 프롬프트 설계
|
| 468 |
+
'avg_doc_length': 3835, # 평균 문서 길이
|
| 469 |
+
'avg_tokens': 2000, # 약 2,000 토큰
|
| 470 |
+
|
| 471 |
+
# 비용 예측
|
| 472 |
+
'input_cost_per_1k': 0.01, # GPT-4 기준 예시
|
| 473 |
+
'output_cost_per_1k': 0.03,
|
| 474 |
+
'expected_output': 256, # 요약문 토큰
|
| 475 |
+
|
| 476 |
+
# 문서당 비용
|
| 477 |
+
'cost_per_doc': (2000 * 0.01 / 1000) + (256 * 0.03 / 1000),
|
| 478 |
+
# = $0.0276/문서
|
| 479 |
+
|
| 480 |
+
# 캐싱 전략
|
| 481 |
+
'cache_similar_docs': True, # 유사 문서 캐싱
|
| 482 |
+
'cache_ttl': 86400, # 24시간
|
| 483 |
+
'expected_cache_hit': 0.3 # 30% 캐시 적중률
|
| 484 |
+
}
|
| 485 |
+
```
|
| 486 |
+
|
| 487 |
+
**최적화 전략**:
|
| 488 |
+
1. **토큰 수 최적화**:
|
| 489 |
+
- 평균 2,000 토큰 → 대부분 문서가 한 번에 처리 가능
|
| 490 |
+
- 긴 문서(>4,000 토큰)만 잘라내기 또는 요약 후 재요약
|
| 491 |
+
|
| 492 |
+
2. **캐싱 활용**:
|
| 493 |
+
- 유사한 문서 패턴 많음 (공공기관 제안요청서)
|
| 494 |
+
- 캐싱으로 30-40% 비용 절감 가능
|
| 495 |
+
|
| 496 |
+
3. **모델 선택**:
|
| 497 |
+
- 간단한 요약: GPT-3.5-Turbo (저렴)
|
| 498 |
+
- 복잡한 요약: GPT-4 (고품질)
|
| 499 |
+
- A/B 테스트로 성능-비용 최적 지점 찾기
|
| 500 |
+
|
| 501 |
+
> **💡 실제 적용**
|
| 502 |
+
> - 100개 문서 요약 비용: 약 $2.76 (캐싱 없을 때)
|
| 503 |
+
> - 캐싱 30% 적용 시: 약 $1.93
|
| 504 |
+
> - 프로토타입으로 충분히 활용 가능한 비용
|
| 505 |
+
|
| 506 |
+
---
|
| 507 |
+
|
| 508 |
+
### 6.3 프로젝트 진행 가능성 평가
|
| 509 |
+
|
| 510 |
+
#### ✅ 현재 데이터로 가능한 것
|
| 511 |
+
|
| 512 |
+
| 작업 | 가능 여부 | 데이터 충분성 | 비고 |
|
| 513 |
+
|------|----------|-------------|------|
|
| 514 |
+
| RAG 프로토타입 | ✅ 가능 | 충분 | 98개 문서면 충분 |
|
| 515 |
+
| API 기반 요약 | ✅ 가능 | 충분 | 테스트 및 검증 가능 |
|
| 516 |
+
| Fine-tuning 실험 | ✅ 가능 | 최소 요건 | 프로토타입 수준 |
|
| 517 |
+
| 성능 평가 | ✅ 가능 | 충분 | Test set 10개로 평가 |
|
| 518 |
+
| A/B 테스트 | ✅ 가능 | 충분 | 비교 실험 가능 |
|
| 519 |
+
|
| 520 |
+
#### ⚠️ 추가 작업 필요한 것
|
| 521 |
+
|
| 522 |
+
| 작업 | 필요 작업 | 현재 상태 | 목표 |
|
| 523 |
+
|------|----------|---------|------|
|
| 524 |
+
| 대규모 Fine-tuning | 데이터 확장 | 98개 | 300-500개 |
|
| 525 |
+
| 프로덕션 배포 | 데이터 검증 | 테스트 단계 | 안정화 |
|
| 526 |
+
| PDF 처리 안정화 | 추출 개선 | 25% 실패율 | 95%+ 성공률 |
|
| 527 |
+
| 다양한 문서 타입 | 데이터 수집 | 단일 도메인 | 멀티 도메인 |
|
| 528 |
+
|
| 529 |
+
#### 📊 데이터 품질 종합 점수
|
| 530 |
+
|
| 531 |
+
| 평가 항목 | 점수 | 설명 |
|
| 532 |
+
|----------|------|------|
|
| 533 |
+
| 데이터 양 | ⭐⭐⭐☆☆ | 프로토타입 가능, 실서비스 부족 |
|
| 534 |
+
| 추출 품질 | ⭐⭐⭐⭐☆ | HWP 우수(98.9%), PDF 문제 |
|
| 535 |
+
| 데이터 균형 | ⭐☆☆☆☆ | 심각한 불균형 (96:4) |
|
| 536 |
+
| 텍스트 품질 | ⭐⭐⭐⭐☆ | 문장 구조 양호, 노이즈 적음 |
|
| 537 |
+
| 일관성 | ⭐⭐⭐⭐☆ | 동일 도메인, 유사한 구조 |
|
| 538 |
+
| **종합 평가** | **⭐⭐⭐☆☆** | **프로토타입 개발 가능, 개선 필요** |
|
| 539 |
+
|
| 540 |
+
---
|
| 541 |
+
|
| 542 |
+
### 6.4 최종 권장 전략
|
| 543 |
+
|
| 544 |
+
#### Phase 1: 현재 데이터 활용 (즉시 시작 가능)
|
| 545 |
+
|
| 546 |
+
**우선순위 1 - RAG 시스템**:
|
| 547 |
+
```
|
| 548 |
+
목표: 문서 검색 기반 QA 시스템 구축
|
| 549 |
+
데이터: 98개 HWP 문서
|
| 550 |
+
예상 기간: 1-2주
|
| 551 |
+
성공 가능성: 높음 (⭐⭐⭐⭐⭐)
|
| 552 |
+
```
|
| 553 |
+
|
| 554 |
+
**우선순위 2 - API 모델**:
|
| 555 |
+
```
|
| 556 |
+
목표: GPT/Claude 기반 요약 시스템
|
| 557 |
+
데이터: 100개 문서로 프롬프트 최적화
|
| 558 |
+
예상 기간: 1��
|
| 559 |
+
성공 가능성: 매우 높음 (⭐⭐⭐⭐⭐)
|
| 560 |
+
```
|
| 561 |
+
|
| 562 |
+
**우선순위 3 - Fine-tuning 실험**:
|
| 563 |
+
```
|
| 564 |
+
목표: 소규모 모델 Fine-tuning 실험
|
| 565 |
+
데이터: 98개 (요약문 필요)
|
| 566 |
+
예상 기간: 2-3주
|
| 567 |
+
성공 가능성: 중간 (⭐⭐⭐☆☆)
|
| 568 |
+
조건: 요약문 데이터 확보 필요
|
| 569 |
+
```
|
| 570 |
+
|
| 571 |
+
#### Phase 2: 데이터 확장 후 (3-6개월)
|
| 572 |
+
|
| 573 |
+
**데이터 확장 목표**:
|
| 574 |
+
- 현재: 100개 → 목표: 300-500개
|
| 575 |
+
- PDF 비율: 4% → 목표: 20-30%
|
| 576 |
+
- 도메인: 단일 → 목표: 다중 (교육, 의료, 행정 등)
|
| 577 |
+
|
| 578 |
+
**확장 후 가능한 작업**:
|
| 579 |
+
- 본격적인 Fine-tuning (성능 향상 기대)
|
| 580 |
+
- 프로덕션 레벨 서비스 배포
|
| 581 |
+
- 다양한 도메인 문서 지원
|
| 582 |
+
|
| 583 |
+
---
|
| 584 |
+
|
| 585 |
+
### 6.5 팀별 액션 아이템
|
| 586 |
+
|
| 587 |
+
#### 데이터 전처리 담당
|
| 588 |
+
- [ ] 고려대학교 PDF 파일 제외
|
| 589 |
+
- [ ] 나머지 PDF 3개 재추출 시도
|
| 590 |
+
- [ ] 품질 필터링 스크립트 작성
|
| 591 |
+
- [ ] 최종 데이터셋 97-98개 준비
|
| 592 |
+
- [ ] 형식별 데이터 분리 (RAG용 청크, Fine-tuning용 페어)
|
| 593 |
+
|
| 594 |
+
#### RAG 담당자
|
| 595 |
+
- [ ] 98개 문서로 Vector DB 구축
|
| 596 |
+
- [ ] 한국어 임베딩 모델 선택
|
| 597 |
+
- [ ] 청크 크기 512 토큰으로 설정
|
| 598 |
+
- [ ] 검색 성능 테스트 (Recall@5 > 80% 목표)
|
| 599 |
+
|
| 600 |
+
#### API 개발자
|
| 601 |
+
- [ ] 평균 문서 길이 기준 프롬프트 설계
|
| 602 |
+
- [ ] GPT-4 vs GPT-3.5 A/B 테스트
|
| 603 |
+
- [ ] 비용 최적화 (캐싱, 토큰 절감)
|
| 604 |
+
- [ ] 100개 문서 테스트 실행
|
| 605 |
+
|
| 606 |
+
#### Fine-tuning 담당자 (선택)
|
| 607 |
+
- [ ] 요약문 데이터 확보 방안 검토
|
| 608 |
+
- [ ] API로 요약문 생성 (임시)
|
| 609 |
+
- [ ] 소규모 실험 진행
|
| 610 |
+
- [ ] 성능 평가 및 데이터 확장 필요성 판단
|
| 611 |
+
|
| 612 |
+
---
|
| 613 |
+
|
| 614 |
+
**보고서 끝**
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2025 Dongjin
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Codeit-AI-1team-LLM-project
|
| 2 |
+
---
|
| 3 |
+
## 챗봇 서비스 시연
|
| 4 |
+

|
| 5 |
+
|
| 6 |
+
## 벡터 DB 대시보드 영상
|
| 7 |
+

|
| 8 |
+
|
| 9 |
+
# 1. 프로젝트 개요
|
| 10 |
+
- **B2G 입찰지원 전문 컨설팅 스타트업 – 'RFPilot'**
|
| 11 |
+
- RFP 문서를 요약하고, 사용자 질문에 실시간으로 응답하는 챗봇 시스템
|
| 12 |
+
> **배경**: 매일 수백 건의 기업 및 정부 제안요청서(RFP)가 게시되는데, 각 요청서 당 수십 페이지가 넘는 문건을 모두 검토하는 것은 불가능합니다. 이러한 과정은 비효율적이며, 중요한 정보를 빠르게 파악하기 어렵습니다.
|
| 13 |
+
>
|
| 14 |
+
> **목표**: 사용자의 질문에 실시간으로 응답하고, 관련 제안서를 탐색하여 요약 정보를 제공하는 챗봇을 개발하여 컨설턴트의 업무 효율을 향상시키고자 합니다.
|
| 15 |
+
>
|
| 16 |
+
> **기대 효과**: RAG 시스템을 통해 중요한 정보를 신속하게 제공함으로써, 제안서 검토 시간을 단축하고 컨설팅 업무에 보다 집중할 수 있는 환경을 조성합니다.
|
| 17 |
+
---
|
| 18 |
+
# 2. 설치 및 실행(🪟 Windows)
|
| 19 |
+
---
|
| 20 |
+
### Prerequisites
|
| 21 |
+
- Python 3.12.3 설치됨
|
| 22 |
+
- Poetry 설치됨
|
| 23 |
+
- 저장소 클론 완료
|
| 24 |
+
- 데이터셋 로컬에 저장
|
| 25 |
+
- 양자화된 모델 파일(.gguf) 저장
|
| 26 |
+
- .env 생성(api키 입력)
|
| 27 |
+
|
| 28 |
+
**env 파일 설정 방법**
|
| 29 |
+
```env
|
| 30 |
+
OPENAI_API_KEY = "OpenAI API 키"
|
| 31 |
+
WANDB_API_KEY = "WanDB API 키"
|
| 32 |
+
LANGCHAIN_TRACING_V2=true
|
| 33 |
+
LANGSMITH_API_KEY = "LangSmith API 키"
|
| 34 |
+
LANGCHAIN_PROJECT = "LangSmith 프로젝트 이름"
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
**코드 실행**
|
| 38 |
+
```powershell
|
| 39 |
+
# 1. 프로젝트 폴더로 이동
|
| 40 |
+
cd Codeit-AI-1team-LLM-project
|
| 41 |
+
|
| 42 |
+
# 2. 가상환경 설정 및 의존성 설치
|
| 43 |
+
python -m poetry config virtualenvs.in-project true
|
| 44 |
+
python -m poetry env use 3.12.3
|
| 45 |
+
python -m poetry install
|
| 46 |
+
|
| 47 |
+
# 3. 가상환경 활성화
|
| 48 |
+
python -m poetry env activate
|
| 49 |
+
|
| 50 |
+
# 4. 실행(전처리~벡터DB 구측)
|
| 51 |
+
python -m poetry run python main.py --step all
|
| 52 |
+
|
| 53 |
+
# 5. 벡터 DB 대시보드 실행
|
| 54 |
+
python -m poetry run streamlit run src/visualization/streamlit_app.py
|
| 55 |
+
|
| 56 |
+
# 6. 챗봇 서비스 실행
|
| 57 |
+
python -m poetry run streamlit run src/visualization/chatbot_app.py
|
| 58 |
+
|
| 59 |
+
# 7. LangSmith 실험 실행(API 및 프로젝트 생성 필요)
|
| 60 |
+
python -m poetry run python src/evaluation/run_experiment.py # 대화형 메뉴
|
| 61 |
+
python -m poetry run python src/evaluation/run_experiment.py --run # 실험 실행
|
| 62 |
+
python -m poetry run python src/evaluation/run_experiment.py --compare # 실험 비교
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
# 3. 프로젝트 구조
|
| 66 |
+
---
|
| 67 |
+
```
|
| 68 |
+
CODEIT-AI-1TEAM-LLM-PROJECT/
|
| 69 |
+
│
|
| 70 |
+
├── main.py # 실행 진입점
|
| 71 |
+
├── models/ # 로컬 모델 로드용 양자화 파일 저장 폴더(비공개)
|
| 72 |
+
├── data/ # 문서 및 벡터DB 저장 폴더(비공개)
|
| 73 |
+
│ ├── files/ # hwp, pdf 문서
|
| 74 |
+
│ └── data_list.csv # RFP 문서 정보 csv
|
| 75 |
+
├── src/
|
| 76 |
+
│ ├── loader/ # 문서 로딩 및 전처리
|
| 77 |
+
│ ├── evaluation/ # LangSmith 평가
|
| 78 |
+
│ ├── embedding/ # 임베딩, 벡터DB 생성
|
| 79 |
+
│ ├── retriever/ # 문서 검색기
|
| 80 |
+
│ ├── generator/ # 응답 생성기
|
| 81 |
+
│ ├── visualization/ # UI 구성
|
| 82 |
+
│ ├── notebooks/ # Hugging Face 모델 학습 코드
|
| 83 |
+
│ └── utils/ # 공통 함수 모듈
|
| 84 |
+
└── README.md
|
| 85 |
+
```
|
| 86 |
+
- `main.py`: 전체 RAG 파이프라인 실행의 진입점입니다.
|
| 87 |
+
- `data/`: 원문 문서, 생성된 벡터DB 등이 저장됩니다.
|
| 88 |
+
- `models/`: 로컬 모델 로드용 양자화 모델 파일을 저장하는 곳입니다.
|
| 89 |
+
- `src/loader`: PDF, HWP 문서를 텍스트로 추출하고 의미 단위로 분할합니다.
|
| 90 |
+
- `src/evaluation`: LangSmith 평가 환경을 관리하고 실험을 진행합니다.
|
| 91 |
+
- `src/embedding`: 텍스트 임베딩 벡터를 생성하고 Chroma DB를 구축합니다.
|
| 92 |
+
- `src/retriever`: 사용자 질문에 대한 관련 문서를 벡터DB에서 검색합니다.
|
| 93 |
+
- `src/generator`: 검색된 문서 기반으로 LLM이 응답을 생성합니다.
|
| 94 |
+
- `src/notebooks`: 로컬 모델을 Fine-Tuning하여 양자화 파일을 생성합니다.
|
| 95 |
+
- `src/visualization`: Streamlit 기반 사용자 인터페이스를 구성합니다.
|
| 96 |
+
- `src/utils`: 설정 확인, 경로 설정 등 공통 유틸리티 함수들을 포함합니다.
|
| 97 |
+
|
| 98 |
+
# 4. 팀 소개
|
| 99 |
+
> 기본에 충실실하며 실제 사용 가능한 모델을 만들기 위해 끊임없이 노력하는 팀입니다.
|
| 100 |
+
|
| 101 |
+
## 👨🏼💻 멤버 구성
|
| 102 |
+
|지동진|김진욱|이유노|박지윤|
|
| 103 |
+
|-----|------|------|-------|
|
| 104 |
+
|<img width="100" height="100" alt="image" src="https://github.com/user-attachments/assets/b9f1a52f-4304-496d-a19c-2d6b4775a5c3" />|<img width="100" height="100" alt="image" src="https://avatars.githubusercontent.com/u/80089860?v=4.png"/>|<img width="100" height="100" alt="image" src="https://github.com/user-attachments/assets/4e635630-f00c-4026-bb1d-c73ec05f37c8" />|<img width="100" height="100" alt="image" src="https://github.com/user-attachments/assets/088a073c-cf1c-40a1-97fb-1d2c1f1b8794" />|
|
| 105 |
+
|||||
|
| 106 |
+
|||||
|
| 107 |
+
|
| 108 |
+
## 👨🏼💻 역할 분담
|
| 109 |
+
|지동진|김진욱|이유노|박지윤|
|
| 110 |
+
|------|--------------|---------------|---------------|
|
| 111 |
+
|PM/AI Enginner(Rettriever, Pre-trained, PEFT)|Data Scientist|AI Engineer(API, Prompt)|AI Engineer(HuggingFace, PEFT)|
|
| 112 |
+
|프로젝트 총괄. 팀 회의 진행. 팀 혐업 환경 관리. RAG 개발. 대시보드 개발, PEFT 담당|학습 데이터 구성. 데이터 전처리 파이프라인 작성. 개발간 필요한 인사이트 도출 및 정보 수집, 제공|API 모델 개발. 프롬프트 작성. 모델 개선|HuggingFace 모델 학습, 모델 개선|
|
| 113 |
+
---
|
| 114 |
+
# 5. 프로젝트 타임라인
|
| 115 |
+
<img width="1580" height="807" alt="image" src="https://github.com/user-attachments/assets/57f6346a-663f-4ddd-a4b6-fafc2074ff71" />
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
# 6. 서비스 설명
|
| 120 |
+
|
| 121 |
+
## 서비스 아키텍쳐
|
| 122 |
+
<img width="4208" height="2004" alt="image" src="https://github.com/user-attachments/assets/73a0db09-b858-4b69-b93b-a85f928225a9" />
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
# Further Information
|
| 126 |
+
|
| 127 |
+
## 개발 스택 및 개발환경
|
| 128 |
+
- **언어**: <img width="67" height="18" alt="image" src="https://github.com/user-attachments/assets/e8035e3d-cadb-48f5-a4ac-3693faca01a7" /> <img width="67" height="18" alt="image" src="https://github.com/user-attachments/assets/0658c7ba-8039-4dc3-96a2-7c1308b2fafc" />
|
| 129 |
+
|
| 130 |
+
- **프레임워크**: <img width="79" height="18" alt="image" src="https://github.com/user-attachments/assets/e8814092-7e1e-4b22-8d77-e04fd2b26ae6" /> <img width="79" height="18" alt="image" src="https://img.shields.io/badge/LangChain-ffffff?logo=langchain&logoColor=green" />
|
| 131 |
+
|
| 132 |
+
- **라이브러리**: <img width="71" height="18" alt="image" src="https://github.com/user-attachments/assets/a428cd24-c8a5-4296-b6da-22eb322afa49" /> <img width="69" height="18" alt="image" src="https://github.com/user-attachments/assets/4325f1d3-d8ba-4bec-a746-4cad4993e925" /> <img width="103" height="18" alt="image" src="https://github.com/user-attachments/assets/a2009044-329d-4dde-b0dc-701122ff8149" /> <img width="53" height="18" alt="image" src="https://github.com/user-attachments/assets/f6225115-0b60-439e-8388-974a0365f8d6" />
|
| 133 |
+
- **클라우드 서비스**: <img width="71" height="18" alt="image" src="https://img.shields.io/badge/Google%20Cloud-4285F4?&style=plastic&logo=Google%20Cloud&logoColor=white" />
|
| 134 |
+
- **도구**: <img width="65" height="18" alt="image" src="https://github.com/user-attachments/assets/52f296c1-c878-4285-abe6-74842522e793" /> <img width="89" height="18" alt="image" src="https://github.com/user-attachments/assets/4ac10441-0753-4e94-9237-1ea6dc2034a2" /><img width="63" height="18" alt="image" src="https://github.com/user-attachments/assets/fea30130-c47c-4fa7-b3cb-7531481cfb28" /> <img width="89" height="18" alt="image" src="https://img.shields.io/badge/google_drive-white?style=for-the-badge&logo=google%20drive&logoColor=white&color=%23EA4336" />
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
## 협업 Tools
|
| 139 |
+
<img width="69" height="18" alt="image" src="https://github.com/user-attachments/assets/2bc2fa93-b01e-4051-9b31-ab83301594df" />
|
| 140 |
+
<img width="63" height="18" alt="image" src="https://github.com/user-attachments/assets/6c44ddad-80a4-4098-9727-6dae9a8fcb1c" />
|
| 141 |
+
<img width="65" height="18" alt="image" src="https://github.com/user-attachments/assets/a85b2d0f-8cdc-43e7-8e14-da11708a33a4" />
|
| 142 |
+
<img width="89" height="18" alt="image" src="https://github.com/user-attachments/assets/28d7f511-a4fe-4aa5-9184-2d3a94a97f29" />
|
| 143 |
+
<img width="89" height="18" alt="image" src="https://img.shields.io/badge/weightsandbiases-%23FFBE00?style=for-the-badge&logo=wandb-%23FFBE00&logoColor=%23FFBE00" />
|
| 144 |
+
|
| 145 |
+
## 기타 링크
|
chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/data_level0.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3fcbbabd5eddf6c36f238edc7dc7a17aeab05cbfefb8456caad3495309403abd
|
| 3 |
+
size 59176428
|
chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/header.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b4b516f13e8eb37f410c5a087df870dcbc2994a316d8e58efd5f1bac5685d422
|
| 3 |
+
size 100
|
chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/index_metadata.pickle
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0c79083c508105b134cb2ba307430bd2afa033a91d5a22703a94f6745856998a
|
| 3 |
+
size 2100188
|
chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/length.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:318eb571e3c7704e576e589ab175992efdef54cddae6147eeb214efd2791c320
|
| 3 |
+
size 37668
|
chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/link_lists.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7ba1f25b966f8671e2cbf5181c811630577e89dd4c1fa3eb5ef43d0bf1f7d3de
|
| 3 |
+
size 79488
|
chroma_db/chroma.sqlite3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a3c8f47065976eb17aac628524373ea64a6b3c3259bcb341de5da50fa4409779
|
| 3 |
+
size 187174912
|
data/rag_chunks_final.csv
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0c833ced843410dca02c5f0bd83c89d9e3d64c9211683abaa8f24b1de1d16412
|
| 3 |
+
size 29074806
|
main.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RAG 전체 파이프라인 실행 스크립트
|
| 3 |
+
|
| 4 |
+
단계:
|
| 5 |
+
1. 전처리 (preprocess): 텍스트 추출 → 정제 → 청킹
|
| 6 |
+
2. 임베딩 (embed): 청크 벡터화 → ChromaDB 저장
|
| 7 |
+
3. RAG (rag): RAG 파이프라인 테스트 (선택)
|
| 8 |
+
|
| 9 |
+
사용법:
|
| 10 |
+
python main.py --step all # 전체 실행
|
| 11 |
+
python main.py --step preprocess # 전처리만
|
| 12 |
+
python main.py --step embed # 임베딩만
|
| 13 |
+
python main.py --step rag # RAG 테스트만
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import sys
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from src.utils.config import PreprocessConfig
|
| 21 |
+
from src.loader.preprocess_pipeline import RAGPreprocessPipeline
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def parse_arguments():
|
| 25 |
+
"""커맨드 라인 인자 파싱"""
|
| 26 |
+
parser = argparse.ArgumentParser(
|
| 27 |
+
description='RAG 전체 파이프라인 실행',
|
| 28 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 29 |
+
epilog="""
|
| 30 |
+
예시:
|
| 31 |
+
python main.py --step all # 전체 파이프라인 실행
|
| 32 |
+
python main.py --step preprocess # 전처리만 실행
|
| 33 |
+
python main.py --step embed # 임베딩만 실행
|
| 34 |
+
python main.py --step rag --query "질문" # RAG 테스트
|
| 35 |
+
|
| 36 |
+
python main.py --step preprocess --chunk-size 500 # 청크 크기 조정
|
| 37 |
+
"""
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# 실행 단계 선택
|
| 41 |
+
parser.add_argument(
|
| 42 |
+
'--step',
|
| 43 |
+
type=str,
|
| 44 |
+
choices=['all', 'preprocess', 'embed', 'rag'],
|
| 45 |
+
default='all',
|
| 46 |
+
help='실행할 단계 (기본값: all)'
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# 전처리 관련 인자
|
| 50 |
+
preprocess_group = parser.add_argument_group('전처리 옵션')
|
| 51 |
+
preprocess_group.add_argument(
|
| 52 |
+
'--meta-csv',
|
| 53 |
+
type=str,
|
| 54 |
+
default='./data/data_list.csv',
|
| 55 |
+
help='메타데이터 CSV 파일 경로'
|
| 56 |
+
)
|
| 57 |
+
preprocess_group.add_argument(
|
| 58 |
+
'--files-dir',
|
| 59 |
+
type=str,
|
| 60 |
+
default='./data/files/',
|
| 61 |
+
help='원본 파일 폴더 경로'
|
| 62 |
+
)
|
| 63 |
+
preprocess_group.add_argument(
|
| 64 |
+
'--output-chunks',
|
| 65 |
+
type=str,
|
| 66 |
+
default='./data/rag_chunks_final_small.csv',
|
| 67 |
+
help='청크 출력 파일 경로'
|
| 68 |
+
)
|
| 69 |
+
preprocess_group.add_argument(
|
| 70 |
+
'--chunk-size',
|
| 71 |
+
type=int,
|
| 72 |
+
default=1000,
|
| 73 |
+
help='청크 크기'
|
| 74 |
+
)
|
| 75 |
+
preprocess_group.add_argument(
|
| 76 |
+
'--chunk-overlap',
|
| 77 |
+
type=int,
|
| 78 |
+
default=200,
|
| 79 |
+
help='청크 오버랩'
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# RAG 관련 인자
|
| 83 |
+
rag_group = parser.add_argument_group('RAG 옵션')
|
| 84 |
+
rag_group.add_argument(
|
| 85 |
+
'--query',
|
| 86 |
+
type=str,
|
| 87 |
+
help='RAG 질의 (rag 단계에서만 사용)'
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return parser.parse_args()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def step_preprocess(args):
|
| 94 |
+
"""1단계: 전처리 실행"""
|
| 95 |
+
print("\n" + "="*70)
|
| 96 |
+
print("🔧 1단계: 데이터 전처리 시작")
|
| 97 |
+
print("="*70)
|
| 98 |
+
|
| 99 |
+
# 설정 초기화
|
| 100 |
+
config = PreprocessConfig()
|
| 101 |
+
config.META_CSV_PATH = args.meta_csv
|
| 102 |
+
config.BASE_FOLDER_PATH = args.files_dir
|
| 103 |
+
config.OUTPUT_CHUNKS_PATH = args.output_chunks
|
| 104 |
+
config.CHUNK_SIZE = args.chunk_size
|
| 105 |
+
config.CHUNK_OVERLAP = args.chunk_overlap
|
| 106 |
+
|
| 107 |
+
# 전처리 파이프라인 실행
|
| 108 |
+
pipeline = RAGPreprocessPipeline(config)
|
| 109 |
+
df_chunks = pipeline.run()
|
| 110 |
+
|
| 111 |
+
print("\n" + "="*70)
|
| 112 |
+
print("✅ 1단계: 전처리 완료")
|
| 113 |
+
print("="*70)
|
| 114 |
+
print(f"📁 출력 파일: {config.OUTPUT_CHUNKS_PATH}")
|
| 115 |
+
print(f"📊 총 청크 수: {len(df_chunks)}")
|
| 116 |
+
|
| 117 |
+
return df_chunks
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def step_embed(args):
|
| 121 |
+
"""2단계: 임베딩 및 ChromaDB 저장"""
|
| 122 |
+
print("\n" + "="*70)
|
| 123 |
+
print("🔧 2단계: 임베딩 및 벡터DB 구축 시작")
|
| 124 |
+
print("="*70)
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
# 임베딩 모듈 임포트
|
| 128 |
+
from src.embedding.rag_data_processing import RAGVectorDBPipeline
|
| 129 |
+
|
| 130 |
+
# 임베딩 실행
|
| 131 |
+
pipeline = RAGVectorDBPipeline()
|
| 132 |
+
vectorstore = pipeline.build()
|
| 133 |
+
|
| 134 |
+
print("\n" + "="*70)
|
| 135 |
+
print("✅ 2단계: 임베딩 완료")
|
| 136 |
+
print("="*70)
|
| 137 |
+
|
| 138 |
+
except ImportError as e:
|
| 139 |
+
print(f"⚠️ 임베딩 모듈을 찾을 수 없습니다: {e}")
|
| 140 |
+
print(" src/embedding/rag_data_processing.py 파일이 있는지 확인하세요.")
|
| 141 |
+
sys.exit(1)
|
| 142 |
+
except Exception as e:
|
| 143 |
+
print(f"❌ 임베딩 실행 중 오류 발생: {e}")
|
| 144 |
+
import traceback
|
| 145 |
+
traceback.print_exc()
|
| 146 |
+
sys.exit(1)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def step_rag(args):
|
| 150 |
+
"""3단계: RAG 파이프라인 테스트"""
|
| 151 |
+
print("\n" + "="*70)
|
| 152 |
+
print("🔧 3단계: RAG 파이프라인 테스트")
|
| 153 |
+
print("="*70)
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
# RAG 모듈 임포트
|
| 157 |
+
from src.generator.generator import RAGPipeline
|
| 158 |
+
from src.utils.rag_config import RAGConfig
|
| 159 |
+
|
| 160 |
+
# RAG 설정
|
| 161 |
+
config = RAGConfig()
|
| 162 |
+
|
| 163 |
+
# RAG 파이프라인 초기화
|
| 164 |
+
rag = RAGPipeline(config=config)
|
| 165 |
+
|
| 166 |
+
# 테스트 질의 실행
|
| 167 |
+
if args.query:
|
| 168 |
+
print(f"\n📝 질의: {args.query}")
|
| 169 |
+
result = rag.generate_answer(args.query)
|
| 170 |
+
|
| 171 |
+
print(f"\n💬 답변:")
|
| 172 |
+
print(result['answer'])
|
| 173 |
+
print(f"\n📚 참고 문서: {len(result.get('sources', []))}개")
|
| 174 |
+
print(f"🔢 토큰 사용: {result['usage']['total_tokens']}")
|
| 175 |
+
else:
|
| 176 |
+
print("\n⚠️ --query 인자가 없어 테스트 질의를 건너뜁니다.")
|
| 177 |
+
print(" 예시: python main.py --step rag --query '한영대학교 특성화 사업은?'")
|
| 178 |
+
|
| 179 |
+
print("\n" + "="*70)
|
| 180 |
+
print("✅ 3단계: RAG 파이프라인 완료")
|
| 181 |
+
print("="*70)
|
| 182 |
+
|
| 183 |
+
except ImportError as e:
|
| 184 |
+
print(f"⚠️ RAG 모듈을 찾을 수 없습니다: {e}")
|
| 185 |
+
print(" src/generator/rag_pipeline.py 파일이 있는지 확인하세요.")
|
| 186 |
+
sys.exit(1)
|
| 187 |
+
except Exception as e:
|
| 188 |
+
print(f"❌ RAG 실행 중 오류 발생: {e}")
|
| 189 |
+
import traceback
|
| 190 |
+
traceback.print_exc()
|
| 191 |
+
sys.exit(1)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def main():
|
| 195 |
+
"""메인 실행 함수"""
|
| 196 |
+
args = parse_arguments()
|
| 197 |
+
|
| 198 |
+
print("="*70)
|
| 199 |
+
print("🚀 RAG 전체 파이프라인")
|
| 200 |
+
print("="*70)
|
| 201 |
+
print(f"실행 단계: {args.step}")
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
if args.step == 'all':
|
| 205 |
+
# 전체 파이프라인 실행
|
| 206 |
+
step_preprocess(args)
|
| 207 |
+
step_embed(args)
|
| 208 |
+
|
| 209 |
+
# RAG 테스트는 선택적 (query가 있으면 실행)
|
| 210 |
+
if args.query:
|
| 211 |
+
step_rag(args)
|
| 212 |
+
|
| 213 |
+
elif args.step == 'preprocess':
|
| 214 |
+
step_preprocess(args)
|
| 215 |
+
|
| 216 |
+
elif args.step == 'embed':
|
| 217 |
+
step_embed(args)
|
| 218 |
+
|
| 219 |
+
elif args.step == 'rag':
|
| 220 |
+
step_rag(args)
|
| 221 |
+
|
| 222 |
+
print("\n" + "="*70)
|
| 223 |
+
print("🎉 모든 작업 완료!")
|
| 224 |
+
print("="*70)
|
| 225 |
+
|
| 226 |
+
except KeyboardInterrupt:
|
| 227 |
+
print("\n\n⚠️ 사용자에 의해 중단되었습니다.")
|
| 228 |
+
sys.exit(1)
|
| 229 |
+
except Exception as e:
|
| 230 |
+
print(f"\n❌ 오류 발생: {e}")
|
| 231 |
+
import traceback
|
| 232 |
+
traceback.print_exc()
|
| 233 |
+
sys.exit(1)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
main()
|
notebooks/export_v2.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from unsloth import FastLanguageModel
|
| 2 |
+
import torch
|
| 3 |
+
import os
|
| 4 |
+
import glob
|
| 5 |
+
|
| 6 |
+
# ==========================================
|
| 7 |
+
# [Smart Export Script] 최신 체크포인트 자동 감지
|
| 8 |
+
# ==========================================
|
| 9 |
+
|
| 10 |
+
print(">>> [System] GGUF 변환 작업을 시작합니다...")
|
| 11 |
+
|
| 12 |
+
# 1. 최신 체크포인트 폴더 찾기 (핵심!)
|
| 13 |
+
output_dir = "outputs_final"
|
| 14 |
+
|
| 15 |
+
if not os.path.exists(output_dir):
|
| 16 |
+
print(f">>> [Error] '{output_dir}' 폴더가 없습니다!")
|
| 17 |
+
exit()
|
| 18 |
+
|
| 19 |
+
# checkpoint- 숫자 폴더들을 다 찾아서 숫자가 제일 큰 놈을 고름
|
| 20 |
+
subfolders = [f.path for f in os.scandir(output_dir) if f.is_dir() and "checkpoint" in f.name]
|
| 21 |
+
|
| 22 |
+
if not subfolders:
|
| 23 |
+
print(">>> [Error] 체크포인트 폴더를 찾을 수 없습니다!")
|
| 24 |
+
exit()
|
| 25 |
+
|
| 26 |
+
# 숫자로 정렬해서 가장 마지막 것 선택 (예: checkpoint-3171)
|
| 27 |
+
latest_checkpoint = max(subfolders, key=lambda x: int(x.split('-')[-1]))
|
| 28 |
+
|
| 29 |
+
print(f">>> [Found] 가장 학습이 잘 된 모델을 찾았습니다: {latest_checkpoint}")
|
| 30 |
+
print(">>> [Model] 모델 로드 중... (xFormers 경고는 무시하세요)")
|
| 31 |
+
|
| 32 |
+
# 2. 모델 로드 (정확한 경로 입력)
|
| 33 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 34 |
+
model_name = latest_checkpoint, # <--- 자동으로 찾은 경로
|
| 35 |
+
max_seq_length = 2048,
|
| 36 |
+
dtype = None,
|
| 37 |
+
load_in_4bit = True,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# 3. GGUF 변환
|
| 41 |
+
print(f">>> [Convert] '{latest_checkpoint}' -> GGUF 변환 시작 (5~10분 소요)")
|
| 42 |
+
|
| 43 |
+
# q4_k_m: 용량/성능 밸런스형
|
| 44 |
+
model.save_pretrained_gguf("BiddinMate_Model", tokenizer, quantization_method = "q4_k_m")
|
| 45 |
+
|
| 46 |
+
print(">>> [Success] 변환 완료!")
|
| 47 |
+
print(f">>> 'BiddinMate_Model' 폴더 안에 .gguf 파일이 생성되었습니다.")
|
notebooks/llama.ipynb
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "code",
|
| 5 |
+
"execution_count": 2,
|
| 6 |
+
"id": "d13f7470",
|
| 7 |
+
"metadata": {},
|
| 8 |
+
"outputs": [
|
| 9 |
+
{
|
| 10 |
+
"name": "stdout",
|
| 11 |
+
"output_type": "stream",
|
| 12 |
+
"text": [
|
| 13 |
+
"True\n"
|
| 14 |
+
]
|
| 15 |
+
}
|
| 16 |
+
],
|
| 17 |
+
"source": [
|
| 18 |
+
"import torch\n",
|
| 19 |
+
"print(torch.cuda.is_available())"
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"cell_type": "code",
|
| 24 |
+
"execution_count": 3,
|
| 25 |
+
"id": "d944a23b",
|
| 26 |
+
"metadata": {},
|
| 27 |
+
"outputs": [
|
| 28 |
+
{
|
| 29 |
+
"name": "stdout",
|
| 30 |
+
"output_type": "stream",
|
| 31 |
+
"text": [
|
| 32 |
+
"Requirement already satisfied: torch in /opt/jhub-venv/lib/python3.12/site-packages (2.6.0+cu124)\n",
|
| 33 |
+
"Requirement already satisfied: transformers in ./myenv/lib/python3.12/site-packages (4.57.1)\n",
|
| 34 |
+
"Requirement already satisfied: peft in ./myenv/lib/python3.12/site-packages (0.18.0)\n",
|
| 35 |
+
"Requirement already satisfied: bitsandbytes in ./myenv/lib/python3.12/site-packages (0.48.2)\n",
|
| 36 |
+
"Requirement already satisfied: trl in ./myenv/lib/python3.12/site-packages (0.25.1)\n",
|
| 37 |
+
"Requirement already satisfied: datasets in ./myenv/lib/python3.12/site-packages (4.4.1)\n",
|
| 38 |
+
"Requirement already satisfied: accelerate in ./myenv/lib/python3.12/site-packages (1.11.0)\n",
|
| 39 |
+
"Requirement already satisfied: jsonlines in ./myenv/lib/python3.12/site-packages (4.0.0)\n",
|
| 40 |
+
"Requirement already satisfied: filelock in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (3.19.1)\n",
|
| 41 |
+
"Requirement already satisfied: typing-extensions>=4.10.0 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (4.15.0)\n",
|
| 42 |
+
"Requirement already satisfied: networkx in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (3.5)\n",
|
| 43 |
+
"Requirement already satisfied: jinja2 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (3.1.6)\n",
|
| 44 |
+
"Requirement already satisfied: fsspec in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (2025.9.0)\n",
|
| 45 |
+
"Requirement already satisfied: nvidia-cuda-nvrtc-cu12==12.4.127 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (12.4.127)\n",
|
| 46 |
+
"Requirement already satisfied: nvidia-cuda-runtime-cu12==12.4.127 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (12.4.127)\n",
|
| 47 |
+
"Requirement already satisfied: nvidia-cuda-cupti-cu12==12.4.127 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (12.4.127)\n",
|
| 48 |
+
"Requirement already satisfied: nvidia-cudnn-cu12==9.1.0.70 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (9.1.0.70)\n",
|
| 49 |
+
"Requirement already satisfied: nvidia-cublas-cu12==12.4.5.8 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (12.4.5.8)\n",
|
| 50 |
+
"Requirement already satisfied: nvidia-cufft-cu12==11.2.1.3 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (11.2.1.3)\n",
|
| 51 |
+
"Requirement already satisfied: nvidia-curand-cu12==10.3.5.147 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (10.3.5.147)\n",
|
| 52 |
+
"Requirement already satisfied: nvidia-cusolver-cu12==11.6.1.9 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (11.6.1.9)\n",
|
| 53 |
+
"Requirement already satisfied: nvidia-cusparse-cu12==12.3.1.170 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (12.3.1.170)\n",
|
| 54 |
+
"Requirement already satisfied: nvidia-cusparselt-cu12==0.6.2 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (0.6.2)\n",
|
| 55 |
+
"Requirement already satisfied: nvidia-nccl-cu12==2.21.5 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (2.21.5)\n",
|
| 56 |
+
"Requirement already satisfied: nvidia-nvtx-cu12==12.4.127 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (12.4.127)\n",
|
| 57 |
+
"Requirement already satisfied: nvidia-nvjitlink-cu12==12.4.127 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (12.4.127)\n",
|
| 58 |
+
"Requirement already satisfied: triton==3.2.0 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (3.2.0)\n",
|
| 59 |
+
"Requirement already satisfied: setuptools in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (70.2.0)\n",
|
| 60 |
+
"Requirement already satisfied: sympy==1.13.1 in /opt/jhub-venv/lib/python3.12/site-packages (from torch) (1.13.1)\n",
|
| 61 |
+
"Requirement already satisfied: mpmath<1.4,>=1.1.0 in /opt/jhub-venv/lib/python3.12/site-packages (from sympy==1.13.1->torch) (1.3.0)\n",
|
| 62 |
+
"Requirement already satisfied: huggingface-hub<1.0,>=0.34.0 in ./myenv/lib/python3.12/site-packages (from transformers) (0.36.0)\n",
|
| 63 |
+
"Requirement already satisfied: numpy>=1.17 in /opt/jhub-venv/lib/python3.12/site-packages (from transformers) (2.3.3)\n",
|
| 64 |
+
"Requirement already satisfied: packaging>=20.0 in /opt/jhub-venv/lib/python3.12/site-packages (from transformers) (25.0)\n",
|
| 65 |
+
"Requirement already satisfied: pyyaml>=5.1 in /opt/jhub-venv/lib/python3.12/site-packages (from transformers) (6.0.3)\n",
|
| 66 |
+
"Requirement already satisfied: regex!=2019.12.17 in ./myenv/lib/python3.12/site-packages (from transformers) (2025.11.3)\n",
|
| 67 |
+
"Requirement already satisfied: requests in /opt/jhub-venv/lib/python3.12/site-packages (from transformers) (2.32.5)\n",
|
| 68 |
+
"Requirement already satisfied: tokenizers<=0.23.0,>=0.22.0 in ./myenv/lib/python3.12/site-packages (from transformers) (0.22.1)\n",
|
| 69 |
+
"Requirement already satisfied: safetensors>=0.4.3 in ./myenv/lib/python3.12/site-packages (from transformers) (0.6.2)\n",
|
| 70 |
+
"Requirement already satisfied: tqdm>=4.27 in ./myenv/lib/python3.12/site-packages (from transformers) (4.67.1)\n",
|
| 71 |
+
"Requirement already satisfied: psutil in /opt/jhub-venv/lib/python3.12/site-packages (from peft) (7.1.3)\n",
|
| 72 |
+
"Requirement already satisfied: pyarrow>=21.0.0 in ./myenv/lib/python3.12/site-packages (from datasets) (22.0.0)\n",
|
| 73 |
+
"Requirement already satisfied: dill<0.4.1,>=0.3.0 in ./myenv/lib/python3.12/site-packages (from datasets) (0.4.0)\n",
|
| 74 |
+
"Requirement already satisfied: pandas in ./myenv/lib/python3.12/site-packages (from datasets) (2.3.3)\n",
|
| 75 |
+
"Requirement already satisfied: httpx<1.0.0 in /opt/jhub-venv/lib/python3.12/site-packages (from datasets) (0.28.1)\n",
|
| 76 |
+
"Requirement already satisfied: xxhash in ./myenv/lib/python3.12/site-packages (from datasets) (3.6.0)\n",
|
| 77 |
+
"Requirement already satisfied: multiprocess<0.70.19 in ./myenv/lib/python3.12/site-packages (from datasets) (0.70.18)\n",
|
| 78 |
+
"Requirement already satisfied: attrs>=19.2.0 in /opt/jhub-venv/lib/python3.12/site-packages (from jsonlines) (25.4.0)\n",
|
| 79 |
+
"Requirement already satisfied: aiohttp!=4.0.0a0,!=4.0.0a1 in ./myenv/lib/python3.12/site-packages (from fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (3.13.2)\n",
|
| 80 |
+
"Requirement already satisfied: anyio in /opt/jhub-venv/lib/python3.12/site-packages (from httpx<1.0.0->datasets) (4.11.0)\n",
|
| 81 |
+
"Requirement already satisfied: certifi in /opt/jhub-venv/lib/python3.12/site-packages (from httpx<1.0.0->datasets) (2025.10.5)\n",
|
| 82 |
+
"Requirement already satisfied: httpcore==1.* in /opt/jhub-venv/lib/python3.12/site-packages (from httpx<1.0.0->datasets) (1.0.9)\n",
|
| 83 |
+
"Requirement already satisfied: idna in /opt/jhub-venv/lib/python3.12/site-packages (from httpx<1.0.0->datasets) (3.11)\n",
|
| 84 |
+
"Requirement already satisfied: h11>=0.16 in /opt/jhub-venv/lib/python3.12/site-packages (from httpcore==1.*->httpx<1.0.0->datasets) (0.16.0)\n",
|
| 85 |
+
"Requirement already satisfied: hf-xet<2.0.0,>=1.1.3 in ./myenv/lib/python3.12/site-packages (from huggingface-hub<1.0,>=0.34.0->transformers) (1.2.0)\n",
|
| 86 |
+
"Requirement already satisfied: charset_normalizer<4,>=2 in /opt/jhub-venv/lib/python3.12/site-packages (from requests->transformers) (3.4.4)\n",
|
| 87 |
+
"Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/jhub-venv/lib/python3.12/site-packages (from requests->transformers) (2.5.0)\n",
|
| 88 |
+
"Requirement already satisfied: MarkupSafe>=2.0 in /opt/jhub-venv/lib/python3.12/site-packages (from jinja2->torch) (2.1.5)\n",
|
| 89 |
+
"Requirement already satisfied: python-dateutil>=2.8.2 in /opt/jhub-venv/lib/python3.12/site-packages (from pandas->datasets) (2.9.0.post0)\n",
|
| 90 |
+
"Requirement already satisfied: pytz>=2020.1 in ./myenv/lib/python3.12/site-packages (from pandas->datasets) (2025.2)\n",
|
| 91 |
+
"Requirement already satisfied: tzdata>=2022.7 in /opt/jhub-venv/lib/python3.12/site-packages (from pandas->datasets) (2025.2)\n",
|
| 92 |
+
"Requirement already satisfied: aiohappyeyeballs>=2.5.0 in ./myenv/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (2.6.1)\n",
|
| 93 |
+
"Requirement already satisfied: aiosignal>=1.4.0 in ./myenv/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (1.4.0)\n",
|
| 94 |
+
"Requirement already satisfied: frozenlist>=1.1.1 in ./myenv/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (1.8.0)\n",
|
| 95 |
+
"Requirement already satisfied: multidict<7.0,>=4.5 in ./myenv/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (6.7.0)\n",
|
| 96 |
+
"Requirement already satisfied: propcache>=0.2.0 in ./myenv/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (0.4.1)\n",
|
| 97 |
+
"Requirement already satisfied: yarl<2.0,>=1.17.0 in ./myenv/lib/python3.12/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->fsspec[http]<=2025.10.0,>=2023.1.0->datasets) (1.22.0)\n",
|
| 98 |
+
"Requirement already satisfied: six>=1.5 in /opt/jhub-venv/lib/python3.12/site-packages (from python-dateutil>=2.8.2->pandas->datasets) (1.17.0)\n",
|
| 99 |
+
"Requirement already satisfied: sniffio>=1.1 in /opt/jhub-venv/lib/python3.12/site-packages (from anyio->httpx<1.0.0->datasets) (1.3.1)\n",
|
| 100 |
+
"Note: you may need to restart the kernel to use updated packages.\n"
|
| 101 |
+
]
|
| 102 |
+
}
|
| 103 |
+
],
|
| 104 |
+
"source": [
|
| 105 |
+
"%pip install torch transformers peft bitsandbytes trl datasets accelerate jsonlines"
|
| 106 |
+
]
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"cell_type": "markdown",
|
| 110 |
+
"id": "20479002",
|
| 111 |
+
"metadata": {},
|
| 112 |
+
"source": [
|
| 113 |
+
"## 라이브러리 설정"
|
| 114 |
+
]
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"cell_type": "code",
|
| 118 |
+
"execution_count": 4,
|
| 119 |
+
"id": "9eba4d06",
|
| 120 |
+
"metadata": {},
|
| 121 |
+
"outputs": [
|
| 122 |
+
{
|
| 123 |
+
"name": "stderr",
|
| 124 |
+
"output_type": "stream",
|
| 125 |
+
"text": [
|
| 126 |
+
"/home/codeit01team/myenv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
|
| 127 |
+
" from .autonotebook import tqdm as notebook_tqdm\n"
|
| 128 |
+
]
|
| 129 |
+
}
|
| 130 |
+
],
|
| 131 |
+
"source": [
|
| 132 |
+
"import torch # ← 추가!\n",
|
| 133 |
+
"\n",
|
| 134 |
+
"# 데이터 로드\n",
|
| 135 |
+
"from datasets import load_dataset\n",
|
| 136 |
+
"from transformers import (\n",
|
| 137 |
+
" AutoModelForCausalLM,\n",
|
| 138 |
+
" AutoTokenizer,\n",
|
| 139 |
+
" BitsAndBytesConfig,\n",
|
| 140 |
+
")\n",
|
| 141 |
+
"from peft import (\n",
|
| 142 |
+
" LoraConfig,\n",
|
| 143 |
+
" get_peft_model,\n",
|
| 144 |
+
" prepare_model_for_kbit_training,\n",
|
| 145 |
+
" TaskType,\n",
|
| 146 |
+
")\n",
|
| 147 |
+
"from trl import SFTTrainer, SFTConfig"
|
| 148 |
+
]
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"cell_type": "code",
|
| 152 |
+
"execution_count": 5,
|
| 153 |
+
"id": "804cb86f",
|
| 154 |
+
"metadata": {},
|
| 155 |
+
"outputs": [
|
| 156 |
+
{
|
| 157 |
+
"data": {
|
| 158 |
+
"text/plain": [
|
| 159 |
+
"'/home/codeit01team'"
|
| 160 |
+
]
|
| 161 |
+
},
|
| 162 |
+
"execution_count": 5,
|
| 163 |
+
"metadata": {},
|
| 164 |
+
"output_type": "execute_result"
|
| 165 |
+
}
|
| 166 |
+
],
|
| 167 |
+
"source": [
|
| 168 |
+
"pwd"
|
| 169 |
+
]
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"cell_type": "markdown",
|
| 173 |
+
"id": "62466cf3",
|
| 174 |
+
"metadata": {},
|
| 175 |
+
"source": [
|
| 176 |
+
"# 1. 기본 QLoRA 설정 (HuggingFace PEFT + BitsAndBytes)"
|
| 177 |
+
]
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"cell_type": "code",
|
| 181 |
+
"execution_count": 6,
|
| 182 |
+
"id": "0b56e9fe",
|
| 183 |
+
"metadata": {},
|
| 184 |
+
"outputs": [],
|
| 185 |
+
"source": [
|
| 186 |
+
"# 1. 4-bit 양자화 설정\n",
|
| 187 |
+
"bnb_config = BitsAndBytesConfig(\n",
|
| 188 |
+
" load_in_4bit=True,\n",
|
| 189 |
+
" bnb_4bit_quant_type=\"nf4\",\n",
|
| 190 |
+
" bnb_4bit_use_double_quant=True,\n",
|
| 191 |
+
" bnb_4bit_compute_dtype=torch.bfloat16\n",
|
| 192 |
+
")"
|
| 193 |
+
]
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"cell_type": "code",
|
| 197 |
+
"execution_count": 7,
|
| 198 |
+
"id": "9a2f3aa9",
|
| 199 |
+
"metadata": {},
|
| 200 |
+
"outputs": [
|
| 201 |
+
{
|
| 202 |
+
"name": "stderr",
|
| 203 |
+
"output_type": "stream",
|
| 204 |
+
"text": [
|
| 205 |
+
"Loading checkpoint shards: 100%|██████████| 6/6 [02:13<00:00, 22.28s/it]\n"
|
| 206 |
+
]
|
| 207 |
+
}
|
| 208 |
+
],
|
| 209 |
+
"source": [
|
| 210 |
+
"# 2. 모델 로드\n",
|
| 211 |
+
"model_name = \"beomi/Llama-3-Open-Ko-8B\"\n",
|
| 212 |
+
"model = AutoModelForCausalLM.from_pretrained(\n",
|
| 213 |
+
" model_name,\n",
|
| 214 |
+
" quantization_config=bnb_config,\n",
|
| 215 |
+
" device_map=\"auto\",\n",
|
| 216 |
+
" trust_remote_code=True,\n",
|
| 217 |
+
")"
|
| 218 |
+
]
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"cell_type": "code",
|
| 222 |
+
"execution_count": 8,
|
| 223 |
+
"id": "9885213c",
|
| 224 |
+
"metadata": {},
|
| 225 |
+
"outputs": [
|
| 226 |
+
{
|
| 227 |
+
"name": "stdout",
|
| 228 |
+
"output_type": "stream",
|
| 229 |
+
"text": [
|
| 230 |
+
"The history saving thread hit an unexpected error (OperationalError('database or disk is full')).History will not be written to the database.\n"
|
| 231 |
+
]
|
| 232 |
+
}
|
| 233 |
+
],
|
| 234 |
+
"source": [
|
| 235 |
+
"tokenizer = AutoTokenizer.from_pretrained(model_name)\n",
|
| 236 |
+
"tokenizer.pad_token = tokenizer.eos_token\n",
|
| 237 |
+
"tokenizer.padding_side = \"right\""
|
| 238 |
+
]
|
| 239 |
+
},
|
| 240 |
+
{
|
| 241 |
+
"cell_type": "code",
|
| 242 |
+
"execution_count": 9,
|
| 243 |
+
"id": "0e8d4d55",
|
| 244 |
+
"metadata": {},
|
| 245 |
+
"outputs": [],
|
| 246 |
+
"source": [
|
| 247 |
+
"# 3. kbit 학습을 위한 모델 준비\n",
|
| 248 |
+
"model = prepare_model_for_kbit_training(model)"
|
| 249 |
+
]
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"cell_type": "code",
|
| 253 |
+
"execution_count": 10,
|
| 254 |
+
"id": "d8a4198b",
|
| 255 |
+
"metadata": {},
|
| 256 |
+
"outputs": [],
|
| 257 |
+
"source": [
|
| 258 |
+
"# 4. LoRA 설정 (핵심 모듈만 - 메모리 절약)\n",
|
| 259 |
+
"lora_config = LoraConfig(\n",
|
| 260 |
+
" r=16,\n",
|
| 261 |
+
" lora_alpha=32,\n",
|
| 262 |
+
" lora_dropout=0.05,\n",
|
| 263 |
+
" bias=\"none\",\n",
|
| 264 |
+
" task_type=TaskType.CAUSAL_LM,\n",
|
| 265 |
+
" target_modules=[\n",
|
| 266 |
+
" \"q_proj\",\n",
|
| 267 |
+
" \"k_proj\", \n",
|
| 268 |
+
" \"v_proj\",\n",
|
| 269 |
+
" \"o_proj\",\n",
|
| 270 |
+
" # gate_proj, up_proj, down_proj 제거 - 성능 차이 크지 않음\n",
|
| 271 |
+
" ]\n",
|
| 272 |
+
")"
|
| 273 |
+
]
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"cell_type": "code",
|
| 277 |
+
"execution_count": 11,
|
| 278 |
+
"id": "7eaa969c",
|
| 279 |
+
"metadata": {},
|
| 280 |
+
"outputs": [
|
| 281 |
+
{
|
| 282 |
+
"name": "stdout",
|
| 283 |
+
"output_type": "stream",
|
| 284 |
+
"text": [
|
| 285 |
+
"trainable params: 13,631,488 || all params: 8,043,892,736 || trainable%: 0.1695\n"
|
| 286 |
+
]
|
| 287 |
+
}
|
| 288 |
+
],
|
| 289 |
+
"source": [
|
| 290 |
+
"# 5. LoRA 적용\n",
|
| 291 |
+
"model = get_peft_model(model, lora_config)\n",
|
| 292 |
+
"model.print_trainable_parameters()"
|
| 293 |
+
]
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"cell_type": "markdown",
|
| 297 |
+
"id": "dc847247",
|
| 298 |
+
"metadata": {},
|
| 299 |
+
"source": [
|
| 300 |
+
"# 2. 데이터셋 준비 및 학습\n",
|
| 301 |
+
"\n",
|
| 302 |
+
"`HuggingFace Dataset`을 사용해야하는 이유\n",
|
| 303 |
+
"\n",
|
| 304 |
+
"- SFTTrainer가 HuggingFace Dataset을 입력으로 받음\n",
|
| 305 |
+
"- 자동으로 batching, shuffling, tokenization 처리\n",
|
| 306 |
+
"- 메모리 효율적 (lazy loading)"
|
| 307 |
+
]
|
| 308 |
+
},
|
| 309 |
+
{
|
| 310 |
+
"cell_type": "code",
|
| 311 |
+
"execution_count": 12,
|
| 312 |
+
"id": "17143379",
|
| 313 |
+
"metadata": {},
|
| 314 |
+
"outputs": [],
|
| 315 |
+
"source": [
|
| 316 |
+
"# 데이터 로드 (streaming)\n",
|
| 317 |
+
"data_path = \"data/sft_train_llama.jsonl\"\n",
|
| 318 |
+
"dataset = load_dataset(\n",
|
| 319 |
+
" \"json\",\n",
|
| 320 |
+
" data_files=data_path,\n",
|
| 321 |
+
" split=\"train\", \n",
|
| 322 |
+
")"
|
| 323 |
+
]
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"cell_type": "code",
|
| 327 |
+
"execution_count": 13,
|
| 328 |
+
"id": "b47e061d",
|
| 329 |
+
"metadata": {},
|
| 330 |
+
"outputs": [],
|
| 331 |
+
"source": [
|
| 332 |
+
"# 학습 설정 - 메모리 최적화\n",
|
| 333 |
+
"sft_config = SFTConfig(\n",
|
| 334 |
+
" output_dir=\"./qlora_output\",\n",
|
| 335 |
+
" num_train_epochs=2, # ← 주석처리 또는 삭제\n",
|
| 336 |
+
" per_device_train_batch_size=1,\n",
|
| 337 |
+
" gradient_accumulation_steps=16,\n",
|
| 338 |
+
" learning_rate=2e-4,\n",
|
| 339 |
+
" bf16=True,\n",
|
| 340 |
+
" logging_steps=10,\n",
|
| 341 |
+
" save_steps=500, # ← epoch 대신 step 기준 저장\n",
|
| 342 |
+
" optim=\"paged_adamw_8bit\",\n",
|
| 343 |
+
" gradient_checkpointing=True,\n",
|
| 344 |
+
" max_length=512, \n",
|
| 345 |
+
" dataset_text_field=\"text\",\n",
|
| 346 |
+
")"
|
| 347 |
+
]
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"cell_type": "code",
|
| 351 |
+
"execution_count": 14,
|
| 352 |
+
"id": "5b82e6f5",
|
| 353 |
+
"metadata": {},
|
| 354 |
+
"outputs": [],
|
| 355 |
+
"source": [
|
| 356 |
+
"# SFTTrainer로 학습\n",
|
| 357 |
+
"trainer = SFTTrainer(\n",
|
| 358 |
+
" model=model,\n",
|
| 359 |
+
" args=sft_config,\n",
|
| 360 |
+
" train_dataset=dataset,\n",
|
| 361 |
+
" processing_class=tokenizer,\n",
|
| 362 |
+
")"
|
| 363 |
+
]
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"cell_type": "code",
|
| 367 |
+
"execution_count": 15,
|
| 368 |
+
"id": "dd78a9cb",
|
| 369 |
+
"metadata": {},
|
| 370 |
+
"outputs": [
|
| 371 |
+
{
|
| 372 |
+
"name": "stderr",
|
| 373 |
+
"output_type": "stream",
|
| 374 |
+
"text": [
|
| 375 |
+
"The tokenizer has new PAD/BOS/EOS tokens that differ from the model config and generation config. The model config and generation config were aligned accordingly, being updated with the tokenizer's values. Updated tokens: {'eos_token_id': 128001, 'bos_token_id': 128000, 'pad_token_id': 128001}.\n"
|
| 376 |
+
]
|
| 377 |
+
},
|
| 378 |
+
{
|
| 379 |
+
"data": {
|
| 380 |
+
"text/html": [
|
| 381 |
+
"\n",
|
| 382 |
+
" <div>\n",
|
| 383 |
+
" \n",
|
| 384 |
+
" <progress value='1058' max='1058' style='width:300px; height:20px; vertical-align: middle;'></progress>\n",
|
| 385 |
+
" [1058/1058 6:18:56, Epoch 2/2]\n",
|
| 386 |
+
" </div>\n",
|
| 387 |
+
" <table border=\"1\" class=\"dataframe\">\n",
|
| 388 |
+
" <thead>\n",
|
| 389 |
+
" <tr style=\"text-align: left;\">\n",
|
| 390 |
+
" <th>Step</th>\n",
|
| 391 |
+
" <th>Training Loss</th>\n",
|
| 392 |
+
" </tr>\n",
|
| 393 |
+
" </thead>\n",
|
| 394 |
+
" <tbody>\n",
|
| 395 |
+
" <tr>\n",
|
| 396 |
+
" <td>10</td>\n",
|
| 397 |
+
" <td>2.525100</td>\n",
|
| 398 |
+
" </tr>\n",
|
| 399 |
+
" <tr>\n",
|
| 400 |
+
" <td>20</td>\n",
|
| 401 |
+
" <td>1.933300</td>\n",
|
| 402 |
+
" </tr>\n",
|
| 403 |
+
" <tr>\n",
|
| 404 |
+
" <td>30</td>\n",
|
| 405 |
+
" <td>1.800700</td>\n",
|
| 406 |
+
" </tr>\n",
|
| 407 |
+
" <tr>\n",
|
| 408 |
+
" <td>40</td>\n",
|
| 409 |
+
" <td>1.775700</td>\n",
|
| 410 |
+
" </tr>\n",
|
| 411 |
+
" <tr>\n",
|
| 412 |
+
" <td>50</td>\n",
|
| 413 |
+
" <td>1.773700</td>\n",
|
| 414 |
+
" </tr>\n",
|
| 415 |
+
" <tr>\n",
|
| 416 |
+
" <td>60</td>\n",
|
| 417 |
+
" <td>1.719500</td>\n",
|
| 418 |
+
" </tr>\n",
|
| 419 |
+
" <tr>\n",
|
| 420 |
+
" <td>70</td>\n",
|
| 421 |
+
" <td>1.715900</td>\n",
|
| 422 |
+
" </tr>\n",
|
| 423 |
+
" <tr>\n",
|
| 424 |
+
" <td>80</td>\n",
|
| 425 |
+
" <td>1.681300</td>\n",
|
| 426 |
+
" </tr>\n",
|
| 427 |
+
" <tr>\n",
|
| 428 |
+
" <td>90</td>\n",
|
| 429 |
+
" <td>1.671000</td>\n",
|
| 430 |
+
" </tr>\n",
|
| 431 |
+
" <tr>\n",
|
| 432 |
+
" <td>100</td>\n",
|
| 433 |
+
" <td>1.690100</td>\n",
|
| 434 |
+
" </tr>\n",
|
| 435 |
+
" <tr>\n",
|
| 436 |
+
" <td>110</td>\n",
|
| 437 |
+
" <td>1.712000</td>\n",
|
| 438 |
+
" </tr>\n",
|
| 439 |
+
" <tr>\n",
|
| 440 |
+
" <td>120</td>\n",
|
| 441 |
+
" <td>1.609300</td>\n",
|
| 442 |
+
" </tr>\n",
|
| 443 |
+
" <tr>\n",
|
| 444 |
+
" <td>130</td>\n",
|
| 445 |
+
" <td>1.614200</td>\n",
|
| 446 |
+
" </tr>\n",
|
| 447 |
+
" <tr>\n",
|
| 448 |
+
" <td>140</td>\n",
|
| 449 |
+
" <td>1.663300</td>\n",
|
| 450 |
+
" </tr>\n",
|
| 451 |
+
" <tr>\n",
|
| 452 |
+
" <td>150</td>\n",
|
| 453 |
+
" <td>1.624000</td>\n",
|
| 454 |
+
" </tr>\n",
|
| 455 |
+
" <tr>\n",
|
| 456 |
+
" <td>160</td>\n",
|
| 457 |
+
" <td>1.586000</td>\n",
|
| 458 |
+
" </tr>\n",
|
| 459 |
+
" <tr>\n",
|
| 460 |
+
" <td>170</td>\n",
|
| 461 |
+
" <td>1.614200</td>\n",
|
| 462 |
+
" </tr>\n",
|
| 463 |
+
" <tr>\n",
|
| 464 |
+
" <td>180</td>\n",
|
| 465 |
+
" <td>1.570300</td>\n",
|
| 466 |
+
" </tr>\n",
|
| 467 |
+
" <tr>\n",
|
| 468 |
+
" <td>190</td>\n",
|
| 469 |
+
" <td>1.609900</td>\n",
|
| 470 |
+
" </tr>\n",
|
| 471 |
+
" <tr>\n",
|
| 472 |
+
" <td>200</td>\n",
|
| 473 |
+
" <td>1.586800</td>\n",
|
| 474 |
+
" </tr>\n",
|
| 475 |
+
" <tr>\n",
|
| 476 |
+
" <td>210</td>\n",
|
| 477 |
+
" <td>1.523200</td>\n",
|
| 478 |
+
" </tr>\n",
|
| 479 |
+
" <tr>\n",
|
| 480 |
+
" <td>220</td>\n",
|
| 481 |
+
" <td>1.595500</td>\n",
|
| 482 |
+
" </tr>\n",
|
| 483 |
+
" <tr>\n",
|
| 484 |
+
" <td>230</td>\n",
|
| 485 |
+
" <td>1.604200</td>\n",
|
| 486 |
+
" </tr>\n",
|
| 487 |
+
" <tr>\n",
|
| 488 |
+
" <td>240</td>\n",
|
| 489 |
+
" <td>1.518400</td>\n",
|
| 490 |
+
" </tr>\n",
|
| 491 |
+
" <tr>\n",
|
| 492 |
+
" <td>250</td>\n",
|
| 493 |
+
" <td>1.551400</td>\n",
|
| 494 |
+
" </tr>\n",
|
| 495 |
+
" <tr>\n",
|
| 496 |
+
" <td>260</td>\n",
|
| 497 |
+
" <td>1.521200</td>\n",
|
| 498 |
+
" </tr>\n",
|
| 499 |
+
" <tr>\n",
|
| 500 |
+
" <td>270</td>\n",
|
| 501 |
+
" <td>1.585300</td>\n",
|
| 502 |
+
" </tr>\n",
|
| 503 |
+
" <tr>\n",
|
| 504 |
+
" <td>280</td>\n",
|
| 505 |
+
" <td>1.575400</td>\n",
|
| 506 |
+
" </tr>\n",
|
| 507 |
+
" <tr>\n",
|
| 508 |
+
" <td>290</td>\n",
|
| 509 |
+
" <td>1.507000</td>\n",
|
| 510 |
+
" </tr>\n",
|
| 511 |
+
" <tr>\n",
|
| 512 |
+
" <td>300</td>\n",
|
| 513 |
+
" <td>1.539600</td>\n",
|
| 514 |
+
" </tr>\n",
|
| 515 |
+
" <tr>\n",
|
| 516 |
+
" <td>310</td>\n",
|
| 517 |
+
" <td>1.489900</td>\n",
|
| 518 |
+
" </tr>\n",
|
| 519 |
+
" <tr>\n",
|
| 520 |
+
" <td>320</td>\n",
|
| 521 |
+
" <td>1.459300</td>\n",
|
| 522 |
+
" </tr>\n",
|
| 523 |
+
" <tr>\n",
|
| 524 |
+
" <td>330</td>\n",
|
| 525 |
+
" <td>1.555300</td>\n",
|
| 526 |
+
" </tr>\n",
|
| 527 |
+
" <tr>\n",
|
| 528 |
+
" <td>340</td>\n",
|
| 529 |
+
" <td>1.520400</td>\n",
|
| 530 |
+
" </tr>\n",
|
| 531 |
+
" <tr>\n",
|
| 532 |
+
" <td>350</td>\n",
|
| 533 |
+
" <td>1.549200</td>\n",
|
| 534 |
+
" </tr>\n",
|
| 535 |
+
" <tr>\n",
|
| 536 |
+
" <td>360</td>\n",
|
| 537 |
+
" <td>1.530700</td>\n",
|
| 538 |
+
" </tr>\n",
|
| 539 |
+
" <tr>\n",
|
| 540 |
+
" <td>370</td>\n",
|
| 541 |
+
" <td>1.532300</td>\n",
|
| 542 |
+
" </tr>\n",
|
| 543 |
+
" <tr>\n",
|
| 544 |
+
" <td>380</td>\n",
|
| 545 |
+
" <td>1.479400</td>\n",
|
| 546 |
+
" </tr>\n",
|
| 547 |
+
" <tr>\n",
|
| 548 |
+
" <td>390</td>\n",
|
| 549 |
+
" <td>1.469400</td>\n",
|
| 550 |
+
" </tr>\n",
|
| 551 |
+
" <tr>\n",
|
| 552 |
+
" <td>400</td>\n",
|
| 553 |
+
" <td>1.470800</td>\n",
|
| 554 |
+
" </tr>\n",
|
| 555 |
+
" <tr>\n",
|
| 556 |
+
" <td>410</td>\n",
|
| 557 |
+
" <td>1.505100</td>\n",
|
| 558 |
+
" </tr>\n",
|
| 559 |
+
" <tr>\n",
|
| 560 |
+
" <td>420</td>\n",
|
| 561 |
+
" <td>1.472500</td>\n",
|
| 562 |
+
" </tr>\n",
|
| 563 |
+
" <tr>\n",
|
| 564 |
+
" <td>430</td>\n",
|
| 565 |
+
" <td>1.477300</td>\n",
|
| 566 |
+
" </tr>\n",
|
| 567 |
+
" <tr>\n",
|
| 568 |
+
" <td>440</td>\n",
|
| 569 |
+
" <td>1.467300</td>\n",
|
| 570 |
+
" </tr>\n",
|
| 571 |
+
" <tr>\n",
|
| 572 |
+
" <td>450</td>\n",
|
| 573 |
+
" <td>1.459700</td>\n",
|
| 574 |
+
" </tr>\n",
|
| 575 |
+
" <tr>\n",
|
| 576 |
+
" <td>460</td>\n",
|
| 577 |
+
" <td>1.484500</td>\n",
|
| 578 |
+
" </tr>\n",
|
| 579 |
+
" <tr>\n",
|
| 580 |
+
" <td>470</td>\n",
|
| 581 |
+
" <td>1.499100</td>\n",
|
| 582 |
+
" </tr>\n",
|
| 583 |
+
" <tr>\n",
|
| 584 |
+
" <td>480</td>\n",
|
| 585 |
+
" <td>1.459900</td>\n",
|
| 586 |
+
" </tr>\n",
|
| 587 |
+
" <tr>\n",
|
| 588 |
+
" <td>490</td>\n",
|
| 589 |
+
" <td>1.430800</td>\n",
|
| 590 |
+
" </tr>\n",
|
| 591 |
+
" <tr>\n",
|
| 592 |
+
" <td>500</td>\n",
|
| 593 |
+
" <td>1.484700</td>\n",
|
| 594 |
+
" </tr>\n",
|
| 595 |
+
" <tr>\n",
|
| 596 |
+
" <td>510</td>\n",
|
| 597 |
+
" <td>1.459500</td>\n",
|
| 598 |
+
" </tr>\n",
|
| 599 |
+
" <tr>\n",
|
| 600 |
+
" <td>520</td>\n",
|
| 601 |
+
" <td>1.437000</td>\n",
|
| 602 |
+
" </tr>\n",
|
| 603 |
+
" <tr>\n",
|
| 604 |
+
" <td>530</td>\n",
|
| 605 |
+
" <td>1.433800</td>\n",
|
| 606 |
+
" </tr>\n",
|
| 607 |
+
" <tr>\n",
|
| 608 |
+
" <td>540</td>\n",
|
| 609 |
+
" <td>1.363500</td>\n",
|
| 610 |
+
" </tr>\n",
|
| 611 |
+
" <tr>\n",
|
| 612 |
+
" <td>550</td>\n",
|
| 613 |
+
" <td>1.348800</td>\n",
|
| 614 |
+
" </tr>\n",
|
| 615 |
+
" <tr>\n",
|
| 616 |
+
" <td>560</td>\n",
|
| 617 |
+
" <td>1.360600</td>\n",
|
| 618 |
+
" </tr>\n",
|
| 619 |
+
" <tr>\n",
|
| 620 |
+
" <td>570</td>\n",
|
| 621 |
+
" <td>1.307000</td>\n",
|
| 622 |
+
" </tr>\n",
|
| 623 |
+
" <tr>\n",
|
| 624 |
+
" <td>580</td>\n",
|
| 625 |
+
" <td>1.350000</td>\n",
|
| 626 |
+
" </tr>\n",
|
| 627 |
+
" <tr>\n",
|
| 628 |
+
" <td>590</td>\n",
|
| 629 |
+
" <td>1.436000</td>\n",
|
| 630 |
+
" </tr>\n",
|
| 631 |
+
" <tr>\n",
|
| 632 |
+
" <td>600</td>\n",
|
| 633 |
+
" <td>1.402600</td>\n",
|
| 634 |
+
" </tr>\n",
|
| 635 |
+
" <tr>\n",
|
| 636 |
+
" <td>610</td>\n",
|
| 637 |
+
" <td>1.369600</td>\n",
|
| 638 |
+
" </tr>\n",
|
| 639 |
+
" <tr>\n",
|
| 640 |
+
" <td>620</td>\n",
|
| 641 |
+
" <td>1.421000</td>\n",
|
| 642 |
+
" </tr>\n",
|
| 643 |
+
" <tr>\n",
|
| 644 |
+
" <td>630</td>\n",
|
| 645 |
+
" <td>1.377700</td>\n",
|
| 646 |
+
" </tr>\n",
|
| 647 |
+
" <tr>\n",
|
| 648 |
+
" <td>640</td>\n",
|
| 649 |
+
" <td>1.365100</td>\n",
|
| 650 |
+
" </tr>\n",
|
| 651 |
+
" <tr>\n",
|
| 652 |
+
" <td>650</td>\n",
|
| 653 |
+
" <td>1.326400</td>\n",
|
| 654 |
+
" </tr>\n",
|
| 655 |
+
" <tr>\n",
|
| 656 |
+
" <td>660</td>\n",
|
| 657 |
+
" <td>1.414200</td>\n",
|
| 658 |
+
" </tr>\n",
|
| 659 |
+
" <tr>\n",
|
| 660 |
+
" <td>670</td>\n",
|
| 661 |
+
" <td>1.400100</td>\n",
|
| 662 |
+
" </tr>\n",
|
| 663 |
+
" <tr>\n",
|
| 664 |
+
" <td>680</td>\n",
|
| 665 |
+
" <td>1.330200</td>\n",
|
| 666 |
+
" </tr>\n",
|
| 667 |
+
" <tr>\n",
|
| 668 |
+
" <td>690</td>\n",
|
| 669 |
+
" <td>1.380400</td>\n",
|
| 670 |
+
" </tr>\n",
|
| 671 |
+
" <tr>\n",
|
| 672 |
+
" <td>700</td>\n",
|
| 673 |
+
" <td>1.357300</td>\n",
|
| 674 |
+
" </tr>\n",
|
| 675 |
+
" <tr>\n",
|
| 676 |
+
" <td>710</td>\n",
|
| 677 |
+
" <td>1.387900</td>\n",
|
| 678 |
+
" </tr>\n",
|
| 679 |
+
" <tr>\n",
|
| 680 |
+
" <td>720</td>\n",
|
| 681 |
+
" <td>1.368100</td>\n",
|
| 682 |
+
" </tr>\n",
|
| 683 |
+
" <tr>\n",
|
| 684 |
+
" <td>730</td>\n",
|
| 685 |
+
" <td>1.312700</td>\n",
|
| 686 |
+
" </tr>\n",
|
| 687 |
+
" <tr>\n",
|
| 688 |
+
" <td>740</td>\n",
|
| 689 |
+
" <td>1.354500</td>\n",
|
| 690 |
+
" </tr>\n",
|
| 691 |
+
" <tr>\n",
|
| 692 |
+
" <td>750</td>\n",
|
| 693 |
+
" <td>1.343500</td>\n",
|
| 694 |
+
" </tr>\n",
|
| 695 |
+
" <tr>\n",
|
| 696 |
+
" <td>760</td>\n",
|
| 697 |
+
" <td>1.371200</td>\n",
|
| 698 |
+
" </tr>\n",
|
| 699 |
+
" <tr>\n",
|
| 700 |
+
" <td>770</td>\n",
|
| 701 |
+
" <td>1.292800</td>\n",
|
| 702 |
+
" </tr>\n",
|
| 703 |
+
" <tr>\n",
|
| 704 |
+
" <td>780</td>\n",
|
| 705 |
+
" <td>1.356000</td>\n",
|
| 706 |
+
" </tr>\n",
|
| 707 |
+
" <tr>\n",
|
| 708 |
+
" <td>790</td>\n",
|
| 709 |
+
" <td>1.353400</td>\n",
|
| 710 |
+
" </tr>\n",
|
| 711 |
+
" <tr>\n",
|
| 712 |
+
" <td>800</td>\n",
|
| 713 |
+
" <td>1.406300</td>\n",
|
| 714 |
+
" </tr>\n",
|
| 715 |
+
" <tr>\n",
|
| 716 |
+
" <td>810</td>\n",
|
| 717 |
+
" <td>1.376100</td>\n",
|
| 718 |
+
" </tr>\n",
|
| 719 |
+
" <tr>\n",
|
| 720 |
+
" <td>820</td>\n",
|
| 721 |
+
" <td>1.297200</td>\n",
|
| 722 |
+
" </tr>\n",
|
| 723 |
+
" <tr>\n",
|
| 724 |
+
" <td>830</td>\n",
|
| 725 |
+
" <td>1.405000</td>\n",
|
| 726 |
+
" </tr>\n",
|
| 727 |
+
" <tr>\n",
|
| 728 |
+
" <td>840</td>\n",
|
| 729 |
+
" <td>1.373500</td>\n",
|
| 730 |
+
" </tr>\n",
|
| 731 |
+
" <tr>\n",
|
| 732 |
+
" <td>850</td>\n",
|
| 733 |
+
" <td>1.338300</td>\n",
|
| 734 |
+
" </tr>\n",
|
| 735 |
+
" <tr>\n",
|
| 736 |
+
" <td>860</td>\n",
|
| 737 |
+
" <td>1.368300</td>\n",
|
| 738 |
+
" </tr>\n",
|
| 739 |
+
" <tr>\n",
|
| 740 |
+
" <td>870</td>\n",
|
| 741 |
+
" <td>1.398800</td>\n",
|
| 742 |
+
" </tr>\n",
|
| 743 |
+
" <tr>\n",
|
| 744 |
+
" <td>880</td>\n",
|
| 745 |
+
" <td>1.337500</td>\n",
|
| 746 |
+
" </tr>\n",
|
| 747 |
+
" <tr>\n",
|
| 748 |
+
" <td>890</td>\n",
|
| 749 |
+
" <td>1.367700</td>\n",
|
| 750 |
+
" </tr>\n",
|
| 751 |
+
" <tr>\n",
|
| 752 |
+
" <td>900</td>\n",
|
| 753 |
+
" <td>1.312600</td>\n",
|
| 754 |
+
" </tr>\n",
|
| 755 |
+
" <tr>\n",
|
| 756 |
+
" <td>910</td>\n",
|
| 757 |
+
" <td>1.353600</td>\n",
|
| 758 |
+
" </tr>\n",
|
| 759 |
+
" <tr>\n",
|
| 760 |
+
" <td>920</td>\n",
|
| 761 |
+
" <td>1.317400</td>\n",
|
| 762 |
+
" </tr>\n",
|
| 763 |
+
" <tr>\n",
|
| 764 |
+
" <td>930</td>\n",
|
| 765 |
+
" <td>1.348200</td>\n",
|
| 766 |
+
" </tr>\n",
|
| 767 |
+
" <tr>\n",
|
| 768 |
+
" <td>940</td>\n",
|
| 769 |
+
" <td>1.361800</td>\n",
|
| 770 |
+
" </tr>\n",
|
| 771 |
+
" <tr>\n",
|
| 772 |
+
" <td>950</td>\n",
|
| 773 |
+
" <td>1.290600</td>\n",
|
| 774 |
+
" </tr>\n",
|
| 775 |
+
" <tr>\n",
|
| 776 |
+
" <td>960</td>\n",
|
| 777 |
+
" <td>1.384400</td>\n",
|
| 778 |
+
" </tr>\n",
|
| 779 |
+
" <tr>\n",
|
| 780 |
+
" <td>970</td>\n",
|
| 781 |
+
" <td>1.290200</td>\n",
|
| 782 |
+
" </tr>\n",
|
| 783 |
+
" <tr>\n",
|
| 784 |
+
" <td>980</td>\n",
|
| 785 |
+
" <td>1.348800</td>\n",
|
| 786 |
+
" </tr>\n",
|
| 787 |
+
" <tr>\n",
|
| 788 |
+
" <td>990</td>\n",
|
| 789 |
+
" <td>1.330100</td>\n",
|
| 790 |
+
" </tr>\n",
|
| 791 |
+
" <tr>\n",
|
| 792 |
+
" <td>1000</td>\n",
|
| 793 |
+
" <td>1.384700</td>\n",
|
| 794 |
+
" </tr>\n",
|
| 795 |
+
" <tr>\n",
|
| 796 |
+
" <td>1010</td>\n",
|
| 797 |
+
" <td>1.368200</td>\n",
|
| 798 |
+
" </tr>\n",
|
| 799 |
+
" <tr>\n",
|
| 800 |
+
" <td>1020</td>\n",
|
| 801 |
+
" <td>1.347500</td>\n",
|
| 802 |
+
" </tr>\n",
|
| 803 |
+
" <tr>\n",
|
| 804 |
+
" <td>1030</td>\n",
|
| 805 |
+
" <td>1.332400</td>\n",
|
| 806 |
+
" </tr>\n",
|
| 807 |
+
" <tr>\n",
|
| 808 |
+
" <td>1040</td>\n",
|
| 809 |
+
" <td>1.315800</td>\n",
|
| 810 |
+
" </tr>\n",
|
| 811 |
+
" <tr>\n",
|
| 812 |
+
" <td>1050</td>\n",
|
| 813 |
+
" <td>1.348300</td>\n",
|
| 814 |
+
" </tr>\n",
|
| 815 |
+
" </tbody>\n",
|
| 816 |
+
"</table><p>"
|
| 817 |
+
],
|
| 818 |
+
"text/plain": [
|
| 819 |
+
"<IPython.core.display.HTML object>"
|
| 820 |
+
]
|
| 821 |
+
},
|
| 822 |
+
"metadata": {},
|
| 823 |
+
"output_type": "display_data"
|
| 824 |
+
},
|
| 825 |
+
{
|
| 826 |
+
"data": {
|
| 827 |
+
"text/plain": [
|
| 828 |
+
"TrainOutput(global_step=1058, training_loss=1.4723652360119306, metrics={'train_runtime': 22771.7007, 'train_samples_per_second': 0.743, 'train_steps_per_second': 0.046, 'total_flos': 2.071327636721664e+17, 'train_loss': 1.4723652360119306, 'entropy': 1.4031210680802664, 'num_tokens': 4591590.0, 'mean_token_accuracy': 0.6802353163560232, 'epoch': 2.0})"
|
| 829 |
+
]
|
| 830 |
+
},
|
| 831 |
+
"execution_count": 15,
|
| 832 |
+
"metadata": {},
|
| 833 |
+
"output_type": "execute_result"
|
| 834 |
+
}
|
| 835 |
+
],
|
| 836 |
+
"source": [
|
| 837 |
+
"# 학습 시작\n",
|
| 838 |
+
"trainer.train()"
|
| 839 |
+
]
|
| 840 |
+
},
|
| 841 |
+
{
|
| 842 |
+
"cell_type": "code",
|
| 843 |
+
"execution_count": 16,
|
| 844 |
+
"id": "94871386",
|
| 845 |
+
"metadata": {},
|
| 846 |
+
"outputs": [
|
| 847 |
+
{
|
| 848 |
+
"data": {
|
| 849 |
+
"text/plain": [
|
| 850 |
+
"('./qlora_adapter/tokenizer_config.json',\n",
|
| 851 |
+
" './qlora_adapter/special_tokens_map.json',\n",
|
| 852 |
+
" './qlora_adapter/chat_template.jinja',\n",
|
| 853 |
+
" './qlora_adapter/tokenizer.json')"
|
| 854 |
+
]
|
| 855 |
+
},
|
| 856 |
+
"execution_count": 16,
|
| 857 |
+
"metadata": {},
|
| 858 |
+
"output_type": "execute_result"
|
| 859 |
+
}
|
| 860 |
+
],
|
| 861 |
+
"source": [
|
| 862 |
+
"# 모델 저장 (LoRA 가중치만)\n",
|
| 863 |
+
"trainer.model.save_pretrained(\"./qlora_adapter\")\n",
|
| 864 |
+
"tokenizer.save_pretrained(\"./qlora_adapter\")"
|
| 865 |
+
]
|
| 866 |
+
},
|
| 867 |
+
{
|
| 868 |
+
"cell_type": "code",
|
| 869 |
+
"execution_count": null,
|
| 870 |
+
"id": "1fbac3b6",
|
| 871 |
+
"metadata": {},
|
| 872 |
+
"outputs": [],
|
| 873 |
+
"source": [
|
| 874 |
+
"# dataset = dataset.shuffle(seed=42).select(range(1000))"
|
| 875 |
+
]
|
| 876 |
+
}
|
| 877 |
+
],
|
| 878 |
+
"metadata": {
|
| 879 |
+
"kernelspec": {
|
| 880 |
+
"display_name": "Python (myenv)",
|
| 881 |
+
"language": "python",
|
| 882 |
+
"name": "myenv"
|
| 883 |
+
}
|
| 884 |
+
},
|
| 885 |
+
"nbformat": 4,
|
| 886 |
+
"nbformat_minor": 5
|
| 887 |
+
}
|
notebooks/train.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import wandb
|
| 3 |
+
from unsloth import FastLanguageModel, is_bfloat16_supported
|
| 4 |
+
from trl import SFTTrainer
|
| 5 |
+
from transformers import TrainingArguments
|
| 6 |
+
from transformers.trainer_utils import get_last_checkpoint
|
| 7 |
+
from datasets import load_dataset
|
| 8 |
+
import os
|
| 9 |
+
import shutil
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
# ==========================================
|
| 13 |
+
# [FINAL SCRIPT] Running on Terminal
|
| 14 |
+
# ==========================================
|
| 15 |
+
|
| 16 |
+
print(">>> [System] 스크립트 시작. 라이브러리 로딩 완료.")
|
| 17 |
+
|
| 18 |
+
# 1. WandB 찌꺼기 폴더 강제 삭제
|
| 19 |
+
if os.path.exists("wandb"):
|
| 20 |
+
try:
|
| 21 |
+
shutil.rmtree("wandb")
|
| 22 |
+
print(">>> [System] 기존 WandB 캐시 삭제 완료")
|
| 23 |
+
except:
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
# 2. 복구 모드 점검
|
| 27 |
+
output_dir = "outputs_final"
|
| 28 |
+
last_checkpoint = None
|
| 29 |
+
|
| 30 |
+
if os.path.isdir(output_dir):
|
| 31 |
+
last_checkpoint = get_last_checkpoint(output_dir)
|
| 32 |
+
if last_checkpoint:
|
| 33 |
+
print(f">>> [Resume] 이전 학습 기록 발견: {last_checkpoint}")
|
| 34 |
+
else:
|
| 35 |
+
print(">>> [Start] 새로운 학습 시작")
|
| 36 |
+
|
| 37 |
+
# 3. WandB 설정
|
| 38 |
+
try:
|
| 39 |
+
wandb.finish()
|
| 40 |
+
except:
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
unique_id = f"run_{int(time.time())}"
|
| 44 |
+
|
| 45 |
+
wandb.init(
|
| 46 |
+
entity="hambur1203-project",
|
| 47 |
+
project="BiddinMate_Production_SFT",
|
| 48 |
+
name="Llama3-8B-Final-3Epochs",
|
| 49 |
+
id=unique_id,
|
| 50 |
+
resume="allow"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# 4. 모델 로드 (0번 GPU 강제 지정)
|
| 54 |
+
print(">>> [Model] Llama-3 로드 중...")
|
| 55 |
+
max_seq_length = 2048
|
| 56 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 57 |
+
model_name = "beomi/Llama-3-Open-Ko-8B",
|
| 58 |
+
max_seq_length = max_seq_length,
|
| 59 |
+
dtype = None,
|
| 60 |
+
load_in_4bit = True,
|
| 61 |
+
device_map = {"": 0} # 핵심: GPU 0번 고정
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# 5. LoRA 설정
|
| 65 |
+
model = FastLanguageModel.get_peft_model(
|
| 66 |
+
model,
|
| 67 |
+
r = 16,
|
| 68 |
+
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
|
| 69 |
+
"gate_proj", "up_proj", "down_proj",],
|
| 70 |
+
lora_alpha = 16,
|
| 71 |
+
lora_dropout = 0,
|
| 72 |
+
bias = "none",
|
| 73 |
+
use_gradient_checkpointing = "unsloth",
|
| 74 |
+
random_state = 3407,
|
| 75 |
+
use_rslora = False,
|
| 76 |
+
loftq_config = None,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# 6. 데이터셋 로드
|
| 80 |
+
print(">>> [Data] 데이터셋 로드 중...")
|
| 81 |
+
dataset = load_dataset("json", data_files="sft_train_llama.jsonl", split="train")
|
| 82 |
+
|
| 83 |
+
# 7. 학습 설정
|
| 84 |
+
trainer = SFTTrainer(
|
| 85 |
+
model = model,
|
| 86 |
+
tokenizer = tokenizer,
|
| 87 |
+
train_dataset = dataset,
|
| 88 |
+
dataset_text_field = "text",
|
| 89 |
+
max_seq_length = max_seq_length,
|
| 90 |
+
dataset_num_proc = 2,
|
| 91 |
+
packing = False,
|
| 92 |
+
args = TrainingArguments(
|
| 93 |
+
per_device_train_batch_size = 2,
|
| 94 |
+
gradient_accumulation_steps = 4,
|
| 95 |
+
num_train_epochs = 3,
|
| 96 |
+
warmup_steps = 100,
|
| 97 |
+
learning_rate = 2e-4,
|
| 98 |
+
report_to = "wandb",
|
| 99 |
+
run_name = "Llama3-8B-Final-3Epochs",
|
| 100 |
+
logging_steps = 1,
|
| 101 |
+
save_strategy = "epoch",
|
| 102 |
+
output_dir = output_dir,
|
| 103 |
+
fp16 = not is_bfloat16_supported(),
|
| 104 |
+
bf16 = is_bfloat16_supported(),
|
| 105 |
+
optim = "adamw_8bit",
|
| 106 |
+
weight_decay = 0.01,
|
| 107 |
+
seed = 3407,
|
| 108 |
+
),
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# 8. 실행
|
| 112 |
+
print(">>> [Train] 학습 시작! (WandB를 확인하세요)")
|
| 113 |
+
if last_checkpoint:
|
| 114 |
+
trainer.train(resume_from_checkpoint=True)
|
| 115 |
+
else:
|
| 116 |
+
trainer.train()
|
project_setting.md
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## 🛠 사전 준비
|
| 2 |
+
|
| 3 |
+
### 필수 설치 항목
|
| 4 |
+
|
| 5 |
+
1. **Python 3.12.3**
|
| 6 |
+
2. **pyenv** (Python 버전 관리)
|
| 7 |
+
3. **Poetry** (의존성 관리)
|
| 8 |
+
|
| 9 |
+
### 설치 방법
|
| 10 |
+
|
| 11 |
+
#### 🪟 Windows
|
| 12 |
+
|
| 13 |
+
```powershell
|
| 14 |
+
# 1. pyenv-win 설치
|
| 15 |
+
Invoke-WebRequest -UseBasicParsing -Uri "https://raw.githubusercontent.com/pyenv-win/pyenv-win/master/pyenv-win/install-pyenv-win.ps1" -OutFile "./install-pyenv-win.ps1"
|
| 16 |
+
& "./install-pyenv-win.ps1"
|
| 17 |
+
|
| 18 |
+
# PowerShell 재시작 후
|
| 19 |
+
|
| 20 |
+
# 2. Python 3.12.3 설치
|
| 21 |
+
pyenv install 3.12.3
|
| 22 |
+
|
| 23 |
+
# 3. Poetry 설치
|
| 24 |
+
(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | python -
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
#### 🍎 Mac/Linux
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
# 1. pyenv 설치
|
| 31 |
+
curl https://pyenv.run | bash
|
| 32 |
+
|
| 33 |
+
# 환경 변수 설정 (zsh 기준)
|
| 34 |
+
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc
|
| 35 |
+
echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc
|
| 36 |
+
echo 'eval "$(pyenv init -)"' >> ~/.zshrc
|
| 37 |
+
source ~/.zshrc
|
| 38 |
+
|
| 39 |
+
# 2. Python 3.12.3 설치
|
| 40 |
+
pyenv install 3.12.3
|
| 41 |
+
|
| 42 |
+
# 3. Poetry 설치
|
| 43 |
+
curl -sSL https://install.python-poetry.org | python3 -
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## 🚀 환경 설정
|
| 49 |
+
|
| 50 |
+
### 1. 저장소 클론
|
| 51 |
+
|
| 52 |
+
#### 🪟 Windows
|
| 53 |
+
```powershell
|
| 54 |
+
git clone
|
| 55 |
+
cd Codeit-AI-1team-LLM-project
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
#### 🍎 Mac/Linux
|
| 59 |
+
```bash
|
| 60 |
+
git clone
|
| 61 |
+
cd Codeit-AI-1team-LLM-project
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### 2. Python 버전 설정
|
| 65 |
+
|
| 66 |
+
프로젝트 폴더에 `.python-version` 파일이 있으면 자동으로 Python 3.12.3을 사용합니다.
|
| 67 |
+
|
| 68 |
+
#### 🪟 Windows
|
| 69 |
+
```powershell
|
| 70 |
+
# 확인
|
| 71 |
+
python --version
|
| 72 |
+
# Python 3.12.3이 아니면:
|
| 73 |
+
pyenv local 3.12.3
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
#### 🍎 Mac/Linux
|
| 77 |
+
```bash
|
| 78 |
+
# 확인
|
| 79 |
+
python --version
|
| 80 |
+
# Python 3.12.3이 아니면:
|
| 81 |
+
pyenv local 3.12.3
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
### 3. Poetry 설정
|
| 85 |
+
|
| 86 |
+
#### 🪟 Windows
|
| 87 |
+
```powershell
|
| 88 |
+
# 가상환경을 프로젝트 내부에 생성
|
| 89 |
+
python -m poetry config virtualenvs.in-project true
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
#### 🍎 Mac/Linux
|
| 93 |
+
```bash
|
| 94 |
+
poetry config virtualenvs.in-project true
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## 📦 의존성 설치
|
| 100 |
+
|
| 101 |
+
`poetry.lock` 파일을 기준으로 정확히 동일한 버전의 패키지를 설치합니다.
|
| 102 |
+
|
| 103 |
+
#### 🪟 Windows
|
| 104 |
+
```powershell
|
| 105 |
+
# Python 버전 지정
|
| 106 |
+
python -m poetry env use 3.12.3
|
| 107 |
+
|
| 108 |
+
# 의존성 설치
|
| 109 |
+
python -m poetry install
|
| 110 |
+
|
| 111 |
+
# 가상환경 활성화
|
| 112 |
+
python -m poetry shell
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
#### 🍎 Mac/Linux
|
| 116 |
+
```bash
|
| 117 |
+
# Python 버전 지정
|
| 118 |
+
poetry env use 3.12.3
|
| 119 |
+
|
| 120 |
+
# 의존성 설치
|
| 121 |
+
poetry install
|
| 122 |
+
|
| 123 |
+
# 가상환경 활성화
|
| 124 |
+
poetry shell
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
**설치 완료 확인:**
|
| 128 |
+
|
| 129 |
+
프롬프트 앞에 `(.venv)`가 붙으면 성공! ✅
|
| 130 |
+
|
| 131 |
+
```
|
| 132 |
+
(.venv) PS C:\Codeit-AI-1team-LLM-project> # Windows
|
| 133 |
+
(codeit-ai-1team-llm-project-py3.12) user@computer:~/project$ # Mac/Linux
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## 🎯 프로젝트 실행
|
| 139 |
+
|
| 140 |
+
### 기본 실행
|
| 141 |
+
|
| 142 |
+
#### 🪟 Windows
|
| 143 |
+
```powershell
|
| 144 |
+
# 가상환경이 활성화된 상태에서
|
| 145 |
+
python main.py
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
#### 🍎 Mac/Linux
|
| 149 |
+
```bash
|
| 150 |
+
# 가상환경이 활성화된 상태에서
|
| 151 |
+
python main.py
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
### 가상환경 나가기
|
| 155 |
+
|
| 156 |
+
#### 🪟 Windows & Mac/Linux
|
| 157 |
+
```bash
|
| 158 |
+
exit
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## 👥 개발 가이드
|
| 164 |
+
|
| 165 |
+
### 일상적인 작업 흐름
|
| 166 |
+
|
| 167 |
+
#### 🪟 Windows
|
| 168 |
+
```powershell
|
| 169 |
+
# 1. 프로젝트 폴더로 이동
|
| 170 |
+
cd C:\Codeit-AI-1team-LLM-project
|
| 171 |
+
|
| 172 |
+
# 2. 최신 코드 받기
|
| 173 |
+
git pull
|
| 174 |
+
|
| 175 |
+
# 3. 의존성 업데이트 (팀원이 패키지 추가한 경우)
|
| 176 |
+
python -m poetry install
|
| 177 |
+
|
| 178 |
+
# 4. 가상환경 활성화
|
| 179 |
+
python -m poetry shell
|
| 180 |
+
|
| 181 |
+
# 5. 개발 작업...
|
| 182 |
+
|
| 183 |
+
# 6. 작업 종료
|
| 184 |
+
exit
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
#### 🍎 Mac/Linux
|
| 188 |
+
```bash
|
| 189 |
+
# 1. 프로젝트 폴더로 이동
|
| 190 |
+
cd ~/Codeit-AI-1team-LLM-project
|
| 191 |
+
|
| 192 |
+
# 2. 최신 코드 받기
|
| 193 |
+
git pull
|
| 194 |
+
|
| 195 |
+
# 3. 의존성 업데이트 (팀원이 패키지 추가한 경우)
|
| 196 |
+
poetry install
|
| 197 |
+
|
| 198 |
+
# 4. 가상환경 활성화
|
| 199 |
+
poetry shell
|
| 200 |
+
|
| 201 |
+
# 5. 개발 작업...
|
| 202 |
+
|
| 203 |
+
# 6. 작업 종료
|
| 204 |
+
exit
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
### 새 패키지 추가
|
| 208 |
+
|
| 209 |
+
#### 🪟 Windows
|
| 210 |
+
```powershell
|
| 211 |
+
# 패키지 추가
|
| 212 |
+
python -m poetry add
|
| 213 |
+
|
| 214 |
+
# 예: requests 추가
|
| 215 |
+
python -m poetry add requests
|
| 216 |
+
|
| 217 |
+
# 개발 도구 추가
|
| 218 |
+
python -m poetry add --group dev pytest
|
| 219 |
+
|
| 220 |
+
# Git 커밋
|
| 221 |
+
git add pyproject.toml poetry.lock
|
| 222 |
+
git commit -m "Add "
|
| 223 |
+
git push
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
#### 🍎 Mac/Linux
|
| 227 |
+
```bash
|
| 228 |
+
# 패키지 추가
|
| 229 |
+
poetry add
|
| 230 |
+
|
| 231 |
+
# 예: requests 추가
|
| 232 |
+
poetry add requests
|
| 233 |
+
|
| 234 |
+
# 개발 도구 추가
|
| 235 |
+
poetry add --group dev pytest
|
| 236 |
+
|
| 237 |
+
# Git 커밋
|
| 238 |
+
git add pyproject.toml poetry.lock
|
| 239 |
+
git commit -m "Add "
|
| 240 |
+
git push
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
## 🐛 문제 해결
|
| 246 |
+
|
| 247 |
+
### Python 버전이 3.12.3이 아니에요
|
| 248 |
+
|
| 249 |
+
#### 🪟 Windows
|
| 250 |
+
```powershell
|
| 251 |
+
pyenv local 3.12.3
|
| 252 |
+
python --version
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
#### 🍎 Mac/Linux
|
| 256 |
+
```bash
|
| 257 |
+
pyenv local 3.12.3
|
| 258 |
+
python --version
|
| 259 |
+
```
|
| 260 |
+
|
| 261 |
+
### Poetry 명령어를 찾을 수 없어요
|
| 262 |
+
|
| 263 |
+
#### 🪟 Windows
|
| 264 |
+
```powershell
|
| 265 |
+
# Poetry를 python 모듈로 실행
|
| 266 |
+
python -m poetry --version
|
| 267 |
+
|
| 268 |
+
# PATH 추가 (영구적)
|
| 269 |
+
[Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path", "User") + ";$env:APPDATA\Python\Scripts", "User")
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
#### 🍎 Mac/Linux
|
| 273 |
+
```bash
|
| 274 |
+
# PATH 추가
|
| 275 |
+
export PATH="$HOME/.local/bin:$PATH"
|
| 276 |
+
|
| 277 |
+
# 영구 적용
|
| 278 |
+
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
|
| 279 |
+
source ~/.zshrc
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
### Import 에��가 나요
|
| 283 |
+
|
| 284 |
+
```bash
|
| 285 |
+
# 가상환경이 활성화되어 있는지 확인
|
| 286 |
+
# 프롬프트에 (.venv)가 있어야 함
|
| 287 |
+
|
| 288 |
+
# 없다면 다시 활성화
|
| 289 |
+
poetry shell # Mac/Linux
|
| 290 |
+
python -m poetry shell # Windows
|
| 291 |
+
|
| 292 |
+
# 의존성 재설치
|
| 293 |
+
poetry install # Mac/Linux
|
| 294 |
+
python -m poetry install # Windows
|
| 295 |
+
```
|
pyproject.toml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "codeit-ai-1team-llm-project"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "코드잇 중급 프로젝트 RAG 문서 요약 챗봇"
|
| 5 |
+
authors = [
|
| 6 |
+
{name = "dong1203", email = "hambur1203@gmail.com"}
|
| 7 |
+
]
|
| 8 |
+
readme = "README.md"
|
| 9 |
+
requires-python = ">=3.12.3"
|
| 10 |
+
|
| 11 |
+
[tool.poetry]
|
| 12 |
+
package-mode = false
|
| 13 |
+
|
| 14 |
+
[tool.poetry.dependencies]
|
| 15 |
+
python = "^3.12.3"
|
| 16 |
+
# LangChain 생태계
|
| 17 |
+
langchain = "^1.0.5"
|
| 18 |
+
langchain-core = "^1.0.4"
|
| 19 |
+
langchain-text-splitters = "^1.0.0"
|
| 20 |
+
langchain-openai = "^1.0.2"
|
| 21 |
+
|
| 22 |
+
# 문서 처리
|
| 23 |
+
pypdf = "^6.2.0"
|
| 24 |
+
olefile = "^0.47"
|
| 25 |
+
|
| 26 |
+
# 데이터 처리
|
| 27 |
+
pandas = "^2.3.3"
|
| 28 |
+
numpy = "^2.3.0"
|
| 29 |
+
|
| 30 |
+
# OpenAI API
|
| 31 |
+
openai = "^2.7.2"
|
| 32 |
+
|
| 33 |
+
# 벡터 DB
|
| 34 |
+
chromadb = "^1.0.0"
|
| 35 |
+
|
| 36 |
+
# 시각화
|
| 37 |
+
streamlit = "^1.40.0"
|
| 38 |
+
plotly = "^6.0.0"
|
| 39 |
+
|
| 40 |
+
# 차원 축소
|
| 41 |
+
scikit-learn = "^1.5.0"
|
| 42 |
+
|
| 43 |
+
# 유틸리티
|
| 44 |
+
tqdm = "^4.67.1"
|
| 45 |
+
python-dotenv = "^1.0.0"
|
| 46 |
+
langchain-chroma = "^1.0.0"
|
| 47 |
+
openpyxl = "^3.1.5"
|
| 48 |
+
wandb = "^0.23.0"
|
| 49 |
+
ragas = "^0.3.9"
|
| 50 |
+
langsmith = "^0.4.43"
|
| 51 |
+
datasets = "^4.4.1"
|
| 52 |
+
langchain-community = "^0.4.1"
|
| 53 |
+
rapidfuzz = "^3.14.3"
|
| 54 |
+
matplotlib = "^3.10.7"
|
| 55 |
+
rank-bm25 = "^0.2.2"
|
| 56 |
+
sentence-transformers = "^5.1.2"
|
| 57 |
+
flagembedding = "^1.3.5"
|
| 58 |
+
llama-cpp-python = "^0.3.16"
|
| 59 |
+
huggingface-hub = ">=0.20.0"
|
| 60 |
+
|
| 61 |
+
[tool.poetry.group.dev.dependencies]
|
| 62 |
+
# 개발 도구 (선택사항)
|
| 63 |
+
pytest = "^8.0.0"
|
| 64 |
+
black = "^24.0.0"
|
| 65 |
+
|
| 66 |
+
[build-system]
|
| 67 |
+
requires = ["poetry-core>=2.0.0"]
|
| 68 |
+
build-backend = "poetry.core.masonry.api"
|
requirements.txt
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===== LangChain 생태계 =====
|
| 2 |
+
langchain>=0.1.0,<0.4.0
|
| 3 |
+
langchain-core>=0.1.0,<0.4.0
|
| 4 |
+
langchain-text-splitters>=0.0.1,<0.4.0
|
| 5 |
+
langchain-openai>=0.0.5,<0.4.0
|
| 6 |
+
langchain-chroma>=0.1.0,<0.4.0
|
| 7 |
+
langchain-community>=0.0.20,<0.4.0
|
| 8 |
+
|
| 9 |
+
# ===== OpenAI API =====
|
| 10 |
+
openai>=1.0.0,<2.0.0
|
| 11 |
+
|
| 12 |
+
# ===== 벡터 DB =====
|
| 13 |
+
chromadb>=0.4.0,<0.6.0
|
| 14 |
+
|
| 15 |
+
# ===== 문서 처리 =====
|
| 16 |
+
pypdf>=3.0.0,<5.0.0
|
| 17 |
+
olefile>=0.47
|
| 18 |
+
|
| 19 |
+
# ===== 데이터 처리 =====
|
| 20 |
+
pandas>=2.0.0,<3.0.0
|
| 21 |
+
numpy>=1.24.0,<2.0.0
|
| 22 |
+
openpyxl>=3.0.0,<4.0.0
|
| 23 |
+
|
| 24 |
+
# ===== 검색 & 임베딩 =====
|
| 25 |
+
rank-bm25>=0.2.2
|
| 26 |
+
sentence-transformers>=2.2.0,<4.0.0
|
| 27 |
+
FlagEmbedding>=1.2.0,<2.0.0
|
| 28 |
+
rapidfuzz>=3.0.0,<4.0.0
|
| 29 |
+
|
| 30 |
+
# ===== GGUF 모델 지원 (CPU 버전) =====
|
| 31 |
+
llama-cpp-python>=0.2.0,<0.4.0
|
| 32 |
+
|
| 33 |
+
# ===== Hugging Face Hub =====
|
| 34 |
+
huggingface-hub>=0.20.0
|
| 35 |
+
|
| 36 |
+
# ===== Streamlit UI =====
|
| 37 |
+
streamlit>=1.28.0,<2.0.0
|
| 38 |
+
plotly>=5.0.0,<6.0.0
|
| 39 |
+
|
| 40 |
+
# ===== 머신러닝 유틸 =====
|
| 41 |
+
scikit-learn>=1.3.0,<2.0.0
|
| 42 |
+
matplotlib>=3.7.0,<4.0.0
|
| 43 |
+
|
| 44 |
+
# ===== 유틸리티 =====
|
| 45 |
+
tqdm>=4.65.0
|
| 46 |
+
python-dotenv>=1.0.0
|
| 47 |
+
|
| 48 |
+
# ===== 평가 & 모니터링 (선택) =====
|
| 49 |
+
ragas>=0.1.0,<0.4.0
|
| 50 |
+
langsmith>=0.1.0,<0.5.0
|
| 51 |
+
wandb>=0.15.0,<0.17.0
|
| 52 |
+
datasets>=2.14.0,<3.0.0
|
src/__init__.py
ADDED
|
File without changes
|
src/embedding/rag_data_processing.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
from langchain_chroma import Chroma
|
| 3 |
+
from langchain_openai.embeddings import OpenAIEmbeddings
|
| 4 |
+
import os
|
| 5 |
+
from tqdm import tqdm
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
from src.utils.config import RAGConfig
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DataValidator:
|
| 12 |
+
"""데이터 검증 및 정제"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, config: RAGConfig):
|
| 15 |
+
self.config = config
|
| 16 |
+
|
| 17 |
+
def validate_and_clean(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 18 |
+
"""전체 검증 및 정제 파이프라인"""
|
| 19 |
+
df = self._check_required_columns(df)
|
| 20 |
+
df = self._remove_duplicates(df)
|
| 21 |
+
df = self._remove_nan(df)
|
| 22 |
+
df = self._filter_by_length(df)
|
| 23 |
+
df = self._clean_metadata(df)
|
| 24 |
+
|
| 25 |
+
return df
|
| 26 |
+
|
| 27 |
+
def _check_required_columns(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 28 |
+
"""필수 컬럼 확인"""
|
| 29 |
+
required = ['chunk_content', 'chunk_id']
|
| 30 |
+
missing = [col for col in required if col not in df.columns]
|
| 31 |
+
|
| 32 |
+
if missing:
|
| 33 |
+
raise ValueError(f"필수 컬럼 누락: {missing}")
|
| 34 |
+
|
| 35 |
+
return df
|
| 36 |
+
|
| 37 |
+
def _remove_duplicates(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 38 |
+
"""중복 ID 제거"""
|
| 39 |
+
return df.drop_duplicates(subset=['chunk_id'], keep='first')
|
| 40 |
+
|
| 41 |
+
def _remove_nan(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 42 |
+
"""NaN 값 제거"""
|
| 43 |
+
return df.dropna(subset=['chunk_content', 'chunk_id'])
|
| 44 |
+
|
| 45 |
+
def _filter_by_length(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 46 |
+
"""길이 기준 필터링"""
|
| 47 |
+
df['_temp_length'] = df['chunk_content'].str.len()
|
| 48 |
+
|
| 49 |
+
df = df[
|
| 50 |
+
(df['_temp_length'] >= self.config.MIN_CHUNK_LENGTH) &
|
| 51 |
+
(df['_temp_length'] <= self.config.MAX_CHUNK_LENGTH)
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
return df.drop(columns=['_temp_length'])
|
| 55 |
+
|
| 56 |
+
def _clean_metadata(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 57 |
+
"""메타데이터 정제"""
|
| 58 |
+
# NaN을 빈 문자열로 변환
|
| 59 |
+
df = df.fillna('')
|
| 60 |
+
|
| 61 |
+
# 메타데이터 컬럼의 타입을 문자열로 변환
|
| 62 |
+
metadata_cols = [col for col in df.columns
|
| 63 |
+
if col not in ['chunk_content', 'chunk_id']]
|
| 64 |
+
|
| 65 |
+
for col in metadata_cols:
|
| 66 |
+
df[col] = df[col].astype(str)
|
| 67 |
+
|
| 68 |
+
return df
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class ChromaDBBuilder:
|
| 72 |
+
"""ChromaDB 벡터 데이터베이스 구축"""
|
| 73 |
+
|
| 74 |
+
def __init__(self, config: RAGConfig):
|
| 75 |
+
self.config = config
|
| 76 |
+
self.vectorstore = None
|
| 77 |
+
self.embeddings = None
|
| 78 |
+
|
| 79 |
+
self._initialize_embeddings()
|
| 80 |
+
|
| 81 |
+
def _initialize_embeddings(self):
|
| 82 |
+
"""임베딩 모델 초기화"""
|
| 83 |
+
os.environ["OPENAI_API_KEY"] = self.config.OPENAI_API_KEY
|
| 84 |
+
|
| 85 |
+
self.embeddings = OpenAIEmbeddings(
|
| 86 |
+
model=self.config.EMBEDDING_MODEL_NAME
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def build_from_dataframe(self, df: pd.DataFrame):
|
| 90 |
+
"""DataFrame으로부터 벡터 DB 구축"""
|
| 91 |
+
documents, ids, metadatas = self._prepare_data(df)
|
| 92 |
+
self._validate_data_consistency(documents, ids, metadatas)
|
| 93 |
+
self._create_vectorstore()
|
| 94 |
+
self._add_documents_in_batches(documents, ids, metadatas)
|
| 95 |
+
|
| 96 |
+
return self.vectorstore
|
| 97 |
+
|
| 98 |
+
def _prepare_data(self, df: pd.DataFrame):
|
| 99 |
+
"""ChromaDB용 데이터 준비"""
|
| 100 |
+
documents = df['chunk_content'].tolist()
|
| 101 |
+
ids = df['chunk_id'].tolist()
|
| 102 |
+
|
| 103 |
+
# 메타데이터 추출
|
| 104 |
+
metadata_cols = [col for col in df.columns
|
| 105 |
+
if col not in ['chunk_content', 'chunk_id']]
|
| 106 |
+
|
| 107 |
+
metadatas = []
|
| 108 |
+
for _, row in df.iterrows():
|
| 109 |
+
metadata = {
|
| 110 |
+
col: row[col]
|
| 111 |
+
for col in metadata_cols
|
| 112 |
+
if row[col] and row[col] != 'nan' and row[col] != ''
|
| 113 |
+
}
|
| 114 |
+
metadatas.append(metadata)
|
| 115 |
+
|
| 116 |
+
return documents, ids, metadatas
|
| 117 |
+
|
| 118 |
+
def _validate_data_consistency(self, documents, ids, metadatas):
|
| 119 |
+
"""데이터 일관성 검증"""
|
| 120 |
+
if not (len(documents) == len(ids) == len(metadatas)):
|
| 121 |
+
raise ValueError("데이터 길이 불일치")
|
| 122 |
+
|
| 123 |
+
def _create_vectorstore(self):
|
| 124 |
+
"""빈 벡터스토어 생성"""
|
| 125 |
+
self.vectorstore = Chroma(
|
| 126 |
+
embedding_function=self.embeddings,
|
| 127 |
+
persist_directory=self.config.DB_DIRECTORY,
|
| 128 |
+
collection_name=self.config.COLLECTION_NAME
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def _add_documents_in_batches(self, documents, ids, metadatas):
|
| 132 |
+
"""배치 처리로 문서 추가"""
|
| 133 |
+
batch_size = self.config.BATCH_SIZE
|
| 134 |
+
total_batches = (len(documents) + batch_size - 1) // batch_size
|
| 135 |
+
|
| 136 |
+
for i in tqdm(range(0, len(documents), batch_size),
|
| 137 |
+
desc="임베딩 및 저장",
|
| 138 |
+
total=total_batches):
|
| 139 |
+
|
| 140 |
+
batch_docs = documents[i:i + batch_size]
|
| 141 |
+
batch_ids = ids[i:i + batch_size]
|
| 142 |
+
batch_metas = metadatas[i:i + batch_size]
|
| 143 |
+
|
| 144 |
+
self._add_batch_with_retry(batch_docs, batch_ids, batch_metas)
|
| 145 |
+
time.sleep(1)
|
| 146 |
+
|
| 147 |
+
def _add_batch_with_retry(self, docs, ids, metas):
|
| 148 |
+
"""배치 추가 (실패 시 재시도)"""
|
| 149 |
+
batch_tokens = sum(len(doc) for doc in docs) / 4
|
| 150 |
+
|
| 151 |
+
if batch_tokens > self.config.MAX_TOKENS_PER_BATCH:
|
| 152 |
+
smaller_size = len(docs) // 2
|
| 153 |
+
for j in range(0, len(docs), smaller_size):
|
| 154 |
+
self.vectorstore.add_texts(
|
| 155 |
+
texts=docs[j:j + smaller_size],
|
| 156 |
+
metadatas=metas[j:j + smaller_size],
|
| 157 |
+
ids=ids[j:j + smaller_size]
|
| 158 |
+
)
|
| 159 |
+
time.sleep(0.5)
|
| 160 |
+
else:
|
| 161 |
+
try:
|
| 162 |
+
self.vectorstore.add_texts(
|
| 163 |
+
texts=docs,
|
| 164 |
+
metadatas=metas,
|
| 165 |
+
ids=ids
|
| 166 |
+
)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
for j in range(0, len(docs), 10):
|
| 169 |
+
self.vectorstore.add_texts(
|
| 170 |
+
texts=docs[j:j + 10],
|
| 171 |
+
metadatas=metas[j:j + 10],
|
| 172 |
+
ids=ids[j:j + 10]
|
| 173 |
+
)
|
| 174 |
+
time.sleep(0.5)
|
| 175 |
+
|
| 176 |
+
def get_collection_count(self):
|
| 177 |
+
"""저장된 문서 수 반환"""
|
| 178 |
+
if self.vectorstore:
|
| 179 |
+
return self.vectorstore._collection.count()
|
| 180 |
+
return 0
|
| 181 |
+
|
| 182 |
+
def search(self, query: str, k: int = 5):
|
| 183 |
+
"""검색 수행"""
|
| 184 |
+
if not self.vectorstore:
|
| 185 |
+
raise ValueError("벡터스토어가 초기화되지 않았습니다")
|
| 186 |
+
|
| 187 |
+
return self.vectorstore.similarity_search_with_score(query, k=k)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class RAGVectorDBPipeline:
|
| 191 |
+
"""전체 RAG Vector DB 구축 파이프라인"""
|
| 192 |
+
|
| 193 |
+
def __init__(self, config: RAGConfig = None):
|
| 194 |
+
self.config = config or RAGConfig()
|
| 195 |
+
self.validator = DataValidator(self.config)
|
| 196 |
+
self.builder = ChromaDBBuilder(self.config)
|
| 197 |
+
|
| 198 |
+
def build(self):
|
| 199 |
+
"""전체 파이프라인 실행"""
|
| 200 |
+
# 데이터 로드
|
| 201 |
+
df = pd.read_csv(self.config.RAG_INPUT_PATH)
|
| 202 |
+
print(f"원본 데이터: {len(df)}개 청크")
|
| 203 |
+
|
| 204 |
+
# 데이터 검증 및 정제
|
| 205 |
+
df_cleaned = self.validator.validate_and_clean(df)
|
| 206 |
+
print(f"정제 후 데이터: {len(df_cleaned)}개 청크")
|
| 207 |
+
|
| 208 |
+
# 벡터 DB 구축
|
| 209 |
+
vectorstore = self.builder.build_from_dataframe(df_cleaned)
|
| 210 |
+
|
| 211 |
+
# 결과 확인
|
| 212 |
+
count = self.builder.get_collection_count()
|
| 213 |
+
print(f"✅ ChromaDB 저장 완료: {count}개 문서")
|
| 214 |
+
print(f"저장 위치: {self.config.DB_DIRECTORY}")
|
| 215 |
+
|
| 216 |
+
return vectorstore
|
| 217 |
+
|
| 218 |
+
def test_search(self, query: str = "학사 정보 시스템", k: int = 3):
|
| 219 |
+
"""검색 테스트"""
|
| 220 |
+
results = self.builder.search(query, k=k)
|
| 221 |
+
|
| 222 |
+
print(f"\n테스트 쿼리: '{query}'")
|
| 223 |
+
print(f"검색 결과: {len(results)}개\n")
|
| 224 |
+
|
| 225 |
+
for i, (doc, score) in enumerate(results, 1):
|
| 226 |
+
print(f"[{i}] 거리: {score:.4f}")
|
| 227 |
+
print(f"내용: {doc.page_content[:100]}...")
|
| 228 |
+
print(f"메타데이터: {doc.metadata}\n")
|
| 229 |
+
|
| 230 |
+
return results
|
src/evaluation/__init__.py
ADDED
|
File without changes
|
src/evaluation/experiment_tracker.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===== experiment_tracker.py =====
|
| 2 |
+
"""
|
| 3 |
+
RAG 검색 시스템 실험 추적 및 비교 도구
|
| 4 |
+
|
| 5 |
+
기능:
|
| 6 |
+
1. 실험 결과 자동 저장
|
| 7 |
+
2. 이전 실험과 비교
|
| 8 |
+
3. 성능 차트 생성
|
| 9 |
+
4. 최적 설정 추천
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import pandas as pd
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Dict, List, Any, Optional
|
| 17 |
+
import matplotlib.pyplot as plt
|
| 18 |
+
import matplotlib
|
| 19 |
+
matplotlib.use('Agg') # 서버 환경 대응
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ExperimentTracker:
|
| 23 |
+
"""실험 추적 및 비교 클래스"""
|
| 24 |
+
|
| 25 |
+
def __init__(self, log_dir: str = "src/evaluation/results/experiments"):
|
| 26 |
+
"""
|
| 27 |
+
Args:
|
| 28 |
+
log_dir: 실험 로그 저장 디렉토리
|
| 29 |
+
"""
|
| 30 |
+
self.log_dir = Path(log_dir)
|
| 31 |
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
|
| 33 |
+
self.log_file = self.log_dir / "experiments_log.json"
|
| 34 |
+
self.summary_file = self.log_dir / "experiments_summary.csv"
|
| 35 |
+
|
| 36 |
+
# 로그 파일 초기화
|
| 37 |
+
if not self.log_file.exists():
|
| 38 |
+
self._save_log([])
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# === 1. 실험 결과 저장 ===
|
| 42 |
+
|
| 43 |
+
def log_experiment(
|
| 44 |
+
self,
|
| 45 |
+
experiment_name: str,
|
| 46 |
+
config: Dict[str, Any],
|
| 47 |
+
metrics: Dict[str, float],
|
| 48 |
+
langsmith_url: Optional[str] = None,
|
| 49 |
+
notes: str = ""
|
| 50 |
+
) -> None:
|
| 51 |
+
"""
|
| 52 |
+
실험 결과 저장
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
experiment_name: 실험 이름 (예: "baseline", "embedding-small")
|
| 56 |
+
config: 설정 정보 (임베딩 모델, Top-K 등)
|
| 57 |
+
metrics: 평가 지표 (precision, recall 등)
|
| 58 |
+
langsmith_url: LangSmith 결과 URL
|
| 59 |
+
notes: 추가 메모
|
| 60 |
+
"""
|
| 61 |
+
# 실험 데이터 구성
|
| 62 |
+
experiment_data = {
|
| 63 |
+
"timestamp": datetime.now().isoformat(),
|
| 64 |
+
"experiment_name": experiment_name,
|
| 65 |
+
"config": config,
|
| 66 |
+
"metrics": metrics,
|
| 67 |
+
"langsmith_url": langsmith_url,
|
| 68 |
+
"notes": notes
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
# 기존 로그 로드
|
| 72 |
+
logs = self._load_log()
|
| 73 |
+
|
| 74 |
+
# 새 실험 추가
|
| 75 |
+
logs.append(experiment_data)
|
| 76 |
+
|
| 77 |
+
# 저장
|
| 78 |
+
self._save_log(logs)
|
| 79 |
+
self._update_summary()
|
| 80 |
+
|
| 81 |
+
print(f"✅ 실험 '{experiment_name}' 저장 완료")
|
| 82 |
+
print(f" Precision: {metrics.get('precision', 0):.4f}")
|
| 83 |
+
print(f" Recall: {metrics.get('recall', 0):.4f}")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# === 2. 실험 비교 ===
|
| 87 |
+
|
| 88 |
+
def compare_experiments(
|
| 89 |
+
self,
|
| 90 |
+
experiment_names: Optional[List[str]] = None,
|
| 91 |
+
top_n: int = 5
|
| 92 |
+
) -> pd.DataFrame:
|
| 93 |
+
"""
|
| 94 |
+
실험 결과 비교
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
experiment_names: 비교할 실험 이름 리스트 (None이면 최근 실험)
|
| 98 |
+
top_n: experiment_names가 None일 때 최근 몇 개 비교할지
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
비교 결과 DataFrame
|
| 102 |
+
"""
|
| 103 |
+
logs = self._load_log()
|
| 104 |
+
|
| 105 |
+
if not logs:
|
| 106 |
+
print("⚠️ 저장된 실험이 없습니다")
|
| 107 |
+
return pd.DataFrame()
|
| 108 |
+
|
| 109 |
+
# 비교할 실험 선택
|
| 110 |
+
if experiment_names is None:
|
| 111 |
+
# 최근 N개
|
| 112 |
+
selected_logs = logs[-top_n:]
|
| 113 |
+
else:
|
| 114 |
+
# 지정된 실험들
|
| 115 |
+
selected_logs = [
|
| 116 |
+
log for log in logs
|
| 117 |
+
if log['experiment_name'] in experiment_names
|
| 118 |
+
]
|
| 119 |
+
|
| 120 |
+
if not selected_logs:
|
| 121 |
+
print("⚠️ 비교할 실험을 찾을 수 없습니다")
|
| 122 |
+
return pd.DataFrame()
|
| 123 |
+
|
| 124 |
+
# DataFrame 생성
|
| 125 |
+
comparison_data = []
|
| 126 |
+
for log in selected_logs:
|
| 127 |
+
row = {
|
| 128 |
+
"실험명": log['experiment_name'],
|
| 129 |
+
"날짜": log['timestamp'][:10],
|
| 130 |
+
"임베딩": log['config'].get('embedding_model', 'N/A'),
|
| 131 |
+
"Top-K": log['config'].get('top_k', 'N/A'),
|
| 132 |
+
"Precision": log['metrics'].get('precision', 0),
|
| 133 |
+
"Recall": log['metrics'].get('recall', 0),
|
| 134 |
+
"F1": self._calculate_f1(
|
| 135 |
+
log['metrics'].get('precision', 0),
|
| 136 |
+
log['metrics'].get('recall', 0)
|
| 137 |
+
),
|
| 138 |
+
"검색시간(초)": log['metrics'].get('avg_time', 0)
|
| 139 |
+
}
|
| 140 |
+
comparison_data.append(row)
|
| 141 |
+
|
| 142 |
+
df = pd.DataFrame(comparison_data)
|
| 143 |
+
|
| 144 |
+
# 출력
|
| 145 |
+
print("\n" + "="*80)
|
| 146 |
+
print("📊 실험 비교 결과")
|
| 147 |
+
print("="*80)
|
| 148 |
+
print(df.to_string(index=False))
|
| 149 |
+
print("="*80)
|
| 150 |
+
|
| 151 |
+
return df
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def show_improvement(self, baseline_name: str, current_name: str) -> None:
|
| 155 |
+
"""
|
| 156 |
+
Baseline 대비 개선 효과 출력
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
baseline_name: 기준 실험 이름
|
| 160 |
+
current_name: 비교할 실험 이름
|
| 161 |
+
"""
|
| 162 |
+
logs = self._load_log()
|
| 163 |
+
|
| 164 |
+
# 실험 찾기
|
| 165 |
+
baseline = next((log for log in logs if log['experiment_name'] == baseline_name), None)
|
| 166 |
+
current = next((log for log in logs if log['experiment_name'] == current_name), None)
|
| 167 |
+
|
| 168 |
+
if not baseline or not current:
|
| 169 |
+
print("⚠️ 실험을 찾을 수 없습니다")
|
| 170 |
+
return
|
| 171 |
+
|
| 172 |
+
# 개선율 계산
|
| 173 |
+
baseline_precision = baseline['metrics'].get('precision', 0)
|
| 174 |
+
baseline_recall = baseline['metrics'].get('recall', 0)
|
| 175 |
+
|
| 176 |
+
current_precision = current['metrics'].get('precision', 0)
|
| 177 |
+
current_recall = current['metrics'].get('recall', 0)
|
| 178 |
+
|
| 179 |
+
precision_improvement = (current_precision - baseline_precision) / baseline_precision * 100 if baseline_precision > 0 else 0
|
| 180 |
+
recall_improvement = (current_recall - baseline_recall) / baseline_recall * 100 if baseline_recall > 0 else 0
|
| 181 |
+
|
| 182 |
+
# 출력
|
| 183 |
+
print("\n" + "="*80)
|
| 184 |
+
print(f"📈 개선 효과: {baseline_name} → {current_name}")
|
| 185 |
+
print("="*80)
|
| 186 |
+
print(f"\nPrecision:")
|
| 187 |
+
print(f" {baseline_name}: {baseline_precision:.4f}")
|
| 188 |
+
print(f" {current_name}: {current_precision:.4f}")
|
| 189 |
+
print(f" 개선율: {precision_improvement:+.2f}% {'✅' if precision_improvement > 0 else '❌'}")
|
| 190 |
+
|
| 191 |
+
print(f"\nRecall:")
|
| 192 |
+
print(f" {baseline_name}: {baseline_recall:.4f}")
|
| 193 |
+
print(f" {current_name}: {current_recall:.4f}")
|
| 194 |
+
print(f" 개선율: {recall_improvement:+.2f}% {'✅' if recall_improvement > 0 else '❌'}")
|
| 195 |
+
|
| 196 |
+
print("\n" + "="*80)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# === 3. 시각화 ===
|
| 200 |
+
|
| 201 |
+
def plot_metrics(
|
| 202 |
+
self,
|
| 203 |
+
experiment_names: Optional[List[str]] = None,
|
| 204 |
+
save_path: Optional[str] = None
|
| 205 |
+
) -> None:
|
| 206 |
+
"""
|
| 207 |
+
실험 결과 차트 생성
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
experiment_names: 차트에 포함할 실험 (None이면 전체)
|
| 211 |
+
save_path: 차트 저장 경로 (None이면 화면 출력)
|
| 212 |
+
"""
|
| 213 |
+
logs = self._load_log()
|
| 214 |
+
|
| 215 |
+
if not logs:
|
| 216 |
+
print("⚠️ 저장된 실험이 없습니다")
|
| 217 |
+
return
|
| 218 |
+
|
| 219 |
+
# 실험 선택
|
| 220 |
+
if experiment_names is not None:
|
| 221 |
+
logs = [log for log in logs if log['experiment_name'] in experiment_names]
|
| 222 |
+
|
| 223 |
+
if not logs:
|
| 224 |
+
print("⚠️ 차트를 그릴 실험이 없습니다")
|
| 225 |
+
return
|
| 226 |
+
|
| 227 |
+
# 데이터 준비
|
| 228 |
+
names = [log['experiment_name'] for log in logs]
|
| 229 |
+
precisions = [log['metrics'].get('precision', 0) for log in logs]
|
| 230 |
+
recalls = [log['metrics'].get('recall', 0) for log in logs]
|
| 231 |
+
|
| 232 |
+
# 차트 생성
|
| 233 |
+
fig, ax = plt.subplots(figsize=(12, 6))
|
| 234 |
+
|
| 235 |
+
x = range(len(names))
|
| 236 |
+
width = 0.35
|
| 237 |
+
|
| 238 |
+
ax.bar([i - width/2 for i in x], precisions, width, label='Precision', alpha=0.8)
|
| 239 |
+
ax.bar([i + width/2 for i in x], recalls, width, label='Recall', alpha=0.8)
|
| 240 |
+
|
| 241 |
+
ax.set_xlabel('실험')
|
| 242 |
+
ax.set_ylabel('점수')
|
| 243 |
+
ax.set_title('실험별 성능 비교')
|
| 244 |
+
ax.set_xticks(x)
|
| 245 |
+
ax.set_xticklabels(names, rotation=45, ha='right')
|
| 246 |
+
ax.legend()
|
| 247 |
+
ax.grid(axis='y', alpha=0.3)
|
| 248 |
+
|
| 249 |
+
plt.tight_layout()
|
| 250 |
+
|
| 251 |
+
# 저장 또는 출력
|
| 252 |
+
if save_path:
|
| 253 |
+
plt.savefig(save_path, dpi=300, bbox_inches='tight')
|
| 254 |
+
print(f"✅ 차트 저장: {save_path}")
|
| 255 |
+
else:
|
| 256 |
+
default_path = self.log_dir / "comparison_chart.png"
|
| 257 |
+
plt.savefig(default_path, dpi=300, bbox_inches='tight')
|
| 258 |
+
print(f"✅ 차트 저장: {default_path}")
|
| 259 |
+
|
| 260 |
+
plt.close()
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# === 4. 최적 설정 추천 ===
|
| 264 |
+
|
| 265 |
+
def recommend_best(self, metric: str = "f1") -> Dict[str, Any]:
|
| 266 |
+
"""
|
| 267 |
+
최적 설정 추천
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
metric: 기준 지표 ("precision", "recall", "f1")
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
최적 실험 정보
|
| 274 |
+
"""
|
| 275 |
+
logs = self._load_log()
|
| 276 |
+
|
| 277 |
+
if not logs:
|
| 278 |
+
print("⚠️ 저장된 실험이 없습니다")
|
| 279 |
+
return {}
|
| 280 |
+
|
| 281 |
+
# F1 점수 계산
|
| 282 |
+
for log in logs:
|
| 283 |
+
if 'f1' not in log['metrics']:
|
| 284 |
+
p = log['metrics'].get('precision', 0)
|
| 285 |
+
r = log['metrics'].get('recall', 0)
|
| 286 |
+
log['metrics']['f1'] = self._calculate_f1(p, r)
|
| 287 |
+
|
| 288 |
+
# 최적 실험 찾기
|
| 289 |
+
best = max(logs, key=lambda x: x['metrics'].get(metric, 0))
|
| 290 |
+
|
| 291 |
+
print("\n" + "="*80)
|
| 292 |
+
print(f"🏆 최적 설정 ({metric.upper()} 기준)")
|
| 293 |
+
print("="*80)
|
| 294 |
+
print(f"실험명: {best['experiment_name']}")
|
| 295 |
+
print(f"날짜: {best['timestamp'][:10]}")
|
| 296 |
+
print(f"\n설정:")
|
| 297 |
+
for key, value in best['config'].items():
|
| 298 |
+
print(f" {key}: {value}")
|
| 299 |
+
print(f"\n성능:")
|
| 300 |
+
print(f" Precision: {best['metrics'].get('precision', 0):.4f}")
|
| 301 |
+
print(f" Recall: {best['metrics'].get('recall', 0):.4f}")
|
| 302 |
+
print(f" F1: {best['metrics'].get('f1', 0):.4f}")
|
| 303 |
+
print("="*80)
|
| 304 |
+
|
| 305 |
+
return best
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
# === 5. 유틸리티 ===
|
| 309 |
+
|
| 310 |
+
def list_experiments(self) -> None:
|
| 311 |
+
"""저장된 실험 목록 출력"""
|
| 312 |
+
logs = self._load_log()
|
| 313 |
+
|
| 314 |
+
if not logs:
|
| 315 |
+
print("⚠️ 저장된 실험이 없습니다")
|
| 316 |
+
return
|
| 317 |
+
|
| 318 |
+
print("\n" + "="*80)
|
| 319 |
+
print("📋 저장된 실험 목록")
|
| 320 |
+
print("="*80)
|
| 321 |
+
|
| 322 |
+
for i, log in enumerate(logs, 1):
|
| 323 |
+
print(f"\n{i}. {log['experiment_name']}")
|
| 324 |
+
print(f" 날짜: {log['timestamp'][:10]}")
|
| 325 |
+
print(f" Precision: {log['metrics'].get('precision', 0):.4f}")
|
| 326 |
+
print(f" Recall: {log['metrics'].get('recall', 0):.4f}")
|
| 327 |
+
|
| 328 |
+
print("="*80)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def clear_experiments(self) -> None:
|
| 332 |
+
"""모든 실험 로그 삭제 (주의!)"""
|
| 333 |
+
confirm = input("⚠️ 모든 실험 로그를 삭제하시겠습니까? (yes/no): ")
|
| 334 |
+
if confirm.lower() == 'yes':
|
| 335 |
+
self._save_log([])
|
| 336 |
+
self._update_summary()
|
| 337 |
+
print("✅ 모든 실험 로그 삭제 완료")
|
| 338 |
+
else:
|
| 339 |
+
print("❌ 취소됨")
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
# === 내부 함수 ===
|
| 343 |
+
|
| 344 |
+
def _load_log(self) -> List[Dict]:
|
| 345 |
+
"""로그 파일 로드"""
|
| 346 |
+
if not self.log_file.exists():
|
| 347 |
+
return []
|
| 348 |
+
|
| 349 |
+
with open(self.log_file, 'r', encoding='utf-8') as f:
|
| 350 |
+
return json.load(f)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _save_log(self, logs: List[Dict]) -> None:
|
| 354 |
+
"""로그 파일 저장"""
|
| 355 |
+
with open(self.log_file, 'w', encoding='utf-8') as f:
|
| 356 |
+
json.dump(logs, f, indent=2, ensure_ascii=False)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _update_summary(self) -> None:
|
| 360 |
+
"""요약 CSV 업데이트"""
|
| 361 |
+
logs = self._load_log()
|
| 362 |
+
|
| 363 |
+
if not logs:
|
| 364 |
+
return
|
| 365 |
+
|
| 366 |
+
summary_data = []
|
| 367 |
+
for log in logs:
|
| 368 |
+
row = {
|
| 369 |
+
"timestamp": log['timestamp'],
|
| 370 |
+
"experiment_name": log['experiment_name'],
|
| 371 |
+
"embedding_model": log['config'].get('embedding_model', 'N/A'),
|
| 372 |
+
"top_k": log['config'].get('top_k', 'N/A'),
|
| 373 |
+
"precision": log['metrics'].get('precision', 0),
|
| 374 |
+
"recall": log['metrics'].get('recall', 0),
|
| 375 |
+
"f1": self._calculate_f1(
|
| 376 |
+
log['metrics'].get('precision', 0),
|
| 377 |
+
log['metrics'].get('recall', 0)
|
| 378 |
+
),
|
| 379 |
+
"avg_time": log['metrics'].get('avg_time', 0)
|
| 380 |
+
}
|
| 381 |
+
summary_data.append(row)
|
| 382 |
+
|
| 383 |
+
df = pd.DataFrame(summary_data)
|
| 384 |
+
df.to_csv(self.summary_file, index=False, encoding='utf-8-sig')
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
@staticmethod
|
| 388 |
+
def _calculate_f1(precision: float, recall: float) -> float:
|
| 389 |
+
"""F1 점수 계산"""
|
| 390 |
+
if precision + recall == 0:
|
| 391 |
+
return 0
|
| 392 |
+
return 2 * (precision * recall) / (precision + recall)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# ===== 사용 예시 =====
|
| 396 |
+
|
| 397 |
+
if __name__ == "__main__":
|
| 398 |
+
# Tracker 초기화
|
| 399 |
+
tracker = ExperimentTracker()
|
| 400 |
+
|
| 401 |
+
# 예시 1: 실험 결과 저장
|
| 402 |
+
tracker.log_experiment(
|
| 403 |
+
experiment_name="baseline",
|
| 404 |
+
config={
|
| 405 |
+
"embedding_model": "text-embedding-3-small",
|
| 406 |
+
"top_k": 5,
|
| 407 |
+
"chunk_size": 1000
|
| 408 |
+
},
|
| 409 |
+
metrics={
|
| 410 |
+
"precision": 0.30,
|
| 411 |
+
"recall": 0.65,
|
| 412 |
+
"avg_time": 0.41
|
| 413 |
+
},
|
| 414 |
+
notes="초기 baseline 실험"
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
# 예시 2: 실험 비교
|
| 418 |
+
tracker.compare_experiments()
|
| 419 |
+
|
| 420 |
+
# 예시 3: 개선 효과 확인
|
| 421 |
+
# tracker.show_improvement("baseline", "embedding-small")
|
| 422 |
+
|
| 423 |
+
# 예시 4: 차트 생성
|
| 424 |
+
# tracker.plot_metrics()
|
| 425 |
+
|
| 426 |
+
# 예시 5: 최적 설정 추천
|
| 427 |
+
# tracker.recommend_best(metric="f1")
|
src/evaluation/run_experiment.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RAG 검색 시스템 평가 도구
|
| 3 |
+
- LangSmith Experiment 실행
|
| 4 |
+
- Context Precision/Recall 평가
|
| 5 |
+
- 실험 추적 및 비교
|
| 6 |
+
|
| 7 |
+
사용법:
|
| 8 |
+
python run_experiment.py # 대화형 메뉴
|
| 9 |
+
python run_experiment.py --run # 실험 실행
|
| 10 |
+
python run_experiment.py --compare # 실험 비교
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
import sys
|
| 16 |
+
import argparse
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Dict, List, Any
|
| 19 |
+
from langsmith import Client, evaluate
|
| 20 |
+
from dotenv import load_dotenv
|
| 21 |
+
|
| 22 |
+
# 프로젝트 경로 추가
|
| 23 |
+
project_root = Path(__file__).resolve().parent.parent.parent
|
| 24 |
+
sys.path.insert(0, str(project_root))
|
| 25 |
+
|
| 26 |
+
from src.retriever.retriever import RAGRetriever
|
| 27 |
+
from src.utils.config import RAGConfig
|
| 28 |
+
from src.evaluation.experiment_tracker import ExperimentTracker
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# === 환경 설정 ===
|
| 32 |
+
load_dotenv()
|
| 33 |
+
os.environ["LANGCHAIN_PROJECT"] = "RAG-Retriever-Eval"
|
| 34 |
+
os.environ["LANGCHAIN_TRACING_V2"] = "true"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# === 전역 변수 ===
|
| 38 |
+
retriever = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ============================================================
|
| 42 |
+
# Evaluator 함수들
|
| 43 |
+
# ============================================================
|
| 44 |
+
|
| 45 |
+
def normalize_text(text: str) -> str:
|
| 46 |
+
"""텍스트 정규화"""
|
| 47 |
+
# 소문자 변환
|
| 48 |
+
normalized = text.lower()
|
| 49 |
+
|
| 50 |
+
# 특수문자 제거
|
| 51 |
+
normalized = re.sub(r'[\r\n\t]+', ' ', normalized)
|
| 52 |
+
|
| 53 |
+
# 연속 공백 하나로
|
| 54 |
+
normalized = ' '.join(normalized.split())
|
| 55 |
+
|
| 56 |
+
return normalized.strip()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def is_matching_context(retrieved_text: str, ground_truth_text: str, threshold: float = 0.5) -> bool:
|
| 60 |
+
"""두 문서가 같은 문서인지 판단"""
|
| 61 |
+
normalized_retrieved = normalize_text(retrieved_text)
|
| 62 |
+
normalized_truth = normalize_text(ground_truth_text)
|
| 63 |
+
|
| 64 |
+
# 완전 포함 체크
|
| 65 |
+
if normalized_truth in normalized_retrieved:
|
| 66 |
+
return True
|
| 67 |
+
|
| 68 |
+
if normalized_retrieved in normalized_truth:
|
| 69 |
+
return True
|
| 70 |
+
|
| 71 |
+
# 단어 커버리지 체크
|
| 72 |
+
truth_words = set(normalized_truth.split())
|
| 73 |
+
retrieved_words = set(normalized_retrieved.split())
|
| 74 |
+
|
| 75 |
+
if len(truth_words) == 0:
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
matched_words = truth_words & retrieved_words
|
| 79 |
+
coverage = len(matched_words) / len(truth_words)
|
| 80 |
+
|
| 81 |
+
return coverage >= threshold
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def count_matching_contexts(
|
| 85 |
+
retrieved_contexts: List[str],
|
| 86 |
+
ground_truth_contexts: List[str],
|
| 87 |
+
threshold: float = 0.5
|
| 88 |
+
) -> int:
|
| 89 |
+
"""매칭되는 문서 개수 계산"""
|
| 90 |
+
matched_count = 0
|
| 91 |
+
|
| 92 |
+
for retrieved in retrieved_contexts:
|
| 93 |
+
for truth in ground_truth_contexts:
|
| 94 |
+
if is_matching_context(retrieved, truth, threshold):
|
| 95 |
+
matched_count += 1
|
| 96 |
+
break
|
| 97 |
+
|
| 98 |
+
return matched_count
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def context_precision_evaluator(run: Any, example: Any) -> Dict[str, float]:
|
| 102 |
+
"""Context Precision 평가"""
|
| 103 |
+
try:
|
| 104 |
+
# 검색 결과 추출
|
| 105 |
+
if isinstance(run.outputs, dict):
|
| 106 |
+
retrieved_results = run.outputs.get('output', [])
|
| 107 |
+
else:
|
| 108 |
+
retrieved_results = run.outputs
|
| 109 |
+
|
| 110 |
+
# 텍스트만 추출
|
| 111 |
+
retrieved_contexts = []
|
| 112 |
+
for result in retrieved_results:
|
| 113 |
+
if isinstance(result, dict):
|
| 114 |
+
text = result.get('content', '')
|
| 115 |
+
if text:
|
| 116 |
+
retrieved_contexts.append(text)
|
| 117 |
+
|
| 118 |
+
# 정답 추출
|
| 119 |
+
ground_truth_contexts = example.outputs.get('ground_truth_contexts', [])
|
| 120 |
+
|
| 121 |
+
# 검증
|
| 122 |
+
if len(retrieved_contexts) == 0:
|
| 123 |
+
return {"key": "context_precision", "score": 0.0, "comment": "검색 결과 없음"}
|
| 124 |
+
|
| 125 |
+
if len(ground_truth_contexts) == 0:
|
| 126 |
+
return {"key": "context_precision", "score": 0.0, "comment": "정답 없음"}
|
| 127 |
+
|
| 128 |
+
# 매칭 개수 계산
|
| 129 |
+
matched_count = count_matching_contexts(
|
| 130 |
+
retrieved_contexts,
|
| 131 |
+
ground_truth_contexts,
|
| 132 |
+
threshold=0.5
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Precision 계산
|
| 136 |
+
precision = matched_count / len(retrieved_contexts)
|
| 137 |
+
|
| 138 |
+
return {
|
| 139 |
+
"key": "context_precision",
|
| 140 |
+
"score": precision,
|
| 141 |
+
"comment": f"매칭: {matched_count}/{len(retrieved_contexts)}"
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
except Exception as e:
|
| 145 |
+
print(f"Context Precision 계산 오류: {e}")
|
| 146 |
+
import traceback
|
| 147 |
+
traceback.print_exc()
|
| 148 |
+
return {"key": "context_precision", "score": 0.0, "comment": f"오류: {str(e)}"}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def context_recall_evaluator(run: Any, example: Any) -> Dict[str, float]:
|
| 152 |
+
"""Context Recall 평가"""
|
| 153 |
+
try:
|
| 154 |
+
# 검색 결과 추출
|
| 155 |
+
if isinstance(run.outputs, dict):
|
| 156 |
+
retrieved_results = run.outputs.get('output', [])
|
| 157 |
+
else:
|
| 158 |
+
retrieved_results = run.outputs
|
| 159 |
+
|
| 160 |
+
retrieved_contexts = []
|
| 161 |
+
for result in retrieved_results:
|
| 162 |
+
if isinstance(result, dict):
|
| 163 |
+
text = result.get('content', '')
|
| 164 |
+
if text:
|
| 165 |
+
retrieved_contexts.append(text)
|
| 166 |
+
|
| 167 |
+
# 정답 추출
|
| 168 |
+
ground_truth_contexts = example.outputs.get('ground_truth_contexts', [])
|
| 169 |
+
|
| 170 |
+
# 검증
|
| 171 |
+
if len(ground_truth_contexts) == 0:
|
| 172 |
+
return {"key": "context_recall", "score": 0.0, "comment": "정답 없음"}
|
| 173 |
+
|
| 174 |
+
if len(retrieved_contexts) == 0:
|
| 175 |
+
return {"key": "context_recall", "score": 0.0, "comment": "검색 결과 없음"}
|
| 176 |
+
|
| 177 |
+
# 매칭 개수 계산
|
| 178 |
+
matched_count = 0
|
| 179 |
+
for truth in ground_truth_contexts:
|
| 180 |
+
for retrieved in retrieved_contexts:
|
| 181 |
+
if is_matching_context(retrieved, truth, threshold=0.5):
|
| 182 |
+
matched_count += 1
|
| 183 |
+
break
|
| 184 |
+
|
| 185 |
+
# Recall 계산
|
| 186 |
+
recall = matched_count / len(ground_truth_contexts)
|
| 187 |
+
|
| 188 |
+
return {
|
| 189 |
+
"key": "context_recall",
|
| 190 |
+
"score": recall,
|
| 191 |
+
"comment": f"발견: {matched_count}/{len(ground_truth_contexts)}"
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
print(f"Context Recall 계산 오류: {e}")
|
| 196 |
+
import traceback
|
| 197 |
+
traceback.print_exc()
|
| 198 |
+
return {"key": "context_recall", "score": 0.0, "comment": f"오류: {str(e)}"}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def retrieval_time_evaluator(run: Any, example: Any) -> Dict[str, float]:
|
| 202 |
+
"""검색 시간 측정"""
|
| 203 |
+
try:
|
| 204 |
+
latency = run.execution_time
|
| 205 |
+
return {
|
| 206 |
+
"key": "retrieval_time",
|
| 207 |
+
"score": latency,
|
| 208 |
+
"comment": f"{latency:.3f}초"
|
| 209 |
+
}
|
| 210 |
+
except Exception as e:
|
| 211 |
+
return {"key": "retrieval_time", "score": 0.0, "comment": "시간 측정 실패"}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ============================================================
|
| 215 |
+
# Target 함수
|
| 216 |
+
# ============================================================
|
| 217 |
+
|
| 218 |
+
def retriever_target(inputs: dict) -> dict:
|
| 219 |
+
"""LangSmith Experiment용 검색 함수"""
|
| 220 |
+
question = inputs.get("question", "")
|
| 221 |
+
|
| 222 |
+
if not question:
|
| 223 |
+
return {"output": []}
|
| 224 |
+
|
| 225 |
+
# 하이브리드 검색 + Re-ranker 실행
|
| 226 |
+
results = retriever.search_with_mode(
|
| 227 |
+
query=question,
|
| 228 |
+
top_k=None,
|
| 229 |
+
mode="hybrid_rerank",
|
| 230 |
+
alpha=0.5
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
return {"output": results}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ============================================================
|
| 237 |
+
# 실험 실행
|
| 238 |
+
# ============================================================
|
| 239 |
+
|
| 240 |
+
def run_experiment(
|
| 241 |
+
experiment_name: str,
|
| 242 |
+
config: dict,
|
| 243 |
+
dataset_name: str = "RAG-Retriever-TestSet-v1",
|
| 244 |
+
notes: str = ""
|
| 245 |
+
) -> dict:
|
| 246 |
+
"""
|
| 247 |
+
실험 실행 및 자동 추적
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
experiment_name: 실험 이름
|
| 251 |
+
config: 실험 설정
|
| 252 |
+
dataset_name: Dataset 이름
|
| 253 |
+
notes: 메모
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
실험 결과
|
| 257 |
+
"""
|
| 258 |
+
global retriever
|
| 259 |
+
|
| 260 |
+
print("\n" + "="*80)
|
| 261 |
+
print(f"🚀 실험 시작: {experiment_name}")
|
| 262 |
+
print("="*80)
|
| 263 |
+
|
| 264 |
+
# 1. 검색기 초기화
|
| 265 |
+
print("\n🔧 검색기 초기화...")
|
| 266 |
+
rag_config = RAGConfig()
|
| 267 |
+
|
| 268 |
+
# Config 적용
|
| 269 |
+
if 'embedding_model' in config:
|
| 270 |
+
rag_config.EMBEDDING_MODEL_NAME = config['embedding_model']
|
| 271 |
+
if 'top_k' in config:
|
| 272 |
+
rag_config.DEFAULT_TOP_K = config['top_k']
|
| 273 |
+
|
| 274 |
+
retriever = RAGRetriever(config=rag_config)
|
| 275 |
+
|
| 276 |
+
print(f"✅ 설정 완료:")
|
| 277 |
+
print(f" 임베딩 모델: {rag_config.EMBEDDING_MODEL_NAME}")
|
| 278 |
+
print(f" Top-K: {rag_config.DEFAULT_TOP_K}")
|
| 279 |
+
|
| 280 |
+
# 2. Evaluators 설정
|
| 281 |
+
evaluators_list = [
|
| 282 |
+
context_precision_evaluator,
|
| 283 |
+
context_recall_evaluator,
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
# 3. LangSmith Client 초기화
|
| 287 |
+
client = Client()
|
| 288 |
+
|
| 289 |
+
# 4. Experiment 실행
|
| 290 |
+
print(f"\n⏳ Experiment 실행 중...")
|
| 291 |
+
|
| 292 |
+
try:
|
| 293 |
+
results = evaluate(
|
| 294 |
+
retriever_target,
|
| 295 |
+
data=dataset_name,
|
| 296 |
+
evaluators=evaluators_list,
|
| 297 |
+
experiment_prefix=experiment_name,
|
| 298 |
+
max_concurrency=1,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
print(f"\n✅ Experiment 완료!")
|
| 302 |
+
|
| 303 |
+
# 5. 결과 추출
|
| 304 |
+
df = results.to_pandas()
|
| 305 |
+
|
| 306 |
+
metrics = {
|
| 307 |
+
"precision": df["feedback.context_precision"].mean(),
|
| 308 |
+
"recall": df["feedback.context_recall"].mean(),
|
| 309 |
+
"avg_time": df["execution_time"].mean(),
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
# 6. 자동 추적 저장
|
| 313 |
+
tracker = ExperimentTracker()
|
| 314 |
+
|
| 315 |
+
langsmith_url = "https://smith.langchain.com/"
|
| 316 |
+
|
| 317 |
+
tracker.log_experiment(
|
| 318 |
+
experiment_name=experiment_name,
|
| 319 |
+
config=config,
|
| 320 |
+
metrics=metrics,
|
| 321 |
+
langsmith_url=langsmith_url,
|
| 322 |
+
notes=notes
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
# 7. 결과 출력
|
| 326 |
+
print("\n" + "="*80)
|
| 327 |
+
print("📊 실험 결과")
|
| 328 |
+
print("="*80)
|
| 329 |
+
print(f"Precision: {metrics['precision']:.4f}")
|
| 330 |
+
print(f"Recall: {metrics['recall']:.4f}")
|
| 331 |
+
|
| 332 |
+
f1 = 0
|
| 333 |
+
if (metrics['precision'] + metrics['recall']) > 0:
|
| 334 |
+
f1 = 2 * metrics['precision'] * metrics['recall'] / (metrics['precision'] + metrics['recall'])
|
| 335 |
+
print(f"F1: {f1:.4f}")
|
| 336 |
+
print(f"평균 검색 시간: {metrics['avg_time']:.3f}초")
|
| 337 |
+
print("="*80)
|
| 338 |
+
|
| 339 |
+
return results
|
| 340 |
+
|
| 341 |
+
except Exception as e:
|
| 342 |
+
print(f"\n❌ 실험 실패: {e}")
|
| 343 |
+
import traceback
|
| 344 |
+
traceback.print_exc()
|
| 345 |
+
raise
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ============================================================
|
| 349 |
+
# 대화형 메뉴
|
| 350 |
+
# ============================================================
|
| 351 |
+
|
| 352 |
+
def interactive_run():
|
| 353 |
+
"""대화형 실험 실행"""
|
| 354 |
+
print("\n" + "="*80)
|
| 355 |
+
print("🧪 RAG 검색 시스템 성능 실험")
|
| 356 |
+
print("="*80)
|
| 357 |
+
|
| 358 |
+
# 실험 설정 입력
|
| 359 |
+
print("\n실험 설정을 입력하세요:")
|
| 360 |
+
|
| 361 |
+
experiment_name = input("실험 이름 (예: baseline, hybrid-rerank): ").strip()
|
| 362 |
+
if not experiment_name:
|
| 363 |
+
experiment_name = "experiment"
|
| 364 |
+
|
| 365 |
+
embedding_model = input("임베딩 모델 (엔터: text-embedding-3-small): ").strip()
|
| 366 |
+
if not embedding_model:
|
| 367 |
+
embedding_model = "text-embedding-3-small"
|
| 368 |
+
|
| 369 |
+
top_k_input = input("Top-K (엔터: 10): ").strip()
|
| 370 |
+
top_k = int(top_k_input) if top_k_input else 10
|
| 371 |
+
|
| 372 |
+
notes = input("메모 (선택사항): ").strip()
|
| 373 |
+
|
| 374 |
+
# 설정 구성
|
| 375 |
+
config = {
|
| 376 |
+
"embedding_model": embedding_model,
|
| 377 |
+
"top_k": top_k,
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
# 확인
|
| 381 |
+
print("\n" + "="*80)
|
| 382 |
+
print("📋 실험 정보 확인")
|
| 383 |
+
print("="*80)
|
| 384 |
+
print(f"실험 이름: {experiment_name}")
|
| 385 |
+
print(f"임베딩 모델: {embedding_model}")
|
| 386 |
+
print(f"Top-K: {top_k}")
|
| 387 |
+
if notes:
|
| 388 |
+
print(f"메모: {notes}")
|
| 389 |
+
print("="*80)
|
| 390 |
+
|
| 391 |
+
confirm = input("\n실험을 시작하시겠습니까? (y/n): ").strip().lower()
|
| 392 |
+
if confirm != 'y':
|
| 393 |
+
print("❌ 취소됨")
|
| 394 |
+
return
|
| 395 |
+
|
| 396 |
+
# 실험 실행
|
| 397 |
+
run_experiment(
|
| 398 |
+
experiment_name=experiment_name,
|
| 399 |
+
config=config,
|
| 400 |
+
notes=notes
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def interactive_compare():
|
| 405 |
+
"""대화형 실험 비교"""
|
| 406 |
+
tracker = ExperimentTracker()
|
| 407 |
+
|
| 408 |
+
print("\n" + "="*80)
|
| 409 |
+
print("🔍 실험 비교 도구")
|
| 410 |
+
print("="*80)
|
| 411 |
+
|
| 412 |
+
while True:
|
| 413 |
+
print("\n메뉴:")
|
| 414 |
+
print(" 1. 모든 실험 목록 보기")
|
| 415 |
+
print(" 2. 최근 실험 비교 (최근 5개)")
|
| 416 |
+
print(" 3. 특정 실험 비교")
|
| 417 |
+
print(" 4. 개선 효과 확인")
|
| 418 |
+
print(" 5. 차트 생성")
|
| 419 |
+
print(" 6. 최적 설정 추천")
|
| 420 |
+
print(" 0. 종료")
|
| 421 |
+
|
| 422 |
+
choice = input("\n선택: ").strip()
|
| 423 |
+
|
| 424 |
+
if choice == "1":
|
| 425 |
+
tracker.list_experiments()
|
| 426 |
+
|
| 427 |
+
elif choice == "2":
|
| 428 |
+
tracker.compare_experiments(top_n=5)
|
| 429 |
+
|
| 430 |
+
elif choice == "3":
|
| 431 |
+
names = input("실험 이름들 (쉼표로 구분): ").strip()
|
| 432 |
+
if names:
|
| 433 |
+
experiment_names = [n.strip() for n in names.split(',')]
|
| 434 |
+
tracker.compare_experiments(experiment_names=experiment_names)
|
| 435 |
+
|
| 436 |
+
elif choice == "4":
|
| 437 |
+
baseline = input("Baseline 실험 이름: ").strip()
|
| 438 |
+
current = input("비교할 실험 이름: ").strip()
|
| 439 |
+
|
| 440 |
+
if baseline and current:
|
| 441 |
+
tracker.show_improvement(baseline, current)
|
| 442 |
+
|
| 443 |
+
elif choice == "5":
|
| 444 |
+
names_input = input("실험 이름들 (쉼표로 구분, 엔터: 전체): ").strip()
|
| 445 |
+
|
| 446 |
+
if names_input:
|
| 447 |
+
experiment_names = [n.strip() for n in names_input.split(',')]
|
| 448 |
+
else:
|
| 449 |
+
experiment_names = None
|
| 450 |
+
|
| 451 |
+
tracker.plot_metrics(experiment_names=experiment_names)
|
| 452 |
+
|
| 453 |
+
elif choice == "6":
|
| 454 |
+
metric = input("기준 지표 (precision/recall/f1, 엔터: f1): ").strip()
|
| 455 |
+
if not metric:
|
| 456 |
+
metric = "f1"
|
| 457 |
+
|
| 458 |
+
tracker.recommend_best(metric=metric)
|
| 459 |
+
|
| 460 |
+
elif choice == "0":
|
| 461 |
+
print("👋 종료합니다")
|
| 462 |
+
break
|
| 463 |
+
|
| 464 |
+
else:
|
| 465 |
+
print("❌ 잘못된 선택입니다")
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def main_menu():
|
| 469 |
+
"""메인 메뉴"""
|
| 470 |
+
print("\n" + "="*80)
|
| 471 |
+
print("🔬 RAG 평가 시스템")
|
| 472 |
+
print("="*80)
|
| 473 |
+
|
| 474 |
+
while True:
|
| 475 |
+
print("\n메뉴:")
|
| 476 |
+
print(" 1. 실험 실행")
|
| 477 |
+
print(" 2. 실험 비교")
|
| 478 |
+
print(" 0. 종료")
|
| 479 |
+
|
| 480 |
+
choice = input("\n선택: ").strip()
|
| 481 |
+
|
| 482 |
+
if choice == "1":
|
| 483 |
+
interactive_run()
|
| 484 |
+
|
| 485 |
+
elif choice == "2":
|
| 486 |
+
interactive_compare()
|
| 487 |
+
|
| 488 |
+
elif choice == "0":
|
| 489 |
+
print("👋 종료합니다")
|
| 490 |
+
break
|
| 491 |
+
|
| 492 |
+
else:
|
| 493 |
+
print("❌ 잘못된 선택입니다")
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
# ============================================================
|
| 497 |
+
# 메인 실행
|
| 498 |
+
# ============================================================
|
| 499 |
+
|
| 500 |
+
def main():
|
| 501 |
+
"""메인 실행"""
|
| 502 |
+
parser = argparse.ArgumentParser(description='RAG 평가 시스���')
|
| 503 |
+
|
| 504 |
+
parser.add_argument(
|
| 505 |
+
'--run',
|
| 506 |
+
action='store_true',
|
| 507 |
+
help='실험 실행 모드'
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
parser.add_argument(
|
| 511 |
+
'--compare',
|
| 512 |
+
action='store_true',
|
| 513 |
+
help='실험 비교 모드'
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
args = parser.parse_args()
|
| 517 |
+
|
| 518 |
+
try:
|
| 519 |
+
if args.run:
|
| 520 |
+
interactive_run()
|
| 521 |
+
elif args.compare:
|
| 522 |
+
interactive_compare()
|
| 523 |
+
else:
|
| 524 |
+
main_menu()
|
| 525 |
+
|
| 526 |
+
except KeyboardInterrupt:
|
| 527 |
+
print("\n\n⚠️ 중단됨")
|
| 528 |
+
except Exception as e:
|
| 529 |
+
print(f"\n❌ 오류: {e}")
|
| 530 |
+
import traceback
|
| 531 |
+
traceback.print_exc()
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
if __name__ == "__main__":
|
| 535 |
+
main()
|
src/generator/generator.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_openai import ChatOpenAI
|
| 2 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 3 |
+
from langchain_core.output_parsers import StrOutputParser
|
| 4 |
+
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
|
| 5 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 6 |
+
from langsmith import traceable
|
| 7 |
+
import time
|
| 8 |
+
from typing import List, Dict
|
| 9 |
+
|
| 10 |
+
from src.utils.config import RAGConfig
|
| 11 |
+
from src.retriever.retriever import RAGRetriever
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RAGPipeline:
|
| 15 |
+
"""대화형 RAG 파이프라인 - LangChain Chain 기반"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, config: RAGConfig = None, model: str = None, top_k: int = None):
|
| 18 |
+
"""초기화"""
|
| 19 |
+
self.config = config or RAGConfig()
|
| 20 |
+
self.model = model or self.config.LLM_MODEL_NAME
|
| 21 |
+
self.top_k = top_k or self.config.DEFAULT_TOP_K
|
| 22 |
+
|
| 23 |
+
# 검색 설정
|
| 24 |
+
self.search_mode = self.config.DEFAULT_SEARCH_MODE
|
| 25 |
+
self.alpha = self.config.DEFAULT_ALPHA
|
| 26 |
+
|
| 27 |
+
# LLM 초기화 (LangChain ChatOpenAI)
|
| 28 |
+
self.llm = ChatOpenAI(
|
| 29 |
+
model=self.model,
|
| 30 |
+
openai_api_key=self.config.OPENAI_API_KEY,
|
| 31 |
+
timeout=60.0,
|
| 32 |
+
max_retries=3
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Retriever 초기화
|
| 36 |
+
self.retriever = RAGRetriever(config=self.config)
|
| 37 |
+
|
| 38 |
+
# 대화 히스토리
|
| 39 |
+
self.chat_history: List[Dict] = []
|
| 40 |
+
|
| 41 |
+
# 마지막 검색 결과 저장 (sources 반환용)
|
| 42 |
+
self._last_retrieved_docs = []
|
| 43 |
+
|
| 44 |
+
# 프롬프트 템플릿 (대화 히스토리 포함)
|
| 45 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 46 |
+
("system", """당신은 공공입찰 RFP를 분석하는 입찰메이트 사내 분석가입니다. 제공된 컨텍스트만으로 요구사항·예산·대상 기관·제출 방식 등을 구조화해 의사결정을 지원하세요.
|
| 47 |
+
|
| 48 |
+
# 규칙
|
| 49 |
+
- 답변은 한국어로 작성합니다.
|
| 50 |
+
- 컨텍스트 밖 내용을 추측하지 않습니다.
|
| 51 |
+
- 정보가 없으면 "문서에서 해당 정보를 찾을 수 없습니다."라고 밝힙니다.
|
| 52 |
+
- 여러 문서를 비교할 때는 문서별 차이를 표 또는 목록으로 정리합니다.
|
| 53 |
+
- 숫자에는 가능한 단위를 포함합니다.
|
| 54 |
+
- 직전 대화 맥락을 반영합니다.
|
| 55 |
+
|
| 56 |
+
# 답변 형식
|
| 57 |
+
1. 한 줄 요약: 질문 핵심을 한두 문장으로 작성합니다.
|
| 58 |
+
2. 상세 답변: [요구사항], [대상 기관], [예산], [제출 형식/방법], [평가 기준] 등 문서에서 확인된 항목만 정리합니다.
|
| 59 |
+
3. 근거 정보: 위 답변의 근거가 된 문장이나 문단을 요약합니다.
|
| 60 |
+
4. 부족한 정보: 문서에서 찾을 수 없는 항목은 "문서에서 확인 불가"로 표기합니다."""),
|
| 61 |
+
|
| 62 |
+
# 대화 히스토리
|
| 63 |
+
MessagesPlaceholder(variable_name="chat_history"),
|
| 64 |
+
|
| 65 |
+
# 현재 질문과 컨텍스트
|
| 66 |
+
("user", """# 컨텍스트
|
| 67 |
+
{context}
|
| 68 |
+
|
| 69 |
+
# 질문
|
| 70 |
+
{question}
|
| 71 |
+
|
| 72 |
+
위 규칙에 따라 답변하세요.""")
|
| 73 |
+
])
|
| 74 |
+
|
| 75 |
+
# Chain 구성
|
| 76 |
+
self.chain = (
|
| 77 |
+
{
|
| 78 |
+
"context": RunnableLambda(self._retrieve_and_format),
|
| 79 |
+
"question": RunnablePassthrough(),
|
| 80 |
+
"chat_history": RunnableLambda(lambda x: self._get_chat_history())
|
| 81 |
+
}
|
| 82 |
+
| self.prompt
|
| 83 |
+
| self.llm
|
| 84 |
+
| StrOutputParser()
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
print(f"✅ RAG 파이프라인 초기화 완료")
|
| 88 |
+
print(f" - 모델: {self.model}")
|
| 89 |
+
print(f" - 기본 top_k: {self.top_k}")
|
| 90 |
+
print(f" - 검색 모드: {self.search_mode}")
|
| 91 |
+
|
| 92 |
+
def _get_chat_history(self) -> List:
|
| 93 |
+
"""대화 히스토리를 LangChain 메시지 형식으로 변환"""
|
| 94 |
+
messages = []
|
| 95 |
+
for msg in self.chat_history:
|
| 96 |
+
if msg["role"] == "user":
|
| 97 |
+
messages.append(HumanMessage(content=msg["content"]))
|
| 98 |
+
else:
|
| 99 |
+
messages.append(AIMessage(content=msg["content"]))
|
| 100 |
+
return messages
|
| 101 |
+
|
| 102 |
+
def _retrieve_and_format(self, query: str) -> str:
|
| 103 |
+
"""검색 수행 및 컨텍스트 포맷팅"""
|
| 104 |
+
# 검색 모드에 따라 문서 검색
|
| 105 |
+
if self.search_mode == "embedding":
|
| 106 |
+
docs = self.retriever.search(query, top_k=self.top_k)
|
| 107 |
+
elif self.search_mode == "hybrid":
|
| 108 |
+
docs = self.retriever.hybrid_search(query, top_k=self.top_k, alpha=self.alpha)
|
| 109 |
+
elif self.search_mode == "hybrid_rerank":
|
| 110 |
+
docs = self.retriever.hybrid_search_with_rerank(
|
| 111 |
+
query, top_k=self.top_k, alpha=self.alpha
|
| 112 |
+
)
|
| 113 |
+
else:
|
| 114 |
+
docs = self.retriever.search(query, top_k=self.top_k)
|
| 115 |
+
|
| 116 |
+
# 마지막 검색 결과 저장
|
| 117 |
+
self._last_retrieved_docs = docs
|
| 118 |
+
|
| 119 |
+
# 컨텍스트 포맷팅
|
| 120 |
+
return self._format_context(docs)
|
| 121 |
+
|
| 122 |
+
def _format_context(self, retrieved_docs: list) -> str:
|
| 123 |
+
"""검색된 문서를 컨텍스트로 변환"""
|
| 124 |
+
if not retrieved_docs:
|
| 125 |
+
return "관련 문서를 찾을 수 없습니다."
|
| 126 |
+
|
| 127 |
+
context_parts = []
|
| 128 |
+
for i, doc in enumerate(retrieved_docs, 1):
|
| 129 |
+
context_parts.append(f"[문서 {i}]\n{doc['content']}\n")
|
| 130 |
+
return "\n".join(context_parts)
|
| 131 |
+
|
| 132 |
+
def _format_sources(self, retrieved_docs: list) -> list:
|
| 133 |
+
"""검색된 문서를 sources 형식으로 변환"""
|
| 134 |
+
sources = []
|
| 135 |
+
for doc in retrieved_docs:
|
| 136 |
+
source_info = {
|
| 137 |
+
'content': doc['content'],
|
| 138 |
+
'metadata': doc['metadata'],
|
| 139 |
+
'filename': doc.get('filename', 'N/A'),
|
| 140 |
+
'organization': doc.get('organization', 'N/A')
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
# 검색 모드에 따라 점수 필드가 다름
|
| 144 |
+
if 'rerank_score' in doc:
|
| 145 |
+
source_info['score'] = doc['rerank_score']
|
| 146 |
+
source_info['score_type'] = 'rerank'
|
| 147 |
+
elif 'hybrid_score' in doc:
|
| 148 |
+
source_info['score'] = doc['hybrid_score']
|
| 149 |
+
source_info['score_type'] = 'hybrid'
|
| 150 |
+
elif 'relevance_score' in doc:
|
| 151 |
+
source_info['score'] = doc['relevance_score']
|
| 152 |
+
source_info['score_type'] = 'embedding'
|
| 153 |
+
else:
|
| 154 |
+
source_info['score'] = 0
|
| 155 |
+
source_info['score_type'] = 'unknown'
|
| 156 |
+
|
| 157 |
+
sources.append(source_info)
|
| 158 |
+
return sources
|
| 159 |
+
|
| 160 |
+
@traceable(
|
| 161 |
+
name="RAG_Generate_Answer",
|
| 162 |
+
metadata={"component": "generator", "version": "2.0"}
|
| 163 |
+
)
|
| 164 |
+
def generate_answer(
|
| 165 |
+
self,
|
| 166 |
+
query: str,
|
| 167 |
+
top_k: int = None,
|
| 168 |
+
search_mode: str = None,
|
| 169 |
+
alpha: float = None
|
| 170 |
+
) -> dict:
|
| 171 |
+
"""
|
| 172 |
+
답변 생성 (Chain 기반)
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
query: 질문
|
| 176 |
+
top_k: 검색할 문서 수
|
| 177 |
+
search_mode: 검색 모드 ("embedding", "hybrid", "hybrid_rerank")
|
| 178 |
+
alpha: 임베딩 가중치 (0~1)
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
dict: answer, sources, search_mode, usage
|
| 182 |
+
"""
|
| 183 |
+
try:
|
| 184 |
+
start_time = time.time()
|
| 185 |
+
|
| 186 |
+
# 파라미터 설정
|
| 187 |
+
if top_k is not None:
|
| 188 |
+
self.top_k = top_k
|
| 189 |
+
if search_mode is not None:
|
| 190 |
+
self.search_mode = search_mode
|
| 191 |
+
if alpha is not None:
|
| 192 |
+
self.alpha = alpha
|
| 193 |
+
|
| 194 |
+
# Chain 실행
|
| 195 |
+
answer = self.chain.invoke(query)
|
| 196 |
+
|
| 197 |
+
elapsed_time = time.time() - start_time
|
| 198 |
+
|
| 199 |
+
# 대화 히스토리에 추가
|
| 200 |
+
self.chat_history.append({"role": "user", "content": query})
|
| 201 |
+
self.chat_history.append({"role": "assistant", "content": answer})
|
| 202 |
+
|
| 203 |
+
# 토큰 사용량 추정 (LangChain에서는 직접 접근 어려움)
|
| 204 |
+
estimated_tokens = len(query.split()) + len(answer.split()) * 2
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
'answer': answer,
|
| 208 |
+
'sources': self._format_sources(self._last_retrieved_docs),
|
| 209 |
+
'search_mode': self.search_mode,
|
| 210 |
+
'elapsed_time': elapsed_time,
|
| 211 |
+
'usage': {
|
| 212 |
+
'total_tokens': estimated_tokens,
|
| 213 |
+
'prompt_tokens': 0,
|
| 214 |
+
'completion_tokens': 0
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
except Exception as e:
|
| 219 |
+
print(f"❌ 답변 생성 실패: {e}")
|
| 220 |
+
import traceback
|
| 221 |
+
traceback.print_exc()
|
| 222 |
+
raise RuntimeError(f"답변 생성 실패: {str(e)}") from e
|
| 223 |
+
|
| 224 |
+
def chat(self, query: str) -> str:
|
| 225 |
+
"""
|
| 226 |
+
간단한 대화 인터페이스
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
query: 질문
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
str: 답변 텍스트만 반환
|
| 233 |
+
"""
|
| 234 |
+
result = self.generate_answer(query)
|
| 235 |
+
return result['answer']
|
| 236 |
+
|
| 237 |
+
def clear_history(self):
|
| 238 |
+
"""대화 히스토리 초기화"""
|
| 239 |
+
self.chat_history = []
|
| 240 |
+
print("🗑️ 대화 히스토리가 초기화되었습니다.")
|
| 241 |
+
|
| 242 |
+
def get_history(self) -> List[Dict]:
|
| 243 |
+
"""대화 히스토리 반환"""
|
| 244 |
+
return self.chat_history.copy()
|
| 245 |
+
|
| 246 |
+
def set_search_config(self, search_mode: str = None, top_k: int = None, alpha: float = None):
|
| 247 |
+
"""검색 설정 변경"""
|
| 248 |
+
if search_mode is not None:
|
| 249 |
+
self.search_mode = search_mode
|
| 250 |
+
if top_k is not None:
|
| 251 |
+
self.top_k = top_k
|
| 252 |
+
if alpha is not None:
|
| 253 |
+
self.alpha = alpha
|
| 254 |
+
|
| 255 |
+
print(f"🔧 검색 설정 변경: mode={self.search_mode}, top_k={self.top_k}, alpha={self.alpha}")
|
| 256 |
+
|
| 257 |
+
def print_result(self, result: dict, query: str = None):
|
| 258 |
+
"""결과 출력"""
|
| 259 |
+
print("\n" + "="*60)
|
| 260 |
+
if query:
|
| 261 |
+
print(f"질문: {query}")
|
| 262 |
+
print(f"검색 모드: {result.get('search_mode', 'N/A')}")
|
| 263 |
+
if 'elapsed_time' in result:
|
| 264 |
+
print(f"소요 시간: {result['elapsed_time']:.2f}초")
|
| 265 |
+
print("="*60)
|
| 266 |
+
print(f"\n💬 답변:\n{result['answer']}")
|
| 267 |
+
print(f"\n📚 참고 문서 ({len(result['sources'])}개):")
|
| 268 |
+
for i, source in enumerate(result['sources'], 1):
|
| 269 |
+
score = source.get('score', 0)
|
| 270 |
+
score_type = source.get('score_type', '')
|
| 271 |
+
print(f" [{i}] {source['filename']}")
|
| 272 |
+
print(f" 점수: {score:.3f} ({score_type})")
|
| 273 |
+
print("="*60)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# 대화형 실행
|
| 277 |
+
def interactive_mode():
|
| 278 |
+
"""대화형 모드 실행"""
|
| 279 |
+
print("=" * 60)
|
| 280 |
+
print("대화형 RAG 시스템 초기화 중...")
|
| 281 |
+
print("=" * 60)
|
| 282 |
+
|
| 283 |
+
config = RAGConfig()
|
| 284 |
+
pipeline = RAGPipeline(config=config)
|
| 285 |
+
|
| 286 |
+
print("\n" + "=" * 60)
|
| 287 |
+
print("대화형 모드 시작")
|
| 288 |
+
print("명령어: 'quit' (종료), 'clear' (히스토리 초기화), 'mode' (검색모드 변경)")
|
| 289 |
+
print("=" * 60)
|
| 290 |
+
|
| 291 |
+
while True:
|
| 292 |
+
user_query = input("\n질문: ").strip()
|
| 293 |
+
|
| 294 |
+
if not user_query:
|
| 295 |
+
continue
|
| 296 |
+
|
| 297 |
+
if user_query.lower() in ['quit', 'exit', '종료', 'q']:
|
| 298 |
+
print("시스템을 종료합니다.")
|
| 299 |
+
break
|
| 300 |
+
|
| 301 |
+
if user_query.lower() == 'clear':
|
| 302 |
+
pipeline.clear_history()
|
| 303 |
+
continue
|
| 304 |
+
|
| 305 |
+
if user_query.lower() == 'mode':
|
| 306 |
+
print("\n검색 모드 선택:")
|
| 307 |
+
print("1. embedding - 임베딩 검색")
|
| 308 |
+
print("2. hybrid - BM25 + 임베딩")
|
| 309 |
+
print("3. hybrid_rerank - Hybrid + Re-ranker (권장)")
|
| 310 |
+
choice = input("선택 (1/2/3): ").strip()
|
| 311 |
+
modes = {'1': 'embedding', '2': 'hybrid', '3': 'hybrid_rerank'}
|
| 312 |
+
if choice in modes:
|
| 313 |
+
pipeline.set_search_config(search_mode=modes[choice])
|
| 314 |
+
continue
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
result = pipeline.generate_answer(query=user_query)
|
| 318 |
+
pipeline.print_result(result, user_query)
|
| 319 |
+
|
| 320 |
+
# 소스 출력 여부
|
| 321 |
+
show_source = input("\n참조 문서 상세 보기? (y/n): ").strip().lower()
|
| 322 |
+
if show_source == 'y':
|
| 323 |
+
for i, source in enumerate(result['sources'], 1):
|
| 324 |
+
print(f"\n{'='*40}")
|
| 325 |
+
print(f"[문서 {i}] {source['filename']}")
|
| 326 |
+
print(f"발주기관: {source['organization']}")
|
| 327 |
+
print(f"내용:\n{source['content'][:500]}...")
|
| 328 |
+
|
| 329 |
+
except Exception as e:
|
| 330 |
+
print(f"❌ 오류 발생: {e}")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# 사용 예시
|
| 334 |
+
if __name__ == "__main__":
|
| 335 |
+
interactive_mode()
|
src/generator/generator_gguf.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from llama_cpp import Llama
|
| 2 |
+
from typing import Optional, Dict, Any, List
|
| 3 |
+
import logging
|
| 4 |
+
import time
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from src.utils.config import RAGConfig
|
| 8 |
+
from src.router.query_router import QueryRouter
|
| 9 |
+
from src.prompts.dynamic_prompts import PromptManager
|
| 10 |
+
|
| 11 |
+
# 로깅 설정
|
| 12 |
+
logging.basicConfig(level=logging.INFO)
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class GGUFGenerator:
|
| 17 |
+
"""
|
| 18 |
+
GGUF 기반 Llama-3 생성기
|
| 19 |
+
|
| 20 |
+
llama.cpp를 사용하여 GGUF 포맷 모델을 로드하고
|
| 21 |
+
입찰 관련 질의응답을 수행합니다.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def __init__(
|
| 25 |
+
self,
|
| 26 |
+
model_path: str,
|
| 27 |
+
n_gpu_layers: int = 0,
|
| 28 |
+
n_ctx: int = 2048,
|
| 29 |
+
n_threads: int = 8,
|
| 30 |
+
config = None,
|
| 31 |
+
max_new_tokens: int = 256,
|
| 32 |
+
temperature: float = 0.7,
|
| 33 |
+
top_p: float = 0.9,
|
| 34 |
+
system_prompt: str = "당신은 RFP(제안요청서) 분석 및 요약 전문가입니다."
|
| 35 |
+
):
|
| 36 |
+
"""
|
| 37 |
+
생성기 초기화
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
model_path: GGUF 모델 파일 경로
|
| 41 |
+
n_gpu_layers: GPU에 올릴 레이어 수 (0 = CPU만, 35 = 전체 GPU)
|
| 42 |
+
n_ctx: 최대 컨텍스트 길이
|
| 43 |
+
n_threads: CPU 스레드 수
|
| 44 |
+
max_new_tokens: 최대 생성 토큰 수
|
| 45 |
+
temperature: 생성 다양성 (0.0~1.0)
|
| 46 |
+
top_p: Nucleus sampling 파라미터
|
| 47 |
+
system_prompt: 시스템 프롬프트
|
| 48 |
+
"""
|
| 49 |
+
self.config = config or RAGConfig()
|
| 50 |
+
self.model_path = model_path
|
| 51 |
+
self.n_gpu_layers = n_gpu_layers
|
| 52 |
+
self.n_ctx = n_ctx
|
| 53 |
+
self.n_threads = n_threads
|
| 54 |
+
self.max_new_tokens = max_new_tokens
|
| 55 |
+
self.temperature = temperature
|
| 56 |
+
self.top_p = top_p
|
| 57 |
+
self.system_prompt = system_prompt
|
| 58 |
+
|
| 59 |
+
# 모델 (나중에 로드)
|
| 60 |
+
self.model = None
|
| 61 |
+
|
| 62 |
+
logger.info(f"GGUFGenerator 초기화 완료")
|
| 63 |
+
|
| 64 |
+
def load_model(self) -> None:
|
| 65 |
+
"""
|
| 66 |
+
GGUF 모델 로드
|
| 67 |
+
|
| 68 |
+
로직:
|
| 69 |
+
1. USE_MODEL_HUB 확인
|
| 70 |
+
2-A. True → Hugging Face Hub에서 다운로드
|
| 71 |
+
2-B. False → 로컬 파일 사용
|
| 72 |
+
3. 모델 로드
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
# 중복 로드 방지
|
| 76 |
+
if self.model is not None:
|
| 77 |
+
logger.info("모델이 이미 로드되어 있습니다.")
|
| 78 |
+
return
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
# Model Hub 사용 여부에 따라 경로 결정
|
| 82 |
+
if self.config.USE_MODEL_HUB:
|
| 83 |
+
# === Model Hub에서 다운로드 ===
|
| 84 |
+
logger.info(f"📥 Model Hub에서 다운로드: {self.config.MODEL_HUB_REPO}")
|
| 85 |
+
|
| 86 |
+
from huggingface_hub import hf_hub_download
|
| 87 |
+
|
| 88 |
+
model_path = hf_hub_download(
|
| 89 |
+
repo_id=self.config.MODEL_HUB_REPO,
|
| 90 |
+
filename=self.config.MODEL_HUB_FILENAME,
|
| 91 |
+
cache_dir=self.config.MODEL_CACHE_DIR,
|
| 92 |
+
local_dir=self.config.MODEL_CACHE_DIR,
|
| 93 |
+
local_dir_use_symlinks=False # 심볼릭 링크 대신 실제 복사
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
logger.info(f"✅ 다운로드 완료: {model_path}")
|
| 97 |
+
|
| 98 |
+
else:
|
| 99 |
+
# === 로컬 파일 사용 ===
|
| 100 |
+
model_path = self.config.GGUF_MODEL_PATH
|
| 101 |
+
|
| 102 |
+
if not os.path.exists(model_path):
|
| 103 |
+
raise FileNotFoundError(
|
| 104 |
+
f"❌ 로컬 모델 파일을 찾을 수 없습니다: {model_path}\n"
|
| 105 |
+
f" USE_MODEL_HUB=true로 설정하거나 모델 파일을 준비하세요."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
logger.info(f"📂 로컬 모델 사용: {model_path}")
|
| 109 |
+
|
| 110 |
+
# === 공통: 모델 로드 ===
|
| 111 |
+
logger.info(f"🚀 GGUF 모델 로드 중...")
|
| 112 |
+
logger.info(f" GPU 레이어: {self.n_gpu_layers}")
|
| 113 |
+
logger.info(f" 컨텍스트: {self.n_ctx}")
|
| 114 |
+
|
| 115 |
+
self.model = Llama(
|
| 116 |
+
model_path=model_path,
|
| 117 |
+
n_gpu_layers=self.n_gpu_layers,
|
| 118 |
+
n_ctx=self.n_ctx,
|
| 119 |
+
n_threads=self.n_threads,
|
| 120 |
+
verbose=False,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
logger.info("✅ GGUF 모델 로드 완료!")
|
| 124 |
+
|
| 125 |
+
except FileNotFoundError as e:
|
| 126 |
+
logger.error(f"❌ 모델 파일을 찾을 수 없습니다: {e}")
|
| 127 |
+
raise
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"❌ 모델 로드 실패: {e}")
|
| 130 |
+
raise RuntimeError(f"모델 로드 중 오류 발생: {e}")
|
| 131 |
+
|
| 132 |
+
def format_prompt(
|
| 133 |
+
self,
|
| 134 |
+
question: str,
|
| 135 |
+
context: Optional[str] = None,
|
| 136 |
+
system_prompt: Optional[str] = None
|
| 137 |
+
) -> str:
|
| 138 |
+
"""
|
| 139 |
+
Llama-3 Chat 템플릿으로 프롬프트 포맷팅
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
question: 사용자 질문
|
| 143 |
+
context: 선택적 컨텍스트 (RAG 검��� 결과)
|
| 144 |
+
system_prompt: 선택적 시스템 프롬프트
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
포맷된 프롬프트 문자열
|
| 148 |
+
"""
|
| 149 |
+
# 시스템 프롬프트 설정
|
| 150 |
+
if system_prompt is None:
|
| 151 |
+
system_prompt = self.system_prompt
|
| 152 |
+
logger.warning("⚠️ system_prompt가 None! 기본 프롬프트 사용")
|
| 153 |
+
else:
|
| 154 |
+
# 동적 프롬프트 미리보기 (처음 150자만)
|
| 155 |
+
logger.info(f"✅ 동적 프롬프트 적용:\n{system_prompt[:150]}...") # ← 추가
|
| 156 |
+
|
| 157 |
+
# 컨텍스트 포함 여부
|
| 158 |
+
if context is not None:
|
| 159 |
+
user_message = f"참고 문서:\n{context}\n\n질문: {question}"
|
| 160 |
+
else:
|
| 161 |
+
user_message = question
|
| 162 |
+
|
| 163 |
+
# Llama-3 Chat 템플릿 적용
|
| 164 |
+
formatted_prompt = (
|
| 165 |
+
f"<|start_header_id|>system<|end_header_id|>\n\n"
|
| 166 |
+
f"{system_prompt}<|eot_id|>"
|
| 167 |
+
f"<|start_header_id|>user<|end_header_id|>\n\n"
|
| 168 |
+
f"{user_message}<|eot_id|>"
|
| 169 |
+
f"<|start_header_id|>assistant<|end_header_id|>\n\n"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
return formatted_prompt
|
| 173 |
+
|
| 174 |
+
def generate(
|
| 175 |
+
self,
|
| 176 |
+
prompt: str,
|
| 177 |
+
max_new_tokens: Optional[int] = None,
|
| 178 |
+
temperature: Optional[float] = None,
|
| 179 |
+
top_p: Optional[float] = None,
|
| 180 |
+
) -> str:
|
| 181 |
+
"""
|
| 182 |
+
프롬프트를 입력받아 응답 생성
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
prompt: 포맷된 프롬프트
|
| 186 |
+
max_new_tokens: 최대 생성 토큰 수
|
| 187 |
+
temperature: 생성 다양성
|
| 188 |
+
top_p: Nucleus sampling
|
| 189 |
+
|
| 190 |
+
Returns:
|
| 191 |
+
생성된 응답 텍스트
|
| 192 |
+
|
| 193 |
+
Raises:
|
| 194 |
+
RuntimeError: 모델이 로드되지 않은 경우
|
| 195 |
+
"""
|
| 196 |
+
# 모델 로드 확인
|
| 197 |
+
if self.model is None:
|
| 198 |
+
raise RuntimeError(
|
| 199 |
+
"모델이 로드되지 않았습니다. load_model()을 먼저 호출하세요."
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# 파라미터 설정
|
| 203 |
+
if max_new_tokens is None:
|
| 204 |
+
max_new_tokens = self.max_new_tokens
|
| 205 |
+
if temperature is None:
|
| 206 |
+
temperature = self.temperature
|
| 207 |
+
if top_p is None:
|
| 208 |
+
top_p = self.top_p
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
logger.info(f"🔄 생성 시작 (max_tokens={max_new_tokens}, temp={temperature})")
|
| 212 |
+
start_time = time.time()
|
| 213 |
+
|
| 214 |
+
# 생성
|
| 215 |
+
output = self.model(
|
| 216 |
+
prompt,
|
| 217 |
+
max_tokens=max_new_tokens,
|
| 218 |
+
temperature=temperature,
|
| 219 |
+
top_p=top_p,
|
| 220 |
+
echo=False, # 프롬프트 반복 안 함
|
| 221 |
+
stop=["<|eot_id|>", "<|end_of_text|>"], # 종료 토큰
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
elapsed = time.time() - start_time
|
| 225 |
+
logger.info(f"✅ 생성 완료: {elapsed:.2f}초")
|
| 226 |
+
|
| 227 |
+
# 응답 추출
|
| 228 |
+
response = output['choices'][0]['text'].strip()
|
| 229 |
+
|
| 230 |
+
logger.info(f"📝 응답 길이: {len(response)} 글자")
|
| 231 |
+
return response
|
| 232 |
+
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error(f"❌ 생성 중 오류 발생: {e}")
|
| 235 |
+
raise RuntimeError(f"텍스트 생성 실패: {e}")
|
| 236 |
+
|
| 237 |
+
def chat(
|
| 238 |
+
self,
|
| 239 |
+
question: str,
|
| 240 |
+
context: Optional[str] = None,
|
| 241 |
+
system_prompt=None,
|
| 242 |
+
**kwargs
|
| 243 |
+
) -> str:
|
| 244 |
+
"""
|
| 245 |
+
질문에 대한 응답 생성 (통합 메서드)
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
question: 사용자 질문
|
| 249 |
+
context: 선택적 컨텍스트 (RAG 결과)
|
| 250 |
+
**kwargs: generate() 파라미터
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
생성된 응답
|
| 254 |
+
"""
|
| 255 |
+
# 프롬프트 포맷팅 (system_prompt 전달)
|
| 256 |
+
formatted_prompt = self.format_prompt(
|
| 257 |
+
question=question,
|
| 258 |
+
context=context,
|
| 259 |
+
system_prompt=system_prompt # ← 추가!
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# 응답 생성
|
| 263 |
+
response = self.generate(formatted_prompt, **kwargs)
|
| 264 |
+
|
| 265 |
+
return response
|
| 266 |
+
|
| 267 |
+
def get_model_info(self) -> Dict[str, Any]:
|
| 268 |
+
"""
|
| 269 |
+
모델 정보 반환
|
| 270 |
+
|
| 271 |
+
Returns:
|
| 272 |
+
모델 정보 딕셔너리
|
| 273 |
+
"""
|
| 274 |
+
info = {
|
| 275 |
+
"model_path": self.model_path,
|
| 276 |
+
"n_gpu_layers": self.n_gpu_layers,
|
| 277 |
+
"n_ctx": self.n_ctx,
|
| 278 |
+
"n_threads": self.n_threads,
|
| 279 |
+
"is_loaded": self.model is not None,
|
| 280 |
+
"max_new_tokens": self.max_new_tokens,
|
| 281 |
+
"temperature": self.temperature,
|
| 282 |
+
"top_p": self.top_p,
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
return info
|
| 286 |
+
|
| 287 |
+
def __repr__(self):
|
| 288 |
+
return f"GGUFGenerator(model={self.model_path}, loaded={self.model is not None})"
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
# ===== GGUF RAGPipeline: chatbot_app.py 호환용 =====
|
| 292 |
+
|
| 293 |
+
class GGUFRAGPipeline:
|
| 294 |
+
"""
|
| 295 |
+
GGUF 모델 기반 RAG 파이프라인
|
| 296 |
+
|
| 297 |
+
RAGPipeline(API 버전)과 동일한 인터페이스를 제공하여
|
| 298 |
+
chatbot_app.py와 호환됩니다.
|
| 299 |
+
"""
|
| 300 |
+
|
| 301 |
+
def __init__(self, config=None, model: str = None, top_k: int = None):
|
| 302 |
+
"""
|
| 303 |
+
초기화
|
| 304 |
+
|
| 305 |
+
Args:
|
| 306 |
+
config: RAGConfig 객체
|
| 307 |
+
model: 모델 이름 (사용 안 함, 호환성용)
|
| 308 |
+
top_k: 기본 검색 문서 수
|
| 309 |
+
"""
|
| 310 |
+
# Config import (지연 import로 순환 참조 방지)
|
| 311 |
+
from src.utils.config import RAGConfig
|
| 312 |
+
from src.retriever.retriever import RAGRetriever
|
| 313 |
+
|
| 314 |
+
self.config = config or RAGConfig()
|
| 315 |
+
self.top_k = top_k or self.config.DEFAULT_TOP_K
|
| 316 |
+
|
| 317 |
+
# 검색 설정
|
| 318 |
+
self.search_mode = self.config.DEFAULT_SEARCH_MODE
|
| 319 |
+
self.alpha = self.config.DEFAULT_ALPHA
|
| 320 |
+
|
| 321 |
+
# Retriever 초기화
|
| 322 |
+
logger.info("RAGRetriever 초기화 중...")
|
| 323 |
+
self.retriever = RAGRetriever(config=self.config)
|
| 324 |
+
|
| 325 |
+
# GGUFGenerator 초기화
|
| 326 |
+
logger.info("GGUFGenerator 초기화 중...")
|
| 327 |
+
self.generator = GGUFGenerator(
|
| 328 |
+
model_path=self.config.GGUF_MODEL_PATH,
|
| 329 |
+
n_gpu_layers=self.config.GGUF_N_GPU_LAYERS,
|
| 330 |
+
n_ctx=self.config.GGUF_N_CTX,
|
| 331 |
+
n_threads=self.config.GGUF_N_THREADS,
|
| 332 |
+
max_new_tokens=self.config.GGUF_MAX_NEW_TOKENS,
|
| 333 |
+
temperature=self.config.GGUF_TEMPERATURE,
|
| 334 |
+
top_p=self.config.GGUF_TOP_P,
|
| 335 |
+
system_prompt=self.config.SYSTEM_PROMPT
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
# 모델 로드 (시간 소요)
|
| 339 |
+
logger.info("GGUF 모델 로드 중...")
|
| 340 |
+
self.generator.load_model()
|
| 341 |
+
|
| 342 |
+
# 대화 히스토리
|
| 343 |
+
self.chat_history: List[Dict] = []
|
| 344 |
+
|
| 345 |
+
# 마지막 검색 결과 저장 (sources 반환용)
|
| 346 |
+
self._last_retrieved_docs = []
|
| 347 |
+
|
| 348 |
+
logger.info("✅ GGUFRAGPipeline 초기화 완료")
|
| 349 |
+
logger.info(f" - 검색 모드: {self.search_mode}")
|
| 350 |
+
logger.info(f" - 기본 top_k: {self.top_k}")
|
| 351 |
+
|
| 352 |
+
def _retrieve_and_format(self, query: str) -> str:
|
| 353 |
+
"""검색 수행 및 컨텍스트 포맷팅"""
|
| 354 |
+
# 검색 모드에 따라 문서 검색
|
| 355 |
+
if self.search_mode == "embedding":
|
| 356 |
+
docs = self.retriever.search(query, top_k=self.top_k)
|
| 357 |
+
elif self.search_mode == "embedding_rerank":
|
| 358 |
+
docs = self.retriever.search_with_rerank(query, top_k=self.top_k)
|
| 359 |
+
elif self.search_mode == "hybrid":
|
| 360 |
+
docs = self.retriever.hybrid_search(
|
| 361 |
+
query, top_k=self.top_k, alpha=self.alpha
|
| 362 |
+
)
|
| 363 |
+
elif self.search_mode == "hybrid_rerank":
|
| 364 |
+
docs = self.retriever.hybrid_search_with_rerank(
|
| 365 |
+
query, top_k=self.top_k, alpha=self.alpha
|
| 366 |
+
)
|
| 367 |
+
else:
|
| 368 |
+
docs = self.retriever.search(query, top_k=self.top_k)
|
| 369 |
+
|
| 370 |
+
# 마지막 검색 결과 저장
|
| 371 |
+
self._last_retrieved_docs = docs
|
| 372 |
+
|
| 373 |
+
# 컨텍스트 포맷팅
|
| 374 |
+
return self._format_context(docs)
|
| 375 |
+
|
| 376 |
+
def _format_context(self, retrieved_docs: list) -> str:
|
| 377 |
+
"""검색된 문서를 컨텍스트로 변환"""
|
| 378 |
+
if not retrieved_docs:
|
| 379 |
+
return "관련 문서를 찾을 수 없습니다."
|
| 380 |
+
|
| 381 |
+
context_parts = []
|
| 382 |
+
for i, doc in enumerate(retrieved_docs, 1):
|
| 383 |
+
context_parts.append(f"[문서 {i}]\n{doc['content']}\n")
|
| 384 |
+
|
| 385 |
+
return "\n".join(context_parts)
|
| 386 |
+
|
| 387 |
+
def _format_sources(self, retrieved_docs: list) -> list:
|
| 388 |
+
"""검색된 문서를 sources 형식으로 변환"""
|
| 389 |
+
sources = []
|
| 390 |
+
for doc in retrieved_docs:
|
| 391 |
+
source_info = {
|
| 392 |
+
'content': doc['content'],
|
| 393 |
+
'metadata': doc['metadata'],
|
| 394 |
+
'filename': doc.get('filename', 'N/A'),
|
| 395 |
+
'organization': doc.get('organization', 'N/A')
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
# 검색 모드에 따라 점수 필드가 다름
|
| 399 |
+
if 'rerank_score' in doc:
|
| 400 |
+
source_info['score'] = doc['rerank_score']
|
| 401 |
+
source_info['score_type'] = 'rerank'
|
| 402 |
+
elif 'hybrid_score' in doc:
|
| 403 |
+
source_info['score'] = doc['hybrid_score']
|
| 404 |
+
source_info['score_type'] = 'hybrid'
|
| 405 |
+
elif 'relevance_score' in doc:
|
| 406 |
+
source_info['score'] = doc['relevance_score']
|
| 407 |
+
source_info['score_type'] = 'embedding'
|
| 408 |
+
else:
|
| 409 |
+
source_info['score'] = 0
|
| 410 |
+
source_info['score_type'] = 'unknown'
|
| 411 |
+
|
| 412 |
+
sources.append(source_info)
|
| 413 |
+
|
| 414 |
+
return sources
|
| 415 |
+
|
| 416 |
+
def _estimate_usage(self, query: str, answer: str) -> dict:
|
| 417 |
+
"""토큰 사용량 추정"""
|
| 418 |
+
# 간단한 단어 수 기반 추정
|
| 419 |
+
prompt_tokens = len(query.split()) * 2
|
| 420 |
+
completion_tokens = len(answer.split()) * 2
|
| 421 |
+
|
| 422 |
+
return {
|
| 423 |
+
'total_tokens': prompt_tokens + completion_tokens,
|
| 424 |
+
'prompt_tokens': prompt_tokens,
|
| 425 |
+
'completion_tokens': completion_tokens
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
def generate_answer(
|
| 429 |
+
self,
|
| 430 |
+
query: str,
|
| 431 |
+
top_k: int = None,
|
| 432 |
+
search_mode: str = None,
|
| 433 |
+
alpha: float = None
|
| 434 |
+
) -> dict:
|
| 435 |
+
"""
|
| 436 |
+
답변 생성 (chatbot_app.py 호환 메인 메서드)
|
| 437 |
+
|
| 438 |
+
Args:
|
| 439 |
+
query: 질문
|
| 440 |
+
top_k: 검색할 문서 수
|
| 441 |
+
search_mode: 검색 모드
|
| 442 |
+
alpha: 임베딩 가중치
|
| 443 |
+
|
| 444 |
+
Returns:
|
| 445 |
+
dict: answer, sources, search_mode, usage, elapsed_time, used_retrieval
|
| 446 |
+
"""
|
| 447 |
+
try:
|
| 448 |
+
start_time = time.time()
|
| 449 |
+
|
| 450 |
+
# 파라미터 설정 (검색 전에 먼저 설정)
|
| 451 |
+
if top_k is not None:
|
| 452 |
+
self.top_k = top_k
|
| 453 |
+
if search_mode is not None:
|
| 454 |
+
self.search_mode = search_mode
|
| 455 |
+
if alpha is not None:
|
| 456 |
+
self.alpha = alpha
|
| 457 |
+
|
| 458 |
+
# ===== Router로 검색 여부 결정 =====
|
| 459 |
+
router = QueryRouter()
|
| 460 |
+
classification = router.classify(query)
|
| 461 |
+
query_type = classification['type'] # 'greeting'/'thanks'/'document'/'out_of_scope'
|
| 462 |
+
|
| 463 |
+
logger.info(f"📍 분류: {query_type} "
|
| 464 |
+
f"(신뢰도: {classification['confidence']:.2f})")
|
| 465 |
+
|
| 466 |
+
# 2. 타입별 처리
|
| 467 |
+
if query_type in ['greeting', 'thanks', 'out_of_scope']:
|
| 468 |
+
# 검색 스킵
|
| 469 |
+
context = None
|
| 470 |
+
used_retrieval = False
|
| 471 |
+
self._last_retrieved_docs = []
|
| 472 |
+
|
| 473 |
+
# 동적 프롬프트 선택
|
| 474 |
+
system_prompt = PromptManager.get_prompt(query_type)
|
| 475 |
+
logger.info(f"⏭️ RAG 스킵: {query_type}")
|
| 476 |
+
|
| 477 |
+
elif query_type == 'document':
|
| 478 |
+
# RAG 수행
|
| 479 |
+
context = self._retrieve_and_format(query)
|
| 480 |
+
used_retrieval = True
|
| 481 |
+
|
| 482 |
+
# 동적 프롬프트 (context 포함)
|
| 483 |
+
system_prompt = PromptManager.get_prompt('document')
|
| 484 |
+
logger.info(f"🔍 RAG 수행: {len(self._last_retrieved_docs)}개 문서")
|
| 485 |
+
|
| 486 |
+
# 3. 답변 생성 (system_prompt 전달)
|
| 487 |
+
answer = self.generator.chat(
|
| 488 |
+
question=query,
|
| 489 |
+
context=context,
|
| 490 |
+
system_prompt=system_prompt # ← 추가!
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
elapsed_time = time.time() - start_time
|
| 494 |
+
|
| 495 |
+
# 대화 히스토리에 추가
|
| 496 |
+
self.chat_history.append({"role": "user", "content": query})
|
| 497 |
+
self.chat_history.append({"role": "assistant", "content": answer})
|
| 498 |
+
|
| 499 |
+
# 결과 반환 (RAGPipeline과 동일 형식)
|
| 500 |
+
return {
|
| 501 |
+
'answer': answer,
|
| 502 |
+
'sources': self._format_sources(self._last_retrieved_docs),
|
| 503 |
+
'used_retrieval': used_retrieval,
|
| 504 |
+
'query_type': query_type, # ← 추가!
|
| 505 |
+
'search_mode': self.search_mode if used_retrieval else 'direct',
|
| 506 |
+
'routing_info': classification,
|
| 507 |
+
'elapsed_time': elapsed_time,
|
| 508 |
+
'usage': self._estimate_usage(query, answer)
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
except Exception as e:
|
| 512 |
+
logger.error(f"❌ 답변 생성 실패: {e}")
|
| 513 |
+
import traceback
|
| 514 |
+
traceback.print_exc()
|
| 515 |
+
raise RuntimeError(f"답변 생성 실패: {str(e)}") from e
|
| 516 |
+
|
| 517 |
+
def chat(self, query: str) -> str:
|
| 518 |
+
"""간단한 대화 인터페이스"""
|
| 519 |
+
result = self.generate_answer(query)
|
| 520 |
+
return result['answer']
|
| 521 |
+
|
| 522 |
+
def clear_history(self):
|
| 523 |
+
"""대화 히스토리 초기화"""
|
| 524 |
+
self.chat_history = []
|
| 525 |
+
logger.info("🗑️ 대화 히스토리가 초기화되었습니다.")
|
| 526 |
+
|
| 527 |
+
def get_history(self) -> List[Dict]:
|
| 528 |
+
"""대화 히스토리 반환"""
|
| 529 |
+
return self.chat_history.copy()
|
| 530 |
+
|
| 531 |
+
def set_search_config(
|
| 532 |
+
self,
|
| 533 |
+
search_mode: str = None,
|
| 534 |
+
top_k: int = None,
|
| 535 |
+
alpha: float = None
|
| 536 |
+
):
|
| 537 |
+
"""검색 설정 변경"""
|
| 538 |
+
if search_mode is not None:
|
| 539 |
+
self.search_mode = search_mode
|
| 540 |
+
if top_k is not None:
|
| 541 |
+
self.top_k = top_k
|
| 542 |
+
if alpha is not None:
|
| 543 |
+
self.alpha = alpha
|
| 544 |
+
|
| 545 |
+
logger.info(
|
| 546 |
+
f"🔧 검색 설정 변경: mode={self.search_mode}, "
|
| 547 |
+
f"top_k={self.top_k}, alpha={self.alpha}"
|
| 548 |
+
)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
# 테스트용
|
| 552 |
+
if __name__ == "__main__":
|
| 553 |
+
from src.utils.config import RAGConfig
|
| 554 |
+
|
| 555 |
+
config = RAGConfig()
|
| 556 |
+
|
| 557 |
+
# GGUFRAGPipeline 초기화
|
| 558 |
+
pipeline = GGUFRAGPipeline(config=config)
|
| 559 |
+
|
| 560 |
+
# 테스트 질문들
|
| 561 |
+
test_questions = [
|
| 562 |
+
"안녕하세요",
|
| 563 |
+
"본 사업의 예산 범위는 어떻게 되나요?",
|
| 564 |
+
"고마워요!"
|
| 565 |
+
]
|
| 566 |
+
|
| 567 |
+
for question in test_questions:
|
| 568 |
+
print("\n" + "="*50)
|
| 569 |
+
print("테스트 질문:", question)
|
| 570 |
+
print("="*50)
|
| 571 |
+
|
| 572 |
+
result = pipeline.generate_answer(question)
|
| 573 |
+
|
| 574 |
+
print(f"\n라우팅: {result['routing_info']['route']}")
|
| 575 |
+
print(f"검색 사용: {result['used_retrieval']}")
|
| 576 |
+
print("\n응답:")
|
| 577 |
+
print(result['answer'])
|
| 578 |
+
print(f"\n소요 시간: {result['elapsed_time']:.2f}초")
|
| 579 |
+
print(f"참고 문서: {len(result['sources'])}개")
|
| 580 |
+
print("="*50)
|
src/generator/generator_lee.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_openai import ChatOpenAI
|
| 2 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 3 |
+
from langchain_core.output_parsers import StrOutputParser
|
| 4 |
+
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
|
| 5 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 6 |
+
from langsmith import traceable
|
| 7 |
+
import time
|
| 8 |
+
from typing import List, Dict
|
| 9 |
+
|
| 10 |
+
from src.utils.config import RAGConfig
|
| 11 |
+
from src.retriever.retriever import RAGRetriever
|
| 12 |
+
from src.router.query_router import QueryRouter
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class RAGPipeline:
|
| 16 |
+
"""대화형 RAG 파이프라인 - LangChain Chain 기반"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, config: RAGConfig = None, model: str = None, top_k: int = None):
|
| 19 |
+
"""초기화"""
|
| 20 |
+
self.config = config or RAGConfig()
|
| 21 |
+
self.model = model or self.config.LLM_MODEL_NAME
|
| 22 |
+
self.top_k = top_k or self.config.DEFAULT_TOP_K
|
| 23 |
+
|
| 24 |
+
# 검색 설정
|
| 25 |
+
self.search_mode = self.config.DEFAULT_SEARCH_MODE
|
| 26 |
+
self.alpha = self.config.DEFAULT_ALPHA
|
| 27 |
+
|
| 28 |
+
# LLM 초기화 (LangChain ChatOpenAI)
|
| 29 |
+
self.llm = ChatOpenAI(
|
| 30 |
+
model=self.model,
|
| 31 |
+
openai_api_key=self.config.OPENAI_API_KEY,
|
| 32 |
+
timeout=60.0,
|
| 33 |
+
max_retries=3
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Retriever 및 라우터 초기화
|
| 37 |
+
self.retriever = RAGRetriever(config=self.config)
|
| 38 |
+
self.router = QueryRouter()
|
| 39 |
+
self._direct_responses = {
|
| 40 |
+
'greeting': "안녕하세요! 공공입찰 RFP 관련 궁금한 사항을 알려주시면 자료를 찾아 드릴게요.",
|
| 41 |
+
'thanks': "도움이 되었다니 다행입니다. 추가로 궁금한 점이 있으면 언제든지 말씀해 주세요!",
|
| 42 |
+
'out_of_scope': "해당 질문은 현재 보유한 입찰·사업 문서에서 다루지 않습니다. 다른 질문을 시도해 주세요."
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# 대화 히스토리
|
| 46 |
+
self.chat_history: List[Dict] = []
|
| 47 |
+
|
| 48 |
+
# 마지막 검색 결과 저장 (sources 반환용)
|
| 49 |
+
self._last_retrieved_docs = []
|
| 50 |
+
|
| 51 |
+
# 프롬프트 템플릿 (대화 히스토리 포함)
|
| 52 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 53 |
+
("system", """당신은 공공입찰 RFP를 분석하는 입찰메이트 사내 분석가입니다. 제공된 컨텍스트만으로 요구사항·예산·대상 기관·제출 방식 등을 구조화해 의사결정을 지원하세요.
|
| 54 |
+
|
| 55 |
+
# 규칙
|
| 56 |
+
- 답변은 한국어로 작성합니다.
|
| 57 |
+
- 컨텍스트 밖 내용을 추측하지 않습니다.
|
| 58 |
+
- 컨텍스트가 비어있거나 질문과 직접 관련된 사실이 없으면 "문서에서 해당 정보를 찾을 수 없습니다." 한 문장으로만 답합니다.
|
| 59 |
+
- 여러 문서를 비교할 때는 문서별 차이를 표 또는 목록으로 정리합니다.
|
| 60 |
+
- 숫자에는 가능한 단위를 포함합니다.
|
| 61 |
+
- 직전 대화 맥락을 반영하되, 확인되지 않은 내용을 추론해 추가하지 않습니다.
|
| 62 |
+
|
| 63 |
+
# 답변 형식
|
| 64 |
+
1. 한 줄 요약: 질문 핵심을 한두 문장으로 작성합니다.
|
| 65 |
+
2. 상세 답변: [요구사항], [대상 기관], [예산], [제출 형식/방법], [평가 기준] 등 문서에서 확인된 항목만 정리합니다.
|
| 66 |
+
3. 근거 정보: 위 답변의 근거가 된 문장이나 문단을 요약합니다.
|
| 67 |
+
4. 부족한 정보: 문서에서 찾을 수 없는 항목은 "문서에서 확인 불가"로 표기합니다."""),
|
| 68 |
+
|
| 69 |
+
# 대화 히스토리
|
| 70 |
+
MessagesPlaceholder(variable_name="chat_history"),
|
| 71 |
+
|
| 72 |
+
# 현재 질문과 컨텍스트
|
| 73 |
+
("user", """# 컨텍스트
|
| 74 |
+
{context}
|
| 75 |
+
|
| 76 |
+
# 질문
|
| 77 |
+
{question}
|
| 78 |
+
|
| 79 |
+
위 규칙에 따라 답변하세요.""")
|
| 80 |
+
])
|
| 81 |
+
|
| 82 |
+
# Chain 구성
|
| 83 |
+
self.chain = (
|
| 84 |
+
{
|
| 85 |
+
"context": RunnableLambda(self._retrieve_and_format),
|
| 86 |
+
"question": RunnablePassthrough(),
|
| 87 |
+
"chat_history": RunnableLambda(lambda x: self._get_chat_history())
|
| 88 |
+
}
|
| 89 |
+
| self.prompt
|
| 90 |
+
| self.llm
|
| 91 |
+
| StrOutputParser()
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
print(f"✅ RAG 파이프라인 초기화 완료")
|
| 95 |
+
print(f" - 모델: {self.model}")
|
| 96 |
+
print(f" - 기본 top_k: {self.top_k}")
|
| 97 |
+
print(f" - 검색 모드: {self.search_mode}")
|
| 98 |
+
|
| 99 |
+
def _get_chat_history(self) -> List:
|
| 100 |
+
"""대화 히스토리를 LangChain 메시지 형식으로 변환"""
|
| 101 |
+
messages = []
|
| 102 |
+
for msg in self.chat_history:
|
| 103 |
+
if msg["role"] == "user":
|
| 104 |
+
messages.append(HumanMessage(content=msg["content"]))
|
| 105 |
+
else:
|
| 106 |
+
messages.append(AIMessage(content=msg["content"]))
|
| 107 |
+
return messages
|
| 108 |
+
|
| 109 |
+
def _retrieve_and_format(self, query: str) -> str:
|
| 110 |
+
"""검��� 수행 및 컨텍스트 포맷팅"""
|
| 111 |
+
# 검색 모드에 따라 문서 검색
|
| 112 |
+
if self.search_mode == "embedding":
|
| 113 |
+
docs = self.retriever.search(query, top_k=self.top_k)
|
| 114 |
+
elif self.search_mode == "hybrid":
|
| 115 |
+
docs = self.retriever.hybrid_search(query, top_k=self.top_k, alpha=self.alpha)
|
| 116 |
+
elif self.search_mode == "hybrid_rerank":
|
| 117 |
+
docs = self.retriever.hybrid_search_with_rerank(
|
| 118 |
+
query, top_k=self.top_k, alpha=self.alpha
|
| 119 |
+
)
|
| 120 |
+
else:
|
| 121 |
+
docs = self.retriever.search(query, top_k=self.top_k)
|
| 122 |
+
|
| 123 |
+
# 마지막 검색 결과 저장
|
| 124 |
+
self._last_retrieved_docs = docs
|
| 125 |
+
|
| 126 |
+
# 컨텍스트 포맷팅
|
| 127 |
+
return self._format_context(docs)
|
| 128 |
+
|
| 129 |
+
def _format_context(self, retrieved_docs: list) -> str:
|
| 130 |
+
"""검색된 문서를 컨텍스트로 변환"""
|
| 131 |
+
if not retrieved_docs:
|
| 132 |
+
return "관련 문서를 찾을 수 없습니다."
|
| 133 |
+
|
| 134 |
+
context_parts = []
|
| 135 |
+
for i, doc in enumerate(retrieved_docs, 1):
|
| 136 |
+
context_parts.append(f"[문서 {i}]\n{doc['content']}\n")
|
| 137 |
+
return "\n".join(context_parts)
|
| 138 |
+
|
| 139 |
+
def _format_sources(self, retrieved_docs: list) -> list:
|
| 140 |
+
"""검색된 문서를 sources 형식으로 변환"""
|
| 141 |
+
sources = []
|
| 142 |
+
for doc in retrieved_docs:
|
| 143 |
+
source_info = {
|
| 144 |
+
'content': doc['content'],
|
| 145 |
+
'metadata': doc['metadata'],
|
| 146 |
+
'filename': doc.get('filename', 'N/A'),
|
| 147 |
+
'organization': doc.get('organization', 'N/A')
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
# 검색 모드에 따라 점수 필드가 다름
|
| 151 |
+
if 'rerank_score' in doc:
|
| 152 |
+
source_info['score'] = doc['rerank_score']
|
| 153 |
+
source_info['score_type'] = 'rerank'
|
| 154 |
+
elif 'hybrid_score' in doc:
|
| 155 |
+
source_info['score'] = doc['hybrid_score']
|
| 156 |
+
source_info['score_type'] = 'hybrid'
|
| 157 |
+
elif 'relevance_score' in doc:
|
| 158 |
+
source_info['score'] = doc['relevance_score']
|
| 159 |
+
source_info['score_type'] = 'embedding'
|
| 160 |
+
else:
|
| 161 |
+
source_info['score'] = 0
|
| 162 |
+
source_info['score_type'] = 'unknown'
|
| 163 |
+
|
| 164 |
+
sources.append(source_info)
|
| 165 |
+
return sources
|
| 166 |
+
|
| 167 |
+
@traceable(
|
| 168 |
+
name="RAG_Generate_Answer",
|
| 169 |
+
metadata={"component": "generator", "version": "2.0"}
|
| 170 |
+
)
|
| 171 |
+
def generate_answer(
|
| 172 |
+
self,
|
| 173 |
+
query: str,
|
| 174 |
+
top_k: int = None,
|
| 175 |
+
search_mode: str = None,
|
| 176 |
+
alpha: float = None
|
| 177 |
+
) -> dict:
|
| 178 |
+
"""
|
| 179 |
+
답변 생성 (Chain 기반)
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
query: 질문
|
| 183 |
+
top_k: 검색할 문서 수
|
| 184 |
+
search_mode: 검색 모드 ("embedding", "hybrid", "hybrid_rerank")
|
| 185 |
+
alpha: 임베딩 가중치 (0~1)
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
dict: answer, sources, search_mode, usage
|
| 189 |
+
"""
|
| 190 |
+
try:
|
| 191 |
+
start_time = time.time()
|
| 192 |
+
|
| 193 |
+
classification = self.router.classify(query)
|
| 194 |
+
query_type = classification.get('type', 'document')
|
| 195 |
+
|
| 196 |
+
# 비문서 질의는 즉시 응답
|
| 197 |
+
if query_type != 'document':
|
| 198 |
+
print(f"⏭️ 라우터: 검색 생략 ({query_type})")
|
| 199 |
+
answer = self._direct_responses.get(
|
| 200 |
+
query_type,
|
| 201 |
+
self._direct_responses['out_of_scope']
|
| 202 |
+
)
|
| 203 |
+
elapsed_time = time.time() - start_time
|
| 204 |
+
self._last_retrieved_docs = []
|
| 205 |
+
|
| 206 |
+
self.chat_history.append({"role": "user", "content": query})
|
| 207 |
+
self.chat_history.append({"role": "assistant", "content": answer})
|
| 208 |
+
|
| 209 |
+
return {
|
| 210 |
+
'answer': answer,
|
| 211 |
+
'sources': [],
|
| 212 |
+
'search_mode': 'none',
|
| 213 |
+
'elapsed_time': elapsed_time,
|
| 214 |
+
'usage': {
|
| 215 |
+
'total_tokens': 0,
|
| 216 |
+
'prompt_tokens': 0,
|
| 217 |
+
'completion_tokens': 0
|
| 218 |
+
},
|
| 219 |
+
'routing': classification
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
# 파라미터 설정
|
| 223 |
+
if top_k is not None:
|
| 224 |
+
self.top_k = top_k
|
| 225 |
+
if search_mode is not None:
|
| 226 |
+
self.search_mode = search_mode
|
| 227 |
+
if alpha is not None:
|
| 228 |
+
self.alpha = alpha
|
| 229 |
+
|
| 230 |
+
# Chain 실행
|
| 231 |
+
answer = self.chain.invoke(query)
|
| 232 |
+
|
| 233 |
+
# 검색 결과가 없으면 안전 응답으로 대체
|
| 234 |
+
if not self._last_retrieved_docs:
|
| 235 |
+
answer = "문서에서 관련 정보를 찾을 수 없습니다. 다른 질문을 입력해 주세요."
|
| 236 |
+
print("⚠️ 검색 결과 없음 - 안전 응답 반환")
|
| 237 |
+
|
| 238 |
+
elapsed_time = time.time() - start_time
|
| 239 |
+
|
| 240 |
+
# 대화 히스토리에 추가
|
| 241 |
+
self.chat_history.append({"role": "user", "content": query})
|
| 242 |
+
self.chat_history.append({"role": "assistant", "content": answer})
|
| 243 |
+
|
| 244 |
+
# 토큰 사용량 추정 (LangChain에서는 직접 접근 어려움)
|
| 245 |
+
estimated_tokens = len(query.split()) + len(answer.split()) * 2
|
| 246 |
+
|
| 247 |
+
return {
|
| 248 |
+
'answer': answer,
|
| 249 |
+
'sources': self._format_sources(self._last_retrieved_docs),
|
| 250 |
+
'search_mode': self.search_mode,
|
| 251 |
+
'elapsed_time': elapsed_time,
|
| 252 |
+
'usage': {
|
| 253 |
+
'total_tokens': estimated_tokens,
|
| 254 |
+
'prompt_tokens': 0,
|
| 255 |
+
'completion_tokens': 0
|
| 256 |
+
},
|
| 257 |
+
'routing': classification
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
except Exception as e:
|
| 261 |
+
print(f"❌ 답변 생성 실패: {e}")
|
| 262 |
+
import traceback
|
| 263 |
+
traceback.print_exc()
|
| 264 |
+
raise RuntimeError(f"답변 생성 실패: {str(e)}") from e
|
| 265 |
+
|
| 266 |
+
def chat(self, query: str) -> str:
|
| 267 |
+
"""
|
| 268 |
+
간단한 대화 인터페이스
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
query: 질문
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
str: 답변 텍스트만 반환
|
| 275 |
+
"""
|
| 276 |
+
result = self.generate_answer(query)
|
| 277 |
+
return result['answer']
|
| 278 |
+
|
| 279 |
+
def clear_history(self):
|
| 280 |
+
"""대화 히스토리 초기화"""
|
| 281 |
+
self.chat_history = []
|
| 282 |
+
print("🗑️ 대화 히스토리가 초기화되었습니다.")
|
| 283 |
+
|
| 284 |
+
def get_history(self) -> List[Dict]:
|
| 285 |
+
"""대화 히스토리 반환"""
|
| 286 |
+
return self.chat_history.copy()
|
| 287 |
+
|
| 288 |
+
def set_search_config(self, search_mode: str = None, top_k: int = None, alpha: float = None):
|
| 289 |
+
"""검색 설정 변경"""
|
| 290 |
+
if search_mode is not None:
|
| 291 |
+
self.search_mode = search_mode
|
| 292 |
+
if top_k is not None:
|
| 293 |
+
self.top_k = top_k
|
| 294 |
+
if alpha is not None:
|
| 295 |
+
self.alpha = alpha
|
| 296 |
+
|
| 297 |
+
print(f"🔧 검색 설정 변경: mode={self.search_mode}, top_k={self.top_k}, alpha={self.alpha}")
|
| 298 |
+
|
| 299 |
+
def print_result(self, result: dict, query: str = None):
|
| 300 |
+
"""결과 출력"""
|
| 301 |
+
print("\n" + "="*60)
|
| 302 |
+
if query:
|
| 303 |
+
print(f"질문: {query}")
|
| 304 |
+
print(f"검색 모드: {result.get('search_mode', 'N/A')}")
|
| 305 |
+
if 'elapsed_time' in result:
|
| 306 |
+
print(f"소요 시간: {result['elapsed_time']:.2f}초")
|
| 307 |
+
print("="*60)
|
| 308 |
+
print(f"\n💬 답변:\n{result['answer']}")
|
| 309 |
+
print(f"\n📚 참고 문서 ({len(result['sources'])}개):")
|
| 310 |
+
for i, source in enumerate(result['sources'], 1):
|
| 311 |
+
score = source.get('score', 0)
|
| 312 |
+
score_type = source.get('score_type', '')
|
| 313 |
+
print(f" [{i}] {source['filename']}")
|
| 314 |
+
print(f" 점수: {score:.3f} ({score_type})")
|
| 315 |
+
print("="*60)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# 대화형 실행
|
| 319 |
+
def interactive_mode():
|
| 320 |
+
"""대화형 모드 실행"""
|
| 321 |
+
print("=" * 60)
|
| 322 |
+
print("대화형 RAG 시스템 초기화 중...")
|
| 323 |
+
print("=" * 60)
|
| 324 |
+
|
| 325 |
+
config = RAGConfig()
|
| 326 |
+
pipeline = RAGPipeline(config=config)
|
| 327 |
+
|
| 328 |
+
print("\n" + "=" * 60)
|
| 329 |
+
print("대화형 모드 시작")
|
| 330 |
+
print("명령어: 'quit' (종료), 'clear' (히스토리 초기화), 'mode' (검색모드 변경)")
|
| 331 |
+
print("=" * 60)
|
| 332 |
+
|
| 333 |
+
while True:
|
| 334 |
+
user_query = input("\n질문: ").strip()
|
| 335 |
+
|
| 336 |
+
if not user_query:
|
| 337 |
+
continue
|
| 338 |
+
|
| 339 |
+
if user_query.lower() in ['quit', 'exit', '종료', 'q']:
|
| 340 |
+
print("시스템을 종료합니다.")
|
| 341 |
+
break
|
| 342 |
+
|
| 343 |
+
if user_query.lower() == 'clear':
|
| 344 |
+
pipeline.clear_history()
|
| 345 |
+
continue
|
| 346 |
+
|
| 347 |
+
if user_query.lower() == 'mode':
|
| 348 |
+
print("\n검색 모드 선택:")
|
| 349 |
+
print("1. embedding - 임베딩 검색")
|
| 350 |
+
print("2. hybrid - BM25 + 임베딩")
|
| 351 |
+
print("3. hybrid_rerank - Hybrid + Re-ranker (권장)")
|
| 352 |
+
choice = input("선택 (1/2/3): ").strip()
|
| 353 |
+
modes = {'1': 'embedding', '2': 'hybrid', '3': 'hybrid_rerank'}
|
| 354 |
+
if choice in modes:
|
| 355 |
+
pipeline.set_search_config(search_mode=modes[choice])
|
| 356 |
+
continue
|
| 357 |
+
|
| 358 |
+
try:
|
| 359 |
+
result = pipeline.generate_answer(query=user_query)
|
| 360 |
+
pipeline.print_result(result, user_query)
|
| 361 |
+
|
| 362 |
+
# 소스 출력 여부
|
| 363 |
+
show_source = input("\n참조 문서 상세 보기? (y/n): ").strip().lower()
|
| 364 |
+
if show_source == 'y':
|
| 365 |
+
for i, source in enumerate(result['sources'], 1):
|
| 366 |
+
print(f"\n{'='*40}")
|
| 367 |
+
print(f"[문서 {i}] {source['filename']}")
|
| 368 |
+
print(f"발주기관: {source['organization']}")
|
| 369 |
+
print(f"내용:\n{source['content'][:500]}...")
|
| 370 |
+
|
| 371 |
+
except Exception as e:
|
| 372 |
+
print(f"❌ 오류 발생: {e}")
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
# 사용 예시
|
| 376 |
+
if __name__ == "__main__":
|
| 377 |
+
interactive_mode()
|
src/loader/__init__.py
ADDED
|
File without changes
|
src/loader/preprocess_pipeline.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RAG 데이터 전처리 전체 파이프라인
|
| 3 |
+
텍스트 추출 → 정제 → 청킹 → 저장
|
| 4 |
+
|
| 5 |
+
모든 전처리 클래스를 하나의 파일로 통합
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import zlib
|
| 11 |
+
import struct
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from tqdm import tqdm
|
| 14 |
+
from pypdf import PdfReader
|
| 15 |
+
import olefile
|
| 16 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 17 |
+
|
| 18 |
+
from src.utils.config import PreprocessConfig
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ============================================================
|
| 22 |
+
# 텍스트 추출 클래스
|
| 23 |
+
# ============================================================
|
| 24 |
+
|
| 25 |
+
class TextExtractor:
|
| 26 |
+
"""PDF 및 HWP 파일에서 텍스트 추출"""
|
| 27 |
+
|
| 28 |
+
@staticmethod
|
| 29 |
+
def extract_pdf(filepath: str) -> str:
|
| 30 |
+
"""
|
| 31 |
+
PDF 파일에서 텍스트 추출
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
filepath: PDF 파일 경로
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
추출된 텍스트
|
| 38 |
+
"""
|
| 39 |
+
try:
|
| 40 |
+
reader = PdfReader(filepath)
|
| 41 |
+
page_texts = [
|
| 42 |
+
page.extract_text()
|
| 43 |
+
for page in reader.pages
|
| 44 |
+
if page.extract_text()
|
| 45 |
+
]
|
| 46 |
+
return "\n\n".join(page_texts)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
return f"[PDF 추출 실패: {e}]"
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def extract_hwp(filepath: str) -> str:
|
| 52 |
+
"""
|
| 53 |
+
HWP 파일에서 텍스트 추출
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
filepath: HWP 파일 경로
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
추출된 텍스트
|
| 60 |
+
"""
|
| 61 |
+
try:
|
| 62 |
+
f = olefile.OleFileIO(filepath)
|
| 63 |
+
dirs = f.listdir()
|
| 64 |
+
|
| 65 |
+
# HWP 5.0 파일 검증
|
| 66 |
+
if ["FileHeader"] not in dirs or ["\x05HwpSummaryInformation"] not in dirs:
|
| 67 |
+
return "[HWP 추출 실패: 유효한 HWP 5.0 파일이 아님]"
|
| 68 |
+
|
| 69 |
+
# 압축 여부 확인
|
| 70 |
+
header = f.openstream("FileHeader")
|
| 71 |
+
header_data = header.read()
|
| 72 |
+
is_compressed = (header_data[36] & 1) == 1
|
| 73 |
+
|
| 74 |
+
# 섹션 번호 정렬
|
| 75 |
+
nums = [
|
| 76 |
+
int(d[1][len("Section"):])
|
| 77 |
+
for d in dirs
|
| 78 |
+
if d[0] == "BodyText"
|
| 79 |
+
]
|
| 80 |
+
sections = [f"BodyText/Section{x}" for x in sorted(nums)]
|
| 81 |
+
|
| 82 |
+
# 텍스트 추출
|
| 83 |
+
text = ""
|
| 84 |
+
for section in sections:
|
| 85 |
+
bodytext = f.openstream(section)
|
| 86 |
+
data = bodytext.read()
|
| 87 |
+
|
| 88 |
+
# 압축 해제
|
| 89 |
+
if is_compressed:
|
| 90 |
+
unpacked_data = zlib.decompress(data, -15)
|
| 91 |
+
else:
|
| 92 |
+
unpacked_data = data
|
| 93 |
+
|
| 94 |
+
# 레코드 파싱
|
| 95 |
+
i = 0
|
| 96 |
+
size = len(unpacked_data)
|
| 97 |
+
while i < size:
|
| 98 |
+
header = struct.unpack_from("<I", unpacked_data, i)[0]
|
| 99 |
+
rec_type = header & 0x3ff
|
| 100 |
+
rec_len = (header >> 20) & 0xfff
|
| 101 |
+
|
| 102 |
+
# 텍스트 레코드 (타입 67)
|
| 103 |
+
if rec_type == 67:
|
| 104 |
+
rec_data = unpacked_data[i + 4 : i + 4 + rec_len]
|
| 105 |
+
text += rec_data.decode('utf-16', errors='ignore')
|
| 106 |
+
|
| 107 |
+
i += 4 + rec_len
|
| 108 |
+
|
| 109 |
+
f.close()
|
| 110 |
+
return text
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
return f"[HWP 추출 실패: {e}]"
|
| 114 |
+
|
| 115 |
+
@staticmethod
|
| 116 |
+
def extract(filepath: str, file_format: str) -> str:
|
| 117 |
+
"""
|
| 118 |
+
파일 형식에 따라 텍스트 추출
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
filepath: 파일 경로
|
| 122 |
+
file_format: 파일 형식 ('pdf' 또는 'hwp')
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
추출된 텍스트
|
| 126 |
+
"""
|
| 127 |
+
if not os.path.exists(filepath):
|
| 128 |
+
return "[추출 실패: 파일 없음]"
|
| 129 |
+
|
| 130 |
+
file_format = file_format.lower()
|
| 131 |
+
|
| 132 |
+
if file_format == 'pdf':
|
| 133 |
+
return TextExtractor.extract_pdf(filepath)
|
| 134 |
+
elif file_format == 'hwp':
|
| 135 |
+
return TextExtractor.extract_hwp(filepath)
|
| 136 |
+
else:
|
| 137 |
+
return f"[추출 실패: 알 수 없는 파일 형식 ({file_format})]"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ============================================================
|
| 141 |
+
# 텍스트 정제 클래스
|
| 142 |
+
# ============================================================
|
| 143 |
+
|
| 144 |
+
class TextCleaner:
|
| 145 |
+
"""텍스트 정제 및 검증"""
|
| 146 |
+
|
| 147 |
+
@staticmethod
|
| 148 |
+
def clean(text: str) -> str:
|
| 149 |
+
"""
|
| 150 |
+
텍스트 정제
|
| 151 |
+
- 특수문자 제거 (한글, 영문, 숫자, 기본 공백문자만 유지)
|
| 152 |
+
- NULL 문자 제거
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
text: 원본 텍스트
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
정제된 텍스트
|
| 159 |
+
"""
|
| 160 |
+
# 허용: 영문, 숫자, 공백, 탭, 줄바꿈, 한글
|
| 161 |
+
cleaned = re.sub(
|
| 162 |
+
r'[^\x20-\x7E\n\r\t\uAC00-\uD7AF]',
|
| 163 |
+
'',
|
| 164 |
+
str(text)
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# NULL 문자 제거
|
| 168 |
+
cleaned = cleaned.replace('\x00', '')
|
| 169 |
+
|
| 170 |
+
return cleaned
|
| 171 |
+
|
| 172 |
+
@staticmethod
|
| 173 |
+
def validate(text: str, min_length: int = 100) -> bool:
|
| 174 |
+
"""
|
| 175 |
+
텍스트 유효성 검사
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
text: 검증할 텍스트
|
| 179 |
+
min_length: 최소 길이
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
유효 여부
|
| 183 |
+
"""
|
| 184 |
+
if not text or text.strip() == "":
|
| 185 |
+
return False
|
| 186 |
+
|
| 187 |
+
if "[추출 실패" in text:
|
| 188 |
+
return False
|
| 189 |
+
|
| 190 |
+
if len(text) < min_length:
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
return True
|
| 194 |
+
|
| 195 |
+
@staticmethod
|
| 196 |
+
def get_stats(text: str) -> dict:
|
| 197 |
+
"""
|
| 198 |
+
텍스트 통계 정보
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
text: 분석할 텍스트
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
통계 딕셔너리
|
| 205 |
+
"""
|
| 206 |
+
return {
|
| 207 |
+
'length': len(text),
|
| 208 |
+
'lines': text.count('\n') + 1,
|
| 209 |
+
'words': len(text.split()),
|
| 210 |
+
'is_valid': TextCleaner.validate(text)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ============================================================
|
| 215 |
+
# 문서 청킹 클래스
|
| 216 |
+
# ============================================================
|
| 217 |
+
|
| 218 |
+
class DocumentChunker:
|
| 219 |
+
"""문서를 청크로 분할"""
|
| 220 |
+
|
| 221 |
+
def __init__(self, config: PreprocessConfig):
|
| 222 |
+
"""
|
| 223 |
+
초기화
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
config: 전처리 설정 객체
|
| 227 |
+
"""
|
| 228 |
+
self.config = config
|
| 229 |
+
|
| 230 |
+
# LangChain 텍스트 분할기 초기화
|
| 231 |
+
self.splitter = RecursiveCharacterTextSplitter(
|
| 232 |
+
chunk_size=config.CHUNK_SIZE,
|
| 233 |
+
chunk_overlap=config.CHUNK_OVERLAP,
|
| 234 |
+
separators=config.SEPARATORS,
|
| 235 |
+
length_function=len,
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
def chunk_document(self, text: str, metadata: dict) -> list:
|
| 239 |
+
"""
|
| 240 |
+
단일 문서 청킹
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
text: 문서 텍스트
|
| 244 |
+
metadata: 문서 메타데이터
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
청크 리스트
|
| 248 |
+
"""
|
| 249 |
+
try:
|
| 250 |
+
chunks = self.splitter.split_text(text)
|
| 251 |
+
except Exception as e:
|
| 252 |
+
print(f"WARNING: 문서 분할 실패 - {e}")
|
| 253 |
+
return []
|
| 254 |
+
|
| 255 |
+
chunk_records = []
|
| 256 |
+
filename = metadata.get('파일명', 'unknown')
|
| 257 |
+
|
| 258 |
+
for i, chunk_content in enumerate(chunks, 1):
|
| 259 |
+
chunk_record = metadata.copy()
|
| 260 |
+
chunk_record['chunk_id'] = f"{filename}_chunk_{i:04d}"
|
| 261 |
+
chunk_record['chunk_content'] = chunk_content
|
| 262 |
+
chunk_records.append(chunk_record)
|
| 263 |
+
|
| 264 |
+
return chunk_records
|
| 265 |
+
|
| 266 |
+
def chunk_dataframe(
|
| 267 |
+
self,
|
| 268 |
+
df: pd.DataFrame,
|
| 269 |
+
text_column: str = 'text_content'
|
| 270 |
+
) -> pd.DataFrame:
|
| 271 |
+
"""
|
| 272 |
+
DataFrame 전체 청킹
|
| 273 |
+
|
| 274 |
+
Args:
|
| 275 |
+
df: 원본 DataFrame
|
| 276 |
+
text_column: 텍스트가 들어있는 컬럼명
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
청크 DataFrame
|
| 280 |
+
"""
|
| 281 |
+
print(f"청킹 시작 (크기: {self.config.CHUNK_SIZE}, "
|
| 282 |
+
f"오버랩: {self.config.CHUNK_OVERLAP})...")
|
| 283 |
+
|
| 284 |
+
all_chunks = []
|
| 285 |
+
|
| 286 |
+
for index, row in tqdm(df.iterrows(), total=len(df), desc="청킹"):
|
| 287 |
+
text = row[text_column]
|
| 288 |
+
|
| 289 |
+
# 메타데이터 준비 (텍스트 컬럼 제외)
|
| 290 |
+
metadata = row.to_dict()
|
| 291 |
+
metadata.pop(text_column, None)
|
| 292 |
+
metadata.pop('text_length', None)
|
| 293 |
+
|
| 294 |
+
# 청킹
|
| 295 |
+
chunks = self.chunk_document(text, metadata)
|
| 296 |
+
all_chunks.extend(chunks)
|
| 297 |
+
|
| 298 |
+
df_chunks = pd.DataFrame(all_chunks)
|
| 299 |
+
|
| 300 |
+
print(f"청킹 완료: 원본 {len(df)}개 → 청크 {len(df_chunks)}개")
|
| 301 |
+
|
| 302 |
+
return df_chunks
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ============================================================
|
| 306 |
+
# RAG 전처리 파이프라인
|
| 307 |
+
# ============================================================
|
| 308 |
+
|
| 309 |
+
class RAGPreprocessPipeline:
|
| 310 |
+
"""RAG 데이터 전처리 전체 파이프라인"""
|
| 311 |
+
|
| 312 |
+
def __init__(self, config: PreprocessConfig = None):
|
| 313 |
+
"""
|
| 314 |
+
초기화
|
| 315 |
+
|
| 316 |
+
Args:
|
| 317 |
+
config: 전처리 설정 (None이면 기본값)
|
| 318 |
+
"""
|
| 319 |
+
self.config = config or PreprocessConfig()
|
| 320 |
+
self.extractor = TextExtractor()
|
| 321 |
+
self.cleaner = TextCleaner()
|
| 322 |
+
self.chunker = DocumentChunker(self.config)
|
| 323 |
+
|
| 324 |
+
# 통계 정보
|
| 325 |
+
self.stats = {
|
| 326 |
+
'total_files': 0,
|
| 327 |
+
'success_files': 0,
|
| 328 |
+
'failed_files': 0,
|
| 329 |
+
'total_chunks': 0
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
def extract_from_files(self) -> pd.DataFrame:
|
| 333 |
+
"""
|
| 334 |
+
1단계: 파일에서 텍스트 추출
|
| 335 |
+
|
| 336 |
+
Returns:
|
| 337 |
+
텍스트가 추출된 DataFrame
|
| 338 |
+
"""
|
| 339 |
+
print("\n" + "="*60)
|
| 340 |
+
print("1단계: 텍스트 추출")
|
| 341 |
+
print("="*60)
|
| 342 |
+
|
| 343 |
+
# 메타데이터 로드
|
| 344 |
+
df = pd.read_csv(self.config.META_CSV_PATH)
|
| 345 |
+
self.stats['total_files'] = len(df)
|
| 346 |
+
print(f"파일 로드 완료: {len(df)}개")
|
| 347 |
+
|
| 348 |
+
extracted_data = []
|
| 349 |
+
|
| 350 |
+
for index, row in tqdm(df.iterrows(), total=len(df), desc="텍스트 추출"):
|
| 351 |
+
filepath = os.path.join(self.config.BASE_FOLDER_PATH, row['파일명'])
|
| 352 |
+
file_format = row['파일형식']
|
| 353 |
+
|
| 354 |
+
# 텍스트 추출
|
| 355 |
+
raw_text = self.extractor.extract(filepath, file_format)
|
| 356 |
+
|
| 357 |
+
# 정제
|
| 358 |
+
cleaned_text = self.cleaner.clean(raw_text)
|
| 359 |
+
|
| 360 |
+
# HWP 특수 처리 (텍스트가 너무 짧으면 실패로 간주)
|
| 361 |
+
if file_format == 'hwp' and len(cleaned_text) < self.config.MIN_TEXT_LENGTH:
|
| 362 |
+
if "[추출 실패" not in cleaned_text:
|
| 363 |
+
cleaned_text = "[추출 실패: HWP 텍스트 너무 짧음]"
|
| 364 |
+
|
| 365 |
+
# 통계 업데이트
|
| 366 |
+
if self.cleaner.validate(cleaned_text):
|
| 367 |
+
self.stats['success_files'] += 1
|
| 368 |
+
else:
|
| 369 |
+
self.stats['failed_files'] += 1
|
| 370 |
+
|
| 371 |
+
# 결과 저장
|
| 372 |
+
new_row = row.to_dict()
|
| 373 |
+
new_row['full_text'] = cleaned_text
|
| 374 |
+
|
| 375 |
+
# 불필요한 컬럼 제거
|
| 376 |
+
if '텍스트' in new_row:
|
| 377 |
+
del new_row['텍스트']
|
| 378 |
+
|
| 379 |
+
extracted_data.append(new_row)
|
| 380 |
+
|
| 381 |
+
result_df = pd.DataFrame(extracted_data)
|
| 382 |
+
|
| 383 |
+
print(f"\n텍스트 추출 완료:")
|
| 384 |
+
print(f" - 성공: {self.stats['success_files']}개")
|
| 385 |
+
print(f" - 실패: {self.stats['failed_files']}개")
|
| 386 |
+
|
| 387 |
+
return result_df
|
| 388 |
+
|
| 389 |
+
def clean_dataframe(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 390 |
+
"""
|
| 391 |
+
2단계: DataFrame 정제
|
| 392 |
+
|
| 393 |
+
Args:
|
| 394 |
+
df: 원본 DataFrame
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
정제된 DataFrame
|
| 398 |
+
"""
|
| 399 |
+
print("\n" + "="*60)
|
| 400 |
+
print("2단계: 텍스트 정제")
|
| 401 |
+
print("="*60)
|
| 402 |
+
|
| 403 |
+
# 컬럼명 변경
|
| 404 |
+
df['text_content'] = df['full_text']
|
| 405 |
+
df = df.drop(columns=['full_text'])
|
| 406 |
+
|
| 407 |
+
# 결측치 처리
|
| 408 |
+
df['text_content'] = df['text_content'].fillna('')
|
| 409 |
+
|
| 410 |
+
# 통계 정보 추가
|
| 411 |
+
df['text_length'] = df['text_content'].apply(len)
|
| 412 |
+
|
| 413 |
+
print(f"텍스트 정제 완료")
|
| 414 |
+
print(f" - 평균 길이: {df['text_length'].mean():.0f} 문자")
|
| 415 |
+
print(f" - 최소 길이: {df['text_length'].min()} 문자")
|
| 416 |
+
print(f" - 최대 길이: {df['text_length'].max()} 문자")
|
| 417 |
+
|
| 418 |
+
return df
|
| 419 |
+
|
| 420 |
+
def create_chunks(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 421 |
+
"""
|
| 422 |
+
3단계: 청킹
|
| 423 |
+
|
| 424 |
+
Args:
|
| 425 |
+
df: 정제된 DataFrame
|
| 426 |
+
|
| 427 |
+
Returns:
|
| 428 |
+
청크 DataFrame
|
| 429 |
+
"""
|
| 430 |
+
print("\n" + "="*60)
|
| 431 |
+
print("3단계: 청킹")
|
| 432 |
+
print("="*60)
|
| 433 |
+
|
| 434 |
+
# [추가] 필터링 전 상태 확인
|
| 435 |
+
original_count = len(df)
|
| 436 |
+
print(f"🔍 필터링 전 문서 수: {original_count}")
|
| 437 |
+
|
| 438 |
+
# 샘플 텍스트 미리보기
|
| 439 |
+
if len(df) > 0:
|
| 440 |
+
sample = df['text_content'].iloc[0]
|
| 441 |
+
print(f"🔍 첫 번째 문서 미리보기:")
|
| 442 |
+
print(f" 시작 부분: {sample[:100]}...")
|
| 443 |
+
print(f" 전체 길이: {len(sample)}자")
|
| 444 |
+
|
| 445 |
+
# 추출 실패 패턴이 있는지 확인
|
| 446 |
+
has_failure = any([
|
| 447 |
+
'[추출 실패' in sample,
|
| 448 |
+
'[PDF 추출 실패' in sample,
|
| 449 |
+
'[HWP 추출 실패' in sample
|
| 450 |
+
])
|
| 451 |
+
print(f" 추출 실패 포함?: {has_failure}")
|
| 452 |
+
|
| 453 |
+
# 추출 실패 문서 필터링 (raw string 사용)
|
| 454 |
+
df = df[~df['text_content'].str.contains(r'\[추출 실패', na=False)]
|
| 455 |
+
df = df[~df['text_content'].str.contains(r'\[PDF 추출 실패', na=False)]
|
| 456 |
+
df = df[~df['text_content'].str.contains(r'\[HWP 추출 실패', na=False)]
|
| 457 |
+
|
| 458 |
+
filtered_count = original_count - len(df)
|
| 459 |
+
|
| 460 |
+
print(f"\n📊 필터링 결과:")
|
| 461 |
+
print(f" 제외된 문서: {filtered_count}개")
|
| 462 |
+
print(f" 남은 문서: {len(df)}개")
|
| 463 |
+
|
| 464 |
+
if len(df) == 0:
|
| 465 |
+
print("\n❌ 경고: 모든 문서가 필터링되었습니다!")
|
| 466 |
+
print(" → 추출이 모두 실패했거나 필터링 조건이 너무 엄격합니다.")
|
| 467 |
+
return pd.DataFrame()
|
| 468 |
+
|
| 469 |
+
if filtered_count > 0:
|
| 470 |
+
print(f"⚠️ 추출 실패 문서 제외: {filtered_count}개")
|
| 471 |
+
print(f"✅ 유효한 문서: {len(df)}개")
|
| 472 |
+
|
| 473 |
+
# 청킹 시작
|
| 474 |
+
df_chunks = self.chunker.chunk_dataframe(df)
|
| 475 |
+
self.stats['total_chunks'] = len(df_chunks)
|
| 476 |
+
|
| 477 |
+
return df_chunks
|
| 478 |
+
|
| 479 |
+
def save_chunks(self, df_chunks: pd.DataFrame):
|
| 480 |
+
"""
|
| 481 |
+
4단계: 결과 저장
|
| 482 |
+
|
| 483 |
+
Args:
|
| 484 |
+
df_chunks: 청크 DataFrame
|
| 485 |
+
"""
|
| 486 |
+
print("\n" + "="*60)
|
| 487 |
+
print("4단계: 결과 저장")
|
| 488 |
+
print("="*60)
|
| 489 |
+
|
| 490 |
+
df_chunks.to_csv(
|
| 491 |
+
self.config.OUTPUT_CHUNKS_PATH,
|
| 492 |
+
index=False,
|
| 493 |
+
encoding='utf-8-sig'
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
print(f"최종 청크 저장 완료: {self.config.OUTPUT_CHUNKS_PATH}")
|
| 497 |
+
print(f"총 청크 수: {len(df_chunks)}")
|
| 498 |
+
|
| 499 |
+
def run(self) -> pd.DataFrame:
|
| 500 |
+
"""
|
| 501 |
+
전체 파이프라인 실행
|
| 502 |
+
|
| 503 |
+
Returns:
|
| 504 |
+
최종 청크 DataFrame
|
| 505 |
+
"""
|
| 506 |
+
print("="*60)
|
| 507 |
+
print("RAG 전처리 파이프라인 시작")
|
| 508 |
+
print("="*60)
|
| 509 |
+
|
| 510 |
+
# 설정 검증
|
| 511 |
+
self.config.validate()
|
| 512 |
+
print(self.config)
|
| 513 |
+
|
| 514 |
+
# 1. 텍스트 추출
|
| 515 |
+
df_extracted = self.extract_from_files()
|
| 516 |
+
|
| 517 |
+
# 2. 텍스트 정제
|
| 518 |
+
df_cleaned = self.clean_dataframe(df_extracted)
|
| 519 |
+
|
| 520 |
+
# 3. 청킹
|
| 521 |
+
df_chunks = self.create_chunks(df_cleaned)
|
| 522 |
+
|
| 523 |
+
# 4. 저장
|
| 524 |
+
self.save_chunks(df_chunks)
|
| 525 |
+
|
| 526 |
+
# 최종 통계
|
| 527 |
+
self._print_final_stats()
|
| 528 |
+
|
| 529 |
+
print("\n" + "="*60)
|
| 530 |
+
print("✅ RAG 전처리 파이프라인 완료")
|
| 531 |
+
print("="*60)
|
| 532 |
+
|
| 533 |
+
return df_chunks
|
| 534 |
+
|
| 535 |
+
def _print_final_stats(self):
|
| 536 |
+
"""최종 통계 출력"""
|
| 537 |
+
print("\n" + "="*60)
|
| 538 |
+
print("📊 최종 통계")
|
| 539 |
+
print("="*60)
|
| 540 |
+
print(f"총 파일 수: {self.stats['total_files']}")
|
| 541 |
+
|
| 542 |
+
if self.stats['total_files'] > 0:
|
| 543 |
+
success_rate = self.stats['success_files'] / self.stats['total_files'] * 100
|
| 544 |
+
fail_rate = self.stats['failed_files'] / self.stats['total_files'] * 100
|
| 545 |
+
|
| 546 |
+
print(f" - 추출 성공: {self.stats['success_files']} ({success_rate:.1f}%)")
|
| 547 |
+
print(f" - 추출 실패: {self.stats['failed_files']} ({fail_rate:.1f}%)")
|
| 548 |
+
|
| 549 |
+
print(f"총 청크 수: {self.stats['total_chunks']}")
|
| 550 |
+
|
| 551 |
+
if self.stats['success_files'] > 0:
|
| 552 |
+
avg_chunks = self.stats['total_chunks'] / self.stats['success_files']
|
| 553 |
+
print(f"파일당 평균 청크: {avg_chunks:.1f}개")
|
src/prompts/dynamic_prompts.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class PromptManager:
|
| 2 |
+
"""질문 유형별 시스템 프롬프트 관리"""
|
| 3 |
+
|
| 4 |
+
PROMPTS = {
|
| 5 |
+
'greeting': """You are a helpful RFP analysis chatbot assistant.
|
| 6 |
+
|
| 7 |
+
Example conversations:
|
| 8 |
+
User: 안녕하세요
|
| 9 |
+
Assistant: 안녕하세요! RFP 문서 분석을 도와드리겠습니다. 어떤 도움이 필요하신가요?
|
| 10 |
+
|
| 11 |
+
User: 반가워요
|
| 12 |
+
Assistant: 반갑습니다! 공공조달 제안서 관련 질문이 있으시면 말씀해주세요.
|
| 13 |
+
|
| 14 |
+
User: 처음이에요
|
| 15 |
+
Assistant: 환영합니다! 저는 RFP 문서 요약과 정보 검색을 도와드립니다. 무엇을 도와드릴까요?
|
| 16 |
+
|
| 17 |
+
Instructions:
|
| 18 |
+
- Greet warmly in 1-2 sentences like the examples above
|
| 19 |
+
- Offer help with RFP analysis
|
| 20 |
+
- Be concise and natural
|
| 21 |
+
|
| 22 |
+
Response in Korean:""",
|
| 23 |
+
|
| 24 |
+
'thanks': """You are a helpful RFP analysis chatbot.
|
| 25 |
+
|
| 26 |
+
Example conversations:
|
| 27 |
+
User: 고마워요
|
| 28 |
+
Assistant: 천만에요! 언제든 RFP 관련 질문 있으시면 도와드리겠습니다.
|
| 29 |
+
|
| 30 |
+
User: 감사합니다
|
| 31 |
+
Assistant: 도움이 되어 기쁩니다. 추가 질문 있으시면 언제든 말씀해주세요!
|
| 32 |
+
|
| 33 |
+
User: 도움 많이 받았어요
|
| 34 |
+
Assistant: 감사합니다! 필요하실 때 다시 찾아주세요.
|
| 35 |
+
|
| 36 |
+
Instructions:
|
| 37 |
+
- Respond warmly in 1-2 sentences like the examples above
|
| 38 |
+
- Offer continued assistance
|
| 39 |
+
- Keep it brief and friendly
|
| 40 |
+
|
| 41 |
+
Response in Korean:""",
|
| 42 |
+
|
| 43 |
+
'document': """You are an RFP analysis expert.
|
| 44 |
+
|
| 45 |
+
Example conversations:
|
| 46 |
+
User: 이 프로젝트의 예산은 얼마인가요?
|
| 47 |
+
Assistant: 검색된 문서에 따르면, 본 사업의 총 예산은 5억원이며, 소프트웨어 개발비 3억원, 인프라 구축비 2억원으로 구성되어 있습니다.
|
| 48 |
+
|
| 49 |
+
User: 사업 기간이 어떻게 되나요?
|
| 50 |
+
Assistant: 문서에 명시된 사업 기간은 2024년 3월부터 2024년 12월까지 총 10개월입니다.
|
| 51 |
+
|
| 52 |
+
User: 필수 기술 요구사항은 뭔가요?
|
| 53 |
+
Assistant: 검색된 문서에서 확인할 수 없습니다. 다른 문서를 검색하시거나 구체적인 키워드로 질문해주세요.
|
| 54 |
+
|
| 55 |
+
Instructions:
|
| 56 |
+
- Answer based ONLY on the provided documents
|
| 57 |
+
- If not in documents, say "검색된 문서에서 확인할 수 없습니다"
|
| 58 |
+
- Include specific details (names, numbers, dates) like the examples
|
| 59 |
+
- Be professional and precise
|
| 60 |
+
|
| 61 |
+
Response in Korean:""",
|
| 62 |
+
|
| 63 |
+
'out_of_scope': """You are a helpful assistant.
|
| 64 |
+
|
| 65 |
+
Example conversations:
|
| 66 |
+
User: 오늘 날씨 어때?
|
| 67 |
+
Assistant: 죄송하지만 날씨 정보는 제공하지 않습니다. 저는 RFP 문서 분석과 공공조달 정보 검색을 도와드립니다. RFP 관련 질문 있으시면 말씀해주세요!
|
| 68 |
+
|
| 69 |
+
User: 저녁 메뉴 추천해줘
|
| 70 |
+
Assistant: 아쉽지만 그 질문은 제 전문 분야가 아닙니다. 대신 입찰 문서 요약, 사업 정보 검색, 프로젝트 비교 등을 도와드릴 수 있어요. 필요하신가요?
|
| 71 |
+
|
| 72 |
+
User: 주식 투자 조언해줘
|
| 73 |
+
Assistant: 죄송하지만 투자 관련 조언은 제공하지 않습니다. 저는 공공기관 RFP 분석 전문 챗봇입니다. RFP 관련 질문이 있으시면 기꺼이 도와드리겠습니다!
|
| 74 |
+
|
| 75 |
+
Instructions:
|
| 76 |
+
- Politely decline in 2-3 sentences like the examples
|
| 77 |
+
- Briefly mention what you CAN help with
|
| 78 |
+
- Invite RFP-related questions
|
| 79 |
+
- Stay friendly and professional
|
| 80 |
+
|
| 81 |
+
Response in Korean:"""
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
@classmethod
|
| 85 |
+
def get_prompt(cls, query_type: str, context: str = None) -> str:
|
| 86 |
+
"""프롬프트 가져오기 (context는 무시)"""
|
| 87 |
+
return cls.PROMPTS[query_type]
|
src/prompts/dynamic_prompts_jiyunpark.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class PromptManager:
|
| 2 |
+
"""질문 유형별 시스템 프롬프트 관리"""
|
| 3 |
+
|
| 4 |
+
PROMPTS = {
|
| 5 |
+
'greeting': """You are a helpful RFP analysis chatbot assistant.
|
| 6 |
+
|
| 7 |
+
Example conversations:
|
| 8 |
+
User: 안녕하세요
|
| 9 |
+
Assistant: 안녕하세요! RFP 문서 분석을 도와드리겠습니다. 어떤 도움이 필요하신가요?
|
| 10 |
+
|
| 11 |
+
User: 반가워요
|
| 12 |
+
Assistant: 반갑습니다! 공공조달 제안서 관련 질문이 있으시면 말씀해주세요.
|
| 13 |
+
|
| 14 |
+
User: 처음이에요
|
| 15 |
+
Assistant: 환영합니다! 저는 RFP 문서 요약과 정보 검색을 도와드립니다. 무엇을 도와드릴까요?
|
| 16 |
+
|
| 17 |
+
Instructions:
|
| 18 |
+
- Greet warmly in 1-2 sentences like the examples above
|
| 19 |
+
- Offer help with RFP analysis
|
| 20 |
+
- Be concise and natural
|
| 21 |
+
|
| 22 |
+
Response in Korean:""",
|
| 23 |
+
|
| 24 |
+
'thanks': """You are a helpful RFP analysis chatbot.
|
| 25 |
+
|
| 26 |
+
Example conversations:
|
| 27 |
+
User: 고마워요
|
| 28 |
+
Assistant: 천만에요! 언제든 RFP 관련 질문 있으시면 도와드리겠습니다.
|
| 29 |
+
|
| 30 |
+
User: 감사합니다
|
| 31 |
+
Assistant: 도움이 되어 기쁩니다. 추가 질문 있으시면 언제든 말씀해주세요!
|
| 32 |
+
|
| 33 |
+
User: 도움 많이 받았어요
|
| 34 |
+
Assistant: 감사합니다! 필요하실 때 다시 찾아주세요.
|
| 35 |
+
|
| 36 |
+
Instructions:
|
| 37 |
+
- Respond warmly in 1-2 sentences like the examples above
|
| 38 |
+
- Offer continued assistance
|
| 39 |
+
- Keep it brief and friendly
|
| 40 |
+
|
| 41 |
+
Response in Korean:""",
|
| 42 |
+
|
| 43 |
+
'document': """You are an RFP analysis expert for Korean public procurement.
|
| 44 |
+
|
| 45 |
+
You always answer based ONLY on the RFP excerpts and metadata provided to you
|
| 46 |
+
(예: [문서 1], [문서 2] 형태의 태그가 붙은 텍스트들).
|
| 47 |
+
If the necessary information is not clearly present, you MUST say
|
| 48 |
+
"검색된 문서에서 확인할 수 없습니다." and DO NOT guess numbers or dates.
|
| 49 |
+
|
| 50 |
+
===============================
|
| 51 |
+
1. 먼저 질문 의도를 파악하세요.
|
| 52 |
+
===============================
|
| 53 |
+
|
| 54 |
+
사용자의 질문을 읽고, 아래 세 가지 중 어떤 유형인지 스스로 결정합니다:
|
| 55 |
+
|
| 56 |
+
(A) 조건에 맞는 사업 찾기 (여러 개)
|
| 57 |
+
- "어떤 제안요청서가 있나요?", "어떤 사업이 있나요?", "찾아줘" 처럼
|
| 58 |
+
조건(예산, 분야, 기간, 과업 등)에 맞는 사업 후보를 여러 개 찾으라고 할 때
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
(B) 단일 사업 정보 조회
|
| 62 |
+
- 특정 사업명, 파일명, 공고번호, 기관명을 언급하거나
|
| 63 |
+
"이 사업", "이 제안요청서"처럼 하나의 RFP를 가리키는 표현이 있을 때
|
| 64 |
+
|
| 65 |
+
(C) 일반 설명 / 제도 해설
|
| 66 |
+
- RFP 문서 구조, 평가 항목, 제출 서류, 용어 설명 등
|
| 67 |
+
특정 사업이 아니라 개념을 물어보는 경우
|
| 68 |
+
|
| 69 |
+
====================================
|
| 70 |
+
2. 유형별로 아래 출력 형식을 반드시 따르십시오.
|
| 71 |
+
====================================
|
| 72 |
+
|
| 73 |
+
■ (A) 조건에 맞는 사업 찾기일 때:
|
| 74 |
+
|
| 75 |
+
1) 사용자 조건 요약
|
| 76 |
+
- 사용자의 질문 속 조건(예산, 기간, 분야, 과업 등)을 한국어로 1~2문장으로 다시 정리합니다.
|
| 77 |
+
예) "예산 3억 이상이며 홈페이지 제작 과업이 제외된 제안요청서를 찾습니다."
|
| 78 |
+
|
| 79 |
+
2) 후보 사업 목록 (최대 10개)
|
| 80 |
+
- 표 또는 목록 형태로 정리합니다.
|
| 81 |
+
- 각 행(또는 항목)에 아래 정보를 포함합니다:
|
| 82 |
+
- 사업명
|
| 83 |
+
- 발주기관(주관기관)
|
| 84 |
+
- 사업 기간
|
| 85 |
+
- 추정 사업비(또는 예산 범위)
|
| 86 |
+
- 주요 과업 한 줄 요약
|
| 87 |
+
- 참가 자격
|
| 88 |
+
- 근거 문서 태그 (예: [문서 1], [문서 4])
|
| 89 |
+
|
| 90 |
+
- 조건과 완전히 일치하는지, 일부만 일치하는지 명시합니다.
|
| 91 |
+
예) "예산 조건은 부합하지만, 홈페이지 구축 과업을 포함하고 있어 사용자의 조건과 완전히 일치하지는 않습니다."
|
| 92 |
+
|
| 93 |
+
3) 제한 사항/주의사항
|
| 94 |
+
- 검색된 Top-K 문서 안에서만 판단했음을 명확히 공지하기 위해 마지막에 1~2문장으로,
|
| 95 |
+
"검색된 상위 문서 내에서만 판단했기 때문에, 실제 모든 제안요청서를 완전히 포괄하지는 않을 수 있습니다."
|
| 96 |
+
와 같은 주의 문구를 적습니다.
|
| 97 |
+
|
| 98 |
+
-----------------------------------
|
| 99 |
+
(A) 조건 기반 여러 사업 찾기 - Example conversations
|
| 100 |
+
-----------------------------------
|
| 101 |
+
|
| 102 |
+
User: 용역 기간이 12개월 이하면서, 사업 금액이 5억원 이상인 사업을 찾아줘.
|
| 103 |
+
Assistant: 용역 기간이 12개월 이하이고, 사업 예산이 5억원 이상인 사업은 다음과 같습니다.
|
| 104 |
+
- 사업명: 2024년도 평택시 버스정보��스템(BIS) 구축사업 용역
|
| 105 |
+
- 발주기관: 평택시
|
| 106 |
+
- 사업 기간: 착수일로부터 ~ 2024.10.31
|
| 107 |
+
- 소요예산: 999,494,600원(부가세 포함)
|
| 108 |
+
- 주요 과업: 버스정보 안내단말기(BIT), BIT LCD 모니터, 내장 NVR, 관제 및 운영PC,
|
| 109 |
+
Oracle DB 라이선스, 응용소프트웨어 개발 및 개선
|
| 110 |
+
- 참가 자격: 1) 지방자치단체를 당사자로 하는 계약에 관한 법률 시행령 제13조 및 동법시행규칙 제14조 규정에 의한 자격을 구비한 업체
|
| 111 |
+
2) 소프트웨어진흥법 제24조 및 제58조 규정한 소프트웨어사업자(컴퓨터관련 서비스업 분야)로 신고한 업체
|
| 112 |
+
3) 중소기업제품 구매촉진 및 판로지원에 관한 법률 제9조에 의한 직접생산확인 증명서 [전산업무(소프트웨어개발)분야의 ‘정보시스템개발서비스’]를 소지한 자 (전자입찰서 제출 마감일 전일까지 발급된 것으로 유효기간 내에 있어야 함)
|
| 113 |
+
4) 직접생산확인증명서[버스및차량정보안내장치(4321151403)]를 소지한 자
|
| 114 |
+
(전자입찰서 제출 마감일 전일까지 발급된 것으로 유효기간 내에 있어야 함)
|
| 115 |
+
5) 전자입찰서 제출 마감일 전일까지 정보통신공사업법 제14조에 따른 정보통신공사업 등록업체
|
| 116 |
+
6) 상기항의 요건을 동시에 만족하지 못할 경우에는 공동도급이 가능하며, 입찰참가등록 신청 시 공동수급표준협정서(공동이행 또는 분담이행방식)를 제출하여야 한다. (단, 2개 업체 이내로 제한함)
|
| 117 |
+
7) 본 사업은 사업금액이 20억원 미만인 사업으로서,「대기업인 소프트웨어사업자가 참여할 수 있는 사업금액의 하한」(과학기술정보통신부고시)에 의거 대기업 및 중견기업 소프트웨어 사업자는 본 입찰에 참여할 수 없음
|
| 118 |
+
- [문서 1]
|
| 119 |
+
|
| 120 |
+
- 사업명: 봉화군 재난통합관리시스템 고도화 사업
|
| 121 |
+
- 발주기관: 봉화군 안전건설과
|
| 122 |
+
- 사업 기간: 착수일로부터 7개월(210일)
|
| 123 |
+
- 사업금액: 900,000,000원(부가세 포함)
|
| 124 |
+
- 주요 과업: 재난통합관리시스템, 통합상황전파시스템, 통합연계시스템, 재난위험지역 경보발령 범위 확대 및 고도화 구축
|
| 125 |
+
- 참가 자격: 1) 「지방자치단체를 당사자로 하는 계약에 관한 법률」시행령 제13조 및 같은 법 시행규칙 제14조에 의한 요건을 갖추고, 반드시 입찰일 전일까지 나라장터 입찰참가자격 등록을 필한 업체로 다음 각항의 요건을 모두 갖춘 업체
|
| 126 |
+
- 소프트웨어사업자(컴퓨터관련 서비스사업, 업종코드:1468)
|
| 127 |
+
- 정보통신공사업(업종코드:0036)
|
| 128 |
+
2) 「중소기업제품 구매촉진 및 판로지원에 관한 법률」 제9조4항 및 같은 법 시행규칙 제5조3항에 의하여 다음 분야의 직접생산증명서를 입찰서 제출 마감일 전일 이전에 발급받은 자
|
| 129 |
+
- 정보시스템개발서비스(물품분류번호 : 8111159901)
|
| 130 |
+
- 동보장치(물품분류번호 : 4322269601)
|
| 131 |
+
3) 「중소기업기본법」 제2조에 따른 중소기업 또는 「소상공인 보호 및 지원에 관한 법률」에 따른 소상공인으로, 중소기업・소상공인확인서를 소지한 업체이어야 하며, 중소기업 공공구매 종합정보망(www.smpp.go.kr)에서 확인이 되지 않을 경우 입찰참가자격이 없음.
|
| 132 |
+
(이하 참가 자격 상세 내용 생략)
|
| 133 |
+
- [문서 2]
|
| 134 |
+
|
| 135 |
+
검색된 상위 문서 내에서만 판단했기 때문에, 실제 모든 제안요청서를 완전히 포괄하지는 않을 수 있습니다.
|
| 136 |
+
|
| 137 |
+
User: 중소기업 기본법에 따른 중·소기업자가 아니면 입찰 참가할 수 없는 사업이 있나요?
|
| 138 |
+
Assistant: 「중소기업기본법」에 따른 중·소기업으로 입찰 참가 자격이 제한되는 사업은 다음과 같습니다.
|
| 139 |
+
- 사업명: 2024년 벤처확인종합관리시스템 기능 고도화 용역사업
|
| 140 |
+
- 발주기관: 벤처기업확인기관
|
| 141 |
+
- 사업 기간: 계약일로부터 150일
|
| 142 |
+
- 소요예산: 352,000,000원(부가가치세 포함)
|
| 143 |
+
- 주요 과업: 대민사이트, 백오피스 구축
|
| 144 |
+
- 참가 자격: 1) 「국가를 당사자로 하는 계약에 관한 법률」 시행령 제12조에 의한 입찰 참가자격을 갖추고, 동법 시행령 제76조에 의해 부정당 업체로 입찰 참가제한을 받고 있지 않은 업체
|
| 145 |
+
2) 중소기업제품 구매촉진 및 판로지원에 관한 법률 제9조에 의거 직접생산증명서(정보시스템개발서비스, 세부품명코드 : 8111159901)를 소지한 자
|
| 146 |
+
3) 「중소기업제품 구매촉진 및 판로지원에 관한 법률」 제9조 따른 직접생산확인증명서(정보시스템 개발서비스)를 소지한 업체
|
| 147 |
+
4) 본 사업은 추정금액 20억원 미만인 사업으로써 「소프트웨어 진흥법」 제48조의 규정에 따라 대기업 및 「독점규제 및 공정거래에 관한 법률」제14조에 따라 지정된 상호출자제한기업집단에 속하는 기업은 참여할 수 없음
|
| 148 |
+
5) 「중소기업제품 구매촉진 및 판로지원에 관한 법률」시행령 제2조의2에 따라 「중소기업기본법」에 따른 중‧소기업 또는 「소상공인 보호 및 지원에 관한 법률」에 따른 소상공인으로서, 제출 마감일 전일까지 발급되고 유효기간 내의 중‧소기업 또는 소상공인 확인서를 소지한 업체
|
| 149 |
+
(이하 참가 자격 상세 내용 생략)
|
| 150 |
+
- [문서 3]
|
| 151 |
+
|
| 152 |
+
- 사업명: KUSF 체육특기자 경기기록 관리시스템 개발
|
| 153 |
+
- 발주기관: 한국대학스포츠협의회(KUSF)
|
| 154 |
+
- 사업기간: 계약체결 후 2024년 12월 31일까지
|
| 155 |
+
- 소요예산: 1억 5천만 원(부가가치세 포함)
|
| 156 |
+
- 주요 과업: 체육특기자 경기력 평가지표 관련 대회경기기록 입력 페이지 개발, 서비스 개인정보 관련 데이터 관리를 통한 정보보안 지원, 서비스 인프라 환경 관리, 오류 모니터링 및 수정, 시스템 운영 기술지원
|
| 157 |
+
- 참가 자격: 1) 「국가종합전자조달시스템 입찰참가자격등록규정」에 의하여 반드시 나라장터에 입찰서 제출 마감일 전일까지 소프트웨어사업자(컴퓨터관련서비스사업, 업종코드 : 1468)으로 입찰참가자격을 등록한 자
|
| 158 |
+
※ 제안 업체는 「소프트웨어 진흥법」 제58조(소프트웨어사업자 실적 등 관리) 및 동법 시행령 제53조(소프트웨어사업자의 실적 등 관리)에 따라야 하며, 최근년도 결산신고 된 SW사업자 신고확인서를 제출서류에 포함하여야 함
|
| 159 |
+
2) 「중소기업제품 구매촉진 및 판로지원에 관한 법률」제9조 및 같은 법 시행령 제10조에의한 직접생산확인증명서(세부품명: 정보시스템개발서비스, 세부품명번호 : 8111159901)를 소지한 자
|
| 160 |
+
※ 직접생산확인증명서는 전자입찰서 제출마감일 전일까지 발급된 것으로 유효기간 내에 있어야 함
|
| 161 |
+
3) 「중소기업기본법」제2조에 따른 중·소기업자 및「소상공인 보호 및 지원에 관한 법률」제2조에 따른 소상공인으로서「중소기업 범위 및 확인에 관한 규정」에 따라 발급된 <중·소기업·소상공인 확인서>를 소지한 자
|
| 162 |
+
- [문서 4]
|
| 163 |
+
|
| 164 |
+
검색된 상위 문서 내에서만 판단했기 때문에, 실제 모든 제안요청서를 완전히 포괄하지는 않을 수 있습니다.
|
| 165 |
+
|
| 166 |
+
User: 공동수급(컨소시엄) 형태의 입찰이 가능한 사업을 찾아줘.
|
| 167 |
+
Assistant: 공동수급(컨소시엄) 형태의 입찰이 가능한 사업은 다음과 같습니다.
|
| 168 |
+
- 사업명: 고려대학교 차세대 포털·학사 정보시스템 구축 사업
|
| 169 |
+
- 발주기관: 고려대학교
|
| 170 |
+
- 사업예산: 11,270,000,000원 (V.A.T 포함, 3년 분할 지급)
|
| 171 |
+
- 주요 과업: 응용 SW(포털시스템, 모바일서비스, 학사행정시스템, 전자계약시스템, 그룹웨어·전자결재시스템, 규정관리시스템, 경영통계시스템, 챗봇, AI선배) 개발, 개인정보영향평가
|
| 172 |
+
- 참가 자격: 1) 소프트웨어산업진흥법 제24조 규정에 의거 소프트웨어사업자(컴퓨터관련 서비스사업, 업종코드 : 1468)로 등록된 업체
|
| 173 |
+
2) 국가를 당사자로 하는 계약에 관한 법률시행령 제12조 및 동법시행규칙 제14조 규정에 의한 자격을 구비한 업체
|
| 174 |
+
3) 국가를 당사자로 하는 계약에 관한 법률 제27조 및 동법시행령 제27조 및 동법시행령 제76조에 의한 부정당업자로 제재를 받지 않는 업체
|
| 175 |
+
4) 소프트웨어산업진흥법 제48조 제4항에 따라 상호출자제한기업집단에 속하는 회사는 입찰에 참여할 수 없음
|
| 176 |
+
5) 입찰공고일 현재 국세 및 지방세 체납 사실이 없어야 하고, 청산, 합병, 매각 등 정리절차 중이거나 계획 중인 사업자나 법원에 화의 또는 법정관리를 신청 중인 사업자는 입찰에 참여할 수 없음
|
| 177 |
+
(이하 참가 자격 상세 내용 생략)
|
| 178 |
+
- [문서 5]
|
| 179 |
+
|
| 180 |
+
- 사업명: 실시간통합연구비관리시스템(RCMS) 연계 모듈 변경 사업
|
| 181 |
+
- 주관기관: 광주과학기술원
|
| 182 |
+
- 사업예산: 54,450,000원(VAT 포함)
|
| 183 |
+
- 주요 과업: ZEUS-RCMS간 연계 모듈 변경, 부가세 연구과제 전송 관련 개선
|
| 184 |
+
- 참가 자격: 1) 「국가를 당사자로 하는 계약에 관한 법률시행령」제12조 및 동법 시행 규칙 제14조 규정에 의한 자격을 갖춘 업체로써 동법 시행령 제76조(부정당 업자의 입찰 참가자격 제한)에 해당하지 않는 업체
|
| 185 |
+
2) 소프트웨어산업진흥법 제24조에 의한 소프트웨어사업자(컴퓨터관련서비스사업)(업종코드 1468)로 등록되어 있는 업체
|
| 186 |
+
3) 소프트웨어산업 진흥법 제24조의2 제2항에 따라 대기업의 참여가 제한됨
|
| 187 |
+
4) 단독 도는 공동수급(주계약자관리방식만 허용, 2개사 이내, 최소 지분율 25%이상) 가능
|
| 188 |
+
5) 중소기업청 고시 중소기업자간 경쟁제품 직접생산확인기준에 의거 직접생산확인증명서(소프트웨어유지 및 지원서비스(8111229901), 정보시스템개발서비스(8111159901))를 모두 소지한 업체 (개찰일 전일 이전 발급한 것으로 유효기간 내에 있어야 함)
|
| 189 |
+
- [문서 6]
|
| 190 |
+
|
| 191 |
+
검색된 상위 문서 내에서만 판단했기 때문에, 실제 모든 제안요청서를 완전히 포괄하지는 않을 수 있습니다.
|
| 192 |
+
|
| 193 |
+
■ (B) 단일 사업 정보 조회일 때:
|
| 194 |
+
|
| 195 |
+
1) 한 줄 요약
|
| 196 |
+
- 해당 사업이 어떤 사업인지 "사업명 + 핵심 목적"을 1문장으로 요약합니다.
|
| 197 |
+
|
| 198 |
+
2) 사용자가 특정 사업의 구체적인 조건을 묻는 경우 그 조건을 찾아서 답하고, 사용자가 요약을 요구하는 경우 아래 기본 정보를 제시합니다.
|
| 199 |
+
- 기본 정보
|
| 200 |
+
- 총 사업비 또는 추정가격
|
| 201 |
+
- 사업 기간(착수일 ~ 종료일, 또는 개월 수)
|
| 202 |
+
- 발주기관
|
| 203 |
+
- 입찰 및 계약 방식(예: 제한 경쟁 입찰(협상에 의한 계약))
|
| 204 |
+
- 사업자 선정/제안서 평가 방식
|
| 205 |
+
- 제출 필요 서류
|
| 206 |
+
- 제출 기한 및 제출 방식(예를 들어 2024년 2월 1일까지 이메일로 제출)
|
| 207 |
+
- 입찰 참가 자격
|
| 208 |
+
- 질문에서 추가로 요청한 항목만 더 포함(예를 들어 홈페이지 포함 여부, 수행 장소, 과업 범위 등)
|
| 209 |
+
|
| 210 |
+
3) 세부 내용 요약 (선택)
|
| 211 |
+
- 필요할 경우, 주요 과업 범위만 2~3문장으로 요약합니다.
|
| 212 |
+
|
| 213 |
+
4) 근거
|
| 214 |
+
- "근거" 섹션을 따로 두고,
|
| 215 |
+
사용한 문서와 문장들을 명시합니다.
|
| 216 |
+
예시:
|
| 217 |
+
- [문서 3] 예산 항목에서 "총 사업비는 2억 5천만 원으로 ..."라고 명시되어 있습니다.
|
| 218 |
+
- [문서 3] 사업기간 항목에서 "계약일로부터 12개월"이라고 명시되어 있습니다.
|
| 219 |
+
|
| 220 |
+
----------------------------------
|
| 221 |
+
(B) 단일 사업 정보 조회 - Example Conversations
|
| 222 |
+
----------------------------------
|
| 223 |
+
|
| 224 |
+
User: BIFF&ACFM 온라인서비스 재개발 및 행사지원시스템 공급 사업에서 사업 예산은 얼마인가요? 그리고 이 사업에서 제출 필요 서류는 무엇인가요?
|
| 225 |
+
Assistant: '부산국제영화제(BIFF)'와 '아시아콘텐츠&필름마켓(ACFM)'에서 주관하는 "BIFF&ACFM 온라인 서비스 재개발 및 행사지원시스템 공급 사업"의 주요 과업은 부산국제영화제 온라인 서비스 재개발 및 유지관리, ACFM e마켓 온라인 서비스 플랫폼 서비스 재개발 및 유지관리, 부산국제영화제 행사지원시스템 공급입니다.
|
| 226 |
+
이 사업의 제안요청서에 적힌 사업예산과 제출 필요 서류를 알려드리겠습니다.
|
| 227 |
+
검색된 문서에 따르면, 본 사업의 총 예산은 금243,000,000원(금이억사천삼백만 원) (VAT 포함)입니다.
|
| 228 |
+
제출 필요 서류는 입찰참가표지, 입찰참가신청서, 입찰보증금지급각서, 참가업체 일반현황, 제안사 조직 및 인원현황, 산출내역서(상세견적)(*가격입찰 전자입찰시 파일첨부),
|
| 229 |
+
사용인감계, 법인등기부등본, 법인인감증명서, 신용평가등급 확인서, 서약서, 보안각서, 청렴계약 이행각서, 사업자등록증 사본, 사업실적증명서 원본(3개월 이내 발급된 원본),
|
| 230 |
+
지방세완납증명서, 국세완납증명서, 경력증명서(건강보험 자격득실확인서로 대체 가능), 재직증명서, 소프트웨어사업자 일반 현황 관리확인서, 직접생산 확인증명서 원본입니다.
|
| 231 |
+
|
| 232 |
+
User: 재단법인 예술경영지원센터에서 주관하는 통합 정보시스템 구축 사전 컨설팅 사업을 따내고 싶습니다. 이 프로젝트의 사업기간과 사업자 선정 방식, 제안서 평가 방식을 알려주세요.
|
| 233 |
+
Assistant: '재단법인 예술경영제원센터'에서 주관하는 "통합 정보시스템 구축 사전 컨설팅" 사업의 주요 과업은 환경 및 현황분석, 통합미술정보시스템 구축 데이터 분석 및 설계, 통합정보시스템 벤치마킹 사례 조사, 비전 및 전략 수립, 통합미술정보시스템 서비스 개발입니다.
|
| 234 |
+
이 사업의 제안요청서에 적힌 사업기간과 사업자 선정 방식 및 제안서 평가 방식에 대해 알려드리겠습니다.
|
| 235 |
+
문서에 명시된 사업 기간은 계약체결일로부터 2024년 11월 29일까지입니다.
|
| 236 |
+
사업자 선정 방식은 경쟁입찰 후 협상에 의한 계약 방식입니다.
|
| 237 |
+
제안서 평가 방법은 다음과 같습니다. 평가 절차는 기술 및 가격평가(수요기관 평가/발표평가)로 이루어지며, 평가항목은 소프트웨어 기술성 평가기준(과학기술정보통신부 고시) 및 조달청 협상에 의한 계약 제안서 평가 세부기준 적용입니다.
|
| 238 |
+
|
| 239 |
+
User: 2025 구미아시아육상경기선수권대회 종합정보시스템 및 홈페이지 등 구축 용역 제안요청서에서 제안서 작성 방법을 찾아줘.
|
| 240 |
+
Assistant: '2025 구미아시아육상경기선수권대회 조직위원회'에서 주관하는 "종합정보시스템 및 홈페이지 등 구축 용역 제안요청서"에 적힌 제안서 작성 방법은 다음과 같습니다.
|
| 241 |
+
1) 제안서는 A4용지를 기준으로 하고, PDF 형식으로 변환하여 제출하여야 한다
|
| 242 |
+
2) 제안서의 각 페이지는 쉽게 참조할 수 있도록 페이지 하단 중앙에 일련번호를 부여하여야 한다
|
| 243 |
+
3) 제안서는 적절한 문서편집기를 사용하되, 사용된 영문약어에 대해서는 약어표를 제공해야 한다
|
| 244 |
+
4) 제안서의 내용을 객관적으로 입증할 수 있는 관련 자료는 제안서의 별첨으로 제출한다
|
| 245 |
+
5) 제안서의 내용은“명확한 용어”를 사용하여 표현하여야 한다
|
| 246 |
+
또한 제안서 작성 요령은 다음과 같습니다.
|
| 247 |
+
1. 제안서(PT 심사자료)
|
| 248 |
+
가. 제출 및 규격 * 서식11 참고
|
| 249 |
+
- 제출부수 : 제안서 10부(1부만 업체명 표기 / USB 별도제출)
|
| 250 |
+
- 매 수 : 표지 및 간지를 포함하여 50페이지 이내(단면)
|
| 251 |
+
- 규 격 : A4규격(210×297)
|
| 252 |
+
- 인 쇄 : PT 보고용 파워포인트로 작성 후 단면으로 출력
|
| 253 |
+
- 편 철 : A4 횡으로 상단 편철
|
| 254 |
+
- 기 타
|
| 255 |
+
페이지 하단에 페이지 번호 작성
|
| 256 |
+
매수가 초과되는 부분은 심사에 반영 안함
|
| 257 |
+
|
| 258 |
+
나. 작성지침
|
| 259 |
+
- 제안서에는 업체를 인식할 수 있는 행사 실적 및 일체의 표기(사명, 로고, 마크, 대표자명 등)를 사용해서는 안 됨
|
| 260 |
+
- 제안서의 기재사항은 객관적으로 입증할 수 있어야 하고 “제공할 수도 있다, 가능하다, 고려하고 있다” 등과 같은 명확하지 않은 표현은 제안서 평가 시 불가능한 것으로 간주함
|
| 261 |
+
- 제출된 제안서 내용이 허위, 과장된 사실임이 발견될 경우에는 입찰자격 박탈은 물론 발주처에서 관련서류를 임의 처리할 수 있음
|
| 262 |
+
- 타 업체에 비하여 차별적으로 비교우위에 있는 사항은 기술이 가능하되 제안서에 제안자를 인지할 수 있는 어떠한 표기나 표식을 할 수 없음
|
| 263 |
+
- 제출된 제안서 내용은 수정, 변경, 보완할 수 없음
|
| 264 |
+
2. 가격제안서
|
| 265 |
+
가. 제출부수 : 1부(가격제안서 및 사업비 산출내역서)
|
| 266 |
+
나. 제출방법 : 밀봉 및 봉합날인
|
| 267 |
+
다. 작성방법
|
| 268 |
+
- 산출내역서는 시설별, 시스템별, 프로그램별, 운영별 등 세분하여 작성
|
| 269 |
+
- 가격제안서와 산출내역서상의 금액이 상이한 경우 가격제안서의 금액을 우선으로 적용
|
| 270 |
+
|
| 271 |
+
User: 호계체육관 배드민턴장 및 탁구장 예약 시스템 구축 제안요청서를 한 페이지로 요약해줘.
|
| 272 |
+
Assistant: '안양시 체육과'와 '안양시 도시공사'에서 주관하는 "호계체육관 배드민턴장 및 탁구장 예약 시스템 구축" 사업의 주요 과업은 호계체육관 배드민턴장 및 탁구장 예약 시스템 구축(웹 서비스, 키오스크 서비스)입니다.
|
| 273 |
+
이 사업의 제안요청서를 한 페이지 분량으로 요약해드리겠습니다.
|
| 274 |
+
- 사업예산: 49,500천원(부가가치세 포함)
|
| 275 |
+
- 사업기간: 계약 후 120일
|
| 276 |
+
- 사업기관: 안양시 체육과, 도시공사
|
| 277 |
+
- 입찰 및 낙찰방식: 제한경쟁 입찰 후 협상에 의한 계약
|
| 278 |
+
- 사업자 선정(제안서 평가) 방식: 기술능력평가(90점)와 입찰가격평가(10점)를 실시하여 종합평가점수(100점)로 평가. 기술능력평가(90점)은 정량적 평가(20점)과 정성적 평가(70점)을 합한다.
|
| 279 |
+
- 제출 필요 서류: 1) 입찰 등록서류(입찰참가신청서, 사업자등록증, 법인등기부등본, 인감증명서, 인감증명서, 사용인감계, 입찰참가자격 증명서류, 위임장, 서약서, 청렴계약 이행서약서, 보안각서, 개인정보 수집·이용·제공동의서, 입찰보증금 지급각서)
|
| 280 |
+
2) 가격 제안서 및 산출 내역서
|
| 281 |
+
3) 기술능력 정량적 평가 서류(정량적 평가지표 자가진단표, 제안업체 일반현황, 조직 및 인원현황, 기술자 보유 현황, 기술자 이력사항, 수행실적 총괄표, 용역수행 실적증명서, 사회적 책임 확인서)
|
| 282 |
+
※ 원본1부 사본2부
|
| 283 |
+
4) 기술능력 정성적 평가 서류(제안서 10부, 발표자료 10부, ppt파일-USB 1매)
|
| 284 |
+
- 제출 기한 및 제출 방식: 제출기간은 입찰공고서 참조라고 쓰여있으며, 제출방법은 참가자격을 갖춘 업체의 대표 또는 위임장을 소지한 대리인 직접 방문 제출 (우편, 팩스, e-mail 등 기타접수 불가)
|
| 285 |
+
- 입찰 참가 자격: 1) ⌜지방자치단체를 당사자로 하는 계약에 관한 법률 시행령⌟ 제13조(입찰의 참가자격) 및 동법 시행규칙 제14조(입찰 참가자격 요건의 증명)에 의한 경쟁 입찰 참가 자격요건을 갖춘 업체
|
| 286 |
+
2) 공고일 현재 ⌜소프트웨어 진흥법⌟ 제24조에 의거 소프트웨어사업자로서 컴퓨터 관련 서비스사업(업종코드 : 1468)으로 등록된 업체
|
| 287 |
+
3) 정보시스템개발서비스(세부품명번호: 8111159901) 직접생산확인증명서 소지한 자
|
| 288 |
+
「중소기업기본법」제2조에 따른 중·소기업자 또는「소상공인 보호 및 지원에 관한 법률」제2조에 따른 소상공인으로서「중소기업 범위 및 확인에 관한 규정」에 따라 발급된 <중·소기업·소상공인 확인서>를 소지한 자”
|
| 289 |
+
4) 지방자치단체를 당사자로 하는 계약에 관한 법률 제31조 및 동법 시행령 제92조(부정당업자의 입찰참가자격 제한) 규정에 따라 입찰 참가자격을 제한 받지 아니한 업체
|
| 290 |
+
5) 본 사업은 정보누출금지대상으로 「지방자치단체를 당사자로 하는 계약에 관한 법률 시행령」 제92조 제2항 제3호의 적용을 받음
|
| 291 |
+
6) 본 입찰은 공동수급(분담이행방식)을 허용하지 않음
|
| 292 |
+
7) 조달청 전자입찰 참가자격 등록업체이어야 하며, 조달청 입찰참가자격 미등록 업체는 조달청 입찰참가자격등록규정에 따라 제안서 제출마감일 전 근무일까지 조달청 콜센터(☎1588-0800)에 문의하여 등록하여야 함
|
| 293 |
+
|
| 294 |
+
■ (C) 일반 설명 / 해설일 때:
|
| 295 |
+
|
| 296 |
+
- 질문에 맞게 개념을 설명하되,
|
| 297 |
+
반드시 제공된 문서에 근거하여 설명합니다.
|
| 298 |
+
- 문서에서 특정 항목(예: 평가항목, 제출서류)을 여러 번 언급하는 경우,
|
| 299 |
+
공통점을 정리해서 설명하고
|
| 300 |
+
각 경우의 차이점이 있으면 간단히 비교합니다.
|
| 301 |
+
- 근거 문서 태그를 최소 1개 이상 제시합니다.
|
| 302 |
+
|
| 303 |
+
----------------------------------
|
| 304 |
+
(C) 일반 설명 / 해설 - Example Conversations
|
| 305 |
+
----------------------------------
|
| 306 |
+
|
| 307 |
+
User: 체육특기자 경기력 평가지표가 무엇이고 그 목적은 무엇인가요?
|
| 308 |
+
Assistant: 체육특기자 경기력 평가지표는 단체종목에서 경기 수행 중 발현되는 개인의 기술 결과들을 종합하여 개인의 종합적인 경기력을 나타낼 수 있는 개인 경기력 평가지표입니다.
|
| 309 |
+
체육특기자 경기력 평가지표 개발 목적과 필요성은 다음과 같습니다.
|
| 310 |
+
체육특기자 대입 공정성과 체육특기자 특별전형의 취지를 살리기 위해 체육특기자 개개인의 경기력을 객관적으로 평가할 수 있어야 하며, 대학이 체육특기자 선발에 활용할 수 있는 개인 경기력 평가지표 개발이 필요합니다.
|
| 311 |
+
또한 단체종목에 있어서 경기실적증명서가 가장 실제적인 평가 결과이기는 하나, 이는 개인이 아니라 단체의 경기실적을 나타내는 것임으로 개인 경기력을 입시에 반영하는 것에는 한계가 있습니다.
|
| 312 |
+
- [문서 9] KUSF 체육특기자 경기기록 관리시스템 개발 제안요청서
|
| 313 |
+
|
| 314 |
+
User: 공동수급체란 무엇인가요?
|
| 315 |
+
Assistant: 공동수급체란 구성원을 2인 이상으로 하여 수급인이 당해 계약을 공동으로 수행하기 위하여 잠정적으로 결성한 실체를 말합니다.
|
| 316 |
+
- [문서 10] 2024년 항해용 간행물 품질관리 업무보조 시스템 구축
|
| 317 |
+
|
| 318 |
+
===============================
|
| 319 |
+
3. 공통 규칙
|
| 320 |
+
===============================
|
| 321 |
+
|
| 322 |
+
- 답변은 항상 한국어로 작성합니다.
|
| 323 |
+
- 숫자, 금액, 날짜는 문서에 있는 값만 사용하고, 추정하지 않습니다.
|
| 324 |
+
- 필요한 정보가 문서에 없으면,
|
| 325 |
+
"검색된 문서에서 확인할 수 없습니다."라고 명확히 말합니다.
|
| 326 |
+
- 사용자가 특별히 요청하지 않는 한, 너무 장황하게 설명하지 말고
|
| 327 |
+
핵심 정보 위주로 정리합니다.
|
| 328 |
+
- 근거 문서 태그([문서 1], [문서 2])는 retrieval 단계에서 제공된 번호를 따라 사용합니다.
|
| 329 |
+
- 문서 내용이 불확실할 때는 절대 추론하지 않습니다.
|
| 330 |
+
|
| 331 |
+
Response in Korean:""",
|
| 332 |
+
|
| 333 |
+
'out_of_scope': """You are a helpful assistant.
|
| 334 |
+
|
| 335 |
+
Example conversations:
|
| 336 |
+
User: 오늘 날씨 어때?
|
| 337 |
+
Assistant: 죄송하지만 날씨 정보는 제공하지 않습니다. 저는 RFP 문서 분석과 공공조달 정보 검색을 도와드립니다. RFP 관련 질문 있으시면 말씀해주세요!
|
| 338 |
+
|
| 339 |
+
User: 저녁 메뉴 추천해줘
|
| 340 |
+
Assistant: 아쉽지만 그 질문은 제 전문 분야가 아닙니다. 대신 입찰 문서 요약, 사업 정보 검색, 프로젝트 비교 등을 도와드릴 수 있어요. 필요하신가요?
|
| 341 |
+
|
| 342 |
+
User: 주식 투자 조언해줘
|
| 343 |
+
Assistant: 죄송하지만 투자 관련 조언은 제공하지 않습니다. 저는 공공기관 RFP 분석 전문 챗봇입니다. RFP 관련 질문이 있으시면 기꺼이 도와드리겠습니다!
|
| 344 |
+
|
| 345 |
+
Instructions:
|
| 346 |
+
- Politely decline in 2-3 sentences like the examples
|
| 347 |
+
- Briefly mention what you CAN help with
|
| 348 |
+
- Invite RFP-related questions
|
| 349 |
+
- Stay friendly and professional
|
| 350 |
+
|
| 351 |
+
Response in Korean:"""
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
@classmethod
|
| 355 |
+
def get_prompt(cls, query_type: str, context: str = None) -> str:
|
| 356 |
+
"""프롬프트 가져오기 (context는 무시)"""
|
| 357 |
+
return cls.PROMPTS[query_type]
|
src/retriever/main.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from RAG_pipeline_v1.rag_config import RAGConfig
|
| 3 |
+
from RAG_pipeline_v1.rag_data_processing import RAGVectorDBPipeline
|
| 4 |
+
from RAG_pipeline_v1.rag_pipeline import RAGPipeline
|
| 5 |
+
from RAG_pipeline_v1.rag_evaluator import RAGEvaluator
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def main():
|
| 9 |
+
"""메인 실행 함수"""
|
| 10 |
+
|
| 11 |
+
# ===== 환경 설정 =====
|
| 12 |
+
print("="*60)
|
| 13 |
+
print("RAG 시스템 초기화")
|
| 14 |
+
print("="*60)
|
| 15 |
+
|
| 16 |
+
os.environ["OPENAI_API_KEY"] = RAGConfig.OPENAI_API_KEY
|
| 17 |
+
|
| 18 |
+
config = RAGConfig()
|
| 19 |
+
config.validate()
|
| 20 |
+
print(config)
|
| 21 |
+
|
| 22 |
+
# ===== 1. Vector DB 구축 (최초 1회만) =====
|
| 23 |
+
# 주석 해제하여 실행
|
| 24 |
+
# print("\n" + "="*60)
|
| 25 |
+
# print("Vector DB 구축")
|
| 26 |
+
# print("="*60)
|
| 27 |
+
# db_pipeline = RAGVectorDBPipeline(config)
|
| 28 |
+
# vectorstore = db_pipeline.build()
|
| 29 |
+
# db_pipeline.test_search()
|
| 30 |
+
|
| 31 |
+
# ===== 2. RAG 파이프라인 초기화 =====
|
| 32 |
+
print("\n" + "="*60)
|
| 33 |
+
print("RAG 파이프라인 초기화")
|
| 34 |
+
print("="*60)
|
| 35 |
+
|
| 36 |
+
rag = RAGPipeline(config=config)
|
| 37 |
+
|
| 38 |
+
# ===== 3. 테스트 쿼리 =====
|
| 39 |
+
print("\n" + "="*60)
|
| 40 |
+
print("테스트 쿼리")
|
| 41 |
+
print("="*60)
|
| 42 |
+
|
| 43 |
+
test_queries = [
|
| 44 |
+
"한영대학교의 특성화 교육환경 구축 사업은 무엇인가요?",
|
| 45 |
+
"재난 안전 관리 시스템 구축 사업은 어떤 것이 있나요?",
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
for query in test_queries:
|
| 49 |
+
result = rag.generate_answer(query)
|
| 50 |
+
rag.print_result(result)
|
| 51 |
+
print("\n")
|
| 52 |
+
|
| 53 |
+
# ===== 4. 평가 =====
|
| 54 |
+
print("\n" + "="*60)
|
| 55 |
+
print("시스템 평가")
|
| 56 |
+
print("="*60)
|
| 57 |
+
|
| 58 |
+
evaluator = RAGEvaluator(rag)
|
| 59 |
+
eval_results = evaluator.evaluate()
|
| 60 |
+
|
| 61 |
+
print("\n" + "="*60)
|
| 62 |
+
print("✅ 모든 작업 완료")
|
| 63 |
+
print("="*60)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
main()
|
src/retriever/retriever.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_chroma import Chroma
|
| 2 |
+
from langchain_openai.embeddings import OpenAIEmbeddings
|
| 3 |
+
from langsmith import traceable
|
| 4 |
+
import time
|
| 5 |
+
import os
|
| 6 |
+
from rank_bm25 import BM25Okapi
|
| 7 |
+
import numpy as np
|
| 8 |
+
from sentence_transformers import CrossEncoder
|
| 9 |
+
|
| 10 |
+
from src.utils.config import RAGConfig
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class RAGRetriever:
|
| 14 |
+
"""RAG 검색 시스템 (Hybrid Search + Re-ranker)"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, config: RAGConfig = None):
|
| 17 |
+
self.config = config or RAGConfig()
|
| 18 |
+
self.vectorstore = None
|
| 19 |
+
self.embeddings = None
|
| 20 |
+
|
| 21 |
+
self._initialize_embeddings()
|
| 22 |
+
self._create_vectorstore()
|
| 23 |
+
self._initialize_bm25()
|
| 24 |
+
self._initialize_reranker()
|
| 25 |
+
|
| 26 |
+
def _initialize_embeddings(self):
|
| 27 |
+
"""임베딩 모델 초기화"""
|
| 28 |
+
os.environ["OPENAI_API_KEY"] = self.config.OPENAI_API_KEY
|
| 29 |
+
|
| 30 |
+
self.embeddings = OpenAIEmbeddings(
|
| 31 |
+
model=self.config.EMBEDDING_MODEL_NAME
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def _create_vectorstore(self):
|
| 35 |
+
"""기존 벡터스토어 로드"""
|
| 36 |
+
self.vectorstore = Chroma(
|
| 37 |
+
embedding_function=self.embeddings,
|
| 38 |
+
persist_directory=self.config.DB_DIRECTORY,
|
| 39 |
+
collection_name=self.config.COLLECTION_NAME
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
def _initialize_bm25(self):
|
| 43 |
+
"""BM25 인덱스 생성"""
|
| 44 |
+
all_docs = self.vectorstore.get()
|
| 45 |
+
|
| 46 |
+
self.doc_texts = all_docs['documents']
|
| 47 |
+
self.doc_ids = all_docs['ids']
|
| 48 |
+
self.doc_metadatas = all_docs['metadatas']
|
| 49 |
+
|
| 50 |
+
self.content_to_id = {text: doc_id for text, doc_id in zip(self.doc_texts, self.doc_ids)}
|
| 51 |
+
|
| 52 |
+
tokenized_docs = [doc.split() for doc in self.doc_texts]
|
| 53 |
+
self.bm25 = BM25Okapi(tokenized_docs)
|
| 54 |
+
|
| 55 |
+
print(f"✅ BM25 인덱스 생성 완료: {len(self.doc_texts)}개 문서")
|
| 56 |
+
|
| 57 |
+
def _initialize_reranker(self):
|
| 58 |
+
"""Re-ranker 초기화"""
|
| 59 |
+
self.reranker = CrossEncoder('BAAI/bge-reranker-base')
|
| 60 |
+
print("✅ Re-ranker 초기화 완료 (bge-reranker-base)")
|
| 61 |
+
|
| 62 |
+
@staticmethod
|
| 63 |
+
def _min_max_normalize(scores):
|
| 64 |
+
"""0~1 범위로 정규화"""
|
| 65 |
+
scores = np.array(scores)
|
| 66 |
+
min_score = scores.min()
|
| 67 |
+
max_score = scores.max()
|
| 68 |
+
|
| 69 |
+
if max_score == min_score:
|
| 70 |
+
return np.full_like(scores, 0.5, dtype=float)
|
| 71 |
+
|
| 72 |
+
return (scores - min_score) / (max_score - min_score)
|
| 73 |
+
|
| 74 |
+
def _find_doc_id_by_content(self, content):
|
| 75 |
+
"""문서 content로 ID 찾기"""
|
| 76 |
+
return self.content_to_id.get(content, None)
|
| 77 |
+
|
| 78 |
+
def _rerank(self, query, documents, top_k):
|
| 79 |
+
"""
|
| 80 |
+
검색 결과 재정렬
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
query: 검색 쿼리
|
| 84 |
+
documents: hybrid_search 결과 리스트
|
| 85 |
+
top_k: 최종 반환할 문서 수
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
재정렬된 상위 k개 문서
|
| 89 |
+
"""
|
| 90 |
+
if len(documents) == 0:
|
| 91 |
+
return []
|
| 92 |
+
|
| 93 |
+
# 1. (query, document) 쌍 생성
|
| 94 |
+
pairs = [[query, doc['content']] for doc in documents]
|
| 95 |
+
|
| 96 |
+
# 2. CrossEncoder로 점수 계산
|
| 97 |
+
scores = self.reranker.predict(pairs)
|
| 98 |
+
|
| 99 |
+
# 3. 점수를 문서에 추가
|
| 100 |
+
for i, doc in enumerate(documents):
|
| 101 |
+
doc['rerank_score'] = float(scores[i])
|
| 102 |
+
|
| 103 |
+
# 4. 정렬 및 반환
|
| 104 |
+
sorted_docs = sorted(documents,
|
| 105 |
+
key=lambda x: x['rerank_score'],
|
| 106 |
+
reverse=True)
|
| 107 |
+
|
| 108 |
+
return sorted_docs[:top_k]
|
| 109 |
+
|
| 110 |
+
@traceable(
|
| 111 |
+
name="RAG_Hybrid_Search",
|
| 112 |
+
metadata={"component": "retriever", "version": "2.0"}
|
| 113 |
+
)
|
| 114 |
+
def hybrid_search(self, query, top_k=None, alpha=0.5):
|
| 115 |
+
"""
|
| 116 |
+
Hybrid Search: BM25 + 임베딩 결합
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
query: 검색 쿼리
|
| 120 |
+
top_k: 반환할 문서 수
|
| 121 |
+
alpha: 임베딩 가중치 (0~1)
|
| 122 |
+
"""
|
| 123 |
+
start_time = time.time()
|
| 124 |
+
|
| 125 |
+
if top_k is None:
|
| 126 |
+
top_k = self.config.DEFAULT_TOP_K
|
| 127 |
+
|
| 128 |
+
# 1. BM25 검색
|
| 129 |
+
tokenized_query = query.split()
|
| 130 |
+
bm25_scores = self.bm25.get_scores(tokenized_query)
|
| 131 |
+
bm25_normalized = self._min_max_normalize(bm25_scores)
|
| 132 |
+
|
| 133 |
+
# 2. 임베딩 검색
|
| 134 |
+
embedding_results = self.vectorstore.similarity_search_with_score(
|
| 135 |
+
query, k=min(top_k * 3, len(self.doc_texts))
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# 3. 임베딩 점수 정규화
|
| 139 |
+
embedding_scores_raw = {}
|
| 140 |
+
for doc, distance in embedding_results:
|
| 141 |
+
doc_id = self._find_doc_id_by_content(doc.page_content)
|
| 142 |
+
if doc_id:
|
| 143 |
+
embedding_scores_raw[doc_id] = 1 / (1 + distance)
|
| 144 |
+
|
| 145 |
+
if embedding_scores_raw:
|
| 146 |
+
embed_values = np.array(list(embedding_scores_raw.values()))
|
| 147 |
+
embed_normalized = self._min_max_normalize(embed_values)
|
| 148 |
+
embedding_scores = dict(zip(embedding_scores_raw.keys(), embed_normalized))
|
| 149 |
+
else:
|
| 150 |
+
embedding_scores = {}
|
| 151 |
+
|
| 152 |
+
# 4. 하이브리드 점수 계산
|
| 153 |
+
hybrid_scores = {}
|
| 154 |
+
for i, doc_id in enumerate(self.doc_ids):
|
| 155 |
+
bm25_score = bm25_normalized[i]
|
| 156 |
+
embed_score = embedding_scores.get(doc_id, 0)
|
| 157 |
+
hybrid_scores[doc_id] = (1 - alpha) * bm25_score + alpha * embed_score
|
| 158 |
+
|
| 159 |
+
# 5. 정렬 및 상위 k개 선택
|
| 160 |
+
sorted_ids = sorted(hybrid_scores.keys(),
|
| 161 |
+
key=lambda x: hybrid_scores[x],
|
| 162 |
+
reverse=True)
|
| 163 |
+
top_ids = sorted_ids[:top_k]
|
| 164 |
+
|
| 165 |
+
# 6. 결과 포맷팅
|
| 166 |
+
formatted_results = []
|
| 167 |
+
for doc_id in top_ids:
|
| 168 |
+
idx = self.doc_ids.index(doc_id)
|
| 169 |
+
formatted_results.append({
|
| 170 |
+
'content': self.doc_texts[idx],
|
| 171 |
+
'metadata': self.doc_metadatas[idx],
|
| 172 |
+
'hybrid_score': hybrid_scores[doc_id],
|
| 173 |
+
'bm25_score': float(bm25_normalized[idx]),
|
| 174 |
+
'embed_score': embedding_scores.get(doc_id, 0),
|
| 175 |
+
'filename': self.doc_metadatas[idx].get('파일명', 'N/A'),
|
| 176 |
+
'organization': self.doc_metadatas[idx].get('발주 기관', 'N/A')
|
| 177 |
+
})
|
| 178 |
+
|
| 179 |
+
end_time = time.time()
|
| 180 |
+
print(f"🔍 Hybrid 검색 완료: {len(formatted_results)}개 (alpha={alpha}, {end_time-start_time:.3f}초)")
|
| 181 |
+
return formatted_results
|
| 182 |
+
|
| 183 |
+
@traceable(
|
| 184 |
+
name="RAG_Hybrid_Search_Rerank",
|
| 185 |
+
metadata={"component": "retriever", "version": "3.0"}
|
| 186 |
+
)
|
| 187 |
+
def hybrid_search_with_rerank(self, query, top_k=None, alpha=0.5, rerank_candidates=None):
|
| 188 |
+
"""
|
| 189 |
+
Hybrid Search + Re-ranking
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
query: 검색 쿼리
|
| 193 |
+
top_k: 최종 반환할 문서 수
|
| 194 |
+
alpha: BM25/임베딩 가중치
|
| 195 |
+
rerank_candidates: Re-rank할 후보 수 (None이면 top_k * 3)
|
| 196 |
+
"""
|
| 197 |
+
start_time = time.time()
|
| 198 |
+
|
| 199 |
+
if top_k is None:
|
| 200 |
+
top_k = self.config.DEFAULT_TOP_K
|
| 201 |
+
|
| 202 |
+
if rerank_candidates is None:
|
| 203 |
+
rerank_candidates = top_k * 3
|
| 204 |
+
|
| 205 |
+
# 1. Hybrid Search로 후보 문서 가져오기
|
| 206 |
+
candidates = self.hybrid_search(query, top_k=rerank_candidates, alpha=alpha)
|
| 207 |
+
|
| 208 |
+
# 2. Re-ranking
|
| 209 |
+
if len(candidates) > 0:
|
| 210 |
+
results = self._rerank(query, candidates, top_k)
|
| 211 |
+
else:
|
| 212 |
+
results = []
|
| 213 |
+
|
| 214 |
+
end_time = time.time()
|
| 215 |
+
print(f"🔄 Re-ranking 완료: {len(candidates)}개 → {len(results)}개 ({end_time-start_time:.3f}초)")
|
| 216 |
+
|
| 217 |
+
return results
|
| 218 |
+
|
| 219 |
+
def search_with_mode(self, query, top_k=None, mode="hybrid_rerank", alpha=0.5):
|
| 220 |
+
"""검색 모드 선택"""
|
| 221 |
+
if mode == "embedding":
|
| 222 |
+
return self.search(query, top_k)
|
| 223 |
+
elif mode == "bm25":
|
| 224 |
+
return self.hybrid_search(query, top_k, alpha=0.0)
|
| 225 |
+
elif mode == "hybrid":
|
| 226 |
+
return self.hybrid_search(query, top_k, alpha=alpha)
|
| 227 |
+
elif mode == "hybrid_rerank":
|
| 228 |
+
return self.hybrid_search_with_rerank(query, top_k, alpha)
|
| 229 |
+
else:
|
| 230 |
+
raise ValueError(f"Unknown mode: {mode}")
|
| 231 |
+
|
| 232 |
+
@traceable(
|
| 233 |
+
name="RAG_Retriever_Search",
|
| 234 |
+
metadata={"component": "retriever", "version": "1.0"}
|
| 235 |
+
)
|
| 236 |
+
def search(self, query: str, top_k: int = None, filter_metadata: dict = None):
|
| 237 |
+
"""
|
| 238 |
+
유사 문서 검색 (임베딩 기반)
|
| 239 |
+
"""
|
| 240 |
+
start_time = time.time()
|
| 241 |
+
if top_k is None:
|
| 242 |
+
top_k = self.config.DEFAULT_TOP_K
|
| 243 |
+
|
| 244 |
+
if filter_metadata:
|
| 245 |
+
results = self.vectorstore.similarity_search_with_score(
|
| 246 |
+
query, k=top_k, filter=filter_metadata
|
| 247 |
+
)
|
| 248 |
+
else:
|
| 249 |
+
results = self.vectorstore.similarity_search_with_score(
|
| 250 |
+
query, k=top_k
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
formatted_results = []
|
| 254 |
+
for doc, score in results:
|
| 255 |
+
formatted_results.append({
|
| 256 |
+
'content': doc.page_content,
|
| 257 |
+
'metadata': doc.metadata,
|
| 258 |
+
'distance': score,
|
| 259 |
+
'relevance_score': 1 - score,
|
| 260 |
+
'filename': doc.metadata.get('파일명', 'N/A'),
|
| 261 |
+
'organization': doc.metadata.get('발주 기관', 'N/A')
|
| 262 |
+
})
|
| 263 |
+
|
| 264 |
+
end_time = time.time()
|
| 265 |
+
print(f"🔍 검색 완료: {len(results)}개 ({end_time-start_time:.3f}초)")
|
| 266 |
+
return formatted_results
|
| 267 |
+
|
| 268 |
+
def search_with_rerank(self, query, top_k=None, rerank_candidates=None):
|
| 269 |
+
"""
|
| 270 |
+
임베딩 검색 + Re-ranking
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
query: 검색 쿼리
|
| 274 |
+
top_k: 최종 반환할 문서 수
|
| 275 |
+
rerank_candidates: Re-rank할 후보 수
|
| 276 |
+
|
| 277 |
+
Returns:
|
| 278 |
+
재정렬된 문서 리스트
|
| 279 |
+
"""
|
| 280 |
+
start_time = time.time()
|
| 281 |
+
|
| 282 |
+
if top_k is None:
|
| 283 |
+
top_k = self.config.DEFAULT_TOP_K
|
| 284 |
+
|
| 285 |
+
if rerank_candidates is None:
|
| 286 |
+
rerank_candidates = top_k * 3
|
| 287 |
+
|
| 288 |
+
# 1. 임베딩 검색으로 후보 가져오기
|
| 289 |
+
candidates = self.search(query, top_k=rerank_candidates)
|
| 290 |
+
|
| 291 |
+
# 2. Re-ranking
|
| 292 |
+
if len(candidates) > 0:
|
| 293 |
+
results = self._rerank(query, candidates, top_k)
|
| 294 |
+
else:
|
| 295 |
+
results = []
|
| 296 |
+
|
| 297 |
+
end_time = time.time()
|
| 298 |
+
print(f"🔄 Embedding + Re-ranking 완료: {len(candidates)}개 → {len(results)}개 ({end_time-start_time:.3f}초)")
|
| 299 |
+
|
| 300 |
+
return results
|
| 301 |
+
|
| 302 |
+
def search_by_organization(self, query: str, organization: str, top_k: int = None):
|
| 303 |
+
"""특정 발주기관만 검색"""
|
| 304 |
+
return self.search(
|
| 305 |
+
query, top_k=top_k, filter_metadata={'발주 기관': organization}
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
def get_retriever(self):
|
| 309 |
+
"""LangChain 체인용 Retriever 반환"""
|
| 310 |
+
return self.vectorstore.as_retriever(
|
| 311 |
+
search_type="similarity",
|
| 312 |
+
search_kwargs={"k": self.config.DEFAULT_TOP_K}
|
| 313 |
+
)
|
src/router/query_router.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/router/query_router.py
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class QueryRouter:
|
| 8 |
+
"""Query를 RAG vs Direct로 라우팅"""
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
# 키워드 정의
|
| 12 |
+
self.greeting_keywords = [
|
| 13 |
+
"안녕", "hi", "hello", "반가워", "처음"
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
self.thanks_keywords = [
|
| 17 |
+
"고마워", "감사", "thanks", "고맙"
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
self.document_keywords = [
|
| 21 |
+
# 돈 관련
|
| 22 |
+
"예산", "비용", "금액", "원", "만원", "억",
|
| 23 |
+
# 일정 관련
|
| 24 |
+
"기한", "마감", "언제", "기간", "납기",
|
| 25 |
+
# 문서 관련
|
| 26 |
+
"요구사항", "제출", "서류", "양식", "평가",
|
| 27 |
+
# 조직 관련
|
| 28 |
+
"발주", "기관", "담당자", "연락처",
|
| 29 |
+
# 사업 관련
|
| 30 |
+
"사업명", "과업", "범위", "목적"
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
def classify(self, query: str) -> dict:
|
| 34 |
+
query_lower = query.lower()
|
| 35 |
+
|
| 36 |
+
# 짧은 질문일 때만 인사/감사 체크
|
| 37 |
+
if len(query) < 20: # ← is_short 대신 직접 체크
|
| 38 |
+
if any(kw in query_lower for kw in self.thanks_keywords):
|
| 39 |
+
return {
|
| 40 |
+
'type': 'thanks',
|
| 41 |
+
'confidence': 0.9,
|
| 42 |
+
'reason': '감사 인사 감지'
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
elif any(kw in query_lower for kw in self.greeting_keywords):
|
| 46 |
+
return {
|
| 47 |
+
'type': 'greeting',
|
| 48 |
+
'confidence': 0.9,
|
| 49 |
+
'reason': '인사 감지'
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# 문서 관련 판별
|
| 53 |
+
if any(kw in query_lower for kw in self.document_keywords):
|
| 54 |
+
return {
|
| 55 |
+
'type': 'document',
|
| 56 |
+
'confidence': 0.85,
|
| 57 |
+
'reason': '문서 키워드 감지'
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# 3. 기본값
|
| 61 |
+
return {
|
| 62 |
+
'type': 'out_of_scope',
|
| 63 |
+
'confidence': 0.5,
|
| 64 |
+
'reason': 'RFP 키워드 없음'
|
| 65 |
+
}
|
src/router/query_router_lee.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/router/query_router.py
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
class QueryRouter:
|
| 8 |
+
"""Query를 RAG vs Direct로 라우팅"""
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
# 키워드 정의
|
| 12 |
+
self.greeting_keywords = [
|
| 13 |
+
"안녕", "hi", "hello", "반가워", "처음", "인사"
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
self.thanks_keywords = [
|
| 17 |
+
"고마워", "감사", "thanks", "고맙", "땡큐"
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
self.document_keywords = [
|
| 21 |
+
# 돈 관련
|
| 22 |
+
"예산", "비용", "금액", "원", "만원", "억", "억원",
|
| 23 |
+
# 일정 관련
|
| 24 |
+
"기한", "마감", "언제", "기간", "납기", "일정",
|
| 25 |
+
# 문서 관련
|
| 26 |
+
"요구사항", "제출", "서류", "양식", "평가", "rfp",
|
| 27 |
+
# 조직 관련
|
| 28 |
+
"발주", "기관", "담당자", "연락처", "부처", "지자체",
|
| 29 |
+
# 사업/계약 관련
|
| 30 |
+
"사업", "사업명", "과업", "범위", "목적", "계약", "입찰",
|
| 31 |
+
"공고", "프로젝트", "위탁", "용역", "협상", "제안"
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
def classify(self, query: str) -> dict:
|
| 35 |
+
query_lower = query.lower()
|
| 36 |
+
query_length = len(query)
|
| 37 |
+
|
| 38 |
+
# 짧은 질문일 때만 인사/감사 체크
|
| 39 |
+
if query_length < 25:
|
| 40 |
+
if any(kw in query_lower for kw in self.thanks_keywords):
|
| 41 |
+
return {
|
| 42 |
+
'type': 'thanks',
|
| 43 |
+
'confidence': 0.9,
|
| 44 |
+
'reason': '감사 인사 감지'
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
if any(kw in query_lower for kw in self.greeting_keywords):
|
| 48 |
+
return {
|
| 49 |
+
'type': 'greeting',
|
| 50 |
+
'confidence': 0.9,
|
| 51 |
+
'reason': '인사 감지'
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
# 문서 관련 판별 (키워드 또는 숫자+사업 맥락)
|
| 55 |
+
if any(kw in query_lower for kw in self.document_keywords):
|
| 56 |
+
match_count = sum(1 for kw in self.document_keywords if kw in query_lower)
|
| 57 |
+
confidence = min(0.7 + 0.05 * match_count, 0.95)
|
| 58 |
+
return {
|
| 59 |
+
'type': 'document',
|
| 60 |
+
'confidence': confidence,
|
| 61 |
+
'reason': f'문서 키워드 {match_count}개 감지'
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
# 숫자와 행정 용어가 혼합된 경우 약한 문서 추정
|
| 65 |
+
if any(ch.isdigit() for ch in query) and any(term in query_lower for term in ["사업", "과업", "계획"]):
|
| 66 |
+
return {
|
| 67 |
+
'type': 'document',
|
| 68 |
+
'confidence': 0.65,
|
| 69 |
+
'reason': '숫자와 사업 키워드 동시 감지'
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# 기본값
|
| 73 |
+
return {
|
| 74 |
+
'type': 'out_of_scope',
|
| 75 |
+
'confidence': 0.4,
|
| 76 |
+
'reason': 'RFP 관련 키워드 미감지'
|
| 77 |
+
}
|
src/utils/__init__.py
ADDED
|
File without changes
|
src/utils/config.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Config:
|
| 6 |
+
"""RAG 시스템 통합 설정 클래스"""
|
| 7 |
+
|
| 8 |
+
def __init__(self):
|
| 9 |
+
# .env 파일 로드
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
# ===== API 키 =====
|
| 13 |
+
self.OPENAI_API_KEY = self._get_api_key()
|
| 14 |
+
|
| 15 |
+
# ===== 경로 설정 =====
|
| 16 |
+
# 전처리
|
| 17 |
+
self.META_CSV_PATH = "./data/data_list.csv"
|
| 18 |
+
self.BASE_FOLDER_PATH = "./data/files/"
|
| 19 |
+
self.OUTPUT_CHUNKS_PATH = "./data/rag_chunks_final.csv"
|
| 20 |
+
|
| 21 |
+
# RAG
|
| 22 |
+
self.RAG_INPUT_PATH = "./data/rag_chunks_final.csv"
|
| 23 |
+
self.DB_DIRECTORY = "./chroma_db"
|
| 24 |
+
|
| 25 |
+
# ===== 전처리 설정 =====
|
| 26 |
+
self.CHUNK_SIZE = 1000
|
| 27 |
+
self.CHUNK_OVERLAP = 200
|
| 28 |
+
self.SEPARATORS = ["\n\n", "\n", " ", ""]
|
| 29 |
+
self.MIN_TEXT_LENGTH = 100 # 최소 텍스트 길이
|
| 30 |
+
|
| 31 |
+
# ===== 임베딩 설정 =====
|
| 32 |
+
self.EMBEDDING_MODEL_NAME = "text-embedding-3-small"
|
| 33 |
+
self.BATCH_SIZE = 50
|
| 34 |
+
self.MAX_TOKENS_PER_BATCH = 250000
|
| 35 |
+
|
| 36 |
+
# 청크 검증 기준
|
| 37 |
+
self.MIN_CHUNK_LENGTH = 10
|
| 38 |
+
self.MAX_CHUNK_LENGTH = 10000
|
| 39 |
+
|
| 40 |
+
# ===== 벡터 DB 설정 =====
|
| 41 |
+
self.COLLECTION_NAME = "rag_documents"
|
| 42 |
+
|
| 43 |
+
# ===== 검색 설정 =====
|
| 44 |
+
self.DEFAULT_TOP_K = 10
|
| 45 |
+
self.DEFAULT_ALPHA = 0.5 # Hybrid Search 가중치
|
| 46 |
+
self.DEFAULT_SEARCH_MODE = "hybrid_rerank"
|
| 47 |
+
|
| 48 |
+
# ===== LLM 설정 =====
|
| 49 |
+
self.LLM_MODEL_NAME = "gpt-5-mini"
|
| 50 |
+
self.DEFAULT_TEMPERATURE = 0.0
|
| 51 |
+
self.DEFAULT_MAX_TOKENS = 1000
|
| 52 |
+
|
| 53 |
+
# ========== GGUF 모델 설정 (신규) ==========
|
| 54 |
+
self.GGUF_MODEL_PATH = "./models/Llama-3-Open-Ko-8B.Q4_K_M.gguf"
|
| 55 |
+
self.GGUF_N_GPU_LAYERS = 35 # GPU에 올릴 레이어 수 (0 = CPU만, 35 = 전체)
|
| 56 |
+
self.GGUF_N_CTX = 16384 # 컨텍스트 길이
|
| 57 |
+
self.GGUF_N_THREADS = 8 # CPU 스레드 수
|
| 58 |
+
|
| 59 |
+
self.GGUF_MAX_NEW_TOKENS = 512
|
| 60 |
+
self.GGUF_TEMPERATURE = 0.5
|
| 61 |
+
self.GGUF_TOP_P = 0.9
|
| 62 |
+
|
| 63 |
+
# ========== Model Hub 설정 (신규) ==========
|
| 64 |
+
# Hugging Face Spaces 배포 시 True로 설정
|
| 65 |
+
self.USE_MODEL_HUB = os.getenv("USE_MODEL_HUB", "false").lower() == "true"
|
| 66 |
+
|
| 67 |
+
# Model Hub 레포 정보
|
| 68 |
+
self.MODEL_HUB_REPO = "Dongjin1203/RFP_Documents_chatbot" # 실제 레포명으로 변경 필요
|
| 69 |
+
self.MODEL_HUB_FILENAME = "Llama-3-Open-Ko-8B.Q4_K_M.gguf"
|
| 70 |
+
|
| 71 |
+
# 다운로드 캐시 디렉토리
|
| 72 |
+
self.MODEL_CACHE_DIR = "./models"
|
| 73 |
+
|
| 74 |
+
# 시스템 프롬프트
|
| 75 |
+
self.SYSTEM_PROMPT = "당신은 RFP(제안요청서) 분석 및 요약 전문가입니다."
|
| 76 |
+
|
| 77 |
+
def validate_gguf(self):
|
| 78 |
+
"""GGUF 모델 설정 유효성 검사"""
|
| 79 |
+
if not os.path.exists(self.GGUF_MODEL_PATH):
|
| 80 |
+
raise FileNotFoundError(
|
| 81 |
+
f"GGUF 모델 파일을 찾을 수 없습니다: {self.GGUF_MODEL_PATH}"
|
| 82 |
+
)
|
| 83 |
+
return True
|
| 84 |
+
|
| 85 |
+
def _get_api_key(self) -> str:
|
| 86 |
+
"""환경변수에서 API 키 로드"""
|
| 87 |
+
api_key = os.getenv("OPENAI_API_KEY")
|
| 88 |
+
|
| 89 |
+
if not api_key:
|
| 90 |
+
raise ValueError(
|
| 91 |
+
"OPENAI_API_KEY가 설정되지 않았습니다.\n"
|
| 92 |
+
"프로젝트 루트에 .env 파일을 만들고 OPENAI_API_KEY=your-key 를 추가하세요."
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
return api_key
|
| 96 |
+
|
| 97 |
+
def validate_preprocess(self):
|
| 98 |
+
"""전처리 설정 유효성 검사"""
|
| 99 |
+
if not os.path.exists(self.META_CSV_PATH):
|
| 100 |
+
raise FileNotFoundError(
|
| 101 |
+
f"메타 CSV 파일을 찾을 수 없습니다: {self.META_CSV_PATH}"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
if not os.path.exists(self.BASE_FOLDER_PATH):
|
| 105 |
+
raise FileNotFoundError(
|
| 106 |
+
f"파일 폴더를 찾을 수 없습니다: {self.BASE_FOLDER_PATH}"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# 출력 폴더 생성
|
| 110 |
+
output_dir = os.path.dirname(self.OUTPUT_CHUNKS_PATH)
|
| 111 |
+
if output_dir:
|
| 112 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 113 |
+
|
| 114 |
+
return True
|
| 115 |
+
|
| 116 |
+
def validate_rag(self):
|
| 117 |
+
"""RAG 설정 유효성 검사"""
|
| 118 |
+
if not self.OPENAI_API_KEY:
|
| 119 |
+
raise ValueError("OPENAI_API_KEY가 설정되지 않았습니다")
|
| 120 |
+
|
| 121 |
+
if not os.path.exists(self.RAG_INPUT_PATH):
|
| 122 |
+
raise FileNotFoundError(
|
| 123 |
+
f"입력 파일을 찾을 수 없습니다: {self.RAG_INPUT_PATH}"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
return True
|
| 127 |
+
|
| 128 |
+
def validate_all(self):
|
| 129 |
+
"""전체 설정 유효성 검사"""
|
| 130 |
+
self.validate_preprocess()
|
| 131 |
+
self.validate_rag()
|
| 132 |
+
return True
|
| 133 |
+
|
| 134 |
+
def validate(self):
|
| 135 |
+
"""설정 유효성 검사 (하위 호환성)"""
|
| 136 |
+
return self.validate_preprocess()
|
| 137 |
+
|
| 138 |
+
def __repr__(self):
|
| 139 |
+
"""설정 정보 출력"""
|
| 140 |
+
return f"""
|
| 141 |
+
Config 설정:
|
| 142 |
+
[경로]
|
| 143 |
+
- 메타 CSV: {self.META_CSV_PATH}
|
| 144 |
+
- 파일 폴더: {self.BASE_FOLDER_PATH}
|
| 145 |
+
- 청크 출력: {self.OUTPUT_CHUNKS_PATH}
|
| 146 |
+
- DB 경로: {self.DB_DIRECTORY}
|
| 147 |
+
- 어댑터 경로: {self.FINETUNED_ADAPTER_PATH}
|
| 148 |
+
|
| 149 |
+
[전처리]
|
| 150 |
+
- 청크 크기: {self.CHUNK_SIZE}
|
| 151 |
+
- 청크 오버랩: {self.CHUNK_OVERLAP}
|
| 152 |
+
|
| 153 |
+
[모델]
|
| 154 |
+
- 임베딩: {self.EMBEDDING_MODEL_NAME}
|
| 155 |
+
- LLM: {self.LLM_MODEL_NAME}
|
| 156 |
+
- Fine-tuned: {self.FINETUNED_BASE_MODEL}
|
| 157 |
+
|
| 158 |
+
[검색]
|
| 159 |
+
- Top-K: {self.DEFAULT_TOP_K}
|
| 160 |
+
- Alpha: {self.DEFAULT_ALPHA}
|
| 161 |
+
- 모드: {self.DEFAULT_SEARCH_MODE}
|
| 162 |
+
|
| 163 |
+
[생성]
|
| 164 |
+
- Temperature: {self.FINETUNED_TEMPERATURE}
|
| 165 |
+
- Max Tokens: {self.FINETUNED_MAX_NEW_TOKENS}
|
| 166 |
+
"""
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# 하위 호환성을 위한 별칭
|
| 170 |
+
PreprocessConfig = Config
|
| 171 |
+
RAGConfig = Config
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# 테스트용
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
config = Config()
|
| 177 |
+
print(config)
|
src/utils/conversation_manager.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# src/utils/conversation_manager.py
|
| 2 |
+
|
| 3 |
+
"""
|
| 4 |
+
대화 히스토리 관리자 (메모리 기반)
|
| 5 |
+
|
| 6 |
+
기능:
|
| 7 |
+
- UI 표시용 / 분석용 히스토리 분리
|
| 8 |
+
- 전체 대화 저장 (greeting, thanks, document, out_of_scope)
|
| 9 |
+
- JSON 내보내기
|
| 10 |
+
- 통계 기능
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import List, Dict, Optional
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ConversationManager:
|
| 22 |
+
"""
|
| 23 |
+
대화 히스토리 관리 (메모리 기반)
|
| 24 |
+
|
| 25 |
+
Streamlit session_state와 함께 사용:
|
| 26 |
+
- UI 히스토리: Streamlit 메시지 형식
|
| 27 |
+
- DB 히스토리: 분석/저장용 형식
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def __init__(self):
|
| 31 |
+
"""초기화"""
|
| 32 |
+
self.ui_history: List[Dict] = [] # Streamlit 표시용
|
| 33 |
+
self.db_history: List[Dict] = [] # 분석/저장용
|
| 34 |
+
|
| 35 |
+
logger.info("💬 ConversationManager 초기화 완료")
|
| 36 |
+
|
| 37 |
+
def add_message(
|
| 38 |
+
self,
|
| 39 |
+
user_msg: str,
|
| 40 |
+
ai_msg: str,
|
| 41 |
+
query_type: str,
|
| 42 |
+
sources: Optional[List] = None,
|
| 43 |
+
usage: Optional[Dict] = None,
|
| 44 |
+
search_mode: Optional[str] = None,
|
| 45 |
+
used_retrieval: bool = False,
|
| 46 |
+
routing_info: Optional[Dict] = None
|
| 47 |
+
):
|
| 48 |
+
"""
|
| 49 |
+
메시지 추가 (전체 저장)
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
user_msg: 사용자 질문
|
| 53 |
+
ai_msg: AI 답변
|
| 54 |
+
query_type: 질문 유형 (greeting/thanks/document/out_of_scope)
|
| 55 |
+
sources: 참고 문서 리스트
|
| 56 |
+
usage: 토큰 사용량
|
| 57 |
+
search_mode: 검색 모드
|
| 58 |
+
used_retrieval: 검색 사용 여부
|
| 59 |
+
routing_info: 라우팅 정보
|
| 60 |
+
"""
|
| 61 |
+
timestamp = datetime.now()
|
| 62 |
+
|
| 63 |
+
# ===== UI 히스토리 (Streamlit 메시지 형식) =====
|
| 64 |
+
# 사용자 메시지
|
| 65 |
+
self.ui_history.append({
|
| 66 |
+
'role': 'user',
|
| 67 |
+
'content': user_msg,
|
| 68 |
+
'timestamp': timestamp
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
# AI 메시지
|
| 72 |
+
self.ui_history.append({
|
| 73 |
+
'role': 'assistant',
|
| 74 |
+
'content': ai_msg,
|
| 75 |
+
'sources': sources or [],
|
| 76 |
+
'usage': usage or {},
|
| 77 |
+
'search_mode': search_mode,
|
| 78 |
+
'used_retrieval': used_retrieval,
|
| 79 |
+
'routing_info': routing_info,
|
| 80 |
+
'type': query_type, # 분석용 추가
|
| 81 |
+
'timestamp': timestamp
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
# ===== DB 히스토리 (분석용) =====
|
| 85 |
+
self.db_history.append({
|
| 86 |
+
'user': user_msg,
|
| 87 |
+
'assistant': ai_msg,
|
| 88 |
+
'type': query_type,
|
| 89 |
+
'timestamp': timestamp.isoformat(),
|
| 90 |
+
'sources_count': len(sources) if sources else 0,
|
| 91 |
+
'used_retrieval': used_retrieval,
|
| 92 |
+
'search_mode': search_mode,
|
| 93 |
+
'routing_info': routing_info
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
logger.info(f"💾 대화 저장: {query_type} - {user_msg[:30]}...")
|
| 97 |
+
|
| 98 |
+
def get_ui_history(self) -> List[Dict]:
|
| 99 |
+
"""
|
| 100 |
+
UI 표시용 히스토리 반환 (Streamlit 형식)
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
Streamlit 메시지 리스트
|
| 104 |
+
"""
|
| 105 |
+
return self.ui_history
|
| 106 |
+
|
| 107 |
+
def get_db_history(self, last_n: Optional[int] = None) -> List[Dict]:
|
| 108 |
+
"""
|
| 109 |
+
분석/저장용 히스토리 반환
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
last_n: 최근 N개만 반환 (None이면 전체)
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
대화 기록 리스트
|
| 116 |
+
"""
|
| 117 |
+
if last_n:
|
| 118 |
+
return self.db_history[-last_n:]
|
| 119 |
+
return self.db_history
|
| 120 |
+
|
| 121 |
+
def get_history_by_type(self, query_type: str) -> List[Dict]:
|
| 122 |
+
"""
|
| 123 |
+
특정 질문 유형만 필터링
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
query_type: 'greeting', 'thanks', 'document', 'out_of_scope'
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
필터링된 대화 리스트
|
| 130 |
+
"""
|
| 131 |
+
return [
|
| 132 |
+
msg for msg in self.db_history
|
| 133 |
+
if msg['type'] == query_type
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
def get_statistics(self) -> Dict[str, int]:
|
| 137 |
+
"""
|
| 138 |
+
질문 유형별 통계
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
{'greeting': 5, 'document': 20, ...}
|
| 142 |
+
"""
|
| 143 |
+
from collections import Counter
|
| 144 |
+
|
| 145 |
+
types = [msg['type'] for msg in self.db_history]
|
| 146 |
+
stats = dict(Counter(types))
|
| 147 |
+
|
| 148 |
+
# 총 대화 수 추가
|
| 149 |
+
stats['total'] = len(self.db_history)
|
| 150 |
+
|
| 151 |
+
return stats
|
| 152 |
+
|
| 153 |
+
def export_to_json(self) -> str:
|
| 154 |
+
"""
|
| 155 |
+
JSON 형식으로 내보내기
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
JSON 문자열
|
| 159 |
+
"""
|
| 160 |
+
export_data = {
|
| 161 |
+
'timestamp': datetime.now().isoformat(),
|
| 162 |
+
'total_conversations': len(self.db_history),
|
| 163 |
+
'statistics': self.get_statistics(),
|
| 164 |
+
'conversations': self.db_history
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
return json.dumps(export_data, ensure_ascii=False, indent=2)
|
| 168 |
+
|
| 169 |
+
def clear(self):
|
| 170 |
+
"""히스토리 초기화"""
|
| 171 |
+
self.ui_history = []
|
| 172 |
+
self.db_history = []
|
| 173 |
+
logger.info("🗑️ 대화 히스토리 초기화")
|
| 174 |
+
|
| 175 |
+
def __len__(self):
|
| 176 |
+
"""대화 개수 (사용자 질문 기준)"""
|
| 177 |
+
return len(self.db_history)
|
| 178 |
+
|
| 179 |
+
def __repr__(self):
|
| 180 |
+
stats = self.get_statistics()
|
| 181 |
+
return (
|
| 182 |
+
f"ConversationManager("
|
| 183 |
+
f"total={stats.get('total', 0)}, "
|
| 184 |
+
f"document={stats.get('document', 0)}, "
|
| 185 |
+
f"greeting={stats.get('greeting', 0)}, "
|
| 186 |
+
f"thanks={stats.get('thanks', 0)}, "
|
| 187 |
+
f"out_of_scope={stats.get('out_of_scope', 0)})"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ===== 테스트 코드 =====
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
# 테스트
|
| 194 |
+
manager = ConversationManager()
|
| 195 |
+
|
| 196 |
+
# 대화 추가
|
| 197 |
+
manager.add_message(
|
| 198 |
+
user_msg="안녕하세요",
|
| 199 |
+
ai_msg="안녕하세요! 무엇을 도와드릴까요?",
|
| 200 |
+
query_type="greeting"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
manager.add_message(
|
| 204 |
+
user_msg="예산이 얼마인가요?",
|
| 205 |
+
ai_msg="예산은 5억원입니다.",
|
| 206 |
+
query_type="document",
|
| 207 |
+
sources=[{'content': '예산: 5억원', 'score': 0.95}],
|
| 208 |
+
used_retrieval=True,
|
| 209 |
+
search_mode="hybrid_rerank"
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
manager.add_message(
|
| 213 |
+
user_msg="고마워요",
|
| 214 |
+
ai_msg="천만에요! 언제든 질문하세요.",
|
| 215 |
+
query_type="thanks"
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# 통계 출력
|
| 219 |
+
print("\n===== 통계 =====")
|
| 220 |
+
print(manager.get_statistics())
|
| 221 |
+
|
| 222 |
+
# 히스토리 출력
|
| 223 |
+
print("\n===== DB 히스토리 =====")
|
| 224 |
+
for msg in manager.get_db_history():
|
| 225 |
+
print(f"{msg['type']}: {msg['user'][:20]}...")
|
| 226 |
+
|
| 227 |
+
# JSON 내보내기
|
| 228 |
+
print("\n===== JSON Export =====")
|
| 229 |
+
print(manager.export_to_json())
|
| 230 |
+
|
| 231 |
+
# Representation
|
| 232 |
+
print("\n===== Manager Info =====")
|
| 233 |
+
print(manager)
|
src/visualization/chatbot_app.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
공공기관 사업제안서 RAG 챗봇
|
| 3 |
+
|
| 4 |
+
기능:
|
| 5 |
+
- 모델 선택 (API/로컬)
|
| 6 |
+
- Query Router (검색 vs 직접 답변)
|
| 7 |
+
- RAG 기반 질의응답 (Hybrid Search + Re-ranker)
|
| 8 |
+
- 조건부 참고 문서 표시
|
| 9 |
+
- 대화 히스토리 관리
|
| 10 |
+
- 검색 모드 선택
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import streamlit as st
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
import json
|
| 18 |
+
|
| 19 |
+
# 프로젝트 루트 추가
|
| 20 |
+
root_dir = Path(__file__).parent.parent.parent
|
| 21 |
+
sys.path.insert(0, str(root_dir))
|
| 22 |
+
|
| 23 |
+
from src.utils.config import RAGConfig
|
| 24 |
+
from src.utils.conversation_manager import ConversationManager
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# ===== 페이지 설정 =====
|
| 28 |
+
st.set_page_config(
|
| 29 |
+
page_title="공공기관 사업제안서 챗봇",
|
| 30 |
+
page_icon="🤖",
|
| 31 |
+
layout="wide",
|
| 32 |
+
initial_sidebar_state="expanded"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ===== 스타일 =====
|
| 37 |
+
st.markdown("""
|
| 38 |
+
<style>
|
| 39 |
+
.main-header {
|
| 40 |
+
font-size: 2.5rem;
|
| 41 |
+
font-weight: bold;
|
| 42 |
+
color: #1f77b4;
|
| 43 |
+
margin-bottom: 0.5rem;
|
| 44 |
+
}
|
| 45 |
+
.sub-header {
|
| 46 |
+
font-size: 1.2rem;
|
| 47 |
+
color: #666;
|
| 48 |
+
margin-bottom: 2rem;
|
| 49 |
+
}
|
| 50 |
+
.chat-message {
|
| 51 |
+
padding: 1.5rem;
|
| 52 |
+
border-radius: 0.5rem;
|
| 53 |
+
margin-bottom: 1rem;
|
| 54 |
+
display: flex;
|
| 55 |
+
flex-direction: column;
|
| 56 |
+
}
|
| 57 |
+
.user-message {
|
| 58 |
+
background-color: #e3f2fd;
|
| 59 |
+
border-left: 5px solid #2196f3;
|
| 60 |
+
}
|
| 61 |
+
.assistant-message {
|
| 62 |
+
background-color: #f5f5f5;
|
| 63 |
+
border-left: 5px solid #4caf50;
|
| 64 |
+
}
|
| 65 |
+
.message-header {
|
| 66 |
+
font-weight: bold;
|
| 67 |
+
margin-bottom: 0.5rem;
|
| 68 |
+
display: flex;
|
| 69 |
+
align-items: center;
|
| 70 |
+
gap: 0.5rem;
|
| 71 |
+
}
|
| 72 |
+
.message-content {
|
| 73 |
+
line-height: 1.6;
|
| 74 |
+
}
|
| 75 |
+
.source-document {
|
| 76 |
+
background-color: #fff9c4;
|
| 77 |
+
padding: 1rem;
|
| 78 |
+
border-radius: 0.3rem;
|
| 79 |
+
margin: 0.5rem 0;
|
| 80 |
+
border-left: 3px solid #fbc02d;
|
| 81 |
+
}
|
| 82 |
+
.source-header {
|
| 83 |
+
font-weight: bold;
|
| 84 |
+
color: #f57f17;
|
| 85 |
+
margin-bottom: 0.5rem;
|
| 86 |
+
}
|
| 87 |
+
.metadata {
|
| 88 |
+
font-size: 0.85rem;
|
| 89 |
+
color: #666;
|
| 90 |
+
margin-top: 0.5rem;
|
| 91 |
+
}
|
| 92 |
+
.token-usage {
|
| 93 |
+
background-color: #e8f5e9;
|
| 94 |
+
padding: 0.5rem 1rem;
|
| 95 |
+
border-radius: 0.3rem;
|
| 96 |
+
font-size: 0.9rem;
|
| 97 |
+
margin-top: 0.5rem;
|
| 98 |
+
}
|
| 99 |
+
.search-mode-info {
|
| 100 |
+
background-color: #e3f2fd;
|
| 101 |
+
padding: 0.5rem 1rem;
|
| 102 |
+
border-radius: 0.3rem;
|
| 103 |
+
font-size: 0.9rem;
|
| 104 |
+
margin-top: 0.5rem;
|
| 105 |
+
}
|
| 106 |
+
.routing-info {
|
| 107 |
+
background-color: #fff3e0;
|
| 108 |
+
padding: 0.5rem 1rem;
|
| 109 |
+
border-radius: 0.3rem;
|
| 110 |
+
font-size: 0.9rem;
|
| 111 |
+
margin-top: 0.5rem;
|
| 112 |
+
border-left: 3px solid #ff9800;
|
| 113 |
+
}
|
| 114 |
+
</style>
|
| 115 |
+
""", unsafe_allow_html=True)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ===== 세션 상태 초기화 =====
|
| 119 |
+
if 'conv_manager' not in st.session_state:
|
| 120 |
+
st.session_state.conv_manager = ConversationManager()
|
| 121 |
+
|
| 122 |
+
if 'rag_pipeline' not in st.session_state:
|
| 123 |
+
st.session_state.rag_pipeline = None
|
| 124 |
+
|
| 125 |
+
if 'model_type' not in st.session_state:
|
| 126 |
+
st.session_state.model_type = None
|
| 127 |
+
|
| 128 |
+
if 'show_routing_info' not in st.session_state:
|
| 129 |
+
st.session_state.show_routing_info = False
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ===== RAG 파이프라인 초기화 =====
|
| 133 |
+
@st.cache_resource
|
| 134 |
+
def initialize_rag(model_type):
|
| 135 |
+
"""RAG 파이프라인 초기화 (모델 타입에 따라 분기)"""
|
| 136 |
+
try:
|
| 137 |
+
config = RAGConfig()
|
| 138 |
+
|
| 139 |
+
if model_type == "API 모델 (GPT)":
|
| 140 |
+
from src.generator.generator import RAGPipeline
|
| 141 |
+
rag = RAGPipeline(config=config)
|
| 142 |
+
return rag, None, "API"
|
| 143 |
+
|
| 144 |
+
else: # "로컬 모델 (GGUF)"
|
| 145 |
+
from src.generator.generator_gguf import GGUFRAGPipeline
|
| 146 |
+
rag = GGUFRAGPipeline(config=config)
|
| 147 |
+
return rag, None, "Local-GGUF"
|
| 148 |
+
|
| 149 |
+
except Exception as e:
|
| 150 |
+
return None, str(e), None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ===== 답변 생성 =====
|
| 154 |
+
def generate_answer(query: str, top_k: int = 10, search_mode: str = "hybrid_rerank", alpha: float = 0.5):
|
| 155 |
+
"""질의에 대한 답변 생성"""
|
| 156 |
+
try:
|
| 157 |
+
result = st.session_state.rag_pipeline.generate_answer(
|
| 158 |
+
query=query,
|
| 159 |
+
top_k=top_k,
|
| 160 |
+
search_mode=search_mode,
|
| 161 |
+
alpha=alpha
|
| 162 |
+
)
|
| 163 |
+
return result
|
| 164 |
+
|
| 165 |
+
except Exception as e:
|
| 166 |
+
return {
|
| 167 |
+
'answer': f"❌ 오류가 발생했습니다: {str(e)}",
|
| 168 |
+
'sources': [],
|
| 169 |
+
'used_retrieval': False, # ← 추가
|
| 170 |
+
'search_mode': search_mode,
|
| 171 |
+
'routing_info': None, # ← 추가
|
| 172 |
+
'usage': {'total_tokens': 0, 'prompt_tokens': 0, 'completion_tokens': 0}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ===== 메시지 표시 =====
|
| 177 |
+
def display_message(
|
| 178 |
+
role: str,
|
| 179 |
+
content: str,
|
| 180 |
+
sources: list = None,
|
| 181 |
+
usage: dict = None,
|
| 182 |
+
search_mode: str = None,
|
| 183 |
+
used_retrieval: bool = None, # ← 신규
|
| 184 |
+
routing_info: dict = None # ← 신규
|
| 185 |
+
):
|
| 186 |
+
"""
|
| 187 |
+
메시지를 화면에 표시
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
role: 'user' 또는 'assistant'
|
| 191 |
+
content: 메시지 내용
|
| 192 |
+
sources: 참고 문서 리스트 (assistant만)
|
| 193 |
+
usage: 토큰 사용량 (assistant만)
|
| 194 |
+
search_mode: 검색 모드 (assistant만)
|
| 195 |
+
used_retrieval: 검색 사용 여부 (assistant만)
|
| 196 |
+
routing_info: 라우팅 정보 (assistant만)
|
| 197 |
+
"""
|
| 198 |
+
if role == 'user':
|
| 199 |
+
st.markdown(f"""
|
| 200 |
+
<div class="chat-message user-message">
|
| 201 |
+
<div class="message-header">
|
| 202 |
+
👤 사용자
|
| 203 |
+
</div>
|
| 204 |
+
<div class="message-content">
|
| 205 |
+
{content}
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
""", unsafe_allow_html=True)
|
| 209 |
+
|
| 210 |
+
else: # assistant
|
| 211 |
+
# 답변
|
| 212 |
+
st.markdown(f"""
|
| 213 |
+
<div class="chat-message assistant-message">
|
| 214 |
+
<div class="message-header">
|
| 215 |
+
🤖 챗봇
|
| 216 |
+
</div>
|
| 217 |
+
<div class="message-content">
|
| 218 |
+
{content}
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
""", unsafe_allow_html=True)
|
| 222 |
+
|
| 223 |
+
# ===== 라우팅 정보 (개발 모드) =====
|
| 224 |
+
if st.session_state.show_routing_info and routing_info:
|
| 225 |
+
route_icon = "🔍" if routing_info.get('route') == 'rag' else "💬"
|
| 226 |
+
st.markdown(f"""
|
| 227 |
+
<div class="routing-info">
|
| 228 |
+
{route_icon} 라우팅: {routing_info.get('route', 'N/A').upper()}
|
| 229 |
+
(신뢰도: {routing_info.get('confidence', 0):.2f}) -
|
| 230 |
+
{routing_info.get('reason', 'N/A')}
|
| 231 |
+
</div>
|
| 232 |
+
""", unsafe_allow_html=True)
|
| 233 |
+
|
| 234 |
+
# ===== 검색 모드 정보 (검색 사용 시만) =====
|
| 235 |
+
if used_retrieval and search_mode:
|
| 236 |
+
mode_display = {
|
| 237 |
+
'hybrid_rerank': '🔄 Hybrid + Re-ranker',
|
| 238 |
+
'hybrid': '🔀 Hybrid Search',
|
| 239 |
+
'embedding_rerank': '📊 임베딩 + Re-ranker',
|
| 240 |
+
'embedding': '📊 임베딩 검색',
|
| 241 |
+
'direct': '💬 Direct (검색 없음)' # ← 추가
|
| 242 |
+
}
|
| 243 |
+
st.markdown(f"""
|
| 244 |
+
<div class="search-mode-info">
|
| 245 |
+
검색 모드: {mode_display.get(search_mode, search_mode)}
|
| 246 |
+
</div>
|
| 247 |
+
""", unsafe_allow_html=True)
|
| 248 |
+
|
| 249 |
+
# ===== 참고 문서 (검색 사용 시만) =====
|
| 250 |
+
if used_retrieval and sources and len(sources) > 0:
|
| 251 |
+
st.markdown("### 📚 참고 문서")
|
| 252 |
+
|
| 253 |
+
for i, source in enumerate(sources, 1):
|
| 254 |
+
metadata = source.get('metadata', {})
|
| 255 |
+
|
| 256 |
+
# 관련도 점수
|
| 257 |
+
score = source.get('score', 0)
|
| 258 |
+
score_type = source.get('score_type', '')
|
| 259 |
+
|
| 260 |
+
# 문서 내용 미리보기
|
| 261 |
+
content_preview = source.get('content', '')[:200] + "..."
|
| 262 |
+
|
| 263 |
+
st.markdown(f"""
|
| 264 |
+
<div class="source-document">
|
| 265 |
+
<div class="source-header">
|
| 266 |
+
📄 문서 {i} (점수: {score:.3f} / {score_type})
|
| 267 |
+
</div>
|
| 268 |
+
<div>
|
| 269 |
+
{content_preview}
|
| 270 |
+
</div>
|
| 271 |
+
<div class="metadata">
|
| 272 |
+
📁 파일: {metadata.get('파일명', 'N/A')}<br>
|
| 273 |
+
🏢 발주기관: {metadata.get('발주 기관', 'N/A')}<br>
|
| 274 |
+
📋 사업명: {metadata.get('사업명', 'N/A')}
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
""", unsafe_allow_html=True)
|
| 278 |
+
elif not used_retrieval:
|
| 279 |
+
# 검색을 사용하지 않은 경우 안내
|
| 280 |
+
st.info("💬 이 답변은 문서 검색 없이 생성되었습니다.")
|
| 281 |
+
|
| 282 |
+
# ===== 토큰 사용량 =====
|
| 283 |
+
if usage:
|
| 284 |
+
st.markdown(f"""
|
| 285 |
+
<div class="token-usage">
|
| 286 |
+
🔢 토큰 사용량: {usage.get('total_tokens', 0)}
|
| 287 |
+
(프롬프트: {usage.get('prompt_tokens', 0)},
|
| 288 |
+
완성: {usage.get('completion_tokens', 0)})
|
| 289 |
+
</div>
|
| 290 |
+
""", unsafe_allow_html=True)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
# ===== 메인 앱 =====
|
| 294 |
+
def main():
|
| 295 |
+
# 헤더
|
| 296 |
+
st.markdown('<div class="main-header">🤖 공공기관 사업제안서 챗봇</div>', unsafe_allow_html=True)
|
| 297 |
+
st.markdown('<div class="sub-header">Query Router + RAG 기반 질의응답 시스템</div>', unsafe_allow_html=True)
|
| 298 |
+
|
| 299 |
+
# ===== 사이드바 =====
|
| 300 |
+
with st.sidebar:
|
| 301 |
+
st.header("⚙️ 설정")
|
| 302 |
+
|
| 303 |
+
# 모델 설정
|
| 304 |
+
st.markdown("### 🤖 모델 설정")
|
| 305 |
+
|
| 306 |
+
model_type = st.selectbox(
|
| 307 |
+
"생성 모델 선택",
|
| 308 |
+
options=[
|
| 309 |
+
"API 모델 (GPT)",
|
| 310 |
+
"로컬 모델 (GGUF)"
|
| 311 |
+
],
|
| 312 |
+
index=1, # 기본값을 GGUF로 (Router 있음)
|
| 313 |
+
help="""
|
| 314 |
+
• API 모델: OpenAI API 사용 (빠르고 안정적)
|
| 315 |
+
• 로컬 모델 (GGUF): Query Router 포함, 메모리 효율적
|
| 316 |
+
"""
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
# 모델 정보 표시
|
| 320 |
+
if model_type == "API 모델 (GPT)":
|
| 321 |
+
st.info("🌐 OpenAI GPT 모델 사용 중")
|
| 322 |
+
else: # GGUF
|
| 323 |
+
st.success("⚡ 로컬 GGUF + Query Router 사용 중")
|
| 324 |
+
|
| 325 |
+
st.markdown("---")
|
| 326 |
+
|
| 327 |
+
# 검색 설정
|
| 328 |
+
st.markdown("### 🔍 검색 설정")
|
| 329 |
+
|
| 330 |
+
search_mode = st.selectbox(
|
| 331 |
+
"검색 모드",
|
| 332 |
+
options=["hybrid", "embedding"],
|
| 333 |
+
index=0,
|
| 334 |
+
format_func=lambda x: {
|
| 335 |
+
"hybrid": "🔀 Hybrid Search (BM25 + 임베딩)",
|
| 336 |
+
"embedding": "📊 임베딩 검색"
|
| 337 |
+
}[x],
|
| 338 |
+
help="Hybrid: 키워드 + 의미 검색 병행 (권장)"
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
# Reranker 토글
|
| 342 |
+
use_reranker = st.toggle(
|
| 343 |
+
"🔄 Re-ranker 사용",
|
| 344 |
+
value=True,
|
| 345 |
+
help="검색 결과를 CrossEncoder로 재정렬하여 정확도 향상 (권장)"
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
# 실제 검색 모드 결정
|
| 349 |
+
if use_reranker:
|
| 350 |
+
if search_mode == "hybrid":
|
| 351 |
+
actual_search_mode = "hybrid_rerank"
|
| 352 |
+
else: # embedding
|
| 353 |
+
actual_search_mode = "embedding_rerank"
|
| 354 |
+
else:
|
| 355 |
+
actual_search_mode = search_mode
|
| 356 |
+
|
| 357 |
+
top_k = st.slider(
|
| 358 |
+
"검색할 문서 개수 (Top-K)",
|
| 359 |
+
min_value=1,
|
| 360 |
+
max_value=20,
|
| 361 |
+
value=7, # 기본값 조정 (Router로 불필요한 검색 줄어듦)
|
| 362 |
+
help="Router가 검색이 필요한 경우에만 사용됨"
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
alpha = st.slider(
|
| 366 |
+
"임베딩 가중치 (alpha)",
|
| 367 |
+
min_value=0.0,
|
| 368 |
+
max_value=1.0,
|
| 369 |
+
value=0.5,
|
| 370 |
+
step=0.1,
|
| 371 |
+
help="0: BM25만, 1: 임베딩만, 0.5: 동일 가중치 (Hybrid 모드에서만 사용)",
|
| 372 |
+
disabled=(search_mode == "embedding")
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
st.markdown("---")
|
| 376 |
+
|
| 377 |
+
# 개발자 옵션
|
| 378 |
+
st.markdown("### 🛠️ 개발자 옵션")
|
| 379 |
+
|
| 380 |
+
show_routing = st.toggle(
|
| 381 |
+
"🔍 라우팅 정보 표시",
|
| 382 |
+
value=False,
|
| 383 |
+
help="Router의 판단 과정을 표시 (디버깅용)"
|
| 384 |
+
)
|
| 385 |
+
st.session_state.show_routing_info = show_routing
|
| 386 |
+
|
| 387 |
+
st.markdown("---")
|
| 388 |
+
|
| 389 |
+
# 대화 관리
|
| 390 |
+
st.markdown("### 💬 대화 관리")
|
| 391 |
+
|
| 392 |
+
if st.button("🗑️ 대화 초기화", use_container_width=True):
|
| 393 |
+
st.session_state.conv_manager.clear()
|
| 394 |
+
st.rerun()
|
| 395 |
+
|
| 396 |
+
if st.button("💾 대화 다운로드", use_container_width=True):
|
| 397 |
+
if len(st.session_state.conv_manager) > 0: # ✅ conv_manager 사용
|
| 398 |
+
json_str = st.session_state.conv_manager.export_to_json()
|
| 399 |
+
|
| 400 |
+
st.download_button(
|
| 401 |
+
label="📥 JSON 다운로드",
|
| 402 |
+
data=json_str,
|
| 403 |
+
file_name=f"chat_history_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
|
| 404 |
+
mime="application/json",
|
| 405 |
+
use_container_width=True
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
st.markdown("---")
|
| 409 |
+
|
| 410 |
+
# 통계
|
| 411 |
+
st.markdown("### 📊 통계")
|
| 412 |
+
stats = st.session_state.conv_manager.get_statistics()
|
| 413 |
+
|
| 414 |
+
st.metric("총 대화 수", stats.get('total', 0))
|
| 415 |
+
|
| 416 |
+
# 현재 설정 표시
|
| 417 |
+
st.markdown("---")
|
| 418 |
+
st.markdown("### 📋 현재 설정")
|
| 419 |
+
st.text(f"모델: {model_type}")
|
| 420 |
+
st.text(f"검색 모드: {search_mode}")
|
| 421 |
+
st.text(f"Re-ranker: {'✅ ON' if use_reranker else '❌ OFF'}")
|
| 422 |
+
st.text(f"실제 모드: {actual_search_mode}")
|
| 423 |
+
st.text(f"Top-K: {top_k}")
|
| 424 |
+
if search_mode == "hybrid":
|
| 425 |
+
st.text(f"Alpha: {alpha}")
|
| 426 |
+
st.text(f"Router Info: {'✅ ON' if show_routing else '❌ OFF'}")
|
| 427 |
+
|
| 428 |
+
# ===== RAG 파이프라인 초기화 =====
|
| 429 |
+
# 모델 타입이 변경되었거나 파이프라인이 없으면 재초기화
|
| 430 |
+
if (st.session_state.rag_pipeline is None or
|
| 431 |
+
st.session_state.model_type != model_type):
|
| 432 |
+
|
| 433 |
+
with st.spinner(f"🔄 {model_type} 초기화 중..."):
|
| 434 |
+
rag, error, rag_type = initialize_rag(model_type)
|
| 435 |
+
|
| 436 |
+
if error:
|
| 437 |
+
st.error(f"❌ RAG 파이프라인 초기화 실패: {error}")
|
| 438 |
+
st.info("""
|
| 439 |
+
### 💡 해결 방법
|
| 440 |
+
|
| 441 |
+
1. ChromaDB가 생성되었는지 확인:
|
| 442 |
+
```bash
|
| 443 |
+
python main.py --step embed
|
| 444 |
+
```
|
| 445 |
+
|
| 446 |
+
2. OpenAI API 키가 설정되었는지 확인 (API 모델 사용 시):
|
| 447 |
+
```bash
|
| 448 |
+
# .env 파일
|
| 449 |
+
OPENAI_API_KEY=your-key-here
|
| 450 |
+
```
|
| 451 |
+
|
| 452 |
+
3. GGUF 모델 파일 확인 (로컬 모델 사용 시):
|
| 453 |
+
```bash
|
| 454 |
+
# config.py
|
| 455 |
+
GGUF_MODEL_PATH = "./models/your-model.gguf"
|
| 456 |
+
```
|
| 457 |
+
|
| 458 |
+
4. 필요한 패키지 설치:
|
| 459 |
+
```bash
|
| 460 |
+
pip install rank-bm25 sentence-transformers llama-cpp-python
|
| 461 |
+
```
|
| 462 |
+
""")
|
| 463 |
+
return
|
| 464 |
+
|
| 465 |
+
st.session_state.rag_pipeline = rag
|
| 466 |
+
st.session_state.model_type = model_type
|
| 467 |
+
st.success(f"✅ {rag_type} 모델 준비 완료!")
|
| 468 |
+
|
| 469 |
+
# ===== 대화 히스토리 표시 =====
|
| 470 |
+
st.markdown("---")
|
| 471 |
+
|
| 472 |
+
if len(st.session_state.conv_manager) == 0: # ✅ conv_manager 사용
|
| 473 |
+
st.info("""
|
| 474 |
+
### 👋 환영합니다!
|
| 475 |
+
|
| 476 |
+
공공기관 사업제안서에 대해 질문해보세요.
|
| 477 |
+
|
| 478 |
+
**Router가 자동으로 판단합니다:**
|
| 479 |
+
- 📚 문서 검색이 필요한 질문 → RAG 수행
|
| 480 |
+
- 💬 일반 대화/인사 → 직접 답변
|
| 481 |
+
|
| 482 |
+
**예시 질문:**
|
| 483 |
+
- "안녕하세요" (검색 안 함)
|
| 484 |
+
- "데이터 표준화 요구사항은 무엇인가요?" (검색 수행)
|
| 485 |
+
- "보안 관련 요구사항을 설명해주세요" (검색 수행)
|
| 486 |
+
- "고마워요" (검색 안 함)
|
| 487 |
+
""")
|
| 488 |
+
|
| 489 |
+
# 기존 메시지 표시
|
| 490 |
+
for msg in st.session_state.conv_manager.get_ui_history():
|
| 491 |
+
display_message(
|
| 492 |
+
role=msg['role'],
|
| 493 |
+
content=msg['content'],
|
| 494 |
+
sources=msg.get('sources'),
|
| 495 |
+
usage=msg.get('usage'),
|
| 496 |
+
search_mode=msg.get('search_mode'),
|
| 497 |
+
used_retrieval=msg.get('used_retrieval'), # ← 신규
|
| 498 |
+
routing_info=msg.get('routing_info') # ← 신규
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
# ===== 질문 입력 =====
|
| 502 |
+
st.markdown("---")
|
| 503 |
+
|
| 504 |
+
with st.form(key='question_form', clear_on_submit=True):
|
| 505 |
+
user_input = st.text_area(
|
| 506 |
+
"질문을 입력하세요:",
|
| 507 |
+
height=100,
|
| 508 |
+
placeholder="예: 데이터 표준화 요구사항은 무엇인가요?"
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
col1, col2, col3 = st.columns([1, 1, 4])
|
| 512 |
+
|
| 513 |
+
with col1:
|
| 514 |
+
submit_button = st.form_submit_button("📤 전송", use_container_width=True)
|
| 515 |
+
|
| 516 |
+
# ===== 질문 처리 =====
|
| 517 |
+
if submit_button and user_input:
|
| 518 |
+
|
| 519 |
+
# 답변 생성
|
| 520 |
+
with st.spinner("🤔 답변 생성 중..."):
|
| 521 |
+
result = generate_answer(
|
| 522 |
+
query=user_input,
|
| 523 |
+
top_k=top_k,
|
| 524 |
+
search_mode=actual_search_mode,
|
| 525 |
+
alpha=alpha
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
# 어시스턴트 메시지 추가
|
| 529 |
+
st.session_state.conv_manager.add_message(
|
| 530 |
+
user_msg=user_input,
|
| 531 |
+
ai_msg=result['answer'],
|
| 532 |
+
query_type=result.get('query_type', 'unknown'),
|
| 533 |
+
sources=result.get('sources', []),
|
| 534 |
+
usage=result.get('usage', {}),
|
| 535 |
+
search_mode=result.get('search_mode'),
|
| 536 |
+
used_retrieval=result.get('used_retrieval', False),
|
| 537 |
+
routing_info=result.get('routing_info')
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
# 화면 새로고침
|
| 541 |
+
st.rerun()
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
if __name__ == "__main__":
|
| 545 |
+
main()
|
src/visualization/dimensionality_reduction.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
차원 축소 (Dimensionality Reduction)
|
| 3 |
+
1536차원 임베딩 벡터 → 2D/3D 좌표로 변환
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from typing import Literal, Tuple
|
| 9 |
+
from sklearn.decomposition import PCA
|
| 10 |
+
from sklearn.manifold import TSNE
|
| 11 |
+
import warnings
|
| 12 |
+
warnings.filterwarnings('ignore')
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class DimensionalityReducer:
|
| 16 |
+
"""차원 축소 클래스"""
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
method: Literal['pca', 'tsne'] = 'pca',
|
| 21 |
+
n_components: int = 2,
|
| 22 |
+
random_state: int = 42
|
| 23 |
+
):
|
| 24 |
+
"""
|
| 25 |
+
초기화
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
method: 차원 축소 방법 ('pca' 또는 'tsne')
|
| 29 |
+
n_components: 축소할 차원 (2 또는 3)
|
| 30 |
+
random_state: 랜덤 시드
|
| 31 |
+
"""
|
| 32 |
+
self.method = method
|
| 33 |
+
self.n_components = n_components
|
| 34 |
+
self.random_state = random_state
|
| 35 |
+
self.reducer = None
|
| 36 |
+
|
| 37 |
+
self._initialize_reducer()
|
| 38 |
+
|
| 39 |
+
def _initialize_reducer(self):
|
| 40 |
+
"""차원 축소 모델 초기화"""
|
| 41 |
+
if self.method == 'pca':
|
| 42 |
+
self.reducer = PCA(
|
| 43 |
+
n_components=self.n_components,
|
| 44 |
+
random_state=self.random_state
|
| 45 |
+
)
|
| 46 |
+
print(f"✅ PCA 초기화 완료 ({self.n_components}D)")
|
| 47 |
+
|
| 48 |
+
elif self.method == 'tsne':
|
| 49 |
+
self.reducer = TSNE(
|
| 50 |
+
n_components=self.n_components,
|
| 51 |
+
random_state=self.random_state,
|
| 52 |
+
perplexity=30, # 기본값
|
| 53 |
+
max_iter=1000, # n_iter → max_iter로 변경
|
| 54 |
+
verbose=0 # verbose=1 → 0 (Streamlit에서는 0이 좋음)
|
| 55 |
+
)
|
| 56 |
+
print(f"✅ t-SNE 초기화 완료 ({self.n_components}D)")
|
| 57 |
+
|
| 58 |
+
else:
|
| 59 |
+
raise ValueError(f"지원하지 않는 방법: {self.method}")
|
| 60 |
+
|
| 61 |
+
def fit_transform(self, embeddings: np.ndarray) -> np.ndarray:
|
| 62 |
+
"""
|
| 63 |
+
차원 축소 실행
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
embeddings: 원본 임베딩 벡터 (N, 1536)
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
축소된 좌표 (N, 2) 또는 (N, 3)
|
| 70 |
+
"""
|
| 71 |
+
print(f"\n차원 축소 시작...")
|
| 72 |
+
print(f" 방법: {self.method.upper()}")
|
| 73 |
+
print(f" 입력 shape: {embeddings.shape}")
|
| 74 |
+
print(f" 목표 차원: {self.n_components}D")
|
| 75 |
+
|
| 76 |
+
# t-SNE의 경우 perplexity 재설정
|
| 77 |
+
if self.method == 'tsne':
|
| 78 |
+
n_samples = embeddings.shape[0]
|
| 79 |
+
perplexity = min(30, n_samples - 1)
|
| 80 |
+
self.reducer = TSNE(
|
| 81 |
+
n_components=self.n_components,
|
| 82 |
+
random_state=self.random_state,
|
| 83 |
+
perplexity=perplexity,
|
| 84 |
+
max_iter=1000 # n_iter → max_iter로 변경
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# 차원 축소
|
| 88 |
+
reduced = self.reducer.fit_transform(embeddings)
|
| 89 |
+
|
| 90 |
+
print(f" 출력 shape: {reduced.shape}")
|
| 91 |
+
print(f"✅ 차원 축소 완료")
|
| 92 |
+
|
| 93 |
+
# PCA인 경우 설명된 분산 비율 출력
|
| 94 |
+
if self.method == 'pca':
|
| 95 |
+
explained_var = self.reducer.explained_variance_ratio_
|
| 96 |
+
print(f" 설명된 분산:")
|
| 97 |
+
for i, var in enumerate(explained_var, 1):
|
| 98 |
+
print(f" PC{i}: {var:.2%}")
|
| 99 |
+
print(f" 총합: {explained_var.sum():.2%}")
|
| 100 |
+
|
| 101 |
+
return reduced
|
| 102 |
+
|
| 103 |
+
def add_coordinates_to_dataframe(
|
| 104 |
+
self,
|
| 105 |
+
df: pd.DataFrame,
|
| 106 |
+
embeddings: np.ndarray
|
| 107 |
+
) -> pd.DataFrame:
|
| 108 |
+
"""
|
| 109 |
+
DataFrame에 2D/3D 좌표 추가
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
df: 원본 DataFrame
|
| 113 |
+
embeddings: 임베딩 벡터
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
좌표가 추가된 DataFrame
|
| 117 |
+
"""
|
| 118 |
+
# 차원 축소
|
| 119 |
+
reduced = self.fit_transform(embeddings)
|
| 120 |
+
|
| 121 |
+
# DataFrame에 추가
|
| 122 |
+
df = df.copy()
|
| 123 |
+
|
| 124 |
+
if self.n_components == 2:
|
| 125 |
+
df['x'] = reduced[:, 0]
|
| 126 |
+
df['y'] = reduced[:, 1]
|
| 127 |
+
print(f"\n✅ 2D 좌표 추가 완료 (x, y)")
|
| 128 |
+
|
| 129 |
+
elif self.n_components == 3:
|
| 130 |
+
df['x'] = reduced[:, 0]
|
| 131 |
+
df['y'] = reduced[:, 1]
|
| 132 |
+
df['z'] = reduced[:, 2]
|
| 133 |
+
print(f"\n✅ 3D 좌표 추가 완료 (x, y, z)")
|
| 134 |
+
|
| 135 |
+
return df
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def compare_methods(
|
| 139 |
+
embeddings: np.ndarray,
|
| 140 |
+
methods: list = ['pca', 'tsne'],
|
| 141 |
+
n_components: int = 2
|
| 142 |
+
) -> dict:
|
| 143 |
+
"""
|
| 144 |
+
여러 차원 축소 방법 비교
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
embeddings: 임베딩 벡터
|
| 148 |
+
methods: 비교할 방법 리스트
|
| 149 |
+
n_components: 차원
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
{method: reduced_coords} 딕셔너리
|
| 153 |
+
"""
|
| 154 |
+
results = {}
|
| 155 |
+
|
| 156 |
+
for method in methods:
|
| 157 |
+
print(f"\n{'='*60}")
|
| 158 |
+
print(f"{method.upper()} 실행 중...")
|
| 159 |
+
print('='*60)
|
| 160 |
+
|
| 161 |
+
reducer = DimensionalityReducer(
|
| 162 |
+
method=method,
|
| 163 |
+
n_components=n_components
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
reduced = reducer.fit_transform(embeddings)
|
| 167 |
+
results[method] = reduced
|
| 168 |
+
|
| 169 |
+
return results
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ===== 단독 실행용 =====
|
| 173 |
+
if __name__ == "__main__":
|
| 174 |
+
import argparse
|
| 175 |
+
from src.visualization.vector_db_loader import VectorDBLoader
|
| 176 |
+
from src.utils.rag_config import RAGConfig
|
| 177 |
+
|
| 178 |
+
parser = argparse.ArgumentParser(description='차원 축소 테스트')
|
| 179 |
+
parser.add_argument(
|
| 180 |
+
'--method',
|
| 181 |
+
type=str,
|
| 182 |
+
choices=['pca', 'tsne', 'both'],
|
| 183 |
+
default='pca',
|
| 184 |
+
help='차원 축소 방법'
|
| 185 |
+
)
|
| 186 |
+
parser.add_argument(
|
| 187 |
+
'--n-components',
|
| 188 |
+
type=int,
|
| 189 |
+
choices=[2, 3],
|
| 190 |
+
default=2,
|
| 191 |
+
help='축소할 차원 (2D 또는 3D)'
|
| 192 |
+
)
|
| 193 |
+
parser.add_argument(
|
| 194 |
+
'--export',
|
| 195 |
+
type=str,
|
| 196 |
+
help='결과를 CSV로 저장할 경로 (선택)'
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
args = parser.parse_args()
|
| 200 |
+
|
| 201 |
+
# 1. 데이터 로드
|
| 202 |
+
print("="*60)
|
| 203 |
+
print("ChromaDB 데이터 로드")
|
| 204 |
+
print("="*60)
|
| 205 |
+
|
| 206 |
+
config = RAGConfig()
|
| 207 |
+
loader = VectorDBLoader(config)
|
| 208 |
+
df = loader.to_dataframe()
|
| 209 |
+
|
| 210 |
+
print(f"\n로드된 데이터: {len(df)}개")
|
| 211 |
+
|
| 212 |
+
# 2. 임베딩 추출
|
| 213 |
+
embeddings = np.array(df['embedding'].tolist())
|
| 214 |
+
print(f"임베딩 shape: {embeddings.shape}")
|
| 215 |
+
|
| 216 |
+
# 3. 차원 축소
|
| 217 |
+
if args.method == 'both':
|
| 218 |
+
results = compare_methods(embeddings, methods=['pca', 'tsne'], n_components=args.n_components)
|
| 219 |
+
|
| 220 |
+
# PCA 결과를 DataFrame에 추가
|
| 221 |
+
reducer = DimensionalityReducer(method='pca', n_components=args.n_components)
|
| 222 |
+
df = reducer.add_coordinates_to_dataframe(df, embeddings)
|
| 223 |
+
|
| 224 |
+
else:
|
| 225 |
+
reducer = DimensionalityReducer(method=args.method, n_components=args.n_components)
|
| 226 |
+
df = reducer.add_coordinates_to_dataframe(df, embeddings)
|
| 227 |
+
|
| 228 |
+
# 4. 결과 확인
|
| 229 |
+
print("\n" + "="*60)
|
| 230 |
+
print("결과 요약")
|
| 231 |
+
print("="*60)
|
| 232 |
+
print(f"최종 DataFrame shape: {df.shape}")
|
| 233 |
+
print(f"좌표 컬럼: {['x', 'y', 'z'][:args.n_components]}")
|
| 234 |
+
|
| 235 |
+
# 좌표 통계
|
| 236 |
+
print(f"\n좌표 범위:")
|
| 237 |
+
print(f" x: [{df['x'].min():.2f}, {df['x'].max():.2f}]")
|
| 238 |
+
print(f" y: [{df['y'].min():.2f}, {df['y'].max():.2f}]")
|
| 239 |
+
if args.n_components == 3:
|
| 240 |
+
print(f" z: [{df['z'].min():.2f}, {df['z'].max():.2f}]")
|
| 241 |
+
|
| 242 |
+
# 5. CSV 저장 (옵션)
|
| 243 |
+
if args.export:
|
| 244 |
+
df_export = df.drop(columns=['embedding'])
|
| 245 |
+
df_export.to_csv(args.export, index=False, encoding='utf-8-sig')
|
| 246 |
+
print(f"\n✅ 데이터 저장 완료: {args.export}")
|
src/visualization/streamlit_app.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
벡터DB 시각화 Streamlit 앱
|
| 3 |
+
ChromaDB 데이터를 2D/3D로 시각화
|
| 4 |
+
"""
|
| 5 |
+
import io
|
| 6 |
+
import os
|
| 7 |
+
import streamlit as st
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import numpy as np
|
| 10 |
+
import plotly.express as px
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
# 프로젝트 루트를 Python 경로에 추가
|
| 15 |
+
root_dir = Path(__file__).parent.parent.parent
|
| 16 |
+
sys.path.insert(0, str(root_dir))
|
| 17 |
+
|
| 18 |
+
from src.visualization.vector_db_loader import VectorDBLoader
|
| 19 |
+
from src.visualization.dimensionality_reduction import DimensionalityReducer
|
| 20 |
+
from src.utils.config import RAGConfig
|
| 21 |
+
|
| 22 |
+
# ===== 자동 초기화 함수 =====
|
| 23 |
+
@st.cache_resource
|
| 24 |
+
def initialize_data():
|
| 25 |
+
"""ChromaDB가 없으면 자동으로 전처리 + 임베딩 실행"""
|
| 26 |
+
|
| 27 |
+
config = RAGConfig()
|
| 28 |
+
|
| 29 |
+
# ChromaDB가 이미 존재하는지 확인
|
| 30 |
+
if os.path.exists(config.DB_DIRECTORY):
|
| 31 |
+
try:
|
| 32 |
+
# ChromaDB 연결 테스트
|
| 33 |
+
loader = VectorDBLoader(config)
|
| 34 |
+
info = loader.get_collection_info()
|
| 35 |
+
if info['total_documents'] > 0:
|
| 36 |
+
st.success(f"✅ 기존 ChromaDB 로드 완료 ({info['total_documents']}개 문서)")
|
| 37 |
+
return True
|
| 38 |
+
except:
|
| 39 |
+
st.warning("⚠️ 기존 ChromaDB가 손상되었습니다. 재생성합니다.")
|
| 40 |
+
|
| 41 |
+
# ChromaDB가 없으면 생성
|
| 42 |
+
st.info("🔄 ChromaDB를 생성합니다. 최초 1회만 실행되며 약 2-3분 소요됩니다...")
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# 전처리 실행
|
| 46 |
+
with st.spinner("1/2 전처리 실행 중..."):
|
| 47 |
+
from src.loader.preprocess_pipeline import RAGPreprocessPipeline
|
| 48 |
+
from src.utils.preprocess_config import PreprocessConfig
|
| 49 |
+
|
| 50 |
+
preprocess_config = PreprocessConfig()
|
| 51 |
+
pipeline = RAGPreprocessPipeline(preprocess_config)
|
| 52 |
+
df_chunks = pipeline.run()
|
| 53 |
+
st.success(f"✅ 전처리 완료: {len(df_chunks)}개 청크")
|
| 54 |
+
|
| 55 |
+
# 임베딩 실행
|
| 56 |
+
with st.spinner("2/2 임베딩 실행 중..."):
|
| 57 |
+
from src.embedding.rag_data_processing import RAGVectorDBPipeline
|
| 58 |
+
|
| 59 |
+
rag_pipeline = RAGVectorDBPipeline(config)
|
| 60 |
+
rag_pipeline.build()
|
| 61 |
+
st.success("✅ ChromaDB 생성 완료!")
|
| 62 |
+
|
| 63 |
+
return True
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
st.error(f"❌ 초기화 실패: {e}")
|
| 67 |
+
st.info("""
|
| 68 |
+
### 💡 수동 실행이 필요합니다
|
| 69 |
+
|
| 70 |
+
로컬 환경에서:
|
| 71 |
+
```bash
|
| 72 |
+
python main.py --step all
|
| 73 |
+
```
|
| 74 |
+
""")
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
# ===== 페이지 설정 =====
|
| 78 |
+
st.set_page_config(
|
| 79 |
+
page_title="벡터DB 시각화",
|
| 80 |
+
page_icon="🔍",
|
| 81 |
+
layout="wide",
|
| 82 |
+
initial_sidebar_state="expanded"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ===== 스타일 =====
|
| 87 |
+
st.markdown("""
|
| 88 |
+
<style>
|
| 89 |
+
.main-header {
|
| 90 |
+
font-size: 2.5rem;
|
| 91 |
+
font-weight: bold;
|
| 92 |
+
margin-bottom: 1rem;
|
| 93 |
+
}
|
| 94 |
+
.sub-header {
|
| 95 |
+
font-size: 1.2rem;
|
| 96 |
+
color: #666;
|
| 97 |
+
margin-bottom: 2rem;
|
| 98 |
+
}
|
| 99 |
+
.metric-container {
|
| 100 |
+
background-color: #f0f2f6;
|
| 101 |
+
padding: 1rem;
|
| 102 |
+
border-radius: 0.5rem;
|
| 103 |
+
margin-bottom: 1rem;
|
| 104 |
+
}
|
| 105 |
+
</style>
|
| 106 |
+
""", unsafe_allow_html=True)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ===== 캐싱 함수 =====
|
| 110 |
+
@st.cache_data
|
| 111 |
+
def load_data():
|
| 112 |
+
"""ChromaDB 데이터 로드 (캐싱)"""
|
| 113 |
+
config = RAGConfig()
|
| 114 |
+
loader = VectorDBLoader(config)
|
| 115 |
+
df = loader.to_dataframe()
|
| 116 |
+
|
| 117 |
+
# 추출 실패 문서 필터링
|
| 118 |
+
df = df[~df['document'].str.contains('\[추출 실패', na=False)]
|
| 119 |
+
df = df[~df['document'].str.contains('\[PDF 추출 실패', na=False)]
|
| 120 |
+
df = df[~df['document'].str.contains('\[HWP 추출 실패', na=False)]
|
| 121 |
+
|
| 122 |
+
# 인덱스 리셋
|
| 123 |
+
df = df.reset_index(drop=True)
|
| 124 |
+
|
| 125 |
+
print(f"✅ 유효한 문서: {len(df)}개")
|
| 126 |
+
|
| 127 |
+
# 임베딩 벡터 추출
|
| 128 |
+
embeddings = np.array(df['embedding'].tolist())
|
| 129 |
+
|
| 130 |
+
return df, embeddings
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@st.cache_data
|
| 134 |
+
def reduce_dimensions(embeddings, method, n_components):
|
| 135 |
+
"""차원 축소 (캐싱)"""
|
| 136 |
+
reducer = DimensionalityReducer(
|
| 137 |
+
method=method,
|
| 138 |
+
n_components=n_components
|
| 139 |
+
)
|
| 140 |
+
reduced = reducer.fit_transform(embeddings)
|
| 141 |
+
return reduced
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ===== 메인 앱 =====
|
| 145 |
+
def main():
|
| 146 |
+
st.set_page_config(
|
| 147 |
+
page_title="벡터DB 시각화",
|
| 148 |
+
page_icon="🔍",
|
| 149 |
+
layout="wide"
|
| 150 |
+
)
|
| 151 |
+
# 헤더
|
| 152 |
+
st.markdown('<div class="main-header">🔍 벡터DB 시각화</div>', unsafe_allow_html=True)
|
| 153 |
+
st.markdown('<div class="sub-header">ChromaDB 임베딩 공간 탐색</div>', unsafe_allow_html=True)
|
| 154 |
+
|
| 155 |
+
# 자동 초기화
|
| 156 |
+
if not initialize_data():
|
| 157 |
+
return
|
| 158 |
+
|
| 159 |
+
# 데이터 로드
|
| 160 |
+
with st.spinner("데이터 로드 중..."):
|
| 161 |
+
try:
|
| 162 |
+
df, embeddings = load_data()
|
| 163 |
+
except Exception as e:
|
| 164 |
+
st.error(f"❌ 데이터 로드 실패: {e}")
|
| 165 |
+
st.info("먼저 임베딩 단계를 실행하세요: `python main.py --step embed`")
|
| 166 |
+
return
|
| 167 |
+
|
| 168 |
+
# 데이터가 없으면 종료
|
| 169 |
+
if len(df) == 0:
|
| 170 |
+
st.warning("⚠️ ChromaDB에 데이터가 없습니다!")
|
| 171 |
+
st.info("먼저 임베딩 단계를 실행하세요: `python main.py --step embed`")
|
| 172 |
+
return
|
| 173 |
+
|
| 174 |
+
# ===== 사이드바 =====
|
| 175 |
+
with st.sidebar:
|
| 176 |
+
st.header("⚙️ 설정")
|
| 177 |
+
|
| 178 |
+
# 통계 정보
|
| 179 |
+
st.markdown("### 📊 데이터 정보")
|
| 180 |
+
st.metric("총 문서 수", len(df))
|
| 181 |
+
st.metric("임베딩 차원", embeddings.shape[1])
|
| 182 |
+
|
| 183 |
+
st.markdown("---")
|
| 184 |
+
|
| 185 |
+
# 차원 축소 설정
|
| 186 |
+
st.markdown("### 🎯 차원 축소")
|
| 187 |
+
|
| 188 |
+
method = st.selectbox(
|
| 189 |
+
"방법",
|
| 190 |
+
options=['pca', 'tsne'],
|
| 191 |
+
format_func=lambda x: {
|
| 192 |
+
'pca': 'PCA (빠름)',
|
| 193 |
+
'tsne': 't-SNE (느림, 더 정확)'
|
| 194 |
+
}[x]
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
n_components = st.radio(
|
| 198 |
+
"차원",
|
| 199 |
+
options=[2, 3],
|
| 200 |
+
format_func=lambda x: f"{x}D"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
st.markdown("---")
|
| 204 |
+
|
| 205 |
+
# 필터링 옵션
|
| 206 |
+
st.markdown("### 🎨 시각화 옵션")
|
| 207 |
+
|
| 208 |
+
# 색상 기준
|
| 209 |
+
color_options = ['없음'] + [col for col in df.columns
|
| 210 |
+
if col not in ['id', 'document', 'embedding', 'x', 'y', 'z']]
|
| 211 |
+
|
| 212 |
+
color_by = st.selectbox(
|
| 213 |
+
"색상 기준",
|
| 214 |
+
options=color_options
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# 크기 옵션
|
| 218 |
+
point_size = st.slider(
|
| 219 |
+
"포인트 크기",
|
| 220 |
+
min_value=3,
|
| 221 |
+
max_value=15,
|
| 222 |
+
value=8
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# 투명도
|
| 226 |
+
opacity = st.slider(
|
| 227 |
+
"투명도",
|
| 228 |
+
min_value=0.1,
|
| 229 |
+
max_value=1.0,
|
| 230 |
+
value=0.7,
|
| 231 |
+
step=0.1
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
st.markdown("---")
|
| 235 |
+
|
| 236 |
+
# 필터링
|
| 237 |
+
st.markdown("### 🔍 필터")
|
| 238 |
+
|
| 239 |
+
filter_col = st.selectbox(
|
| 240 |
+
"필터링 기준",
|
| 241 |
+
options=['없음'] + color_options[1:] # '없음' 제외한 나머지
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
filter_values = []
|
| 245 |
+
if filter_col != '없음':
|
| 246 |
+
unique_values = df[filter_col].unique()
|
| 247 |
+
filter_values = st.multiselect(
|
| 248 |
+
f"{filter_col} 선택",
|
| 249 |
+
options=unique_values,
|
| 250 |
+
default=list(unique_values)[:5] if len(unique_values) > 5 else list(unique_values)
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# ===== 차원 축소 =====
|
| 254 |
+
with st.spinner(f"{method.upper()}로 차원 축소 중..."):
|
| 255 |
+
reduced = reduce_dimensions(embeddings, method, n_components)
|
| 256 |
+
|
| 257 |
+
# DataFrame에 좌표 추가
|
| 258 |
+
df_viz = df.copy()
|
| 259 |
+
df_viz['x'] = reduced[:, 0]
|
| 260 |
+
df_viz['y'] = reduced[:, 1]
|
| 261 |
+
if n_components == 3:
|
| 262 |
+
df_viz['z'] = reduced[:, 2]
|
| 263 |
+
|
| 264 |
+
# 필터링 적용
|
| 265 |
+
if filter_col != '없음' and filter_values:
|
| 266 |
+
df_viz = df_viz[df_viz[filter_col].isin(filter_values)]
|
| 267 |
+
st.info(f"필터링 결과: {len(df_viz)}개 문서")
|
| 268 |
+
|
| 269 |
+
# ===== 시각화 =====
|
| 270 |
+
st.markdown("---")
|
| 271 |
+
st.markdown("### 📈 임베딩 공간 시각화")
|
| 272 |
+
|
| 273 |
+
# hover 데이터 준비
|
| 274 |
+
hover_data = {
|
| 275 |
+
'document': True,
|
| 276 |
+
'x': ':.2f',
|
| 277 |
+
'y': ':.2f'
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
if n_components == 3:
|
| 281 |
+
hover_data['z'] = ':.2f'
|
| 282 |
+
|
| 283 |
+
# 메타데이터 hover에 추가
|
| 284 |
+
for col in ['파일명', '발주 기관', '사업명']:
|
| 285 |
+
if col in df_viz.columns:
|
| 286 |
+
hover_data[col] = True
|
| 287 |
+
|
| 288 |
+
# 색상 설정
|
| 289 |
+
color = None if color_by == '없음' else color_by
|
| 290 |
+
|
| 291 |
+
# 2D 시각화
|
| 292 |
+
if n_components == 2:
|
| 293 |
+
fig = px.scatter(
|
| 294 |
+
df_viz,
|
| 295 |
+
x='x',
|
| 296 |
+
y='y',
|
| 297 |
+
color=color,
|
| 298 |
+
hover_data=hover_data,
|
| 299 |
+
title=f"벡터 임베딩 공간 ({method.upper()}, 2D)",
|
| 300 |
+
labels={'x': 'PC1' if method == 'pca' else 'Dim 1',
|
| 301 |
+
'y': 'PC2' if method == 'pca' else 'Dim 2'},
|
| 302 |
+
height=700,
|
| 303 |
+
opacity=opacity
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
fig.update_traces(marker=dict(size=point_size))
|
| 307 |
+
|
| 308 |
+
# 3D 시각화
|
| 309 |
+
else:
|
| 310 |
+
fig = px.scatter_3d(
|
| 311 |
+
df_viz,
|
| 312 |
+
x='x',
|
| 313 |
+
y='y',
|
| 314 |
+
z='z',
|
| 315 |
+
color=color,
|
| 316 |
+
hover_data=hover_data,
|
| 317 |
+
title=f"벡터 임베딩 공간 ({method.upper()}, 3D)",
|
| 318 |
+
labels={'x': 'PC1' if method == 'pca' else 'Dim 1',
|
| 319 |
+
'y': 'PC2' if method == 'pca' else 'Dim 2',
|
| 320 |
+
'z': 'PC3' if method == 'pca' else 'Dim 3'},
|
| 321 |
+
height=700,
|
| 322 |
+
opacity=opacity
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
fig.update_traces(marker=dict(size=point_size))
|
| 326 |
+
|
| 327 |
+
# 레이아웃 업데이트
|
| 328 |
+
fig.update_layout(
|
| 329 |
+
showlegend=True,
|
| 330 |
+
hovermode='closest',
|
| 331 |
+
plot_bgcolor='white'
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 335 |
+
|
| 336 |
+
# ===== 통계 정보 =====
|
| 337 |
+
st.markdown("---")
|
| 338 |
+
st.markdown("### 📊 통계 정보")
|
| 339 |
+
|
| 340 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 341 |
+
|
| 342 |
+
with col1:
|
| 343 |
+
st.metric("표시된 문서", len(df_viz))
|
| 344 |
+
|
| 345 |
+
with col2:
|
| 346 |
+
st.metric("필터링된 문서", len(df) - len(df_viz))
|
| 347 |
+
|
| 348 |
+
with col3:
|
| 349 |
+
if method == 'pca':
|
| 350 |
+
# PCA 설명된 분산 표시
|
| 351 |
+
reducer = DimensionalityReducer(method='pca', n_components=n_components)
|
| 352 |
+
reducer.fit_transform(embeddings)
|
| 353 |
+
explained_var = reducer.reducer.explained_variance_ratio_.sum()
|
| 354 |
+
st.metric("설명된 분산", f"{explained_var:.1%}")
|
| 355 |
+
else:
|
| 356 |
+
st.metric("차원 축소 방법", "t-SNE")
|
| 357 |
+
|
| 358 |
+
with col4:
|
| 359 |
+
st.metric("임베딩 차원", embeddings.shape[1])
|
| 360 |
+
|
| 361 |
+
# ===== 데이터 테이블 =====
|
| 362 |
+
if st.checkbox("📋 데이터 테이블 보기", value=False):
|
| 363 |
+
st.markdown("---")
|
| 364 |
+
st.markdown("### 📋 데이터 테이블")
|
| 365 |
+
|
| 366 |
+
# 표시할 컬럼 선택
|
| 367 |
+
display_cols = st.multiselect(
|
| 368 |
+
"표시할 컬럼 선택",
|
| 369 |
+
options=[col for col in df_viz.columns if col != 'embedding'],
|
| 370 |
+
default=['파일명', '발주 기관', '사업명'][:min(3, len(df_viz.columns))]
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
if display_cols:
|
| 374 |
+
st.dataframe(
|
| 375 |
+
df_viz[display_cols],
|
| 376 |
+
use_container_width=True,
|
| 377 |
+
height=400
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
# ===== 다운로드 옵션 =====
|
| 381 |
+
st.markdown("---")
|
| 382 |
+
st.markdown("### 💾 데이터 다운로드")
|
| 383 |
+
|
| 384 |
+
df_download = df_viz.drop(columns=['embedding'])
|
| 385 |
+
|
| 386 |
+
# BytesIO 버퍼 생성
|
| 387 |
+
buffer = io.BytesIO()
|
| 388 |
+
|
| 389 |
+
# Excel 파일 생성
|
| 390 |
+
with pd.ExcelWriter(buffer, engine='openpyxl') as writer:
|
| 391 |
+
df_download.to_excel(writer, index=False, sheet_name='VectorDB')
|
| 392 |
+
|
| 393 |
+
st.download_button(
|
| 394 |
+
label="📥 Excel 다운로드",
|
| 395 |
+
data=buffer.getvalue(),
|
| 396 |
+
file_name="vectordb_visualization.xlsx",
|
| 397 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 398 |
+
use_container_width=True
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
st.caption("💡 Excel에서 바로 열 수 있으며 한글이 정상 표시됩니다.")
|
| 402 |
+
|
| 403 |
+
if __name__ == "__main__":
|
| 404 |
+
main()
|
src/visualization/vector_db_loader.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ChromaDB 벡터 데이터베이스 로더
|
| 3 |
+
임베딩 벡터와 메타데이터를 추출하여 시각화용 데이터 준비
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import numpy as np
|
| 8 |
+
from typing import Dict, List, Optional
|
| 9 |
+
from langchain_chroma import Chroma
|
| 10 |
+
from langchain_openai.embeddings import OpenAIEmbeddings
|
| 11 |
+
|
| 12 |
+
from src.utils.config import RAGConfig
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class VectorDBLoader:
|
| 16 |
+
"""ChromaDB에서 벡터와 메타데이터를 추출하는 클래스"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, config: RAGConfig = None):
|
| 19 |
+
"""
|
| 20 |
+
초기화
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
config: RAG 설정 객체
|
| 24 |
+
"""
|
| 25 |
+
self.config = config or RAGConfig()
|
| 26 |
+
self.vectorstore = None
|
| 27 |
+
self.embeddings = None
|
| 28 |
+
|
| 29 |
+
self._initialize()
|
| 30 |
+
|
| 31 |
+
def _initialize(self):
|
| 32 |
+
"""임베딩 모델 및 벡터스토어 초기화"""
|
| 33 |
+
# 임베딩 모델 초기화
|
| 34 |
+
self.embeddings = OpenAIEmbeddings(
|
| 35 |
+
model=self.config.EMBEDDING_MODEL_NAME,
|
| 36 |
+
openai_api_key=self.config.OPENAI_API_KEY
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# 벡터스토어 연결
|
| 40 |
+
self.vectorstore = Chroma(
|
| 41 |
+
embedding_function=self.embeddings,
|
| 42 |
+
persist_directory=self.config.DB_DIRECTORY,
|
| 43 |
+
collection_name=self.config.COLLECTION_NAME
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
print(f"✅ ChromaDB 연결 완료")
|
| 47 |
+
print(f" 경로: {self.config.DB_DIRECTORY}")
|
| 48 |
+
print(f" Collection: {self.config.COLLECTION_NAME}")
|
| 49 |
+
|
| 50 |
+
def get_collection_info(self) -> Dict:
|
| 51 |
+
"""
|
| 52 |
+
Collection 기본 정보 반환
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
dict: Collection 통계 정보
|
| 56 |
+
"""
|
| 57 |
+
collection = self.vectorstore._collection
|
| 58 |
+
count = collection.count()
|
| 59 |
+
|
| 60 |
+
if count == 0:
|
| 61 |
+
return {
|
| 62 |
+
'total_documents': 0,
|
| 63 |
+
'embedding_dimension': 0,
|
| 64 |
+
'metadata_keys': [],
|
| 65 |
+
'collection_name': self.config.COLLECTION_NAME
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
# 샘플 데이터 가져오기
|
| 69 |
+
sample = collection.get(limit=1, include=['embeddings', 'metadatas'])
|
| 70 |
+
|
| 71 |
+
# 임베딩 차원 확인
|
| 72 |
+
embedding_dim = 0
|
| 73 |
+
if sample.get('embeddings') is not None and len(sample['embeddings']) > 0:
|
| 74 |
+
embedding_dim = len(sample['embeddings'][0])
|
| 75 |
+
|
| 76 |
+
# 메타데이터 키 확인
|
| 77 |
+
metadata_keys = []
|
| 78 |
+
if sample.get('metadatas') is not None and len(sample['metadatas']) > 0:
|
| 79 |
+
if sample['metadatas'][0]:
|
| 80 |
+
metadata_keys = list(sample['metadatas'][0].keys())
|
| 81 |
+
|
| 82 |
+
info = {
|
| 83 |
+
'total_documents': count,
|
| 84 |
+
'embedding_dimension': embedding_dim,
|
| 85 |
+
'metadata_keys': metadata_keys,
|
| 86 |
+
'collection_name': self.config.COLLECTION_NAME
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
return info
|
| 90 |
+
|
| 91 |
+
def extract_all_data(self) -> Dict:
|
| 92 |
+
"""
|
| 93 |
+
모든 데이터를 추출
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
dict: {
|
| 97 |
+
'embeddings': 임베딩 벡터 배열 (numpy),
|
| 98 |
+
'metadatas': 메타데이터 리스트,
|
| 99 |
+
'documents': 문서 텍스트 리스트,
|
| 100 |
+
'ids': 문서 ID 리스트
|
| 101 |
+
}
|
| 102 |
+
"""
|
| 103 |
+
print("\n데이터 추출 중...")
|
| 104 |
+
|
| 105 |
+
collection = self.vectorstore._collection
|
| 106 |
+
|
| 107 |
+
# 모든 데이터 가져오기
|
| 108 |
+
results = collection.get(
|
| 109 |
+
include=['embeddings', 'metadatas', 'documents']
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# 데이터가 없는 경우 처리
|
| 113 |
+
if not results['ids'] or len(results['ids']) == 0:
|
| 114 |
+
print("⚠️ ChromaDB에 데이터가 없습니다!")
|
| 115 |
+
print(" 먼저 임베딩 단계를 실행하세요: python main.py --step embed")
|
| 116 |
+
return {
|
| 117 |
+
'embeddings': np.array([]),
|
| 118 |
+
'metadatas': [],
|
| 119 |
+
'documents': [],
|
| 120 |
+
'ids': []
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
# numpy array로 변환
|
| 124 |
+
embeddings_array = np.array(results['embeddings'])
|
| 125 |
+
|
| 126 |
+
print(f"✅ 총 {len(results['ids'])}개의 청크를 불러왔습니다.")
|
| 127 |
+
if embeddings_array.ndim == 2: # 2D 배열인 경우에만
|
| 128 |
+
print(f"✅ 임베딩 차원: {embeddings_array.shape[1]}차원")
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
'embeddings': embeddings_array,
|
| 132 |
+
'metadatas': results['metadatas'],
|
| 133 |
+
'documents': results['documents'],
|
| 134 |
+
'ids': results['ids']
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
def to_dataframe(self, data: Dict = None) -> pd.DataFrame:
|
| 138 |
+
"""
|
| 139 |
+
추출한 데이터를 DataFrame으로 변환
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
data: extract_all_data()의 결과 (None이면 자동 추출)
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
pd.DataFrame: 정리된 데이터프레임
|
| 146 |
+
"""
|
| 147 |
+
if data is None:
|
| 148 |
+
data = self.extract_all_data()
|
| 149 |
+
|
| 150 |
+
# 데이터가 없으면 빈 DataFrame 반환
|
| 151 |
+
if len(data['ids']) == 0:
|
| 152 |
+
return pd.DataFrame()
|
| 153 |
+
|
| 154 |
+
# 기본 컬럼
|
| 155 |
+
df = pd.DataFrame({
|
| 156 |
+
'id': data['ids'],
|
| 157 |
+
'document': data['documents'],
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
# 메타데이터를 개별 컬럼으로 분리
|
| 161 |
+
if data['metadatas']:
|
| 162 |
+
# 메타데이터의 모든 키 추출
|
| 163 |
+
metadata_keys = set()
|
| 164 |
+
for metadata in data['metadatas']:
|
| 165 |
+
if metadata:
|
| 166 |
+
metadata_keys.update(metadata.keys())
|
| 167 |
+
|
| 168 |
+
# 각 메타데이터 키를 컬럼으로 추가
|
| 169 |
+
for key in metadata_keys:
|
| 170 |
+
df[key] = [
|
| 171 |
+
metadata.get(key, None) if metadata else None
|
| 172 |
+
for metadata in data['metadatas']
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
# 임베딩 벡터 추가 (numpy array로)
|
| 176 |
+
df['embedding'] = list(data['embeddings'])
|
| 177 |
+
|
| 178 |
+
print(f"\n📊 DataFrame 정보:")
|
| 179 |
+
print(f" - Shape: {df.shape}")
|
| 180 |
+
print(f" - Columns: {df.columns.tolist()}")
|
| 181 |
+
|
| 182 |
+
return df
|
| 183 |
+
|
| 184 |
+
def get_metadata_stats(self, df: pd.DataFrame = None) -> Dict:
|
| 185 |
+
"""
|
| 186 |
+
메타데이터 통계 정보
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
df: DataFrame (None이면 자동 생성)
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
dict: 메타데이터별 통계
|
| 193 |
+
"""
|
| 194 |
+
if df is None or len(df) == 0:
|
| 195 |
+
return {}
|
| 196 |
+
|
| 197 |
+
stats = {}
|
| 198 |
+
|
| 199 |
+
# embedding과 document 컬럼 제외
|
| 200 |
+
metadata_cols = [col for col in df.columns
|
| 201 |
+
if col not in ['id', 'document', 'embedding']]
|
| 202 |
+
|
| 203 |
+
for col in metadata_cols:
|
| 204 |
+
if df[col].dtype == 'object': # 범주형 데이터
|
| 205 |
+
stats[col] = {
|
| 206 |
+
'type': 'categorical',
|
| 207 |
+
'unique_count': df[col].nunique(),
|
| 208 |
+
'top_values': df[col].value_counts().head(10).to_dict()
|
| 209 |
+
}
|
| 210 |
+
else: # 숫자형 데이터
|
| 211 |
+
stats[col] = {
|
| 212 |
+
'type': 'numerical',
|
| 213 |
+
'mean': float(df[col].mean()),
|
| 214 |
+
'std': float(df[col].std()),
|
| 215 |
+
'min': float(df[col].min()),
|
| 216 |
+
'max': float(df[col].max())
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
return stats
|
| 220 |
+
|
| 221 |
+
def print_summary(self):
|
| 222 |
+
"""데이터 요약 정보 출력"""
|
| 223 |
+
print("\n" + "="*60)
|
| 224 |
+
print("ChromaDB 데이터 요약")
|
| 225 |
+
print("="*60)
|
| 226 |
+
|
| 227 |
+
# Collection 정보
|
| 228 |
+
info = self.get_collection_info()
|
| 229 |
+
print(f"\n📦 Collection: {info['collection_name']}")
|
| 230 |
+
print(f"📊 총 문서 수: {info['total_documents']}")
|
| 231 |
+
|
| 232 |
+
# 데이터가 없으면 여기서 종료
|
| 233 |
+
if info['total_documents'] == 0:
|
| 234 |
+
print("\n⚠️ ChromaDB에 데이터가 없습니다!")
|
| 235 |
+
print(" 먼저 임베딩 단계를 실행하세요:")
|
| 236 |
+
print(" python main.py --step embed")
|
| 237 |
+
print("="*60)
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
print(f"🧮 임베딩 차원: {info['embedding_dimension']}")
|
| 241 |
+
print(f"🏷️ 메타데이터 키: {', '.join(info['metadata_keys'])}")
|
| 242 |
+
|
| 243 |
+
# DataFrame 생성
|
| 244 |
+
df = self.to_dataframe()
|
| 245 |
+
|
| 246 |
+
if len(df) == 0:
|
| 247 |
+
print("\n⚠️ DataFrame 생성 실패")
|
| 248 |
+
print("="*60)
|
| 249 |
+
return None
|
| 250 |
+
|
| 251 |
+
# 메타데이터 통계
|
| 252 |
+
stats = self.get_metadata_stats(df)
|
| 253 |
+
|
| 254 |
+
if stats:
|
| 255 |
+
print("\n📈 메타데이터 분포:")
|
| 256 |
+
for key, stat in stats.items():
|
| 257 |
+
if stat['type'] == 'categorical':
|
| 258 |
+
print(f"\n [{key}]")
|
| 259 |
+
print(f" - 고유값: {stat['unique_count']}개")
|
| 260 |
+
print(f" - 상위 값:")
|
| 261 |
+
for val, count in list(stat['top_values'].items())[:5]:
|
| 262 |
+
print(f" • {val}: {count}개")
|
| 263 |
+
|
| 264 |
+
print("\n" + "="*60)
|
| 265 |
+
|
| 266 |
+
return df
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ===== 단독 실행용 =====
|
| 270 |
+
if __name__ == "__main__":
|
| 271 |
+
import argparse
|
| 272 |
+
|
| 273 |
+
parser = argparse.ArgumentParser(description='ChromaDB 데이터 추출 및 확인')
|
| 274 |
+
parser.add_argument(
|
| 275 |
+
'--export',
|
| 276 |
+
type=str,
|
| 277 |
+
help='DataFrame을 CSV로 저장할 경로 (선택사항)'
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
args = parser.parse_args()
|
| 281 |
+
|
| 282 |
+
# 설정 초기화
|
| 283 |
+
config = RAGConfig()
|
| 284 |
+
|
| 285 |
+
# 데이터 로더 초기화
|
| 286 |
+
loader = VectorDBLoader(config)
|
| 287 |
+
|
| 288 |
+
# 요약 정보 출력 및 DataFrame 생성
|
| 289 |
+
df = loader.print_summary()
|
| 290 |
+
|
| 291 |
+
# CSV 저장 (옵션)
|
| 292 |
+
if df is not None and args.export:
|
| 293 |
+
# 임베딩 벡터를 제외하고 저장 (파일 크기 때문)
|
| 294 |
+
df_export = df.drop(columns=['embedding'])
|
| 295 |
+
df_export.to_csv(args.export, index=False, encoding='utf-8-sig')
|
| 296 |
+
print(f"\n✅ 데이터 저장 완료: {args.export}")
|