Dongjin1203 commited on
Commit
4739096
·
0 Parent(s):

Initial commit for HF Spaces deployment

Browse files
Files changed (46) hide show
  1. .devcontainer/devcontainer.json +33 -0
  2. .dockerfile +68 -0
  3. .env.example +15 -0
  4. .gitattributes +8 -0
  5. .gitignore +244 -0
  6. .vscode/settings.json +4 -0
  7. EDA_report.md +614 -0
  8. LICENSE +21 -0
  9. README.md +145 -0
  10. chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/data_level0.bin +3 -0
  11. chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/header.bin +3 -0
  12. chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/index_metadata.pickle +3 -0
  13. chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/length.bin +3 -0
  14. chroma_db/29e2b771-69ae-4fd1-9025-9dda88ce7e45/link_lists.bin +3 -0
  15. chroma_db/chroma.sqlite3 +3 -0
  16. data/rag_chunks_final.csv +3 -0
  17. main.py +237 -0
  18. notebooks/export_v2.py +47 -0
  19. notebooks/llama.ipynb +887 -0
  20. notebooks/train.py +116 -0
  21. project_setting.md +295 -0
  22. pyproject.toml +68 -0
  23. requirements.txt +52 -0
  24. src/__init__.py +0 -0
  25. src/embedding/rag_data_processing.py +230 -0
  26. src/evaluation/__init__.py +0 -0
  27. src/evaluation/experiment_tracker.py +427 -0
  28. src/evaluation/run_experiment.py +535 -0
  29. src/generator/generator.py +335 -0
  30. src/generator/generator_gguf.py +580 -0
  31. src/generator/generator_lee.py +377 -0
  32. src/loader/__init__.py +0 -0
  33. src/loader/preprocess_pipeline.py +553 -0
  34. src/prompts/dynamic_prompts.py +87 -0
  35. src/prompts/dynamic_prompts_jiyunpark.py +357 -0
  36. src/retriever/main.py +67 -0
  37. src/retriever/retriever.py +313 -0
  38. src/router/query_router.py +65 -0
  39. src/router/query_router_lee.py +77 -0
  40. src/utils/__init__.py +0 -0
  41. src/utils/config.py +177 -0
  42. src/utils/conversation_manager.py +233 -0
  43. src/visualization/chatbot_app.py +545 -0
  44. src/visualization/dimensionality_reduction.py +246 -0
  45. src/visualization/streamlit_app.py +404 -0
  46. 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
+ ![VectorDB Dashboard](asset/chatbot.gif)
5
+
6
+ ## 벡터 DB 대시보드 영상
7
+ ![VectorDB Dashboard](asset/vectorDB.gif)
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
+ |![https://github.com/Dongjin-1203](https://img.shields.io/badge/github-181717?style=for-the-badge&logo=github&logoColor=white)|![https://github.com/Jinuk93](https://img.shields.io/badge/github-181717?style=for-the-badge&logo=github&logoColor=white)|![https://github.com/Leeyuno0419](https://img.shields.io/badge/github-181717?style=for-the-badge&logo=github&logoColor=white)|![https://github.com/krapnuyij](https://img.shields.io/badge/github-181717?style=for-the-badge&logo=github&logoColor=white)|
106
+ |![hamubr1203@gmail.com](https://img.shields.io/badge/Gmail-D14836?style=for-the-badge&logo=gmail&logoColor=white)|![rlawlsdnr430@gmail.com](https://img.shields.io/badge/Gmail-D14836?style=for-the-badge&logo=gmail&logoColor=white)|![yoonolee0419@gmail.com](https://img.shields.io/badge/Gmail-D14836?style=for-the-badge&logo=gmail&logoColor=white)|![jiyun1147@gmail.com](https://img.shields.io/badge/Gmail-D14836?style=for-the-badge&logo=gmail&logoColor=white)|
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}")