A retrieval-augmented generation (RAG) pipeline over neurology medical textbooks, with a rigorous LLM-as-judge evaluation harness and an experimental agentic (self-correcting) RAG variant built on LangGraph.
Ask a clinical question → the pipeline retrieves the most relevant passages from the textbook corpus → a language model answers grounded in those passages, keeping source metadata and a similarity score for each retrieved chunk. Multiple generator models are then scored head-to-head on the same questions so you can see which one actually answers best.
- RAG pipeline — local embeddings + ChromaDB vector store + retrieve-then-generate.
- Pluggable generators — compare Mistral Small, Gemini Flash, and local Ollama models
(e.g.
qwen2.5:14b) on the same pipeline. - Evaluation harness — four LLM-as-judge metrics (correctness, groundedness, answer
relevance, retrieval relevance) logged to LangSmith, with a local, neutral judge
(
llama3.1:8b) so scoring is free, unlimited, and unbiased. - Agentic RAG — a LangGraph graph that grades its own retrieved context and verifies its own answer, re-retrieving / rewriting / regenerating with a loop budget.
- Parallel ingestion — PyMuPDF +
ProcessPoolExecutorfor fast multi-PDF loading.
flowchart LR
PDFs["PDFs (pdfs/)"] --> CHUNK["chunk<br/>3000 / 500 overlap"]
CHUNK --> EMB["embed<br/>all-MiniLM-L6-v2 · 384-d"]
EMB --> STORE[("ChromaDB<br/>data/vector_store · cosine")]
Q(["Question"]) --> RET["retrieve top-k=6"]
STORE --> RET
RET --> GEN["generate<br/>(Mistral / Gemini / qwen)"]
GEN --> A(["Answer + sources + similarity"])
Ingestion is cached (fingerprinted by filename/size/mtime), and the ChromaDB store is persistent — so re-runs skip re-parsing and re-embedding.
The agentic variant wraps the same retrieve → generate steps in a decision loop; see
notebooks/agentic_rag_security.md and the diagram in
notebooks/agentic_rag.ipynb.
drkrag/
├── notebooks/ # all notebooks (run these) — see notebooks/README.md
│ ├── rag_evaluation.ipynb # main eval harness (generators vs judges, LangSmith)
│ ├── agentic_rag.ipynb # self-correcting LangGraph RAG + A/B vs linear
│ ├── notebook_parallelized.ipynb # parallel PDF loading (PyMuPDF)
│ ├── notebook.ipynb, rag_semantic_extended.ipynb, simple_example_loaders.ipynb
│ ├── agentic_rag_security.md # security hardening reference
│ └── README.md # the bootstrap convention + prerequisites
├── src/ # importable package
│ ├── document_loader.py # process_all_pdfs(), split_documents() (+ caching)
│ ├── embeddings.py # EmbeddingManager, VectorStore (ChromaDB)
│ └── check_ocr_needed.py
├── utils/ # importable package
│ └── pdf_parallel_utils.py # parallel PyMuPDF page-range loading
├── pdfs/ # source PDFs (gitignored — bring your own)
├── data/ # vector store + caches (gitignored — auto-generated)
├── .env # API keys (gitignored — never commit)
└── requirements.txt
Note:
pdfs/,data/, and.envare gitignored on purpose — the corpus is copyrighted, the vector store is large and regenerable, and.envholds secrets.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtInstall Ollama, make sure it's running (http://localhost:11434), and
pull the models used by the notebooks:
ollama pull qwen2.5:14b # local generator
ollama pull llama3.1:8b # local LLM-as-judge (neutral, unlimited)Create a .env with the keys you use — only the ones you actually need:
MISTRAL_API_KEY=... # Mistral generator (free tier)
GOOGLE_API_KEY=... # Gemini generator (free tier; quota-limited)
LANGSMITH_API_KEY=... # experiment tracking / tracing
LANGSMITH_TRACING=true # optional: populate the LangSmith Traces tab
GROQ_API_KEY=... # optional alternate hosted model
Local generation + local judging need no keys — only LangSmith needs one for tracking.
Drop your source PDFs into pdfs/. The first run embeds them into data/vector_store;
subsequent runs reuse the cache.
Open a notebook in notebooks/ and run cells top-to-bottom. Run the bootstrap cell first —
it resolves the project root, fixes imports, and loads .env (see
notebooks/README.md for the convention).
rag_evaluation.ipynb— builds the store, defines generators + judges, and runs a head-to-head evaluation against a LangSmith dataset. Results render side-by-side under Datasets & Experiments in smith.langchain.com.agentic_rag.ipynb— the self-correcting graph, plus an A/B comparison against the linear pipeline on the same dataset and judges.
Ingestion & embedding (src/)
process_all_pdfs()loads PDFs (cached by a directory fingerprint) andsplit_documents()chunks them (default 3000 chars, 500 overlap).EmbeddingManagerembeds chunks with SentenceTransformers —all-MiniLM-L6-v2by default (384-dim, cosine), pluggable to larger models (e.g. Nemotron, with bf16 and asymmetric query/passage prefixes). Device auto-detectsmps/cuda/cpu.VectorStorepersists them in ChromaDB (cosine distance) atdata/vector_store.
Retrieval & generation
retrieve(query, k=6)embeds the query, pulls the top-6 chunks, and returns clean dicts withcontent,metadata(source file / page), and asimilarityscore.- The generator answers using only the retrieved context, via LangChain's
init_chat_model(swap the model string to change providers).
Each answer is graded by an LLM-as-judge on four metrics:
| Metric | Needs a reference? | Question it answers |
|---|---|---|
| Correctness | yes | Is the answer factually right vs. the ground-truth answer? |
| Answer relevance | no | Does the answer address the question? |
| Groundedness | no | Is every claim supported by the retrieved docs (no hallucination)? |
| Retrieval relevance | no | Were the retrieved chunks actually relevant? |
Separating them is diagnostic: high groundedness + low correctness ⇒ a retrieval problem; good retrieval + low groundedness ⇒ a generation problem.
The judge is llama3.1:8b running locally — chosen because it's neutral (not one of the
generators, so no self-preference bias), unlimited, and never rate-limited. Verdicts use
constrained JSON decoding with a graceful fallback so a malformed grade never crashes a run
or silently produces a null score.
agentic_rag.ipynb turns the fixed retrieve → generate chain into a LangGraph graph with
two gates where the model steers its own flow:
- grade documents — if the retrieved context is weak, rewrite the query and re-retrieve.
- verify — if the answer hallucinates, regenerate; if it's incomplete, fetch more context; otherwise return it.
Loop counters (MAX_LOOPS) plus a recursion_limit guarantee it always terminates with a
best-effort answer. Because this lets untrusted document text influence decisions, it carries
a larger attack surface — see the hardening reference in
notebooks/agentic_rag_security.md.
| Role | Options |
|---|---|
| Generator | mistral-small-latest, gemini-flash-latest, qwen2.5:14b (local Ollama) |
| Judge | llama3.1:8b (local Ollama) |
| Embeddings | all-MiniLM-L6-v2 (default), pluggable |
| Vector store | ChromaDB (cosine, persistent) |
| Orchestration | LangChain · LangGraph · LangSmith |
The corpus is copyrighted reference material and is not included — bring your own PDFs. Two paths send content off-machine: LangSmith tracing (questions, retrieved passages, answers) and hosted generators (Mistral/Gemini). Keep the fully-local path (Ollama generator + local judge) for anything sensitive, and never put patient data (PHI) in this system. See the security doc for the full threat model and hardenings.