{"slug": "from-rag-to-agentic-ai-how-i-added-langgraph-to-my-local", "title": "From RAG to Agentic AI. How I Added LangGraph to My Local", "summary": "A developer evolved a local RAG assistant into an agentic AI architecture using LangGraph, adding a classifier that routes questions to specialized agents for procedural queries, error code lookup, or escalation. The system uses a shared state with a 'niveau_support' field to communicate urgency between agents.", "body_md": "In my [previous article](https://dev.to/josaphatstar/i-built-a-local-rag-assistant-with-ollama-chromadb-and-langchain-heres-what-i-learned-5a2e), I built a fully local RAG assistant Ollama, ChromaDB, LangChain, all running in Docker. It answered technical support questions by searching through documentation and citing sources. It worked.\n\nBut after using it for a while, I noticed something uncomfortable: **it treated every question the same way**.\n\nAsk it *\"how to close monthly payroll?\"* it searches the docs. Fine.\n\nAsk it *\"the server crashes at startup\"* it also searches the docs. Less fine.\n\nAsk it something completely outside the documentation it searches the docs. Useless.\n\nA real support technician doesn't do that. They first *assess* the situation, then decide what to do: look it up, run a diagnosis, or escalate to a human. My RAG had no such judgment.\n\nThat's what this article is about how I evolved the system into an **Agentic AI architecture** using LangGraph, where the assistant first decides *which strategy to use*, then acts accordingly.\n\nClassic RAG is a linear pipeline. Every query follows the exact same path:\n\n```\nQuestion → Embed → Retrieve → Prompt → LLM → Answer\n```\n\nNo branching. No decision-making. No memory between steps.\n\nThis works perfectly for procedural questions where the answer lives in the docs. But technical support involves at least three distinct scenarios:\n\n| Scenario | Example | Best strategy |\n|---|---|---|\n| Procedural question | \"How do I create an account?\" | Search documentation |\n| Known error code | \"ERR-COMP-001 appears\" | Lookup error database |\n| Unknown incident | \"Server crashes, no idea why\" | Diagnose + escalate if needed |\n\nA single RAG pipeline handles the first case well and the other two poorly. The solution is to add a layer of reasoning *before* retrieval.\n\nThe shift from RAG to Agentic AI comes down to one thing: **the system plans before it acts**.\n\nInstead of one fixed pipeline, you have:\n\n```\nQuestion\n    ↓\nClassifier (what kind of question is this?)\n    ↓\n    ├── Procedural → RAG Agent (search docs)\n    ├── Error code → Diagnostic Agent (lookup + LLM analysis)\n    └── Complex    → Diagnostic Agent → Escalation Agent (generate ticket)\n```\n\nEach branch is an **agent**; an autonomous function that receives a shared state, does its job, updates the state, and returns it. LangGraph connects these agents into a directed graph with conditional routing between them.\n\nBefore building agents, you define the state they all share. Think of it as a case file that every agent reads and updates:\n\n``` python\n# src/agents/state.py\n\nfrom typing import TypedDict, List, Annotated, Sequence\nfrom langchain_core.messages import BaseMessage\nimport operator\n\nclass AgentState(TypedDict):\n    messages: Annotated[Sequence[BaseMessage], operator.add]\n    question: str          # Original question never changes\n    plan: List[str]\n    current_step: int\n    past_steps: List[str]  # Audit trail of what each agent did\n    response: str\n    next_agent: str\n    niveau_support: int    # 1=resolved, 2=partial, 3=needs human\n```\n\nThe `niveau_support`\n\nfield is key it's how agents communicate urgency to each other. If an agent sets it to 3, the orchestrator escalates automatically.\n\n`Annotated[Sequence[BaseMessage], operator.add]`\n\nmeans messages accumulate — each agent appends rather than overwrites. You get a full conversation trace at the end.\n\nThe pipeline from my previous article, repackaged as an autonomous agent:\n\n``` php\n# src/agents/rag_agent.py\n\ndef rag_agent(state: AgentState) -> AgentState:\n    \"\"\"Searches the documentation and generates a grounded answer.\"\"\"\n\n    docs = vectorstore.similarity_search(state[\"question\"], k=3)\n\n    if not docs:\n        return {\n            **state,\n            \"messages\": list(state[\"messages\"]) + [\n                AIMessage(content=\"[RAG] No relevant document found.\")\n            ],\n            \"past_steps\": state[\"past_steps\"] + [\"RAG: no results\"],\n            \"niveau_support\": 3   # Nothing found — escalate\n        }\n\n    context = \"\\n\\n\".join([\n        f\"[Source: {d.metadata.get('module', '?')}]\\n{d.page_content}\"\n        for d in docs\n    ])\n\n    answer = llm.invoke(\n        f\"Answer from context only.\\nContext: {context}\\nQuestion: {state['question']}\"\n    )\n\n    return {\n        **state,\n        \"messages\": list(state[\"messages\"]) + [AIMessage(content=f\"[RAG] {answer}\")],\n        \"past_steps\": state[\"past_steps\"] + [\"RAG: documentation search completed\"],\n        \"niveau_support\": 1\n    }\n```\n\nThe critical difference from the classic RAG: the agent returns an **updated state**, not just an answer. Every agent follows this same pattern — receive state, do work, return updated state.\n\nHandles error codes and technical incidents with two modes:\n\n```\n# src/agents/diagnostic_agent.py\n\nKNOWN_ERRORS = {\n    \"ERR-COMP-001\": {\n        \"description\": \"Debit/credit imbalance\",\n        \"solution\": \"Check Total Debit = Total Credit. Go to Accounting > Journal Entry.\"\n    },\n    \"ERR-PAIE-042\": {\n        \"description\": \"Payroll line missing rate\",\n        \"solution\": \"Go to Settings > Payroll Lines, fill in the missing rate.\"\n    },\n}\n\ndef diagnostic_agent(state: AgentState) -> AgentState:\n    question = state[\"question\"]\n    found_codes = [code for code in KNOWN_ERRORS if code.lower() in question.lower()]\n\n    if found_codes:\n        # Mode 1 : known error: instant answer, no LLM call needed\n        responses = [\n            f\"Code {code}: {KNOWN_ERRORS[code]['description']}\\n\"\n            f\"Solution: {KNOWN_ERRORS[code]['solution']}\"\n            for code in found_codes\n        ]\n        return {\n            **state,\n            \"messages\": list(state[\"messages\"]) + [\n                AIMessage(content=f\"[Diagnostic] {chr(10).join(responses)}\")\n            ],\n            \"past_steps\": state[\"past_steps\"] + [\"Diagnostic: known error, resolved\"],\n            \"niveau_support\": 1\n        }\n\n    # Mode 2 : unknown error: LLM analysis\n    answer = llm.invoke(\n        f\"Analyze this technical issue. If unsure, say so.\\nIssue: {question}\"\n    )\n    return {\n        **state,\n        \"messages\": list(state[\"messages\"]) + [AIMessage(content=f\"[Diagnostic] {answer}\")],\n        \"past_steps\": state[\"past_steps\"] + [\"Diagnostic: LLM analysis, uncertain\"],\n        \"niveau_support\": 2\n    }\n```\n\nThe known error lookup runs in milliseconds without touching the LLM; a meaningful performance gain for common incidents.\n\nCalled only when `niveau_support >= 3`\n\n. It doesn't try to answer; it generates a structured ticket with the full diagnostic history already attached:\n\n``` php\n# src/agents/escalade_agent.py\n\ndef escalade_agent(state: AgentState) -> AgentState:\n    ticket = f\"\"\"\n=== SUPPORT TICKET — LEVEL 3 ===\nDate     : {datetime.now().strftime(\"%Y-%m-%d %H:%M\")}\nPriority : HIGH\n\nISSUE:\n{state[\"question\"]}\n\nDIAGNOSTIC STEPS ALREADY TAKEN:\n{chr(10).join(state[\"past_steps\"])}\n\nCONCLUSION: Human intervention required.\n================================\"\"\"\n\n    return {\n        **state,\n        \"messages\": list(state[\"messages\"]) + [AIMessage(content=ticket)],\n        \"past_steps\": state[\"past_steps\"] + [\"Escalation: level 3 ticket generated\"],\n        \"niveau_support\": 3\n    }\n```\n\nThis is what separates a real support system from a chatbot; it knows when to stop and hand off, with context already attached so the user doesn't have to repeat themselves.\n\n``` php\n# src/agents/orchestrator.py\n\ndef classifier(state: AgentState) -> str:\n    question = state[\"question\"].lower()\n\n    if re.search(r'err-\\w+-\\d+', question):\n        return \"diagnostic\"\n\n    problem_words = [\"error\", \"crash\", \"bug\", \"not working\", \"fails\", \"problem\"]\n    if any(word in question for word in problem_words):\n        return \"diagnostic\"\n\n    return \"rag\"  # Default for procedural questions\n\ndef should_escalate(state: AgentState) -> str:\n    return \"escalade\" if state.get(\"niveau_support\", 1) >= 3 else END\n\ndef create_orchestrator():\n    workflow = StateGraph(AgentState)\n\n    workflow.add_node(\"rag_agent\", rag_agent)\n    workflow.add_node(\"diagnostic_agent\", diagnostic_agent)\n    workflow.add_node(\"escalade_agent\", escalade_agent)\n\n    workflow.set_conditional_entry_point(\n        classifier,\n        {\"rag\": \"rag_agent\", \"diagnostic\": \"diagnostic_agent\"}\n    )\n\n    workflow.add_edge(\"rag_agent\", END)\n    workflow.add_conditional_edges(\n        \"diagnostic_agent\",\n        should_escalate,\n        {\"escalade\": \"escalade_agent\", END: END}\n    )\n    workflow.add_edge(\"escalade_agent\", END)\n\n    return workflow.compile()\n```\n\nThree questions, three different paths:\n\n**Procedural question**\n\n```\nInput  : \"How to close monthly payroll?\"\nPath   : classifier → rag_agent → END\nOutput : agents_used: [\"RAG: documentation search completed\"]\n         niveau: 1\n```\n\n**Known error code**\n\n```\nInput  : \"ERR-COMP-001 appears on journal validation\"\nPath   : classifier → diagnostic_agent → END\nOutput : agents_used: [\"Diagnostic: known error, resolved\"]\n         niveau: 1  (no LLM call, instant response)\n```\n\n**Unknown incident**\n\n```\nInput  : \"Server crashes at startup, no idea why\"\nPath   : classifier → diagnostic_agent → escalade_agent → END\nOutput : agents_used: [\"Diagnostic: LLM analysis\", \"Escalation: ticket generated\"]\n         niveau: 3\n```\n\n**The classifier is fragile.** Keyword matching breaks on edge cases *\"I don't have a problem, just need help configuring payroll\"* contains \"problem\" and routes to diagnostic instead of RAG. A small LLM classification call, or a fine-tuned classifier, would be more robust.\n\n**Agents reload the embedding model on every call.** Instantiating `HuggingFaceEmbeddings`\n\non each agent invocation adds unnecessary overhead. Initialize once at startup and pass the instance through the state.\n\n**The escalation threshold is hardcoded.** A feedback loop where user ratings influence the confidence threshold over time would make this genuinely adaptive that's what reinforcement learning adds in the next phase of this project.\n\n| Classic RAG | Agentic AI | |\n|---|---|---|\nBest for |\nUniform question types | Mixed question types |\nComplexity |\nLow | Medium-High |\nLatency |\nOne LLM call | One or more LLM calls |\nFlexibility |\nFixed pipeline | Extensible — add agents without rewriting |\nWhen to choose |\nStart here | When RAG fails on edge cases |\n\nMy honest take: **start with RAG**. Get it working, understand its failure modes, then add agents where you actually need branching logic. Adding LangGraph too early is over-engineering.\n\n**Design your AgentState first.** It's the contract between all components. Changing it mid-project means updating every agent get it right upfront.\n\n**Agents should do one thing.** The moment an agent tries to retrieve *and* diagnose, it becomes untestable and unroutable. Keep them single-responsibility.\n\n** past_steps is your audit trail.** When a ticket escalates to a human, they see exactly what the system tried. Small design choice, professional result.\n\n**The classifier is your most important and most brittle piece of code.** Test it with as many edge cases as you can before trusting it.\n\nThese two articles cover Phases 1 and 2 of my internship project. The logical next step is a reinforcement learning feedback loop; user ratings adjusting the classifier's routing decisions over time; which I may write about once it's implemented and tested.\n\nI'm a Master's student in AI & Big Data based, building these systems with open-source tools and no GPU. If you're working on multi-agent systems or ran into similar design decisions; especially around the classifier or state design; I'd love to hear your approach in the comments.", "url": "https://wpnews.pro/news/from-rag-to-agentic-ai-how-i-added-langgraph-to-my-local", "canonical_source": "https://dev.to/josaphatstar/from-rag-to-agentic-ai-how-i-added-langgraph-to-my-local-a6e", "published_at": "2026-07-29 21:42:11+00:00", "updated_at": "2026-07-29 22:00:57.308930+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-agents", "developer-tools"], "entities": ["LangGraph", "Ollama", "ChromaDB", "LangChain", "Docker"], "alternates": {"html": "https://wpnews.pro/news/from-rag-to-agentic-ai-how-i-added-langgraph-to-my-local", "markdown": "https://wpnews.pro/news/from-rag-to-agentic-ai-how-i-added-langgraph-to-my-local.md", "text": "https://wpnews.pro/news/from-rag-to-agentic-ai-how-i-added-langgraph-to-my-local.txt", "jsonld": "https://wpnews.pro/news/from-rag-to-agentic-ai-how-i-added-langgraph-to-my-local.jsonld"}}