File size: 19,018 Bytes
f628d40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
"""
Streamlit + Groq API - 8種 RAG 策略 PDF 問答系統
安裝依賴:pip install streamlit groq pypdf sentence-transformers numpy faiss-cpu scikit-learn
執行方式:streamlit run rag_streamlit.py
"""

import streamlit as st
from groq import Groq
import numpy as np
from sentence_transformers import SentenceTransformer
import faiss
from pypdf import PdfReader
import re
from sklearn.feature_extraction.text import TfidfVectorizer
import tempfile
import os

# ─────────────────────────────────────────────
# 頁面設定
# ─────────────────────────────────────────────
st.set_page_config(
    page_title="多策略 RAG PDF 問答系統",
    page_icon="🤖",
    layout="wide",
    initial_sidebar_state="expanded",
)

# ─────────────────────────────────────────────
# 自訂樣式
# ─────────────────────────────────────────────
st.markdown("""
<style>
    /* 全域字體 & 背景 */
    html, body, [class*="css"] {
        font-family: 'Segoe UI', sans-serif;
    }
    .main { background-color: #f8f9fb; }

    /* 標題卡片 */
    .hero {
        background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
        border-radius: 16px;
        padding: 2rem 2.5rem;
        color: white;
        margin-bottom: 1.5rem;
    }
    .hero h1 { margin: 0; font-size: 2rem; letter-spacing: -0.5px; }
    .hero p  { margin: 0.5rem 0 0; opacity: 0.75; font-size: 1rem; }

    /* 區塊卡片 */
    .card {
        background: white;
        border-radius: 12px;
        padding: 1.5rem;
        box-shadow: 0 2px 12px rgba(0,0,0,0.06);
        margin-bottom: 1.2rem;
    }

    /* 答案區 */
    .answer-box {
        background: #f0f7ff;
        border-left: 4px solid #2563eb;
        border-radius: 8px;
        padding: 1.2rem 1.5rem;
        white-space: pre-wrap;
        line-height: 1.75;
        font-size: 0.95rem;
    }

    /* 策略徽章 */
    .badge {
        display: inline-block;
        background: #e0e7ff;
        color: #3730a3;
        border-radius: 6px;
        padding: 2px 10px;
        font-size: 0.82rem;
        font-weight: 600;
        margin-bottom: 0.5rem;
    }

    /* 來源文本 */
    .source-chunk {
        background: #fafafa;
        border: 1px solid #e5e7eb;
        border-radius: 8px;
        padding: 0.9rem 1.1rem;
        margin-bottom: 0.8rem;
        font-size: 0.85rem;
        line-height: 1.65;
        color: #374151;
    }
    .chunk-label {
        font-weight: 700;
        color: #6b7280;
        font-size: 0.75rem;
        text-transform: uppercase;
        letter-spacing: 0.05em;
        margin-bottom: 4px;
    }

    /* 狀態欄 */
    .status-ok   { color: #16a34a; font-weight: 600; }
    .status-err  { color: #dc2626; font-weight: 600; }
    .status-warn { color: #d97706; font-weight: 600; }

    div[data-testid="stExpander"] { border-radius: 10px; }
</style>
""", unsafe_allow_html=True)


