A local-first agentic RAG system built with LangGraph, combining three research patterns — Adaptive RAG, Corrective RAG (CRAG), and Self-RAG — into a single self-correcting workflow.
Instead of the classic "retrieve once, generate once" pipeline, this graph reasons about its own retrieval and its own answers: it decides where to look, checks whether what it found is actually relevant, falls back to web search when it isn't, and refuses to ship an answer that is hallucinated or off-topic.
Everything runs locally through Ollama — no OpenAI key required. The only external service is Tavily for web search.
| Pattern | Question it answers | Where it lives |
|---|---|---|
| Adaptive RAG | Should I even use the vector store for this question? | graph/chains/router.py |
| Corrective RAG | Are the retrieved documents actually relevant? If not, what do I do? | graph/chains/retrieval_grader.py, graph/nodes/grade_documents.py |
| Self-RAG | Is my answer grounded in the sources, and does it address the question? | graph/chains/hallucination_grader.py, graph/chains/answer_grader.py |
The entry point is a conditional edge, not a node. An LLM router classifies the incoming question against the known scope of the index (agents, prompt engineering, adversarial attacks on LLMs) and returns a structured datasource of either vectorstore or websearch. Questions about last week's news skip the vector store entirely; questions about agent memory go straight to retrieval.
After retrieval, every document is graded for relevance one at a time. Irrelevant documents are dropped from the state rather than being passed to the generator as noise. If any document was dropped, a web_search flag flips to True and the graph detours through Tavily to top up the context before generating.
Generation is not the end of the graph. The answer is scored twice:
- Grounded in the documents? If not, the graph loops back and regenerates.
- Does it address the question? If not, the graph routes to web search for better context and tries again.
Only an answer that passes both checks reaches END.
graph TD
START([START]) --> ROUTE{Adaptive RAG<br/>router}
ROUTE -->|vectorstore| RETRIEVE[retrieve]
ROUTE -->|websearch| WEBSEARCH[websearch]
RETRIEVE --> GRADE[grade_documents]
GRADE --> DECIDE{any doc<br/>irrelevant?}
DECIDE -->|yes - Corrective RAG| WEBSEARCH
DECIDE -->|no| GENERATE[generate]
WEBSEARCH --> GENERATE
GENERATE --> SELF{Self-RAG<br/>grounded? useful?}
SELF -->|not supported - regenerate| GENERATE
SELF -->|not useful - more context| WEBSEARCH
SELF -->|useful| FINISH([END])
A rendered PNG of the compiled graph is written to graph/graph.png on every import of graph.graph.
Every node reads and writes a single GraphState (graph/state.py):
class GraphState(TypedDict):
question: str # the user's question
generation: str # the LLM's answer
web_search: bool # did document grading fail?
documents: List[str] # the working contextagentic-rag/
├── ingestion.py # Loads blog posts → chunks → Chroma vector store
├── main.py # Entry point
└── graph/
├── graph.py # Node wiring, conditional edges, compiled app
├── state.py # GraphState
├── consts.py # Node name constants
├── chains/
│ ├── router.py # Adaptive RAG: vectorstore vs. websearch
│ ├── retrieval_grader.py # Corrective RAG: is this document relevant?
│ ├── generation.py # The RAG answer chain
│ ├── hallucination_grader.py # Self-RAG: grounded in the facts?
│ ├── answer_grader.py # Self-RAG: does it answer the question?
│ └── tests/test_chains.py # Unit tests for each chain
└── nodes/
├── retrieve.py
├── grade_documents.py
├── generate.py
└── web_search.py
Each grader is a small, independently testable chain: a prompt piped into an LLM with with_structured_output(...), so every decision comes back as a validated Pydantic model rather than a string that has to be parsed.
ollama pull qwen3:1.7b # reasoning, routing, and grading
ollama pull nomic-embed-text # embeddingsgit clone git@github.com:HenrikGharagyozyan/agentic-rag.git
cd agentic-rag
uv syncCreate a .env file in the project root:
TAVILY_API_KEY=your-tavily-key
# Optional — LangSmith tracing
LANGSMITH_TRACING=true
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
LANGSMITH_API_KEY=your-langsmith-key
LANGSMITH_PROJECT=agentic-raguv run python main.pyOn the first run, ingestion.py fetches three Lilian Weng blog posts, chunks them, embeds them with nomic-embed-text, and persists a Chroma store to ./.chroma. Subsequent runs reuse it, so startup is fast.
The graph narrates its decisions as it goes:
---ROUTE QUESTION---
---ROUTE QUESTION TO RAG---
---RETRIEVE---
---CHECK DOCUMENT RELEVANCE TO QUESTION---
---GRADE: DOCUMENT RELEVANT---
---GRADE: DOCUMENT NOT RELEVANT---
---ASSESS GRADED DOCUMENTS---
---DECISION: NOT ALL DOCUMENTS ARE RELEVANT TO QUESTION, INCLUDE WEB SEARCH---
---WEB SEARCH---
---GENERATE---
---CHECK HALLUCINATIONS---
---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---
---GRADE GENERATION vs QUESTION---
---DECISION: GENERATION ADDRESSES QUESTION---
from graph.graph import app
result = app.invoke(input={"question": "what is agent memory?"})
print(result["generation"])uv run pytestThe suite exercises each chain in isolation — the retrieval grader on both relevant and irrelevant documents, the hallucination grader on both grounded and fabricated answers, and the router on questions inside and outside the index's scope.
Note: the tests call a real LLM and a real vector store, so they are slow (minutes, not seconds) and Ollama must be running.
Modules import through the graph.* package path, so run them as modules from the project root rather than by file path:
uv run python -m graph.nodes.web_search # works
uv run python graph/nodes/web_search.py # ModuleNotFoundError: No module named 'graph'| Layer | Choice |
|---|---|
| Orchestration | LangGraph |
| LLM | Ollama (qwen3:1.7b) |
| Embeddings | Ollama (nomic-embed-text) |
| Vector store | Chroma (persisted locally) |
| Web search | Tavily |
| Structured output | Pydantic |
| Observability | LangSmith (optional) |
- Adaptive RAG — Jeong et al., 2024
- Corrective RAG — Yan et al., 2024
- Self-RAG — Asai et al., 2023
Released under the MIT License.