hungnha commited on
Commit
92c9b4d
·
1 Parent(s): bf7ec12

Thay đổi promt

Browse files
.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 - Hệ thống Hỏi đáp Quy chế Sinh viên
2
 
3
- Hệ thống RAG hỗ trợ sinh viên tra cứu quy chế, quy định tại Đại học Bách khoa Nội.
4
 
5
- ## Tính năng
6
 
7
- - Hybrid Search (Vector + BM25)
8
- - Reranking với Qwen3-Reranker
9
- - Small-to-Big Retrieval cho bảng biểu
10
- - Giao diện chat Gradio
11
 
12
- ## Cài đặt
 
 
 
 
 
 
13
 
14
- **Yêu cầu:** Python 3.10+
15
 
16
- Ubuntu/Debian cần cài thêm:
17
 
18
- sudo apt update
19
- sudo apt install python3-venv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
 
21
 
22
- **Bước 1:** Chạy setup script
23
 
24
- - **Linux/Mac:** `bash setup.sh`
25
- - **Windows:** nhấp đúp `setup.bat` hoặc gõ `setup.bat` trong cmd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
- > Script sẽ: tạo venv → cài dependencies → tải data → tạo .env
28
 
29
- **Bước 2:** Cấu hình API keys
30
 
31
- Sửa file `.env`:
32
 
33
- SILICONFLOW_API_KEY=your_key # Embedding & Reranking
34
- GROQ_API_KEY=your_key # LLM Generation
 
 
35
 
36
- Lấy API keys tại: [SiliconFlow](https://siliconflow.ai/) | [Groq](https://groq.com/)
37
 
38
- **Bước 3:** Chạy ứng dụng
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  source venv/bin/activate # Linux/Mac
41
- venv\Scripts\activate # Windows
 
 
 
 
 
 
 
 
 
 
 
42
 
 
 
 
 
43
  python scripts/run_app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
 
 
 
 
 
 
 
45
 
46
- Truy cập: http://127.0.0.1:7860
47
 
48
- ## Data
 
 
 
49
 
50
- Data trên HuggingFace: [hungnha/do_an_tot_nghiep](https://huggingface.co/datasets/hungnha/do_an_tot_nghiep)
51
 
52
- Tải thủ công:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 __future__ import annotations
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.retrival import Retriever, RetrievalMode, get_retrieval_config
30
  from core.rag.generator import RAGContextBuilder, build_context, build_prompt, SYSTEM_PROMPT
31
 
32
  _load_env()
33
 
34
- RETRIEVAL_MODE = RetrievalMode.HYBRID_RERANK # Test with debug logs
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
- # Load retrieval config
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" Đang khởi tạo Database & Re-ranker...")
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 Client
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
- # RAGContextBuilder
81
  STATE.rag_builder = RAGContextBuilder(retriever=STATE.retriever)
82
 
83
- print(" Đã sẵn sàng!")
84
 
85
 
86
- def rag_chat(message: str, history: List[Dict[str, str]] | None = None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 prepare context
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 để generate answer
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
- # Lọc bỏ phần <think>...</think> trước khi yield
122
- display_text = _filter_think_tags(acc)
123
- yield display_text
124
 
125
- # Yield kết quả cuối cùng (đã lọc think)
126
- yield _filter_think_tags(acc)
 
 
 
 
 
 
127
 
128
 
129
- def _filter_think_tags(text: str) -> str:
130
- # Loại bỏ các block <think>...</think> (kể cả multi-line)
131
- filtered = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL)
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
- # Create Gradio interface
140
  demo = gr.ChatInterface(
141
- fn=rag_chat,
142
- title=f"HUST RAG Assistant",
143
- description=f"Trợ lý học vụ Đại học Bách khoa Hà Nội",
 
 
 
 
 
 
 
 
 
 
 
 
144
  examples=[
145
- "Sinh viên vi phạm quy chế thi thì bị xử lý như thế nào?",
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 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 chứa PDF gốc
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
- # Kiểm tra cache đã tồn tại chưa
21
  if cache_dir.exists() and any(cache_dir.iterdir()):
22
- print(f"Cache đã tồn tại: {cache_dir}")
23
  return cache_dir / "data_rag"
24
 
25
- print(f"Đang tải từ HuggingFace: {HF_RAW_PDF_REPO}")
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"Tìm thấy {len(pdf_files)} file PDF\n")
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
- # Bỏ qua nếu file không thay đổi (hash khớp)
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 tính hash
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"Lỗi: {rel_path} - {e}")
76
 
77
- # Hiển thị tiến độ
78
  if (idx + 1) % 20 == 0:
79
- print(f"Tiến độ: {idx + 1}/{len(pdf_files)}")
80
 
81
  return results, processed, skipped
82
 
83
 
84
  def main():
85
  import argparse
86
- parser = argparse.ArgumentParser(description="Tải PDF tạo hash index")
87
- parser.add_argument("--source", type=str, help="Đường dẫn local tới PDFs (bỏ qua tải HF)")
88
- parser.add_argument("--download-only", action="store_true", help="Chỉ tải về, không copy")
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
- # Xác định thư mục nguồn
97
  if args.source:
98
  source_root = Path(args.source)
99
  if not source_root.exists():
100
- return print(f"Không tìm thấy thư mục nguồn: {source_root}")
101
  else:
102
- # Tải từ HuggingFace
103
  source_root = download_from_hf(data_dir / "raw_pdf_cache")
104
  if args.download_only:
105
- return print(f"PDF đã cache tại: {source_root}")
106
 
107
  if not source_root.exists():
108
- return print(f"Không tìm thấy thư mục PDF: {source_root}")
109
 
110
- # Xử lý
111
  existing = load_existing_hashes(hash_file)
112
- print(f"Đã tải {len(existing)} hash từ index")
113
 
114
  results, processed, skipped = process_pdfs(source_root, files_dir, existing)
115
 
116
- # Lưu kết quả
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"\nHoàn tất! Tổng: {len(results)} | Mới: {processed} | Bỏ qua: {skipped}")
123
- print(f"File index: {hash_file}")
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
- # Hằng số
13
- CHUNK_SIZE = 8192 # Đọc file theo chunk 8KB
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
- """Khởi tạo HashProcessor."""
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
- """Tính SHA256 hash của một file."""
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"Lỗi khi đọc file {path}: {e}")
37
  return None
