Thay đổi promt
Browse files- .gitattributes +2 -0
- .gitignore +3 -0
- README.md +324 -27
- colab.ipynb +0 -476
- core/gradio/gradio_rag.py +2 -182
- core/gradio/user_gradio.py +99 -34
- core/hash_file/hash_data_goc.py +23 -26
- core/hash_file/hash_file.py +20 -25
- core/preprocessing/docling_processor.py +24 -27
- core/preprocessing/pdf_parser.py +11 -11
- core/rag/chunk.py +52 -52
- core/rag/embedding_model.py +19 -20
- core/rag/generator.py +27 -21
- core/rag/{retrival.py → retrieval.py} +57 -59
- core/rag/vector_store.py +48 -48
- evaluation/eval_utils.py +15 -17
- evaluation/ragas_eval.py +34 -26
- scripts/build_data.py +42 -44
- scripts/run_app.py +52 -0
- scripts/run_eval.py +5 -5
- setup.bat +35 -0
- setup.sh +38 -0
- test_chunk.md +0 -696
.gitattributes
CHANGED
|
@@ -59,3 +59,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 59 |
*.webm filter=lfs diff=lfs merge=lfs -text
|
| 60 |
*.pdf filter=lfs diff=lfs merge=lfs -text
|
| 61 |
data/files/*.pdf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 59 |
*.webm filter=lfs diff=lfs merge=lfs -text
|
| 60 |
*.pdf filter=lfs diff=lfs merge=lfs -text
|
| 61 |
data/files/*.pdf filter=lfs diff=lfs merge=lfs -text
|
| 62 |
+
# SCM syntax highlighting & preventing 3-way merges
|
| 63 |
+
pixi.lock merge=binary linguist-language=YAML linguist-generated=true -diff
|
.gitignore
CHANGED
|
@@ -157,3 +157,6 @@ __pycache__/
|
|
| 157 |
|
| 158 |
# Download with: python scripts/download_data.py
|
| 159 |
data/
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
# Download with: python scripts/download_data.py
|
| 159 |
data/
|
| 160 |
+
# pixi environments
|
| 161 |
+
.pixi/*
|
| 162 |
+
!.pixi/config.toml
|
README.md
CHANGED
|
@@ -1,56 +1,353 @@
|
|
| 1 |
-
# HUST RAG
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
- Reranking với Qwen3-Reranker
|
| 9 |
-
- Small-to-Big Retrieval cho bảng biểu
|
| 10 |
-
- Giao diện chat Gradio
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
|
| 31 |
-
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
|
| 38 |
-
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
source venv/bin/activate # Linux/Mac
|
| 41 |
-
venv\Scripts\activate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
python scripts/run_app.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
huggingface-cli download hungnha/do_an_tot_nghiep --repo-type dataset --local-dir ./data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
|
|
|
| 55 |
|
|
|
|
| 56 |
|
|
|
|
|
|
| 1 |
+
# HUST RAG — Student Regulations Q&A System
|
| 2 |
|
| 3 |
+
A Retrieval-Augmented Generation (RAG) system that helps students query academic regulations and policies at Hanoi University of Science and Technology (HUST). The system processes Markdown-based regulation documents, stores them in a vector database, and uses a hybrid retrieval pipeline with reranking to provide accurate, context-grounded answers through a conversational chat interface.
|
| 4 |
|
| 5 |
+
---
|
| 6 |
|
| 7 |
+
## ✨ Key Features
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
+
- **Hybrid Search** — Combines vector similarity search (ChromaDB) with BM25 keyword matching for both semantic and lexical retrieval
|
| 10 |
+
- **Reranking** — Uses Qwen3-Reranker-8B via SiliconFlow API to re-score and sort retrieved documents by relevance
|
| 11 |
+
- **Small-to-Big Retrieval** — Summarizes large tables with an LLM, embeds the summary for search, and returns the full original table at query time
|
| 12 |
+
- **4 Retrieval Modes** — `vector_only`, `bm25_only`, `hybrid`, `hybrid_rerank` — configurable per query
|
| 13 |
+
- **Incremental Data Build** — Hash-based change detection ensures only modified files are re-processed when rebuilding the database
|
| 14 |
+
- **Streaming Chat UI** — Gradio-based conversational interface with real-time response streaming
|
| 15 |
+
- **RAGAS Evaluation** — Built-in evaluation pipeline using the RAGAS framework with metrics like faithfulness, relevancy, precision, recall, and ROUGE scores
|
| 16 |
|
| 17 |
+
---
|
| 18 |
|
| 19 |
+
## 🏗️ System Architecture
|
| 20 |
|
| 21 |
+
```
|
| 22 |
+
┌────────────────────────────────────────────────────────────────────┐
|
| 23 |
+
│ User Query (Gradio UI) │
|
| 24 |
+
└──────────────────────────────┬─────────────────────────────────────┘
|
| 25 |
+
│
|
| 26 |
+
▼
|
| 27 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 28 |
+
│ Retrieval Pipeline │
|
| 29 |
+
│ │
|
| 30 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
| 31 |
+
│ │ Vector Search │ + │ BM25 Search │ → │ Ensemble (weighted) │ │
|
| 32 |
+
│ │ (ChromaDB) │ │ (rank-bm25) │ │ vector:0.5 + bm25:0.5 │ │
|
| 33 |
+
│ └──────────────┘ └──────────────┘ └──────────┬─────────────┘ │
|
| 34 |
+
│ │ │
|
| 35 |
+
│ ▼ │
|
| 36 |
+
│ ┌──────────────────┐ │
|
| 37 |
+
│ │ Qwen3-Reranker │ │
|
| 38 |
+
│ │ (SiliconFlow API) │ │
|
| 39 |
+
│ └────────┬─────────┘ │
|
| 40 |
+
│ │ │
|
| 41 |
+
│ Small-to-Big: │ │
|
| 42 |
+
│ summary hit → │ │
|
| 43 |
+
│ swap w/ parent │ │
|
| 44 |
+
└───────────────────────────────────────────┬──────────────────────────┘
|
| 45 |
+
│
|
| 46 |
+
▼
|
| 47 |
+
┌──────────────────────────────────────────────────────────────────────┐
|
| 48 |
+
│ Context Builder + LLM │
|
| 49 |
+
│ │
|
| 50 |
+
│ Context (top-k docs + metadata) → Prompt → LLM (Groq API) │
|
| 51 |
+
│ → Streaming Response │
|
| 52 |
+
└──────────────────────────────────────────────────────────────────────┘
|
| 53 |
+
```
|
| 54 |
|
| 55 |
+
---
|
| 56 |
|
| 57 |
+
## 📁 Project Structure
|
| 58 |
|
| 59 |
+
```
|
| 60 |
+
DoAn/
|
| 61 |
+
├── core/ # Core application modules
|
| 62 |
+
│ ├── rag/ # RAG engine
|
| 63 |
+
│ │ ├── chunk.py # Markdown chunking with table extraction & Small-to-Big
|
| 64 |
+
│ │ ├── embedding_model.py # Qwen3-Embedding wrapper (SiliconFlow API)
|
| 65 |
+
│ │ ├── vector_store.py # ChromaDB wrapper with parent node storage
|
| 66 |
+
│ │ ├── retrieval.py # Hybrid retriever + SiliconFlow reranker
|
| 67 |
+
│ │ └── generator.py # Context builder & prompt construction
|
| 68 |
+
│ ├── gradio/ # Chat interfaces
|
| 69 |
+
│ │ ├── user_gradio.py # Main Gradio app (production + debug modes)
|
| 70 |
+
│ │ └── gradio_rag.py # Debug mode launcher (thin wrapper)
|
| 71 |
+
│ └── hash_file/ # File hashing utilities
|
| 72 |
+
│ └── hash_file.py # SHA-256 hash processor for change detection
|
| 73 |
+
│
|
| 74 |
+
├── scripts/ # Workflow scripts
|
| 75 |
+
│ ├── run_app.py # Application entry point (data check + env check + launch)
|
| 76 |
+
│ ├── build_data.py # Build/update ChromaDB from markdown files
|
| 77 |
+
│ ├── download_data.py # Download data from HuggingFace
|
| 78 |
+
│ └── run_eval.py # Run RAGAS evaluation
|
| 79 |
+
│
|
| 80 |
+
├── evaluation/ # Evaluation pipeline
|
| 81 |
+
│ ├── eval_utils.py # Shared utilities (RAG init, answer generation)
|
| 82 |
+
│ └── ragas_eval.py # RAGAS evaluation with multiple metrics
|
| 83 |
+
│
|
| 84 |
+
├── test/ # Unit tests
|
| 85 |
+
│ ├── conftest.py # Shared fixtures and sample data
|
| 86 |
+
│ ├── test_chunk.py # Chunking logic tests
|
| 87 |
+
│ ├── test_embedding.py # Embedding model tests
|
| 88 |
+
│ ├── test_vector_store.py # Vector store tests
|
| 89 |
+
│ ├── test_retrieval.py # Retrieval pipeline tests
|
| 90 |
+
│ ├── test_generator.py # Generator/context builder tests
|
| 91 |
+
│ └── ...
|
| 92 |
+
│
|
| 93 |
+
├── data/ # Data directory (downloaded from HuggingFace)
|
| 94 |
+
│ ├── data_process/ # Processed markdown files
|
| 95 |
+
│ └── chroma/ # ChromaDB persistence directory
|
| 96 |
+
│
|
| 97 |
+
├── requirements.txt # Python dependencies
|
| 98 |
+
├── setup.sh # Linux/Mac setup script
|
| 99 |
+
├── setup.bat # Windows setup script
|
| 100 |
+
└── .env # API keys (not tracked in git)
|
| 101 |
+
```
|
| 102 |
|
| 103 |
+
---
|
| 104 |
|
| 105 |
+
## 🚀 Getting Started
|
| 106 |
|
| 107 |
+
### Prerequisites
|
| 108 |
|
| 109 |
+
- **Python 3.10+**
|
| 110 |
+
- **API Keys:**
|
| 111 |
+
- [SiliconFlow](https://siliconflow.ai/) — for embedding (Qwen3-Embedding-4B) and reranking (Qwen3-Reranker-8B)
|
| 112 |
+
- [Groq](https://groq.com/) — for LLM generation (Qwen3-32B)
|
| 113 |
|
| 114 |
+
### Quick Setup (Recommended)
|
| 115 |
|
| 116 |
+
Run the automated setup script which creates a virtual environment, installs dependencies, downloads data, and creates the `.env` file:
|
| 117 |
|
| 118 |
+
```bash
|
| 119 |
+
# Linux / macOS
|
| 120 |
+
bash setup.sh
|
| 121 |
+
|
| 122 |
+
# Windows
|
| 123 |
+
setup.bat
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
Then edit `.env` with your API keys:
|
| 127 |
+
|
| 128 |
+
```env
|
| 129 |
+
SILICONFLOW_API_KEY=your_siliconflow_key
|
| 130 |
+
GROQ_API_KEY=your_groq_key
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### Manual Setup
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
# 1. Create and activate virtual environment
|
| 137 |
+
python3 -m venv venv
|
| 138 |
source venv/bin/activate # Linux/Mac
|
| 139 |
+
# venv\Scripts\activate # Windows
|
| 140 |
+
|
| 141 |
+
# 2. Install dependencies
|
| 142 |
+
pip install -r requirements.txt
|
| 143 |
+
|
| 144 |
+
# 3. Download data from HuggingFace
|
| 145 |
+
python scripts/download_data.py
|
| 146 |
+
|
| 147 |
+
# 4. Create .env file with your API keys
|
| 148 |
+
echo "SILICONFLOW_API_KEY=your_key" > .env
|
| 149 |
+
echo "GROQ_API_KEY=your_key" >> .env
|
| 150 |
+
```
|
| 151 |
|
| 152 |
+
### Running the Application
|
| 153 |
+
|
| 154 |
+
```bash
|
| 155 |
+
source venv/bin/activate # Linux/Mac
|
| 156 |
python scripts/run_app.py
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
Access the chat interface at: **http://127.0.0.1:7860**
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## 📖 Usage Guide
|
| 164 |
+
|
| 165 |
+
### Chat Interface
|
| 166 |
+
|
| 167 |
+
The Gradio chat interface supports natural language questions about HUST student regulations. Example questions:
|
| 168 |
+
|
| 169 |
+
| Question | Topic |
|
| 170 |
+
|----------|-------|
|
| 171 |
+
| Sinh viên vi phạm quy chế thi thì bị xử lý như thế nào? | Exam violation penalties |
|
| 172 |
+
| Điều kiện để đổi ngành là gì? | Major transfer requirements |
|
| 173 |
+
| Làm thế nào để đăng ký hoãn thi? | Exam postponement registration |
|
| 174 |
+
|
| 175 |
+
### Debug Mode
|
| 176 |
+
|
| 177 |
+
To launch the debug interface that shows retrieved documents and relevance scores:
|
| 178 |
+
|
| 179 |
+
```bash
|
| 180 |
+
python core/gradio/gradio_rag.py
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
### Building/Updating the Database
|
| 184 |
+
|
| 185 |
+
When you add, modify, or delete markdown files in `data/data_process/`, rebuild the database:
|
| 186 |
+
|
| 187 |
+
```bash
|
| 188 |
+
# Incremental update (only changed files)
|
| 189 |
+
python scripts/build_data.py
|
| 190 |
+
|
| 191 |
+
# Force full rebuild
|
| 192 |
+
python scripts/build_data.py --force
|
| 193 |
+
|
| 194 |
+
# Skip orphan deletion
|
| 195 |
+
python scripts/build_data.py --no-delete
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
The build script will:
|
| 199 |
+
1. Detect changed files via SHA-256 hash comparison
|
| 200 |
+
2. Delete chunks from removed files
|
| 201 |
+
3. Re-chunk and re-embed only modified files
|
| 202 |
+
4. Automatically invalidate the BM25 cache
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## 🔧 Core Components
|
| 207 |
+
|
| 208 |
+
### Chunking (`core/rag/chunk.py`)
|
| 209 |
+
|
| 210 |
+
Processes Markdown documents into searchable chunks:
|
| 211 |
+
|
| 212 |
+
| Feature | Description |
|
| 213 |
+
|---------|-------------|
|
| 214 |
+
| **YAML Frontmatter Extraction** | Parses metadata (document type, year, cohort, program) into chunk metadata |
|
| 215 |
+
| **Heading-based Splitting** | Uses `MarkdownNodeParser` to split by headings, preserving document structure |
|
| 216 |
+
| **Table Extraction & Splitting** | Extracts Markdown tables, splits large tables into chunks of 15 rows |
|
| 217 |
+
| **Small-to-Big Pattern** | Summarizes tables with LLM → embeds summary → links to parent (full table) |
|
| 218 |
+
| **Small Chunk Merging** | Merges chunks smaller than 200 characters with adjacent chunks |
|
| 219 |
+
| **Metadata Enrichment** | Extracts course names and codes from content using regex patterns |
|
| 220 |
|
| 221 |
+
**Configuration:**
|
| 222 |
+
```python
|
| 223 |
+
CHUNK_SIZE = 1500 # Maximum chunk size in characters
|
| 224 |
+
CHUNK_OVERLAP = 150 # Overlap between consecutive chunks
|
| 225 |
+
MIN_CHUNK_SIZE = 200 # Minimum chunk size (smaller chunks get merged)
|
| 226 |
+
TABLE_ROWS_PER_CHUNK = 15 # Maximum rows per table chunk
|
| 227 |
+
```
|
| 228 |
|
| 229 |
+
### Embedding (`core/rag/embedding_model.py`)
|
| 230 |
|
| 231 |
+
- **Model:** Qwen3-Embedding-4B via SiliconFlow API
|
| 232 |
+
- **Dimensions:** 2048
|
| 233 |
+
- **Batch processing** with configurable batch size (default: 16)
|
| 234 |
+
- **Rate limit handling** with exponential backoff retry
|
| 235 |
|
| 236 |
+
### Vector Store (`core/rag/vector_store.py`)
|
| 237 |
|
| 238 |
+
- **Backend:** ChromaDB with LangChain integration
|
| 239 |
+
- **Parent node storage:** Separate JSON file for Small-to-Big parent nodes (not embedded)
|
| 240 |
+
- **Content-based document IDs:** SHA-256 hash of (source_file, header_path, chunk_index, content)
|
| 241 |
+
- **Metadata flattening:** Converts complex metadata types to ChromaDB-compatible formats
|
| 242 |
+
- **Batch operations:** `add_documents()` and `upsert_documents()` with configurable batch size
|
| 243 |
+
|
| 244 |
+
### Retrieval (`core/rag/retrieval.py`)
|
| 245 |
+
|
| 246 |
+
| Mode | Description |
|
| 247 |
+
|------|-------------|
|
| 248 |
+
| `vector_only` | Pure vector similarity search via ChromaDB |
|
| 249 |
+
| `bm25_only` | Pure keyword matching via BM25 (with lazy-load and disk caching) |
|
| 250 |
+
| `hybrid` | Ensemble of vector + BM25 with configurable weights (default: 0.5/0.5) |
|
| 251 |
+
| `hybrid_rerank` | Hybrid search followed by Qwen3-Reranker-8B reranking **(default)** |
|
| 252 |
+
|
| 253 |
+
**Small-to-Big at retrieval time:** When a table summary node is retrieved, it is automatically swapped with the full parent table before returning results to the user.
|
| 254 |
+
|
| 255 |
+
**Configuration:**
|
| 256 |
+
```python
|
| 257 |
+
rerank_model = "Qwen/Qwen3-Reranker-8B" # Reranker model
|
| 258 |
+
initial_k = 25 # Documents fetched before reranking
|
| 259 |
+
top_k = 5 # Final documents returned
|
| 260 |
+
vector_weight = 0.5 # Weight for vector search
|
| 261 |
+
bm25_weight = 0.5 # Weight for BM25 search
|
| 262 |
+
```
|
| 263 |
+
|
| 264 |
+
### Generator (`core/rag/generator.py`)
|
| 265 |
+
|
| 266 |
+
- Builds rich context strings with metadata (source, document type, year, cohort, program, faculty)
|
| 267 |
+
- Constructs prompts with a Vietnamese system prompt that enforces context-grounded answers
|
| 268 |
+
- `RAGContextBuilder` combines retrieval and context preparation into a single step
|
| 269 |
+
|
| 270 |
+
---
|
| 271 |
+
|
| 272 |
+
## 📊 Evaluation
|
| 273 |
+
|
| 274 |
+
The project includes a RAGAS-based evaluation pipeline.
|
| 275 |
+
|
| 276 |
+
### Running Evaluation
|
| 277 |
+
|
| 278 |
+
```bash
|
| 279 |
+
# Evaluate with default settings (10 samples, hybrid_rerank)
|
| 280 |
+
python scripts/run_eval.py
|
| 281 |
+
|
| 282 |
+
# Custom sample size and mode
|
| 283 |
+
python scripts/run_eval.py --samples 50 --mode hybrid_rerank
|
| 284 |
+
|
| 285 |
+
# Run all retrieval modes for comparison
|
| 286 |
+
python scripts/run_eval.py --samples 20 --mode all
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
### Metrics
|
| 290 |
+
|
| 291 |
+
| Metric | Description |
|
| 292 |
+
|--------|-------------|
|
| 293 |
+
| **Faithfulness** | How well the answer is grounded in the retrieved context |
|
| 294 |
+
| **Answer Relevancy** | How relevant the answer is to the question |
|
| 295 |
+
| **Context Precision** | How precise the retrieved contexts are |
|
| 296 |
+
| **Context Recall** | How well the retrieved contexts cover the ground truth |
|
| 297 |
+
| **ROUGE-1 / ROUGE-2 / ROUGE-L** | N-gram overlap with ground truth answers |
|
| 298 |
+
|
| 299 |
+
Results are saved to `evaluation/results/` as both JSON and CSV files with timestamps.
|
| 300 |
+
|
| 301 |
+
---
|
| 302 |
+
|
| 303 |
+
## 🧪 Testing
|
| 304 |
+
|
| 305 |
+
```bash
|
| 306 |
+
# Run all tests
|
| 307 |
+
pytest test/ -v
|
| 308 |
+
|
| 309 |
+
# Run specific test module
|
| 310 |
+
pytest test/test_chunk.py -v
|
| 311 |
+
pytest test/test_retrieval.py -v
|
| 312 |
+
|
| 313 |
+
# Run with coverage
|
| 314 |
+
pytest test/ --cov=core --cov-report=term-missing
|
| 315 |
+
```
|
| 316 |
+
|
| 317 |
+
---
|
| 318 |
+
|
| 319 |
+
## 🛠️ Technology Stack
|
| 320 |
+
|
| 321 |
+
| Category | Technology |
|
| 322 |
+
|----------|------------|
|
| 323 |
+
| **Embedding** | Qwen3-Embedding-4B (SiliconFlow API) |
|
| 324 |
+
| **Reranking** | Qwen3-Reranker-8B (SiliconFlow API) |
|
| 325 |
+
| **LLM** | Qwen3-32B (Groq API) |
|
| 326 |
+
| **Vector Database** | ChromaDB |
|
| 327 |
+
| **Keyword Search** | BM25 (rank-bm25) |
|
| 328 |
+
| **Framework** | LangChain + LlamaIndex (chunking) |
|
| 329 |
+
| **UI** | Gradio |
|
| 330 |
+
| **Evaluation** | RAGAS |
|
| 331 |
+
| **Language** | Python 3.10+ |
|
| 332 |
+
|
| 333 |
+
---
|
| 334 |
+
|
| 335 |
+
## 📦 Data
|
| 336 |
+
|
| 337 |
+
The processed data is hosted on HuggingFace: [hungnha/do_an_tot_nghiep](https://huggingface.co/datasets/hungnha/do_an_tot_nghiep)
|
| 338 |
+
|
| 339 |
+
**Manual download:**
|
| 340 |
+
```bash
|
| 341 |
huggingface-cli download hungnha/do_an_tot_nghiep --repo-type dataset --local-dir ./data
|
| 342 |
+
```
|
| 343 |
+
|
| 344 |
+
The data directory contains:
|
| 345 |
+
- `data_process/` — Processed Markdown regulation documents
|
| 346 |
+
- `chroma/` — ChromaDB persistence files (vector index + parent nodes)
|
| 347 |
+
- `data.csv` — Evaluation dataset (questions + ground truth answers)
|
| 348 |
|
| 349 |
+
---
|
| 350 |
|
| 351 |
+
## 📄 License
|
| 352 |
|
| 353 |
+
This project is developed as an undergraduate thesis at Hanoi University of Science and Technology (HUST).
|
colab.ipynb
DELETED
|
@@ -1,476 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"cells": [
|
| 3 |
-
{
|
| 4 |
-
"cell_type": "code",
|
| 5 |
-
"execution_count": 1,
|
| 6 |
-
"id": "287f0df4",
|
| 7 |
-
"metadata": {},
|
| 8 |
-
"outputs": [
|
| 9 |
-
{
|
| 10 |
-
"ename": "KeyboardInterrupt",
|
| 11 |
-
"evalue": "",
|
| 12 |
-
"output_type": "error",
|
| 13 |
-
"traceback": [
|
| 14 |
-
"\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
|
| 15 |
-
"\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)",
|
| 16 |
-
"\u001b[0;32m/tmp/ipython-input-3329394316.py\u001b[0m in \u001b[0;36m<cell line: 0>\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;32mfrom\u001b[0m \u001b[0mgoogle\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcolab\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mdrive\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mdrive\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmount\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m'/content/drive'\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mforce_remount\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
|
| 17 |
-
"\u001b[0;32m/usr/local/lib/python3.12/dist-packages/google/colab/drive.py\u001b[0m in \u001b[0;36mmount\u001b[0;34m(mountpoint, force_remount, timeout_ms, readonly)\u001b[0m\n\u001b[1;32m 95\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mmount\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmountpoint\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mforce_remount\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mFalse\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtimeout_ms\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m120000\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mreadonly\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mFalse\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 96\u001b[0m \u001b[0;34m\"\"\"Mount your Google Drive at the specified mountpoint path.\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 97\u001b[0;31m return _mount(\n\u001b[0m\u001b[1;32m 98\u001b[0m \u001b[0mmountpoint\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 99\u001b[0m \u001b[0mforce_remount\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mforce_remount\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
|
| 18 |
-
"\u001b[0;32m/usr/local/lib/python3.12/dist-packages/google/colab/drive.py\u001b[0m in \u001b[0;36m_mount\u001b[0;34m(mountpoint, force_remount, timeout_ms, ephemeral, readonly)\u001b[0m\n\u001b[1;32m 132\u001b[0m )\n\u001b[1;32m 133\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mephemeral\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 134\u001b[0;31m _message.blocking_request(\n\u001b[0m\u001b[1;32m 135\u001b[0m \u001b[0;34m'request_auth'\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 136\u001b[0m \u001b[0mrequest\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m{\u001b[0m\u001b[0;34m'authType'\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0;34m'dfs_ephemeral'\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
|
| 19 |
-
"\u001b[0;32m/usr/local/lib/python3.12/dist-packages/google/colab/_message.py\u001b[0m in \u001b[0;36mblocking_request\u001b[0;34m(request_type, request, timeout_sec, parent)\u001b[0m\n\u001b[1;32m 174\u001b[0m \u001b[0mrequest_type\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mrequest\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mparent\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mparent\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexpect_reply\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 175\u001b[0m )\n\u001b[0;32m--> 176\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mread_reply_from_input\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mrequest_id\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtimeout_sec\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
|
| 20 |
-
"\u001b[0;32m/usr/local/lib/python3.12/dist-packages/google/colab/_message.py\u001b[0m in \u001b[0;36mread_reply_from_input\u001b[0;34m(message_id, timeout_sec)\u001b[0m\n\u001b[1;32m 94\u001b[0m \u001b[0mreply\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_read_next_input_message\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 95\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mreply\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0m_NOT_READY\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0misinstance\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mreply\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdict\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 96\u001b[0;31m \u001b[0mtime\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msleep\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m0.025\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 97\u001b[0m \u001b[0;32mcontinue\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 98\u001b[0m if (\n",
|
| 21 |
-
"\u001b[0;31mKeyboardInterrupt\u001b[0m: "
|
| 22 |
-
]
|
| 23 |
-
}
|
| 24 |
-
],
|
| 25 |
-
"source": [
|
| 26 |
-
"from google.colab import drive\n",
|
| 27 |
-
"drive.mount('/content/drive', force_remount=True)"
|
| 28 |
-
]
|
| 29 |
-
},
|
| 30 |
-
{
|
| 31 |
-
"cell_type": "code",
|
| 32 |
-
"execution_count": null,
|
| 33 |
-
"id": "f6891108",
|
| 34 |
-
"metadata": {},
|
| 35 |
-
"outputs": [],
|
| 36 |
-
"source": [
|
| 37 |
-
"# 2. Install dependencies\n",
|
| 38 |
-
"# Cài đặt hệ thống Tesseract, ngôn ngữ Tiếng Việt và các thư viện development cần thiết để build tesserocr\n",
|
| 39 |
-
"!sudo apt-get update > /dev/null\n",
|
| 40 |
-
"!sudo apt-get install -y tesseract-ocr tesseract-ocr-vie libtesseract-dev libleptonica-dev pkg-config > /dev/null\n",
|
| 41 |
-
"\n",
|
| 42 |
-
"# Cài đặt tesserocr (Python wrapper cho Tesseract) và docling\n",
|
| 43 |
-
"# Lưu ý: tesserocr cần được build từ source nên cần các thư viện dev ở trên\n",
|
| 44 |
-
"!pip install tesserocr docling pypdfium2"
|
| 45 |
-
]
|
| 46 |
-
},
|
| 47 |
-
{
|
| 48 |
-
"cell_type": "code",
|
| 49 |
-
"execution_count": null,
|
| 50 |
-
"id": "ca42bfce",
|
| 51 |
-
"metadata": {},
|
| 52 |
-
"outputs": [],
|
| 53 |
-
"source": [
|
| 54 |
-
"# 3. Extract Data\n",
|
| 55 |
-
"import os\n",
|
| 56 |
-
"import zipfile\n",
|
| 57 |
-
"\n",
|
| 58 |
-
"# Path to your zip file on Drive\n",
|
| 59 |
-
"zip_path = '/content/drive/MyDrive/data_rag.zip' \n",
|
| 60 |
-
"extract_path = '/content/data_rag/files'\n",
|
| 61 |
-
"\n",
|
| 62 |
-
"if not os.path.exists(extract_path):\n",
|
| 63 |
-
" os.makedirs(extract_path, exist_ok=True)\n",
|
| 64 |
-
" print(f\"Extracting {zip_path}...\")\n",
|
| 65 |
-
" try:\n",
|
| 66 |
-
" with zipfile.ZipFile(zip_path, 'r') as zip_ref:\n",
|
| 67 |
-
" zip_ref.extractall(extract_path)\n",
|
| 68 |
-
" print(\"Done extraction!\")\n",
|
| 69 |
-
" except FileNotFoundError:\n",
|
| 70 |
-
" print(f\"❌ File not found: {zip_path}. Please check the path.\")\n",
|
| 71 |
-
"else:\n",
|
| 72 |
-
" print(\"Files already extracted.\")"
|
| 73 |
-
]
|
| 74 |
-
},
|
| 75 |
-
{
|
| 76 |
-
"cell_type": "code",
|
| 77 |
-
"execution_count": null,
|
| 78 |
-
"id": "988f7e96",
|
| 79 |
-
"metadata": {},
|
| 80 |
-
"outputs": [],
|
| 81 |
-
"source": [
|
| 82 |
-
"# 4. Define Processor Class (Refactored for High Quality & Performance with Tesseract)\n",
|
| 83 |
-
"import json\n",
|
| 84 |
-
"import os\n",
|
| 85 |
-
"import logging\n",
|
| 86 |
-
"import shutil\n",
|
| 87 |
-
"import re\n",
|
| 88 |
-
"import gc\n",
|
| 89 |
-
"import signal\n",
|
| 90 |
-
"from pathlib import Path\n",
|
| 91 |
-
"from typing import Optional\n",
|
| 92 |
-
"\n",
|
| 93 |
-
"# --- AUTO-CONFIG TESSERACT DATA PATH ---\n",
|
| 94 |
-
"# Fix lỗi \"No language models have been detected\"\n",
|
| 95 |
-
"# Tự động tìm đường dẫn chứa file ngôn ngữ (vie.traineddata) và set biến môi trường\n",
|
| 96 |
-
"def setup_tesseract_path():\n",
|
| 97 |
-
" possible_paths = [\n",
|
| 98 |
-
" \"/usr/share/tesseract-ocr/4.00/tessdata\",\n",
|
| 99 |
-
" \"/usr/share/tesseract-ocr/5/tessdata\",\n",
|
| 100 |
-
" \"/usr/share/tesseract-ocr/tessdata\",\n",
|
| 101 |
-
" \"/usr/local/share/tessdata\"\n",
|
| 102 |
-
" ]\n",
|
| 103 |
-
" \n",
|
| 104 |
-
" found = False\n",
|
| 105 |
-
" for path in possible_paths:\n",
|
| 106 |
-
" if os.path.exists(os.path.join(path, \"vie.traineddata\")):\n",
|
| 107 |
-
" os.environ[\"TESSDATA_PREFIX\"] = path\n",
|
| 108 |
-
" print(f\"✅ Found Tesseract data at: {path}\")\n",
|
| 109 |
-
" print(f\" Set TESSDATA_PREFIX={path}\")\n",
|
| 110 |
-
" found = True\n",
|
| 111 |
-
" break\n",
|
| 112 |
-
" \n",
|
| 113 |
-
" if not found:\n",
|
| 114 |
-
" print(\"⚠️ WARNING: Could not find 'vie.traineddata'. Tesseract might fail.\")\n",
|
| 115 |
-
" print(\" Please run Cell #2 to install tesseract-ocr-vie.\")\n",
|
| 116 |
-
"\n",
|
| 117 |
-
"setup_tesseract_path()\n",
|
| 118 |
-
"\n",
|
| 119 |
-
"# Setup logging\n",
|
| 120 |
-
"logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')\n",
|
| 121 |
-
"logger = logging.getLogger(__name__)\n",
|
| 122 |
-
"\n",
|
| 123 |
-
"# Docling imports\n",
|
| 124 |
-
"from docling.document_converter import DocumentConverter, FormatOption\n",
|
| 125 |
-
"from docling.datamodel.base_models import InputFormat\n",
|
| 126 |
-
"from docling.datamodel.pipeline_options import (\n",
|
| 127 |
-
" PdfPipelineOptions, \n",
|
| 128 |
-
" TableStructureOptions,\n",
|
| 129 |
-
" AcceleratorOptions,\n",
|
| 130 |
-
" AcceleratorDevice,\n",
|
| 131 |
-
" TesseractOcrOptions # SỬ DỤNG TESSERACT CHO ĐỘ CHÍNH XÁC CAO NHẤT\n",
|
| 132 |
-
")\n",
|
| 133 |
-
"from docling.datamodel.settings import settings\n",
|
| 134 |
-
"from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend\n",
|
| 135 |
-
"from docling.pipeline.standard_pdf_pipeline import StandardPdfPipeline\n",
|
| 136 |
-
"\n",
|
| 137 |
-
"class ColabDoclingProcessor:\n",
|
| 138 |
-
" def __init__(self, output_dir: str, use_ocr: bool = True, timeout: int = 300):\n",
|
| 139 |
-
" self.output_dir = output_dir\n",
|
| 140 |
-
" self.use_ocr = use_ocr\n",
|
| 141 |
-
" self.timeout = timeout\n",
|
| 142 |
-
" os.makedirs(output_dir, exist_ok=True)\n",
|
| 143 |
-
" \n",
|
| 144 |
-
" # 1. Cấu hình Pipeline Options\n",
|
| 145 |
-
" pipeline_options = PdfPipelineOptions()\n",
|
| 146 |
-
" \n",
|
| 147 |
-
" # --- Cấu hình TableFormer (Ưu tiên số 1) ---\n",
|
| 148 |
-
" # Kích hoạt nhận diện cấu trúc bảng\n",
|
| 149 |
-
" pipeline_options.do_table_structure = True\n",
|
| 150 |
-
" # Sử dụng chế độ ACCURATE để đảm bảo bảng biểu phức tạp (điểm số, học phí) không bị vỡ\n",
|
| 151 |
-
" pipeline_options.table_structure_options = TableStructureOptions(\n",
|
| 152 |
-
" do_cell_matching=True, # Khớp text vào ô chính xác hơn\n",
|
| 153 |
-
" mode=\"accurate\" # Chế độ chính xác cao\n",
|
| 154 |
-
" )\n",
|
| 155 |
-
"\n",
|
| 156 |
-
" # --- FIX LỖI ẢNH MỜ (QUAN TRỌNG) ---\n",
|
| 157 |
-
" # Tăng độ phân giải ảnh lên gấp 3 lần để Tesseract nhìn rõ dấu tiếng Việt\n",
|
| 158 |
-
" # Mặc định là 1.0 (mờ), set lên 3.0 sẽ nét căng.\n",
|
| 159 |
-
" pipeline_options.images_scale = 3.0\n",
|
| 160 |
-
"\n",
|
| 161 |
-
" # --- Chiến lược OCR với Tesseract ---\n",
|
| 162 |
-
" if use_ocr:\n",
|
| 163 |
-
" pipeline_options.do_ocr = True\n",
|
| 164 |
-
" \n",
|
| 165 |
-
" # --- CẤU HÌNH TESSERACT TƯỜNG MINH ---\n",
|
| 166 |
-
" ocr_options = TesseractOcrOptions()\n",
|
| 167 |
-
" \n",
|
| 168 |
-
" # Cấu hình ngôn ngữ tiếng Việt (vie) - Phải khớp với gói tesseract-ocr-vie\n",
|
| 169 |
-
" ocr_options.lang = [\"vie\"] \n",
|
| 170 |
-
" \n",
|
| 171 |
-
" # --- CHẾ ĐỘ HYBRID (THÔNG MINH) ---\n",
|
| 172 |
-
" # Tắt force_full_page_ocr để Docling tự quyết định:\n",
|
| 173 |
-
" # 1. Nếu text layer tốt -> Dùng text layer (Nhanh, nhẹ)\n",
|
| 174 |
-
" # 2. Nếu text layer lỗi hoặc là ảnh -> Dùng OCR\n",
|
| 175 |
-
" ocr_options.force_full_page_ocr = False\n",
|
| 176 |
-
" \n",
|
| 177 |
-
" # Gán options vào pipeline\n",
|
| 178 |
-
" pipeline_options.ocr_options = ocr_options\n",
|
| 179 |
-
" else:\n",
|
| 180 |
-
" pipeline_options.do_ocr = False\n",
|
| 181 |
-
"\n",
|
| 182 |
-
" # --- Tối ưu phần cứng (GPU Acceleration) ---\n",
|
| 183 |
-
" # Tự động phát hiện và sử dụng GPU nếu có (Colab T4/L4)\n",
|
| 184 |
-
" pipeline_options.accelerator_options = AcceleratorOptions(\n",
|
| 185 |
-
" num_threads=8, # Tăng thread cho Tesseract\n",
|
| 186 |
-
" device=AcceleratorDevice.AUTO \n",
|
| 187 |
-
" )\n",
|
| 188 |
-
"\n",
|
| 189 |
-
" # 2. Tạo Format Options\n",
|
| 190 |
-
" format_options = {\n",
|
| 191 |
-
" InputFormat.PDF: FormatOption(\n",
|
| 192 |
-
" backend=PyPdfiumDocumentBackend,\n",
|
| 193 |
-
" pipeline_cls=StandardPdfPipeline,\n",
|
| 194 |
-
" pipeline_options=pipeline_options\n",
|
| 195 |
-
" )\n",
|
| 196 |
-
" }\n",
|
| 197 |
-
" \n",
|
| 198 |
-
" # Khởi tạo Converter\n",
|
| 199 |
-
" self.converter = DocumentConverter(format_options=format_options)\n",
|
| 200 |
-
" print(f\"🚀 Docling Processor Initialized\")\n",
|
| 201 |
-
" print(f\" - OCR Engine: TESSERACT (Vietnamese)\")\n",
|
| 202 |
-
" print(f\" - Mode: HYBRID (Text Layer + OCR fallback)\")\n",
|
| 203 |
-
" print(f\" - Image Scale: 3.0 (High Resolution)\")\n",
|
| 204 |
-
" print(f\" - Table Mode: Accurate\")\n",
|
| 205 |
-
" print(f\" - Device: Auto-detect (GPU/CPU)\")\n",
|
| 206 |
-
" print(f\" - Timeout: {self.timeout}s per file\")\n",
|
| 207 |
-
"\n",
|
| 208 |
-
" def clean_markdown(self, text: str) -> str:\n",
|
| 209 |
-
" \"\"\"Hậu xử lý: Làm sạch Markdown.\"\"\"\n",
|
| 210 |
-
" # 1. Xóa dòng \"Trang x\" (An toàn)\n",
|
| 211 |
-
" text = re.sub(r'\\n\\s*Trang\\s+\\d+\\s*\\n', '\\n', text)\n",
|
| 212 |
-
" \n",
|
| 213 |
-
" # 3. Xóa nhiều dòng trống (An toàn & Cần thiết)\n",
|
| 214 |
-
" text = re.sub(r'\\n{3,}', '\\n\\n', text)\n",
|
| 215 |
-
" return text.strip()\n",
|
| 216 |
-
"\n",
|
| 217 |
-
" def parse_directory(self, source_dir: str):\n",
|
| 218 |
-
" print(f\"📂 Parsing PDFs in: {source_dir}\")\n",
|
| 219 |
-
" source_path = Path(source_dir)\n",
|
| 220 |
-
" pdf_files = list(source_path.rglob(\"*.pdf\"))\n",
|
| 221 |
-
" print(f\" Found {len(pdf_files)} PDF files.\")\n",
|
| 222 |
-
" \n",
|
| 223 |
-
" results = {\"total\": 0, \"parsed\": 0, \"skipped\": 0, \"errors\": 0}\n",
|
| 224 |
-
" \n",
|
| 225 |
-
" # Define timeout handler\n",
|
| 226 |
-
" def timeout_handler(signum, frame):\n",
|
| 227 |
-
" raise TimeoutError(\"Processing timeout\")\n",
|
| 228 |
-
" \n",
|
| 229 |
-
" # Register signal for timeout\n",
|
| 230 |
-
" signal.signal(signal.SIGALRM, timeout_handler)\n",
|
| 231 |
-
" \n",
|
| 232 |
-
" for i, file_path in enumerate(pdf_files):\n",
|
| 233 |
-
" filename = file_path.name\n",
|
| 234 |
-
" \n",
|
| 235 |
-
" # --- GIỮ NGUYÊN CẤU TRÚC THƯ MỤC ---\n",
|
| 236 |
-
" # Tính toán đường dẫn tương đối: data/files/subdir/file.pdf -> subdir/file.pdf\n",
|
| 237 |
-
" try:\n",
|
| 238 |
-
" relative_path = file_path.relative_to(source_path)\n",
|
| 239 |
-
" except ValueError:\n",
|
| 240 |
-
" # Fallback nếu file không nằm trong source_dir (ít khi xảy ra với rglob)\n",
|
| 241 |
-
" relative_path = Path(filename)\n",
|
| 242 |
-
"\n",
|
| 243 |
-
" # Tạo đường dẫn output tương ứng: output_dir/subdir/file.md\n",
|
| 244 |
-
" output_file_path = Path(self.output_dir) / relative_path.with_suffix(\".md\")\n",
|
| 245 |
-
" \n",
|
| 246 |
-
" # Tạo thư mục con nếu chưa tồn tại\n",
|
| 247 |
-
" output_file_path.parent.mkdir(parents=True, exist_ok=True)\n",
|
| 248 |
-
" \n",
|
| 249 |
-
" output_path = str(output_file_path)\n",
|
| 250 |
-
" \n",
|
| 251 |
-
" # --- TỐI ƯU 1: SKIP NẾU ĐÃ CÓ KẾT QUẢ (Checkpoint) ---\n",
|
| 252 |
-
" if os.path.exists(output_path):\n",
|
| 253 |
-
" results[\"skipped\"] += 1\n",
|
| 254 |
-
" if results[\"skipped\"] % 50 == 0:\n",
|
| 255 |
-
" print(f\"⏩ Skipped {results['skipped']} files (already processed)...\")\n",
|
| 256 |
-
" continue\n",
|
| 257 |
-
"\n",
|
| 258 |
-
" try:\n",
|
| 259 |
-
" # Set timeout\n",
|
| 260 |
-
" signal.alarm(self.timeout)\n",
|
| 261 |
-
" \n",
|
| 262 |
-
" # Convert\n",
|
| 263 |
-
" result = self.converter.convert(str(file_path))\n",
|
| 264 |
-
" \n",
|
| 265 |
-
" # Cancel timeout\n",
|
| 266 |
-
" signal.alarm(0)\n",
|
| 267 |
-
" \n",
|
| 268 |
-
" # Export to Markdown (Làm sạch dữ liệu ảnh rác)\n",
|
| 269 |
-
" markdown_content = result.document.export_to_markdown(image_placeholder=\"\")\n",
|
| 270 |
-
" \n",
|
| 271 |
-
" # Post-processing cleaning\n",
|
| 272 |
-
" markdown_content = self.clean_markdown(markdown_content)\n",
|
| 273 |
-
" \n",
|
| 274 |
-
" # Metadata Extraction (Chuẩn bị cho RAG)\n",
|
| 275 |
-
" metadata_header = f\"\"\"---\n",
|
| 276 |
-
"filename: {filename}\n",
|
| 277 |
-
"filepath: {file_path}\n",
|
| 278 |
-
"page_count: {len(result.document.pages)}\n",
|
| 279 |
-
"processed_at: {os.path.getmtime(file_path)}\n",
|
| 280 |
-
"---\n",
|
| 281 |
-
"\n",
|
| 282 |
-
"\"\"\"\n",
|
| 283 |
-
" final_content = metadata_header + markdown_content\n",
|
| 284 |
-
" \n",
|
| 285 |
-
" # Save\n",
|
| 286 |
-
" with open(output_path, 'w', encoding='utf-8') as f:\n",
|
| 287 |
-
" f.write(final_content)\n",
|
| 288 |
-
" \n",
|
| 289 |
-
" results[\"parsed\"] += 1\n",
|
| 290 |
-
" \n",
|
| 291 |
-
" # --- TỐI ƯU 2: GIẢI PHÓNG RAM ---\n",
|
| 292 |
-
" del result\n",
|
| 293 |
-
" del markdown_content\n",
|
| 294 |
-
" \n",
|
| 295 |
-
" if (i+1) % 10 == 0:\n",
|
| 296 |
-
" gc.collect()\n",
|
| 297 |
-
" print(f\"✅ Processed {i+1}/{len(pdf_files)} files (Skipped: {results['skipped']})\")\n",
|
| 298 |
-
" \n",
|
| 299 |
-
" except TimeoutError:\n",
|
| 300 |
-
" print(f\"⏰ Timeout parsing {filename} (>{self.timeout}s)\")\n",
|
| 301 |
-
" results[\"errors\"] += 1\n",
|
| 302 |
-
" except Exception as e:\n",
|
| 303 |
-
" print(f\"❌ Failed to parse {filename}: {e}\")\n",
|
| 304 |
-
" results[\"errors\"] += 1\n",
|
| 305 |
-
" finally:\n",
|
| 306 |
-
" signal.alarm(0) # Ensure alarm is off\n",
|
| 307 |
-
" \n",
|
| 308 |
-
" return results"
|
| 309 |
-
]
|
| 310 |
-
},
|
| 311 |
-
{
|
| 312 |
-
"cell_type": "code",
|
| 313 |
-
"execution_count": null,
|
| 314 |
-
"id": "0b87fec5",
|
| 315 |
-
"metadata": {},
|
| 316 |
-
"outputs": [],
|
| 317 |
-
"source": [
|
| 318 |
-
"# 5.5. Test Run on Specific File\n",
|
| 319 |
-
"# Chạy cell này để kiểm tra chất lượng trên file cụ thể (giống Marker)\n",
|
| 320 |
-
"import os\n",
|
| 321 |
-
"from pathlib import Path\n",
|
| 322 |
-
"\n",
|
| 323 |
-
"# Setup paths (đồng bộ với Cell 3)\n",
|
| 324 |
-
"source_dir = '/content/data_rag/files'\n",
|
| 325 |
-
"root = Path(source_dir)\n",
|
| 326 |
-
"\n",
|
| 327 |
-
"if not root.exists():\n",
|
| 328 |
-
" print(f\"❌ Source directory not found: {root}\")\n",
|
| 329 |
-
" print(\"⚠️ Hãy chạy Cell 3 (Extract Data) trước.\")\n",
|
| 330 |
-
"else:\n",
|
| 331 |
-
" # Nếu zip giải nén ra 1 thư mục con 'files' thì đi vào đó\n",
|
| 332 |
-
" nested_files = root / 'files'\n",
|
| 333 |
-
" if nested_files.exists():\n",
|
| 334 |
-
" root = nested_files\n",
|
| 335 |
-
"\n",
|
| 336 |
-
" # Tìm file cụ thể\n",
|
| 337 |
-
" target_filename = \"1.1. Kỹ thuật Cơ điện tử.pdf\"\n",
|
| 338 |
-
" # Nếu bạn biết chắc thư mục con, điền ở đây (vd: 'quy_che'); nếu không chắc có thể để None\n",
|
| 339 |
-
" target_subdir = \"quy_che\"\n",
|
| 340 |
-
"\n",
|
| 341 |
-
" preferred_path = (root / target_subdir / target_filename) if target_subdir else (root / target_filename)\n",
|
| 342 |
-
" target_path = preferred_path\n",
|
| 343 |
-
"\n",
|
| 344 |
-
" if not target_path.exists():\n",
|
| 345 |
-
" # Fallback: tự động tìm theo tên file trong toàn bộ cây thư mục\n",
|
| 346 |
-
" matches = list(root.rglob(target_filename))\n",
|
| 347 |
-
" if len(matches) == 1:\n",
|
| 348 |
-
" target_path = matches[0]\n",
|
| 349 |
-
" print(f\"🔎 Auto-found file at: {target_path}\")\n",
|
| 350 |
-
" elif len(matches) > 1:\n",
|
| 351 |
-
" print(\"⚠️ Found multiple matches. Showing up to 20:\")\n",
|
| 352 |
-
" for p in matches[:20]:\n",
|
| 353 |
-
" print(f\" - {p}\")\n",
|
| 354 |
-
" target_path = matches[0]\n",
|
| 355 |
-
" print(f\"➡️ Using first match: {target_path}\")\n",
|
| 356 |
-
" else:\n",
|
| 357 |
-
" print(f\"❌ File not found: {preferred_path}\")\n",
|
| 358 |
-
" print(f\"Searching in: {root}\")\n",
|
| 359 |
-
" # Gợi ý: in ra các thư mục cấp 1 để bạn chọn đúng target_subdir\n",
|
| 360 |
-
" subdirs = sorted([p.name for p in root.iterdir() if p.is_dir()])\n",
|
| 361 |
-
" if subdirs:\n",
|
| 362 |
-
" print(\"📁 Top-level folders:\")\n",
|
| 363 |
-
" for name in subdirs[:30]:\n",
|
| 364 |
-
" print(f\" - {name}\")\n",
|
| 365 |
-
" raise FileNotFoundError(target_filename)\n",
|
| 366 |
-
"\n",
|
| 367 |
-
" print(f\"🧪 Using target file: {target_path}\")\n",
|
| 368 |
-
" \n",
|
| 369 |
-
" # Initialize processor for test\n",
|
| 370 |
-
" test_output_dir = '/content/data/test_output'\n",
|
| 371 |
-
" os.makedirs(test_output_dir, exist_ok=True)\n",
|
| 372 |
-
" \n",
|
| 373 |
-
" print(\"🚀 Initializing processor for test run (OCR Enabled - Default)...\")\n",
|
| 374 |
-
" # Use ColabDoclingProcessor defined in previous cell\n",
|
| 375 |
-
" test_processor = ColabDoclingProcessor(\n",
|
| 376 |
-
" output_dir=test_output_dir,\n",
|
| 377 |
-
" use_ocr=True,\n",
|
| 378 |
-
" )\n",
|
| 379 |
-
" \n",
|
| 380 |
-
" try:\n",
|
| 381 |
-
" print(f\"⏳ Processing {target_path.name}...\")\n",
|
| 382 |
-
" result = test_processor.converter.convert(str(target_path))\n",
|
| 383 |
-
" markdown_content = result.document.export_to_markdown()\n",
|
| 384 |
-
" \n",
|
| 385 |
-
" # Save to local output\n",
|
| 386 |
-
" output_file = Path(test_output_dir) / f\"{target_path.stem}.md\"\n",
|
| 387 |
-
" with open(output_file, 'w', encoding='utf-8') as f:\n",
|
| 388 |
-
" f.write(markdown_content)\n",
|
| 389 |
-
" \n",
|
| 390 |
-
" print(f\"💾 Saved local test file: {output_file}\")\n",
|
| 391 |
-
" \n",
|
| 392 |
-
" print(\"\\n\" + \"=\"*50)\n",
|
| 393 |
-
" print(\"📄 RESULT PREVIEW (First 2000 characters)\")\n",
|
| 394 |
-
" print(\"=\"*50)\n",
|
| 395 |
-
" print(markdown_content[:2000])\n",
|
| 396 |
-
" print(\"\\n\" + \"=\"*50)\n",
|
| 397 |
-
" print(\"✅ Test completed! Hãy chạy cell tiếp theo để lưu kết quả lên Drive.\")\n",
|
| 398 |
-
" \n",
|
| 399 |
-
" except Exception as e:\n",
|
| 400 |
-
" print(f\"❌ Test failed: {e}\")"
|
| 401 |
-
]
|
| 402 |
-
},
|
| 403 |
-
{
|
| 404 |
-
"cell_type": "code",
|
| 405 |
-
"execution_count": null,
|
| 406 |
-
"id": "a46429ed",
|
| 407 |
-
"metadata": {},
|
| 408 |
-
"outputs": [],
|
| 409 |
-
"source": [
|
| 410 |
-
"# 5.6. Save Test Result to Google Drive\n",
|
| 411 |
-
"import shutil\n",
|
| 412 |
-
"\n",
|
| 413 |
-
"# Cấu hình đường dẫn lưu trên Drive (Lưu vào folder riêng để dễ so sánh với Marker)\n",
|
| 414 |
-
"drive_test_folder = '/content/drive/MyDrive/docling/test_result_docling'\n",
|
| 415 |
-
"\n",
|
| 416 |
-
"# Biến test_output_dir được định nghĩa ở cell 5.5\n",
|
| 417 |
-
"if 'test_output_dir' in locals() and os.path.exists(test_output_dir):\n",
|
| 418 |
-
" # Tạo thư mục cha trên Drive nếu chưa có\n",
|
| 419 |
-
" if not os.path.exists(os.path.dirname(drive_test_folder)):\n",
|
| 420 |
-
" os.makedirs(os.path.dirname(drive_test_folder), exist_ok=True)\n",
|
| 421 |
-
" \n",
|
| 422 |
-
" print(f\"📂 Copying test results to: {drive_test_folder}\")\n",
|
| 423 |
-
" \n",
|
| 424 |
-
" # Sử dụng copytree với dirs_exist_ok=True để copy cả thư mục con và ghi đè nếu cần\n",
|
| 425 |
-
" # Cách này giữ nguyên cấu trúc thư mục (subdir)\n",
|
| 426 |
-
" try:\n",
|
| 427 |
-
" shutil.copytree(test_output_dir, drive_test_folder, dirs_exist_ok=True)\n",
|
| 428 |
-
" print(f\" ✅ Copied entire folder structure successfully!\")\n",
|
| 429 |
-
" except Exception as e:\n",
|
| 430 |
-
" print(f\" ❌ Error copying folder: {e}\")\n",
|
| 431 |
-
" \n",
|
| 432 |
-
" print(f\"\\n🎉 Done! Bạn có thể mở Drive để xem file markdown đầy đủ tại: {drive_test_folder}\")\n",
|
| 433 |
-
"else:\n",
|
| 434 |
-
" print(\"❌ Không tìm thấy thư mục kết quả test hoặc biến 'test_output_dir' chưa được định nghĩa.\")\n",
|
| 435 |
-
" print(\"⚠️ Hãy chạy cell 5.5 (Test Run) trước khi chạy cell này!\")"
|
| 436 |
-
]
|
| 437 |
-
},
|
| 438 |
-
{
|
| 439 |
-
"cell_type": "code",
|
| 440 |
-
"execution_count": null,
|
| 441 |
-
"id": "8228498a",
|
| 442 |
-
"metadata": {},
|
| 443 |
-
"outputs": [],
|
| 444 |
-
"source": [
|
| 445 |
-
"# 6. Run Processing & Save Results\n",
|
| 446 |
-
"output_dir = '/content/data/docling_output'\n",
|
| 447 |
-
"# Bật OCR \n",
|
| 448 |
-
"processor = ColabDoclingProcessor(output_dir=output_dir, use_ocr=True) \n",
|
| 449 |
-
"\n",
|
| 450 |
-
"# Determine source directory (handle if zip extracted to subfolder)\n",
|
| 451 |
-
"source_dir = '/content/data_rag/files' \n",
|
| 452 |
-
"# Check if files are in a subfolder named 'files' inside the extraction path\n",
|
| 453 |
-
"if os.path.exists(os.path.join(source_dir, 'files')):\n",
|
| 454 |
-
" source_dir = os.path.join(source_dir, 'files')\n",
|
| 455 |
-
"\n",
|
| 456 |
-
"# Run\n",
|
| 457 |
-
"processor.parse_directory(source_dir)\n",
|
| 458 |
-
"\n",
|
| 459 |
-
"# Zip output and save to Drive\n",
|
| 460 |
-
"output_zip_path = '/content/drive/MyDrive/docling/docling_output.zip'\n",
|
| 461 |
-
"print(f\"Zipping output to {output_zip_path}...\")\n",
|
| 462 |
-
"shutil.make_archive(output_zip_path.replace('.zip', ''), 'zip', output_dir)\n",
|
| 463 |
-
"print(\"🎉 Done! Check your Google Drive for docling_output.zip\")"
|
| 464 |
-
]
|
| 465 |
-
}
|
| 466 |
-
],
|
| 467 |
-
"metadata": {
|
| 468 |
-
"kernelspec": {
|
| 469 |
-
"display_name": "Python 3 (ipykernel)",
|
| 470 |
-
"language": "python",
|
| 471 |
-
"name": "python3"
|
| 472 |
-
}
|
| 473 |
-
},
|
| 474 |
-
"nbformat": 4,
|
| 475 |
-
"nbformat_minor": 5
|
| 476 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
core/gradio/gradio_rag.py
CHANGED
|
@@ -1,188 +1,8 @@
|
|
| 1 |
-
from
|
| 2 |
-
import os
|
| 3 |
-
import sys
|
| 4 |
-
from dataclasses import dataclass
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from typing import Dict, List, Optional
|
| 7 |
-
import gradio as gr
|
| 8 |
-
from dotenv import find_dotenv, load_dotenv
|
| 9 |
-
from openai import OpenAI
|
| 10 |
-
|
| 11 |
-
# Thêm thư mục gốc vào Python path
|
| 12 |
-
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 13 |
-
if str(REPO_ROOT) not in sys.path:
|
| 14 |
-
sys.path.insert(0, str(REPO_ROOT))
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
@dataclass
|
| 18 |
-
class GradioConfig:
|
| 19 |
-
"""Cấu hình Gradio server: host và port."""
|
| 20 |
-
server_host: str = "127.0.0.1"
|
| 21 |
-
server_port: int = 7860
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
def _load_env() -> None:
|
| 25 |
-
"""Tải biến môi trường từ file .env."""
|
| 26 |
-
dotenv_path = find_dotenv(usecwd=True) or ""
|
| 27 |
-
load_dotenv(dotenv_path=dotenv_path or None, override=False)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
# Import các module RAG
|
| 31 |
-
from core.rag.embedding_model import EmbeddingConfig, QwenEmbeddings
|
| 32 |
-
from core.rag.vector_store import ChromaConfig, ChromaVectorDB
|
| 33 |
-
from core.rag.retrival import Retriever, RetrievalMode, get_retrieval_config
|
| 34 |
-
from core.rag.generator import RAGContextBuilder, build_context, build_prompt, SYSTEM_PROMPT
|
| 35 |
-
|
| 36 |
-
_load_env()
|
| 37 |
-
|
| 38 |
-
# Cấu hình retrieval và LLM
|
| 39 |
-
RETRIEVAL_MODE = RetrievalMode.HYBRID_RERANK # Chế độ tìm kiếm
|
| 40 |
-
LLM_MODEL = os.getenv("LLM_MODEL", "qwen/qwen3-32b") # Model LLM
|
| 41 |
-
LLM_API_BASE = "https://api.groq.com/openai/v1" # Groq API endpoint
|
| 42 |
-
LLM_API_KEY_ENV = "GROQ_API_KEY" # Biến môi trường chứa API key
|
| 43 |
-
|
| 44 |
-
# Khởi tạo cấu hình
|
| 45 |
-
GRADIO_CFG = GradioConfig()
|
| 46 |
-
RETRIEVAL_CFG = get_retrieval_config()
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
class AppState:
|
| 50 |
-
"""Quản lý trạng thái ứng dụng: database, retriever, LLM client."""
|
| 51 |
-
|
| 52 |
-
def __init__(self) -> None:
|
| 53 |
-
self.db: Optional[ChromaVectorDB] = None
|
| 54 |
-
self.retriever: Optional[Retriever] = None
|
| 55 |
-
self.rag_builder: Optional[RAGContextBuilder] = None
|
| 56 |
-
self.client: Optional[OpenAI] = None
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
STATE = AppState() # Singleton state
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _init_resources() -> None:
|
| 63 |
-
"""Khởi tạo các tài nguyên: DB, Retriever, LLM client (lazy init)."""
|
| 64 |
-
if STATE.db is not None:
|
| 65 |
-
return
|
| 66 |
-
|
| 67 |
-
print(f" Đang khởi tạo Database & Re-ranker...")
|
| 68 |
-
print(f" Retrieval Mode: {RETRIEVAL_MODE.value}")
|
| 69 |
-
|
| 70 |
-
# Khởi tạo embedding và database
|
| 71 |
-
emb = QwenEmbeddings(EmbeddingConfig())
|
| 72 |
-
db_cfg = ChromaConfig()
|
| 73 |
-
|
| 74 |
-
STATE.db = ChromaVectorDB(embedder=emb, config=db_cfg)
|
| 75 |
-
STATE.retriever = Retriever(vector_db=STATE.db)
|
| 76 |
-
|
| 77 |
-
# Khởi tạo LLM client
|
| 78 |
-
api_key = (os.getenv(LLM_API_KEY_ENV) or "").strip()
|
| 79 |
-
if not api_key:
|
| 80 |
-
raise RuntimeError(f"Missing {LLM_API_KEY_ENV}")
|
| 81 |
-
STATE.client = OpenAI(api_key=api_key, base_url=LLM_API_BASE)
|
| 82 |
-
|
| 83 |
-
# Khởi tạo RAG builder
|
| 84 |
-
STATE.rag_builder = RAGContextBuilder(retriever=STATE.retriever)
|
| 85 |
-
|
| 86 |
-
print(" Đã sẵn sàng!")
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
def rag_chat(message: str, history: List[Dict[str, str]] | None = None):
|
| 90 |
-
"""Xử lý chat: retrieve documents -> gọi LLM -> stream response"""
|
| 91 |
-
_init_resources()
|
| 92 |
-
|
| 93 |
-
assert STATE.db is not None
|
| 94 |
-
assert STATE.client is not None
|
| 95 |
-
assert STATE.retriever is not None
|
| 96 |
-
assert STATE.rag_builder is not None
|
| 97 |
-
|
| 98 |
-
# Retrieve và chuẩn bị context
|
| 99 |
-
prepared = STATE.rag_builder.retrieve_and_prepare(
|
| 100 |
-
message,
|
| 101 |
-
k=RETRIEVAL_CFG.top_k,
|
| 102 |
-
initial_k=RETRIEVAL_CFG.initial_k,
|
| 103 |
-
mode=RETRIEVAL_MODE.value,
|
| 104 |
-
)
|
| 105 |
-
results = prepared["results"]
|
| 106 |
-
|
| 107 |
-
if not results:
|
| 108 |
-
yield "Xin lỗi, tôi không tìm thấy thông tin phù hợp trong dữ liệu."
|
| 109 |
-
return
|
| 110 |
-
|
| 111 |
-
# Gọi LLM với streaming
|
| 112 |
-
completion = STATE.client.chat.completions.create(
|
| 113 |
-
model=LLM_MODEL,
|
| 114 |
-
messages=[{"role": "user", "content": prepared["prompt"]}],
|
| 115 |
-
temperature=0.0,
|
| 116 |
-
max_tokens=4096,
|
| 117 |
-
stream=True,
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
# Stream response
|
| 121 |
-
acc = ""
|
| 122 |
-
for chunk in completion:
|
| 123 |
-
delta = getattr(chunk.choices[0].delta, "content", "") or ""
|
| 124 |
-
if delta:
|
| 125 |
-
acc += delta
|
| 126 |
-
yield acc
|
| 127 |
-
|
| 128 |
-
# Thêm debug info về các documents đã retrieve
|
| 129 |
-
debug_info = f"\n\n---\n\n**Retrieved (Top {len(results)} | Mode: {RETRIEVAL_MODE.value})**\n\n"
|
| 130 |
-
for i, r in enumerate(results, 1):
|
| 131 |
-
md = r.get("metadata", {})
|
| 132 |
-
content = r.get("content", "").strip()
|
| 133 |
-
rerank_score = r.get("rerank_score")
|
| 134 |
-
distance = r.get("distance")
|
| 135 |
-
|
| 136 |
-
# Trích xuất metadata
|
| 137 |
-
source = md.get("source_file", "N/A")
|
| 138 |
-
doc_type = md.get("document_type", "N/A")
|
| 139 |
-
header = md.get("header_path", "")
|
| 140 |
-
cohorts = md.get("applicable_cohorts", "")
|
| 141 |
-
program = md.get("program_name", "")
|
| 142 |
-
issued_year = md.get("issued_year", "")
|
| 143 |
-
|
| 144 |
-
# Format score
|
| 145 |
-
score_info = ""
|
| 146 |
-
if rerank_score is not None:
|
| 147 |
-
score_info += f"Rerank: `{rerank_score:.4f}` "
|
| 148 |
-
if distance is not None:
|
| 149 |
-
score_info += f"Distance: `{distance:.4f}`"
|
| 150 |
-
if not score_info:
|
| 151 |
-
score_info = f"Rank: `{r.get('final_rank', i)}`"
|
| 152 |
-
|
| 153 |
-
# Format metadata
|
| 154 |
-
meta_parts = [f"**Nguồn:** {source}", f"**Loại:** {doc_type}"]
|
| 155 |
-
if issued_year:
|
| 156 |
-
meta_parts.append(f"**Năm:** {issued_year}")
|
| 157 |
-
if cohorts:
|
| 158 |
-
meta_parts.append(f"**Áp dụng:** {cohorts}")
|
| 159 |
-
if program:
|
| 160 |
-
meta_parts.append(f"**CTĐT:** {program}")
|
| 161 |
-
|
| 162 |
-
debug_info += f"**#{i}** | {score_info}\n"
|
| 163 |
-
debug_info += f" - {' | '.join(meta_parts)}\n"
|
| 164 |
-
if header and header != "/":
|
| 165 |
-
debug_info += f" - **Mục:** {header[:80]}{'...' if len(header) > 80 else ''}\n"
|
| 166 |
-
debug_info += f" - **Content:** {content[:200]}{'...' if len(content) > 200 else ''}\n\n"
|
| 167 |
-
|
| 168 |
-
yield acc + debug_info
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
# Tạo giao diện Gradio
|
| 172 |
-
demo = gr.ChatInterface(
|
| 173 |
-
fn=rag_chat,
|
| 174 |
-
title=f"HUST RAG Assistant",
|
| 175 |
-
description=f"Trợ lý học vụ Đại học Bách khoa Hà Nội",
|
| 176 |
-
examples=[
|
| 177 |
-
"Điều kiện tốt nghiệp đại học là gì?",
|
| 178 |
-
"Điều kiện để đổi ngành là gì?",
|
| 179 |
-
"Làm thế nào để đăng ký hoãn thi?",
|
| 180 |
-
],
|
| 181 |
-
)
|
| 182 |
|
| 183 |
if __name__ == "__main__":
|
| 184 |
print(f"\n{'='*60}")
|
| 185 |
-
print(f"Starting HUST RAG Assistant")
|
| 186 |
print(f"{'='*60}\n")
|
| 187 |
demo.launch(
|
| 188 |
server_name=GRADIO_CFG.server_host,
|
|
|
|
| 1 |
+
from core.gradio.user_gradio import demo_debug as demo, GRADIO_CFG
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
if __name__ == "__main__":
|
| 4 |
print(f"\n{'='*60}")
|
| 5 |
+
print(f"Starting HUST RAG Assistant (Debug Mode)")
|
| 6 |
print(f"{'='*60}\n")
|
| 7 |
demo.launch(
|
| 8 |
server_name=GRADIO_CFG.server_host,
|
core/gradio/user_gradio.py
CHANGED
|
@@ -9,6 +9,7 @@ from dotenv import find_dotenv, load_dotenv
|
|
| 9 |
from openai import OpenAI
|
| 10 |
import re
|
| 11 |
|
|
|
|
| 12 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 13 |
if str(REPO_ROOT) not in sys.path:
|
| 14 |
sys.path.insert(0, str(REPO_ROOT))
|
|
@@ -19,26 +20,27 @@ class GradioConfig:
|
|
| 19 |
server_host: str = "127.0.0.1"
|
| 20 |
server_port: int = 7860
|
| 21 |
|
|
|
|
| 22 |
def _load_env() -> None:
|
| 23 |
dotenv_path = find_dotenv(usecwd=True) or ""
|
| 24 |
load_dotenv(dotenv_path=dotenv_path or None, override=False)
|
| 25 |
|
| 26 |
|
|
|
|
| 27 |
from core.rag.embedding_model import EmbeddingConfig, QwenEmbeddings
|
| 28 |
from core.rag.vector_store import ChromaConfig, ChromaVectorDB
|
| 29 |
-
from core.rag.
|
| 30 |
from core.rag.generator import RAGContextBuilder, build_context, build_prompt, SYSTEM_PROMPT
|
| 31 |
|
| 32 |
_load_env()
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
# LLM Config (hardcoded sau khi xóa LLMConfig từ generator)
|
| 37 |
LLM_MODEL = os.getenv("LLM_MODEL", "qwen/qwen3-32b")
|
| 38 |
LLM_API_BASE = "https://api.groq.com/openai/v1"
|
| 39 |
LLM_API_KEY_ENV = "GROQ_API_KEY"
|
| 40 |
|
| 41 |
-
#
|
| 42 |
GRADIO_CFG = GradioConfig()
|
| 43 |
RETRIEVAL_CFG = get_retrieval_config()
|
| 44 |
|
|
@@ -51,39 +53,84 @@ class AppState:
|
|
| 51 |
self.client: Optional[OpenAI] = None
|
| 52 |
|
| 53 |
|
| 54 |
-
STATE = AppState()
|
| 55 |
|
| 56 |
|
| 57 |
def _init_resources() -> None:
|
| 58 |
if STATE.db is not None:
|
| 59 |
return
|
| 60 |
|
| 61 |
-
print(f"
|
| 62 |
print(f" Retrieval Mode: {RETRIEVAL_MODE.value}")
|
| 63 |
|
|
|
|
| 64 |
emb = QwenEmbeddings(EmbeddingConfig())
|
| 65 |
-
|
| 66 |
db_cfg = ChromaConfig()
|
| 67 |
|
| 68 |
-
STATE.db = ChromaVectorDB(
|
| 69 |
-
embedder=emb,
|
| 70 |
-
config=db_cfg,
|
| 71 |
-
)
|
| 72 |
STATE.retriever = Retriever(vector_db=STATE.db)
|
| 73 |
|
| 74 |
-
# LLM
|
| 75 |
api_key = (os.getenv(LLM_API_KEY_ENV) or "").strip()
|
| 76 |
if not api_key:
|
| 77 |
raise RuntimeError(f"Missing {LLM_API_KEY_ENV}")
|
| 78 |
STATE.client = OpenAI(api_key=api_key, base_url=LLM_API_BASE)
|
| 79 |
|
| 80 |
-
#
|
| 81 |
STATE.rag_builder = RAGContextBuilder(retriever=STATE.retriever)
|
| 82 |
|
| 83 |
-
print("
|
| 84 |
|
| 85 |
|
| 86 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
_init_resources()
|
| 88 |
|
| 89 |
assert STATE.db is not None
|
|
@@ -91,7 +138,7 @@ def rag_chat(message: str, history: List[Dict[str, str]] | None = None):
|
|
| 91 |
assert STATE.retriever is not None
|
| 92 |
assert STATE.rag_builder is not None
|
| 93 |
|
| 94 |
-
# Retrieve
|
| 95 |
prepared = STATE.rag_builder.retrieve_and_prepare(
|
| 96 |
message,
|
| 97 |
k=RETRIEVAL_CFG.top_k,
|
|
@@ -104,7 +151,7 @@ def rag_chat(message: str, history: List[Dict[str, str]] | None = None):
|
|
| 104 |
yield "Xin lỗi, tôi không tìm thấy thông tin phù hợp trong dữ liệu."
|
| 105 |
return
|
| 106 |
|
| 107 |
-
# LLM streaming
|
| 108 |
completion = STATE.client.chat.completions.create(
|
| 109 |
model=LLM_MODEL,
|
| 110 |
messages=[{"role": "user", "content": prepared["prompt"]}],
|
|
@@ -113,36 +160,54 @@ def rag_chat(message: str, history: List[Dict[str, str]] | None = None):
|
|
| 113 |
stream=True,
|
| 114 |
)
|
| 115 |
|
|
|
|
| 116 |
acc = ""
|
| 117 |
for chunk in completion:
|
| 118 |
delta = getattr(chunk.choices[0].delta, "content", "") or ""
|
| 119 |
if delta:
|
| 120 |
acc += delta
|
| 121 |
-
#
|
| 122 |
-
|
| 123 |
-
yield display_text
|
| 124 |
|
| 125 |
-
# Yield
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
# Loại bỏ khoảng trắng thừa đầu dòng
|
| 133 |
-
filtered = filtered.strip()
|
| 134 |
-
return filtered
|
| 135 |
|
| 136 |
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
-
#
|
| 140 |
demo = gr.ChatInterface(
|
| 141 |
-
fn=
|
| 142 |
-
title=
|
| 143 |
-
description=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
examples=[
|
| 145 |
-
"
|
| 146 |
"Điều kiện để đổi ngành là gì?",
|
| 147 |
"Làm thế nào để đăng ký hoãn thi?",
|
| 148 |
],
|
|
|
|
| 9 |
from openai import OpenAI
|
| 10 |
import re
|
| 11 |
|
| 12 |
+
# Add project root to Python path
|
| 13 |
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 14 |
if str(REPO_ROOT) not in sys.path:
|
| 15 |
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
| 20 |
server_host: str = "127.0.0.1"
|
| 21 |
server_port: int = 7860
|
| 22 |
|
| 23 |
+
|
| 24 |
def _load_env() -> None:
|
| 25 |
dotenv_path = find_dotenv(usecwd=True) or ""
|
| 26 |
load_dotenv(dotenv_path=dotenv_path or None, override=False)
|
| 27 |
|
| 28 |
|
| 29 |
+
# RAG module imports
|
| 30 |
from core.rag.embedding_model import EmbeddingConfig, QwenEmbeddings
|
| 31 |
from core.rag.vector_store import ChromaConfig, ChromaVectorDB
|
| 32 |
+
from core.rag.retrieval import Retriever, RetrievalMode, get_retrieval_config
|
| 33 |
from core.rag.generator import RAGContextBuilder, build_context, build_prompt, SYSTEM_PROMPT
|
| 34 |
|
| 35 |
_load_env()
|
| 36 |
|
| 37 |
+
# Retrieval and LLM configuration
|
| 38 |
+
RETRIEVAL_MODE = RetrievalMode.HYBRID_RERANK
|
|
|
|
| 39 |
LLM_MODEL = os.getenv("LLM_MODEL", "qwen/qwen3-32b")
|
| 40 |
LLM_API_BASE = "https://api.groq.com/openai/v1"
|
| 41 |
LLM_API_KEY_ENV = "GROQ_API_KEY"
|
| 42 |
|
| 43 |
+
# Initialize configs
|
| 44 |
GRADIO_CFG = GradioConfig()
|
| 45 |
RETRIEVAL_CFG = get_retrieval_config()
|
| 46 |
|
|
|
|
| 53 |
self.client: Optional[OpenAI] = None
|
| 54 |
|
| 55 |
|
| 56 |
+
STATE = AppState() # Singleton state
|
| 57 |
|
| 58 |
|
| 59 |
def _init_resources() -> None:
|
| 60 |
if STATE.db is not None:
|
| 61 |
return
|
| 62 |
|
| 63 |
+
print(f" Initializing Database & Reranker...")
|
| 64 |
print(f" Retrieval Mode: {RETRIEVAL_MODE.value}")
|
| 65 |
|
| 66 |
+
# Initialize embedding and vector database
|
| 67 |
emb = QwenEmbeddings(EmbeddingConfig())
|
|
|
|
| 68 |
db_cfg = ChromaConfig()
|
| 69 |
|
| 70 |
+
STATE.db = ChromaVectorDB(embedder=emb, config=db_cfg)
|
|
|
|
|
|
|
|
|
|
| 71 |
STATE.retriever = Retriever(vector_db=STATE.db)
|
| 72 |
|
| 73 |
+
# Initialize LLM client
|
| 74 |
api_key = (os.getenv(LLM_API_KEY_ENV) or "").strip()
|
| 75 |
if not api_key:
|
| 76 |
raise RuntimeError(f"Missing {LLM_API_KEY_ENV}")
|
| 77 |
STATE.client = OpenAI(api_key=api_key, base_url=LLM_API_BASE)
|
| 78 |
|
| 79 |
+
# Initialize RAG context builder
|
| 80 |
STATE.rag_builder = RAGContextBuilder(retriever=STATE.retriever)
|
| 81 |
|
| 82 |
+
print(" Ready!")
|
| 83 |
|
| 84 |
|
| 85 |
+
def _filter_think_tags(text: str) -> str:
|
| 86 |
+
filtered = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
|
| 87 |
+
return filtered.strip()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _format_debug_info(results: List[Dict]) -> str:
|
| 91 |
+
debug_info = f"\n\n---\n\n**Retrieved (Top {len(results)} | Mode: {RETRIEVAL_MODE.value})**\n\n"
|
| 92 |
+
for i, r in enumerate(results, 1):
|
| 93 |
+
md = r.get("metadata", {})
|
| 94 |
+
content = r.get("content", "").strip()
|
| 95 |
+
rerank_score = r.get("rerank_score")
|
| 96 |
+
distance = r.get("distance")
|
| 97 |
+
|
| 98 |
+
# Extract metadata fields
|
| 99 |
+
source = md.get("source_file", "N/A")
|
| 100 |
+
doc_type = md.get("document_type", "N/A")
|
| 101 |
+
header = md.get("header_path", "")
|
| 102 |
+
cohorts = md.get("applicable_cohorts", "")
|
| 103 |
+
program = md.get("program_name", "")
|
| 104 |
+
issued_year = md.get("issued_year", "")
|
| 105 |
+
|
| 106 |
+
# Format relevance score
|
| 107 |
+
score_info = ""
|
| 108 |
+
if rerank_score is not None:
|
| 109 |
+
score_info += f"Rerank: `{rerank_score:.4f}` "
|
| 110 |
+
if distance is not None:
|
| 111 |
+
score_info += f"Distance: `{distance:.4f}`"
|
| 112 |
+
if not score_info:
|
| 113 |
+
score_info = f"Rank: `{r.get('final_rank', i)}`"
|
| 114 |
+
|
| 115 |
+
# Format metadata labels
|
| 116 |
+
meta_parts = [f"**Nguồn:** {source}", f"**Loại:** {doc_type}"]
|
| 117 |
+
if issued_year:
|
| 118 |
+
meta_parts.append(f"**Năm:** {issued_year}")
|
| 119 |
+
if cohorts:
|
| 120 |
+
meta_parts.append(f"**Áp dụng:** {cohorts}")
|
| 121 |
+
if program:
|
| 122 |
+
meta_parts.append(f"**CTĐT:** {program}")
|
| 123 |
+
|
| 124 |
+
debug_info += f"**#{i}** | {score_info}\n"
|
| 125 |
+
debug_info += f" - {' | '.join(meta_parts)}\n"
|
| 126 |
+
if header and header != "/":
|
| 127 |
+
debug_info += f" - **Mục:** {header[:80]}{'...' if len(header) > 80 else ''}\n"
|
| 128 |
+
debug_info += f" - **Content:** {content[:200]}{'...' if len(content) > 200 else ''}\n\n"
|
| 129 |
+
|
| 130 |
+
return debug_info
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def rag_chat(message: str, history: List[Dict[str, str]] | None = None, *, debug: bool = False):
|
| 134 |
_init_resources()
|
| 135 |
|
| 136 |
assert STATE.db is not None
|
|
|
|
| 138 |
assert STATE.retriever is not None
|
| 139 |
assert STATE.rag_builder is not None
|
| 140 |
|
| 141 |
+
# Retrieve and prepare context
|
| 142 |
prepared = STATE.rag_builder.retrieve_and_prepare(
|
| 143 |
message,
|
| 144 |
k=RETRIEVAL_CFG.top_k,
|
|
|
|
| 151 |
yield "Xin lỗi, tôi không tìm thấy thông tin phù hợp trong dữ liệu."
|
| 152 |
return
|
| 153 |
|
| 154 |
+
# Call LLM with streaming
|
| 155 |
completion = STATE.client.chat.completions.create(
|
| 156 |
model=LLM_MODEL,
|
| 157 |
messages=[{"role": "user", "content": prepared["prompt"]}],
|
|
|
|
| 160 |
stream=True,
|
| 161 |
)
|
| 162 |
|
| 163 |
+
# Stream response tokens
|
| 164 |
acc = ""
|
| 165 |
for chunk in completion:
|
| 166 |
delta = getattr(chunk.choices[0].delta, "content", "") or ""
|
| 167 |
if delta:
|
| 168 |
acc += delta
|
| 169 |
+
# Filter out <think>...</think> blocks before yielding
|
| 170 |
+
yield _filter_think_tags(acc)
|
|
|
|
| 171 |
|
| 172 |
+
# Yield final result
|
| 173 |
+
final_text = _filter_think_tags(acc)
|
| 174 |
+
|
| 175 |
+
if debug:
|
| 176 |
+
# Append debug info about retrieved documents
|
| 177 |
+
final_text += _format_debug_info(results)
|
| 178 |
+
|
| 179 |
+
yield final_text
|
| 180 |
|
| 181 |
|
| 182 |
+
# --- User interface (production) ---
|
| 183 |
+
def _rag_chat_user(message: str, history: List[Dict[str, str]] | None = None):
|
| 184 |
+
yield from rag_chat(message, history, debug=False)
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
+
# --- Debug interface (development) ---
|
| 188 |
+
def _rag_chat_debug(message: str, history: List[Dict[str, str]] | None = None):
|
| 189 |
+
yield from rag_chat(message, history, debug=True)
|
| 190 |
|
| 191 |
|
| 192 |
+
# Production interface (no debug info)
|
| 193 |
demo = gr.ChatInterface(
|
| 194 |
+
fn=_rag_chat_user,
|
| 195 |
+
title="HUST RAG Assistant",
|
| 196 |
+
description="Trợ lý học vụ Đại học Bách khoa Hà Nội",
|
| 197 |
+
examples=[
|
| 198 |
+
"Cách tính điểm học tập học kỳ ?",
|
| 199 |
+
"Điều kiện để đổi ngành là gì?",
|
| 200 |
+
"Làm thế nào để đăng ký hoãn thi?",
|
| 201 |
+
],
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# Debug interface (shows retrieved docs info)
|
| 205 |
+
demo_debug = gr.ChatInterface(
|
| 206 |
+
fn=_rag_chat_debug,
|
| 207 |
+
title="HUST RAG Assistant (Debug)",
|
| 208 |
+
description="Trợ lý học vụ Đại học Bách khoa Hà Nội - Chế độ debug",
|
| 209 |
examples=[
|
| 210 |
+
"Điều kiện tốt nghiệp đại học là gì?",
|
| 211 |
"Điều kiện để đổi ngành là gì?",
|
| 212 |
"Làm thế nào để đăng ký hoãn thi?",
|
| 213 |
],
|
core/hash_file/hash_data_goc.py
CHANGED
|
@@ -9,20 +9,19 @@ if str(PROJECT_ROOT) not in sys.path:
|
|
| 9 |
|
| 10 |
from core.hash_file.hash_file import HashProcessor
|
| 11 |
|
| 12 |
-
# HuggingFace repo
|
| 13 |
HF_RAW_PDF_REPO = "hungnha/Do_An_Dataset"
|
| 14 |
|
| 15 |
|
| 16 |
def download_from_hf(cache_dir: Path) -> Path:
|
| 17 |
-
"""Tải PDF từ HuggingFace, trả về đường dẫn tới folder data_rag."""
|
| 18 |
from huggingface_hub import snapshot_download
|
| 19 |
|
| 20 |
-
#
|
| 21 |
if cache_dir.exists() and any(cache_dir.iterdir()):
|
| 22 |
-
print(f"Cache
|
| 23 |
return cache_dir / "data_rag"
|
| 24 |
|
| 25 |
-
print(f"
|
| 26 |
snapshot_download(
|
| 27 |
repo_id=HF_RAW_PDF_REPO,
|
| 28 |
repo_type="dataset",
|
|
@@ -33,7 +32,6 @@ def download_from_hf(cache_dir: Path) -> Path:
|
|
| 33 |
|
| 34 |
|
| 35 |
def load_existing_hashes(path: Path) -> dict:
|
| 36 |
-
"""Đọc hash index cũ từ file JSON."""
|
| 37 |
if not path.exists():
|
| 38 |
return {}
|
| 39 |
try:
|
|
@@ -44,10 +42,9 @@ def load_existing_hashes(path: Path) -> dict:
|
|
| 44 |
|
| 45 |
|
| 46 |
def process_pdfs(source_root: Path, dest_dir: Path, existing_hashes: dict) -> tuple:
|
| 47 |
-
"""Copy PDFs và tính hash. Trả về (results, processed, skipped)."""
|
| 48 |
hasher = HashProcessor(verbose=False)
|
| 49 |
pdf_files = list(source_root.rglob("*.pdf"))
|
| 50 |
-
print(f"
|
| 51 |
|
| 52 |
results, processed, skipped = [], 0, 0
|
| 53 |
|
|
@@ -56,7 +53,7 @@ def process_pdfs(source_root: Path, dest_dir: Path, existing_hashes: dict) -> tu
|
|
| 56 |
dest = dest_dir / rel_path
|
| 57 |
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 58 |
|
| 59 |
-
#
|
| 60 |
if dest.exists() and rel_path in existing_hashes:
|
| 61 |
current_hash = hasher.get_file_hash(str(dest))
|
| 62 |
if current_hash == existing_hashes[rel_path]:
|
|
@@ -64,7 +61,7 @@ def process_pdfs(source_root: Path, dest_dir: Path, existing_hashes: dict) -> tu
|
|
| 64 |
skipped += 1
|
| 65 |
continue
|
| 66 |
|
| 67 |
-
# Copy
|
| 68 |
try:
|
| 69 |
shutil.copy2(src, dest)
|
| 70 |
file_hash = hasher.get_file_hash(str(dest))
|
|
@@ -72,20 +69,20 @@ def process_pdfs(source_root: Path, dest_dir: Path, existing_hashes: dict) -> tu
|
|
| 72 |
results.append({'filename': rel_path, 'hash': file_hash, 'index': idx})
|
| 73 |
processed += 1
|
| 74 |
except Exception as e:
|
| 75 |
-
print(f"
|
| 76 |
|
| 77 |
-
#
|
| 78 |
if (idx + 1) % 20 == 0:
|
| 79 |
-
print(f"
|
| 80 |
|
| 81 |
return results, processed, skipped
|
| 82 |
|
| 83 |
|
| 84 |
def main():
|
| 85 |
import argparse
|
| 86 |
-
parser = argparse.ArgumentParser(description="
|
| 87 |
-
parser.add_argument("--source", type=str, help="
|
| 88 |
-
parser.add_argument("--download-only", action="store_true", help="
|
| 89 |
args = parser.parse_args()
|
| 90 |
|
| 91 |
data_dir = PROJECT_ROOT / "data"
|
|
@@ -93,34 +90,34 @@ def main():
|
|
| 93 |
files_dir.mkdir(parents=True, exist_ok=True)
|
| 94 |
hash_file = data_dir / "hash_data_goc_index.json"
|
| 95 |
|
| 96 |
-
#
|
| 97 |
if args.source:
|
| 98 |
source_root = Path(args.source)
|
| 99 |
if not source_root.exists():
|
| 100 |
-
return print(f"
|
| 101 |
else:
|
| 102 |
-
#
|
| 103 |
source_root = download_from_hf(data_dir / "raw_pdf_cache")
|
| 104 |
if args.download_only:
|
| 105 |
-
return print(f"
|
| 106 |
|
| 107 |
if not source_root.exists():
|
| 108 |
-
return print(f"
|
| 109 |
|
| 110 |
-
#
|
| 111 |
existing = load_existing_hashes(hash_file)
|
| 112 |
-
print(f"
|
| 113 |
|
| 114 |
results, processed, skipped = process_pdfs(source_root, files_dir, existing)
|
| 115 |
|
| 116 |
-
#
|
| 117 |
hash_file.write_text(json.dumps({
|
| 118 |
'train': results,
|
| 119 |
'total_files': len(results)
|
| 120 |
}, ensure_ascii=False, indent=2), encoding='utf-8')
|
| 121 |
|
| 122 |
-
print(f"\
|
| 123 |
-
print(f"
|
| 124 |
|
| 125 |
|
| 126 |
if __name__ == "__main__":
|
|
|
|
| 9 |
|
| 10 |
from core.hash_file.hash_file import HashProcessor
|
| 11 |
|
| 12 |
+
# HuggingFace repo containing raw PDFs
|
| 13 |
HF_RAW_PDF_REPO = "hungnha/Do_An_Dataset"
|
| 14 |
|
| 15 |
|
| 16 |
def download_from_hf(cache_dir: Path) -> Path:
|
|
|
|
| 17 |
from huggingface_hub import snapshot_download
|
| 18 |
|
| 19 |
+
# Check if cache already exists
|
| 20 |
if cache_dir.exists() and any(cache_dir.iterdir()):
|
| 21 |
+
print(f"Cache already exists: {cache_dir}")
|
| 22 |
return cache_dir / "data_rag"
|
| 23 |
|
| 24 |
+
print(f"Downloading from HuggingFace: {HF_RAW_PDF_REPO}")
|
| 25 |
snapshot_download(
|
| 26 |
repo_id=HF_RAW_PDF_REPO,
|
| 27 |
repo_type="dataset",
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def load_existing_hashes(path: Path) -> dict:
|
|
|
|
| 35 |
if not path.exists():
|
| 36 |
return {}
|
| 37 |
try:
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def process_pdfs(source_root: Path, dest_dir: Path, existing_hashes: dict) -> tuple:
|
|
|
|
| 45 |
hasher = HashProcessor(verbose=False)
|
| 46 |
pdf_files = list(source_root.rglob("*.pdf"))
|
| 47 |
+
print(f"Found {len(pdf_files)} PDF files\n")
|
| 48 |
|
| 49 |
results, processed, skipped = [], 0, 0
|
| 50 |
|
|
|
|
| 53 |
dest = dest_dir / rel_path
|
| 54 |
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 55 |
|
| 56 |
+
# Skip if file unchanged (hash matches)
|
| 57 |
if dest.exists() and rel_path in existing_hashes:
|
| 58 |
current_hash = hasher.get_file_hash(str(dest))
|
| 59 |
if current_hash == existing_hashes[rel_path]:
|
|
|
|
| 61 |
skipped += 1
|
| 62 |
continue
|
| 63 |
|
| 64 |
+
# Copy and compute hash
|
| 65 |
try:
|
| 66 |
shutil.copy2(src, dest)
|
| 67 |
file_hash = hasher.get_file_hash(str(dest))
|
|
|
|
| 69 |
results.append({'filename': rel_path, 'hash': file_hash, 'index': idx})
|
| 70 |
processed += 1
|
| 71 |
except Exception as e:
|
| 72 |
+
print(f"Error: {rel_path} - {e}")
|
| 73 |
|
| 74 |
+
# Display progress
|
| 75 |
if (idx + 1) % 20 == 0:
|
| 76 |
+
print(f"Progress: {idx + 1}/{len(pdf_files)}")
|
| 77 |
|
| 78 |
return results, processed, skipped
|
| 79 |
|
| 80 |
|
| 81 |
def main():
|
| 82 |
import argparse
|
| 83 |
+
parser = argparse.ArgumentParser(description="Download PDFs and build hash index")
|
| 84 |
+
parser.add_argument("--source", type=str, help="Local path to PDFs (skip HF download)")
|
| 85 |
+
parser.add_argument("--download-only", action="store_true", help="Download only, no copy")
|
| 86 |
args = parser.parse_args()
|
| 87 |
|
| 88 |
data_dir = PROJECT_ROOT / "data"
|
|
|
|
| 90 |
files_dir.mkdir(parents=True, exist_ok=True)
|
| 91 |
hash_file = data_dir / "hash_data_goc_index.json"
|
| 92 |
|
| 93 |
+
# Determine source directory
|
| 94 |
if args.source:
|
| 95 |
source_root = Path(args.source)
|
| 96 |
if not source_root.exists():
|
| 97 |
+
return print(f"Source directory not found: {source_root}")
|
| 98 |
else:
|
| 99 |
+
# Download from HuggingFace
|
| 100 |
source_root = download_from_hf(data_dir / "raw_pdf_cache")
|
| 101 |
if args.download_only:
|
| 102 |
+
return print(f"PDFs cached at: {source_root}")
|
| 103 |
|
| 104 |
if not source_root.exists():
|
| 105 |
+
return print(f"PDF directory not found: {source_root}")
|
| 106 |
|
| 107 |
+
# Process
|
| 108 |
existing = load_existing_hashes(hash_file)
|
| 109 |
+
print(f"Loaded {len(existing)} hashes from existing index")
|
| 110 |
|
| 111 |
results, processed, skipped = process_pdfs(source_root, files_dir, existing)
|
| 112 |
|
| 113 |
+
# Save results
|
| 114 |
hash_file.write_text(json.dumps({
|
| 115 |
'train': results,
|
| 116 |
'total_files': len(results)
|
| 117 |
}, ensure_ascii=False, indent=2), encoding='utf-8')
|
| 118 |
|
| 119 |
+
print(f"\nDone! Total: {len(results)} | New: {processed} | Skipped: {skipped}")
|
| 120 |
+
print(f"Index file: {hash_file}")
|
| 121 |
|
| 122 |
|
| 123 |
if __name__ == "__main__":
|
core/hash_file/hash_file.py
CHANGED
|
@@ -9,23 +9,22 @@ from pathlib import Path
|
|
| 9 |
from typing import Dict, List, Optional
|
| 10 |
from datetime import datetime
|
| 11 |
|
| 12 |
-
#
|
| 13 |
-
CHUNK_SIZE = 8192 #
|
| 14 |
DEFAULT_FILE_EXTENSION = '.pdf'
|
| 15 |
|
| 16 |
|
| 17 |
class HashProcessor:
|
| 18 |
-
"""Lớp xử lý hash cho files - dùng để phát hiện thay đổi và tránh xử lý lại."""
|
| 19 |
|
| 20 |
def __init__(self, verbose: bool = True):
|
| 21 |
-
|
| 22 |
self.verbose = verbose
|
| 23 |
self.logger = logging.getLogger(__name__)
|
| 24 |
if not verbose:
|
| 25 |
self.logger.setLevel(logging.WARNING)
|
| 26 |
|
| 27 |
def get_file_hash(self, path: str) -> Optional[str]:
|
| 28 |
-
|
| 29 |
h = hashlib.sha256()
|
| 30 |
try:
|
| 31 |
with open(path, "rb") as f:
|
|
@@ -33,10 +32,10 @@ class HashProcessor:
|
|
| 33 |
h.update(chunk)
|
| 34 |
return h.hexdigest()
|
| 35 |
except (IOError, OSError) as e:
|
| 36 |
-
self.logger.error(f"
|
| 37 |
return None
|
| 38 |
except Exception as e:
|
| 39 |
-
self.logger.error(f"
|
| 40 |
return None
|
| 41 |
|
| 42 |
def scan_files_for_hash(
|
|
@@ -45,13 +44,13 @@ class HashProcessor:
|
|
| 45 |
file_extension: str = DEFAULT_FILE_EXTENSION,
|
| 46 |
recursive: bool = False
|
| 47 |
) -> Dict[str, List[Dict[str, str]]]:
|
| 48 |
-
|
| 49 |
source_path = Path(source_dir)
|
| 50 |
if not source_path.exists():
|
| 51 |
-
raise FileNotFoundError(f"
|
| 52 |
|
| 53 |
hash_to_files = defaultdict(list)
|
| 54 |
-
self.logger.info(f"
|
| 55 |
|
| 56 |
pattern = f"**/*{file_extension}" if recursive else f"*{file_extension}"
|
| 57 |
|
|
@@ -62,7 +61,7 @@ class HashProcessor:
|
|
| 62 |
if not file_path.is_file():
|
| 63 |
continue
|
| 64 |
|
| 65 |
-
self.logger.info(f"
|
| 66 |
|
| 67 |
file_hash = self.get_file_hash(str(file_path))
|
| 68 |
if file_hash:
|
|
@@ -72,53 +71,49 @@ class HashProcessor:
|
|
| 72 |
'size': file_path.stat().st_size
|
| 73 |
})
|
| 74 |
except PermissionError as e:
|
| 75 |
-
self.logger.error(f"
|
| 76 |
raise
|
| 77 |
|
| 78 |
return hash_to_files
|
| 79 |
|
| 80 |
def load_processed_index(self, index_file: str) -> Dict:
|
| 81 |
-
|
| 82 |
if os.path.exists(index_file):
|
| 83 |
try:
|
| 84 |
with open(index_file, "r", encoding="utf-8") as f:
|
| 85 |
return json.load(f)
|
| 86 |
except json.JSONDecodeError as e:
|
| 87 |
-
self.logger.error(f"
|
| 88 |
return {}
|
| 89 |
except Exception as e:
|
| 90 |
-
self.logger.error(f"
|
| 91 |
return {}
|
| 92 |
return {}
|
| 93 |
|
| 94 |
def save_processed_index(self, index_file: str, processed_hashes: Dict) -> None:
|
| 95 |
-
"""Lưu index đã xử lý vào file JSON (atomic write).
|
| 96 |
-
|
| 97 |
-
Ghi vào file tạm trước, sau đó rename để đảm bảo an toàn.
|
| 98 |
-
"""
|
| 99 |
temp_name = None
|
| 100 |
try:
|
| 101 |
os.makedirs(os.path.dirname(index_file), exist_ok=True)
|
| 102 |
|
| 103 |
-
#
|
| 104 |
dir_name = os.path.dirname(index_file)
|
| 105 |
with tempfile.NamedTemporaryFile('w', dir=dir_name, delete=False, encoding='utf-8') as tmp_file:
|
| 106 |
json.dump(processed_hashes, tmp_file, indent=2, ensure_ascii=False)
|
| 107 |
temp_name = tmp_file.name
|
| 108 |
|
| 109 |
-
#
|
| 110 |
shutil.move(temp_name, index_file)
|
| 111 |
-
self.logger.info(f"
|
| 112 |
|
| 113 |
except Exception as e:
|
| 114 |
-
self.logger.error(f"
|
| 115 |
if temp_name and os.path.exists(temp_name):
|
| 116 |
os.remove(temp_name)
|
| 117 |
|
| 118 |
def get_current_timestamp(self) -> str:
|
| 119 |
-
|
| 120 |
return datetime.now().isoformat()
|
| 121 |
|
| 122 |
def get_string_hash(self, text: str) -> str:
|
| 123 |
-
|
| 124 |
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
|
|
|
| 9 |
from typing import Dict, List, Optional
|
| 10 |
from datetime import datetime
|
| 11 |
|
| 12 |
+
# Constants
|
| 13 |
+
CHUNK_SIZE = 8192 # Read files in 8KB chunks
|
| 14 |
DEFAULT_FILE_EXTENSION = '.pdf'
|
| 15 |
|
| 16 |
|
| 17 |
class HashProcessor:
|
|
|
|
| 18 |
|
| 19 |
def __init__(self, verbose: bool = True):
|
| 20 |
+
|
| 21 |
self.verbose = verbose
|
| 22 |
self.logger = logging.getLogger(__name__)
|
| 23 |
if not verbose:
|
| 24 |
self.logger.setLevel(logging.WARNING)
|
| 25 |
|
| 26 |
def get_file_hash(self, path: str) -> Optional[str]:
|
| 27 |
+
|
| 28 |
h = hashlib.sha256()
|
| 29 |
try:
|
| 30 |
with open(path, "rb") as f:
|
|
|
|
| 32 |
h.update(chunk)
|
| 33 |
return h.hexdigest()
|
| 34 |
except (IOError, OSError) as e:
|
| 35 |
+
self.logger.error(f"Error reading file {path}: {e}")
|
| 36 |
return None
|
| 37 |
except Exception as e:
|
| 38 |
+
self.logger.error(f"Unexpected error processing file {path}: {e}")
|
| 39 |
return None
|
| 40 |
|
| 41 |
def scan_files_for_hash(
|
|
|
|
| 44 |
file_extension: str = DEFAULT_FILE_EXTENSION,
|
| 45 |
recursive: bool = False
|
| 46 |
) -> Dict[str, List[Dict[str, str]]]:
|
| 47 |
+
|
| 48 |
source_path = Path(source_dir)
|
| 49 |
if not source_path.exists():
|
| 50 |
+
raise FileNotFoundError(f"Directory not found: {source_dir}")
|
| 51 |
|
| 52 |
hash_to_files = defaultdict(list)
|
| 53 |
+
self.logger.info(f"Scanning files in: {source_dir}")
|
| 54 |
|
| 55 |
pattern = f"**/*{file_extension}" if recursive else f"*{file_extension}"
|
| 56 |
|
|
|
|
| 61 |
if not file_path.is_file():
|
| 62 |
continue
|
| 63 |
|
| 64 |
+
self.logger.info(f"Computing hash for: {file_path.name}")
|
| 65 |
|
| 66 |
file_hash = self.get_file_hash(str(file_path))
|
| 67 |
if file_hash:
|
|
|
|
| 71 |
'size': file_path.stat().st_size
|
| 72 |
})
|
| 73 |
except PermissionError as e:
|
| 74 |
+
self.logger.error(f"Permission error: {e}")
|
| 75 |
raise
|
| 76 |
|
| 77 |
return hash_to_files
|
| 78 |
|
| 79 |
def load_processed_index(self, index_file: str) -> Dict:
|
| 80 |
+
|
| 81 |
if os.path.exists(index_file):
|
| 82 |
try:
|
| 83 |
with open(index_file, "r", encoding="utf-8") as f:
|
| 84 |
return json.load(f)
|
| 85 |
except json.JSONDecodeError as e:
|
| 86 |
+
self.logger.error(f"Error reading index file {index_file}: {e}")
|
| 87 |
return {}
|
| 88 |
except Exception as e:
|
| 89 |
+
self.logger.error(f"Unexpected error reading index: {e}")
|
| 90 |
return {}
|
| 91 |
return {}
|
| 92 |
|
| 93 |
def save_processed_index(self, index_file: str, processed_hashes: Dict) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
temp_name = None
|
| 95 |
try:
|
| 96 |
os.makedirs(os.path.dirname(index_file), exist_ok=True)
|
| 97 |
|
| 98 |
+
# Write to temp file first
|
| 99 |
dir_name = os.path.dirname(index_file)
|
| 100 |
with tempfile.NamedTemporaryFile('w', dir=dir_name, delete=False, encoding='utf-8') as tmp_file:
|
| 101 |
json.dump(processed_hashes, tmp_file, indent=2, ensure_ascii=False)
|
| 102 |
temp_name = tmp_file.name
|
| 103 |
|
| 104 |
+
# Atomic rename temp to target (POSIX)
|
| 105 |
shutil.move(temp_name, index_file)
|
| 106 |
+
self.logger.info(f"Saved index file safely: {index_file}")
|
| 107 |
|
| 108 |
except Exception as e:
|
| 109 |
+
self.logger.error(f"Error saving index file {index_file}: {e}")
|
| 110 |
if temp_name and os.path.exists(temp_name):
|
| 111 |
os.remove(temp_name)
|
| 112 |
|
| 113 |
def get_current_timestamp(self) -> str:
|
| 114 |
+
|
| 115 |
return datetime.now().isoformat()
|
| 116 |
|
| 117 |
def get_string_hash(self, text: str) -> str:
|
| 118 |
+
|
| 119 |
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
core/preprocessing/docling_processor.py
CHANGED
|
@@ -13,7 +13,7 @@ from docling.datamodel.pipeline_options import PdfPipelineOptions, TableStructur
|
|
| 13 |
from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend
|
| 14 |
from docling.pipeline.standard_pdf_pipeline import StandardPdfPipeline
|
| 15 |
|
| 16 |
-
#
|
| 17 |
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
| 18 |
if str(PROJECT_ROOT) not in sys.path:
|
| 19 |
sys.path.insert(0, str(PROJECT_ROOT))
|
|
@@ -22,26 +22,26 @@ from core.hash_file.hash_file import HashProcessor
|
|
| 22 |
|
| 23 |
|
| 24 |
class DoclingProcessor:
|
| 25 |
-
|
| 26 |
|
| 27 |
def __init__(self, output_dir: str, use_ocr: bool = True, timeout: int = 300, images_scale: float = 3.0):
|
| 28 |
-
|
| 29 |
self.output_dir = output_dir
|
| 30 |
self.timeout = timeout
|
| 31 |
self.logger = logging.getLogger(__name__)
|
| 32 |
self.hasher = HashProcessor(verbose=False)
|
| 33 |
os.makedirs(output_dir, exist_ok=True)
|
| 34 |
|
| 35 |
-
#
|
| 36 |
self.hash_index_path = Path(output_dir) / "docling_hash_index.json"
|
| 37 |
self.hash_index = self.hasher.load_processed_index(str(self.hash_index_path))
|
| 38 |
|
| 39 |
-
#
|
| 40 |
opts = PdfPipelineOptions(do_ocr=use_ocr, do_table_structure=True)
|
| 41 |
opts.table_structure_options = TableStructureOptions(do_cell_matching=True, mode=TableFormerMode.ACCURATE)
|
| 42 |
opts.images_scale = images_scale
|
| 43 |
|
| 44 |
-
#
|
| 45 |
if use_ocr:
|
| 46 |
ocr = EasyOcrOptions()
|
| 47 |
ocr.lang = ["vi"]
|
|
@@ -54,39 +54,39 @@ class DoclingProcessor:
|
|
| 54 |
self.logger.info(f"Docling | OCR={use_ocr} | Table=accurate | Scale={images_scale} | timeout={timeout}s")
|
| 55 |
|
| 56 |
def clean_markdown(self, text: str) -> str:
|
| 57 |
-
|
| 58 |
text = re.sub(r'\n\s*Trang\s+\d+\s*\n', '\n', text)
|
| 59 |
return re.sub(r'\n{3,}', '\n\n', text).strip()
|
| 60 |
|
| 61 |
def _should_process(self, pdf_path: str, output_path: Path) -> bool:
|
| 62 |
-
|
| 63 |
-
#
|
| 64 |
if not output_path.exists():
|
| 65 |
return True
|
| 66 |
|
| 67 |
-
#
|
| 68 |
current_hash = self.hasher.get_file_hash(pdf_path)
|
| 69 |
if not current_hash:
|
| 70 |
return True
|
| 71 |
|
| 72 |
-
#
|
| 73 |
saved_hash = self.hash_index.get(pdf_path, {}).get("hash")
|
| 74 |
return current_hash != saved_hash
|
| 75 |
|
| 76 |
def _save_hash(self, pdf_path: str, file_hash: str) -> None:
|
| 77 |
-
|
| 78 |
self.hash_index[pdf_path] = {
|
| 79 |
"hash": file_hash,
|
| 80 |
"processed_at": self.hasher.get_current_timestamp()
|
| 81 |
}
|
| 82 |
|
| 83 |
def parse_document(self, file_path: str) -> str | None:
|
| 84 |
-
|
| 85 |
if not os.path.exists(file_path):
|
| 86 |
return None
|
| 87 |
filename = os.path.basename(file_path)
|
| 88 |
try:
|
| 89 |
-
#
|
| 90 |
signal.signal(signal.SIGALRM, lambda s, f: (_ for _ in ()).throw(TimeoutError()))
|
| 91 |
signal.alarm(self.timeout)
|
| 92 |
|
|
@@ -95,22 +95,19 @@ class DoclingProcessor:
|
|
| 95 |
signal.alarm(0)
|
| 96 |
|
| 97 |
md = self.clean_markdown(md)
|
| 98 |
-
#
|
| 99 |
return f"---\nfilename: {filename}\nfilepath: {file_path}\npage_count: {len(result.document.pages)}\nprocessed_at: {datetime.now().isoformat()}\n---\n\n{md}"
|
| 100 |
except TimeoutError:
|
| 101 |
self.logger.warning(f"Timeout: {filename}")
|
| 102 |
signal.alarm(0)
|
| 103 |
return None
|
| 104 |
except Exception as e:
|
| 105 |
-
self.logger.error(f"
|
| 106 |
signal.alarm(0)
|
| 107 |
return None
|
| 108 |
|
| 109 |
def parse_directory(self, source_dir: str) -> dict:
|
| 110 |
-
"
|
| 111 |
-
source_path = Path(source_dir)
|
| 112 |
-
pdf_files = list(source_path.rglob("*.pdf"))
|
| 113 |
-
self.logger.info(f"Tìm thấy {len(pdf_files)} file PDF trong {source_dir}")
|
| 114 |
|
| 115 |
results = {"total": len(pdf_files), "parsed": 0, "skipped": 0, "errors": 0}
|
| 116 |
|
|
@@ -124,31 +121,31 @@ class DoclingProcessor:
|
|
| 124 |
|
| 125 |
pdf_path = str(fp)
|
| 126 |
|
| 127 |
-
#
|
| 128 |
if not self._should_process(pdf_path, out):
|
| 129 |
results["skipped"] += 1
|
| 130 |
continue
|
| 131 |
|
| 132 |
-
#
|
| 133 |
file_hash = self.hasher.get_file_hash(pdf_path)
|
| 134 |
|
| 135 |
md = self.parse_document(pdf_path)
|
| 136 |
if md:
|
| 137 |
out.write_text(md, encoding="utf-8")
|
| 138 |
results["parsed"] += 1
|
| 139 |
-
#
|
| 140 |
if file_hash:
|
| 141 |
self._save_hash(pdf_path, file_hash)
|
| 142 |
else:
|
| 143 |
results["errors"] += 1
|
| 144 |
|
| 145 |
-
#
|
| 146 |
if (i + 1) % 10 == 0:
|
| 147 |
gc.collect()
|
| 148 |
-
self.logger.info(f"{i+1}/{len(pdf_files)} (
|
| 149 |
|
| 150 |
-
#
|
| 151 |
self.hasher.save_processed_index(str(self.hash_index_path), self.hash_index)
|
| 152 |
|
| 153 |
-
self.logger.info(f"
|
| 154 |
return results
|
|
|
|
| 13 |
from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend
|
| 14 |
from docling.pipeline.standard_pdf_pipeline import StandardPdfPipeline
|
| 15 |
|
| 16 |
+
# Add project root to path for HashProcessor import
|
| 17 |
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
| 18 |
if str(PROJECT_ROOT) not in sys.path:
|
| 19 |
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class DoclingProcessor:
|
| 25 |
+
|
| 26 |
|
| 27 |
def __init__(self, output_dir: str, use_ocr: bool = True, timeout: int = 300, images_scale: float = 3.0):
|
| 28 |
+
|
| 29 |
self.output_dir = output_dir
|
| 30 |
self.timeout = timeout
|
| 31 |
self.logger = logging.getLogger(__name__)
|
| 32 |
self.hasher = HashProcessor(verbose=False)
|
| 33 |
os.makedirs(output_dir, exist_ok=True)
|
| 34 |
|
| 35 |
+
# Hash index file
|
| 36 |
self.hash_index_path = Path(output_dir) / "docling_hash_index.json"
|
| 37 |
self.hash_index = self.hasher.load_processed_index(str(self.hash_index_path))
|
| 38 |
|
| 39 |
+
# PDF pipeline configuration
|
| 40 |
opts = PdfPipelineOptions(do_ocr=use_ocr, do_table_structure=True)
|
| 41 |
opts.table_structure_options = TableStructureOptions(do_cell_matching=True, mode=TableFormerMode.ACCURATE)
|
| 42 |
opts.images_scale = images_scale
|
| 43 |
|
| 44 |
+
# Vietnamese OCR configuration
|
| 45 |
if use_ocr:
|
| 46 |
ocr = EasyOcrOptions()
|
| 47 |
ocr.lang = ["vi"]
|
|
|
|
| 54 |
self.logger.info(f"Docling | OCR={use_ocr} | Table=accurate | Scale={images_scale} | timeout={timeout}s")
|
| 55 |
|
| 56 |
def clean_markdown(self, text: str) -> str:
|
| 57 |
+
|
| 58 |
text = re.sub(r'\n\s*Trang\s+\d+\s*\n', '\n', text)
|
| 59 |
return re.sub(r'\n{3,}', '\n\n', text).strip()
|
| 60 |
|
| 61 |
def _should_process(self, pdf_path: str, output_path: Path) -> bool:
|
| 62 |
+
|
| 63 |
+
# If output doesn't exist -> needs processing
|
| 64 |
if not output_path.exists():
|
| 65 |
return True
|
| 66 |
|
| 67 |
+
# Compute hash of current PDF file
|
| 68 |
current_hash = self.hasher.get_file_hash(pdf_path)
|
| 69 |
if not current_hash:
|
| 70 |
return True
|
| 71 |
|
| 72 |
+
# Compare with saved hash
|
| 73 |
saved_hash = self.hash_index.get(pdf_path, {}).get("hash")
|
| 74 |
return current_hash != saved_hash
|
| 75 |
|
| 76 |
def _save_hash(self, pdf_path: str, file_hash: str) -> None:
|
| 77 |
+
|
| 78 |
self.hash_index[pdf_path] = {
|
| 79 |
"hash": file_hash,
|
| 80 |
"processed_at": self.hasher.get_current_timestamp()
|
| 81 |
}
|
| 82 |
|
| 83 |
def parse_document(self, file_path: str) -> str | None:
|
| 84 |
+
|
| 85 |
if not os.path.exists(file_path):
|
| 86 |
return None
|
| 87 |
filename = os.path.basename(file_path)
|
| 88 |
try:
|
| 89 |
+
# Set timeout to prevent hanging
|
| 90 |
signal.signal(signal.SIGALRM, lambda s, f: (_ for _ in ()).throw(TimeoutError()))
|
| 91 |
signal.alarm(self.timeout)
|
| 92 |
|
|
|
|
| 95 |
signal.alarm(0)
|
| 96 |
|
| 97 |
md = self.clean_markdown(md)
|
| 98 |
+
# Add frontmatter metadata
|
| 99 |
return f"---\nfilename: {filename}\nfilepath: {file_path}\npage_count: {len(result.document.pages)}\nprocessed_at: {datetime.now().isoformat()}\n---\n\n{md}"
|
| 100 |
except TimeoutError:
|
| 101 |
self.logger.warning(f"Timeout: {filename}")
|
| 102 |
signal.alarm(0)
|
| 103 |
return None
|
| 104 |
except Exception as e:
|
| 105 |
+
self.logger.error(f"Error: {filename}: {e}")
|
| 106 |
signal.alarm(0)
|
| 107 |
return None
|
| 108 |
|
| 109 |
def parse_directory(self, source_dir: str) -> dict:
|
| 110 |
+
self.logger.info(f"Found {len(pdf_files)} PDF files in {source_dir}")
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
results = {"total": len(pdf_files), "parsed": 0, "skipped": 0, "errors": 0}
|
| 113 |
|
|
|
|
| 121 |
|
| 122 |
pdf_path = str(fp)
|
| 123 |
|
| 124 |
+
# Check hash to decide if processing is needed
|
| 125 |
if not self._should_process(pdf_path, out):
|
| 126 |
results["skipped"] += 1
|
| 127 |
continue
|
| 128 |
|
| 129 |
+
# Compute hash before processing
|
| 130 |
file_hash = self.hasher.get_file_hash(pdf_path)
|
| 131 |
|
| 132 |
md = self.parse_document(pdf_path)
|
| 133 |
if md:
|
| 134 |
out.write_text(md, encoding="utf-8")
|
| 135 |
results["parsed"] += 1
|
| 136 |
+
# Save hash after successful processing
|
| 137 |
if file_hash:
|
| 138 |
self._save_hash(pdf_path, file_hash)
|
| 139 |
else:
|
| 140 |
results["errors"] += 1
|
| 141 |
|
| 142 |
+
# Clean up memory every 10 files
|
| 143 |
if (i + 1) % 10 == 0:
|
| 144 |
gc.collect()
|
| 145 |
+
self.logger.info(f"{i+1}/{len(pdf_files)} (skipped: {results['skipped']})")
|
| 146 |
|
| 147 |
+
# Save hash index after processing
|
| 148 |
self.hasher.save_processed_index(str(self.hash_index_path), self.hash_index)
|
| 149 |
|
| 150 |
+
self.logger.info(f"Done: {results['parsed']} processed, {results['skipped']} skipped, {results['errors']} errors")
|
| 151 |
return results
|
core/preprocessing/pdf_parser.py
CHANGED
|
@@ -1,22 +1,22 @@
|
|
| 1 |
from docling_processor import DoclingProcessor
|
| 2 |
|
| 3 |
-
#
|
| 4 |
-
PDF_FILE = "" #
|
| 5 |
-
SOURCE_DIR = "data/data_raw" #
|
| 6 |
-
OUTPUT_DIR = "data" #
|
| 7 |
-
USE_OCR = False #
|
| 8 |
|
| 9 |
|
| 10 |
if __name__ == "__main__":
|
| 11 |
processor = DoclingProcessor(OUTPUT_DIR, use_ocr=USE_OCR)
|
| 12 |
|
| 13 |
if PDF_FILE:
|
| 14 |
-
# Parse
|
| 15 |
-
print(f"
|
| 16 |
result = processor.parse_document(PDF_FILE)
|
| 17 |
-
print("
|
| 18 |
else:
|
| 19 |
-
# Parse
|
| 20 |
-
print(f"
|
| 21 |
r = processor.parse_directory(SOURCE_DIR)
|
| 22 |
-
print(f"
|
|
|
|
| 1 |
from docling_processor import DoclingProcessor
|
| 2 |
|
| 3 |
+
# Configuration
|
| 4 |
+
PDF_FILE = "" # Single file (leave empty to parse entire directory)
|
| 5 |
+
SOURCE_DIR = "data/data_raw" # Directory containing PDFs
|
| 6 |
+
OUTPUT_DIR = "data" # Markdown output directory
|
| 7 |
+
USE_OCR = False # Enable OCR for scanned PDFs
|
| 8 |
|
| 9 |
|
| 10 |
if __name__ == "__main__":
|
| 11 |
processor = DoclingProcessor(OUTPUT_DIR, use_ocr=USE_OCR)
|
| 12 |
|
| 13 |
if PDF_FILE:
|
| 14 |
+
# Parse a single file
|
| 15 |
+
print(f"Processing: {PDF_FILE}")
|
| 16 |
result = processor.parse_document(PDF_FILE)
|
| 17 |
+
print("Done!" if result else "Error or skipped")
|
| 18 |
else:
|
| 19 |
+
# Parse entire directory
|
| 20 |
+
print(f"Processing directory: {SOURCE_DIR}")
|
| 21 |
r = processor.parse_directory(SOURCE_DIR)
|
| 22 |
+
print(f"Total: {r['total']} | Success: {r['parsed']} | Skipped: {r['skipped']} | Errors: {r['errors']}")
|
core/rag/chunk.py
CHANGED
|
@@ -10,13 +10,13 @@ from llama_index.core import Document
|
|
| 10 |
from llama_index.core.node_parser import MarkdownNodeParser, SentenceSplitter
|
| 11 |
from llama_index.core.schema import BaseNode, TextNode
|
| 12 |
|
| 13 |
-
#
|
| 14 |
CHUNK_SIZE = 1500
|
| 15 |
CHUNK_OVERLAP = 150
|
| 16 |
MIN_CHUNK_SIZE = 200
|
| 17 |
TABLE_ROWS_PER_CHUNK = 15
|
| 18 |
|
| 19 |
-
#
|
| 20 |
ENABLE_TABLE_SUMMARY = True
|
| 21 |
MIN_TABLE_ROWS_FOR_SUMMARY = 0
|
| 22 |
SUMMARY_MODEL = "openai/gpt-oss-120b"
|
|
@@ -31,20 +31,20 @@ TABLE_TITLE_PATTERN = re.compile(r"(?:^|\n)#+\s*(?:Bảng|BẢNG)\s*(\d+(?:\.\d+
|
|
| 31 |
|
| 32 |
|
| 33 |
def _is_table_row(line: str) -> bool:
|
| 34 |
-
|
| 35 |
s = line.strip()
|
| 36 |
return s.startswith("|") and s.endswith("|") and s.count("|") >= 2
|
| 37 |
|
| 38 |
|
| 39 |
def _is_separator(line: str) -> bool:
|
| 40 |
-
|
| 41 |
if not _is_table_row(line):
|
| 42 |
return False
|
| 43 |
return not line.strip().replace("|", "").replace("-", "").replace(":", "").replace(" ", "")
|
| 44 |
|
| 45 |
|
| 46 |
def _is_header(line: str) -> bool:
|
| 47 |
-
|
| 48 |
if not _is_table_row(line):
|
| 49 |
return False
|
| 50 |
cells = [c.strip() for c in line.split("|") if c.strip()]
|
|
@@ -54,7 +54,7 @@ def _is_header(line: str) -> bool:
|
|
| 54 |
|
| 55 |
|
| 56 |
def _extract_tables(text: str) -> Tuple[List[Tuple[str, List[str]]], str]:
|
| 57 |
-
|
| 58 |
lines, tables, last_header, i = text.split("\n"), [], None, 0
|
| 59 |
|
| 60 |
while i < len(lines) - 1:
|
|
@@ -78,7 +78,7 @@ def _extract_tables(text: str) -> Tuple[List[Tuple[str, List[str]]], str]:
|
|
| 78 |
else:
|
| 79 |
i += 1
|
| 80 |
|
| 81 |
-
#
|
| 82 |
result, tbl_idx, i = [], 0, 0
|
| 83 |
while i < len(lines):
|
| 84 |
if tbl_idx < len(tables) and i < len(lines) - 1 and _is_table_row(lines[i]) and _is_separator(lines[i + 1]):
|
|
@@ -95,7 +95,7 @@ def _extract_tables(text: str) -> Tuple[List[Tuple[str, List[str]]], str]:
|
|
| 95 |
|
| 96 |
|
| 97 |
def _split_table(header: str, rows: List[str], max_rows: int = TABLE_ROWS_PER_CHUNK) -> List[str]:
|
| 98 |
-
|
| 99 |
if len(rows) <= max_rows:
|
| 100 |
return [header + "\n".join(rows)]
|
| 101 |
|
|
@@ -104,7 +104,7 @@ def _split_table(header: str, rows: List[str], max_rows: int = TABLE_ROWS_PER_CH
|
|
| 104 |
chunk_rows = rows[i:i + max_rows]
|
| 105 |
chunks.append(chunk_rows)
|
| 106 |
|
| 107 |
-
#
|
| 108 |
if len(chunks) > 1 and len(chunks[-1]) < 5:
|
| 109 |
chunks[-2].extend(chunks[-1])
|
| 110 |
chunks.pop()
|
|
@@ -116,14 +116,14 @@ _summary_client: Optional[OpenAI] = None
|
|
| 116 |
|
| 117 |
|
| 118 |
def _get_summary_client() -> Optional[OpenAI]:
|
| 119 |
-
|
| 120 |
global _summary_client
|
| 121 |
if _summary_client is not None:
|
| 122 |
return _summary_client
|
| 123 |
|
| 124 |
api_key = os.getenv("GROQ_API_KEY", "").strip()
|
| 125 |
if not api_key:
|
| 126 |
-
print("
|
| 127 |
return None
|
| 128 |
|
| 129 |
_summary_client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL)
|
|
@@ -139,17 +139,17 @@ def _summarize_table(
|
|
| 139 |
max_retries: int = 5,
|
| 140 |
base_delay: float = 2.0
|
| 141 |
) -> str:
|
| 142 |
-
|
| 143 |
import time
|
| 144 |
|
| 145 |
if not ENABLE_TABLE_SUMMARY:
|
| 146 |
-
raise RuntimeError("
|
| 147 |
|
| 148 |
client = _get_summary_client()
|
| 149 |
if client is None:
|
| 150 |
-
raise RuntimeError("
|
| 151 |
|
| 152 |
-
#
|
| 153 |
table_id_parts = []
|
| 154 |
if table_number:
|
| 155 |
table_id_parts.append(f"Bảng {table_number}")
|
|
@@ -188,17 +188,17 @@ Bảng:
|
|
| 188 |
if summary.strip():
|
| 189 |
return summary.strip()
|
| 190 |
else:
|
| 191 |
-
raise ValueError("API
|
| 192 |
|
| 193 |
except Exception as e:
|
| 194 |
last_error = e
|
| 195 |
delay = base_delay * (2 ** attempt) # Exponential backoff: 2, 4, 8, 16, 32 giây
|
| 196 |
-
print(f"
|
| 197 |
-
print(f"
|
| 198 |
time.sleep(delay)
|
| 199 |
|
| 200 |
-
#
|
| 201 |
-
raise RuntimeError(f"
|
| 202 |
|
| 203 |
|
| 204 |
def _create_table_nodes(
|
|
@@ -209,11 +209,11 @@ def _create_table_nodes(
|
|
| 209 |
table_title: str = "",
|
| 210 |
source_file: str = ""
|
| 211 |
) -> List[TextNode]:
|
| 212 |
-
|
| 213 |
-
#
|
| 214 |
row_count = table_text.count("\n")
|
| 215 |
|
| 216 |
-
#
|
| 217 |
table_meta = {**metadata}
|
| 218 |
if table_number:
|
| 219 |
table_meta["table_number"] = table_number
|
|
@@ -221,15 +221,15 @@ def _create_table_nodes(
|
|
| 221 |
table_meta["table_title"] = table_title
|
| 222 |
|
| 223 |
if row_count < MIN_TABLE_ROWS_FOR_SUMMARY:
|
| 224 |
-
#
|
| 225 |
return [TextNode(text=table_text, metadata={**table_meta, "is_table": True})]
|
| 226 |
|
| 227 |
-
#
|
| 228 |
if _get_summary_client() is None:
|
| 229 |
-
#
|
| 230 |
return [TextNode(text=table_text, metadata={**table_meta, "is_table": True})]
|
| 231 |
|
| 232 |
-
#
|
| 233 |
summary = _summarize_table(
|
| 234 |
table_text,
|
| 235 |
context_hint,
|
|
@@ -238,36 +238,36 @@ def _create_table_nodes(
|
|
| 238 |
source_file=source_file
|
| 239 |
)
|
| 240 |
|
| 241 |
-
#
|
| 242 |
parent_id = str(uuid.uuid4())
|
| 243 |
parent_node = TextNode(
|
| 244 |
text=table_text,
|
| 245 |
metadata={
|
| 246 |
**table_meta,
|
| 247 |
"is_table": True,
|
| 248 |
-
"is_parent": True, # Flag
|
| 249 |
"node_id": parent_id,
|
| 250 |
}
|
| 251 |
)
|
| 252 |
parent_node.id_ = parent_id
|
| 253 |
|
| 254 |
-
#
|
| 255 |
summary_node = TextNode(
|
| 256 |
text=summary,
|
| 257 |
metadata={
|
| 258 |
**table_meta,
|
| 259 |
"is_table_summary": True,
|
| 260 |
-
"parent_id": parent_id, # Link
|
| 261 |
}
|
| 262 |
)
|
| 263 |
|
| 264 |
-
table_id = f"
|
| 265 |
-
print(f"
|
| 266 |
return [parent_node, summary_node]
|
| 267 |
|
| 268 |
|
| 269 |
def _enrich_metadata(node: BaseNode, source_path: Path | None) -> None:
|
| 270 |
-
|
| 271 |
if source_path:
|
| 272 |
node.metadata.update({"source_path": str(source_path), "source_file": source_path.name})
|
| 273 |
if "Học phần" in (text := node.get_content()) and (m := COURSE_PATTERN.search(text)):
|
|
@@ -275,7 +275,7 @@ def _enrich_metadata(node: BaseNode, source_path: Path | None) -> None:
|
|
| 275 |
|
| 276 |
|
| 277 |
def _chunk_text(text: str, metadata: dict) -> List[BaseNode]:
|
| 278 |
-
|
| 279 |
if len(text) <= CHUNK_SIZE:
|
| 280 |
return [TextNode(text=text, metadata=metadata.copy())]
|
| 281 |
return SentenceSplitter(chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP).get_nodes_from_documents(
|
|
@@ -284,7 +284,7 @@ def _chunk_text(text: str, metadata: dict) -> List[BaseNode]:
|
|
| 284 |
|
| 285 |
|
| 286 |
def _extract_frontmatter(text: str) -> Tuple[Dict[str, Any], str]:
|
| 287 |
-
|
| 288 |
match = FRONTMATTER_PATTERN.match(text)
|
| 289 |
if not match:
|
| 290 |
return {}, text
|
|
@@ -298,23 +298,23 @@ def _extract_frontmatter(text: str) -> Tuple[Dict[str, Any], str]:
|
|
| 298 |
|
| 299 |
|
| 300 |
def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[BaseNode]:
|
| 301 |
-
|
| 302 |
if not text or not text.strip():
|
| 303 |
return []
|
| 304 |
|
| 305 |
path = Path(source_path) if source_path else None
|
| 306 |
|
| 307 |
-
#
|
| 308 |
frontmatter_meta, text = _extract_frontmatter(text)
|
| 309 |
|
| 310 |
tables, text_with_placeholders = _extract_tables(text)
|
| 311 |
|
| 312 |
-
#
|
| 313 |
base_meta = {**frontmatter_meta}
|
| 314 |
if path:
|
| 315 |
base_meta.update({"source_path": str(path), "source_file": path.name})
|
| 316 |
|
| 317 |
-
# Parse
|
| 318 |
doc = Document(text=text_with_placeholders, metadata=base_meta.copy())
|
| 319 |
heading_nodes = MarkdownNodeParser().get_nodes_from_documents([doc])
|
| 320 |
|
|
@@ -329,10 +329,10 @@ def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[Bas
|
|
| 329 |
|
| 330 |
last_end = 0
|
| 331 |
for match in matches:
|
| 332 |
-
# Text
|
| 333 |
before_text = content[last_end:match.start()].strip()
|
| 334 |
|
| 335 |
-
#
|
| 336 |
table_number = ""
|
| 337 |
table_title = ""
|
| 338 |
if before_text:
|
|
@@ -344,15 +344,15 @@ def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[Bas
|
|
| 344 |
if before_text and len(before_text) >= MIN_CHUNK_SIZE:
|
| 345 |
nodes.extend(_chunk_text(before_text, meta) if len(before_text) > CHUNK_SIZE else [TextNode(text=before_text, metadata=meta.copy())])
|
| 346 |
|
| 347 |
-
# Chunk
|
| 348 |
if (idx := int(match.group(1))) < len(tables):
|
| 349 |
header, rows = tables[idx]
|
| 350 |
table_chunks = _split_table(header, rows)
|
| 351 |
|
| 352 |
-
#
|
| 353 |
context_hint = meta.get("Header 1", "") or meta.get("section", "")
|
| 354 |
|
| 355 |
-
#
|
| 356 |
source_file = meta.get("source_file", "") or (path.name if path else "")
|
| 357 |
|
| 358 |
for i, chunk in enumerate(table_chunks):
|
|
@@ -360,7 +360,7 @@ def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[Bas
|
|
| 360 |
if len(table_chunks) > 1:
|
| 361 |
chunk_meta["table_part"] = f"{i+1}/{len(table_chunks)}"
|
| 362 |
|
| 363 |
-
#
|
| 364 |
table_nodes = _create_table_nodes(
|
| 365 |
chunk,
|
| 366 |
chunk_meta,
|
|
@@ -373,11 +373,11 @@ def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[Bas
|
|
| 373 |
|
| 374 |
last_end = match.end()
|
| 375 |
|
| 376 |
-
# Text
|
| 377 |
if (after := content[last_end:].strip()) and len(after) >= MIN_CHUNK_SIZE:
|
| 378 |
nodes.extend(_chunk_text(after, meta) if len(after) > CHUNK_SIZE else [TextNode(text=after, metadata=meta.copy())])
|
| 379 |
|
| 380 |
-
#
|
| 381 |
final: List[BaseNode] = []
|
| 382 |
i = 0
|
| 383 |
while i < len(nodes):
|
|
@@ -385,12 +385,12 @@ def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[Bas
|
|
| 385 |
curr_content = curr.get_content()
|
| 386 |
curr_is_table = curr.metadata.get("is_table")
|
| 387 |
|
| 388 |
-
#
|
| 389 |
if not curr_content.strip():
|
| 390 |
i += 1
|
| 391 |
continue
|
| 392 |
|
| 393 |
-
#
|
| 394 |
if not curr_is_table and len(curr_content) < MIN_CHUNK_SIZE and i + 1 < len(nodes):
|
| 395 |
next_node = nodes[i + 1]
|
| 396 |
next_is_table = next_node.metadata.get("is_table")
|
|
@@ -417,8 +417,8 @@ def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[Bas
|
|
| 417 |
|
| 418 |
|
| 419 |
def chunk_markdown_file(path: str | Path) -> List[BaseNode]:
|
| 420 |
-
|
| 421 |
p = Path(path)
|
| 422 |
if not p.exists():
|
| 423 |
-
raise FileNotFoundError(f"
|
| 424 |
return chunk_markdown(p.read_text(encoding="utf-8"), source_path=p)
|
|
|
|
| 10 |
from llama_index.core.node_parser import MarkdownNodeParser, SentenceSplitter
|
| 11 |
from llama_index.core.schema import BaseNode, TextNode
|
| 12 |
|
| 13 |
+
# Chunking configuration
|
| 14 |
CHUNK_SIZE = 1500
|
| 15 |
CHUNK_OVERLAP = 150
|
| 16 |
MIN_CHUNK_SIZE = 200
|
| 17 |
TABLE_ROWS_PER_CHUNK = 15
|
| 18 |
|
| 19 |
+
# Small-to-Big configuration
|
| 20 |
ENABLE_TABLE_SUMMARY = True
|
| 21 |
MIN_TABLE_ROWS_FOR_SUMMARY = 0
|
| 22 |
SUMMARY_MODEL = "openai/gpt-oss-120b"
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def _is_table_row(line: str) -> bool:
|
| 34 |
+
|
| 35 |
s = line.strip()
|
| 36 |
return s.startswith("|") and s.endswith("|") and s.count("|") >= 2
|
| 37 |
|
| 38 |
|
| 39 |
def _is_separator(line: str) -> bool:
|
| 40 |
+
|
| 41 |
if not _is_table_row(line):
|
| 42 |
return False
|
| 43 |
return not line.strip().replace("|", "").replace("-", "").replace(":", "").replace(" ", "")
|
| 44 |
|
| 45 |
|
| 46 |
def _is_header(line: str) -> bool:
|
| 47 |
+
|
| 48 |
if not _is_table_row(line):
|
| 49 |
return False
|
| 50 |
cells = [c.strip() for c in line.split("|") if c.strip()]
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def _extract_tables(text: str) -> Tuple[List[Tuple[str, List[str]]], str]:
|
| 57 |
+
|
| 58 |
lines, tables, last_header, i = text.split("\n"), [], None, 0
|
| 59 |
|
| 60 |
while i < len(lines) - 1:
|
|
|
|
| 78 |
else:
|
| 79 |
i += 1
|
| 80 |
|
| 81 |
+
# Replace tables with placeholders
|
| 82 |
result, tbl_idx, i = [], 0, 0
|
| 83 |
while i < len(lines):
|
| 84 |
if tbl_idx < len(tables) and i < len(lines) - 1 and _is_table_row(lines[i]) and _is_separator(lines[i + 1]):
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
def _split_table(header: str, rows: List[str], max_rows: int = TABLE_ROWS_PER_CHUNK) -> List[str]:
|
| 98 |
+
|
| 99 |
if len(rows) <= max_rows:
|
| 100 |
return [header + "\n".join(rows)]
|
| 101 |
|
|
|
|
| 104 |
chunk_rows = rows[i:i + max_rows]
|
| 105 |
chunks.append(chunk_rows)
|
| 106 |
|
| 107 |
+
# Merge last chunk if too small (< 5 rows)
|
| 108 |
if len(chunks) > 1 and len(chunks[-1]) < 5:
|
| 109 |
chunks[-2].extend(chunks[-1])
|
| 110 |
chunks.pop()
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
def _get_summary_client() -> Optional[OpenAI]:
|
| 119 |
+
|
| 120 |
global _summary_client
|
| 121 |
if _summary_client is not None:
|
| 122 |
return _summary_client
|
| 123 |
|
| 124 |
api_key = os.getenv("GROQ_API_KEY", "").strip()
|
| 125 |
if not api_key:
|
| 126 |
+
print("GROQ_API_KEY not set. Table summarization disabled.")
|
| 127 |
return None
|
| 128 |
|
| 129 |
_summary_client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL)
|
|
|
|
| 139 |
max_retries: int = 5,
|
| 140 |
base_delay: float = 2.0
|
| 141 |
) -> str:
|
| 142 |
+
|
| 143 |
import time
|
| 144 |
|
| 145 |
if not ENABLE_TABLE_SUMMARY:
|
| 146 |
+
raise RuntimeError("Table summarization is disabled. Set ENABLE_TABLE_SUMMARY = True")
|
| 147 |
|
| 148 |
client = _get_summary_client()
|
| 149 |
if client is None:
|
| 150 |
+
raise RuntimeError("GROQ_API_KEY not set. Cannot summarize table.")
|
| 151 |
|
| 152 |
+
# Build table identifier string
|
| 153 |
table_id_parts = []
|
| 154 |
if table_number:
|
| 155 |
table_id_parts.append(f"Bảng {table_number}")
|
|
|
|
| 188 |
if summary.strip():
|
| 189 |
return summary.strip()
|
| 190 |
else:
|
| 191 |
+
raise ValueError("API returned empty summary")
|
| 192 |
|
| 193 |
except Exception as e:
|
| 194 |
last_error = e
|
| 195 |
delay = base_delay * (2 ** attempt) # Exponential backoff: 2, 4, 8, 16, 32 giây
|
| 196 |
+
print(f"Retry {attempt + 1}/{max_retries} for {table_identifier}: {e}")
|
| 197 |
+
print(f" Waiting {delay:.1f}s before retry...")
|
| 198 |
time.sleep(delay)
|
| 199 |
|
| 200 |
+
# All retries failed
|
| 201 |
+
raise RuntimeError(f"Failed to summarize {table_identifier} after {max_retries} attempts. Last error: {last_error}")
|
| 202 |
|
| 203 |
|
| 204 |
def _create_table_nodes(
|
|
|
|
| 209 |
table_title: str = "",
|
| 210 |
source_file: str = ""
|
| 211 |
) -> List[TextNode]:
|
| 212 |
+
|
| 213 |
+
# Count rows to decide if summarization is needed
|
| 214 |
row_count = table_text.count("\n")
|
| 215 |
|
| 216 |
+
# Add table info to metadata
|
| 217 |
table_meta = {**metadata}
|
| 218 |
if table_number:
|
| 219 |
table_meta["table_number"] = table_number
|
|
|
|
| 221 |
table_meta["table_title"] = table_title
|
| 222 |
|
| 223 |
if row_count < MIN_TABLE_ROWS_FOR_SUMMARY:
|
| 224 |
+
# Table too small, no summary needed
|
| 225 |
return [TextNode(text=table_text, metadata={**table_meta, "is_table": True})]
|
| 226 |
|
| 227 |
+
# Check if summarization is possible (needs API key)
|
| 228 |
if _get_summary_client() is None:
|
| 229 |
+
# No API key -> return simple table node without summary
|
| 230 |
return [TextNode(text=table_text, metadata={**table_meta, "is_table": True})]
|
| 231 |
|
| 232 |
+
# Create summary with retry logic
|
| 233 |
summary = _summarize_table(
|
| 234 |
table_text,
|
| 235 |
context_hint,
|
|
|
|
| 238 |
source_file=source_file
|
| 239 |
)
|
| 240 |
|
| 241 |
+
# Create parent node (original table - NOT embedded)
|
| 242 |
parent_id = str(uuid.uuid4())
|
| 243 |
parent_node = TextNode(
|
| 244 |
text=table_text,
|
| 245 |
metadata={
|
| 246 |
**table_meta,
|
| 247 |
"is_table": True,
|
| 248 |
+
"is_parent": True, # Flag to skip embedding
|
| 249 |
"node_id": parent_id,
|
| 250 |
}
|
| 251 |
)
|
| 252 |
parent_node.id_ = parent_id
|
| 253 |
|
| 254 |
+
# Create summary node (WILL be embedded for search)
|
| 255 |
summary_node = TextNode(
|
| 256 |
text=summary,
|
| 257 |
metadata={
|
| 258 |
**table_meta,
|
| 259 |
"is_table_summary": True,
|
| 260 |
+
"parent_id": parent_id, # Link to parent
|
| 261 |
}
|
| 262 |
)
|
| 263 |
|
| 264 |
+
table_id = f"Table {table_number}" if table_number else "table"
|
| 265 |
+
print(f"Created summary for {table_id} ({row_count} rows)")
|
| 266 |
return [parent_node, summary_node]
|
| 267 |
|
| 268 |
|
| 269 |
def _enrich_metadata(node: BaseNode, source_path: Path | None) -> None:
|
| 270 |
+
|
| 271 |
if source_path:
|
| 272 |
node.metadata.update({"source_path": str(source_path), "source_file": source_path.name})
|
| 273 |
if "Học phần" in (text := node.get_content()) and (m := COURSE_PATTERN.search(text)):
|
|
|
|
| 275 |
|
| 276 |
|
| 277 |
def _chunk_text(text: str, metadata: dict) -> List[BaseNode]:
|
| 278 |
+
|
| 279 |
if len(text) <= CHUNK_SIZE:
|
| 280 |
return [TextNode(text=text, metadata=metadata.copy())]
|
| 281 |
return SentenceSplitter(chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP).get_nodes_from_documents(
|
|
|
|
| 284 |
|
| 285 |
|
| 286 |
def _extract_frontmatter(text: str) -> Tuple[Dict[str, Any], str]:
|
| 287 |
+
|
| 288 |
match = FRONTMATTER_PATTERN.match(text)
|
| 289 |
if not match:
|
| 290 |
return {}, text
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
def chunk_markdown(text: str, source_path: str | Path | None = None) -> List[BaseNode]:
|
| 301 |
+
|
| 302 |
if not text or not text.strip():
|
| 303 |
return []
|
| 304 |
|
| 305 |
path = Path(source_path) if source_path else None
|
| 306 |
|
| 307 |
+
# Extract YAML frontmatter as metadata (not chunked)
|
| 308 |
frontmatter_meta, text = _extract_frontmatter(text)
|
| 309 |
|
| 310 |
tables, text_with_placeholders = _extract_tables(text)
|
| 311 |
|
| 312 |
+
# Base metadata from frontmatter + source path
|
| 313 |
base_meta = {**frontmatter_meta}
|
| 314 |
if path:
|
| 315 |
base_meta.update({"source_path": str(path), "source_file": path.name})
|
| 316 |
|
| 317 |
+
# Parse by headings
|
| 318 |
doc = Document(text=text_with_placeholders, metadata=base_meta.copy())
|
| 319 |
heading_nodes = MarkdownNodeParser().get_nodes_from_documents([doc])
|
| 320 |
|
|
|
|
| 329 |
|
| 330 |
last_end = 0
|
| 331 |
for match in matches:
|
| 332 |
+
# Text before table
|
| 333 |
before_text = content[last_end:match.start()].strip()
|
| 334 |
|
| 335 |
+
# Extract table number and title from text before table
|
| 336 |
table_number = ""
|
| 337 |
table_title = ""
|
| 338 |
if before_text:
|
|
|
|
| 344 |
if before_text and len(before_text) >= MIN_CHUNK_SIZE:
|
| 345 |
nodes.extend(_chunk_text(before_text, meta) if len(before_text) > CHUNK_SIZE else [TextNode(text=before_text, metadata=meta.copy())])
|
| 346 |
|
| 347 |
+
# Chunk table - using Small-to-Big pattern
|
| 348 |
if (idx := int(match.group(1))) < len(tables):
|
| 349 |
header, rows = tables[idx]
|
| 350 |
table_chunks = _split_table(header, rows)
|
| 351 |
|
| 352 |
+
# Get context hint from header path
|
| 353 |
context_hint = meta.get("Header 1", "") or meta.get("section", "")
|
| 354 |
|
| 355 |
+
# Get source file for summary
|
| 356 |
source_file = meta.get("source_file", "") or (path.name if path else "")
|
| 357 |
|
| 358 |
for i, chunk in enumerate(table_chunks):
|
|
|
|
| 360 |
if len(table_chunks) > 1:
|
| 361 |
chunk_meta["table_part"] = f"{i+1}/{len(table_chunks)}"
|
| 362 |
|
| 363 |
+
# Create parent + summary nodes if needed
|
| 364 |
table_nodes = _create_table_nodes(
|
| 365 |
chunk,
|
| 366 |
chunk_meta,
|
|
|
|
| 373 |
|
| 374 |
last_end = match.end()
|
| 375 |
|
| 376 |
+
# Text after table
|
| 377 |
if (after := content[last_end:].strip()) and len(after) >= MIN_CHUNK_SIZE:
|
| 378 |
nodes.extend(_chunk_text(after, meta) if len(after) > CHUNK_SIZE else [TextNode(text=after, metadata=meta.copy())])
|
| 379 |
|
| 380 |
+
# Merge small nodes with next node
|
| 381 |
final: List[BaseNode] = []
|
| 382 |
i = 0
|
| 383 |
while i < len(nodes):
|
|
|
|
| 385 |
curr_content = curr.get_content()
|
| 386 |
curr_is_table = curr.metadata.get("is_table")
|
| 387 |
|
| 388 |
+
# Skip empty nodes
|
| 389 |
if not curr_content.strip():
|
| 390 |
i += 1
|
| 391 |
continue
|
| 392 |
|
| 393 |
+
# If current node is small and not a table -> merge with next
|
| 394 |
if not curr_is_table and len(curr_content) < MIN_CHUNK_SIZE and i + 1 < len(nodes):
|
| 395 |
next_node = nodes[i + 1]
|
| 396 |
next_is_table = next_node.metadata.get("is_table")
|
|
|
|
| 417 |
|
| 418 |
|
| 419 |
def chunk_markdown_file(path: str | Path) -> List[BaseNode]:
|
| 420 |
+
|
| 421 |
p = Path(path)
|
| 422 |
if not p.exists():
|
| 423 |
+
raise FileNotFoundError(f"File not found: {p}")
|
| 424 |
return chunk_markdown(p.read_text(encoding="utf-8"), source_path=p)
|
core/rag/embedding_model.py
CHANGED
|
@@ -13,18 +13,17 @@ logger = logging.getLogger(__name__)
|
|
| 13 |
|
| 14 |
@dataclass
|
| 15 |
class EmbeddingConfig:
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
batch_size: int = 16 # Số text mỗi batch
|
| 21 |
|
| 22 |
|
| 23 |
_embed_config: EmbeddingConfig | None = None
|
| 24 |
|
| 25 |
|
| 26 |
def get_embedding_config() -> EmbeddingConfig:
|
| 27 |
-
|
| 28 |
global _embed_config
|
| 29 |
if _embed_config is None:
|
| 30 |
_embed_config = EmbeddingConfig()
|
|
@@ -32,32 +31,32 @@ def get_embedding_config() -> EmbeddingConfig:
|
|
| 32 |
|
| 33 |
|
| 34 |
class QwenEmbeddings(Embeddings):
|
| 35 |
-
|
| 36 |
|
| 37 |
def __init__(self, config: EmbeddingConfig | None = None):
|
| 38 |
-
|
| 39 |
self.config = config or get_embedding_config()
|
| 40 |
|
| 41 |
api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
| 42 |
if not api_key:
|
| 43 |
-
raise ValueError("
|
| 44 |
|
| 45 |
self._client = OpenAI(
|
| 46 |
api_key=api_key,
|
| 47 |
base_url=self.config.api_base_url,
|
| 48 |
)
|
| 49 |
-
logger.info(f"
|
| 50 |
|
| 51 |
def embed_query(self, text: str) -> List[float]:
|
| 52 |
-
|
| 53 |
return self._embed_texts([text])[0]
|
| 54 |
|
| 55 |
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
| 56 |
-
|
| 57 |
return self._embed_texts(texts)
|
| 58 |
|
| 59 |
def _embed_texts(self, texts: Sequence[str]) -> List[List[float]]:
|
| 60 |
-
|
| 61 |
if not texts:
|
| 62 |
return []
|
| 63 |
|
|
@@ -65,11 +64,11 @@ class QwenEmbeddings(Embeddings):
|
|
| 65 |
batch_size = self.config.batch_size
|
| 66 |
max_retries = 3
|
| 67 |
|
| 68 |
-
#
|
| 69 |
for i in range(0, len(texts), batch_size):
|
| 70 |
batch = list(texts[i:i + batch_size])
|
| 71 |
|
| 72 |
-
# Retry logic
|
| 73 |
for attempt in range(max_retries):
|
| 74 |
try:
|
| 75 |
response = self._client.embeddings.create(
|
|
@@ -80,10 +79,10 @@ class QwenEmbeddings(Embeddings):
|
|
| 80 |
all_embeddings.append(item.embedding)
|
| 81 |
break
|
| 82 |
except Exception as e:
|
| 83 |
-
#
|
| 84 |
if "rate" in str(e).lower() and attempt < max_retries - 1:
|
| 85 |
-
wait_time = 2 ** attempt
|
| 86 |
-
logger.warning(f"
|
| 87 |
time.sleep(wait_time)
|
| 88 |
else:
|
| 89 |
raise
|
|
@@ -91,10 +90,10 @@ class QwenEmbeddings(Embeddings):
|
|
| 91 |
return all_embeddings
|
| 92 |
|
| 93 |
def embed_texts_np(self, texts: Sequence[str]) -> np.ndarray:
|
| 94 |
-
|
| 95 |
return np.asarray(self._embed_texts(list(texts)), dtype=np.float32)
|
| 96 |
|
| 97 |
|
| 98 |
-
#
|
| 99 |
SiliconFlowConfig = EmbeddingConfig
|
| 100 |
get_config = get_embedding_config
|
|
|
|
| 13 |
|
| 14 |
@dataclass
|
| 15 |
class EmbeddingConfig:
|
| 16 |
+
api_base_url: str = "https://api.siliconflow.com/v1"
|
| 17 |
+
model: str = "Qwen/Qwen3-Embedding-4B"
|
| 18 |
+
dimension: int = 2048
|
| 19 |
+
batch_size: int = 16
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
_embed_config: EmbeddingConfig | None = None
|
| 23 |
|
| 24 |
|
| 25 |
def get_embedding_config() -> EmbeddingConfig:
|
| 26 |
+
|
| 27 |
global _embed_config
|
| 28 |
if _embed_config is None:
|
| 29 |
_embed_config = EmbeddingConfig()
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
class QwenEmbeddings(Embeddings):
|
| 34 |
+
|
| 35 |
|
| 36 |
def __init__(self, config: EmbeddingConfig | None = None):
|
| 37 |
+
|
| 38 |
self.config = config or get_embedding_config()
|
| 39 |
|
| 40 |
api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
| 41 |
if not api_key:
|
| 42 |
+
raise ValueError("Missing SILICONFLOW_API_KEY environment variable")
|
| 43 |
|
| 44 |
self._client = OpenAI(
|
| 45 |
api_key=api_key,
|
| 46 |
base_url=self.config.api_base_url,
|
| 47 |
)
|
| 48 |
+
logger.info(f"Initialized QwenEmbeddings: {self.config.model}")
|
| 49 |
|
| 50 |
def embed_query(self, text: str) -> List[float]:
|
| 51 |
+
|
| 52 |
return self._embed_texts([text])[0]
|
| 53 |
|
| 54 |
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
| 55 |
+
|
| 56 |
return self._embed_texts(texts)
|
| 57 |
|
| 58 |
def _embed_texts(self, texts: Sequence[str]) -> List[List[float]]:
|
| 59 |
+
|
| 60 |
if not texts:
|
| 61 |
return []
|
| 62 |
|
|
|
|
| 64 |
batch_size = self.config.batch_size
|
| 65 |
max_retries = 3
|
| 66 |
|
| 67 |
+
# Process in batches
|
| 68 |
for i in range(0, len(texts), batch_size):
|
| 69 |
batch = list(texts[i:i + batch_size])
|
| 70 |
|
| 71 |
+
# Retry logic for rate limits
|
| 72 |
for attempt in range(max_retries):
|
| 73 |
try:
|
| 74 |
response = self._client.embeddings.create(
|
|
|
|
| 79 |
all_embeddings.append(item.embedding)
|
| 80 |
break
|
| 81 |
except Exception as e:
|
| 82 |
+
# Rate limit -> wait and retry
|
| 83 |
if "rate" in str(e).lower() and attempt < max_retries - 1:
|
| 84 |
+
wait_time = 2 ** attempt
|
| 85 |
+
logger.warning(f"Rate limited, waiting {wait_time}s...")
|
| 86 |
time.sleep(wait_time)
|
| 87 |
else:
|
| 88 |
raise
|
|
|
|
| 90 |
return all_embeddings
|
| 91 |
|
| 92 |
def embed_texts_np(self, texts: Sequence[str]) -> np.ndarray:
|
| 93 |
+
|
| 94 |
return np.asarray(self._embed_texts(list(texts)), dtype=np.float32)
|
| 95 |
|
| 96 |
|
| 97 |
+
# Backward compatibility aliases
|
| 98 |
SiliconFlowConfig = EmbeddingConfig
|
| 99 |
get_config = get_embedding_config
|
core/rag/generator.py
CHANGED
|
@@ -2,21 +2,28 @@ from __future__ import annotations
|
|
| 2 |
from typing import Any, Dict, List, TYPE_CHECKING
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
-
from core.rag.
|
| 6 |
|
| 7 |
|
| 8 |
-
# System prompt
|
| 9 |
-
SYSTEM_PROMPT = """Bạn là Trợ lý học vụ Đại học Bách khoa Hà Nội.
|
| 10 |
|
| 11 |
## NGUYÊN TẮC:
|
| 12 |
-
1. Chỉ
|
| 13 |
-
2. Nếu CONTEXT chứa nhiều văn bản khác nhau, ưu tiên nội dung mới nhất, TRỪ KHI có điều khoản chuyển tiếp nói khác.
|
| 14 |
-
3.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
|
| 18 |
def build_context(results: List[Dict[str, Any]], max_chars: int = 8000) -> str:
|
| 19 |
-
|
| 20 |
parts = []
|
| 21 |
for i, r in enumerate(results, 1):
|
| 22 |
meta = r.get("metadata", {})
|
|
@@ -31,7 +38,7 @@ def build_context(results: List[Dict[str, Any]], max_chars: int = 8000) -> str:
|
|
| 31 |
issued_year = meta.get("issued_year", "")
|
| 32 |
content = r.get("content", "").strip()
|
| 33 |
|
| 34 |
-
#
|
| 35 |
meta_info = f"Nguồn: {source}"
|
| 36 |
if header and header != "/":
|
| 37 |
meta_info += f" | Mục: {header}"
|
|
@@ -54,20 +61,20 @@ def build_context(results: List[Dict[str, Any]], max_chars: int = 8000) -> str:
|
|
| 54 |
parts.append(f"[TÀI LIỆU {i}]\n{meta_info}\n{content}")
|
| 55 |
|
| 56 |
context = "\n---\n".join(parts)
|
| 57 |
-
#
|
| 58 |
return context[:max_chars] if len(context) > max_chars else context
|
| 59 |
|
| 60 |
|
| 61 |
def build_prompt(question: str, context: str) -> str:
|
| 62 |
-
|
| 63 |
return f"{SYSTEM_PROMPT}\n\n## CONTEXT:\n{context}\n\n## CÂU HỎI: {question}\n\n## TRẢ LỜI:"
|
| 64 |
|
| 65 |
|
| 66 |
class RAGContextBuilder:
|
| 67 |
-
|
| 68 |
|
| 69 |
def __init__(self, retriever: "Retriever", max_context_chars: int = 8000):
|
| 70 |
-
|
| 71 |
self._retriever = retriever
|
| 72 |
self._max_context_chars = max_context_chars
|
| 73 |
|
|
@@ -78,11 +85,10 @@ class RAGContextBuilder:
|
|
| 78 |
initial_k: int = 20,
|
| 79 |
mode: str = "hybrid_rerank"
|
| 80 |
) -> Dict[str, Any]:
|
| 81 |
-
|
| 82 |
-
# Tìm kiếm documents liên quan
|
| 83 |
results = self._retriever.flexible_search(question, k=k, initial_k=initial_k, mode=mode)
|
| 84 |
|
| 85 |
-
#
|
| 86 |
if not results:
|
| 87 |
return {
|
| 88 |
"results": [],
|
|
@@ -91,17 +97,17 @@ class RAGContextBuilder:
|
|
| 91 |
"prompt": "",
|
| 92 |
}
|
| 93 |
|
| 94 |
-
#
|
| 95 |
context_text = build_context(results, self._max_context_chars)
|
| 96 |
prompt = build_prompt(question, context_text)
|
| 97 |
|
| 98 |
return {
|
| 99 |
-
"results": results,
|
| 100 |
-
"contexts": [r.get("content", "")[:1000] for r in results],
|
| 101 |
-
"context_text": context_text,
|
| 102 |
-
"prompt": prompt,
|
| 103 |
}
|
| 104 |
|
| 105 |
|
| 106 |
-
#
|
| 107 |
RAGGenerator = RAGContextBuilder
|
|
|
|
| 2 |
from typing import Any, Dict, List, TYPE_CHECKING
|
| 3 |
|
| 4 |
if TYPE_CHECKING:
|
| 5 |
+
from core.rag.retrieval import Retriever
|
| 6 |
|
| 7 |
|
| 8 |
+
# System prompt for LLM (exported for gradio/eval usage)
|
| 9 |
+
SYSTEM_PROMPT = """Bạn là Trợ lý học vụ Đại học Bách khoa Hà Nội. Nhiệm vụ: trả lời câu hỏi của sinh viên về quy chế, quy định dựa trên tài liệu được cung cấp.
|
| 10 |
|
| 11 |
## NGUYÊN TẮC:
|
| 12 |
+
1. **Chỉ dùng CONTEXT:** Chỉ trả lời dựa trên CONTEXT được cung cấp. Tuyệt đối không suy đoán hay bổ sung thông tin ngoài CONTEXT.
|
| 13 |
+
2. **Ưu tiên văn bản mới:** Nếu CONTEXT chứa nhiều văn bản khác nhau, ưu tiên nội dung mới nhất (năm ban hành lớn hơn), TRỪ KHI có điều khoản chuyển tiếp nói khác.
|
| 14 |
+
3. **Trích dẫn nguồn:** Ghi rõ thông tin lấy từ văn bản nào (tên file, điều/khoản nếu có) để sinh viên có thể tự tra cứu.
|
| 15 |
+
4. **Lưu ý phạm vi áp dụng:** Nếu quy định chỉ áp dụng cho khóa/chương trình cụ thể, hãy nêu rõ điều kiện áp dụng.
|
| 16 |
+
5. **Không tìm thấy:** Nếu CONTEXT không chứa thông tin liên quan, trả lời: "Không tìm thấy thông tin trong dữ liệu hiện có. Bạn nên liên hệ Phòng Đào tạo để được hỗ trợ."
|
| 17 |
+
|
| 18 |
+
## CÁCH TRÌNH BÀY:
|
| 19 |
+
- Trả lời rõ ràng, dễ hiểu, thân thiện với sinh viên.
|
| 20 |
+
- Sử dụng bullet points khi liệt kê nhiều điều kiện/bước.
|
| 21 |
+
- Nếu câu trả lời phức tạp, chia thành các phần nhỏ có tiêu đề.
|
| 22 |
"""
|
| 23 |
|
| 24 |
|
| 25 |
def build_context(results: List[Dict[str, Any]], max_chars: int = 8000) -> str:
|
| 26 |
+
|
| 27 |
parts = []
|
| 28 |
for i, r in enumerate(results, 1):
|
| 29 |
meta = r.get("metadata", {})
|
|
|
|
| 38 |
issued_year = meta.get("issued_year", "")
|
| 39 |
content = r.get("content", "").strip()
|
| 40 |
|
| 41 |
+
# Build metadata line
|
| 42 |
meta_info = f"Nguồn: {source}"
|
| 43 |
if header and header != "/":
|
| 44 |
meta_info += f" | Mục: {header}"
|
|
|
|
| 61 |
parts.append(f"[TÀI LIỆU {i}]\n{meta_info}\n{content}")
|
| 62 |
|
| 63 |
context = "\n---\n".join(parts)
|
| 64 |
+
# Truncate if exceeds limit
|
| 65 |
return context[:max_chars] if len(context) > max_chars else context
|
| 66 |
|
| 67 |
|
| 68 |
def build_prompt(question: str, context: str) -> str:
|
| 69 |
+
|
| 70 |
return f"{SYSTEM_PROMPT}\n\n## CONTEXT:\n{context}\n\n## CÂU HỎI: {question}\n\n## TRẢ LỜI:"
|
| 71 |
|
| 72 |
|
| 73 |
class RAGContextBuilder:
|
| 74 |
+
|
| 75 |
|
| 76 |
def __init__(self, retriever: "Retriever", max_context_chars: int = 8000):
|
| 77 |
+
|
| 78 |
self._retriever = retriever
|
| 79 |
self._max_context_chars = max_context_chars
|
| 80 |
|
|
|
|
| 85 |
initial_k: int = 20,
|
| 86 |
mode: str = "hybrid_rerank"
|
| 87 |
) -> Dict[str, Any]:
|
| 88 |
+
# Search for relevant documents
|
|
|
|
| 89 |
results = self._retriever.flexible_search(question, k=k, initial_k=initial_k, mode=mode)
|
| 90 |
|
| 91 |
+
# No results found
|
| 92 |
if not results:
|
| 93 |
return {
|
| 94 |
"results": [],
|
|
|
|
| 97 |
"prompt": "",
|
| 98 |
}
|
| 99 |
|
| 100 |
+
# Build context and prompt
|
| 101 |
context_text = build_context(results, self._max_context_chars)
|
| 102 |
prompt = build_prompt(question, context_text)
|
| 103 |
|
| 104 |
return {
|
| 105 |
+
"results": results,
|
| 106 |
+
"contexts": [r.get("content", "")[:1000] for r in results],
|
| 107 |
+
"context_text": context_text,
|
| 108 |
+
"prompt": prompt,
|
| 109 |
}
|
| 110 |
|
| 111 |
|
| 112 |
+
# Backward compatibility alias
|
| 113 |
RAGGenerator = RAGContextBuilder
|
core/rag/{retrival.py → retrieval.py}
RENAMED
|
@@ -22,30 +22,28 @@ logger = logging.getLogger(__name__)
|
|
| 22 |
|
| 23 |
|
| 24 |
class RetrievalMode(str, Enum):
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
HYBRID_RERANK = "hybrid_rerank" # Hybrid + reranking
|
| 30 |
|
| 31 |
|
| 32 |
@dataclass
|
| 33 |
class RetrievalConfig:
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
bm25_weight: float = 0.5 # Trọng số BM25
|
| 42 |
|
| 43 |
|
| 44 |
_retrieval_config: RetrievalConfig | None = None
|
| 45 |
|
| 46 |
|
| 47 |
def get_retrieval_config() -> RetrievalConfig:
|
| 48 |
-
|
| 49 |
global _retrieval_config
|
| 50 |
if _retrieval_config is None:
|
| 51 |
_retrieval_config = RetrievalConfig()
|
|
@@ -53,7 +51,7 @@ def get_retrieval_config() -> RetrievalConfig:
|
|
| 53 |
|
| 54 |
|
| 55 |
class SiliconFlowReranker(BaseDocumentCompressor):
|
| 56 |
-
|
| 57 |
api_key: str = Field(default="")
|
| 58 |
api_base_url: str = Field(default="")
|
| 59 |
model: str = Field(default="")
|
|
@@ -68,11 +66,11 @@ class SiliconFlowReranker(BaseDocumentCompressor):
|
|
| 68 |
query: str,
|
| 69 |
callbacks: Optional[Callbacks] = None,
|
| 70 |
) -> Sequence[Document]:
|
| 71 |
-
|
| 72 |
if not documents or not self.api_key:
|
| 73 |
return list(documents)
|
| 74 |
|
| 75 |
-
# Retry
|
| 76 |
for attempt in range(3):
|
| 77 |
try:
|
| 78 |
response = requests.post(
|
|
@@ -95,7 +93,7 @@ class SiliconFlowReranker(BaseDocumentCompressor):
|
|
| 95 |
if "results" not in data:
|
| 96 |
return list(documents)
|
| 97 |
|
| 98 |
-
#
|
| 99 |
reranked: List[Document] = []
|
| 100 |
for result in data["results"]:
|
| 101 |
doc = documents[result["index"]]
|
|
@@ -106,36 +104,36 @@ class SiliconFlowReranker(BaseDocumentCompressor):
|
|
| 106 |
return reranked
|
| 107 |
|
| 108 |
except Exception as e:
|
| 109 |
-
# Rate limit ->
|
| 110 |
if "rate" in str(e).lower() and attempt < 2:
|
| 111 |
time.sleep(2 ** attempt)
|
| 112 |
else:
|
| 113 |
-
logger.error(f"
|
| 114 |
return list(documents)
|
| 115 |
|
| 116 |
return list(documents)
|
| 117 |
|
| 118 |
|
| 119 |
class Retriever:
|
| 120 |
-
|
| 121 |
|
| 122 |
def __init__(self, vector_db: "ChromaVectorDB", use_reranker: bool = True):
|
| 123 |
-
|
| 124 |
self._vector_db = vector_db
|
| 125 |
self._config = get_retrieval_config()
|
| 126 |
self._reranker: Optional[SiliconFlowReranker] = None
|
| 127 |
|
| 128 |
-
# Vector retriever
|
| 129 |
self._vector_retriever = self._vector_db.vectorstore.as_retriever(
|
| 130 |
search_kwargs={"k": self._config.initial_k}
|
| 131 |
)
|
| 132 |
|
| 133 |
-
# Lazy-load BM25 -
|
| 134 |
self._bm25_retriever: Optional[BM25Retriever] = None
|
| 135 |
self._bm25_initialized = False
|
| 136 |
self._ensemble_retriever: Optional[EnsembleRetriever] = None
|
| 137 |
|
| 138 |
-
#
|
| 139 |
from pathlib import Path
|
| 140 |
persist_dir = getattr(self._vector_db.config, 'persist_dir', None)
|
| 141 |
if persist_dir:
|
|
@@ -146,22 +144,22 @@ class Retriever:
|
|
| 146 |
if use_reranker:
|
| 147 |
self._reranker = self._init_reranker()
|
| 148 |
|
| 149 |
-
logger.info("
|
| 150 |
|
| 151 |
def _save_bm25_cache(self, bm25: BM25Retriever) -> None:
|
| 152 |
-
|
| 153 |
if not self._bm25_cache_path:
|
| 154 |
return
|
| 155 |
try:
|
| 156 |
import pickle
|
| 157 |
with open(self._bm25_cache_path, 'wb') as f:
|
| 158 |
pickle.dump(bm25, f)
|
| 159 |
-
logger.info(f"
|
| 160 |
except Exception as e:
|
| 161 |
-
logger.warning(f"
|
| 162 |
|
| 163 |
def _load_bm25_cache(self) -> Optional[BM25Retriever]:
|
| 164 |
-
|
| 165 |
if not self._bm25_cache_path or not self._bm25_cache_path.exists():
|
| 166 |
return None
|
| 167 |
try:
|
|
@@ -170,33 +168,33 @@ class Retriever:
|
|
| 170 |
with open(self._bm25_cache_path, 'rb') as f:
|
| 171 |
bm25 = pickle.load(f)
|
| 172 |
bm25.k = self._config.initial_k
|
| 173 |
-
logger.info(f"
|
| 174 |
return bm25
|
| 175 |
except Exception as e:
|
| 176 |
-
logger.warning(f"
|
| 177 |
return None
|
| 178 |
|
| 179 |
def _init_bm25(self) -> Optional[BM25Retriever]:
|
| 180 |
-
|
| 181 |
if self._bm25_initialized:
|
| 182 |
return self._bm25_retriever
|
| 183 |
|
| 184 |
self._bm25_initialized = True
|
| 185 |
|
| 186 |
-
#
|
| 187 |
cached = self._load_bm25_cache()
|
| 188 |
if cached:
|
| 189 |
self._bm25_retriever = cached
|
| 190 |
return cached
|
| 191 |
|
| 192 |
-
# Build
|
| 193 |
try:
|
| 194 |
start = time.time()
|
| 195 |
-
logger.info("
|
| 196 |
|
| 197 |
docs = self._vector_db.get_all_documents()
|
| 198 |
if not docs:
|
| 199 |
-
logger.warning("
|
| 200 |
return None
|
| 201 |
|
| 202 |
lc_docs = [
|
|
@@ -207,18 +205,18 @@ class Retriever:
|
|
| 207 |
bm25.k = self._config.initial_k
|
| 208 |
|
| 209 |
self._bm25_retriever = bm25
|
| 210 |
-
logger.info(f"
|
| 211 |
|
| 212 |
-
#
|
| 213 |
self._save_bm25_cache(bm25)
|
| 214 |
|
| 215 |
return bm25
|
| 216 |
except Exception as e:
|
| 217 |
-
logger.error(f"
|
| 218 |
return None
|
| 219 |
|
| 220 |
def _get_ensemble_retriever(self) -> EnsembleRetriever:
|
| 221 |
-
|
| 222 |
if self._ensemble_retriever is not None:
|
| 223 |
return self._ensemble_retriever
|
| 224 |
|
|
@@ -229,7 +227,7 @@ class Retriever:
|
|
| 229 |
weights=[self._config.vector_weight, self._config.bm25_weight]
|
| 230 |
)
|
| 231 |
else:
|
| 232 |
-
# Fallback
|
| 233 |
self._ensemble_retriever = EnsembleRetriever(
|
| 234 |
retrievers=[self._vector_retriever],
|
| 235 |
weights=[1.0]
|
|
@@ -237,7 +235,7 @@ class Retriever:
|
|
| 237 |
return self._ensemble_retriever
|
| 238 |
|
| 239 |
def _init_reranker(self) -> Optional[SiliconFlowReranker]:
|
| 240 |
-
|
| 241 |
api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
| 242 |
if not api_key:
|
| 243 |
return None
|
|
@@ -249,7 +247,7 @@ class Retriever:
|
|
| 249 |
)
|
| 250 |
|
| 251 |
def _build_final(self):
|
| 252 |
-
|
| 253 |
ensemble = self._get_ensemble_retriever()
|
| 254 |
if self._reranker:
|
| 255 |
return ContextualCompressionRetriever(
|
|
@@ -260,20 +258,20 @@ class Retriever:
|
|
| 260 |
|
| 261 |
@property
|
| 262 |
def has_reranker(self) -> bool:
|
| 263 |
-
|
| 264 |
return self._reranker is not None
|
| 265 |
|
| 266 |
def _to_result(self, doc: Document, rank: int, **extra) -> Dict[str, Any]:
|
| 267 |
-
|
| 268 |
metadata = doc.metadata or {}
|
| 269 |
content = doc.page_content
|
| 270 |
|
| 271 |
-
# Small-to-Big:
|
| 272 |
if metadata.get("is_table_summary") and metadata.get("parent_id"):
|
| 273 |
parent = self._vector_db.get_parent_node(metadata["parent_id"])
|
| 274 |
if parent:
|
| 275 |
content = parent.get("content", content)
|
| 276 |
-
# Merge metadata,
|
| 277 |
metadata = {
|
| 278 |
**parent.get("metadata", {}),
|
| 279 |
"original_summary": doc.page_content[:200],
|
|
@@ -291,7 +289,7 @@ class Retriever:
|
|
| 291 |
def vector_search(
|
| 292 |
self, text: str, *, k: int | None = None, where: Optional[Dict[str, Any]] = None
|
| 293 |
) -> List[Dict[str, Any]]:
|
| 294 |
-
|
| 295 |
if not text.strip():
|
| 296 |
return []
|
| 297 |
k = k or self._config.top_k
|
|
@@ -299,7 +297,7 @@ class Retriever:
|
|
| 299 |
return [self._to_result(doc, i + 1, distance=score) for i, (doc, score) in enumerate(results)]
|
| 300 |
|
| 301 |
def bm25_search(self, text: str, *, k: int | None = None) -> List[Dict[str, Any]]:
|
| 302 |
-
|
| 303 |
if not text.strip():
|
| 304 |
return []
|
| 305 |
bm25 = self._init_bm25() # Lazy-load BM25
|
|
@@ -313,7 +311,7 @@ class Retriever:
|
|
| 313 |
def hybrid_search(
|
| 314 |
self, text: str, *, k: int | None = None, initial_k: int | None = None
|
| 315 |
) -> List[Dict[str, Any]]:
|
| 316 |
-
|
| 317 |
if not text.strip():
|
| 318 |
return []
|
| 319 |
k = k or self._config.top_k
|
|
@@ -335,13 +333,13 @@ class Retriever:
|
|
| 335 |
where: Optional[Dict[str, Any]] = None,
|
| 336 |
initial_k: int | None = None,
|
| 337 |
) -> List[Dict[str, Any]]:
|
| 338 |
-
|
| 339 |
if not text.strip():
|
| 340 |
return []
|
| 341 |
k = k or self._config.top_k
|
| 342 |
initial_k = initial_k or self._config.initial_k
|
| 343 |
|
| 344 |
-
#
|
| 345 |
if where:
|
| 346 |
results = self._vector_db.vectorstore.similarity_search(text, k=initial_k, filter=where)
|
| 347 |
if self._reranker:
|
|
@@ -351,7 +349,7 @@ class Retriever:
|
|
| 351 |
for i, doc in enumerate(results[:k])
|
| 352 |
]
|
| 353 |
|
| 354 |
-
#
|
| 355 |
if initial_k:
|
| 356 |
self._vector_retriever.search_kwargs["k"] = initial_k
|
| 357 |
bm25 = self._init_bm25()
|
|
@@ -362,7 +360,7 @@ class Retriever:
|
|
| 362 |
ensemble = self._get_ensemble_retriever()
|
| 363 |
ensemble_results = ensemble.invoke(text)
|
| 364 |
|
| 365 |
-
# Rerank
|
| 366 |
if self._reranker:
|
| 367 |
results = self._reranker.compress_documents(ensemble_results, text)
|
| 368 |
else:
|
|
@@ -382,11 +380,11 @@ class Retriever:
|
|
| 382 |
initial_k: int | None = None,
|
| 383 |
where: Optional[Dict[str, Any]] = None,
|
| 384 |
) -> List[Dict[str, Any]]:
|
| 385 |
-
|
| 386 |
if not text.strip():
|
| 387 |
return []
|
| 388 |
|
| 389 |
-
# Parse mode
|
| 390 |
if isinstance(mode, str):
|
| 391 |
try:
|
| 392 |
mode = RetrievalMode(mode.lower())
|
|
@@ -396,7 +394,7 @@ class Retriever:
|
|
| 396 |
k = k or self._config.top_k
|
| 397 |
initial_k = initial_k or self._config.initial_k
|
| 398 |
|
| 399 |
-
#
|
| 400 |
if mode == RetrievalMode.VECTOR_ONLY:
|
| 401 |
return self.vector_search(text, k=k, where=where)
|
| 402 |
elif mode == RetrievalMode.BM25_ONLY:
|
|
@@ -408,5 +406,5 @@ class Retriever:
|
|
| 408 |
else: # HYBRID_RERANK
|
| 409 |
return self.search_with_rerank(text, k=k, where=where, initial_k=initial_k)
|
| 410 |
|
| 411 |
-
#
|
| 412 |
query = vector_search
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class RetrievalMode(str, Enum):
|
| 25 |
+
VECTOR_ONLY = "vector_only"
|
| 26 |
+
BM25_ONLY = "bm25_only"
|
| 27 |
+
HYBRID = "hybrid"
|
| 28 |
+
HYBRID_RERANK = "hybrid_rerank"
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass
|
| 32 |
class RetrievalConfig:
|
| 33 |
+
rerank_api_base_url: str = "https://api.siliconflow.com/v1"
|
| 34 |
+
rerank_model: str = "Qwen/Qwen3-Reranker-8B"
|
| 35 |
+
rerank_top_n: int = 10
|
| 36 |
+
initial_k: int = 25
|
| 37 |
+
top_k: int = 5
|
| 38 |
+
vector_weight: float = 0.5
|
| 39 |
+
bm25_weight: float = 0.5
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
_retrieval_config: RetrievalConfig | None = None
|
| 43 |
|
| 44 |
|
| 45 |
def get_retrieval_config() -> RetrievalConfig:
|
| 46 |
+
|
| 47 |
global _retrieval_config
|
| 48 |
if _retrieval_config is None:
|
| 49 |
_retrieval_config = RetrievalConfig()
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
class SiliconFlowReranker(BaseDocumentCompressor):
|
| 54 |
+
|
| 55 |
api_key: str = Field(default="")
|
| 56 |
api_base_url: str = Field(default="")
|
| 57 |
model: str = Field(default="")
|
|
|
|
| 66 |
query: str,
|
| 67 |
callbacks: Optional[Callbacks] = None,
|
| 68 |
) -> Sequence[Document]:
|
| 69 |
+
|
| 70 |
if not documents or not self.api_key:
|
| 71 |
return list(documents)
|
| 72 |
|
| 73 |
+
# Retry with exponential backoff
|
| 74 |
for attempt in range(3):
|
| 75 |
try:
|
| 76 |
response = requests.post(
|
|
|
|
| 93 |
if "results" not in data:
|
| 94 |
return list(documents)
|
| 95 |
|
| 96 |
+
# Build reranked document list with scores
|
| 97 |
reranked: List[Document] = []
|
| 98 |
for result in data["results"]:
|
| 99 |
doc = documents[result["index"]]
|
|
|
|
| 104 |
return reranked
|
| 105 |
|
| 106 |
except Exception as e:
|
| 107 |
+
# Rate limit -> wait and retry
|
| 108 |
if "rate" in str(e).lower() and attempt < 2:
|
| 109 |
time.sleep(2 ** attempt)
|
| 110 |
else:
|
| 111 |
+
logger.error(f"Rerank error: {e}")
|
| 112 |
return list(documents)
|
| 113 |
|
| 114 |
return list(documents)
|
| 115 |
|
| 116 |
|
| 117 |
class Retriever:
|
| 118 |
+
|
| 119 |
|
| 120 |
def __init__(self, vector_db: "ChromaVectorDB", use_reranker: bool = True):
|
| 121 |
+
|
| 122 |
self._vector_db = vector_db
|
| 123 |
self._config = get_retrieval_config()
|
| 124 |
self._reranker: Optional[SiliconFlowReranker] = None
|
| 125 |
|
| 126 |
+
# Vector retriever from ChromaDB
|
| 127 |
self._vector_retriever = self._vector_db.vectorstore.as_retriever(
|
| 128 |
search_kwargs={"k": self._config.initial_k}
|
| 129 |
)
|
| 130 |
|
| 131 |
+
# Lazy-load BM25 - only initialized when needed
|
| 132 |
self._bm25_retriever: Optional[BM25Retriever] = None
|
| 133 |
self._bm25_initialized = False
|
| 134 |
self._ensemble_retriever: Optional[EnsembleRetriever] = None
|
| 135 |
|
| 136 |
+
# BM25 cache path (saved to disk)
|
| 137 |
from pathlib import Path
|
| 138 |
persist_dir = getattr(self._vector_db.config, 'persist_dir', None)
|
| 139 |
if persist_dir:
|
|
|
|
| 144 |
if use_reranker:
|
| 145 |
self._reranker = self._init_reranker()
|
| 146 |
|
| 147 |
+
logger.info("Initialized Retriever")
|
| 148 |
|
| 149 |
def _save_bm25_cache(self, bm25: BM25Retriever) -> None:
|
| 150 |
+
|
| 151 |
if not self._bm25_cache_path:
|
| 152 |
return
|
| 153 |
try:
|
| 154 |
import pickle
|
| 155 |
with open(self._bm25_cache_path, 'wb') as f:
|
| 156 |
pickle.dump(bm25, f)
|
| 157 |
+
logger.info(f"Saved BM25 cache to {self._bm25_cache_path}")
|
| 158 |
except Exception as e:
|
| 159 |
+
logger.warning(f"Failed to save BM25 cache: {e}")
|
| 160 |
|
| 161 |
def _load_bm25_cache(self) -> Optional[BM25Retriever]:
|
| 162 |
+
|
| 163 |
if not self._bm25_cache_path or not self._bm25_cache_path.exists():
|
| 164 |
return None
|
| 165 |
try:
|
|
|
|
| 168 |
with open(self._bm25_cache_path, 'rb') as f:
|
| 169 |
bm25 = pickle.load(f)
|
| 170 |
bm25.k = self._config.initial_k
|
| 171 |
+
logger.info(f"Loaded BM25 from cache in {time.time() - start:.2f}s")
|
| 172 |
return bm25
|
| 173 |
except Exception as e:
|
| 174 |
+
logger.warning(f"Failed to load BM25 cache: {e}")
|
| 175 |
return None
|
| 176 |
|
| 177 |
def _init_bm25(self) -> Optional[BM25Retriever]:
|
| 178 |
+
|
| 179 |
if self._bm25_initialized:
|
| 180 |
return self._bm25_retriever
|
| 181 |
|
| 182 |
self._bm25_initialized = True
|
| 183 |
|
| 184 |
+
# Try loading from cache first
|
| 185 |
cached = self._load_bm25_cache()
|
| 186 |
if cached:
|
| 187 |
self._bm25_retriever = cached
|
| 188 |
return cached
|
| 189 |
|
| 190 |
+
# Build from scratch if no cache
|
| 191 |
try:
|
| 192 |
start = time.time()
|
| 193 |
+
logger.info("Building BM25 index from documents...")
|
| 194 |
|
| 195 |
docs = self._vector_db.get_all_documents()
|
| 196 |
if not docs:
|
| 197 |
+
logger.warning("No documents found for BM25")
|
| 198 |
return None
|
| 199 |
|
| 200 |
lc_docs = [
|
|
|
|
| 205 |
bm25.k = self._config.initial_k
|
| 206 |
|
| 207 |
self._bm25_retriever = bm25
|
| 208 |
+
logger.info(f"Built BM25 with {len(docs)} docs in {time.time() - start:.2f}s")
|
| 209 |
|
| 210 |
+
# Save to cache for next time
|
| 211 |
self._save_bm25_cache(bm25)
|
| 212 |
|
| 213 |
return bm25
|
| 214 |
except Exception as e:
|
| 215 |
+
logger.error(f"Failed to initialize BM25: {e}")
|
| 216 |
return None
|
| 217 |
|
| 218 |
def _get_ensemble_retriever(self) -> EnsembleRetriever:
|
| 219 |
+
|
| 220 |
if self._ensemble_retriever is not None:
|
| 221 |
return self._ensemble_retriever
|
| 222 |
|
|
|
|
| 227 |
weights=[self._config.vector_weight, self._config.bm25_weight]
|
| 228 |
)
|
| 229 |
else:
|
| 230 |
+
# Fallback to vector only
|
| 231 |
self._ensemble_retriever = EnsembleRetriever(
|
| 232 |
retrievers=[self._vector_retriever],
|
| 233 |
weights=[1.0]
|
|
|
|
| 235 |
return self._ensemble_retriever
|
| 236 |
|
| 237 |
def _init_reranker(self) -> Optional[SiliconFlowReranker]:
|
| 238 |
+
|
| 239 |
api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
| 240 |
if not api_key:
|
| 241 |
return None
|
|
|
|
| 247 |
)
|
| 248 |
|
| 249 |
def _build_final(self):
|
| 250 |
+
|
| 251 |
ensemble = self._get_ensemble_retriever()
|
| 252 |
if self._reranker:
|
| 253 |
return ContextualCompressionRetriever(
|
|
|
|
| 258 |
|
| 259 |
@property
|
| 260 |
def has_reranker(self) -> bool:
|
| 261 |
+
|
| 262 |
return self._reranker is not None
|
| 263 |
|
| 264 |
def _to_result(self, doc: Document, rank: int, **extra) -> Dict[str, Any]:
|
| 265 |
+
|
| 266 |
metadata = doc.metadata or {}
|
| 267 |
content = doc.page_content
|
| 268 |
|
| 269 |
+
# Small-to-Big: if summary node -> swap with parent (original table)
|
| 270 |
if metadata.get("is_table_summary") and metadata.get("parent_id"):
|
| 271 |
parent = self._vector_db.get_parent_node(metadata["parent_id"])
|
| 272 |
if parent:
|
| 273 |
content = parent.get("content", content)
|
| 274 |
+
# Merge metadata, keep summary info for debugging
|
| 275 |
metadata = {
|
| 276 |
**parent.get("metadata", {}),
|
| 277 |
"original_summary": doc.page_content[:200],
|
|
|
|
| 289 |
def vector_search(
|
| 290 |
self, text: str, *, k: int | None = None, where: Optional[Dict[str, Any]] = None
|
| 291 |
) -> List[Dict[str, Any]]:
|
| 292 |
+
|
| 293 |
if not text.strip():
|
| 294 |
return []
|
| 295 |
k = k or self._config.top_k
|
|
|
|
| 297 |
return [self._to_result(doc, i + 1, distance=score) for i, (doc, score) in enumerate(results)]
|
| 298 |
|
| 299 |
def bm25_search(self, text: str, *, k: int | None = None) -> List[Dict[str, Any]]:
|
| 300 |
+
|
| 301 |
if not text.strip():
|
| 302 |
return []
|
| 303 |
bm25 = self._init_bm25() # Lazy-load BM25
|
|
|
|
| 311 |
def hybrid_search(
|
| 312 |
self, text: str, *, k: int | None = None, initial_k: int | None = None
|
| 313 |
) -> List[Dict[str, Any]]:
|
| 314 |
+
|
| 315 |
if not text.strip():
|
| 316 |
return []
|
| 317 |
k = k or self._config.top_k
|
|
|
|
| 333 |
where: Optional[Dict[str, Any]] = None,
|
| 334 |
initial_k: int | None = None,
|
| 335 |
) -> List[Dict[str, Any]]:
|
| 336 |
+
|
| 337 |
if not text.strip():
|
| 338 |
return []
|
| 339 |
k = k or self._config.top_k
|
| 340 |
initial_k = initial_k or self._config.initial_k
|
| 341 |
|
| 342 |
+
# Has filter -> use vector search + manual rerank
|
| 343 |
if where:
|
| 344 |
results = self._vector_db.vectorstore.similarity_search(text, k=initial_k, filter=where)
|
| 345 |
if self._reranker:
|
|
|
|
| 349 |
for i, doc in enumerate(results[:k])
|
| 350 |
]
|
| 351 |
|
| 352 |
+
# Update k for initial fetch
|
| 353 |
if initial_k:
|
| 354 |
self._vector_retriever.search_kwargs["k"] = initial_k
|
| 355 |
bm25 = self._init_bm25()
|
|
|
|
| 360 |
ensemble = self._get_ensemble_retriever()
|
| 361 |
ensemble_results = ensemble.invoke(text)
|
| 362 |
|
| 363 |
+
# Rerank if available
|
| 364 |
if self._reranker:
|
| 365 |
results = self._reranker.compress_documents(ensemble_results, text)
|
| 366 |
else:
|
|
|
|
| 380 |
initial_k: int | None = None,
|
| 381 |
where: Optional[Dict[str, Any]] = None,
|
| 382 |
) -> List[Dict[str, Any]]:
|
| 383 |
+
|
| 384 |
if not text.strip():
|
| 385 |
return []
|
| 386 |
|
| 387 |
+
# Parse mode from string
|
| 388 |
if isinstance(mode, str):
|
| 389 |
try:
|
| 390 |
mode = RetrievalMode(mode.lower())
|
|
|
|
| 394 |
k = k or self._config.top_k
|
| 395 |
initial_k = initial_k or self._config.initial_k
|
| 396 |
|
| 397 |
+
# Dispatch to corresponding method by mode
|
| 398 |
if mode == RetrievalMode.VECTOR_ONLY:
|
| 399 |
return self.vector_search(text, k=k, where=where)
|
| 400 |
elif mode == RetrievalMode.BM25_ONLY:
|
|
|
|
| 406 |
else: # HYBRID_RERANK
|
| 407 |
return self.search_with_rerank(text, k=k, where=where, initial_k=initial_k)
|
| 408 |
|
| 409 |
+
# Backward compatibility alias
|
| 410 |
query = vector_search
|
core/rag/vector_store.py
CHANGED
|
@@ -13,76 +13,76 @@ logger = logging.getLogger(__name__)
|
|
| 13 |
|
| 14 |
@dataclass
|
| 15 |
class ChromaConfig:
|
| 16 |
-
|
| 17 |
|
| 18 |
def _default_persist_dir() -> str:
|
| 19 |
-
|
| 20 |
repo_root = Path(__file__).resolve().parents[2]
|
| 21 |
return str((repo_root / "data" / "chroma").resolve())
|
| 22 |
|
| 23 |
-
persist_dir: str = field(default_factory=_default_persist_dir)
|
| 24 |
-
collection_name: str = "hust_rag_collection"
|
| 25 |
|
| 26 |
|
| 27 |
class ChromaVectorDB:
|
| 28 |
-
|
| 29 |
|
| 30 |
def __init__(
|
| 31 |
self,
|
| 32 |
embedder: Any,
|
| 33 |
config: ChromaConfig | None = None,
|
| 34 |
):
|
| 35 |
-
|
| 36 |
self.embedder = embedder
|
| 37 |
self.config = config or ChromaConfig()
|
| 38 |
self._hasher = HashProcessor(verbose=False)
|
| 39 |
|
| 40 |
-
#
|
| 41 |
self._parent_nodes_path = Path(self.config.persist_dir) / "parent_nodes.json"
|
| 42 |
self._parent_nodes: Dict[str, Dict[str, Any]] = self._load_parent_nodes()
|
| 43 |
|
| 44 |
-
#
|
| 45 |
self._vs = Chroma(
|
| 46 |
collection_name=self.config.collection_name,
|
| 47 |
embedding_function=self.embedder,
|
| 48 |
persist_directory=self.config.persist_dir,
|
| 49 |
)
|
| 50 |
-
logger.info(f"
|
| 51 |
|
| 52 |
def _load_parent_nodes(self) -> Dict[str, Dict[str, Any]]:
|
| 53 |
-
|
| 54 |
if self._parent_nodes_path.exists():
|
| 55 |
try:
|
| 56 |
with open(self._parent_nodes_path, 'r', encoding='utf-8') as f:
|
| 57 |
data = json.load(f)
|
| 58 |
-
logger.info(f"
|
| 59 |
return data
|
| 60 |
except Exception as e:
|
| 61 |
-
logger.warning(f"
|
| 62 |
return {}
|
| 63 |
|
| 64 |
def _save_parent_nodes(self) -> None:
|
| 65 |
-
|
| 66 |
try:
|
| 67 |
self._parent_nodes_path.parent.mkdir(parents=True, exist_ok=True)
|
| 68 |
with open(self._parent_nodes_path, 'w', encoding='utf-8') as f:
|
| 69 |
json.dump(self._parent_nodes, f, ensure_ascii=False, indent=2)
|
| 70 |
-
logger.info(f"
|
| 71 |
except Exception as e:
|
| 72 |
-
logger.warning(f"
|
| 73 |
|
| 74 |
@property
|
| 75 |
def collection(self):
|
| 76 |
-
|
| 77 |
return getattr(self._vs, "_collection", None)
|
| 78 |
|
| 79 |
@property
|
| 80 |
def vectorstore(self):
|
| 81 |
-
|
| 82 |
return self._vs
|
| 83 |
|
| 84 |
def _flatten_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
| 85 |
-
|
| 86 |
out: Dict[str, Any] = {}
|
| 87 |
for k, v in (metadata or {}).items():
|
| 88 |
if v is None:
|
|
@@ -90,33 +90,33 @@ class ChromaVectorDB:
|
|
| 90 |
if isinstance(v, (str, int, float, bool)):
|
| 91 |
out[str(k)] = v
|
| 92 |
elif isinstance(v, (list, tuple, set, dict)):
|
| 93 |
-
#
|
| 94 |
out[str(k)] = json.dumps(v, ensure_ascii=False)
|
| 95 |
else:
|
| 96 |
out[str(k)] = str(v)
|
| 97 |
return out
|
| 98 |
|
| 99 |
def _normalize_doc(self, doc: Any) -> Dict[str, Any]:
|
| 100 |
-
|
| 101 |
-
#
|
| 102 |
if isinstance(doc, dict):
|
| 103 |
return doc
|
| 104 |
-
# TextNode/BaseNode
|
| 105 |
if hasattr(doc, "get_content") and hasattr(doc, "metadata"):
|
| 106 |
return {
|
| 107 |
"content": doc.get_content(),
|
| 108 |
"metadata": dict(doc.metadata) if doc.metadata else {},
|
| 109 |
}
|
| 110 |
-
# Document
|
| 111 |
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
|
| 112 |
return {
|
| 113 |
"content": doc.page_content,
|
| 114 |
"metadata": dict(doc.metadata) if doc.metadata else {},
|
| 115 |
}
|
| 116 |
-
raise TypeError(f"
|
| 117 |
|
| 118 |
def _to_documents(self, docs: Sequence[Any], ids: Sequence[str]) -> List[Document]:
|
| 119 |
-
|
| 120 |
out: List[Document] = []
|
| 121 |
for d, doc_id in zip(docs, ids):
|
| 122 |
normalized = self._normalize_doc(d)
|
|
@@ -126,7 +126,7 @@ class ChromaVectorDB:
|
|
| 126 |
return out
|
| 127 |
|
| 128 |
def _doc_id(self, doc: Any) -> str:
|
| 129 |
-
|
| 130 |
normalized = self._normalize_doc(doc)
|
| 131 |
md = normalized.get("metadata") or {}
|
| 132 |
key = {
|
|
@@ -144,14 +144,14 @@ class ChromaVectorDB:
|
|
| 144 |
ids: Optional[Sequence[str]] = None,
|
| 145 |
batch_size: int = 128,
|
| 146 |
) -> int:
|
| 147 |
-
|
| 148 |
if not docs:
|
| 149 |
return 0
|
| 150 |
|
| 151 |
if ids is not None and len(ids) != len(docs):
|
| 152 |
-
raise ValueError("
|
| 153 |
|
| 154 |
-
#
|
| 155 |
regular_docs = []
|
| 156 |
regular_ids = []
|
| 157 |
parent_count = 0
|
|
@@ -162,7 +162,7 @@ class ChromaVectorDB:
|
|
| 162 |
doc_id = ids[i] if ids else self._doc_id(d)
|
| 163 |
|
| 164 |
if md.get("is_parent"):
|
| 165 |
-
#
|
| 166 |
parent_id = md.get("node_id", doc_id)
|
| 167 |
self._parent_nodes[parent_id] = {
|
| 168 |
"id": parent_id,
|
|
@@ -175,13 +175,13 @@ class ChromaVectorDB:
|
|
| 175 |
regular_ids.append(doc_id)
|
| 176 |
|
| 177 |
if parent_count > 0:
|
| 178 |
-
logger.info(f"
|
| 179 |
self._save_parent_nodes()
|
| 180 |
|
| 181 |
if not regular_docs:
|
| 182 |
return parent_count
|
| 183 |
|
| 184 |
-
#
|
| 185 |
bs = max(1, batch_size)
|
| 186 |
total = 0
|
| 187 |
|
|
@@ -193,13 +193,13 @@ class ChromaVectorDB:
|
|
| 193 |
try:
|
| 194 |
self._vs.add_documents(lc_docs, ids=batch_ids)
|
| 195 |
except TypeError:
|
| 196 |
-
# Fallback
|
| 197 |
texts = [d.page_content for d in lc_docs]
|
| 198 |
metas = [d.metadata for d in lc_docs]
|
| 199 |
self._vs.add_texts(texts=texts, metadatas=metas, ids=batch_ids)
|
| 200 |
total += len(batch)
|
| 201 |
|
| 202 |
-
logger.info(f"
|
| 203 |
return total + parent_count
|
| 204 |
|
| 205 |
def upsert_documents(
|
|
@@ -209,14 +209,14 @@ class ChromaVectorDB:
|
|
| 209 |
ids: Optional[Sequence[str]] = None,
|
| 210 |
batch_size: int = 128,
|
| 211 |
) -> int:
|
| 212 |
-
|
| 213 |
if not docs:
|
| 214 |
return 0
|
| 215 |
|
| 216 |
if ids is not None and len(ids) != len(docs):
|
| 217 |
-
raise ValueError("
|
| 218 |
|
| 219 |
-
#
|
| 220 |
regular_docs = []
|
| 221 |
regular_ids = []
|
| 222 |
parent_count = 0
|
|
@@ -227,7 +227,7 @@ class ChromaVectorDB:
|
|
| 227 |
doc_id = ids[i] if ids else self._doc_id(d)
|
| 228 |
|
| 229 |
if md.get("is_parent"):
|
| 230 |
-
#
|
| 231 |
parent_id = md.get("node_id", doc_id)
|
| 232 |
self._parent_nodes[parent_id] = {
|
| 233 |
"id": parent_id,
|
|
@@ -240,7 +240,7 @@ class ChromaVectorDB:
|
|
| 240 |
regular_ids.append(doc_id)
|
| 241 |
|
| 242 |
if parent_count > 0:
|
| 243 |
-
logger.info(f"
|
| 244 |
self._save_parent_nodes()
|
| 245 |
|
| 246 |
if not regular_docs:
|
|
@@ -249,11 +249,11 @@ class ChromaVectorDB:
|
|
| 249 |
bs = max(1, batch_size)
|
| 250 |
col = self.collection
|
| 251 |
|
| 252 |
-
# Fallback
|
| 253 |
if col is None:
|
| 254 |
return self.add_documents(regular_docs, ids=regular_ids, batch_size=bs) + parent_count
|
| 255 |
|
| 256 |
-
# Upsert
|
| 257 |
total = 0
|
| 258 |
for start in range(0, len(regular_docs), bs):
|
| 259 |
batch = regular_docs[start : start + bs]
|
|
@@ -265,16 +265,16 @@ class ChromaVectorDB:
|
|
| 265 |
col.upsert(ids=batch_ids, documents=texts, metadatas=metas, embeddings=embs)
|
| 266 |
total += len(batch)
|
| 267 |
|
| 268 |
-
logger.info(f"
|
| 269 |
return total + parent_count
|
| 270 |
|
| 271 |
def count(self) -> int:
|
| 272 |
-
|
| 273 |
col = self.collection
|
| 274 |
return int(col.count()) if col else 0
|
| 275 |
|
| 276 |
def get_all_documents(self, limit: int = 5000) -> List[Dict[str, Any]]:
|
| 277 |
-
|
| 278 |
col = self.collection
|
| 279 |
if col is None:
|
| 280 |
return []
|
|
@@ -291,7 +291,7 @@ class ChromaVectorDB:
|
|
| 291 |
return docs
|
| 292 |
|
| 293 |
def delete_documents(self, ids: Sequence[str]) -> int:
|
| 294 |
-
|
| 295 |
if not ids:
|
| 296 |
return 0
|
| 297 |
|
|
@@ -300,14 +300,14 @@ class ChromaVectorDB:
|
|
| 300 |
return 0
|
| 301 |
|
| 302 |
col.delete(ids=list(ids))
|
| 303 |
-
logger.info(f"
|
| 304 |
return len(ids)
|
| 305 |
|
| 306 |
def get_parent_node(self, parent_id: str) -> Optional[Dict[str, Any]]:
|
| 307 |
-
|
| 308 |
return self._parent_nodes.get(parent_id)
|
| 309 |
|
| 310 |
@property
|
| 311 |
def parent_nodes(self) -> Dict[str, Dict[str, Any]]:
|
| 312 |
-
|
| 313 |
return self._parent_nodes
|
|
|
|
| 13 |
|
| 14 |
@dataclass
|
| 15 |
class ChromaConfig:
|
| 16 |
+
|
| 17 |
|
| 18 |
def _default_persist_dir() -> str:
|
| 19 |
+
|
| 20 |
repo_root = Path(__file__).resolve().parents[2]
|
| 21 |
return str((repo_root / "data" / "chroma").resolve())
|
| 22 |
|
| 23 |
+
persist_dir: str = field(default_factory=_default_persist_dir)
|
| 24 |
+
collection_name: str = "hust_rag_collection"
|
| 25 |
|
| 26 |
|
| 27 |
class ChromaVectorDB:
|
| 28 |
+
|
| 29 |
|
| 30 |
def __init__(
|
| 31 |
self,
|
| 32 |
embedder: Any,
|
| 33 |
config: ChromaConfig | None = None,
|
| 34 |
):
|
| 35 |
+
|
| 36 |
self.embedder = embedder
|
| 37 |
self.config = config or ChromaConfig()
|
| 38 |
self._hasher = HashProcessor(verbose=False)
|
| 39 |
|
| 40 |
+
# Parent node storage (not embedded, used for Small-to-Big)
|
| 41 |
self._parent_nodes_path = Path(self.config.persist_dir) / "parent_nodes.json"
|
| 42 |
self._parent_nodes: Dict[str, Dict[str, Any]] = self._load_parent_nodes()
|
| 43 |
|
| 44 |
+
# Initialize ChromaDB
|
| 45 |
self._vs = Chroma(
|
| 46 |
collection_name=self.config.collection_name,
|
| 47 |
embedding_function=self.embedder,
|
| 48 |
persist_directory=self.config.persist_dir,
|
| 49 |
)
|
| 50 |
+
logger.info(f"Initialized ChromaVectorDB: {self.config.collection_name}")
|
| 51 |
|
| 52 |
def _load_parent_nodes(self) -> Dict[str, Dict[str, Any]]:
|
| 53 |
+
|
| 54 |
if self._parent_nodes_path.exists():
|
| 55 |
try:
|
| 56 |
with open(self._parent_nodes_path, 'r', encoding='utf-8') as f:
|
| 57 |
data = json.load(f)
|
| 58 |
+
logger.info(f"Loaded {len(data)} parent nodes from {self._parent_nodes_path}")
|
| 59 |
return data
|
| 60 |
except Exception as e:
|
| 61 |
+
logger.warning(f"Failed to load parent nodes: {e}")
|
| 62 |
return {}
|
| 63 |
|
| 64 |
def _save_parent_nodes(self) -> None:
|
| 65 |
+
|
| 66 |
try:
|
| 67 |
self._parent_nodes_path.parent.mkdir(parents=True, exist_ok=True)
|
| 68 |
with open(self._parent_nodes_path, 'w', encoding='utf-8') as f:
|
| 69 |
json.dump(self._parent_nodes, f, ensure_ascii=False, indent=2)
|
| 70 |
+
logger.info(f"Saved {len(self._parent_nodes)} parent nodes to {self._parent_nodes_path}")
|
| 71 |
except Exception as e:
|
| 72 |
+
logger.warning(f"Failed to save parent nodes: {e}")
|
| 73 |
|
| 74 |
@property
|
| 75 |
def collection(self):
|
| 76 |
+
|
| 77 |
return getattr(self._vs, "_collection", None)
|
| 78 |
|
| 79 |
@property
|
| 80 |
def vectorstore(self):
|
| 81 |
+
|
| 82 |
return self._vs
|
| 83 |
|
| 84 |
def _flatten_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
| 85 |
+
|
| 86 |
out: Dict[str, Any] = {}
|
| 87 |
for k, v in (metadata or {}).items():
|
| 88 |
if v is None:
|
|
|
|
| 90 |
if isinstance(v, (str, int, float, bool)):
|
| 91 |
out[str(k)] = v
|
| 92 |
elif isinstance(v, (list, tuple, set, dict)):
|
| 93 |
+
# Convert list/dict to JSON string
|
| 94 |
out[str(k)] = json.dumps(v, ensure_ascii=False)
|
| 95 |
else:
|
| 96 |
out[str(k)] = str(v)
|
| 97 |
return out
|
| 98 |
|
| 99 |
def _normalize_doc(self, doc: Any) -> Dict[str, Any]:
|
| 100 |
+
|
| 101 |
+
# Already a dict
|
| 102 |
if isinstance(doc, dict):
|
| 103 |
return doc
|
| 104 |
+
# TextNode/BaseNode from llama_index
|
| 105 |
if hasattr(doc, "get_content") and hasattr(doc, "metadata"):
|
| 106 |
return {
|
| 107 |
"content": doc.get_content(),
|
| 108 |
"metadata": dict(doc.metadata) if doc.metadata else {},
|
| 109 |
}
|
| 110 |
+
# Document from LangChain
|
| 111 |
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
|
| 112 |
return {
|
| 113 |
"content": doc.page_content,
|
| 114 |
"metadata": dict(doc.metadata) if doc.metadata else {},
|
| 115 |
}
|
| 116 |
+
raise TypeError(f"Unsupported document type: {type(doc)}")
|
| 117 |
|
| 118 |
def _to_documents(self, docs: Sequence[Any], ids: Sequence[str]) -> List[Document]:
|
| 119 |
+
|
| 120 |
out: List[Document] = []
|
| 121 |
for d, doc_id in zip(docs, ids):
|
| 122 |
normalized = self._normalize_doc(d)
|
|
|
|
| 126 |
return out
|
| 127 |
|
| 128 |
def _doc_id(self, doc: Any) -> str:
|
| 129 |
+
|
| 130 |
normalized = self._normalize_doc(doc)
|
| 131 |
md = normalized.get("metadata") or {}
|
| 132 |
key = {
|
|
|
|
| 144 |
ids: Optional[Sequence[str]] = None,
|
| 145 |
batch_size: int = 128,
|
| 146 |
) -> int:
|
| 147 |
+
|
| 148 |
if not docs:
|
| 149 |
return 0
|
| 150 |
|
| 151 |
if ids is not None and len(ids) != len(docs):
|
| 152 |
+
raise ValueError("Number of ids must match number of docs")
|
| 153 |
|
| 154 |
+
# Separate parent nodes (not embedded) from regular nodes
|
| 155 |
regular_docs = []
|
| 156 |
regular_ids = []
|
| 157 |
parent_count = 0
|
|
|
|
| 162 |
doc_id = ids[i] if ids else self._doc_id(d)
|
| 163 |
|
| 164 |
if md.get("is_parent"):
|
| 165 |
+
# Store parent node separately (for Small-to-Big)
|
| 166 |
parent_id = md.get("node_id", doc_id)
|
| 167 |
self._parent_nodes[parent_id] = {
|
| 168 |
"id": parent_id,
|
|
|
|
| 175 |
regular_ids.append(doc_id)
|
| 176 |
|
| 177 |
if parent_count > 0:
|
| 178 |
+
logger.info(f"Saved {parent_count} parent nodes (not embedded)")
|
| 179 |
self._save_parent_nodes()
|
| 180 |
|
| 181 |
if not regular_docs:
|
| 182 |
return parent_count
|
| 183 |
|
| 184 |
+
# Add in batches
|
| 185 |
bs = max(1, batch_size)
|
| 186 |
total = 0
|
| 187 |
|
|
|
|
| 193 |
try:
|
| 194 |
self._vs.add_documents(lc_docs, ids=batch_ids)
|
| 195 |
except TypeError:
|
| 196 |
+
# Fallback if add_documents doesn't accept ids
|
| 197 |
texts = [d.page_content for d in lc_docs]
|
| 198 |
metas = [d.metadata for d in lc_docs]
|
| 199 |
self._vs.add_texts(texts=texts, metadatas=metas, ids=batch_ids)
|
| 200 |
total += len(batch)
|
| 201 |
|
| 202 |
+
logger.info(f"Added {total} documents to vector store")
|
| 203 |
return total + parent_count
|
| 204 |
|
| 205 |
def upsert_documents(
|
|
|
|
| 209 |
ids: Optional[Sequence[str]] = None,
|
| 210 |
batch_size: int = 128,
|
| 211 |
) -> int:
|
| 212 |
+
|
| 213 |
if not docs:
|
| 214 |
return 0
|
| 215 |
|
| 216 |
if ids is not None and len(ids) != len(docs):
|
| 217 |
+
raise ValueError("Number of ids must match number of docs")
|
| 218 |
|
| 219 |
+
# Separate parent nodes from regular nodes
|
| 220 |
regular_docs = []
|
| 221 |
regular_ids = []
|
| 222 |
parent_count = 0
|
|
|
|
| 227 |
doc_id = ids[i] if ids else self._doc_id(d)
|
| 228 |
|
| 229 |
if md.get("is_parent"):
|
| 230 |
+
# Store parent node separately
|
| 231 |
parent_id = md.get("node_id", doc_id)
|
| 232 |
self._parent_nodes[parent_id] = {
|
| 233 |
"id": parent_id,
|
|
|
|
| 240 |
regular_ids.append(doc_id)
|
| 241 |
|
| 242 |
if parent_count > 0:
|
| 243 |
+
logger.info(f"Saved {parent_count} parent nodes (not embedded)")
|
| 244 |
self._save_parent_nodes()
|
| 245 |
|
| 246 |
if not regular_docs:
|
|
|
|
| 249 |
bs = max(1, batch_size)
|
| 250 |
col = self.collection
|
| 251 |
|
| 252 |
+
# Fallback if no collection available
|
| 253 |
if col is None:
|
| 254 |
return self.add_documents(regular_docs, ids=regular_ids, batch_size=bs) + parent_count
|
| 255 |
|
| 256 |
+
# Upsert in batches
|
| 257 |
total = 0
|
| 258 |
for start in range(0, len(regular_docs), bs):
|
| 259 |
batch = regular_docs[start : start + bs]
|
|
|
|
| 265 |
col.upsert(ids=batch_ids, documents=texts, metadatas=metas, embeddings=embs)
|
| 266 |
total += len(batch)
|
| 267 |
|
| 268 |
+
logger.info(f"Upserted {total} documents to vector store")
|
| 269 |
return total + parent_count
|
| 270 |
|
| 271 |
def count(self) -> int:
|
| 272 |
+
|
| 273 |
col = self.collection
|
| 274 |
return int(col.count()) if col else 0
|
| 275 |
|
| 276 |
def get_all_documents(self, limit: int = 5000) -> List[Dict[str, Any]]:
|
| 277 |
+
|
| 278 |
col = self.collection
|
| 279 |
if col is None:
|
| 280 |
return []
|
|
|
|
| 291 |
return docs
|
| 292 |
|
| 293 |
def delete_documents(self, ids: Sequence[str]) -> int:
|
| 294 |
+
|
| 295 |
if not ids:
|
| 296 |
return 0
|
| 297 |
|
|
|
|
| 300 |
return 0
|
| 301 |
|
| 302 |
col.delete(ids=list(ids))
|
| 303 |
+
logger.info(f"Deleted {len(ids)} documents from vector store")
|
| 304 |
return len(ids)
|
| 305 |
|
| 306 |
def get_parent_node(self, parent_id: str) -> Optional[Dict[str, Any]]:
|
| 307 |
+
|
| 308 |
return self._parent_nodes.get(parent_id)
|
| 309 |
|
| 310 |
@property
|
| 311 |
def parent_nodes(self) -> Dict[str, Dict[str, Any]]:
|
| 312 |
+
|
| 313 |
return self._parent_nodes
|
evaluation/eval_utils.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
"""Các utility functions cho evaluation."""
|
| 2 |
-
|
| 3 |
import os
|
| 4 |
import sys
|
| 5 |
import re
|
|
@@ -17,17 +15,17 @@ load_dotenv(find_dotenv(usecwd=True))
|
|
| 17 |
from openai import OpenAI
|
| 18 |
from core.rag.embedding_model import EmbeddingConfig, QwenEmbeddings
|
| 19 |
from core.rag.vector_store import ChromaConfig, ChromaVectorDB
|
| 20 |
-
from core.rag.
|
| 21 |
from core.rag.generator import RAGGenerator
|
| 22 |
|
| 23 |
|
| 24 |
def strip_thinking(text: str) -> str:
|
| 25 |
-
|
| 26 |
return re.sub(r'<think>.*?</think>\s*', '', text, flags=re.DOTALL).strip()
|
| 27 |
|
| 28 |
|
| 29 |
def load_csv_data(csv_path: str, sample_size: int = 0) -> tuple[list, list]:
|
| 30 |
-
|
| 31 |
questions, ground_truths = [], []
|
| 32 |
with open(csv_path, 'r', encoding='utf-8') as f:
|
| 33 |
for row in csv.DictReader(f):
|
|
@@ -35,7 +33,7 @@ def load_csv_data(csv_path: str, sample_size: int = 0) -> tuple[list, list]:
|
|
| 35 |
questions.append(row['question'])
|
| 36 |
ground_truths.append(row['ground_truth'])
|
| 37 |
|
| 38 |
-
#
|
| 39 |
if sample_size > 0:
|
| 40 |
questions = questions[:sample_size]
|
| 41 |
ground_truths = ground_truths[:sample_size]
|
|
@@ -44,16 +42,16 @@ def load_csv_data(csv_path: str, sample_size: int = 0) -> tuple[list, list]:
|
|
| 44 |
|
| 45 |
|
| 46 |
def init_rag() -> tuple[RAGGenerator, QwenEmbeddings, OpenAI]:
|
| 47 |
-
|
| 48 |
embeddings = QwenEmbeddings(EmbeddingConfig())
|
| 49 |
db = ChromaVectorDB(embedder=embeddings, config=ChromaConfig())
|
| 50 |
retriever = Retriever(vector_db=db)
|
| 51 |
rag = RAGGenerator(retriever=retriever)
|
| 52 |
|
| 53 |
-
#
|
| 54 |
api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
| 55 |
if not api_key:
|
| 56 |
-
raise ValueError("
|
| 57 |
|
| 58 |
llm_client = OpenAI(api_key=api_key, base_url="https://api.siliconflow.com/v1", timeout=60.0)
|
| 59 |
return rag, embeddings, llm_client
|
|
@@ -67,18 +65,18 @@ def generate_answers(
|
|
| 67 |
retrieval_mode: str = "hybrid_rerank",
|
| 68 |
max_workers: int = 8,
|
| 69 |
) -> tuple[list, list]:
|
| 70 |
-
|
| 71 |
|
| 72 |
def process(idx_q):
|
| 73 |
-
|
| 74 |
idx, q = idx_q
|
| 75 |
try:
|
| 76 |
-
# Retrieve
|
| 77 |
prepared = rag.retrieve_and_prepare(q, mode=retrieval_mode)
|
| 78 |
if not prepared["results"]:
|
| 79 |
return idx, "Không tìm thấy thông tin.", []
|
| 80 |
|
| 81 |
-
#
|
| 82 |
resp = llm_client.chat.completions.create(
|
| 83 |
model=llm_model,
|
| 84 |
messages=[{"role": "user", "content": prepared["prompt"]}],
|
|
@@ -88,20 +86,20 @@ def generate_answers(
|
|
| 88 |
answer = strip_thinking(resp.choices[0].message.content or "")
|
| 89 |
return idx, answer, prepared["contexts"]
|
| 90 |
except Exception as e:
|
| 91 |
-
print(f" Q{idx+1}
|
| 92 |
return idx, "Không thể trả lời.", []
|
| 93 |
|
| 94 |
n = len(questions)
|
| 95 |
answers, contexts = [""] * n, [[] for _ in range(n)]
|
| 96 |
|
| 97 |
-
print(f"
|
| 98 |
|
| 99 |
-
#
|
| 100 |
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 101 |
futures = {executor.submit(process, (i, q)): i for i, q in enumerate(questions)}
|
| 102 |
for i, future in enumerate(as_completed(futures), 1):
|
| 103 |
idx, ans, ctx = future.result(timeout=120)
|
| 104 |
answers[idx], contexts[idx] = ans, ctx
|
| 105 |
-
print(f" [{i}/{n}]
|
| 106 |
|
| 107 |
return answers, contexts
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import sys
|
| 3 |
import re
|
|
|
|
| 15 |
from openai import OpenAI
|
| 16 |
from core.rag.embedding_model import EmbeddingConfig, QwenEmbeddings
|
| 17 |
from core.rag.vector_store import ChromaConfig, ChromaVectorDB
|
| 18 |
+
from core.rag.retrieval import Retriever
|
| 19 |
from core.rag.generator import RAGGenerator
|
| 20 |
|
| 21 |
|
| 22 |
def strip_thinking(text: str) -> str:
|
| 23 |
+
|
| 24 |
return re.sub(r'<think>.*?</think>\s*', '', text, flags=re.DOTALL).strip()
|
| 25 |
|
| 26 |
|
| 27 |
def load_csv_data(csv_path: str, sample_size: int = 0) -> tuple[list, list]:
|
| 28 |
+
|
| 29 |
questions, ground_truths = [], []
|
| 30 |
with open(csv_path, 'r', encoding='utf-8') as f:
|
| 31 |
for row in csv.DictReader(f):
|
|
|
|
| 33 |
questions.append(row['question'])
|
| 34 |
ground_truths.append(row['ground_truth'])
|
| 35 |
|
| 36 |
+
# Limit sample size
|
| 37 |
if sample_size > 0:
|
| 38 |
questions = questions[:sample_size]
|
| 39 |
ground_truths = ground_truths[:sample_size]
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def init_rag() -> tuple[RAGGenerator, QwenEmbeddings, OpenAI]:
|
| 45 |
+
|
| 46 |
embeddings = QwenEmbeddings(EmbeddingConfig())
|
| 47 |
db = ChromaVectorDB(embedder=embeddings, config=ChromaConfig())
|
| 48 |
retriever = Retriever(vector_db=db)
|
| 49 |
rag = RAGGenerator(retriever=retriever)
|
| 50 |
|
| 51 |
+
# Initialize LLM client
|
| 52 |
api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
| 53 |
if not api_key:
|
| 54 |
+
raise ValueError("Missing SILICONFLOW_API_KEY")
|
| 55 |
|
| 56 |
llm_client = OpenAI(api_key=api_key, base_url="https://api.siliconflow.com/v1", timeout=60.0)
|
| 57 |
return rag, embeddings, llm_client
|
|
|
|
| 65 |
retrieval_mode: str = "hybrid_rerank",
|
| 66 |
max_workers: int = 8,
|
| 67 |
) -> tuple[list, list]:
|
| 68 |
+
|
| 69 |
|
| 70 |
def process(idx_q):
|
| 71 |
+
|
| 72 |
idx, q = idx_q
|
| 73 |
try:
|
| 74 |
+
# Retrieve and prepare context
|
| 75 |
prepared = rag.retrieve_and_prepare(q, mode=retrieval_mode)
|
| 76 |
if not prepared["results"]:
|
| 77 |
return idx, "Không tìm thấy thông tin.", []
|
| 78 |
|
| 79 |
+
# Call LLM to generate answer
|
| 80 |
resp = llm_client.chat.completions.create(
|
| 81 |
model=llm_model,
|
| 82 |
messages=[{"role": "user", "content": prepared["prompt"]}],
|
|
|
|
| 86 |
answer = strip_thinking(resp.choices[0].message.content or "")
|
| 87 |
return idx, answer, prepared["contexts"]
|
| 88 |
except Exception as e:
|
| 89 |
+
print(f" Q{idx+1} Error: {e}")
|
| 90 |
return idx, "Không thể trả lời.", []
|
| 91 |
|
| 92 |
n = len(questions)
|
| 93 |
answers, contexts = [""] * n, [[] for _ in range(n)]
|
| 94 |
|
| 95 |
+
print(f" Generating {n} answers...")
|
| 96 |
|
| 97 |
+
# Parallel processing with ThreadPoolExecutor
|
| 98 |
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 99 |
futures = {executor.submit(process, (i, q)): i for i, q in enumerate(questions)}
|
| 100 |
for i, future in enumerate(as_completed(futures), 1):
|
| 101 |
idx, ans, ctx = future.result(timeout=120)
|
| 102 |
answers[idx], contexts[idx] = ans, ctx
|
| 103 |
+
print(f" [{i}/{n}] Done")
|
| 104 |
|
| 105 |
return answers, contexts
|
evaluation/ragas_eval.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
"""Script đánh giá RAG bằng RAGAS framework."""
|
| 2 |
-
|
| 3 |
import os
|
| 4 |
import sys
|
| 5 |
import json
|
|
@@ -23,34 +21,34 @@ from ragas.run_config import RunConfig
|
|
| 23 |
|
| 24 |
from evaluation.eval_utils import load_csv_data, init_rag, generate_answers
|
| 25 |
|
| 26 |
-
#
|
| 27 |
-
CSV_PATH = "data/data.csv"
|
| 28 |
-
OUTPUT_DIR = "evaluation/results"
|
| 29 |
-
LLM_MODEL = os.getenv("EVAL_LLM_MODEL", "nex-agi/DeepSeek-V3.1-Nex-N1")
|
| 30 |
API_BASE = "https://api.siliconflow.com/v1"
|
| 31 |
|
| 32 |
|
| 33 |
def run_evaluation(sample_size: int = 10, retrieval_mode: str = "hybrid_rerank") -> dict:
|
| 34 |
-
|
| 35 |
print(f"\n{'='*60}")
|
| 36 |
print(f"RAGAS EVALUATION - Mode: {retrieval_mode}")
|
| 37 |
print(f"{'='*60}")
|
| 38 |
|
| 39 |
-
#
|
| 40 |
rag, embeddings, llm_client = init_rag()
|
| 41 |
|
| 42 |
-
#
|
| 43 |
questions, ground_truths = load_csv_data(str(REPO_ROOT / CSV_PATH), sample_size)
|
| 44 |
-
print(f"
|
| 45 |
|
| 46 |
-
# Generate
|
| 47 |
answers, contexts = generate_answers(
|
| 48 |
rag, questions, llm_client,
|
| 49 |
llm_model=LLM_MODEL,
|
| 50 |
retrieval_mode=retrieval_mode,
|
| 51 |
)
|
| 52 |
|
| 53 |
-
#
|
| 54 |
api_key = os.getenv("SILICONFLOW_API_KEY", "")
|
| 55 |
evaluator_llm = LangchainLLMWrapper(ChatOpenAI(
|
| 56 |
model=LLM_MODEL,
|
|
@@ -62,7 +60,7 @@ def run_evaluation(sample_size: int = 10, retrieval_mode: str = "hybrid_rerank")
|
|
| 62 |
))
|
| 63 |
evaluator_embeddings = LangchainEmbeddingsWrapper(embeddings)
|
| 64 |
|
| 65 |
-
#
|
| 66 |
dataset = Dataset.from_dict({
|
| 67 |
"question": questions,
|
| 68 |
"answer": answers,
|
|
@@ -70,8 +68,8 @@ def run_evaluation(sample_size: int = 10, retrieval_mode: str = "hybrid_rerank")
|
|
| 70 |
"ground_truth": ground_truths,
|
| 71 |
})
|
| 72 |
|
| 73 |
-
#
|
| 74 |
-
print("\n
|
| 75 |
results = evaluate(
|
| 76 |
dataset=dataset,
|
| 77 |
metrics=[
|
|
@@ -79,9 +77,9 @@ def run_evaluation(sample_size: int = 10, retrieval_mode: str = "hybrid_rerank")
|
|
| 79 |
answer_relevancy, # Độ liên quan của câu trả lời
|
| 80 |
context_precision, # Độ chính xác của context
|
| 81 |
context_recall, # Độ bao phủ của context
|
| 82 |
-
RougeScore(rouge_type='rouge1', mode='fmeasure'),
|
| 83 |
-
RougeScore(rouge_type='rouge2', mode='fmeasure'),
|
| 84 |
-
RougeScore(rouge_type='rougeL', mode='fmeasure'),
|
| 85 |
],
|
| 86 |
llm=evaluator_llm,
|
| 87 |
embeddings=evaluator_embeddings,
|
|
@@ -89,37 +87,47 @@ def run_evaluation(sample_size: int = 10, retrieval_mode: str = "hybrid_rerank")
|
|
| 89 |
run_config=RunConfig(max_workers=8, timeout=600, max_retries=3),
|
| 90 |
)
|
| 91 |
|
| 92 |
-
#
|
| 93 |
df = results.to_pandas()
|
| 94 |
metric_cols = [c for c in df.columns if c not in ("question", "answer", "contexts", "ground_truth", "user_input", "response", "reference", "retrieved_contexts")]
|
| 95 |
|
| 96 |
-
#
|
| 97 |
avg_scores = {}
|
| 98 |
for col in metric_cols:
|
| 99 |
values = df[col].dropna().tolist()
|
| 100 |
if values:
|
| 101 |
avg_scores[col] = sum(values) / len(values)
|
| 102 |
|
| 103 |
-
#
|
| 104 |
out_path = REPO_ROOT / OUTPUT_DIR
|
| 105 |
out_path.mkdir(parents=True, exist_ok=True)
|
| 106 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 107 |
|
| 108 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
csv_path = out_path / f"ragas_{retrieval_mode}_{timestamp}.csv"
|
| 110 |
with open(csv_path, 'w', encoding='utf-8') as f:
|
| 111 |
f.write("retrieval_mode,sample_size," + ",".join(avg_scores.keys()) + "\n")
|
| 112 |
f.write(f"{retrieval_mode},{len(questions)}," + ",".join(f"{v:.4f}" for v in avg_scores.values()) + "\n")
|
| 113 |
|
| 114 |
-
#
|
| 115 |
print(f"\n{'='*60}")
|
| 116 |
-
print(f"
|
| 117 |
print(f"{'='*60}")
|
| 118 |
for metric, score in avg_scores.items():
|
| 119 |
bar = "#" * int(score * 20) + "-" * (20 - int(score * 20))
|
| 120 |
print(f" {metric:25} [{bar}] {score:.4f}")
|
| 121 |
|
| 122 |
-
print(f"\
|
| 123 |
-
print(f"
|
| 124 |
|
| 125 |
return avg_scores
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import sys
|
| 3 |
import json
|
|
|
|
| 21 |
|
| 22 |
from evaluation.eval_utils import load_csv_data, init_rag, generate_answers
|
| 23 |
|
| 24 |
+
# Configuration
|
| 25 |
+
CSV_PATH = "data/data.csv"
|
| 26 |
+
OUTPUT_DIR = "evaluation/results"
|
| 27 |
+
LLM_MODEL = os.getenv("EVAL_LLM_MODEL", "nex-agi/DeepSeek-V3.1-Nex-N1")
|
| 28 |
API_BASE = "https://api.siliconflow.com/v1"
|
| 29 |
|
| 30 |
|
| 31 |
def run_evaluation(sample_size: int = 10, retrieval_mode: str = "hybrid_rerank") -> dict:
|
| 32 |
+
|
| 33 |
print(f"\n{'='*60}")
|
| 34 |
print(f"RAGAS EVALUATION - Mode: {retrieval_mode}")
|
| 35 |
print(f"{'='*60}")
|
| 36 |
|
| 37 |
+
# Initialize RAG components
|
| 38 |
rag, embeddings, llm_client = init_rag()
|
| 39 |
|
| 40 |
+
# Load test data
|
| 41 |
questions, ground_truths = load_csv_data(str(REPO_ROOT / CSV_PATH), sample_size)
|
| 42 |
+
print(f" Loaded {len(questions)} samples")
|
| 43 |
|
| 44 |
+
# Generate answers
|
| 45 |
answers, contexts = generate_answers(
|
| 46 |
rag, questions, llm_client,
|
| 47 |
llm_model=LLM_MODEL,
|
| 48 |
retrieval_mode=retrieval_mode,
|
| 49 |
)
|
| 50 |
|
| 51 |
+
# Setup RAGAS evaluator
|
| 52 |
api_key = os.getenv("SILICONFLOW_API_KEY", "")
|
| 53 |
evaluator_llm = LangchainLLMWrapper(ChatOpenAI(
|
| 54 |
model=LLM_MODEL,
|
|
|
|
| 60 |
))
|
| 61 |
evaluator_embeddings = LangchainEmbeddingsWrapper(embeddings)
|
| 62 |
|
| 63 |
+
# Convert data to Dataset format
|
| 64 |
dataset = Dataset.from_dict({
|
| 65 |
"question": questions,
|
| 66 |
"answer": answers,
|
|
|
|
| 68 |
"ground_truth": ground_truths,
|
| 69 |
})
|
| 70 |
|
| 71 |
+
# Run RAGAS evaluation
|
| 72 |
+
print("\n Running RAGAS metrics...")
|
| 73 |
results = evaluate(
|
| 74 |
dataset=dataset,
|
| 75 |
metrics=[
|
|
|
|
| 77 |
answer_relevancy, # Độ liên quan của câu trả lời
|
| 78 |
context_precision, # Độ chính xác của context
|
| 79 |
context_recall, # Độ bao phủ của context
|
| 80 |
+
RougeScore(rouge_type='rouge1', mode='fmeasure'),
|
| 81 |
+
RougeScore(rouge_type='rouge2', mode='fmeasure'),
|
| 82 |
+
RougeScore(rouge_type='rougeL', mode='fmeasure'),
|
| 83 |
],
|
| 84 |
llm=evaluator_llm,
|
| 85 |
embeddings=evaluator_embeddings,
|
|
|
|
| 87 |
run_config=RunConfig(max_workers=8, timeout=600, max_retries=3),
|
| 88 |
)
|
| 89 |
|
| 90 |
+
# Extract scores
|
| 91 |
df = results.to_pandas()
|
| 92 |
metric_cols = [c for c in df.columns if c not in ("question", "answer", "contexts", "ground_truth", "user_input", "response", "reference", "retrieved_contexts")]
|
| 93 |
|
| 94 |
+
# Calculate average score for each metric
|
| 95 |
avg_scores = {}
|
| 96 |
for col in metric_cols:
|
| 97 |
values = df[col].dropna().tolist()
|
| 98 |
if values:
|
| 99 |
avg_scores[col] = sum(values) / len(values)
|
| 100 |
|
| 101 |
+
# Save results
|
| 102 |
out_path = REPO_ROOT / OUTPUT_DIR
|
| 103 |
out_path.mkdir(parents=True, exist_ok=True)
|
| 104 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 105 |
|
| 106 |
+
# Save JSON file (detailed)
|
| 107 |
+
json_path = out_path / f"ragas_{retrieval_mode}_{timestamp}.json"
|
| 108 |
+
with open(json_path, 'w', encoding='utf-8') as f:
|
| 109 |
+
json.dump({
|
| 110 |
+
"retrieval_mode": retrieval_mode,
|
| 111 |
+
"sample_size": len(questions),
|
| 112 |
+
"timestamp": timestamp,
|
| 113 |
+
"scores": avg_scores,
|
| 114 |
+
}, f, ensure_ascii=False, indent=2)
|
| 115 |
+
|
| 116 |
+
# Save CSV file (summary)
|
| 117 |
csv_path = out_path / f"ragas_{retrieval_mode}_{timestamp}.csv"
|
| 118 |
with open(csv_path, 'w', encoding='utf-8') as f:
|
| 119 |
f.write("retrieval_mode,sample_size," + ",".join(avg_scores.keys()) + "\n")
|
| 120 |
f.write(f"{retrieval_mode},{len(questions)}," + ",".join(f"{v:.4f}" for v in avg_scores.values()) + "\n")
|
| 121 |
|
| 122 |
+
# Print results
|
| 123 |
print(f"\n{'='*60}")
|
| 124 |
+
print(f"RESULTS - {retrieval_mode} ({len(questions)} samples)")
|
| 125 |
print(f"{'='*60}")
|
| 126 |
for metric, score in avg_scores.items():
|
| 127 |
bar = "#" * int(score * 20) + "-" * (20 - int(score * 20))
|
| 128 |
print(f" {metric:25} [{bar}] {score:.4f}")
|
| 129 |
|
| 130 |
+
print(f"\nSaved: {json_path}")
|
| 131 |
+
print(f"Saved: {csv_path}")
|
| 132 |
|
| 133 |
return avg_scores
|
scripts/build_data.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
"""Script build ChromaDB từ markdown files với incremental update."""
|
| 2 |
-
|
| 3 |
import sys
|
| 4 |
import argparse
|
| 5 |
from pathlib import Path
|
|
@@ -20,7 +18,7 @@ _hasher = HashProcessor(verbose=False)
|
|
| 20 |
|
| 21 |
|
| 22 |
def get_db_file_info(db: ChromaVectorDB) -> dict:
|
| 23 |
-
|
| 24 |
docs = db.get_all_documents()
|
| 25 |
file_to_ids = {}
|
| 26 |
file_to_hash = {}
|
|
@@ -36,7 +34,7 @@ def get_db_file_info(db: ChromaVectorDB) -> dict:
|
|
| 36 |
file_to_ids[source] = set()
|
| 37 |
file_to_ids[source].add(doc_id)
|
| 38 |
|
| 39 |
-
#
|
| 40 |
if source not in file_to_hash and content_hash:
|
| 41 |
file_to_hash[source] = content_hash
|
| 42 |
|
|
@@ -44,65 +42,65 @@ def get_db_file_info(db: ChromaVectorDB) -> dict:
|
|
| 44 |
|
| 45 |
|
| 46 |
def main():
|
| 47 |
-
parser = argparse.ArgumentParser(description="Build ChromaDB
|
| 48 |
-
parser.add_argument("--force", action="store_true", help="
|
| 49 |
-
parser.add_argument("--no-delete", action="store_true", help="
|
| 50 |
args = parser.parse_args()
|
| 51 |
|
| 52 |
print("=" * 60)
|
| 53 |
print("BUILD HUST RAG DATABASE")
|
| 54 |
print("=" * 60)
|
| 55 |
|
| 56 |
-
#
|
| 57 |
-
print("\n[1/5]
|
| 58 |
emb_cfg = EmbeddingConfig()
|
| 59 |
emb = QwenEmbeddings(emb_cfg)
|
| 60 |
print(f" Model: {emb_cfg.model}")
|
| 61 |
print(f" API: {emb_cfg.api_base_url}")
|
| 62 |
|
| 63 |
-
#
|
| 64 |
-
print("\n[2/5]
|
| 65 |
db_cfg = ChromaConfig()
|
| 66 |
db = ChromaVectorDB(embedder=emb, config=db_cfg)
|
| 67 |
old_count = db.count()
|
| 68 |
print(f" Collection: {db_cfg.collection_name}")
|
| 69 |
-
print(f"
|
| 70 |
|
| 71 |
-
#
|
| 72 |
db_info = {"ids": {}, "hashes": {}}
|
| 73 |
if not args.force and old_count > 0:
|
| 74 |
-
print("\n
|
| 75 |
db_info = get_db_file_info(db)
|
| 76 |
-
print(f"
|
| 77 |
|
| 78 |
-
#
|
| 79 |
-
print("\n[3/5]
|
| 80 |
root = REPO_ROOT / "data" / "data_process"
|
| 81 |
md_files = sorted(root.rglob("*.md"))
|
| 82 |
-
print(f"
|
| 83 |
|
| 84 |
-
#
|
| 85 |
current_files = {f.name for f in md_files}
|
| 86 |
db_files = set(db_info["ids"].keys())
|
| 87 |
|
| 88 |
-
#
|
| 89 |
files_to_delete = db_files - current_files
|
| 90 |
|
| 91 |
-
#
|
| 92 |
deleted_count = 0
|
| 93 |
if files_to_delete and not args.no_delete:
|
| 94 |
-
print(f"\n[4/5]
|
| 95 |
for filename in files_to_delete:
|
| 96 |
doc_ids = list(db_info["ids"].get(filename, []))
|
| 97 |
if doc_ids:
|
| 98 |
db.delete_documents(doc_ids)
|
| 99 |
deleted_count += len(doc_ids)
|
| 100 |
-
print(f"
|
| 101 |
else:
|
| 102 |
-
print("\n[4/5]
|
| 103 |
|
| 104 |
-
#
|
| 105 |
-
print("\n[5/5]
|
| 106 |
total_added = 0
|
| 107 |
total_updated = 0
|
| 108 |
skipped = 0
|
|
@@ -112,16 +110,16 @@ def main():
|
|
| 112 |
db_hash = db_info["hashes"].get(f.name, "")
|
| 113 |
existing_ids = db_info["ids"].get(f.name, set())
|
| 114 |
|
| 115 |
-
#
|
| 116 |
if not args.force and db_hash == file_hash:
|
| 117 |
-
print(f" [{i}/{len(md_files)}] {f.name}:
|
| 118 |
skipped += 1
|
| 119 |
continue
|
| 120 |
|
| 121 |
-
#
|
| 122 |
if existing_ids and not args.force:
|
| 123 |
db.delete_documents(list(existing_ids))
|
| 124 |
-
print(f" [{i}/{len(md_files)}] {f.name}:
|
| 125 |
is_update = True
|
| 126 |
else:
|
| 127 |
is_update = False
|
|
@@ -129,7 +127,7 @@ def main():
|
|
| 129 |
try:
|
| 130 |
docs = chunk_markdown_file(f)
|
| 131 |
if docs:
|
| 132 |
-
#
|
| 133 |
for doc in docs:
|
| 134 |
if hasattr(doc, 'metadata'):
|
| 135 |
doc.metadata["content_hash"] = file_hash
|
|
@@ -139,36 +137,36 @@ def main():
|
|
| 139 |
n = db.upsert_documents(docs)
|
| 140 |
if is_update:
|
| 141 |
total_updated += n
|
| 142 |
-
print(f" [{i}/{len(md_files)}] {f.name}: +{n} chunks
|
| 143 |
else:
|
| 144 |
total_added += n
|
| 145 |
print(f" [{i}/{len(md_files)}] {f.name}: {n} chunks")
|
| 146 |
else:
|
| 147 |
-
print(f" [{i}/{len(md_files)}] {f.name}:
|
| 148 |
except Exception as e:
|
| 149 |
-
print(f" [{i}/{len(md_files)}] {f.name}:
|
| 150 |
|
| 151 |
-
#
|
| 152 |
new_count = db.count()
|
| 153 |
has_changes = deleted_count > 0 or total_updated > 0 or total_added > 0
|
| 154 |
|
| 155 |
-
#
|
| 156 |
if has_changes:
|
| 157 |
bm25_cache = REPO_ROOT / "data" / "chroma" / "bm25_cache.pkl"
|
| 158 |
if bm25_cache.exists():
|
| 159 |
bm25_cache.unlink()
|
| 160 |
-
print("\n[!]
|
| 161 |
|
| 162 |
print(f"\n{'=' * 60}")
|
| 163 |
-
print("
|
| 164 |
print("=" * 60)
|
| 165 |
-
print(f"
|
| 166 |
-
print(f"
|
| 167 |
-
print(f"
|
| 168 |
-
print(f"
|
| 169 |
-
print(f"
|
| 170 |
|
| 171 |
-
print("\
|
| 172 |
|
| 173 |
|
| 174 |
if __name__ == "__main__":
|
|
|
|
|
|
|
|
|
|
| 1 |
import sys
|
| 2 |
import argparse
|
| 3 |
from pathlib import Path
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def get_db_file_info(db: ChromaVectorDB) -> dict:
|
| 21 |
+
|
| 22 |
docs = db.get_all_documents()
|
| 23 |
file_to_ids = {}
|
| 24 |
file_to_hash = {}
|
|
|
|
| 34 |
file_to_ids[source] = set()
|
| 35 |
file_to_ids[source].add(doc_id)
|
| 36 |
|
| 37 |
+
# Store first hash found for file
|
| 38 |
if source not in file_to_hash and content_hash:
|
| 39 |
file_to_hash[source] = content_hash
|
| 40 |
|
|
|
|
| 42 |
|
| 43 |
|
| 44 |
def main():
|
| 45 |
+
parser = argparse.ArgumentParser(description="Build ChromaDB from markdown files")
|
| 46 |
+
parser.add_argument("--force", action="store_true", help="Rebuild all files")
|
| 47 |
+
parser.add_argument("--no-delete", action="store_true", help="Don't delete orphaned docs")
|
| 48 |
args = parser.parse_args()
|
| 49 |
|
| 50 |
print("=" * 60)
|
| 51 |
print("BUILD HUST RAG DATABASE")
|
| 52 |
print("=" * 60)
|
| 53 |
|
| 54 |
+
# Step 1: Initialize embedder
|
| 55 |
+
print("\n[1/5] Initializing embedder...")
|
| 56 |
emb_cfg = EmbeddingConfig()
|
| 57 |
emb = QwenEmbeddings(emb_cfg)
|
| 58 |
print(f" Model: {emb_cfg.model}")
|
| 59 |
print(f" API: {emb_cfg.api_base_url}")
|
| 60 |
|
| 61 |
+
# Step 2: Initialize ChromaDB
|
| 62 |
+
print("\n[2/5] Initializing ChromaDB...")
|
| 63 |
db_cfg = ChromaConfig()
|
| 64 |
db = ChromaVectorDB(embedder=emb, config=db_cfg)
|
| 65 |
old_count = db.count()
|
| 66 |
print(f" Collection: {db_cfg.collection_name}")
|
| 67 |
+
print(f" Current docs: {old_count}")
|
| 68 |
|
| 69 |
+
# Get current DB state
|
| 70 |
db_info = {"ids": {}, "hashes": {}}
|
| 71 |
if not args.force and old_count > 0:
|
| 72 |
+
print("\n Scanning documents in DB...")
|
| 73 |
db_info = get_db_file_info(db)
|
| 74 |
+
print(f" Found {len(db_info['ids'])} source files in DB")
|
| 75 |
|
| 76 |
+
# Step 3: Scan markdown files
|
| 77 |
+
print("\n[3/5] Scanning markdown files...")
|
| 78 |
root = REPO_ROOT / "data" / "data_process"
|
| 79 |
md_files = sorted(root.rglob("*.md"))
|
| 80 |
+
print(f" Found {len(md_files)} markdown files on disk")
|
| 81 |
|
| 82 |
+
# Compare files on disk vs in DB
|
| 83 |
current_files = {f.name for f in md_files}
|
| 84 |
db_files = set(db_info["ids"].keys())
|
| 85 |
|
| 86 |
+
# Find files to delete (in DB but not on disk)
|
| 87 |
files_to_delete = db_files - current_files
|
| 88 |
|
| 89 |
+
# Step 4: Delete orphaned docs
|
| 90 |
deleted_count = 0
|
| 91 |
if files_to_delete and not args.no_delete:
|
| 92 |
+
print(f"\n[4/5] Cleaning up {len(files_to_delete)} deleted files...")
|
| 93 |
for filename in files_to_delete:
|
| 94 |
doc_ids = list(db_info["ids"].get(filename, []))
|
| 95 |
if doc_ids:
|
| 96 |
db.delete_documents(doc_ids)
|
| 97 |
deleted_count += len(doc_ids)
|
| 98 |
+
print(f" Deleted: {filename} ({len(doc_ids)} chunks)")
|
| 99 |
else:
|
| 100 |
+
print("\n[4/5] No files to delete")
|
| 101 |
|
| 102 |
+
# Step 5: Process markdown files (add new, update)
|
| 103 |
+
print("\n[5/5] Processing markdown files...")
|
| 104 |
total_added = 0
|
| 105 |
total_updated = 0
|
| 106 |
skipped = 0
|
|
|
|
| 110 |
db_hash = db_info["hashes"].get(f.name, "")
|
| 111 |
existing_ids = db_info["ids"].get(f.name, set())
|
| 112 |
|
| 113 |
+
# Skip if hash matches (file unchanged)
|
| 114 |
if not args.force and db_hash == file_hash:
|
| 115 |
+
print(f" [{i}/{len(md_files)}] {f.name}: SKIPPED (unchanged)")
|
| 116 |
skipped += 1
|
| 117 |
continue
|
| 118 |
|
| 119 |
+
# If file changed, delete old chunks first
|
| 120 |
if existing_ids and not args.force:
|
| 121 |
db.delete_documents(list(existing_ids))
|
| 122 |
+
print(f" [{i}/{len(md_files)}] {f.name}: UPDATED (deleted {len(existing_ids)} old chunks)")
|
| 123 |
is_update = True
|
| 124 |
else:
|
| 125 |
is_update = False
|
|
|
|
| 127 |
try:
|
| 128 |
docs = chunk_markdown_file(f)
|
| 129 |
if docs:
|
| 130 |
+
# Add hash to metadata for change detection
|
| 131 |
for doc in docs:
|
| 132 |
if hasattr(doc, 'metadata'):
|
| 133 |
doc.metadata["content_hash"] = file_hash
|
|
|
|
| 137 |
n = db.upsert_documents(docs)
|
| 138 |
if is_update:
|
| 139 |
total_updated += n
|
| 140 |
+
print(f" [{i}/{len(md_files)}] {f.name}: +{n} new chunks")
|
| 141 |
else:
|
| 142 |
total_added += n
|
| 143 |
print(f" [{i}/{len(md_files)}] {f.name}: {n} chunks")
|
| 144 |
else:
|
| 145 |
+
print(f" [{i}/{len(md_files)}] {f.name}: SKIPPED (no chunks)")
|
| 146 |
except Exception as e:
|
| 147 |
+
print(f" [{i}/{len(md_files)}] {f.name}: ERROR - {e}")
|
| 148 |
|
| 149 |
+
# Summary
|
| 150 |
new_count = db.count()
|
| 151 |
has_changes = deleted_count > 0 or total_updated > 0 or total_added > 0
|
| 152 |
|
| 153 |
+
# Delete BM25 cache if changes detected (BM25 doesn't support incremental update)
|
| 154 |
if has_changes:
|
| 155 |
bm25_cache = REPO_ROOT / "data" / "chroma" / "bm25_cache.pkl"
|
| 156 |
if bm25_cache.exists():
|
| 157 |
bm25_cache.unlink()
|
| 158 |
+
print("\n[!] Deleted BM25 cache (will auto-rebuild on next query)")
|
| 159 |
|
| 160 |
print(f"\n{'=' * 60}")
|
| 161 |
+
print("SUMMARY")
|
| 162 |
print("=" * 60)
|
| 163 |
+
print(f" Deleted (orphaned): {deleted_count} chunks")
|
| 164 |
+
print(f" Updated: {total_updated} chunks")
|
| 165 |
+
print(f" Added: {total_added} chunks")
|
| 166 |
+
print(f" Skipped: {skipped} files")
|
| 167 |
+
print(f" DB docs: {old_count} -> {new_count} ({new_count - old_count:+d})")
|
| 168 |
|
| 169 |
+
print("\nDONE!")
|
| 170 |
|
| 171 |
|
| 172 |
if __name__ == "__main__":
|
scripts/run_app.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Add project root to path
|
| 6 |
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 7 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 8 |
+
|
| 9 |
+
def check_data():
|
| 10 |
+
data_path = REPO_ROOT / "data"
|
| 11 |
+
if not data_path.exists() or not any(data_path.iterdir()):
|
| 12 |
+
print("Data folder not found. Downloading from HuggingFace...")
|
| 13 |
+
from scripts.download_data import download_data
|
| 14 |
+
download_data()
|
| 15 |
+
|
| 16 |
+
def check_env():
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
load_dotenv()
|
| 19 |
+
|
| 20 |
+
required_vars = ["GROQ_API_KEY", "SILICONFLOW_API_KEY"]
|
| 21 |
+
missing = [var for var in required_vars if not os.getenv(var)]
|
| 22 |
+
|
| 23 |
+
if missing:
|
| 24 |
+
print(f"Missing environment variables: {', '.join(missing)}")
|
| 25 |
+
print("Please create a .env file with the required variables.")
|
| 26 |
+
print("Example:")
|
| 27 |
+
print(" GROQ_API_KEY=your_groq_key")
|
| 28 |
+
print(" SILICONFLOW_API_KEY=your_siliconflow_key")
|
| 29 |
+
sys.exit(1)
|
| 30 |
+
|
| 31 |
+
def main():
|
| 32 |
+
print("=" * 60)
|
| 33 |
+
print("HUST RAG Assistant - Startup")
|
| 34 |
+
print("=" * 60)
|
| 35 |
+
|
| 36 |
+
# Check data
|
| 37 |
+
check_data()
|
| 38 |
+
|
| 39 |
+
# Check environment
|
| 40 |
+
check_env()
|
| 41 |
+
|
| 42 |
+
# Run Gradio app
|
| 43 |
+
print("\nStarting Gradio server...")
|
| 44 |
+
from core.gradio.user_gradio import demo, GRADIO_CFG
|
| 45 |
+
|
| 46 |
+
demo.launch(
|
| 47 |
+
server_name=GRADIO_CFG.server_host,
|
| 48 |
+
server_port=GRADIO_CFG.server_port
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
scripts/run_eval.py
CHANGED
|
@@ -8,19 +8,19 @@ if str(REPO_ROOT) not in sys.path:
|
|
| 8 |
|
| 9 |
|
| 10 |
def main():
|
| 11 |
-
parser = argparse.ArgumentParser(description="
|
| 12 |
-
parser.add_argument("--samples", type=int, default=10, help="
|
| 13 |
parser.add_argument("--mode", type=str, default="hybrid_rerank",
|
| 14 |
choices=["vector_only", "bm25_only", "hybrid", "hybrid_rerank", "all"],
|
| 15 |
-
help="
|
| 16 |
args = parser.parse_args()
|
| 17 |
|
| 18 |
from evaluation.ragas_eval import run_evaluation
|
| 19 |
|
| 20 |
if args.mode == "all":
|
| 21 |
-
#
|
| 22 |
print("\n" + "=" * 60)
|
| 23 |
-
print("
|
| 24 |
print("=" * 60)
|
| 25 |
for mode in ["vector_only", "bm25_only", "hybrid", "hybrid_rerank"]:
|
| 26 |
run_evaluation(args.samples, mode)
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
def main():
|
| 11 |
+
parser = argparse.ArgumentParser(description="Evaluate RAG with RAGAS")
|
| 12 |
+
parser.add_argument("--samples", type=int, default=10, help="Number of samples (0 = all)")
|
| 13 |
parser.add_argument("--mode", type=str, default="hybrid_rerank",
|
| 14 |
choices=["vector_only", "bm25_only", "hybrid", "hybrid_rerank", "all"],
|
| 15 |
+
help="Retrieval mode")
|
| 16 |
args = parser.parse_args()
|
| 17 |
|
| 18 |
from evaluation.ragas_eval import run_evaluation
|
| 19 |
|
| 20 |
if args.mode == "all":
|
| 21 |
+
# Run all retrieval modes
|
| 22 |
print("\n" + "=" * 60)
|
| 23 |
+
print("RUNNING ALL RETRIEVAL MODES")
|
| 24 |
print("=" * 60)
|
| 25 |
for mode in ["vector_only", "bm25_only", "hybrid", "hybrid_rerank"]:
|
| 26 |
run_evaluation(args.samples, mode)
|
setup.bat
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
echo HUST RAG - Setup Script
|
| 3 |
+
echo.
|
| 4 |
+
|
| 5 |
+
echo [1/5] Checking Python...
|
| 6 |
+
python --version 2>nul || (echo Error: Python not found & exit /b 1)
|
| 7 |
+
|
| 8 |
+
echo [2/5] Creating virtual environment...
|
| 9 |
+
if exist "venv" if not exist "venv\Scripts\activate.bat" (
|
| 10 |
+
echo Removing broken venv...
|
| 11 |
+
rmdir /s /q venv
|
| 12 |
+
)
|
| 13 |
+
if not exist "venv" (
|
| 14 |
+
python -m venv venv || (echo Error: Cannot create venv & exit /b 1)
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
echo [3/5] Installing dependencies...
|
| 18 |
+
call venv\Scripts\activate.bat
|
| 19 |
+
pip install --upgrade pip -q
|
| 20 |
+
pip install -r requirements.txt -q
|
| 21 |
+
|
| 22 |
+
echo [4/5] Downloading data...
|
| 23 |
+
if not exist "data\chroma" python scripts\download_data.py
|
| 24 |
+
|
| 25 |
+
echo [5/5] Creating .env...
|
| 26 |
+
if not exist ".env" (
|
| 27 |
+
echo SILICONFLOW_API_KEY=your_key_here> .env
|
| 28 |
+
echo GROQ_API_KEY=your_key_here>> .env
|
| 29 |
+
echo Please edit .env with your API keys
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
echo.
|
| 33 |
+
echo Setup complete!
|
| 34 |
+
echo Run: venv\Scripts\activate ^& python scripts\run_app.py
|
| 35 |
+
pause
|
setup.sh
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "HUST RAG - Setup Script"
|
| 5 |
+
echo ""
|
| 6 |
+
|
| 7 |
+
echo "[1/5] Checking Python..."
|
| 8 |
+
python3 --version || { echo "Error: Python 3.10+ required"; exit 1; }
|
| 9 |
+
|
| 10 |
+
echo "[2/5] Creating virtual environment..."
|
| 11 |
+
|
| 12 |
+
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
|
| 13 |
+
echo "Removing broken venv..."
|
| 14 |
+
rm -rf venv
|
| 15 |
+
fi
|
| 16 |
+
|
| 17 |
+
if [ ! -d "venv" ]; then
|
| 18 |
+
python3 -m venv venv || { echo "Error: Cannot create venv. Run: sudo apt install python3-venv"; exit 1; }
|
| 19 |
+
fi
|
| 20 |
+
|
| 21 |
+
echo "[3/5] Installing dependencies..."
|
| 22 |
+
source venv/bin/activate
|
| 23 |
+
pip install --upgrade pip -q
|
| 24 |
+
pip install -r requirements.txt -q
|
| 25 |
+
|
| 26 |
+
echo "[4/5] Downloading data..."
|
| 27 |
+
[ -d "data/chroma" ] || python scripts/download_data.py
|
| 28 |
+
|
| 29 |
+
echo "[5/5] Creating .env..."
|
| 30 |
+
if [ ! -f ".env" ]; then
|
| 31 |
+
echo "SILICONFLOW_API_KEY=your_key_here" > .env
|
| 32 |
+
echo "GROQ_API_KEY=your_key_here" >> .env
|
| 33 |
+
echo "Please edit .env with your API keys"
|
| 34 |
+
fi
|
| 35 |
+
|
| 36 |
+
echo ""
|
| 37 |
+
echo "Setup complete!"
|
| 38 |
+
echo "Run: source venv/bin/activate && python scripts/run_app.py"
|
test_chunk.md
DELETED
|
@@ -1,696 +0,0 @@
|
|
| 1 |
-
# NODE 0
|
| 2 |
-
**Loại:** TextNode
|
| 3 |
-
|
| 4 |
-
**Metadata:**
|
| 5 |
-
- document_type: chuong_trinh_dao_tao
|
| 6 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 7 |
-
- program_code: ME1
|
| 8 |
-
- faculty: Trường Cơ khí
|
| 9 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 10 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 11 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 12 |
-
- header_path: /
|
| 13 |
-
- chunk_index: 0
|
| 14 |
-
|
| 15 |
-
**Nội dung:**
|
| 16 |
-
# 1. Tên chương trình: KỸ THUẬT CƠ ĐIỆN TỬ
|
| 17 |
-
Chương trình đào tạo ngành Cơ điện tử hiện nay được xây dựng trên cơ sở phát triển chương trình đào tạo ngành Cơ điện tử năm 2009 kết hợp với sự tham khảo chương trình đào tạo ngành Cơ điện tử của các trường đại học nổi tiếng trên thế giới như Stanford, Chico (Koa Kỳ), Sibaura (Nhật Bản), Đại học Quốc gia Đài Loan (NTU)…; Chương trình được kiểm định theo tiêu chuẩn AUN -QA năm 2017;
|
| 18 |
-
Sinh viên theo học ngành này sẽ được trang bị các kiến thức cơ sở và chuyên ngành vững chắc, có kỹ năng nghề nghiệp và năng lực nghiên cứu, khả năng làm việc và sáng tạo trong mọi môi trường lao động để giải quyết những vấn đề liên quan đến nghiên cứu thiết kế, chế tạo thiết bị, hệ thống cơ điện tử và vận hành các hệ thống sản xuất công nghiệp, nhanh chóng thích ứng với môi trường làm việc của cuộc cách mạng công nghiệp 4.0.
|
| 19 |
-
|
| 20 |
-
---
|
| 21 |
-
|
| 22 |
-
# NODE 1
|
| 23 |
-
**Loại:** TextNode
|
| 24 |
-
|
| 25 |
-
**Metadata:**
|
| 26 |
-
- document_type: chuong_trinh_dao_tao
|
| 27 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 28 |
-
- program_code: ME1
|
| 29 |
-
- faculty: Trường Cơ khí
|
| 30 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 31 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 32 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 33 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 34 |
-
- chunk_index: 1
|
| 35 |
-
|
| 36 |
-
**Nội dung:**
|
| 37 |
-
# 2. Kiến thức, kỹ năng đạt được sau tốt nghiệp
|
| 38 |
-
|
| 39 |
-
## a. Kiến thức
|
| 40 |
-
Có kiến thức chuyên môn rộng và vững chắc, thích ứng tốt với những công việc phù hợp với ngành, chú trọng khả năng áp dụng kiến thức cơ sở và cốt lõi ngành Cơ điện tử kết hợp khả năng sử dụng công cụ hiện đại để nghiên cứu, thiết kế, chế tạo, xây dựng và vận hành các hệ thống/quá trình/sản phẩm Cơ điện tử.
|
| 41 |
-
|
| 42 |
-
---
|
| 43 |
-
|
| 44 |
-
# NODE 2
|
| 45 |
-
**Loại:** TextNode
|
| 46 |
-
|
| 47 |
-
**Metadata:**
|
| 48 |
-
- document_type: chuong_trinh_dao_tao
|
| 49 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 50 |
-
- program_code: ME1
|
| 51 |
-
- faculty: Trường Cơ khí
|
| 52 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 53 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 54 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 55 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 56 |
-
- chunk_index: 2
|
| 57 |
-
|
| 58 |
-
**Nội dung:**
|
| 59 |
-
## b. Kỹ năng
|
| 60 |
-
Thiết kế, chế tạo, lắp ráp, vận hành và bảo dưỡng các thiết bị, hệ thống, dây chuyền sản xuất Cơ điện tử như: Rô bốt, máy bay, ô tô… hay các hệ thống máy móc trong sản xuất công nghiệp;
|
| 61 |
-
Có kỹ năng làm việc hiệu quả trong nhóm đa ngành và trong môi trường quốc tế;
|
| 62 |
-
Có thể tham gia triển khai và thử nghiệm hệ thống/quá trình/sản phẩm/ giải pháp công nghệ kỹ thuật Cơ điện tử và năng lực vận hành/sử dụng/ khai thác hệ thống/sản phẩm/giải pháp kỹ thuật thuộc lĩnh vực Cơ điện tử.
|
| 63 |
-
|
| 64 |
-
---
|
| 65 |
-
|
| 66 |
-
# NODE 3
|
| 67 |
-
**Loại:** TextNode
|
| 68 |
-
|
| 69 |
-
**Metadata:**
|
| 70 |
-
- document_type: chuong_trinh_dao_tao
|
| 71 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 72 |
-
- program_code: ME1
|
| 73 |
-
- faculty: Trường Cơ khí
|
| 74 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 75 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 76 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 77 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 78 |
-
- chunk_index: 3
|
| 79 |
-
|
| 80 |
-
**Nội dung:**
|
| 81 |
-
## c. Ngoại ngữ
|
| 82 |
-
Sử dụng hiệu quả ngôn ngữ tiếng Anh trong giao tiếp và công việc, đạt TOEIC từ 500 điểm trở lên.
|
| 83 |
-
|
| 84 |
-
## 3.Thời gian đào tạo và khả năng học lên bậc học cao hơn
|
| 85 |
-
- Đào tạo Cử nhân: 4 năm
|
| 86 |
-
- Đào tạo Kỹ sư: 5 năm
|
| 87 |
-
- Đào tạo tích hợp Cử nhân - Thạc sĩ: 5,5 năm
|
| 88 |
-
- Đào tạo tích hợp Cử nhân - Thạc sĩ – Tiến sĩ: 8,5 năm
|
| 89 |
-
|
| 90 |
-
---
|
| 91 |
-
|
| 92 |
-
# NODE 4
|
| 93 |
-
**Loại:** TextNode
|
| 94 |
-
|
| 95 |
-
**Metadata:**
|
| 96 |
-
- document_type: chuong_trinh_dao_tao
|
| 97 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 98 |
-
- program_code: ME1
|
| 99 |
-
- faculty: Trường Cơ khí
|
| 100 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 101 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 102 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 103 |
-
- header_path: /2. Kiến thức, k�� năng đạt được sau tốt nghiệp/
|
| 104 |
-
- chunk_index: 4
|
| 105 |
-
|
| 106 |
-
**Nội dung:**
|
| 107 |
-
## 4. Danh mục học phần và thời lượng học tập:
|
| 108 |
-
Chương trình đào tạo có thể được điều chỉnh hàng năm để đảm bảo tính cập nhật với sự phát triển ển c ủa khoa học, kỹ thuật và công nghệ; tuy nhiên đảm bảo nguyên tắc không gây ảnh hưởng ngược tới kết quả người học đã tích lũy.
|
| 109 |
-
|
| 110 |
-
---
|
| 111 |
-
|
| 112 |
-
# NODE 5
|
| 113 |
-
**Loại:** TextNode
|
| 114 |
-
|
| 115 |
-
**Metadata:**
|
| 116 |
-
- document_type: chuong_trinh_dao_tao
|
| 117 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 118 |
-
- program_code: ME1
|
| 119 |
-
- faculty: Trường Cơ khí
|
| 120 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 121 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 122 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 123 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 124 |
-
- table_part: 1/2
|
| 125 |
-
- is_table: True
|
| 126 |
-
- is_parent: True
|
| 127 |
-
- node_id: f7ab4d2a-b1d3-4fb0-a188-a2b124ddc2f5
|
| 128 |
-
- chunk_index: 5
|
| 129 |
-
|
| 130 |
-
**Nội dung:**
|
| 131 |
-
| TT | MÃ SỐ | TÊN HỌC PHẦN | KHỐI LƯỢNG (Tín chỉ) |
|
| 132 |
-
|--------------------------------------------|--------------------------------------------|----------------------------------------------------------------|-------------------------|
|
| 133 |
-
| Lý luận chính trị + Pháp luật đại cương | Lý luận chính trị + Pháp luật đại cương | Lý luận chính trị + Pháp luật đại cương | 12 |
|
| 134 |
-
| 1 | SSH1110 | Những NLCB của CN Mác-Lênin I | 2(2-1-0-4) |
|
| 135 |
-
| 2 | SSH1120 | Những NLCB của CN Mác-Lênin II | 3(2-1-0-6) |
|
| 136 |
-
| 3 | SSH1050 | Tư tưởng Hồ Chí Minh | 2(2 - 0 - 0 - 4) |
|
| 137 |
-
| 4 | SSH1130 | Đường lối CM của Đảng CSVN | 3(2 - 1 - 0 - 6) |
|
| 138 |
-
| 5 | EM1170 | Pháp luật đại cương | 2(2-0-0-4) |
|
| 139 |
-
| Giáo dục thể chất | Giáo dục thể chất | Giáo dục thể chất | 5 |
|
| 140 |
-
| 6 | PE1014 | Lý luận thể dục thể thao (bắt buộc) | 1(0 - 0 - 2 - 0) |
|
| 141 |
-
| 7 | PE1024 | Bơi lội (bắt buộc) | 1(0 - 0 - 2 - 0) |
|
| 142 |
-
| 8 | | Tự chọn thể dục 1 | 1(0 - 0 - 2 - 0) |
|
| 143 |
-
| 9 | Tự chọn trong danh mục | Tự chọn thể dục 2 | 1(0 - 0 - 2 - 0) |
|
| 144 |
-
| 10 | Tự chọn trong danh mục | Tự chọn thể dục 3 | 1(0 - 0 - 2 - 0) |
|
| 145 |
-
| Giáo dục Quốc phòng - An ninh (165 tiết) | Giáo dục Quốc phòng - An ninh (165 tiết) | Giáo dục Quốc phòng - An ninh (165 tiết) | |
|
| 146 |
-
| 11 | MIL1110 | Đường lối quân sự của Đảng | 0(3 - 0 - 0 - 6) |
|
| 147 |
-
| 12 | MIL1120 | Công tác quốc phòng, an ninh | 0(3 - 0 - 0 - 6) |
|
| 148 |
-
|
| 149 |
-
---
|
| 150 |
-
|
| 151 |
-
# NODE 6
|
| 152 |
-
**Loại:** TextNode
|
| 153 |
-
|
| 154 |
-
**Metadata:**
|
| 155 |
-
- document_type: chuong_trinh_dao_tao
|
| 156 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 157 |
-
- program_code: ME1
|
| 158 |
-
- faculty: Trường Cơ khí
|
| 159 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 160 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 161 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 162 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 163 |
-
- table_part: 1/2
|
| 164 |
-
- is_table_summary: True
|
| 165 |
-
- parent_id: f7ab4d2a-b1d3-4fb0-a188-a2b124ddc2f5
|
| 166 |
-
- chunk_index: 6
|
| 167 |
-
|
| 168 |
-
**Nội dung:**
|
| 169 |
-
**Bảng này thuộc file “1.1. Kỹ thuật Cơ điện tử.md”** – là bảng tổng hợp các h���c phần (môn học) và khối lượng tín chỉ được quy định cho chương trình đào tạo Kỹ thuật Cơ‑điện tử.
|
| 170 |
-
|
| 171 |
-
### Nội dung bảng liệt kê
|
| 172 |
-
Bảng liệt kê **các học phần bắt buộc và tự chọn** trong chương trình, kèm theo mã số môn, tên học phần và số tín chỉ (khối lượng) được phân chia thành các thành phần (giờ lý thuyết – giờ thực hành – giờ thí nghiệm – giờ tự học).
|
| 173 |
-
|
| 174 |
-
### Các cột chính
|
| 175 |
-
| Cột | Nội dung |
|
| 176 |
-
|-----|----------|
|
| 177 |
-
| **TT** | Số thứ tự (cũng có một số dòng tổng hợp như “Lý luận chính trị + Pháp luật đại cương”, “Giáo dục thể chất”, “Giáo dục Quốc phòng – An ninh”). |
|
| 178 |
-
| **MÃ SỐ** | Mã số của học phần (ví dụ: SSH1110, PE1014, MIL1110…). |
|
| 179 |
-
| **TÊN HỌC PHẦN** | Tên đầy đủ của môn học. |
|
| 180 |
-
| **KHỐI LƯỢNG (Tín chỉ)** | Tổng số tín chỉ và chi tiết phân bố (giờ lý thuyết – giờ thực hành – giờ thí nghiệm – giờ tự học) trong ngoặc. |
|
| 181 |
-
|
| 182 |
-
### Thông tin quan trọng / ví dụ số liệu
|
| 183 |
-
- **Lý luận chính trị + Pháp luật đại cương**: Tổng cộng 12 tín chỉ (không chi tiết trong ngoặc).
|
| 184 |
-
- **SSH1110 – “Những NLCB của CN Mác‑Lênin I”**: 2 tín chỉ, chi tiết **2‑1‑0‑4** (2 giờ lý thuyết, 1 giờ thực hành, 0 giờ thí nghiệm, 4 giờ tự học).
|
| 185 |
-
- **SSH1120 – “Những NLCB của CN Mác‑Lênin II”**: 3 tín chỉ, chi tiết **2‑1‑0‑6**.
|
| 186 |
-
- **SSH1050 – “Tư tưởng Hồ Chí Minh”**: 2 tín chỉ, chi tiết **2‑0‑0‑4**.
|
| 187 |
-
- **EM1170 – “Pháp luật đại cương”**: 2 tín chỉ, chi tiết **2‑0‑0‑4**.
|
| 188 |
-
- **Giáo dục thể chất**: Tổng 5 tín chỉ, bao gồm các môn bắt buộc như **PE1014 – Lý luận thể dục thể thao** (1 tín chỉ, **0‑0‑2‑0**) và **PE1024 – Bơi lội** (1 tín chỉ, **0‑0‑2‑0**), cùng ba môn tự chọn mỗi môn 1 tín chỉ (**0‑0‑2‑0**).
|
| 189 |
-
- **Giáo dục Quốc phòng – An ninh (165 tiết)**: Ví dụ **MIL1110 – Đường lối quân sự của Đảng** có 0 tín chỉ tổng, nhưng chi tiết **3‑0‑0‑6** (đánh dấu 3 giờ lý thuyết, 6 giờ tự học).
|
| 190 |
-
|
| 191 |
-
### Kết luận ngắn gọn
|
| 192 |
-
Bảng này là danh sách chi tiết các học phần trong chương trình Kỹ thuật Cơ‑điện tử, nêu rõ **mã môn, tên môn và khối lượng tín chỉ** (cùng cách phân bố giờ học). Nó giúp sinh viên và nhà quản lý chương trình nắm bắt được yêu cầu học tập, số tín chỉ cần hoàn thành và cấu trúc thời gian học cho từng môn
|
| 193 |
-
|
| 194 |
-
---
|
| 195 |
-
|
| 196 |
-
# NODE 7
|
| 197 |
-
**Loại:** TextNode
|
| 198 |
-
|
| 199 |
-
**Metadata:**
|
| 200 |
-
- document_type: chuong_trinh_dao_tao
|
| 201 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 202 |
-
- program_code: ME1
|
| 203 |
-
- faculty: Trường Cơ khí
|
| 204 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 205 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 206 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 207 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 208 |
-
- table_part: 2/2
|
| 209 |
-
- is_table: True
|
| 210 |
-
- is_parent: True
|
| 211 |
-
- node_id: d02a07a0-6b6a-4372-912c-2b3668bd162d
|
| 212 |
-
- chunk_index: 7
|
| 213 |
-
|
| 214 |
-
**Nội dung:**
|
| 215 |
-
| TT | MÃ SỐ | TÊN HỌC PHẦN | KHỐI LƯỢNG (Tín chỉ) |
|
| 216 |
-
|--------------------------------------------|--------------------------------------------|----------------------------------------------------------------|-------------------------|
|
| 217 |
-
| 13 | MIL1130 | QS chung và chiến thuật, kỹ thuật bắn súng tiểu liên AK (CKC) | 0(3-0-2-8) |
|
| 218 |
-
| Tiếng Anh | Tiếng Anh | Tiếng Anh | 6 |
|
| 219 |
-
| 14 | FL1100 | Tiếng Anh I | 3(0 - 6 - 0 - 6) |
|
| 220 |
-
| 15 | FL1101 | Tiếng Anh II | 3(0 - 6 - 0 - 6) |
|
| 221 |
-
| Khối kiến thức Toán và Khoa học cơ bản | Khối kiến thức Toán và Khoa học cơ bản | Khối kiến thức Toán và Khoa học cơ bản | 32 |
|
| 222 |
-
| 16 | MI1111 | Giải tích I | 4(3-2-0-8) |
|
| 223 |
-
| 17 | MI1121 | Giải tích II | 3(2-2-0-6) |
|
| 224 |
-
| 18 | MI1131 | Giải tích III | 3(2-2-0-6) |
|
| 225 |
-
| 19 | MI1141 | Đại số | 4(3 - 2 - 0 - 8) |
|
| 226 |
-
| 20 | ME2030 | Cơ khí đại cương | 2(2-0-0-4) |
|
| 227 |
-
| 21 | PH1110 | Vật lý đại cương I | 3(2-1-1-6) |
|
| 228 |
-
| 22 | PH1120 | Vật lý đại cương II | 3(2-1-1-6) |
|
| 229 |
-
| 23 | IT1110 | Tin học đại cương | 4(3-1-1-8) |
|
| 230 |
-
| 24 | MI2110 | Phương pháp tính và Matlab | 3(2-0-2-6) |
|
| 231 |
-
| 25 | ME2011 | Đồ họa kỹ thuật I | 3(3 - 1 - 0 - 6) |
|
| 232 |
-
| Cơ sở và cốt lõi ngành | Cơ sở và cốt lõi ngành | Cơ sở và cốt lõi ngành | 47 |
|
| 233 |
-
|
| 234 |
-
---
|
| 235 |
-
|
| 236 |
-
# NODE 8
|
| 237 |
-
**Loại:** TextNode
|
| 238 |
-
|
| 239 |
-
**Metadata:**
|
| 240 |
-
- document_type: chuong_trinh_dao_tao
|
| 241 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 242 |
-
- program_code: ME1
|
| 243 |
-
- faculty: Trường Cơ khí
|
| 244 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 245 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 246 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 247 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 248 |
-
- table_part: 2/2
|
| 249 |
-
- is_table_summary: True
|
| 250 |
-
- parent_id: d02a07a0-6b6a-4372-912c-2b3668bd162d
|
| 251 |
-
- chunk_index: 8
|
| 252 |
-
|
| 253 |
-
**Nội dung:**
|
| 254 |
-
**Bảng này thuộc file “1.1. Kỹ thuật Cơ điện tử.md”**
|
| 255 |
-
|
| 256 |
-
- **Nội dung bảng:** Liệt kê các môn học (hoặc khối kiến thức) trong chương trình đào tạo Kỹ thuật Cơ‑điện tử, kèm theo mã số môn, tên môn và số tín chỉ (cùng với cách phân bố tín chỉ/giờ học).
|
| 257 |
-
|
| 258 |
-
- **Các cột chính:**
|
| 259 |
-
1. **TT** – Số thứ tự (hoặc tên khối kiến thức).
|
| 260 |
-
2. **MÃ SỐ** – Mã định danh của môn học.
|
| 261 |
-
3. **TÊN HỌC PHẦN** – Tên đầy đủ của môn hoặc khối kiến thức.
|
| 262 |
-
4. **KHỐI LƯỢNG (Tín chỉ)** – Số tín chỉ và thường đi kèm dạng “X(A‑B‑C‑D)” trong đó:
|
| 263 |
-
- **X** = tổng tín chỉ;
|
| 264 |
-
- **A** = tín chỉ lý thuyết;
|
| 265 |
-
- **B** = tín chỉ thực hành;
|
| 266 |
-
- **C** = tín chỉ thí nghiệm/lab;
|
| 267 |
-
- **D** = tổng số giờ học (theo chuẩn).
|
| 268 |
-
|
| 269 |
-
- **Một số thông tin quan trọng / ví dụ:**
|
| 270 |
-
- Môn **MIL1130 – “QS chung và chiến thuật, kỹ thuật bắn súng tiểu liên AK (CKC)”** có khối lượng **0(3‑0‑2‑8)** → 0 tín chỉ tổng, trong đó 3 tín chỉ lý thuyết, 0 thực hành, 2 lab, 8 giờ học.
|
| 271 |
-
- Các môn **Tiếng Anh I (FL1100)** và **Tiếng Anh II (FL1101)** mỗi môn có **3(0‑6‑0‑6)** → 3 tín chỉ, toàn bộ là thực hành (6 tín chỉ thực hành tương đương 6 giờ).
|
| 272 |
-
- Khối **“Toán và Khoa học cơ bản”** được gộp lại với tổng **32** tín chỉ.
|
| 273 |
-
- Các môn cơ bản như **Giải tích I (MI1111)**, **Giải tích II (MI1121)**, **Giải tích III (MI1131)**, **Đại số (MI1141)** có khối lượng từ **3‑4** tín chỉ, với phân bố lý thuyết‑thực hành rõ ràng (ví dụ: MI1111 – 4(3‑2‑0‑8)).
|
| 274 |
-
- **Cơ khí đại cương (ME2030)**: 2(2‑0‑0‑4) → 2 tín chỉ, toàn lý thuyết, 4 giờ học.
|
| 275 |
-
- **Vật lý đại cương I & II (PH1110, PH1120)**: mỗi môn 3(2‑1‑1‑6).
|
| 276 |
-
- **Tin học đại cương (IT1110)**: 4(3‑1‑1‑8).
|
| 277 |
-
- **Phương pháp tính và Matlab (MI2110)**: 3(2‑0‑2‑6).
|
| 278 |
-
|
| 279 |
-
Tóm lại, bảng này là danh sách chi tiết các môn học và khối kiến thức trong chương trình Kỹ thuật Cơ‑điện tử, kèm theo mã môn và thông tin về số tín chỉ cũng như cách phân bố tín chỉ/giờ học cho từng môn.
|
| 280 |
-
|
| 281 |
-
---
|
| 282 |
-
|
| 283 |
-
# NODE 9
|
| 284 |
-
**Loại:** TextNode
|
| 285 |
-
|
| 286 |
-
**Metadata:**
|
| 287 |
-
- document_type: chuong_trinh_dao_tao
|
| 288 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 289 |
-
- program_code: ME1
|
| 290 |
-
- faculty: Trường Cơ khí
|
| 291 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 292 |
-
- source_file: 1.1. Kỹ thuật Cơ điện t���.md
|
| 293 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 294 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 295 |
-
- table_part: 1/3
|
| 296 |
-
- is_table: True
|
| 297 |
-
- is_parent: True
|
| 298 |
-
- node_id: c2ee7f29-5ba5-4c27-b3b1-4848cf6c3bdc
|
| 299 |
-
- chunk_index: 9
|
| 300 |
-
|
| 301 |
-
**Nội dung:**
|
| 302 |
-
| TT | MÃ SỐ | TÊN HỌC PHẦN | KHỐI LƯỢNG (Tín chỉ) |
|
| 303 |
-
|--------------------------------------------|--------------------------------------------|----------------------------------------------------------------|-------------------------|
|
| 304 |
-
| 26 | ME2201 | Đồ họa kỹ thuật II | 2(2 - 1 - 0 - 4) |
|
| 305 |
-
| 27 | ME2002 | Nhập môn Cơ Điện Tử | 3(2-1-1-6) |
|
| 306 |
-
| 28 | EE2012 | Kỹ thuật điện | 2(2-1-0-4) |
|
| 307 |
-
| 29 | ET2012 | Kỹ thuật điện tử | 2(2-0-1-6) |
|
| 308 |
-
| 30 | ME2112 | Cơ học kỹ thuật I | 2(2-1-0-4) |
|
| 309 |
-
| 31 | ME2101 | Sức bền vật liệu I | 2(2 - 0 - 1 - 4) |
|
| 310 |
-
| 32 | ME2211 | Cơ học kỹ thuật II | 3(2-2-0-6) |
|
| 311 |
-
| 33 | ME2202 | Sức bền vật liệu II | 2(2 - 0 - 1 - 4) |
|
| 312 |
-
| 34 | ME2203 | Nguyên lý máy | 2(2-0-1-4) |
|
| 313 |
-
| 35 | EE3359 | LT Điều khiển tự động | 3(3 - 1 - 0 - 6) |
|
| 314 |
-
| 36 | MSE2228 | Vật liệu học | 2(2-0-1-4) |
|
| 315 |
-
| 37 | ME3212 | Chi tiết máy | 2(2 - 0 - 1 - 4) |
|
| 316 |
-
| 38 | ME3072 | Kỹ thuật đo | 2(2-0-1-4) |
|
| 317 |
-
| 39 | IT3011 | Cấu trúc dữ liệu và thuật toán | 2(2 - 1 - 0 - 4) |
|
| 318 |
-
| 40 | ME3031 | Công nghệ chế tạo máy | 3(3 - 0 - 1 - 6) |
|
| 319 |
-
|
| 320 |
-
---
|
| 321 |
-
|
| 322 |
-
# NODE 10
|
| 323 |
-
**Loại:** TextNode
|
| 324 |
-
|
| 325 |
-
**Metadata:**
|
| 326 |
-
- document_type: chuong_trinh_dao_tao
|
| 327 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 328 |
-
- program_code: ME1
|
| 329 |
-
- faculty: Trường Cơ khí
|
| 330 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 331 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 332 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 333 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 334 |
-
- table_part: 1/3
|
| 335 |
-
- is_table_summary: True
|
| 336 |
-
- parent_id: c2ee7f29-5ba5-4c27-b3b1-4848cf6c3bdc
|
| 337 |
-
- chunk_index: 10
|
| 338 |
-
|
| 339 |
-
**Nội dung:**
|
| 340 |
-
**Bảng này thuộc file **`1.1. Kỹ thuật Cơ điện tử.md`** – nó liệt kê các môn học thuộc chương trình “Kỹ thuật Cơ‑điện‑tử” kèm theo mã số môn và khối lượng tín chỉ.**
|
| 341 |
-
|
| 342 |
-
### Các cột chính của bảng
|
| 343 |
-
| Cột | Nội dung |
|
| 344 |
-
|-----|----------|
|
| 345 |
-
| **TT** | Số thứ tự trong danh sách |
|
| 346 |
-
| **MÃ SỐ** | Mã định danh của môn học (theo chuẩn trường) |
|
| 347 |
-
| **TÊN HỌC PHẦN** | Tên đầy đủ của môn học |
|
| 348 |
-
| **KHỐI LƯỢNG (Tín chỉ)** | Số tín chỉ và phân bố giờ học, thường dạng `X(Y‑Z‑W‑V)` trong đó: <br>• **X** = tổng tín chỉ <br>• **Y** = giờ lý thuyết <br>• **Z** = giờ thực hành <br>• **W** = giờ thí nghiệm/laboratory <br>• **V** = tổng giờ học (theo chuẩn 1 tín chỉ = 4‑6 giờ) |
|
| 349 |
-
|
| 350 |
-
### Thông tin quan trọng / ví dụ cụ thể
|
| 351 |
-
- **Môn “Đồ họa kỹ thuật II” (ME2201)**: khối lượng `2(2‑1‑0‑4)` → 2 tín chỉ, 2 giờ lý thuyết, 1 giờ thực hành, không có giờ thí nghiệm, tổng 4 giờ.
|
| 352 |
-
- **Môn “Nhập môn Cơ Điện Tử” (ME2002)**: `3(2‑1‑1‑6)` → 3 tín chỉ, 2 giờ lý thuyết, 1 giờ thực hành, 1 giờ thí nghiệm, tổng 6 giờ.
|
| 353 |
-
- **Môn “LT Điều khiển tự động” (EE3359)**: `3(3‑1‑0‑6)` → 3 tín chỉ, 3 giờ lý thuyết, 1 giờ thực hành, không thí nghiệm, tổng 6 giờ.
|
| 354 |
-
- Các môn khác như “Kỹ thuật điện” (EE2012), “Cơ học kỹ thuật I” (ME2112), “Sức bền vật liệu I” (ME2101) đều có khối lượng `2(2‑1‑0‑4)` hoặc `2(2‑0‑1‑4)`, cho thấy mức độ cân bằng giữa lý thuyết và thực hành/laboratory.
|
| 355 |
-
|
| 356 |
-
### Tổng quan nhanh
|
| 357 |
-
- **Số môn liệt kê**: từ TT 26 đến 39 (tổng 14 môn).
|
| 358 |
-
- **Mã số** đa dạng: bắt đầu bằng `ME`, `EE`, `ET`, `IT`, `MSE` phản ánh các ngành chuyên môn (Cơ‑điện‑tử, Điện tử, Vật liệu, Tin học…).
|
| 359 |
-
- **Khối lượng tín chỉ** chủ yếu là 2 hoặc 3 tín chỉ, với cấu trúc giờ học tiêu chuẩn của chương trình kỹ thuật.
|
| 360 |
-
|
| 361 |
-
---
|
| 362 |
-
|
| 363 |
-
# NODE 11
|
| 364 |
-
**Loại:** TextNode
|
| 365 |
-
|
| 366 |
-
**Metadata:**
|
| 367 |
-
- document_type: chuong_trinh_dao_tao
|
| 368 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 369 |
-
- program_code: ME1
|
| 370 |
-
- faculty: Trường Cơ khí
|
| 371 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 372 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 373 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 374 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 375 |
-
- table_part: 2/3
|
| 376 |
-
- is_table: True
|
| 377 |
-
- is_parent: True
|
| 378 |
-
- node_id: c7fd469a-aa76-45cb-8c11-6faecdeb3404
|
| 379 |
-
- chunk_index: 11
|
| 380 |
-
|
| 381 |
-
**Nội dung:**
|
| 382 |
-
| TT | MÃ SỐ | TÊN HỌC PHẦN | KHỐI LƯỢNG (Tín chỉ) |
|
| 383 |
-
|--------------------------------------------|--------------------------------------------|----------------------------------------------------------------|-------------------------|
|
| 384 |
-
| 41 | ME3209 | Robotics | 3(3-1-0-6) |
|
| 385 |
-
| 42 | HE2012 | Kỹ thuật nhiệt | 2(2-1-0-4) |
|
| 386 |
-
| 43 | ME3213 | Kỹ thuật lập trình trong CĐT | 3(2-2-0-6) |
|
| 387 |
-
| 44 | TE3600 | Kỹ thuật thủy khí | 2(2-1-0-4) |
|
| 388 |
-
| 45 | ME3215 | Cơ sở máy CNC | 3(3-0-1-6) |
|
| 389 |
-
| Kiến thức bổ trợ | Kiến thức bổ trợ | Kiến thức bổ trợ | 9 |
|
| 390 |
-
| 46 | EM1010 | Quản trị học đại cương | 2(2-1-0-4) |
|
| 391 |
-
| 47 | EM1180 | Văn hóa kinh doanh và tinh thần khởi nghiệp | 2(2 - 1 - 0 - 4) |
|
| 392 |
-
| 48 | ED3280 | Tâm lý học ứng dụng | 2(1-2-0-4) |
|
| 393 |
-
| 49 | ED3220 | Kỹ năng mềm | 2(1 - 2 - 0 - 4) |
|
| 394 |
-
| 50 | ET3262 | Tư duy công nghệ và thiết kế kỹ thuật | 2(1 - 2 - 0 - 4) |
|
| 395 |
-
| 51 | TEX3123 | Thiết kế mỹ thuật công nghiệp | 2(1 - 2 - 0 - 4) |
|
| 396 |
-
| 52 | ME2021 | Technical Writing and Presentation | 3(2-2-0-6) |
|
| 397 |
-
| Tự chọn theo định hướng ứng dụng (chọn theo mô đun) | Tự chọn theo định hướng ứng dụng (chọn theo mô đun) | Tự chọn theo định hướng ứng dụng (chọn theo mô đun) | |
|
| 398 |
-
| Mô đun 1: Hệ thống sản xuất tự đ���ng | Mô đun 1: Hệ thống sản xuất tự động | Mô đun 1: Hệ thống sản xuất tự động | 17 |
|
| 399 |
-
|
| 400 |
-
---
|
| 401 |
-
|
| 402 |
-
# NODE 12
|
| 403 |
-
**Loại:** TextNode
|
| 404 |
-
|
| 405 |
-
**Metadata:**
|
| 406 |
-
- document_type: chuong_trinh_dao_tao
|
| 407 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 408 |
-
- program_code: ME1
|
| 409 |
-
- faculty: Trường Cơ khí
|
| 410 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 411 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 412 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 413 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 414 |
-
- table_part: 2/3
|
| 415 |
-
- is_table_summary: True
|
| 416 |
-
- parent_id: c7fd469a-aa76-45cb-8c11-6faecdeb3404
|
| 417 |
-
- chunk_index: 12
|
| 418 |
-
|
| 419 |
-
**Nội dung:**
|
| 420 |
-
**Bảng này thuộc file “1.1. Kỹ thuật Cơ điện tử.md”**
|
| 421 |
-
|
| 422 |
-
- **Nội dung bảng:** Liệt kê các môn học (cả chuyên ngành và các môn bổ trợ) được đưa vào chương trình đào tạo Kỹ thuật Cơ điện tử, kèm theo mã số môn, tên môn và khối lượng tín chỉ.
|
| 423 |
-
|
| 424 |
-
- **Các cột chính:**
|
| 425 |
-
1. **TT** – số thứ tự trong danh sách.
|
| 426 |
-
2. **MÃ SỐ** – mã định danh của môn học (ví dụ: ME3209, HE2012…).
|
| 427 |
-
3. **TÊN HỌC PHẦN** – tên đầy đủ của môn học (ví dụ: Robotics, Kỹ thuật nhiệt, Kỹ thuật lập trình trong CĐT…).
|
| 428 |
-
4. **KHỐI LƯỢNG (Tín chỉ)** – tổng số tín chỉ và phân bố theo dạng “Lý thuyết‑Thực hành‑Thực tập‑Giờ thực tế” (ví dụ: 3(3‑1‑0‑6) nghĩa là 3 tín chỉ, trong đó 3 giờ lý thuyết, 1 giờ thực hành, 0 giờ thực tập, 6 giờ thực tế).
|
| 429 |
-
|
| 430 |
-
- **Thông tin quan trọng / ví dụ:**
|
| 431 |
-
- Các môn chuyên ngành như **Robotics (ME3209)** có khối lượng 3 tín chỉ (3‑1‑0‑6).
|
| 432 |
-
- Các môn kỹ thuật khác như **Kỹ thuật nhiệt (HE2012)**, **Kỹ thuật thủy khí (TE3600)** đều 2 tín chỉ (2‑1‑0‑4).
|
| 433 |
-
- Các môn “Kiến thức bổ trợ” tổng cộng 9 tín chỉ, không có mã số cụ thể.
|
| 434 |
-
- Các môn mềm và kỹ năng (ví dụ: **Văn hoá kinh doanh và tinh thần khởi nghiệp (EM1180)**, **Kỹ năng mềm (ED3220)**) cũng được liệt kê với khối lượng 2 tín chỉ (2‑1‑0‑4 hoặc 1‑2‑0‑4).
|
| 435 |
-
- Cuối bảng có mục “Tự chọn theo định hướng ứng dụng (chọn theo mô đun)”, cho phép sinh viên lựa chọn các môn theo mô-đun (ví dụ: Mô đun 1: Hệ thống sản xuất tự động).
|
| 436 |
-
|
| 437 |
-
Tóm lại, bảng này là danh sách chi tiết các môn học và số tín chỉ tương ứng trong chương trình Kỹ thuật Cơ điện tử, phân loại rõ ràng giữa môn chuyên ngành, kiến thức bổ trợ và các môn kỹ năng mềm.
|
| 438 |
-
|
| 439 |
-
---
|
| 440 |
-
|
| 441 |
-
# NODE 13
|
| 442 |
-
**Loại:** TextNode
|
| 443 |
-
|
| 444 |
-
**Metadata:**
|
| 445 |
-
- document_type: chuong_trinh_dao_tao
|
| 446 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 447 |
-
- program_code: ME1
|
| 448 |
-
- faculty: Trường Cơ khí
|
| 449 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 450 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 451 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 452 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 453 |
-
- table_part: 3/3
|
| 454 |
-
- is_table: True
|
| 455 |
-
- is_parent: True
|
| 456 |
-
- node_id: 07a25372-e992-4a10-9b3f-4a05fc5e1960
|
| 457 |
-
- chunk_index: 13
|
| 458 |
-
|
| 459 |
-
**Nội dung:**
|
| 460 |
-
| TT | MÃ SỐ | TÊN HỌC PHẦN | KHỐI LƯỢNG (Tín chỉ) |
|
| 461 |
-
|--------------------------------------------|--------------------------------------------|----------------------------------------------------------------|-------------------------|
|
| 462 |
-
| 53 | IT4162 | Vi xử lý | 2(2-1-0-4) |
|
| 463 |
-
| 54 | ME4511 | Cảm biến & xử lý tín hiệu | 2(2 - 1 - 0 - 4) |
|
| 464 |
-
| 55 | ME4601 | Thực tập xưởng Hệ thống SXTĐ | 2(2 - 0 - 1 - 4) |
|
| 465 |
-
| 56 | ME4181 | Phương pháp phần tử hữu hạn | 2(2 - 1 - 0 - 4) |
|
| 466 |
-
| 57 | ME4503 | ĐA TKHT Cơ khí - SXTĐ | 3(1-2-2-6) |
|
| 467 |
-
| 58 | ME4501 | PLC và mạng công nghiệp | 2(2-1-0-4) |
|
| 468 |
-
| 59 | ME4082 | Công nghệ CNC | 2(2-1-0-4) |
|
| 469 |
-
| 60 | ME4112 | Tự động hóa sản xuất | 2(2 - 1 - 0 - 4) |
|
| 470 |
-
| Mô đun 2: Thiết bị tự động | Mô đun 2: Thiết bị tự động | Mô đun 2: Thiết bị tự động | 17 |
|
| 471 |
-
| 61 | IT4162 | Vi xử lý | 2(2-1-0-4) |
|
| 472 |
-
| 62 | ME4511 | Cảm biến & xử lý tín hiệu | 2(2 - 1 - 0 - 4) |
|
| 473 |
-
| 63 | ME4602 | Thực tập xưởng TBTĐ | 2(2-0-1-4) |
|
| 474 |
-
|
| 475 |
-
---
|
| 476 |
-
|
| 477 |
-
# NODE 14
|
| 478 |
-
**Loại:** TextNode
|
| 479 |
-
|
| 480 |
-
**Metadata:**
|
| 481 |
-
- document_type: chuong_trinh_dao_tao
|
| 482 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 483 |
-
- program_code: ME1
|
| 484 |
-
- faculty: Trường Cơ khí
|
| 485 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 486 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 487 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 488 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 489 |
-
- table_part: 3/3
|
| 490 |
-
- is_table_summary: True
|
| 491 |
-
- parent_id: 07a25372-e992-4a10-9b3f-4a05fc5e1960
|
| 492 |
-
- chunk_index: 14
|
| 493 |
-
|
| 494 |
-
**Nội dung:**
|
| 495 |
-
**Bảng này thuộc file:** **1.1. Kỹ thuật Cơ điện tử.md**
|
| 496 |
-
|
| 497 |
-
**Nội dung bảng:**
|
| 498 |
-
Bảng liệt kê danh sách các môn học (học phần) thuộc chương trình “Thiết bị tự động” của ngành Kỹ thuật Cơ điện tử, kèm theo mã số môn và khối lượng tín chỉ (cấu trúc tín chỉ/giờ học).
|
| 499 |
-
|
| 500 |
-
**Các cột chính của bảng**
|
| 501 |
-
|
| 502 |
-
| Cột | Nội dung |
|
| 503 |
-
|-----|----------|
|
| 504 |
-
| **TT** | Số thứ tự trong danh sách |
|
| 505 |
-
| **MÃ SỐ** | Mã số định danh của môn học (theo chuẩn trường) |
|
| 506 |
-
| **TÊN HỌC PHẦN** | Tên đầy đủ của môn học |
|
| 507 |
-
| **KHỐI LƯỢNG (Tín chỉ)** | Số tín chỉ và phân bố giờ học (theory‑lab‑practice‑total) |
|
| 508 |
-
|
| 509 |
-
**Thông tin quan trọng / ví dụ cụ thể**
|
| 510 |
-
|
| 511 |
-
- Hầu hết các môn có **khối lượng 2 tín chỉ** với định dạng `2(2‑1‑0‑4)`, nghĩa là:
|
| 512 |
-
- 2 tín chỉ tổng cộng,
|
| 513 |
-
- 2 giờ lý thuyết, 1 giờ thực hành (lab), 0 giờ thực tập, và 4 giờ học tổng cộng mỗi tuần.
|
| 514 |
-
|
| 515 |
-
- Một số môn có cấu trúc khác, ví dụ:
|
| 516 |
-
- **ĐA TKHT Cơ khí - SXTĐ** (mã ME4503) có **3 tín chỉ** với cấu trúc `3(1‑2‑2‑6)` → 1 giờ lý thuyết, 2 giờ lab, 2 giờ thực tập, tổng 6 giờ.
|
| 517 |
-
|
| 518 |
-
- Bảng còn có một dòng tổng hợp **“Mô đun 2: Thiết bị tự động”** với **khối lượng 17 tín chỉ**, thể hiện tổng số tín chỉ của toàn bộ các môn trong mô-đun này.
|
| 519 |
-
|
| 520 |
-
- Các môn lặp lại ở phần cuối (TT 61‑63) là phiên bản cập nhật/tiếp nối của các môn đã liệt kê ở trên, ví dụ:
|
| 521 |
-
- **Vi xử lý** (IT4162) và **Cảm biến & xử lý tín hiệu** (ME4511) xuất hiện lại với cùng khối lượng tín chỉ.
|
| 522 |
-
|
| 523 |
-
Như vậy, bảng cung cấp một cái nhìn tổng quan về các học phần, mã số và khối lượng tín chỉ cần hoàn thành trong mô-đun “Thiết bị tự động” của chương trình Kỹ thuật Cơ điện tử.
|
| 524 |
-
|
| 525 |
-
---
|
| 526 |
-
|
| 527 |
-
# NODE 15
|
| 528 |
-
**Loại:** TextNode
|
| 529 |
-
|
| 530 |
-
**Metadata:**
|
| 531 |
-
- document_type: chuong_trinh_dao_tao
|
| 532 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 533 |
-
- program_code: ME1
|
| 534 |
-
- faculty: Trường Cơ khí
|
| 535 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 536 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 537 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 538 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 539 |
-
- table_part: 1/2
|
| 540 |
-
- is_table: True
|
| 541 |
-
- is_parent: True
|
| 542 |
-
- node_id: 59b96dee-6f57-4e27-b073-b66959fe2598
|
| 543 |
-
- chunk_index: 15
|
| 544 |
-
|
| 545 |
-
**Nội dung:**
|
| 546 |
-
| TT | MÃ SỐ | TÊN HỌC PHẦN | KHỐI LƯỢNG (Tín chỉ) |
|
| 547 |
-
|--------------------------------------------|--------------------------------------------|----------------------------------------------------------------|-------------------------|
|
| 548 |
-
| 64 | ME4181 | Phương pháp phần tử hữu hạn | 2(2 - 1 - 0 - 4) |
|
| 549 |
-
| 65 | ME4504 | ĐA TKHT Cơ khí - TBTĐ | 3(1-2-2-6) |
|
| 550 |
-
| 66 | ME4501 | PLC và mạng công nghiệp | 2(2-1-0-4) |
|
| 551 |
-
| 67 | ME4082 | Công nghệ CNC | 2(2-1-0-4) |
|
| 552 |
-
| 68 | ME4507 | Robot công nghiệp | 2(2-1-0-4) |
|
| 553 |
-
| Mô đun 3: Robot | Mô đun 3: Robot | Mô đun 3: Robot | 17 |
|
| 554 |
-
| 69 | IT4162 | Vi xử lý | 2(2-1-0-4) |
|
| 555 |
-
| 70 | ME4511 | Cảm biến & xử lý tín hiệu | 2(2 - 1 - 0 - 4) |
|
| 556 |
-
| 71 | ME4603 | Thực tập xưởng Robot | 2(2-1-0-4) |
|
| 557 |
-
| 72 | ME4181 | Phương pháp phần tử hữu hạn | 2(2 - 1 - 0 - 4) |
|
| 558 |
-
| 73 | ME4505 | ĐA TKHTCK - Robot | 3(1-2-2-6) |
|
| 559 |
-
| 74 | ME4508 | Giao diện người máy | 2(0-0-4-4) |
|
| 560 |
-
| 75 | ME4509 | Xử lý ảnh | 2(2-1-0-4) |
|
| 561 |
-
| 76 | ME4512 | Robot tự hành | 2(2-1-0-4) |
|
| 562 |
-
| Mô đun 4: Hệ thống cơ điện tử thông minh | Mô đun 4: Hệ thống cơ điện tử thông minh | Mô đun 4: Hệ thống cơ điện tử thông minh | 17 |
|
| 563 |
-
|
| 564 |
-
---
|
| 565 |
-
|
| 566 |
-
# NODE 16
|
| 567 |
-
**Loại:** TextNode
|
| 568 |
-
|
| 569 |
-
**Metadata:**
|
| 570 |
-
- document_type: chuong_trinh_dao_tao
|
| 571 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 572 |
-
- program_code: ME1
|
| 573 |
-
- faculty: Trường Cơ khí
|
| 574 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 575 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 576 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 577 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 578 |
-
- table_part: 1/2
|
| 579 |
-
- is_table_summary: True
|
| 580 |
-
- parent_id: 59b96dee-6f57-4e27-b073-b66959fe2598
|
| 581 |
-
- chunk_index: 16
|
| 582 |
-
|
| 583 |
-
**Nội dung:**
|
| 584 |
-
**Bảng này thuộc file “1.1. Kỹ thuật Cơ điện tử.md”** – là bảng liệt kê các môn học (học phần) trong chương trình đào tạo Kỹ thuật Cơ điện tử, kèm theo mã số, tên môn và khối lượng tín chỉ.
|
| 585 |
-
|
| 586 |
-
### Nội dung chính của bảng
|
| 587 |
-
- **Liệt kê các học phần** thuộc các mô-đun (Robot, Hệ thống cơ điện tử thông minh…) của chương trình.
|
| 588 |
-
- **Quy định** số tín chỉ và cấu trúc phân bổ giờ học (lý thuyết – thực hành – thí nghiệm – tự học) cho mỗi môn.
|
| 589 |
-
|
| 590 |
-
### Các cột chính
|
| 591 |
-
| Cột | Nội dung |
|
| 592 |
-
|-----|----------|
|
| 593 |
-
| **TT** | Số thứ tự trong danh sách |
|
| 594 |
-
| **MÃ SỐ** | Mã định danh của môn học (ví dụ: ME4181) |
|
| 595 |
-
| **TÊN HỌC PHẦN** | Tên đầy đủ của môn học (ví dụ: “Phương pháp phần tử hữu hạn”) |
|
| 596 |
-
| **KHỐI LƯỢNG (Tín chỉ)** | Tổng số tín chỉ và phân bố giờ học dưới dạng “T( L‑T‑N‑Tự )” (L = lý thuyết, T = thực hành, N = thí nghiệm, Tự = tự học). Ví dụ: **2(2‑1‑0‑4)** nghĩa là 2 tín chỉ, trong đó 2 giờ lý thuyết, 1 giờ thực hành, 0 giờ thí nghiệm, 4 giờ tự học. |
|
| 597 |
-
|
| 598 |
-
### Thông tin quan trọng / ví dụ
|
| 599 |
-
- Các môn có **khối lượng tín chỉ 2** thường có định dạng **2(2‑1‑0‑4)**; các môn 3 tín chỉ có dạng **3(1‑2‑2‑6)**.
|
| 600 |
-
- Hai dòng “Mô đun 3: Robot” và “Mô đun 4: Hệ thống cơ điện tử thông minh” không phải là môn học mà là tiêu đề mô‑đun, mỗi mô‑đun tổng cộng **17 tín chỉ**.
|
| 601 |
-
- Một số môn đặc thù:
|
| 602 |
-
- **ME4508 – Giao diện người máy** có khối lượng **2(0‑0‑4‑4)** (không có giờ lý thuyết hay thực hành, chỉ 4 giờ thí nghiệm và 4 giờ tự học).
|
| 603 |
-
- **ME4603 – Thực tập xưởng Robot** cũng có **2(2‑1‑0‑4)**, cho thấy thực tập vẫn bao gồm giờ lý thuyết và thực hành.
|
| 604 |
-
|
| 605 |
-
Tóm lại, bảng này cung cấp danh sách chi tiết các học phần trong chương trình Kỹ thuật Cơ điện tử, kèm mã số, tên môn và cấu trúc tín chỉ/giờ học cho từng môn, đồng thời tóm lược tổng tín chỉ của các mô‑đun chính.
|
| 606 |
-
|
| 607 |
-
---
|
| 608 |
-
|
| 609 |
-
# NODE 17
|
| 610 |
-
**Loại:** TextNode
|
| 611 |
-
|
| 612 |
-
**Metadata:**
|
| 613 |
-
- document_type: chuong_trinh_dao_tao
|
| 614 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 615 |
-
- program_code: ME1
|
| 616 |
-
- faculty: Trường Cơ khí
|
| 617 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 618 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 619 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 620 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 621 |
-
- table_part: 2/2
|
| 622 |
-
- is_table: True
|
| 623 |
-
- is_parent: True
|
| 624 |
-
- node_id: cda6c2e6-7fea-4fa6-b3ef-3b99e583b948
|
| 625 |
-
- chunk_index: 17
|
| 626 |
-
|
| 627 |
-
**Nội dung:**
|
| 628 |
-
| TT | MÃ SỐ | TÊN HỌC PHẦN | KHỐI LƯỢNG (Tín chỉ) |
|
| 629 |
-
|--------------------------------------------|--------------------------------------------|----------------------------------------------------------------|-------------------------|
|
| 630 |
-
| 77 | IT4162 | Vi xử lý | 2(2-1-0-4) |
|
| 631 |
-
| 78 | ME4511 | Cảm biến & xử lý tín hiệu | 2(2 - 1 - 0 - 4) |
|
| 632 |
-
| 79 | ME4604 | Thực tập xưởng HTCĐT TM | 2(2-1-0-4) |
|
| 633 |
-
| 80 | ME4181 | Phương pháp phần tử hữu hạn | 2(2 - 1 - 0 - 4) |
|
| 634 |
-
| 81 | ME4506 | ĐA TKHTCK - CĐTTM | 3(1-2-2-6) |
|
| 635 |
-
| 82 | ME4508 | Giao diện người máy | 2(0-0-4-4) |
|
| 636 |
-
| 83 | ME4509 | Xử lý ảnh | 2(2-1-0-4) |
|
| 637 |
-
| 84 | EE4829 | Điều khiển nối mạng | 2(2 - 1 - 0 - 4) |
|
| 638 |
-
| Thực tập kỹ thuật và Đồ án tốt nghiệp Cử nhân | Thực tập kỹ thuật và Đồ án tốt nghiệp Cử nhân | Thực tập kỹ thuật và Đồ án tốt nghiệp Cử nhân | 8 |
|
| 639 |
-
| 85 | ME4258 | Thực tập kỹ thuật | 2(0-0-6-4) |
|
| 640 |
-
| 86 | ME4992 | Đồ án tốt nghiệp cử nhân | 6(0 - 0 - 12 - 12) |
|
| 641 |
-
| Khối kiến thức kỹ sư | Khối kiến thức kỹ sư | Khối kiến thức kỹ sư | 35 |
|
| 642 |
-
| | | Tự chọn kỹ sư | 19 |
|
| 643 |
-
| | | Thực tập kỹ sư | 4 |
|
| 644 |
-
| | | Đồ án tốt nghiệp kỹ sư | 12 |
|
| 645 |
-
|
| 646 |
-
---
|
| 647 |
-
|
| 648 |
-
# NODE 18
|
| 649 |
-
**Loại:** TextNode
|
| 650 |
-
|
| 651 |
-
**Metadata:**
|
| 652 |
-
- document_type: chuong_trinh_dao_tao
|
| 653 |
-
- program_name: Kỹ thuật Cơ điện tử
|
| 654 |
-
- program_code: ME1
|
| 655 |
-
- faculty: Trường Cơ khí
|
| 656 |
-
- degree_levels: ['Cu nhan', 'Ky su']
|
| 657 |
-
- source_file: 1.1. Kỹ thuật Cơ điện tử.md
|
| 658 |
-
- source_path: data/data_process/chuong_trinh_dao_tao/1.1. Kỹ thuật Cơ điện tử.md
|
| 659 |
-
- header_path: /2. Kiến thức, kỹ năng đạt được sau tốt nghiệp/
|
| 660 |
-
- table_part: 2/2
|
| 661 |
-
- is_table_summary: True
|
| 662 |
-
- parent_id: cda6c2e6-7fea-4fa6-b3ef-3b99e583b948
|
| 663 |
-
- chunk_index: 18
|
| 664 |
-
|
| 665 |
-
**Nội dung:**
|
| 666 |
-
**Bảng này thuộc file 1.1. Kỹ thuật Cơ điện tử.md** – nó là **bảng liệt kê các môn học, mã số và khối lượng tín chỉ (theo dạng “tín chỉ‑giờ‑bài‑thực‑lab”) của chương trình Kỹ thuật Cơ‑điện tử**.
|
| 667 |
-
|
| 668 |
-
### Các cột chính
|
| 669 |
-
| Cột | Nội dung |
|
| 670 |
-
|-----|----------|
|
| 671 |
-
| **TT** | Số thứ tự trong danh sách môn học |
|
| 672 |
-
| **MÃ SỐ** | Mã định danh của môn (theo chuẩn trường) |
|
| 673 |
-
| **TÊN HỌC PHẦN** | Tên môn học |
|
| 674 |
-
| **KHỐI LƯỢNG (Tín chỉ)** | Tổng số tín chỉ và phân bố giờ học (giờ lý thuyết – giờ thực hành – giờ thí nghiệm – giờ lab) |
|
| 675 |
-
|
| 676 |
-
### Thông tin nổi bật
|
| 677 |
-
- **Môn học chuyên ngành**: ví dụ
|
| 678 |
-
- `IT4162 – Vi xử lý – 2(2‑1‑0‑4)` → 2 tín chỉ, gồm 2 giờ lý thuyết, 1 giờ thực hành, 0 giờ thí nghiệm, 4 giờ lab.
|
| 679 |
-
- `ME4506 – ĐA TKHTCK - CĐTTM – 3(1‑2‑2‑6)` → 3 tín chỉ, 1 giờ lý thuyết, 2 giờ thực hành, 2 giờ thí nghiệm, 6 giờ lab.
|
| 680 |
-
- `ME4508 – Giao diện người máy – 2(0‑0‑4‑4)` → 2 tín chỉ, toàn phần là thí nghiệm và lab (4 giờ mỗi loại).
|
| 681 |
-
|
| 682 |
-
- **Môn thực tập & đồ án**:
|
| 683 |
-
- `Thực tập kỹ thuật và Đồ án tốt nghiệp Cử nhân` (khối 8 tín chỉ).
|
| 684 |
-
- `ME4258 – Thực tập kỹ thuật – 2(0‑0‑6‑4)`.
|
| 685 |
-
- `ME4992 – Đồ án tốt nghiệp cử nhân – 6(0‑0‑12‑12)`.
|
| 686 |
-
|
| 687 |
-
- **Tổng khối lượng tín chỉ** (theo các nhóm kiến thức):
|
| 688 |
-
- **Khối kiến thức kỹ sư**: 35 tín chỉ.
|
| 689 |
-
- **Tự chọn kỹ sư**: 19 tín chỉ.
|
| 690 |
-
- **Thực tập kỹ sư**: 4 tín chỉ.
|
| 691 |
-
- **Đồ án tốt nghiệp kỹ sư**: 12 tín chỉ.
|
| 692 |
-
|
| 693 |
-
Như vậy, bảng cung cấp một cái nhìn tổng quan về cấu trúc môn học và phân bổ tín chỉ cho chương trình Kỹ thuật Cơ‑điện tử, giúp sinh viên và giảng viên nắm rõ yêu cầu học tập và thời lượng mỗi môn.
|
| 694 |
-
|
| 695 |
-
---
|
| 696 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|