Spaces:
Sleeping
Sleeping
| """ | |
| Medical RAG Chatbot β Streamlit UI | |
| Powered by: MedQuAD Β· intfloat/e5-base Β· Mistral via Ollama | |
| """ | |
| import os | |
| import streamlit as st | |
| import pandas as pd | |
| import numpy as np | |
| import faiss | |
| from sentence_transformers import SentenceTransformer | |
| from sklearn.metrics.pairwise import cosine_similarity | |
| from langchain_groq import ChatGroq | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| DATA_PATH = "MedQuAD_combined.csv" | |
| EMBEDDINGS_CACHE = "embeddings_cache.npy" | |
| EMBED_MODEL_NAME = "intfloat/e5-base" | |
| GROQ_MODEL = "llama-3.1-8b-instant" # free-tier Groq model | |
| DEFAULT_K = 3 | |
| DEFAULT_THRESHOLD = 0.50 | |
| # --------------------------------------------------------------------------- | |
| # Cached resource loaders (run once per process, cached across reruns) | |
| # --------------------------------------------------------------------------- | |
| def load_pipeline(): | |
| """Load embedding model, data, embeddings (from cache or fresh), and FAISS index.""" | |
| # 1. Embedding model | |
| model = SentenceTransformer(EMBED_MODEL_NAME) | |
| # 2. Dataset | |
| df = pd.read_csv(DATA_PATH) | |
| df = df.copy() | |
| df["answer"] = ( | |
| df["answer"] | |
| .astype(str) | |
| .str.replace("\n", " ", regex=False) | |
| .str.replace(r" +", " ", regex=True) | |
| .str.strip() | |
| ) | |
| df["text"] = ( | |
| "Focus Area: " + df["focus"].fillna("") + | |
| " ; Question: " + df["question"].fillna("") + | |
| " ; Question Type: " + df["question_qtype"].fillna("") + | |
| " ; Source: " + df["url"].fillna("") + | |
| " ; Answer: " + df["answer"].fillna("") | |
| ) | |
| # 3. Embeddings β load from cache or compute and save | |
| n_rows = len(df) | |
| try: | |
| cached = np.load(EMBEDDINGS_CACHE, allow_pickle=True) | |
| cache_valid = cached.shape[0] == n_rows | |
| except Exception: | |
| cache_valid = False | |
| cached = None | |
| if cache_valid: | |
| embeddings = cached | |
| else: | |
| texts = df["text"].tolist() | |
| embeddings = model.encode( | |
| texts, | |
| batch_size=128, | |
| convert_to_numpy=True, | |
| normalize_embeddings=True, | |
| show_progress_bar=False, | |
| ).astype("float32") | |
| np.save(EMBEDDINGS_CACHE, embeddings) | |
| # 4. FAISS index (inner-product on normalised vectors == cosine similarity) | |
| dimension = embeddings.shape[1] | |
| index = faiss.IndexFlatIP(dimension) | |
| index.add(embeddings) | |
| return model, df, embeddings, index | |
| def load_llm(): | |
| """Initialise the Groq LLM client; returns None if API key is missing.""" | |
| api_key = ( | |
| st.secrets.get("GROQ_API_KEY", None) | |
| if hasattr(st, "secrets") | |
| else None | |
| ) or os.environ.get("GROQ_API_KEY") | |
| if not api_key: | |
| return None | |
| try: | |
| return ChatGroq( | |
| model=GROQ_MODEL, | |
| api_key=api_key, | |
| temperature=0.0, | |
| ) | |
| except Exception: | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # RAG helpers | |
| # --------------------------------------------------------------------------- | |
| def retrieve(query: str, model, df, embeddings, index, k: int, threshold: float): | |
| query_emb = model.encode( | |
| ["query: " + query], convert_to_numpy=True, normalize_embeddings=True | |
| ) | |
| sims = cosine_similarity(query_emb, embeddings)[0] | |
| top_idx = sims.argsort()[::-1][:k] | |
| best_score = float(sims[top_idx[0]]) | |
| if best_score < threshold: | |
| return None, best_score | |
| results = [] | |
| for rank, idx in enumerate(top_idx, start=1): | |
| url = str(df.iloc[idx]["url"]) if pd.notna(df.iloc[idx]["url"]) else "" | |
| results.append( | |
| { | |
| "rank": rank, | |
| "score": float(sims[idx]), | |
| "focus": df.iloc[idx]["focus"], | |
| "question": df.iloc[idx]["question"], | |
| "url": url, | |
| "answer": df.iloc[idx]["answer"].strip(), | |
| "text": df.iloc[idx]["text"], | |
| } | |
| ) | |
| # Only keep results that have a valid source URL | |
| sourced = [r for r in results if r["url"].startswith("http")] | |
| if not sourced: | |
| return None, best_score | |
| return sourced, best_score | |
| def build_prompt(query: str, retrieved_docs: list) -> str: | |
| context_parts = [] | |
| for doc in retrieved_docs: | |
| context_parts.append(doc.get("text", str(doc)) if isinstance(doc, dict) else str(doc)) | |
| context_text = "\n\n---\n\n".join(context_parts) | |
| return f"""You are an expert medical assistant. Answer the user's question based SOLELY \ | |
| on the provided context. If the context does not contain the answer, clearly state that \ | |
| the information is not available in the provided documents. | |
| CONTEXT: | |
| {context_text} | |
| QUESTION: | |
| {query} | |
| ANSWER:""" | |
| REJECTION_PHRASES = [ | |
| "cannot answer that", | |
| "i don't see any context", | |
| "no information provided", | |
| "as a large language model", | |
| "outside the focus area", | |
| "provided context does not contain information" | |
| ] | |
| def _fallback_answer(retrieved_docs: list) -> str: | |
| """Return the top retrieved answer when the LLM is unavailable.""" | |
| top = retrieved_docs[0] | |
| ans = top["answer"] | |
| result = f"**{top['focus']}**\n\n{ans}" | |
| result += f"\n\n**Source:** {top['url']}" | |
| result += "\n\n*Note: AI synthesis unavailable β showing best-matched knowledge base entry.*" | |
| return result | |
| def generate_answer(query: str, retrieved_docs: list, llm) -> str: | |
| if llm is None: | |
| return _fallback_answer(retrieved_docs) | |
| prompt = build_prompt(query, retrieved_docs) | |
| try: | |
| response = llm.invoke(prompt) | |
| # ChatOllama returns an AIMessage; plain Ollama LLM returns a str | |
| ans = response.content if hasattr(response, "content") else str(response) | |
| ans = ans.strip() | |
| if not ans: | |
| return _fallback_answer(retrieved_docs) | |
| for phrase in REJECTION_PHRASES: | |
| if phrase in ans.lower(): | |
| return ( | |
| "The provided context does not contain information relevant " | |
| "to your question. Please ask a medical question covered by " | |
| "the MedQuAD knowledge base." | |
| ) | |
| # Append primary source URL (always present β filtered at retrieval) | |
| ans += f"\n\n**Source:** {retrieved_docs[0]['url']}" | |
| return ans | |
| except Exception: | |
| # LLM call failed (e.g. Ollama not running) β fall back to retrieved doc | |
| return _fallback_answer(retrieved_docs) | |
| # --------------------------------------------------------------------------- | |
| # Streamlit page | |
| # --------------------------------------------------------------------------- | |
| st.set_page_config( | |
| page_title="Medical RAG Chatbot", | |
| page_icon="π₯", | |
| layout="wide", | |
| ) | |
| st.title("π₯ Medical RAG Chatbot") | |
| st.caption( | |
| "Powered by **MedQuAD** Β· " | |
| "**intfloat/e5-base** embeddings Β· **Llama 3.1** via Groq" | |
| ) | |
| # ββ Sidebar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with st.sidebar: | |
| st.header("βοΈ Settings") | |
| k_docs = st.slider( | |
| "Top-k documents to retrieve", min_value=1, max_value=10, value=DEFAULT_K | |
| ) | |
| threshold = st.slider( | |
| "Similarity threshold", | |
| min_value=0.0, | |
| max_value=1.0, | |
| value=DEFAULT_THRESHOLD, | |
| step=0.05, | |
| help="Queries below this cosine-similarity score return 'no results found'.", | |
| ) | |
| show_sources = st.toggle("Show retrieved sources", value=True) | |
| st.divider() | |
| if st.button("ποΈ Clear chat history", use_container_width=True): | |
| st.session_state.messages = [] | |
| st.rerun() | |
| st.divider() | |
| st.info( | |
| "**Requirements**\n" | |
| "- `GROQ_API_KEY` in secrets or env\n" | |
| "- Free key at console.groq.com\n" | |
| "- `MedQuAD_combined.csv` in the same directory" | |
| ) | |
| # ββ Load pipeline ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with st.spinner( | |
| "Loading model & building index⦠" | |
| "(first run computes embeddings β this may take a few minutes)" | |
| ): | |
| model, df, embeddings, index = load_pipeline() | |
| llm = load_llm() | |
| if llm is None: | |
| st.warning( | |
| "Groq API key not found. " | |
| "Add **GROQ_API_KEY** to your Streamlit secrets (`.streamlit/secrets.toml`) " | |
| "or as an environment variable. Get a free key at https://console.groq.com. " | |
| "Answers will fall back to the best-matched knowledge base entry." | |
| ) | |
| st.success(f"Ready β **{len(df):,}** medical Q&A pairs indexed across **{df['folder_name'].nunique()}** source categories.") | |
| # ββ Chat history βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if "messages" not in st.session_state: | |
| st.session_state.messages = [] | |
| for message in st.session_state.messages: | |
| with st.chat_message(message["role"]): | |
| st.markdown(message["content"]) | |
| if show_sources and message.get("sources"): | |
| with st.expander("π Retrieved sources"): | |
| for src in message["sources"]: | |
| st.markdown( | |
| f"**Rank {src['rank']}** Β· Score: `{src['score']:.4f}` Β· " | |
| f"Focus: *{src['focus']}*" | |
| ) | |
| st.markdown(f"> {src['answer'][:400]}...") | |
| st.caption(src["url"]) | |
| # ββ Chat input βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if prompt := st.chat_input("Ask a medical questionβ¦"): | |
| st.session_state.messages.append({"role": "user", "content": prompt}) | |
| with st.chat_message("user"): | |
| st.markdown(prompt) | |
| with st.chat_message("assistant"): | |
| with st.spinner("Searching knowledge baseβ¦"): | |
| results, best_score = retrieve( | |
| prompt, model, df, embeddings, index, k=k_docs, threshold=threshold | |
| ) | |
| if results is None: | |
| answer = ( | |
| f"No relevant medical information found for your query " | |
| f"(best similarity score: **{best_score:.2f}**, threshold: **{threshold}**). " | |
| "Try a more specific medical question, or lower the similarity threshold." | |
| ) | |
| st.markdown(answer) | |
| st.session_state.messages.append( | |
| {"role": "assistant", "content": answer, "sources": []} | |
| ) | |
| else: | |
| with st.spinner("Generating answerβ¦"): | |
| answer = generate_answer(prompt, results, llm) | |
| st.markdown(answer) | |
| if show_sources: | |
| with st.expander("π Retrieved sources"): | |
| for src in results: | |
| st.markdown( | |
| f"**Rank {src['rank']}** Β· Score: `{src['score']:.4f}` Β· " | |
| f"Focus: *{src['focus']}*" | |
| ) | |
| st.markdown(f"> {src['answer'][:400]}...") | |
| st.caption(src["url"]) | |
| st.session_state.messages.append( | |
| {"role": "assistant", "content": answer, "sources": results} | |
| ) | |