Spaces:
Sleeping
Sleeping
| """ | |
| Gradio + Groq API - 8種 RAG 策略 PDF 問答系統 | |
| 需要安装: pip install gradio groq pypdf sentence-transformers numpy faiss-cpu scikit-learn | |
| """ | |
| import gradio as gr | |
| 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 | |
| from collections import Counter | |
| class MultiStrategyRAG: | |
| def __init__(self, api_key): | |
| self.client = Groq(api_key=api_key) | |
| self.embedding_model = SentenceTransformer( | |
| 'sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2' | |
| ) | |
| self.chunks = [] | |
| self.embeddings = None | |
| self.index = None | |
| self.tfidf_vectorizer = None | |
| self.tfidf_matrix = None | |
| def load_pdf(self, pdf_file): | |
| """載入 PDF 檔案""" | |
| try: | |
| reader = PdfReader(pdf_file) | |
| full_text = "" | |
| for page in reader.pages: | |
| text = page.extract_text() | |
| full_text += text + "\n" | |
| # 分割文本 | |
| self.chunks = self._split_text(full_text, chunk_size=800, overlap=150) | |
| # 生成嵌入向量 | |
| self.embeddings = self.embedding_model.encode( | |
| self.chunks, | |
| convert_to_numpy=True | |
| ) | |
| # 建立 FAISS 索引 | |
| dimension = self.embeddings.shape[1] | |
| self.index = faiss.IndexFlatL2(dimension) | |
| self.index.add(self.embeddings.astype('float32')) | |
| # 建立 TF-IDF 索引 | |
| self.tfidf_vectorizer = TfidfVectorizer(max_features=1000) | |
| self.tfidf_matrix = self.tfidf_vectorizer.fit_transform(self.chunks) | |
| return f"✅ 成功載入 PDF!共 {len(reader.pages)} 頁,分割為 {len(self.chunks)} 個片段" | |
| except Exception as e: | |
| return f"❌ 載入失敗: {str(e)}" | |
| def _split_text(self, text, chunk_size, overlap): | |
| """分割文本""" | |
| chunks = [] | |
| start = 0 | |
| text_length = len(text) | |
| while start < text_length: | |
| end = start + chunk_size | |
| chunk = text[start:end] | |
| chunk = re.sub(r'\s+', ' ', chunk).strip() | |
| if chunk: | |
| chunks.append(chunk) | |
| start += chunk_size - overlap | |
| return chunks | |
| # ==================== 8種 RAG 策略 ==================== | |
| def strategy_1_basic_similarity(self, query, top_k=3): | |
| """策略1: 基礎語意相似度搜尋""" | |
| query_vector = self.embedding_model.encode([query]) | |
| distances, indices = self.index.search(query_vector.astype('float32'), top_k) | |
| return [self.chunks[idx] for idx in indices[0]] | |
| def strategy_2_tfidf(self, query, top_k=3): | |
| """策略2: TF-IDF 關鍵詞搜尋""" | |
| query_vector = self.tfidf_vectorizer.transform([query]) | |
| similarities = (self.tfidf_matrix * query_vector.T).toarray().flatten() | |
| top_indices = similarities.argsort()[-top_k:][::-1] | |
| return [self.chunks[idx] for idx in top_indices] | |
| def strategy_3_hybrid(self, query, top_k=3): | |
| """策略3: 混合搜尋 (語意 + TF-IDF)""" | |
| # 語意搜尋 | |
| query_vector = self.embedding_model.encode([query]) | |
| distances, sem_indices = self.index.search(query_vector.astype('float32'), top_k * 2) | |
| # TF-IDF 搜尋 | |
| query_tfidf = self.tfidf_vectorizer.transform([query]) | |
| tfidf_scores = (self.tfidf_matrix * query_tfidf.T).toarray().flatten() | |
| tfidf_indices = tfidf_scores.argsort()[-top_k * 2:][::-1] | |
| # 合併結果(去重) | |
| combined = list(set(sem_indices[0].tolist() + tfidf_indices.tolist())) | |
| return [self.chunks[idx] for idx in combined[:top_k]] | |
| def strategy_4_reranking(self, query, top_k=3): | |
| """策略4: 重新排序(先檢索再用LLM重排)""" | |
| # 先檢索較多候選 | |
| candidates = self.strategy_1_basic_similarity(query, top_k=top_k * 2) | |
| # 使用 LLM 評分重排(簡化版:用相關度評分) | |
| reranked = [] | |
| for chunk in candidates: | |
| prompt = f"問題:{query}\n\n文本:{chunk[:200]}...\n\n這段文本與問題的相關度(0-10):" | |
| try: | |
| response = self.client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[{"role": "user", "content": prompt}], | |
| max_tokens=10, | |
| temperature=0 | |
| ) | |
| score = response.choices[0].message.content.strip() | |
| score = float(re.findall(r'\d+', score)[0]) if re.findall(r'\d+', score) else 0 | |
| reranked.append((chunk, score)) | |
| except: | |
| reranked.append((chunk, 0)) | |
| reranked.sort(key=lambda x: x[1], reverse=True) | |
| return [chunk for chunk, score in reranked[:top_k]] | |
| def strategy_5_multi_query(self, query, top_k=3): | |
| """策略5: 多查詢擴展""" | |
| # 生成相關查詢 | |
| expansion_prompt = f"將以下問題改寫成3個相關但不同角度的問題,用換行分隔:\n{query}" | |
| try: | |
| response = self.client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[{"role": "user", "content": expansion_prompt}], | |
| max_tokens=200, | |
| temperature=0.7 | |
| ) | |
| queries = [query] + response.choices[0].message.content.strip().split('\n')[:3] | |
| except: | |
| queries = [query] | |
| # 對每個查詢搜尋 | |
| all_chunks = [] | |
| for q in queries: | |
| chunks = self.strategy_1_basic_similarity(q, top_k=2) | |
| all_chunks.extend(chunks) | |
| # 去重並保留 top_k | |
| unique_chunks = list(dict.fromkeys(all_chunks)) | |
| return unique_chunks[:top_k] | |
| def strategy_6_contextual_compression(self, query, top_k=3): | |
| """策略6: 上下文壓縮(提取最相關部分)""" | |
| chunks = self.strategy_1_basic_similarity(query, top_k=top_k) | |
| compressed = [] | |
| for chunk in chunks: | |
| # 使用 LLM 提取與問題最相關的部分 | |
| compress_prompt = f"從以下文本中提取與問題「{query}」最相關的1-2句話:\n\n{chunk}" | |
| try: | |
| response = self.client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[{"role": "user", "content": compress_prompt}], | |
| max_tokens=150, | |
| temperature=0 | |
| ) | |
| compressed.append(response.choices[0].message.content.strip()) | |
| except: | |
| compressed.append(chunk[:300]) | |
| return compressed | |
| def strategy_7_parent_child(self, query, top_k=3): | |
| """策略7: 父子文檔(檢索小片段,返回大上下文)""" | |
| # 檢索小片段 | |
| small_chunks = self._split_text(' '.join(self.chunks), chunk_size=300, overlap=50) | |
| small_embeddings = self.embedding_model.encode(small_chunks, convert_to_numpy=True) | |
| small_index = faiss.IndexFlatL2(small_embeddings.shape[1]) | |
| small_index.add(small_embeddings.astype('float32')) | |
| query_vector = self.embedding_model.encode([query]) | |
| distances, indices = small_index.search(query_vector.astype('float32'), top_k) | |
| # 返回包含該小片段的較大上下文 | |
| results = [] | |
| for idx in indices[0]: | |
| # 找到對應的原始大片段 | |
| for big_chunk in self.chunks: | |
| if small_chunks[idx] in big_chunk: | |
| results.append(big_chunk) | |
| break | |
| return list(dict.fromkeys(results))[:top_k] | |
| def strategy_8_hypothetical_answer(self, query, top_k=3): | |
| """策略8: 假設性答案(HyDE - Hypothetical Document Embeddings)""" | |
| # 生成假設性答案 | |
| hyde_prompt = f"請對以下問題給出一個假設性的答案(即使不確定):\n{query}" | |
| try: | |
| response = self.client.chat.completions.create( | |
| model="llama-3.1-8b-instant", | |
| messages=[{"role": "user", "content": hyde_prompt}], | |
| max_tokens=200, | |
| temperature=0.7 | |
| ) | |
| hypothetical_answer = response.choices[0].message.content | |
| except: | |
| hypothetical_answer = query | |
| # 用假設答案搜尋 | |
| query_vector = self.embedding_model.encode([hypothetical_answer]) | |
| distances, indices = self.index.search(query_vector.astype('float32'), top_k) | |
| return [self.chunks[idx] for idx in indices[0]] | |
| def generate_answer(self, query, strategy, top_k=3): | |
| """生成答案""" | |
| if not self.chunks: | |
| return "❌ 請先上傳 PDF 檔案!", "" | |
| # 根據策略選擇檢索方法 | |
| strategies = { | |
| "1. 基礎語意搜尋": self.strategy_1_basic_similarity, | |
| "2. TF-IDF 關鍵詞": self.strategy_2_tfidf, | |
| "3. 混合搜尋": self.strategy_3_hybrid, | |
| "4. 重新排序": self.strategy_4_reranking, | |
| "5. 多查詢擴展": self.strategy_5_multi_query, | |
| "6. 上下文壓縮": self.strategy_6_contextual_compression, | |
| "7. 父子文檔": self.strategy_7_parent_child, | |
| "8. 假設性答案 (HyDE)": self.strategy_8_hypothetical_answer, | |
| } | |
| retrieval_func = strategies.get(strategy, self.strategy_1_basic_similarity) | |
| relevant_chunks = retrieval_func(query, top_k) | |
| # 組合上下文 | |
| context = "\n\n---\n\n".join(relevant_chunks) | |
| # 生成答案 | |
| prompt = f"""請根據以下上下文回答問題。如果上下文中沒有相關資訊,請說明無法回答。 | |
| 上下文: | |
| {context} | |
| 問題:{query} | |
| 請用繁體中文詳細回答:""" | |
| try: | |
| response = 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 | |
| ) | |
| answer = response.choices[0].message.content | |
| source_info = f"📚 使用策略:{strategy}\n📄 檢索片段數:{len(relevant_chunks)}\n\n" + \ | |
| "=" * 50 + "\n相關文本片段:\n" + "=" * 50 + "\n\n" + context | |
| return answer, source_info | |
| except Exception as e: | |
| return f"❌ 生成答案失敗: {str(e)}", "" | |
| # 建立 Gradio 介面 | |
| def create_interface(): | |
| # 初始化 RAG 系統 | |
| API_KEY = "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" | |
| rag = MultiStrategyRAG(api_key=API_KEY) | |
| def upload_pdf(file): | |
| if file is None: | |
| return "⚠️ 請選擇 PDF 檔案" | |
| return rag.load_pdf(file.name) | |
| def ask_question(query, strategy, top_k): | |
| return rag.generate_answer(query, strategy, top_k) | |
| # 建立介面 | |
| with gr.Blocks(title="🤖 多策略 RAG PDF 問答系統", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown(""" | |
| # 🤖 多策略 RAG PDF 問答系統 | |
| 採用 **8 種不同的 RAG 策略**,為您的 PDF 文件提供智能問答服務! | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("### 📤 步驟 1: 上傳 PDF") | |
| pdf_input = gr.File( | |
| label="選擇 PDF 檔案", | |
| file_types=[".pdf"] | |
| ) | |
| upload_btn = gr.Button("🚀 載入文件", variant="primary") | |
| upload_status = gr.Textbox(label="載入狀態", interactive=False) | |
| gr.Markdown("### ⚙️ 步驟 2: 選擇 RAG 策略") | |
| strategy_dropdown = gr.Dropdown( | |
| choices=[ | |
| "1. 基礎語意搜尋", | |
| "2. TF-IDF 關鍵詞", | |
| "3. 混合搜尋", | |
| "4. 重新排序", | |
| "5. 多查詢擴展", | |
| "6. 上下文壓縮", | |
| "7. 父子文檔", | |
| "8. 假設性答案 (HyDE)" | |
| ], | |
| value="1. 基礎語意搜尋", | |
| label="RAG 策略" | |
| ) | |
| top_k_slider = gr.Slider( | |
| minimum=1, | |
| maximum=10, | |
| value=3, | |
| step=1, | |
| label="檢索片段數量 (Top-K)" | |
| ) | |
| gr.Markdown(""" | |
| ### 📖 策略說明 | |
| 1. **基礎語意搜尋**: 使用向量相似度 | |
| 2. **TF-IDF 關鍵詞**: 基於詞頻統計 | |
| 3. **混合搜尋**: 結合語意與關鍵詞 | |
| 4. **重新排序**: LLM 重新評分 | |
| 5. **多查詢擴展**: 生成多個相關問題 | |
| 6. **上下文壓縮**: 提取最相關部分 | |
| 7. **父子文檔**: 小片段檢索大上下文 | |
| 8. **假設性答案**: 先生成答案再搜尋 | |
| """) | |
| with gr.Column(scale=2): | |
| gr.Markdown("### 💬 步驟 3: 提問") | |
| question_input = gr.Textbox( | |
| label="輸入您的問題", | |
| placeholder="例如:這份文件的主要內容是什麼?", | |
| lines=3 | |
| ) | |
| ask_btn = gr.Button("🔍 提問", variant="primary", size="lg") | |
| gr.Markdown("### 💡 答案") | |
| answer_output = gr.Textbox( | |
| label="AI 回答", | |
| lines=10, | |
| interactive=False | |
| ) | |
| with gr.Accordion("📚 查看檢索到的文本片段", open=False): | |
| source_output = gr.Textbox( | |
| label="相關來源", | |
| lines=15, | |
| interactive=False | |
| ) | |
| # 綁定事件 | |
| upload_btn.click( | |
| fn=upload_pdf, | |
| inputs=[pdf_input], | |
| outputs=[upload_status] | |
| ) | |
| ask_btn.click( | |
| fn=ask_question, | |
| inputs=[question_input, strategy_dropdown, top_k_slider], | |
| outputs=[answer_output, source_output] | |
| ) | |
| # 範例問題 | |
| gr.Examples( | |
| examples=[ | |
| ["這份文件的主要內容是什麼?"], | |
| ["文件中提到哪些重要概念?"], | |
| ["有哪些關鍵數據或統計資料?"], | |
| ["文件的結論是什麼?"] | |
| ], | |
| inputs=question_input | |
| ) | |
| return demo | |
| if __name__ == "__main__": | |
| demo = create_interface() | |
| demo.launch( | |
| share=True, # 設為 True 可生成公開連結 | |
| server_name="0.0.0.0", | |
| #server_port=7860 | |
| ) |