# ─────────────────────────────────────────────
# RAG 核心類別
# ─────────────────────────────────────────────
class MultiStrategyRAG:
    def __init__(self, api_key: str):
        self.client = Groq(api_key=api_key)
        self.embedding_model = SentenceTransformer(
            'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2'
        )
        self.chunks: list[str] = []
        self.embeddings = None
        self.index = None
        self.tfidf_vectorizer = None
        self.tfidf_matrix = None

    # ── 載入 PDF ────────────────────────────
    def load_pdf(self, pdf_path: str) -> str:
        try:
            reader = PdfReader(pdf_path)
            full_text = "\n".join(
                (page.extract_text() or "") for page in reader.pages
            )

            self.chunks = self._split_text(full_text, chunk_size=800, overlap=150)

            self.embeddings = self.embedding_model.encode(
                self.chunks, convert_to_numpy=True, show_progress_bar=False
            )

            dim = self.embeddings.shape[1]
            self.index = faiss.IndexFlatL2(dim)
            self.index.add(self.embeddings.astype("float32"))

            self.tfidf_vectorizer = TfidfVectorizer(max_features=1000)
            self.tfidf_matrix = self.tfidf_vectorizer.fit_transform(self.chunks)

            return (
                f"✅ 成功載入!共 **{len(reader.pages)}** 頁,"
                f"分割為 **{len(self.chunks)}** 個片段。"
            )
        except Exception as e:
            return f"❌ 載入失敗:{e}"

    def _split_text(self, text: str, chunk_size: int, overlap: int) -> list[str]:
        chunks, start = [], 0
        while start < len(text):
            chunk = re.sub(r'\s+', ' ', text[start:start + chunk_size]).strip()
            if chunk:
                chunks.append(chunk)
            start += chunk_size - overlap
        return chunks

    # ── 8 種策略 ────────────────────────────
    def strategy_1_basic_similarity(self, query: str, top_k: int = 3):
        """策略1: 基礎語意相似度搜尋"""
        qv = self.embedding_model.encode([query]).astype("float32")
        _, idxs = self.index.search(qv, top_k)
        return [self.chunks[i] for i in idxs[0]]

    def strategy_2_tfidf(self, query: str, top_k: int = 3):
        """策略2: TF-IDF 關鍵詞搜尋"""
        qv = self.tfidf_vectorizer.transform([query])
        scores = (self.tfidf_matrix * qv.T).toarray().flatten()
        return [self.chunks[i] for i in scores.argsort()[-top_k:][::-1]]

    def strategy_3_hybrid(self, query: str, top_k: int = 3):
        """策略3: 混合搜尋(語意 + TF-IDF)"""
        qv = self.embedding_model.encode([query]).astype("float32")
        _, sem_idxs = self.index.search(qv, top_k * 2)

        qv_tfidf = self.tfidf_vectorizer.transform([query])
        tfidf_scores = (self.tfidf_matrix * qv_tfidf.T).toarray().flatten()
        tfidf_idxs = tfidf_scores.argsort()[-top_k * 2:][::-1]

        combined = list(set(sem_idxs[0].tolist() + tfidf_idxs.tolist()))
        return [self.chunks[i] for i in combined[:top_k]]

    def strategy_4_reranking(self, query: str, top_k: int = 3):
        """策略4: 重新排序(LLM 評分)"""
        candidates = self.strategy_1_basic_similarity(query, top_k=top_k * 2)
        scored = []
        for chunk in candidates:
            prompt = (
                f"問題:{query}\n\n文本:{chunk[:200]}...\n\n"
                f"這段文本與問題的相關度(0-10),只回覆數字:"
            )
            try:
                resp = self.client.chat.completions.create(
                    model="llama-3.1-8b-instant",
                    messages=[{"role": "user", "content": prompt}],
                    max_tokens=10,
                    temperature=0,
                )
                raw = resp.choices[0].message.content.strip()
                nums = re.findall(r'\d+', raw)
                score = float(nums[0]) if nums else 0
            except Exception:
                score = 0
            scored.append((chunk, score))
        scored.sort(key=lambda x: x[1], reverse=True)
        return [c for c, _ in scored[:top_k]]

    def strategy_5_multi_query(self, query: str, top_k: int = 3):
        """策略5: 多查詢擴展"""
        expand_prompt = (
            f"將以下問題改寫成3個相關但不同角度的問題,用換行分隔:\n{query}"
        )
        try:
            resp = self.client.chat.completions.create(
                model="llama-3.1-8b-instant",
                messages=[{"role": "user", "content": expand_prompt}],
                max_tokens=200,
                temperature=0.7,
            )
            queries = [query] + resp.choices[0].message.content.strip().split('\n')[:3]
        except Exception:
            queries = [query]

        all_chunks = []
        for q in queries:
            all_chunks.extend(self.strategy_1_basic_similarity(q, top_k=2))
        return list(dict.fromkeys(all_chunks))[:top_k]

    def strategy_6_contextual_compression(self, query: str, top_k: int = 3):
        """策略6: 上下文壓縮"""
        chunks = self.strategy_1_basic_similarity(query, top_k=top_k)
        compressed = []
        for chunk in chunks:
            prompt = (
                f"從以下文本中提取與問題「{query}」最相關的1-2句話:\n\n{chunk}"
            )
            try:
                resp = self.client.chat.completions.create(
                    model="llama-3.1-8b-instant",
                    messages=[{"role": "user", "content": prompt}],
                    max_tokens=150,
                    temperature=0,
                )
                compressed.append(resp.choices[0].message.content.strip())
            except Exception:
                compressed.append(chunk[:300])
        return compressed

    def strategy_7_parent_child(self, query: str, top_k: int = 3):
        """策略7: 父子文檔"""
        full_text = ' '.join(self.chunks)
        small_chunks = self._split_text(full_text, chunk_size=300, overlap=50)
        small_emb = self.embedding_model.encode(
            small_chunks, convert_to_numpy=True, show_progress_bar=False
        ).astype("float32")

        small_index = faiss.IndexFlatL2(small_emb.shape[1])
        small_index.add(small_emb)

        qv = self.embedding_model.encode([query]).astype("float32")
        _, idxs = small_index.search(qv, top_k)

        results = []
        for idx in idxs[0]:
            snippet = small_chunks[idx]
            for big in self.chunks:
                if snippet in big:
                    results.append(big)
                    break
        return list(dict.fromkeys(results))[:top_k]

    def strategy_8_hypothetical_answer(self, query: str, top_k: int = 3):
        """策略8: 假設性答案(HyDE)"""
        hyde_prompt = (
            f"請對以下問題給出一個假設性的答案(即使不確定):\n{query}"
        )
        try:
            resp = self.client.chat.completions.create(
                model="llama-3.1-8b-instant",
                messages=[{"role": "user", "content": hyde_prompt}],
                max_tokens=200,
                temperature=0.7,
            )
            hypothetical = resp.choices[0].message.content
        except Exception:
            hypothetical = query

        qv = self.embedding_model.encode([hypothetical]).astype("float32")
        _, idxs = self.index.search(qv, top_k)
        return [self.chunks[i] for i in idxs[0]]

    # ── 主問答入口 ──────────────────────────
    STRATEGIES = {
        "1. 基礎語意搜尋":        "strategy_1_basic_similarity",
        "2. TF-IDF 關鍵詞":      "strategy_2_tfidf",
        "3. 混合搜尋":            "strategy_3_hybrid",
        "4. 重新排序":            "strategy_4_reranking",
        "5. 多查詢擴展":          "strategy_5_multi_query",
        "6. 上下文壓縮":          "strategy_6_contextual_compression",
        "7. 父子文檔":            "strategy_7_parent_child",
        "8. 假設性答案 (HyDE)":   "strategy_8_hypothetical_answer",
    }

    def generate_answer(self, query: str, strategy: str, top_k: int = 3):
        if not self.chunks:
            return "❌ 請先上傳 PDF 檔案!", []

        method = getattr(self, self.STRATEGIES.get(strategy, "strategy_1_basic_similarity"))
        relevant_chunks = method(query, top_k)
        context = "\n\n---\n\n".join(relevant_chunks)

        prompt = (
            f"請根據以下上下文回答問題。如果上下文中沒有相關資訊,請說明無法回答。\n\n"
            f"上下文:\n{context}\n\n問題:{query}\n\n請用繁體中文詳細回答:"
        )
        try:
            resp = self.client.chat.completions.create(
                model="llama-3.1-8b-instant",
                messages=[
                    {"role": "system", "content": "你是專業的文件分析助手。"},
                    {"role": "user", "content": prompt},
                ],
                max_tokens=1024,
                temperature=0.3,
            )
            return resp.choices[0].message.content, relevant_chunks
        except Exception as e:
            return f"❌ 生成答案失敗:{e}", []


