Mikkatsuki's picture
Create app.py
f628d40 verified
Raw
History Blame Contribute Delete
19 kB
"""
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,
)