whosouravsharma commited on
Commit
a058e67
·
1 Parent(s): 3e0555e

Replace default Streamlit template with medical RAG chat UI

Browse files

Wires src/streamlit_app.py to RagPipeline, adds the rag package under
src/, and updates the Dockerfile/requirements for the pipeline's deps.
artifacts/ is a placeholder for now — index build is a follow-up.

Dockerfile CHANGED
@@ -10,6 +10,7 @@ RUN apt-get update && apt-get install -y \
10
 
11
  COPY requirements.txt ./
12
  COPY src/ ./src/
 
13
 
14
  RUN pip3 install -r requirements.txt
15
 
 
10
 
11
  COPY requirements.txt ./
12
  COPY src/ ./src/
13
+ COPY artifacts/ ./artifacts/
14
 
15
  RUN pip3 install -r requirements.txt
16
 
README.md CHANGED
@@ -1,19 +1,20 @@
1
  ---
2
- title: Medical Assistant Ui
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
  sdk: docker
7
  app_port: 8501
8
  tags:
9
  - streamlit
10
  pinned: false
11
- short_description: Streamlit template space
12
  ---
13
 
14
- # Welcome to Streamlit!
15
 
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
 
17
 
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
1
  ---
2
+ title: Medical Assistant UI
3
+ emoji: 🩺
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
  app_port: 8501
8
  tags:
9
  - streamlit
10
  pinned: false
11
+ short_description: RAG-grounded medical Q&A chat
12
  ---
13
 
14
+ # Medical Assistant
15
 
16
+ A chat UI over a retrieval-augmented question-answering pipeline built on the
17
+ MedQuAD dataset. Ask a medical question and get an answer grounded in
18
+ retrieved source passages, with sources cited below each response.
19
 
20
+ Not a substitute for professional medical advice.
 
artifacts/.gitkeep ADDED
File without changes
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
- altair
2
  pandas
3
- streamlit
 
 
 
 
 
1
+ streamlit
2
  pandas