38
  except Exception as e:
39
- self.logger.error(f"Lỗi không xác định khi xử lý file {path}: {e}")
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
- """Quét thư mục và tính hash cho tất cả files."""
49
  source_path = Path(source_dir)
50
  if not source_path.exists():
51
- raise FileNotFoundError(f"Thư mục không tồn tại: {source_dir}")
52
 
53
  hash_to_files = defaultdict(list)
54
- self.logger.info(f"Đang quét file trong: {source_dir}")
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"Đang tính hash cho: {file_path.name}")
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"Lỗi quyền truy cập: {e}")
76
  raise
77
 
78
  return hash_to_files
79
 
80
  def load_processed_index(self, index_file: str) -> Dict:
81
- """Đọc file index đã xử lý từ JSON."""
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"Lỗi đọc file index {index_file}: {e}")
88
  return {}
89
  except Exception as e:
90
- self.logger.error(f"Lỗi không xác định khi đọc index: {e}")
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
- # Ghi vào file tạm trước
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
- # Rename file tạm thành file chính (atomic operation trên POSIX)
110
  shutil.move(temp_name, index_file)
111
- self.logger.info(f"Đã lưu index file an toàn: {index_file}")
112
 
113
  except Exception as e:
114
- self.logger.error(f"Lỗi khi lưu index file {index_file}: {e}")
115
  if temp_name and os.path.exists(temp_name):
116
  os.remove(temp_name)
117
 
118
  def get_current_timestamp(self) -> str:
119
- """Lấy timestamp hiện tại theo định dạng ISO."""
120
  return datetime.now().isoformat()
121
 
122
  def get_string_hash(self, text: str) -> str:
123
- """Tính SHA256 hash của một chuỗi text."""
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
- # Thêm project root vào path để import HashProcessor
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
- """Chuyển đổi PDF sang Markdown bằng Docling."""
26
 
27
  def __init__(self, output_dir: str, use_ocr: bool = True, timeout: int = 300, images_scale: float = 3.0):
28
- """Khởi tạo processor với cấu hình OCR và table extraction."""
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
- # File lưu hash index
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
- # Cấu hình pipeline PDF
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
- # Cấu hình OCR tiếng Việt
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
- """Xóa số trang và khoảng trắng thừa."""
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
- """Kiểm tra xem file PDF có cần xử lý lại không (dựa trên hash)."""
63
- # Nếu output chưa tồn tại -> cần xử lý
64
  if not output_path.exists():
65
  return True
66
 
67
- # Tính hash file PDF hiện tại
68
  current_hash = self.hasher.get_file_hash(pdf_path)
69
  if not current_hash:
70
  return True
71
 
72
- # So sánh với hash đã lưu
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
- """Lưu hash của file đã xử lý vào index."""
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
- """Chuyển đổi 1 file PDF sang Markdown với timeout."""
85
  if not os.path.exists(file_path):
86
  return None
87
  filename = os.path.basename(file_path)
88
  try:
89
- # Đặt timeout để tránh treo
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
- # Thêm 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"Lỗi: {filename}: {e}")
106
  signal.alarm(0)
107
  return None
108
 
109
  def parse_directory(self, source_dir: str) -> dict:
110
- """Xử toàn bộ thư mục PDF, bỏ qua file không thay đổi (dựa trên hash)."""
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
- # Kiểm tra hash để quyết định cần xử lý không
128
  if not self._should_process(pdf_path, out):
129
  results["skipped"] += 1
130
  continue
131
 
132
- # Tính hash trước khi xử lý
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
- # Lưu hash sau khi xử lý thành công
140
  if file_hash:
141
  self._save_hash(pdf_path, file_hash)
