Commit ·
a058e67
1
Parent(s): 3e0555e
Replace default Streamlit template with medical RAG chat UI
Browse filesWires 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 +1 -0
- README.md +10 -9
- artifacts/.gitkeep +0 -0
- requirements.txt +6 -2
- src/rag/__init__.py +7 -0
- src/rag/chunking.py +46 -0
- src/rag/config.py +52 -0
- src/rag/data/__init__.py +0 -0
- src/rag/data/clean.py +22 -0
- src/rag/data/loader.py +18 -0
- src/rag/embeddings.py +22 -0
- src/rag/generator.py +17 -0
- src/rag/index.py +31 -0
- src/rag/pipeline.py +44 -0
- src/rag/prompts.py +44 -0
- src/rag/retriever.py +48 -0
- src/streamlit_app.py +43 -38
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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 8501
|
| 8 |
tags:
|
| 9 |
- streamlit
|
| 10 |
pinned: false
|
| 11 |
-
short_description:
|
| 12 |
---
|
| 13 |
|
| 14 |
-
#
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 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 |
-
|
| 2 |
pandas
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
import streamlit as st
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
"
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
st.
|
| 34 |
-
.
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 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})
|