karthik.dev
Back to blogRAG

GP Support Agent: Building a Deterministic RAG + LangGraph Agent for Healthcare

2026-08-17 · 4 min read

Why Healthcare AI Must Be Deterministic

Most RAG systems are non-deterministic: ask the same question twice and you might get different answers. For general information retrieval, this is acceptable. For healthcare reception — where answers about appointments, referrals, and medication queries carry real consequences — non-determinism is dangerous.

The GP Support Agent was built with one rule: identical inputs must produce identical outputs.

The Architecture

User Query
    ↓
LangGraph State Machine
    ├── Retrieval Node
    │     └── LangChain vector retriever
    │           └── Top-K chunks from healthcare knowledge base
    ├── Answer Generation Node
    │     └── LLM with retrieved chunks as grounded context
    │           └── Structured output schema (forces citations)
    └── Evaluation Node (offline)
          ├── Groundedness scorer
          ├── Faithfulness scorer
          └── Coverage scorer

Building the Healthcare Knowledge Base

The knowledge base was curated from a realistic GP reception scenario: appointment scheduling, referral processes, prescription queries, opening hours, emergency contacts, and frequently asked patient questions.

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

def build_knowledge_base(documents: list[str]) -> FAISS:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=400,
        chunk_overlap=80,
        separators=["\n\n", "\n", ". ", " "],
    )
    chunks = splitter.split_documents(documents)
    
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    vectorstore = FAISS.from_documents(chunks, embeddings)
    return vectorstore

Chunk size matters enormously. At 400 tokens per chunk with 80-token overlap, each chunk is large enough to contain a complete policy statement but small enough to remain specific. Larger chunks dilute retrieval precision; smaller chunks lose context.

LangGraph for Deterministic Agent State

LangGraph was the right choice for determinism because it makes agent state explicit and transitions verifiable:

from langgraph.graph import StateGraph, END
from typing import TypedDict

class AgentState(TypedDict):
    query: str
    retrieved_chunks: list[str]
    response: str | None
    citations: list[str]

def retrieval_node(state: AgentState) -> AgentState:
    chunks = retriever.get_relevant_documents(state["query"], k=4)
    return {**state, "retrieved_chunks": [c.page_content for c in chunks]}

def generation_node(state: AgentState) -> AgentState:
    context = "\n\n".join(state["retrieved_chunks"])
    prompt = f"""
    You are a GP reception assistant. Answer using ONLY the context below.
    If the answer is not in the context, say "I don't have that information."
    
    Context:
    {context}
    
    Question: {state["query"]}
    
    Answer (cite the relevant context):
    """
    response = llm.invoke(prompt)
    return {**state, "response": response.content}

graph = StateGraph(AgentState)
graph.add_node("retrieve", retrieval_node)
graph.add_node("generate", generation_node)
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)
graph.set_entry_point("retrieve")
agent = graph.compile()

The key to determinism: temperature=0 on the LLM, fixed k=4 for retrieval, and a prompt that explicitly prohibits answers outside the retrieved context.

The Evaluation Harness

The hardest part of building a RAG system isn't the retrieval — it's knowing if it works. I built three evaluators:

Groundedness: Is the answer actually in the chunks?

def score_groundedness(response: str, chunks: list[str]) -> float:
    """Score whether the response is supported by retrieved chunks."""
    eval_prompt = f"""
    Retrieved context:
    {chr(10).join(chunks)}
    
    Agent response: {response}
    
    On a scale of 0.0 to 1.0, how well is the response grounded in the context?
    - 1.0: Every claim in the response appears in the context
    - 0.5: Some claims are in the context, others are not
    - 0.0: The response contradicts or ignores the context
    
    Return only a float between 0.0 and 1.0.
    """
    return float(eval_llm.invoke(eval_prompt).content.strip())

Faithfulness: Does the answer contradict the source?

Groundedness asks "is this in the source?" — faithfulness asks "does this contradict the source?" They're different. A response can ignore the source (low groundedness) without contradicting it (high faithfulness).

Coverage: Did the response address all relevant parts of the question?

Some questions have multiple sub-questions. Coverage measures whether all parts were addressed.

Results on the Test Set

Metric Score
Groundedness 0.91
Faithfulness 0.96
Coverage 0.84

Coverage was the weakest dimension — complex multi-part questions sometimes had one sub-question addressed well and another missed. The fix: decompose complex queries into sub-queries before retrieval.

What Makes This Production-Grade

  1. Deterministic behavior — temperature=0, fixed retrieval k, structured output schema
  2. Explicit refusal — "I don't have that information" beats a hallucinated answer
  3. Evaluation harness — you can measure quality degradation when the knowledge base changes
  4. Grounded citations — every answer references the chunks it came from

GitHub: github.com/karthikrshet/gp-support-agent