# ─────────────────────────────────────────────
# Session State 初始化
# ─────────────────────────────────────────────
if "rag" not in st.session_state:
    st.session_state.rag = None
if "pdf_loaded" not in st.session_state:
    st.session_state.pdf_loaded = False
if "load_msg" not in st.session_state:
    st.session_state.load_msg = ""
if "answer" not in st.session_state:
    st.session_state.answer = ""
if "sources" not in st.session_state:
    st.session_state.sources = []
if "last_strategy" not in st.session_state:
    st.session_state.last_strategy = ""


# ─────────────────────────────────────────────
# Sidebar — 設定
# ─────────────────────────────────────────────
with st.sidebar:
    st.markdown("## ⚙️ 系統設定")

    api_key = st.text_input(
        "Groq API Key",
        type="password",
        placeholder="gsk_...",
        help="前往 https://console.groq.com 取得免費 API Key",
    )

    st.markdown("---")
    st.markdown("## 📤 上傳 PDF")
    uploaded_file = st.file_uploader("選擇 PDF 檔案", type=["pdf"])

    if st.button("🚀 載入文件", use_container_width=True, type="primary"):
        if not api_key:
            st.error("請先輸入 Groq API Key")
        elif uploaded_file is None:
            st.warning("請先選擇 PDF 檔案")
        else:
            with st.spinner("正在解析 PDF 並建立索引…"):
                # 寫入臨時檔
                with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
                    tmp.write(uploaded_file.read())
                    tmp_path = tmp.name
                try:
                    rag = MultiStrategyRAG(api_key=api_key)
                    msg = rag.load_pdf(tmp_path)
                    if msg.startswith("✅"):
                        st.session_state.rag = rag
                        st.session_state.pdf_loaded = True
                    st.session_state.load_msg = msg
                finally:
                    os.unlink(tmp_path)

    if st.session_state.load_msg:
        if "✅" in st.session_state.load_msg:
            st.success(st.session_state.load_msg)
        else:
            st.error(st.session_state.load_msg)

    st.markdown("---")
    st.markdown("## 🎯 RAG 策略")
    strategy = st.selectbox(
        "選擇策略",
        list(MultiStrategyRAG.STRATEGIES.keys()),
        index=0,
    )

    top_k = st.slider("檢索片段數量 (Top-K)", min_value=1, max_value=10, value=3)

    st.markdown("---")
    st.markdown("""
### 📖 策略說明
| # | 名稱 | 方法 |
|---|------|------|
| 1 | 基礎語意 | 向量相似度 |
| 2 | TF-IDF | 詞頻統計 |
| 3 | 混合搜尋 | 語意+關鍵詞 |
| 4 | 重新排序 | LLM 評分 |
| 5 | 多查詢 | 生成多角度問題 |
| 6 | 上下文壓縮 | LLM 提取摘要 |
| 7 | 父子文檔 | 小→大上下文 |
| 8 | HyDE | 先生成假設答案 |
""")


