import os import json import time import numpy as np import pandas as pd import gradio as gr import requests from sentence_transformers import SentenceTransformer, CrossEncoder from pinecone_text.sparse import BM25Encoder from pinecone import Pinecone from openai import OpenAI # ── Env Vars ───────────────────────────────────────────────────────────────── PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", "") OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "") INDEX_NAME = "medrag-hybrid-index" # ── Models ──────────────────────────────────────────────────────────────────── print("Loading models…") dense_model = SentenceTransformer("all-MiniLM-L6-v2") rerank_model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") bm25_encoder = BM25Encoder() bm25_encoder.load("bm25_params.json") pc = Pinecone(api_key=PINECONE_API_KEY) index = pc.Index(INDEX_NAME) or_client = OpenAI( base_url="https://openrouter.ai/api/v1", api_key=OPENROUTER_API_KEY, ) # ── 20 preset queries ───────────────────────────────────────────────────────── PRESET_QUERIES = [ "What is the effect of (--)-alpha-bisabolol on the proteolytic activity of pepsin?", "Does alpha-bisabolol alter the pH-value to achieve its antipeptic action?", "Compare the recovery rates of BA 1 and ifosfamide in rats with DS carcinosarcoma.", "What role does hyperglycemia play in the effectiveness of the substance BA 1?", "How does etafenone affect the transmural gradient of myocardial blood flow in dogs?", "What is the difference in myocardial blood flow between normoxia and hypoxia?", "How does the virostatic compound 5531 affect the induction of tryptophan-pyrrolase?", "At what dosage does compound 5531 inhibit tyrosine-aminotransferase in tissue culture?", "What are the pharmacological properties of RMI 61 140 compared to chlorpromazine?", "Do RMI 61 140 and RMI 61 144 show activity on electroshock tests?", "How does the combination of Sch 1000 and oxazepam affect gastric juice volume?", "What were the findings regarding oxazepam's effect on stimulated acid secretion?", "Which lysosomal glycosidases show high activity in the guinea-pig epidermis?", "List the distinct ester hydrolases characterized in the guinea-pig epidermis.", "What inhibits the serum haemagglutinating property dependent upon polycarboxyl groups?", "How does human erythrocyte stromata influence complement activation in PNH cells?", "What is the effect of salicylic acid on the pentose phosphate pathway in G6PD-deficient cells?", "How does pelleted barley affect rumen pH compared to whole barley in sheep?", "Describe the ordered structure of Poly(8-aminoguanylic acid) in neutral solution.", "What are the estimated pKa values for human liver alanine aminopeptidase active center groups?", ] # ── Retrieval ───────────────────────────────────────────────────────────────── def hybrid_query(query, namespace="recursive", top_k=10): d_vec = dense_model.encode(query).tolist() s_vec = bm25_encoder.encode_queries(query) results = index.query( vector=d_vec, sparse_vector=s_vec, top_k=top_k, include_metadata=True, namespace=namespace, ) scored = [] for r in results["matches"]: text = r["metadata"]["text"] score = rerank_model.predict([query, text]) scored.append({"text": text, "rerank_score": float(score)}) scored.sort(key=lambda x: x["rerank_score"], reverse=True) return scored[:3] def semantic_only_query(query, namespace="recursive", top_k=3): d_vec = dense_model.encode(query).tolist() results = index.query( vector=d_vec, top_k=top_k, include_metadata=True, namespace=namespace, ) return [{"text": r["metadata"]["text"], "rerank_score": r["score"]} for r in results["matches"]] # ── LLM options ─────────────────────────────────────────────────────────────── LLM_OPTIONS = { "LLaMA-3-8B": "meta-llama/llama-3-8b-instruct", "Mistral-Large": "mistralai/mistral-large:free", } LLM_DEFAULT = "LLaMA-3-8B" # ── LLM Helper ──────────────────────────────────────────────────────────────── def call_llm(prompt, max_tokens=500, model_id=None): if model_id is None: raise ValueError("model_id must be passed explicitly from the UI selection.") try: response = requests.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json", "HTTP-Referer": "https://huggingface.co/spaces", "X-Title": "MedRAG QA", }, json={ "model": model_id, "messages": [ {"role": "user", "content": prompt} ], "temperature": 0.1, "max_tokens": max_tokens, }, timeout=60 ) response.raise_for_status() data = response.json() if "choices" in data and data["choices"]: return data["choices"][0]["message"]["content"] else: return f"[LLM Error: Invalid response structure - {data}]" except requests.exceptions.Timeout: return "[LLM Error: Request timed out (60s)]" except requests.exceptions.HTTPError as e: return f"[LLM Error: HTTP {e.response.status_code} - {e.response.text}]" except requests.exceptions.RequestException as e: return f"[LLM Error: {str(e)}]" except Exception as e: return f"[LLM Error: {str(e)}]" # ── Generation ──────────────────────────────────────────────────────────────── def generate_answer(query, chunks, model_id): context = "\n\n".join(c["text"] for c in chunks) prompt = ( "You are a medical research assistant. Answer using ONLY the provided context. " "Be concise and factual.\n\n" f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:" ) return call_llm(prompt, model_id=model_id) # ── Evaluation ──────────────────────────────────────────────────────────────── def evaluate_faithfulness(chunks, answer, model_id): context = "\n\n".join(c["text"] for c in chunks) raw_claims = call_llm( "List every factual claim in the answer as short bullet points. " "Output ONLY the bullet list, no intro text.\n\nAnswer: " + answer, max_tokens=300, model_id=model_id, ) claims = [ l.strip("•-– ").strip() for l in raw_claims.splitlines() if l.strip() and len(l.strip()) > 5 ] rows = [] supported = 0 for claim in claims: verdict = call_llm( f"Context:\n{context}\n\nClaim: {claim}\n\n" "Is this claim supported by the context? Reply with exactly YES or NO.", max_tokens=10, model_id=model_id, ).strip().upper() verdict = "YES" if "YES" in verdict else "NO" if verdict == "YES": supported += 1 rows.append((claim, verdict)) score = round(supported / len(claims), 3) if claims else 0.0 return score, rows def evaluate_relevancy(query, answer, model_id): raw = call_llm( "Generate exactly 3 short questions that the following answer satisfies. " "Output only the 3 questions, one per line, no numbering or bullets.\n\nAnswer: " + answer, max_tokens=200, model_id=model_id, ) alt_qs = [l.strip() for l in raw.splitlines() if l.strip()][:3] if not alt_qs: return 0.0, [] orig_emb = dense_model.encode(query) rows = [] sims = [] for q in alt_qs: emb = dense_model.encode(q) sim = float(np.dot(orig_emb, emb) / (np.linalg.norm(orig_emb) * np.linalg.norm(emb) + 1e-9)) sims.append(sim) rows.append((q, round(sim, 4))) return round(float(np.mean(sims)), 4), rows # ── HTML builders ───────────────────────────────────────────────────────────── CARD = "border:1px solid #e2e8f0;border-radius:10px;padding:16px 18px;margin-bottom:12px;background:#fafafa;font-family:sans-serif" def _answer_html(answer): return f"""
{answer}
{c['text']}
Results will appear here after you submit a query.
" # ── Main handler for Interactive QA ─────────────────────────────────────────── def run_rag(query, retrieval_mode, chunking_strategy, llm_choice): if not query.strip(): return PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, PLACEHOLDER if llm_choice is None: return ( "❌ Please select an LLM model first
", PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, PLACEHOLDER ) model_id = LLM_OPTIONS[llm_choice] namespace = "fixed" if "Fixed" in chunking_strategy else "recursive" chunk_label = "Fixed-Size (500 chars, overlap 50)" if namespace == "fixed" \ else "Recursive Character Split (500 chars, overlap 50)" t0 = time.time() if "Hybrid" in retrieval_mode: chunks = hybrid_query(query, namespace=namespace) mode_label = f"Hybrid BM25 + Dense + CrossEncoder · {chunk_label} · {llm_choice}" else: chunks = semantic_only_query(query, namespace=namespace) mode_label = f"Semantic-Only (Dense) · {chunk_label} · {llm_choice}" r_time = time.time() - t0 t1 = time.time() answer = generate_answer(query, chunks, model_id) g_time = time.time() - t1 t2 = time.time() faith_score, faith_rows = evaluate_faithfulness(chunks, answer, model_id) rel_score, rel_rows = evaluate_relevancy(query, answer, model_id) e_time = time.time() - t2 return ( _answer_html(answer), _chunks_html(chunks), _faith_html(faith_score, faith_rows), _rel_html(rel_score, rel_rows), _latency_html(r_time, g_time, e_time, mode_label), ) # ── Ablation Study Handler ───────────────────────────────────────────────────── def run_ablation_study(progress=gr.Progress()): """ Run ablation study on first 10 queries with 6 configurations: - Config 1: Hybrid + Recursive + LLaMA-3-8B - Config 2: Hybrid + Fixed + LLaMA-3-8B - Config 3: Semantic + Recursive + LLaMA-3-8B - Config 4: Semantic + Fixed + LLaMA-3-8B """ model_id = LLM_OPTIONS["LLaMA-3-8B"] ten_queries = PRESET_QUERIES[:10] configs = [ {"name": "Hybrid + Recursive", "retrieval": "hybrid", "chunking": "recursive"}, {"name": "Hybrid + Fixed", "retrieval": "hybrid", "chunking": "fixed"}, {"name": "Semantic + Recursive", "retrieval": "semantic", "chunking": "recursive"}, {"name": "Semantic + Fixed", "retrieval": "semantic", "chunking": "fixed"}, ] results = [] total_steps = len(ten_queries) * len(configs) current_step = 0 for query_idx, query in enumerate(ten_queries): for config in configs: current_step += 1 progress(current_step / total_steps, desc=f"Query {query_idx+1}/10 - {config['name']}") # Retrieve chunks t0 = time.time() if config["retrieval"] == "hybrid": chunks = hybrid_query(query, namespace=config["chunking"]) else: chunks = semantic_only_query(query, namespace=config["chunking"]) r_time = time.time() - t0 # Generate answer t1 = time.time() answer = generate_answer(query, chunks, model_id) g_time = time.time() - t1 # Evaluate t2 = time.time() faith_score, _ = evaluate_faithfulness(chunks, answer, model_id) rel_score, _ = evaluate_relevancy(query, answer, model_id) e_time = time.time() - t2 results.append({ "Query": f"Q{query_idx+1}", "Configuration": config["name"], "Faithfulness": faith_score, "Relevancy": rel_score, "Retrieval Time": r_time, "Generation Time": g_time, "Evaluation Time": e_time, "Total Time": r_time + g_time + e_time, }) # Create DataFrame df = pd.DataFrame(results) # Compute configuration-wise means config_means = df.groupby("Configuration").agg({ "Faithfulness": "mean", "Relevancy": "mean", "Retrieval Time": "mean", "Generation Time": "mean", "Evaluation Time": "mean", "Total Time": "mean", }).round(4) # Create HTML table for detailed results detailed_html = df.to_html(index=False, float_format=lambda x: f"{x:.4f}") detailed_html = f"""PubMed corpus · Hybrid BM25 + Dense Search · CrossEncoder Re-ranking · LLM-as-a-Judge Evaluation
Running all 20 queries through 4 configurations to compute mean metrics
NLP with Deep Learning — Mini-Project 1 · Corpus: MedRAG/pubmed (200 abstracts, 500+ chunks) · Stack: Pinecone · all-MiniLM-L6-v2 · ms-marco CrossEncoder · OpenRouter
""") if __name__ == "__main__": demo.launch()