3
+ pyarrow
4
+ sentence-transformers
5
+ faiss-cpu
6
+ openai
7
+ python-dotenv
src/rag/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Load .env once, at package import, so scripts/notebooks/API all behave alike.
2
+ try:
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+ except ImportError: # optional dependency; env vars still work if exported
7
+ pass
src/rag/chunking.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Word-window chunking with overlap (notebook cells 12, 15)."""
2
+
3
+ import pandas as pd
4
+
5
+ from rag.config import CONFIG
6
+
7
+
8
+ def chunk_text(text: str, chunk_size: int = CONFIG.chunk_size,
9
+ overlap: int = CONFIG.overlap) -> list[str]:
10
+ words = text.split()
11
+ chunks: list[str] = []
12
+ start = 0
13
+
14
+ while start < len(words):
15
+ end = start + chunk_size
16
+ chunks.append(" ".join(words[start:end]))
17
+ if end >= len(words):
18
+ break
19
+ start = end - overlap
20
+
21
+ return chunks
22
+
23
+
24
+ def chunk_corpus(corpus_df: pd.DataFrame) -> pd.DataFrame:
25
+ """Explode the corpus into one row per chunk, carrying metadata along.
26
+
27
+ The metadata is what makes an answer citable later, so it rides with
28
+ every chunk rather than being re-joined at query time.
29
+ """
30
+ records = []
31
+
32
+ for row in corpus_df.itertuples(index=False):
33
+ for i, chunk in enumerate(chunk_text(row.text)):
34
+ records.append({
35
+ "chunk_id": f"{row.record_id}_{i}",
36
+ "record_id": row.record_id,
37
+ "chunk_number": i,
38
+ "chunk_text": chunk,
39
+ "question": row.question,
40
+ "source": row.source,
41
+ "focus_area": row.focus_area,
42
+ })
43
+
44
+ chunks_df = pd.DataFrame(records)
45
+ chunks_df["chunk_word_count"] = chunks_df["chunk_text"].str.split().str.len()
46
+ return chunks_df
src/rag/config.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single place for every knob in the pipeline.
2
+
3
+ Everything downstream imports from here, so a change to chunk size or the
4
+ embedding model never means hunting through modules.
5
+ """
6
+
7
+ import os
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
12
+ ARTIFACTS_DIR = PROJECT_ROOT / "artifacts"
13
+ DATASET_DIR = PROJECT_ROOT / "dataset"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Config:
18
+ # data
19
+ kaggle_dataset: str = "pythonafroz/medquad-medical-question-answer-for-ai-research"
20
+ csv_name: str = "medquad.csv"
21
+ local_csv: Path = DATASET_DIR / "rag1" / "medquad.csv"
22
+
23
+ # chunking
24
+ chunk_size: int = 500
25
+ overlap: int = 50
26
+
27
+ # embeddings
28
+ embedding_model: str = "all-MiniLM-L6-v2"
29
+ batch_size: int = 32
30
+
31
+ # retrieval
32
+ top_k: int = 5
33
+
34
+ # generation
35
+ llm_model: str = "gpt-5.6-luna"
36
+
37
+ # persisted artifacts
38
+ index_path: Path = ARTIFACTS_DIR / "faiss.index"
39
+ chunks_path: Path = ARTIFACTS_DIR / "chunks.parquet"
40
+
41
+ @property
42
+ def openai_api_key(self) -> str:
43
+ key = os.environ.get("OPENAI_API_KEY")
44
+ if not key:
45
+ raise RuntimeError(
46
+ "OPENAI_API_KEY is not set. Put it in .env or export it; "
47
+ "never hardcode it in a notebook cell."
48
+ )
49
+ return key
50
+
51
+
52
+ CONFIG = Config()
src/rag/data/__init__.py ADDED
File without changes
src/rag/data/clean.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cleaning + corpus construction (notebook cells 6-7)."""
2
+
3
+ import pandas as pd
4
+
5
+
6
+ def clean(df: pd.DataFrame) -> pd.DataFrame:
7
+ """Drop exact duplicate rows and records with no answer."""
8
+ return df.drop_duplicates().dropna(subset=["answer"])
9
+
10
+
11
+ def build_corpus(df: pd.DataFrame) -> pd.DataFrame:
12
+ """Fuse question + answer into the single `text` field we embed."""
13
+ corpus = df.copy()
14
+ corpus["text"] = (
15
+ "Question: " + corpus["question"].astype(str)
16
+ + "\n\n"
17
+ + "Answer: " + corpus["answer"].astype(str)
18
+ )
19
+ corpus = corpus.reset_index(drop=True)
20
+ corpus["record_id"] = corpus.index
21
+ corpus["word_count"] = corpus["text"].str.split().str.len()
22
+ return corpus
src/rag/data/loader.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Get the raw MedQuAD dataframe (notebook cell 2)."""
2
+
3
+ import os
4
+
5
+ import pandas as pd
6
+
7
+ from rag.config import CONFIG
8
+
9
+
10
+ def load_raw() -> pd.DataFrame:
11
+ """Prefer the local copy in dataset/; fall back to a Kaggle download."""
12
+ if CONFIG.local_csv.exists():
13
+ return pd.read_csv(CONFIG.local_csv)
14
+
15
+ import kagglehub
16
+
17
+ path = kagglehub.dataset_download(CONFIG.kaggle_dataset)
18
+ return pd.read_csv(os.path.join(path, CONFIG.csv_name))
src/rag/embeddings.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sentence-transformer embeddings (notebook cells 21-27)."""
2
+
3
+ from functools import lru_cache
4
+
5
+ import numpy as np
6
+ from sentence_transformers import SentenceTransformer
7
+
8
+ from rag.config import CONFIG
9
+
10
+
11
+ @lru_cache(maxsize=1)
12
+ def get_model() -> SentenceTransformer:
13
+ """Cached so importing this module twice doesn't reload 90MB of weights."""
14
+ return SentenceTransformer(CONFIG.embedding_model)
15
+
16
+
17
+ def embed(texts: list[str], show_progress: bool = False) -> np.ndarray:
18
+ return get_model().encode(
19
+ texts,
20
+ batch_size=CONFIG.batch_size,
21
+ show_progress_bar=show_progress,
22
+ ).astype("float32")
src/rag/generator.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM call (notebook cells 39-44)."""
2
+
3
+ from functools import lru_cache
4
+
5
+ from openai import OpenAI
6
+
7
+ from rag.config import CONFIG
8
+
9
+
10
+ @lru_cache(maxsize=1)
11
+ def get_client() -> OpenAI:
12
+ return OpenAI(api_key=CONFIG.openai_api_key)
13
+
14
+
15
+ def generate(prompt: str, model: str = CONFIG.llm_model) -> str:
16
+ response = get_client().responses.create(model=model, input=prompt)
17
+ return response.output_text
src/rag/index.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FAISS index build / save / load (notebook cells 30-31)."""
2
+
3
+ import faiss
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+ from rag.config import CONFIG
8
+
9
+
10
+ def build_index(embeddings: np.ndarray) -> faiss.Index:
11
+ """Inner product over L2-normalized vectors == cosine similarity."""
12
+ matrix = embeddings.astype("float32")
13
+ faiss.normalize_L2(matrix)
14
+
15
+ index = faiss.IndexFlatIP(matrix.shape[1])
16
+ index.add(matrix)
17
+ return index
18
+
19
+
20
+ def save(index: faiss.Index, chunks_df: pd.DataFrame) -> None:
21
+ CONFIG.index_path.parent.mkdir(parents=True, exist_ok=True)
22
+ faiss.write_index(index, str(CONFIG.index_path))
23
+ chunks_df.to_parquet(CONFIG.chunks_path, index=False)
24
+
25
+
26
+ def load() -> tuple[faiss.Index, pd.DataFrame]:
27
+ if not CONFIG.index_path.exists():
28
+ raise FileNotFoundError(
29
+ f"No index at {CONFIG.index_path}. Run: python scripts/build_index.py"
30
+ )
31
+ return faiss.read_index(str(CONFIG.index_path)), pd.read_parquet(CONFIG.chunks_path)
src/rag/pipeline.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The two halves of the diagram in the notebook, as functions.
2
+
3
+ `build()` is the offline path (dataset -> index). `RagPipeline.answer()` is
4
+ the online path (question -> grounded answer + sources).
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+ from rag import index as index_store
10
+ from rag.chunking import chunk_corpus
11
+ from rag.config import CONFIG
12
+ from rag.data.clean import build_corpus, clean
13
+ from rag.data.loader import load_raw
14
+ from rag.embeddings import embed
15
+ from rag.generator import generate
16
+ from rag.prompts import build_prompt
17
+ from rag.retriever import Retrieved, Retriever
18
+
19
+
20
+ def build() -> None:
21
+ """Offline: download -> clean -> corpus -> chunk -> embed -> index -> disk."""
22
+ corpus = build_corpus(clean(load_raw()))
23
+ chunks_df = chunk_corpus(corpus)
24
+ embeddings = embed(chunks_df["chunk_text"].tolist(), show_progress=True)
25
+ index_store.save(index_store.build_index(embeddings), chunks_df)
26
+ print(f"Indexed {len(chunks_df)} chunks from {len(corpus)} records.")
27
+
28
+
29
+ @dataclass
30
+ class Answer:
31
+ query: str
32
+ text: str
33
+ sources: list[Retrieved]
34
+
35
+
36
+ class RagPipeline:
37
+ def __init__(self) -> None:
38
+ idx, chunks_df = index_store.load()
39
+ self.retriever = Retriever(idx, chunks_df)
40
+
41
+ def answer(self, query: str, top_k: int = CONFIG.top_k) -> Answer:
42
+ results = self.retriever.retrieve(query, top_k=top_k)
43
+ text = generate(build_prompt(query, results))
44
+ return Answer(query=query, text=text, sources=results)
src/rag/prompts.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Context assembly and the grounding prompt (notebook cells 42-43).
2
+
3
+ Kept in its own module because the wording here is the main lever against
4
+ hallucination — it should be diffable and reviewable on its own.
5
+ """
6
+
7
+ from rag.retriever import Retrieved
8
+
9
+ SYSTEM_RULES = """You are a medical information assistant.
10
+
11
+ Answer the user's question using ONLY the medical context provided below.
12
+
13
+ Rules:
14
+ - Do not add medical facts that are not supported by the context.
15
+ - Do not make a diagnosis.
16
+ - Do not prescribe treatment or medication.
17
+ - If the context is insufficient to answer the question,
18
+ clearly say that the retrieved information is insufficient.
19
+ - Keep the answer clear and concise.
20
+ - After the answer, list the sources used."""
21
+
22
+
23
+ def build_context(results: list[Retrieved]) -> str:
24
+ parts = [
25
+ f"""Source: {r.source}
26
+ Focus area: {r.focus_area}
27
+ Original question: {r.question}
28
+
29
+ Medical context:
30
+ {r.chunk_text}"""
31
+ for r in results
32
+ ]
33
+ return "\n\n---\n\n".join(parts)
34
+
35
+
36
+ def build_prompt(query: str, results: list[Retrieved]) -> str:
37
+ return f"""{SYSTEM_RULES}
38
+
39
+ Medical context:
40
+ {build_context(results)}
41
+
42
+ User question:
43
+ {query}
44
+ """
src/rag/retriever.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Query -> top-k chunks with metadata (notebook cell 32)."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import faiss
6
+ import pandas as pd
7
+
8
+ from rag.config import CONFIG
9
+ from rag.embeddings import embed
10
+
11
+
12
+ @dataclass
13
+ class Retrieved:
14
+ rank: int
15
+ score: float
16
+ chunk_id: str
17
+ record_id: int
18
+ question: str
19
+ source: str
20
+ focus_area: str
21
+ chunk_text: str
22
+
23
+
24
+ class Retriever:
25
+ def __init__(self, index: faiss.Index, chunks_df: pd.DataFrame):
26
+ self.index = index
27
+ self.chunks_df = chunks_df
28
+
29
+ def retrieve(self, query: str, top_k: int = CONFIG.top_k) -> list[Retrieved]:
30
+ query_embedding = embed([query])
31
+ faiss.normalize_L2(query_embedding)
32
+
33
+ scores, indices = self.index.search(query_embedding, top_k)
34
+
35
+ results = []
36
+ for rank, (score, idx) in enumerate(zip(scores[0], indices[0]), start=1):
37
+ row = self.chunks_df.iloc[idx]
38
+ results.append(Retrieved(
39
+ rank=rank,
40
+ score=float(score),
41
+ chunk_id=row["chunk_id"],
42
+ record_id=int(row["record_id"]),
43
+ question=row["question"],
44
+ source=row["source"],
45
+ focus_area=row["focus_area"],
46
+ chunk_text=row["chunk_text"],
47
+ ))
48
+ return results
src/streamlit_app.py CHANGED
@@ -1,40 +1,45 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
 
 
4
  import streamlit as st
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
5
+
6
  import streamlit as st
7
 
8
+ from rag.pipeline import RagPipeline
9
+
10
+ st.set_page_config(page_title="Medical Assistant", page_icon="🩺")
11
+
12
+
13
+ @st.cache_resource
14
+ def load_pipeline() -> RagPipeline:
15
+ return RagPipeline()
16
+
17
+
18
+ pipeline = load_pipeline()
19
+
20
+ st.title("Medical Assistant")
21
+ st.caption(
22
+ "Ask a medical question and get an answer grounded in the MedQuAD dataset, "
23
+ "with sources cited below each response. Not a substitute for professional "
24
+ "medical advice."
25
+ )
26
+
27
+ if "messages" not in st.session_state:
28
+ st.session_state.messages = []
29
+
30
+ for message in st.session_state.messages:
31
+ with st.chat_message(message["role"]):
32
+ st.markdown(message["content"])
33
+
34
+ if question := st.chat_input("What are the symptoms of glaucoma?"):
35
+ st.session_state.messages.append({"role": "user", "content": question})
36
+ with st.chat_message("user"):
37
+ st.markdown(question)
38
+
39
+ with st.chat_message("assistant"):
40
+ result = pipeline.answer(question)
41
+ sources = "\n".join(f"- {s.source} — {s.focus_area}" for s in result.sources)
42
+ answer = f"{result.text}\n\n**Sources**\n{sources}"
43
+ st.markdown(answer)
44
+
45
+ st.session_state.messages.append({"role": "assistant", "content": answer})