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
- Deterministic behavior — temperature=0, fixed retrieval k, structured output schema
- Explicit refusal — "I don't have that information" beats a hallucinated answer
- Evaluation harness — you can measure quality degradation when the knowledge base changes
- Grounded citations — every answer references the chunks it came from