Mikkatsuki commited on
Commit
f628d40
·
verified ·
1 Parent(s): 2d638bd

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +503 -0
app.py ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streamlit + Groq API - 8種 RAG 策略 PDF 問答系統
3
+ 安裝依賴:pip install streamlit groq pypdf sentence-transformers numpy faiss-cpu scikit-learn
4
+ 執行方式:streamlit run rag_streamlit.py
5
+ """
6
+
7
+ import streamlit as st
8
+ from groq import Groq
9
+ import numpy as np
10
+ from sentence_transformers import SentenceTransformer
11
+ import faiss
12
+ from pypdf import PdfReader
13
+ import re
14
+ from sklearn.feature_extraction.text import TfidfVectorizer
15
+ import tempfile
16
+ import os
17
+
18
+ # ─────────────────────────────────────────────
19
+ # 頁面設定
20
+ # ─────────────────────────────────────────────
21
+ st.set_page_config(
22
+ page_title="多策略 RAG PDF 問答系統",
23
+ page_icon="🤖",
24
+ layout="wide",
25
+ initial_sidebar_state="expanded",
26
+ )
27
+
28
+ # ─────────────────────────────────────────────
29
+ # 自訂樣式
30
+ # ─────────────────────────────────────────────
31
+ st.markdown("""
32
+ <style>
33
+ /* 全域字體 & 背景 */
34
+ html, body, [class*="css"] {
35
+ font-family: 'Segoe UI', sans-serif;
36
+ }
37
+ .main { background-color: #f8f9fb; }
38
+
39
+ /* 標題卡片 */
40
+ .hero {
41
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
42
+ border-radius: 16px;
43
+ padding: 2rem 2.5rem;
44
+ color: white;
45
+ margin-bottom: 1.5rem;
46
+ }
47
+ .hero h1 { margin: 0; font-size: 2rem; letter-spacing: -0.5px; }
48
+ .hero p { margin: 0.5rem 0 0; opacity: 0.75; font-size: 1rem; }
49
+
50
+ /* 區塊卡片 */
51
+ .card {
52
+ background: white;
53
+ border-radius: 12px;
54
+ padding: 1.5rem;
55
+ box-shadow: 0 2px 12px rgba(0,0,0,0.06);
56
+ margin-bottom: 1.2rem;
57
+ }
58
+
59
+ /* 答案區 */
60
+ .answer-box {
61
+ background: #f0f7ff;
62
+ border-left: 4px solid #2563eb;
63
+ border-radius: 8px;
64
+ padding: 1.2rem 1.5rem;
65
+ white-space: pre-wrap;
66
+ line-height: 1.75;
67
+ font-size: 0.95rem;
68
+ }
69
+
70
+ /* 策略徽章 */
71
+ .badge {
72
+ display: inline-block;
73
+ background: #e0e7ff;
74
+ color: #3730a3;
75
+ border-radius: 6px;
76
+ padding: 2px 10px;
77
+ font-size: 0.82rem;
78
+ font-weight: 600;
79
+ margin-bottom: 0.5rem;
80
+ }
81
+
82
+ /* 來源文本 */
83
+ .source-chunk {
84
+ background: #fafafa;
85
+ border: 1px solid #e5e7eb;
86
+ border-radius: 8px;
87
+ padding: 0.9rem 1.1rem;
88
+ margin-bottom: 0.8rem;
89
+ font-size: 0.85rem;
90
+ line-height: 1.65;
91
+ color: #374151;
92
+ }
93
+ .chunk-label {
94
+ font-weight: 700;
95
+ color: #6b7280;
96
+ font-size: 0.75rem;
97
+ text-transform: uppercase;
98
+ letter-spacing: 0.05em;
99
+ margin-bottom: 4px;
100
+ }
101
+
102
+ /* 狀態欄 */
103
+ .status-ok { color: #16a34a; font-weight: 600; }
104
+ .status-err { color: #dc2626; font-weight: 600; }
105
+ .status-warn { color: #d97706; font-weight: 600; }
106
+
107
+ div[data-testid="stExpander"] { border-radius: 10px; }
108
+ </style>
109
+ """, unsafe_allow_html=True)
110
+
111
+
112
+ # ─────────────────────────────────────────────
113
+ # RAG 核心類別
114
+ # ─────────────────────────────────────────────
115
+ class MultiStrategyRAG:
116
+ def __init__(self, api_key: str):
117
+ self.client = Groq(api_key=api_key)
118
+ self.embedding_model = SentenceTransformer(
119
+ 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2'
120
+ )
121
+ self.chunks: list[str] = []
122
+ self.embeddings = None
123
+ self.index = None
124
+ self.tfidf_vectorizer = None
125
+ self.tfidf_matrix = None
126
+
127
+ # ── 載入 PDF ────────────────────────────
128
+ def load_pdf(self, pdf_path: str) -> str:
129
+ try:
130
+ reader = PdfReader(pdf_path)
131
+ full_text = "\n".join(
132
+ (page.extract_text() or "") for page in reader.pages
133
+ )
134
+
135
+ self.chunks = self._split_text(full_text, chunk_size=800, overlap=150)
136
+
137
+ self.embeddings = self.embedding_model.encode(
138
+ self.chunks, convert_to_numpy=True, show_progress_bar=False
139
+ )
140
+
141
+ dim = self.embeddings.shape[1]
142
+ self.index = faiss.IndexFlatL2(dim)
143
+ self.index.add(self.embeddings.astype("float32"))
144
+
145
+ self.tfidf_vectorizer = TfidfVectorizer(max_features=1000)
146
+ self.tfidf_matrix = self.tfidf_vectorizer.fit_transform(self.chunks)
147
+
148
+ return (
149
+ f"✅ 成功載入!共 **{len(reader.pages)}** 頁,"
150
+ f"分割為 **{len(self.chunks)}** 個片段。"
151
+ )
152
+ except Exception as e:
153
+ return f"❌ 載入失敗:{e}"
154
+
155
+ def _split_text(self, text: str, chunk_size: int, overlap: int) -> list[str]:
156
+ chunks, start = [], 0
157
+ while start < len(text):
158
+ chunk = re.sub(r'\s+', ' ', text[start:start + chunk_size]).strip()
159
+ if chunk:
160
+ chunks.append(chunk)
161
+ start += chunk_size - overlap
162
+ return chunks
163
+
164
+ # ── 8 種策略 ────────────────────────────
165
+ def strategy_1_basic_similarity(self, query: str, top_k: int = 3):
166
+ """策略1: 基礎語意相似度搜尋"""
167
+ qv = self.embedding_model.encode([query]).astype("float32")
168
+ _, idxs = self.index.search(qv, top_k)
169
+ return [self.chunks[i] for i in idxs[0]]
170
+
171
+ def strategy_2_tfidf(self, query: str, top_k: int = 3):
172
+ """策略2: TF-IDF 關鍵詞搜尋"""
173
+ qv = self.tfidf_vectorizer.transform([query])
174
+ scores = (self.tfidf_matrix * qv.T).toarray().flatten()
175
+ return [self.chunks[i] for i in scores.argsort()[-top_k:][::-1]]
176
+
177
+ def strategy_3_hybrid(self, query: str, top_k: int = 3):
178
+ """策略3: 混合搜尋(語意 + TF-IDF)"""
179
+ qv = self.embedding_model.encode([query]).astype("float32")
180
+ _, sem_idxs = self.index.search(qv, top_k * 2)
181
+
182
+ qv_tfidf = self.tfidf_vectorizer.transform([query])
183
+ tfidf_scores = (self.tfidf_matrix * qv_tfidf.T).toarray().flatten()
184
+ tfidf_idxs = tfidf_scores.argsort()[-top_k * 2:][::-1]
185
+
186
+ combined = list(set(sem_idxs[0].tolist() + tfidf_idxs.tolist()))
187
+ return [self.chunks[i] for i in combined[:top_k]]
188
+
189
+ def strategy_4_reranking(self, query: str, top_k: int = 3):
190
+ """策略4: 重新排序(LLM 評分)"""
191
+ candidates = self.strategy_1_basic_similarity(query, top_k=top_k * 2)
192
+ scored = []
193
+ for chunk in candidates:
194
+ prompt = (
195
+ f"問題:{query}\n\n文本:{chunk[:200]}...\n\n"
196
+ f"這段文本與問題的相關度(0-10),只回覆數字:"
197
+ )
198
+ try:
199
+ resp = self.client.chat.completions.create(
200
+ model="llama-3.1-8b-instant",
201
+ messages=[{"role": "user", "content": prompt}],
202
+ max_tokens=10,
203
+ temperature=0,
204
+ )
205
+ raw = resp.choices[0].message.content.strip()
206
+ nums = re.findall(r'\d+', raw)
207
+ score = float(nums[0]) if nums else 0
208
+ except Exception:
209
+ score = 0
210
+ scored.append((chunk, score))
211
+ scored.sort(key=lambda x: x[1], reverse=True)
212
+ return [c for c, _ in scored[:top_k]]
213
+
214
+ def strategy_5_multi_query(self, query: str, top_k: int = 3):
215
+ """策略5: 多查詢擴展"""
216
+ expand_prompt = (
217
+ f"將以下問題改寫成3個相關但不同角度的問題,用換行分隔:\n{query}"
218
+ )
219
+ try:
220
+ resp = self.client.chat.completions.create(
221
+ model="llama-3.1-8b-instant",
222
+ messages=[{"role": "user", "content": expand_prompt}],
223
+ max_tokens=200,
224
+ temperature=0.7,
225
+ )
226
+ queries = [query] + resp.choices[0].message.content.strip().split('\n')[:3]
227
+ except Exception:
228
+ queries = [query]
229
+
230
+ all_chunks = []
231
+ for q in queries:
232
+ all_chunks.extend(self.strategy_1_basic_similarity(q, top_k=2))
233
+ return list(dict.fromkeys(all_chunks))[:top_k]
234
+
235
+ def strategy_6_contextual_compression(self, query: str, top_k: int = 3):
236
+ """策略6: 上下文壓縮"""
237
+ chunks = self.strategy_1_basic_similarity(query, top_k=top_k)
238
+ compressed = []
239
+ for chunk in chunks:
240
+ prompt = (
241
+ f"從以下文本中提取與問題「{query}」最相關的1-2句話:\n\n{chunk}"
242
+ )
243
+ try:
244
+ resp = self.client.chat.completions.create(
245
+ model="llama-3.1-8b-instant",
246
+ messages=[{"role": "user", "content": prompt}],
247
+ max_tokens=150,
248
+ temperature=0,
249
+ )
250
+ compressed.append(resp.choices[0].message.content.strip())
251
+ except Exception:
252
+ compressed.append(chunk[:300])
253
+ return compressed
254
+
255
+ def strategy_7_parent_child(self, query: str, top_k: int = 3):
256
+ """策略7: 父子文檔"""
257
+ full_text = ' '.join(self.chunks)
258
+ small_chunks = self._split_text(full_text, chunk_size=300, overlap=50)
259
+ small_emb = self.embedding_model.encode(
260
+ small_chunks, convert_to_numpy=True, show_progress_bar=False
261
+ ).astype("float32")
262
+
263
+ small_index = faiss.IndexFlatL2(small_emb.shape[1])
264
+ small_index.add(small_emb)
265
+
266
+ qv = self.embedding_model.encode([query]).astype("float32")
267
+ _, idxs = small_index.search(qv, top_k)
268
+
269
+ results = []
270
+ for idx in idxs[0]:
271
+ snippet = small_chunks[idx]
272
+ for big in self.chunks:
273
+ if snippet in big:
274
+ results.append(big)
275
+ break
276
+ return list(dict.fromkeys(results))[:top_k]
277
+
278
+ def strategy_8_hypothetical_answer(self, query: str, top_k: int = 3):
279
+ """策略8: 假設性答案(HyDE)"""
280
+ hyde_prompt = (
281
+ f"請對以下問題給出一個假設性的答案(即使不確定):\n{query}"
282
+ )
283
+ try:
284
+ resp = self.client.chat.completions.create(
285
+ model="llama-3.1-8b-instant",
286
+ messages=[{"role": "user", "content": hyde_prompt}],
287
+ max_tokens=200,
288
+ temperature=0.7,
289
+ )
290
+ hypothetical = resp.choices[0].message.content
291
+ except Exception:
292
+ hypothetical = query
293
+
294
+ qv = self.embedding_model.encode([hypothetical]).astype("float32")
295
+ _, idxs = self.index.search(qv, top_k)
296
+ return [self.chunks[i] for i in idxs[0]]
297
+
298
+ # ── 主問答入口 ──────────────────────────
299
+ STRATEGIES = {
300
+ "1. 基礎語意搜尋": "strategy_1_basic_similarity",
301
+ "2. TF-IDF 關鍵詞": "strategy_2_tfidf",
302
+ "3. 混合搜尋": "strategy_3_hybrid",
303
+ "4. 重新排序": "strategy_4_reranking",
304
+ "5. 多查詢擴展": "strategy_5_multi_query",
305
+ "6. 上下文壓縮": "strategy_6_contextual_compression",
306
+ "7. 父子文檔": "strategy_7_parent_child",
307
+ "8. 假設性答案 (HyDE)": "strategy_8_hypothetical_answer",
308
+ }
309
+
310
+ def generate_answer(self, query: str, strategy: str, top_k: int = 3):
311
+ if not self.chunks:
312
+ return "❌ 請先上傳 PDF 檔案!", []
313
+
314
+ method = getattr(self, self.STRATEGIES.get(strategy, "strategy_1_basic_similarity"))
315
+ relevant_chunks = method(query, top_k)
316
+ context = "\n\n---\n\n".join(relevant_chunks)
317
+
318
+ prompt = (
319
+ f"請根據以下上下文回答問題。如果上下文中沒有相關資訊,請說明無法回答。\n\n"
320
+ f"上下文:\n{context}\n\n問題:{query}\n\n請用繁體中文詳細回答:"
321
+ )
322
+ try:
323
+ resp = self.client.chat.completions.create(
324
+ model="llama-3.1-8b-instant",
325
+ messages=[
326
+ {"role": "system", "content": "你是專業的文件分析助手。"},
327
+ {"role": "user", "content": prompt},
328
+ ],
329
+ max_tokens=1024,
330
+ temperature=0.3,
331
+ )
332
+ return resp.choices[0].message.content, relevant_chunks
333
+ except Exception as e:
334
+ return f"❌ 生成答案失敗:{e}", []
335
+
336
+
337
+ # ─────────────────────────────────────────────
338
+ # Session State 初始化
339
+ # ─────────────────────────────────────────────
340
+ if "rag" not in st.session_state:
341
+ st.session_state.rag = None
342
+ if "pdf_loaded" not in st.session_state:
343
+ st.session_state.pdf_loaded = False
344
+ if "load_msg" not in st.session_state:
345
+ st.session_state.load_msg = ""
346
+ if "answer" not in st.session_state:
347
+ st.session_state.answer = ""
348
+ if "sources" not in st.session_state:
349
+ st.session_state.sources = []
350
+ if "last_strategy" not in st.session_state:
351
+ st.session_state.last_strategy = ""
352
+
353
+
354
+ # ─────────────────────────────────────────────
355
+ # Sidebar — 設定
356
+ # ─────────────────────────────────────────────
357
+ with st.sidebar:
358
+ st.markdown("## ⚙️ 系統設定")
359
+
360
+ api_key = st.text_input(
361
+ "Groq API Key",
362
+ type="password",
363
+ placeholder="gsk_...",
364
+ help="前往 https://console.groq.com 取得免費 API Key",
365
+ )
366
+
367
+ st.markdown("---")
368
+ st.markdown("## 📤 上傳 PDF")
369
+ uploaded_file = st.file_uploader("選擇 PDF 檔案", type=["pdf"])
370
+
371
+ if st.button("🚀 載入文件", use_container_width=True, type="primary"):
372
+ if not api_key:
373
+ st.error("請先輸入 Groq API Key")
374
+ elif uploaded_file is None:
375
+ st.warning("請先選擇 PDF 檔案")
376
+ else:
377
+ with st.spinner("正在解析 PDF 並建立索引…"):
378
+ # 寫入臨時檔
379
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
380
+ tmp.write(uploaded_file.read())
381
+ tmp_path = tmp.name
382
+ try:
383
+ rag = MultiStrategyRAG(api_key=api_key)
384
+ msg = rag.load_pdf(tmp_path)
385
+ if msg.startswith("✅"):
386
+ st.session_state.rag = rag
387
+ st.session_state.pdf_loaded = True
388
+ st.session_state.load_msg = msg
389
+ finally:
390
+ os.unlink(tmp_path)
391
+
392
+ if st.session_state.load_msg:
393
+ if "✅" in st.session_state.load_msg:
394
+ st.success(st.session_state.load_msg)
395
+ else:
396
+ st.error(st.session_state.load_msg)
397
+
398
+ st.markdown("---")
399
+ st.markdown("## 🎯 RAG 策略")
400
+ strategy = st.selectbox(
401
+ "選擇策略",
402
+ list(MultiStrategyRAG.STRATEGIES.keys()),
403
+ index=0,
404
+ )
405
+
406
+ top_k = st.slider("檢索片段數量 (Top-K)", min_value=1, max_value=10, value=3)
407
+
408
+ st.markdown("---")
409
+ st.markdown("""
410
+ ### 📖 策略說明
411
+ | # | 名稱 | 方法 |
412
+ |---|------|------|
413
+ | 1 | 基礎語意 | 向量相似度 |
414
+ | 2 | TF-IDF | 詞頻統計 |
415
+ | 3 | 混合搜尋 | 語意+關鍵詞 |
416
+ | 4 | 重新排序 | LLM 評分 |
417
+ | 5 | 多查詢 | 生成多角度問題 |
418
+ | 6 | 上下文壓縮 | LLM 提取摘要 |
419
+ | 7 | 父子文檔 | 小→大上下文 |
420
+ | 8 | HyDE | 先生成假設答案 |
421
+ """)
422
+
423
+
424
+ # ─────────────────────────────────────────────
425
+ # 主頁面
426
+ # ─────────────────────────────────────────────
427
+ st.markdown("""
428
+ <div class="hero">
429
+ <h1>🤖 多策略 RAG PDF 問答系統</h1>
430
+ <p>8 種檢索策略 × Groq Llama 3.1 × 語意向量搜尋 — 智能解析您的文件</p>
431
+ </div>
432
+ """, unsafe_allow_html=True)
433
+
434
+ # 問題輸入區
435
+ st.markdown("### 💬 提問")
436
+ col_q, col_btn = st.columns([5, 1])
437
+ with col_q:
438
+ question = st.text_area(
439
+ "輸入您的問題",
440
+ placeholder="例如:這份文件的主要內容是什麼?",
441
+ height=100,
442
+ label_visibility="collapsed",
443
+ )
444
+ with col_btn:
445
+ st.markdown("<br>", unsafe_allow_html=True)
446
+ ask_clicked = st.button("🔍 提問", use_container_width=True, type="primary")
447
+
448
+ # 範例問題
449
+ st.markdown("**範例問題:**")
450
+ examples = [
451
+ "這份文件的主要內容是什麼?",
452
+ "文件中提到哪些重要概念?",
453
+ "有哪些關鍵數據或統計資料?",
454
+ "文件的結論是什麼?",
455
+ ]
456
+ ex_cols = st.columns(len(examples))
457
+ for col, ex in zip(ex_cols, examples):
458
+ if col.button(ex, use_container_width=True):
459
+ question = ex
460
+ ask_clicked = True
461
+
462
+ st.markdown("---")
463
+
464
+ # 執行問答
465
+ if ask_clicked:
466
+ if not question.strip():
467
+ st.warning("⚠️ 請輸入問題")
468
+ elif not st.session_state.pdf_loaded or st.session_state.rag is None:
469
+ st.error("❌ 請先在左側上傳並載入 PDF 文件")
470
+ else:
471
+ with st.spinner(f"使用「{strategy}」策略搜尋中…"):
472
+ answer, sources = st.session_state.rag.generate_answer(
473
+ question, strategy, top_k
474
+ )
475
+ st.session_state.answer = answer
476
+ st.session_state.sources = sources
477
+ st.session_state.last_strategy = strategy
478
+
479
+ # 顯示答案
480
+ if st.session_state.answer:
481
+ st.markdown("### 💡 AI 回答")
482
+ st.markdown(
483
+ f'<span class="badge">策略:{st.session_state.last_strategy}</span>',
484
+ unsafe_allow_html=True,
485
+ )
486
+ st.markdown(
487
+ f'<div class="answer-box">{st.session_state.answer}</div>',
488
+ unsafe_allow_html=True,
489
+ )
490
+
491
+ # 來源片段
492
+ if st.session_state.sources:
493
+ with st.expander(
494
+ f"📚 查看檢索到的 {len(st.session_state.sources)} 個文本片段", expanded=False
495
+ ):
496
+ for i, chunk in enumerate(st.session_state.sources, 1):
497
+ st.markdown(
498
+ f'<div class="source-chunk">'
499
+ f'<div class="chunk-label">片段 {i}</div>'
500
+ f'{chunk}'
501
+ f'</div>',
502
+ unsafe_allow_html=True,
503
+ )