# ─────────────────────────────────────────────
# 主頁面
# ─────────────────────────────────────────────
st.markdown("""
<div class="hero">
  <h1>🤖 多策略 RAG PDF 問答系統</h1>
  <p>8 種檢索策略 × Groq Llama 3.1 × 語意向量搜尋 — 智能解析您的文件</p>
</div>
""", unsafe_allow_html=True)

# 問題輸入區
st.markdown("### 💬 提問")
col_q, col_btn = st.columns([5, 1])
with col_q:
    question = st.text_area(
        "輸入您的問題",
        placeholder="例如:這份文件的主要內容是什麼?",
        height=100,
        label_visibility="collapsed",
    )
with col_btn:
    st.markdown("<br>", unsafe_allow_html=True)
    ask_clicked = st.button("🔍 提問", use_container_width=True, type="primary")

# 範例問題
st.markdown("**範例問題:**")
examples = [
    "這份文件的主要內容是什麼?",
    "文件中提到哪些重要概念?",
    "有哪些關鍵數據或統計資料?",
    "文件的結論是什麼?",
]
ex_cols = st.columns(len(examples))
for col, ex in zip(ex_cols, examples):
    if col.button(ex, use_container_width=True):
        question = ex
        ask_clicked = True

st.markdown("---")

# 執行問答
if ask_clicked:
    if not question.strip():
        st.warning("⚠️ 請輸入問題")
    elif not st.session_state.pdf_loaded or st.session_state.rag is None:
        st.error("❌ 請先在左側上傳並載入 PDF 文件")
    else:
        with st.spinner(f"使用「{strategy}」策略搜尋中…"):
            answer, sources = st.session_state.rag.generate_answer(
                question, strategy, top_k
            )
        st.session_state.answer = answer
        st.session_state.sources = sources
        st.session_state.last_strategy = strategy

# 顯示答案
if st.session_state.answer:
    st.markdown("### 💡 AI 回答")
    st.markdown(
        f'<span class="badge">策略:{st.session_state.last_strategy}</span>',
        unsafe_allow_html=True,
    )
    st.markdown(
        f'<div class="answer-box">{st.session_state.answer}</div>',
        unsafe_allow_html=True,
    )

    # 來源片段
    if st.session_state.sources:
        with st.expander(
            f"📚 查看檢索到的 {len(st.session_state.sources)} 個文本片段", expanded=False
        ):
            for i, chunk in enumerate(st.session_state.sources, 1):
                st.markdown(
                    f'<div class="source-chunk">'
                    f'<div class="chunk-label">片段 {i}</div>'
                    f'{chunk}'
                    f'</div>',
                    unsafe_allow_html=True,
                )