142
  else:
143
  results["errors"] += 1
144
 
145
- # Dọn memory mỗi 10 files
146
  if (i + 1) % 10 == 0:
147
  gc.collect()
148
- self.logger.info(f"{i+1}/{len(pdf_files)} (bỏ qua: {results['skipped']})")
149
 
150
- # Lưu hash index sau khi xử lý xong
151
  self.hasher.save_processed_index(str(self.hash_index_path), self.hash_index)
152
 
153
- self.logger.info(f"Xong: {results['parsed']} đã xử lý, {results['skipped']} bỏ qua, {results['errors']} lỗi")
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
- # Cấu hình đường dẫn
4
- PDF_FILE = "" # File đơn lẻ (để trống nếu muốn parse cả thư mục)
5
- SOURCE_DIR = "data/data_raw" # Thư mục chứa PDFs
6
- OUTPUT_DIR = "data" # Thư mục xuất Markdown
7
- USE_OCR = False # Bật OCR cho PDF scan
8
 
9
 
10
  if __name__ == "__main__":
11
  processor = DoclingProcessor(OUTPUT_DIR, use_ocr=USE_OCR)
12
 
13
  if PDF_FILE:
14
- # Parse 1 file đơn lẻ
15
- print(f"Đang xử lý: {PDF_FILE}")
16
  result = processor.parse_document(PDF_FILE)
17
- print("Xong!" if result else "Lỗi hoặc bỏ qua")
18
  else:
19
- # Parse cả thư mục
20
- print(f"Đang xử lý thư mục: {SOURCE_DIR}")
21
  r = processor.parse_directory(SOURCE_DIR)
22
- print(f"Tổng: {r['total']} | Thành công: {r['parsed']} | Bỏ qua: {r['skipped']} | Lỗi: {r['errors']}")
 
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
- # Cấu hình chunking
14
  CHUNK_SIZE = 1500
15
  CHUNK_OVERLAP = 150
16
  MIN_CHUNK_SIZE = 200
17
  TABLE_ROWS_PER_CHUNK = 15
18
 
19
- # Cấu hình Small-to-Big
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
- """Kiểm tra dòng có phải là hàng trong bảng Markdown không."""
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
- """Kiểm tra dòng có phải là separator của bảng (|---|---|)."""
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
- """Kiểm tra dòng có phải là header của bảng không."""
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
- """Trích xuất bảng từ text và thay bằng placeholder."""
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
- # Thay bảng bằng placeholder
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
- """Chia bảng lớn thành nhiều chunks nhỏ."""
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
- # Gộp chunk cuối nếu quá nhỏ (< 5 dòng)
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
- """Lấy Groq client để tóm tắt bảng."""
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("Chưa đặt GROQ_API_KEY. Tắt tính năng tóm tắt bảng.")
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
- """Tóm tắt bảng bằng LLM với retry logic."""
143
  import time
144
 
145
  if not ENABLE_TABLE_SUMMARY:
146
- raise RuntimeError("Tính năng tóm tắt bảng đã tắt. Đặt ENABLE_TABLE_SUMMARY = True")
147
 
148
  client = _get_summary_client()
149
  if client is None:
150
- raise RuntimeError("Chưa đặt GROQ_API_KEY. Không thể tóm tắt bảng.")
151
 
152
- # Tạo chuỗi định danh bảng
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 trả về summary rỗng")
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"Thử lại {attempt + 1}/{max_retries} cho {table_identifier}: {e}")
197
- print(f" Đợi {delay:.1f}s trước khi thử lại...")
198
  time.sleep(delay)
199
 
200
- # Tất cả retry đều thất bại
201
- raise RuntimeError(f"Không thể tóm tắt {table_identifier} sau {max_retries} l���n thử. Lỗi cuối: {last_error}")
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
- """Tạo nodes cho bảng. Bảng lớn sẽ có parent + summary node."""
213
- # Đếm số dòng để quyết định cần tóm tắt không
214
  row_count = table_text.count("\n")
215
 
216
- # Thêm thông tin bảng vào metadata
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
- # Bảng quá nhỏ, không cần tóm tắt
225
  return [TextNode(text=table_text, metadata={**table_meta, "is_table": True})]
226
 
227
- # Kiểm tra thể tóm tắt không (cần API key)
228
  if _get_summary_client() is None:
229
- # Không API key -> trả về node bảng đơn giản, không tóm tắt
230
  return [TextNode(text=table_text, metadata={**table_meta, "is_table": True})]
231
 
232
- # Tạo summary với retry logic
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
- # Tạo parent node (bảng gốc - KHÔNG embed)
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 để bỏ qua embedding
249
  "node_id": parent_id,
250
  }
251
  )
252
  parent_node.id_ = parent_id
253
 
254
- # Tạo summary node (SẼ được embed để search)
255
  summary_node = TextNode(
256
  text=summary,
257
  metadata={
258
  **table_meta,
259
  "is_table_summary": True,
260
- "parent_id": parent_id, # Link tới parent
261
  }
262
  )
263
 
264
- table_id = f"Bảng {table_number}" if table_number else "bảng"
265
- print(f"Đã tạo summary cho {table_id} ({row_count} dòng)")
266
  return [parent_node, summary_node]
267
 
268
 
269
  def _enrich_metadata(node: BaseNode, source_path: Path | None) -> None:
270
- """Bổ sung metadata từ source path và trích xuất thông tin học phần."""
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
- """Chia text thành chunks theo kích thước cấu hình."""
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
- """Trích xuất YAML frontmatter từ đầu file."""
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
- """Chunk một file Markdown thành các nodes."""
302
  if not text or not text.strip():
303
  return []
304
 
305
  path = Path(source_path) if source_path else None
306
 
307
- # Trích xuất YAML frontmatter làm metadata (không chunk)
308
  frontmatter_meta, text = _extract_frontmatter(text)
309
 
310
  tables, text_with_placeholders = _extract_tables(text)
311
 
312
- # Metadata bản từ 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 theo headings
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 trước bảng
333
  before_text = content[last_end:match.start()].strip()
334
 
335
- # Trích xuất số bảng tiêu đề từ text trước bảng
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 bảng - sử dụng 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
- # Lấy context hint từ header path
353
  context_hint = meta.get("Header 1", "") or meta.get("section", "")
354
 
355
- # Lấy source file cho summary
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
- # Tạo parent + summary nodes nếu cần
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 sau bảng
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
- # Gộp các node nhỏ với node kế tiếp
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
- # Bỏ qua node rỗng
389
  if not curr_content.strip():
390
  i += 1
391
  continue
392
 
393
- # Nếu node hiện tại nhỏ không phải bảng -> gộp với node sau
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
- """Đọc và chunk một file Markdown."""
421
  p = Path(path)
422
  if not p.exists():
423
- raise FileNotFoundError(f"Không tìm thấy file: {p}")
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
- """Cấu hình cho embedding model."""
17
- api_base_url: str = "https://api.siliconflow.com/v1" # SiliconFlow API
18
- model: str = "Qwen/Qwen3-Embedding-4B" # Model embedding
19
- dimension: int = 2048 # Số chiều vector
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
- """Lấy cấu hình embedding (singleton pattern)."""
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
- """Wrapper embedding model Qwen qua SiliconFlow API"""
36
 
37
  def __init__(self, config: EmbeddingConfig | None = None):
38
- """Khởi tạo embedding client."""
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("Chưa đặt biến môi trường SILICONFLOW_API_KEY")
44
 
45
  self._client = OpenAI(
46
  api_key=api_key,
47
  base_url=self.config.api_base_url,
48
  )
49
- logger.info(f"Đã khởi tạo QwenEmbeddings: {self.config.model}")
50
 
51
  def embed_query(self, text: str) -> List[float]:
52
- """Embed một câu query (dùng cho search)."""
53
  return self._embed_texts([text])[0]
54
 
55
  def embed_documents(self, texts: List[str]) -> List[List[float]]:
56
- """Embed nhiều documents (dùng khi index)."""
57
  return self._embed_texts(texts)
58
 
59
  def _embed_texts(self, texts: Sequence[str]) -> List[List[float]]:
60
- """Embed danh sách texts theo batch với retry logic."""
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
- # Xử theo batch
69
  for i in range(0, len(texts), batch_size):
70
  batch = list(texts[i:i + batch_size])
71
 
72
- # Retry logic cho rate limit
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
- # Nếu bị rate limit -> đợi rồi thử lại
84
  if "rate" in str(e).lower() and attempt < max_retries - 1:
85
- wait_time = 2 ** attempt # Exponential backoff: 1s, 2s, 4s
86
- logger.warning(f"Bị rate limit, đợi {wait_time}s...")
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
- """Embed texts và trả về numpy array (tiện cho tính toán)."""
95
  return np.asarray(self._embed_texts(list(texts)), dtype=np.float32)
96
 
97
 
98
- # Alias để tương thích ngược
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.retrival import Retriever
6
 
7
 
8
- # System prompt cho LLM (export để gradio/eval dùng)
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ỉ được đưa ra câu trả lời dựa trên CONTEXT được cung cấp. Không suy đoán, không bổ sung thông tin ngoài CONTEXT.
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. Nếu không tìm thấy thông tin trong CONTEXT, trả lời: "Không tìm thấy thông tin trong dữ liệu hiện có."
 
 
 
 
 
 
 
15
  """
16
 
17
 
18
  def build_context(results: List[Dict[str, Any]], max_chars: int = 8000) -> str:
19
- """Xây dựng context từ kết quả retrieval để đưa vào prompt."""
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
- # Tạo dòng metadata
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
- # Cắt ngắn nếu vượt quá giới hạn
58
  return context[:max_chars] if len(context) > max_chars else context
59
 
60
 
61
  def build_prompt(question: str, context: str) -> str:
62
- """Ghép system prompt, context và câu hỏi thành prompt hoàn chỉnh."""
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
- """Kết hợp retrieval và context building thành một bước."""
68
 
69
  def __init__(self, retriever: "Retriever", max_context_chars: int = 8000):
70
- """Khởi tạo với retriever và giới hạn context."""
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
- """Retrieve documents chuẩn bị context + prompt cho LLM."""
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
- # Không tìm thấy kết quả
86
  if not results:
87
  return {
88
  "results": [],
@@ -91,17 +97,17 @@ class RAGContextBuilder:
91
  "prompt": "",
92
  }
93
 
94
- # Xây dựng context prompt
95
  context_text = build_context(results, self._max_context_chars)
96
  prompt = build_prompt(question, context_text)
97
 
98
  return {
99
- "results": results, # Kết quả retrieval gốc
100
- "contexts": [r.get("content", "")[:1000] for r in results], # Context rút gọn (cho eval)
101
- "context_text": context_text, # Context đầy đủ
102
- "prompt": prompt, # Prompt hoàn chỉnh
103
  }
104
 
105
 
106
- # Alias để tương thích ngược
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 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 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
- """Các chế độ retrieval hỗ trợ."""
26
- VECTOR_ONLY = "vector_only" # Chỉ dùng vector search
27
- BM25_ONLY = "bm25_only" # Chỉ dùng BM25 keyword search
28
- HYBRID = "hybrid" # Kết hợp vector + BM25
29
- HYBRID_RERANK = "hybrid_rerank" # Hybrid + reranking
30
 
31
 
32
  @dataclass
33
  class RetrievalConfig:
34
- """Cấu hình cho retrieval system."""
35
- rerank_api_base_url: str = "https://api.siliconflow.com/v1" # API reranker
36
- rerank_model: str = "Qwen/Qwen3-Reranker-8B" # Model reranker
37
- rerank_top_n: int = 10 # Số kết quả sau rerank
38
- initial_k: int = 25 # Số docs lấy ban đầu
39
- top_k: int = 5 # Số kết quả cuối cùng
40
- vector_weight: float = 0.5 # Trọng số vector search
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
- """Lấy cấu hình retrieval (singleton pattern)."""
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
- """Reranker sử dụng SiliconFlow API để sắp xếp lại kết quả."""
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
- """Rerank documents dựa trên độ liên quan với query."""
72
  if not documents or not self.api_key:
73
  return list(documents)
74
 
75
- # Retry logic với exponential backoff
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
- # Tạo danh sách documents đã rerank với score
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 -> đợi rồi thử lại
110
  if "rate" in str(e).lower() and attempt < 2:
111
  time.sleep(2 ** attempt)
112
  else:
113
- logger.error(f"Lỗi rerank: {e}")
114
  return list(documents)
115
 
116
  return list(documents)
117
 
118
 
119
  class Retriever:
120
- """Retriever chính hỗ trợ nhiều chế độ tìm kiếm."""
121
 
122
  def __init__(self, vector_db: "ChromaVectorDB", use_reranker: bool = True):
123
- """Khởi tạo retriever với vector DB và reranker."""
124
  self._vector_db = vector_db
125
  self._config = get_retrieval_config()
126
  self._reranker: Optional[SiliconFlowReranker] = None
127
 
128
- # Vector retriever từ ChromaDB
129
  self._vector_retriever = self._vector_db.vectorstore.as_retriever(
130
  search_kwargs={"k": self._config.initial_k}
131
  )
132
 
133
- # Lazy-load BM25 - chỉ khởi tạo khi cần
134
  self._bm25_retriever: Optional[BM25Retriever] = None
135
  self._bm25_initialized = False
136
  self._ensemble_retriever: Optional[EnsembleRetriever] = None
137
 
138
- # Đường dẫn cache BM25 (lưu vào disk)
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("Đã khởi tạo Retriever")
150
 
151
  def _save_bm25_cache(self, bm25: BM25Retriever) -> None:
152
- """Lưu BM25 index vào cache file."""
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"Đã lưu BM25 cache vào {self._bm25_cache_path}")
160
  except Exception as e:
161
- logger.warning(f"Không thể lưu BM25 cache: {e}")
162
 
163
  def _load_bm25_cache(self) -> Optional[BM25Retriever]:
164
- """Tải BM25 index từ cache file."""
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"Đã tải BM25 từ cache trong {time.time() - start:.2f}s")
174
  return bm25
175
  except Exception as e:
176
- logger.warning(f"Không thể tải BM25 cache: {e}")
177
  return None
178
 
179
  def _init_bm25(self) -> Optional[BM25Retriever]:
180
- """Khởi tạo BM25 retriever (lazy-load với cache)."""
181
  if self._bm25_initialized:
182
  return self._bm25_retriever
183
 
184
  self._bm25_initialized = True
185
 
186
- # Thử tải từ cache trước
187
  cached = self._load_bm25_cache()
188
  if cached:
189
  self._bm25_retriever = cached
190
  return cached
191
 
192
- # Build từ đầu nếu không cache
193
  try:
194
  start = time.time()
195
- logger.info("Đang xây dựng BM25 index từ documents...")
196
 
197
  docs = self._vector_db.get_all_documents()
198
  if not docs:
199
- logger.warning("Không tìm thấy documents cho BM25")
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"Đã xây dựng BM25 với {len(docs)} docs trong {time.time() - start:.2f}s")
211
 
212
- # Lưu vào cache cho lần sau
213
  self._save_bm25_cache(bm25)
214
 
215
  return bm25
216
  except Exception as e:
217
- logger.error(f"Không thể khởi tạo BM25: {e}")
218
  return None
219
 
220
  def _get_ensemble_retriever(self) -> EnsembleRetriever:
221
- """Lấy ensemble retriever (vector + BM25)."""
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 về vector only
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
- """Khởi tạo reranker nếu có API key."""
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
- """Build retriever cuối cùng (ensemble + reranker nếu có)."""
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
- """Kiểm tra có reranker không."""
264
  return self._reranker is not None
265
 
266
  def _to_result(self, doc: Document, rank: int, **extra) -> Dict[str, Any]:
267
- """Chuyển Document thành dict result, xử lý Small-to-Big."""
268
  metadata = doc.metadata or {}
269
  content = doc.page_content
270
 
271
- # Small-to-Big: Nếu summary node -> swap với parent (bảng gốc)
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, giữ lại info summary để debug
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
- """Tìm kiếm bằng vector similarity."""
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
- """Tìm kiếm bằng BM25 keyword matching."""
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
- """Tìm kiếm hybrid (vector + BM25) không có rerank."""
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
- """Tìm kiếm hybrid + reranking để có kết quả tốt nhất."""
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
- # filter -> dùng vector search + manual rerank
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
- # Cập nhật k cho initial fetch
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 nếu
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
- """Tìm kiếm linh hoạt với nhiều chế độ."""
386
  if not text.strip():
387
  return []
388
 
389
- # Parse mode từ string
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
- # Gọi method tương ứng theo mode
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
- # Alias để tương thích ngược
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
- """Cấu hình cho ChromaDB."""
17
 
18
  def _default_persist_dir() -> str:
19
- """Lấy đường dẫn mặc định cho persist directory."""
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) # Thư mục lưu DB
24
- collection_name: str = "hust_rag_collection" # Tên collection
25
 
26
 
27
  class ChromaVectorDB:
28
- """Wrapper cho ChromaDB với hỗ trợ Small-to-Big retrieval."""
29
 
30
  def __init__(
31
  self,
32
  embedder: Any,
33
  config: ChromaConfig | None = None,
34
  ):
35
- """Khởi tạo ChromaDB với embedder và config."""
36
  self.embedder = embedder
37
  self.config = config or ChromaConfig()
38
  self._hasher = HashProcessor(verbose=False)
39
 
40
- # Lưu trữ parent nodes (không embed, dùng cho 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
- # Khởi tạo 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"Đã khởi tạo ChromaVectorDB: {self.config.collection_name}")
51
 
52
  def _load_parent_nodes(self) -> Dict[str, Dict[str, Any]]:
53
- """Tải parent nodes từ file JSON."""
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"Đã tải {len(data)} parent nodes từ {self._parent_nodes_path}")
59
  return data
60
  except Exception as e:
61
- logger.warning(f"Không thể tải parent nodes: {e}")
62
  return {}
63
 
64
  def _save_parent_nodes(self) -> None:
65
- """Lưu parent nodes vào file JSON."""
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"Đã lưu {len(self._parent_nodes)} parent nodes vào {self._parent_nodes_path}")
71
  except Exception as e:
72
- logger.warning(f"Không thể lưu parent nodes: {e}")
73
 
74
  @property
75
  def collection(self):
76
- """Lấy collection gốc của ChromaDB."""
77
  return getattr(self._vs, "_collection", None)
78
 
79
  @property
80
  def vectorstore(self):
81
- """Lấy LangChain Chroma vectorstore."""
82
  return self._vs
83
 
84
  def _flatten_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
85
- """Chuyển metadata phức tạp thành format ChromaDB hỗ trợ."""
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
- # Chuyển list/dict thành 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
- """Chuẩn hóa document từ nhiều format khác nhau thành dict."""
101
- # Đã dict
102
  if isinstance(doc, dict):
103
  return doc
104
- # TextNode/BaseNode từ 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 từ 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"Không hỗ trợ loại document: {type(doc)}")
117
 
118
  def _to_documents(self, docs: Sequence[Any], ids: Sequence[str]) -> List[Document]:
119
- """Chuyển danh sách docs thành LangChain Documents."""
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
- """Tạo ID duy nhất cho document dựa trên nội dung."""
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
- """Thêm documents vào vector store."""
148
  if not docs:
149
  return 0
150
 
151
  if ids is not None and len(ids) != len(docs):
152
- raise ValueError("Số lượng ids phải bằng số lượng docs")
153
 
154
- # Tách parent nodes (không embed) khỏi regular nodes
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
- # Lưu parent node riêng (cho Small-to-Big)
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"Đã lưu {parent_count} parent nodes (không embed)")
179
  self._save_parent_nodes()
180
 
181
  if not regular_docs:
182
  return parent_count
183
 
184
- # Thêm theo batch
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 nếu add_documents không nhận 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"Đã thêm {total} documents vào vector store")
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
- """Upsert documents (thêm mới hoặc cập nhật nếu đã tồn tại)."""
213
  if not docs:
214
  return 0
215
 
216
  if ids is not None and len(ids) != len(docs):
217
- raise ValueError("Số lượng ids phải bằng số lượng docs")
218
 
219
- # Tách parent nodes khỏi regular nodes
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
- # Lưu parent node riêng
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"Đã lưu {parent_count} parent nodes (không embed)")
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 nếu không collection
253
  if col is None:
254
  return self.add_documents(regular_docs, ids=regular_ids, batch_size=bs) + parent_count
255
 
256
- # Upsert theo batch
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"Đã upsert {total} documents vào vector store")
269
  return total + parent_count
270
 
271
  def count(self) -> int:
272
- """Đếm số documents trong collection."""
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
- """Lấy tất cả documents từ collection."""
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
- """Xóa documents theo danh sách IDs."""
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"Đã xóa {len(ids)} documents khỏi vector store")
304
  return len(ids)
305
 
306
  def get_parent_node(self, parent_id: str) -> Optional[Dict[str, Any]]:
307
- """Lấy parent node theo ID (cho Small-to-Big)."""
308
  return self._parent_nodes.get(parent_id)
309
 
310
  @property
311
  def parent_nodes(self) -> Dict[str, Dict[str, Any]]:
312
- """Lấy tất cả parent nodes."""
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.retrival import Retriever
21
  from core.rag.generator import RAGGenerator
22
 
23
 
24
  def strip_thinking(text: str) -> str:
25
- """Loại bỏ các block <think>...</think> từ output của LLM."""
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
- """Đọc dữ liệu câu hỏi và ground truth từ file CSV."""
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
- # Giới hạn số lượng sample
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
- """Khởi tạo các components RAG cho evaluation."""
48
  embeddings = QwenEmbeddings(EmbeddingConfig())
49
  db = ChromaVectorDB(embedder=embeddings, config=ChromaConfig())
50
  retriever = Retriever(vector_db=db)
51
  rag = RAGGenerator(retriever=retriever)
52
 
53
- # Khởi tạo LLM client
54
  api_key = os.getenv("SILICONFLOW_API_KEY", "").strip()
55
  if not api_key:
56
- raise ValueError("Chưa đặt SILICONFLOW_API_KEY")
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
- """Generate câu trả lời cho danh sách câu hỏi với parallel processing."""
71
 
72
  def process(idx_q):
73
- """Xử lý một câu hỏi: retrieve + generate."""
74
  idx, q = idx_q
75
  try:
76
- # Retrieve chuẩn bị context
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
- # Gọi LLM để generate answer
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} Lỗi: {e}")
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" Đang generate {n} câu trả lời...")
98
 
99
- # Xử song song với ThreadPoolExecutor
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}] Xong")
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
- # Cấu hình
27
- CSV_PATH = "data/data.csv" # File dữ liệu test
28
- OUTPUT_DIR = "evaluation/results" # Thư mục output
29
- LLM_MODEL = os.getenv("EVAL_LLM_MODEL", "nex-agi/DeepSeek-V3.1-Nex-N1") # Model đánh giá
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
- """Chạy đánh giá RAGAS trên dữ liệu test."""
35
  print(f"\n{'='*60}")
36
  print(f"RAGAS EVALUATION - Mode: {retrieval_mode}")
37
  print(f"{'='*60}")
38
 
39
- # Khởi tạo RAG components
40
  rag, embeddings, llm_client = init_rag()
41
 
42
- # Tải dữ liệu test
43
  questions, ground_truths = load_csv_data(str(REPO_ROOT / CSV_PATH), sample_size)
44
- print(f" Đã tải {len(questions)} samples")
45
 
46
- # Generate câu trả lời
47
  answers, contexts = generate_answers(
48
  rag, questions, llm_client,
49
  llm_model=LLM_MODEL,
50
  retrieval_mode=retrieval_mode,
51
  )
52
 
53
- # Thiết lập RAGAS evaluator
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
- # Chuyển dữ liệu thành format Dataset
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
- # Chạy đánh giá RAGAS
74
- print("\n Đang chạy RAGAS metrics...")
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'), # ROUGE-1
83
- RougeScore(rouge_type='rouge2', mode='fmeasure'), # ROUGE-2
84
- RougeScore(rouge_type='rougeL', mode='fmeasure'), # ROUGE-L
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
- # Trích xuất điểm số
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
- # Tính điểm trung bình cho mỗi metric
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
- # Lưu kết quả
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
- # Lưu file CSV (tóm tắt)
 
 
 
 
 
 
 
 
 
 
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
- # In kết quả
115
  print(f"\n{'='*60}")
116
- print(f"KẾT QUẢ - {retrieval_mode} ({len(questions)} samples)")
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"\nĐã lưu: {json_path}")
123
- print(f"Đã lưu: {csv_path}")
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
- """Lấy thông tin files đã có trong DB (IDs và hash)."""
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
- # Lưu hash đầu tiên tìm thấy cho file
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 từ markdown files")
48
- parser.add_argument("--force", action="store_true", help="Build lại tất cả files")
49
- parser.add_argument("--no-delete", action="store_true", help="Không xóa docs orphaned")
50
  args = parser.parse_args()
51
 
52
  print("=" * 60)
53
  print("BUILD HUST RAG DATABASE")
54
  print("=" * 60)
55
 
56
- # Bước 1: Khởi tạo embedder
57
- print("\n[1/5] Khởi tạo embedder...")
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
- # Bước 2: Khởi tạo ChromaDB
64
- print("\n[2/5] Khởi tạo ChromaDB...")
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" Số docs hiện tại: {old_count}")
70
 
71
- # Lấy trạng thái hiện tại của DB
72
  db_info = {"ids": {}, "hashes": {}}
73
  if not args.force and old_count > 0:
74
- print("\n Đang quét documents trong DB...")
75
  db_info = get_db_file_info(db)
76
- print(f" Tìm thấy {len(db_info['ids'])} source files trong DB")
77
 
78
- # Bước 3: Quét markdown files
79
- print("\n[3/5] Quét markdown files...")
80
  root = REPO_ROOT / "data" / "data_process"
81
  md_files = sorted(root.rglob("*.md"))
82
- print(f" Tìm thấy {len(md_files)} markdown files trên disk")
83
 
84
- # So sánh files trên disk vs trong DB
85
  current_files = {f.name for f in md_files}
86
  db_files = set(db_info["ids"].keys())
87
 
88
- # Tìm files cần xóa ( trong DB nhưng không trên disk)
89
  files_to_delete = db_files - current_files
90
 
91
- # Bước 4: Xóa docs orphaned
92
  deleted_count = 0
93
  if files_to_delete and not args.no_delete:
94
- print(f"\n[4/5] Dọn dẹp {len(files_to_delete)} files đã xóa...")
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" Đã xóa: {filename} ({len(doc_ids)} chunks)")
101
  else:
102
- print("\n[4/5] Không files cần xóa")
103
 
104
- # Bước 5: Xử markdown files (thêm mới, cập nhật)
105
- print("\n[5/5] Xử markdown files...")
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
- # Bỏ qua nếu hash khớp (file không thay đổi)
116
  if not args.force and db_hash == file_hash:
117
- print(f" [{i}/{len(md_files)}] {f.name}: BỎ QUA (không đổi)")
118
  skipped += 1
119
  continue
120
 
121
- # Nếu file thay đổi, xóa chunks cũ trước
122
  if existing_ids and not args.force:
123
  db.delete_documents(list(existing_ids))
124
- print(f" [{i}/{len(md_files)}] {f.name}: CẬP NHẬT (xóa {len(existing_ids)} chunks)")
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
- # Thêm hash vào metadata để phát hiện thay đổi lần sau
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 mới")
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}: BỎ QUA (không chunks)")
148
  except Exception as e:
149
- print(f" [{i}/{len(md_files)}] {f.name}: LỖI - {e}")
150
 
151
- # Tổng kết
152
  new_count = db.count()
153
  has_changes = deleted_count > 0 or total_updated > 0 or total_added > 0
154
 
155
- # Xóa BM25 cache nếu thay đổi (BM25 không hỗ trợ incremental update)
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[!] Đã xóa BM25 cache (sẽ tự rebuild khi query)")
161
 
162
  print(f"\n{'=' * 60}")
163
- print("TỔNG KẾT")
164
  print("=" * 60)
165
- print(f" Đã xóa (orphaned): {deleted_count} chunks")
166
- print(f" Đã cập nhật: {total_updated} chunks")
167
- print(f" Đã thêm mới: {total_added} chunks")
168
- print(f" Đã bỏ qua: {skipped} files")
169
- print(f" Số docs trong DB: {old_count} -> {new_count} ({new_count - old_count:+d})")
170
 
171
- print("\nHOÀN TẤT!")
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="Đánh giá RAG bằng RAGAS")
12
- parser.add_argument("--samples", type=int, default=10, help="Số lượng samples (0 = tất cả)")
13
  parser.add_argument("--mode", type=str, default="hybrid_rerank",
14
  choices=["vector_only", "bm25_only", "hybrid", "hybrid_rerank", "all"],
15
- help="Chế độ retrieval")
16
  args = parser.parse_args()
17
 
18
  from evaluation.ragas_eval import run_evaluation
19
 
20
  if args.mode == "all":
21
- # Chạy tất cả các chế độ retrieval
22
  print("\n" + "=" * 60)
23
- print("CHẠY TẤT CẢ CÁC CHẾ ĐỘ RETRIEVAL")
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
-