Controllable RAG Agent

repository·main·Indexed 23 days ago

https://github.com/nirdiamant/controllable-rag-agent

A sophisticated Retrieval-Augmented Generation (RAG) agent built with LangGraph for complex tasks. It implements multi-step reasoning, self-correction, and hallucination detection through a deterministic graph. Key features include question anonymization to prevent LLM bias, a PlanExecute state for task management, and specialized retrieval from FAISS vector stores containing book chunks, chapter summaries, and quotes. Performance is evaluated using Ragas metrics for correctness, faithfulness, and relevancy.

Tokens
5.2K
Snippets
16
Records
21
Agent score
81%

What's inside controllable-rag-agent

  1. How the Controllable RAG Agent works

    main

    The agent uses a deterministic graph to act as a reasoning engine for complex RAG tasks. The workflow follows these stages:

    1. Data Processing: PDF loading, chapter splitting, text preprocessing, and LLM-based summarization of chapters.
    2. Indexing: Creation of a Book Quotes Database and encoding content/summaries into vector stores (FAISS).
    3. Question Processing:
      • Anonymization: Replacing named entities with variables to prevent LLM bias from pre-trained knowledge.
      • Planning: Generating a high-level plan for the anonymized question, then de-anonymizing it into specific tasks.
    4. Task Execution: For each task, the agent decides whether to retrieve information from vector stores or answer directly using Chain-of-Thought reasoning.
    5. Verification & Re-planning: Verifying that generated content is grounded in the context and updating the plan based on new information.
    6. Final Answer: Generating the final response using accumulated context and Chain-of-Thought reasoning.
  2. Install the Controllable RAG Agent

    main

    To use the agent locally without Docker, follow these steps:

    1. Clone the repository:
      git clone https://github.com/NirDiamant/Controllable-RAG-Agent.git
      cd Controllable-RAG-Agent
    2. Set up your environment variables by creating a .env file in the root directory. You can use .env.example as a template. Required keys include:
      • OPENAI_API_KEY
      • GROQ_API_KEY
    3. Install the dependencies:
      pip install -r requirements.txt
    git clone https://github.com/NirDiamant/Controllable-RAG-Agent.git
    cd Controllable-RAG-Agent
  3. Understand the PlanExecute state structure

    main

    The sophisticated RAG pipeline uses a PlanExecute state (a TypedDict) to pass information between nodes in the LangGraph workflow. This state tracks the lifecycle of a query from anonymization to final answer.

    Key fields include:

    • question: The original user query.
    • anonymized_question: The query with named entities replaced by variables.
    • mapping: A dictionary mapping variables back to original entities.
    • plan: A list of steps to follow.
    • past_steps: A history of completed tasks.
    • curr_context: The context for the current task.
    • aggregated_context: The cumulative information retrieved from all steps.
    • tool: The identifier for the tool currently being used or selected.
    • response: The final generated answer.
    class PlanExecute(TypedDict):
        curr_state: str
        question: str
        anonymized_question: str
        query_to_retrieve_or_answer: str
        plan: List[str]
        past_steps: List[str]
        mapping: dict 
        curr_context: str
        aggregated_context: str
        tool: str
        response: str
  4. How the Qualitative Retrieval Answer Graph works

    main

    The core of the advanced RAG system is a langgraph workflow designed to ensure high-quality, hallucination-free answers. The graph follows this logic:

    1. Retrieve Context: Fetches chunks, chapter summaries, and quotes.
    2. Keep Only Relevant Content: Distills the retrieved context using an LLM.
    3. Relevance Check: If content is relevant, proceed to answer; otherwise, Rewrite Question and retry retrieval.
    4. Answer Question: Generates an answer using Chain-of-Thought reasoning.
    5. Grade Generation: Checks if the answer is a hallucination or if it's useful. If it's a hallucination, it retries answering; if not useful, it rewrites the question; if useful, it ends.
    # The graph structure defined in the notebook
    qualitative_retrieval_answer_workflow.add_edge("retrieve_context_per_question", "keep_only_relevant_content")
    qualitative_retrieval_answer_workflow.add_conditional_edges(
        "keep_only_relevant_content",
        is_relevant_content,
        {"relevant":"answer_question_from_context", "not relevant":"rewrite_question"}
    )
    # ... and so on
  5. Use the Task Handler to select RAG tools

    main

    The task_handler_chain acts as a router that decides which tool to use for a specific task in the plan. It selects from four primary capabilities:

    • retrieve_chunks: Searches for information in a vector store of raw book chunks.
    • retrieve_summaries: Searches for information in a vector store of chapter summaries.
    • retrieve_quotes: Searches for information in a vector store of book quotes.
    • answer_from_context: Answers a question using the aggregated_context already collected.

    The handler receives the curr_task, aggregated_context, last_tool, past_steps, and the original question to make an informed decision.

    class TaskHandlerOutput(BaseModel):
        """Output schema for the task handler."""
        query: str = Field(description="The query to be either retrieved from the vector store, or the question that should be answered from context.")
        curr_context: str = Field(description="The context to be based on in order to answer the query.")
        tool: str = Field(description="The tool to be used should be either retrieve_chunks, retrieve_summaries, retrieve_quotes, or answer_from_context.")
  6. Use conditional edges for decision-making in the RAG workflow

    main

    Conditional edges allow the agent to branch its logic based on runtime evaluations (e.g., checking for hallucinations or deciding which tool to use).

    When using add_conditional_edges(source_node, routing_function, mapping_dict), the routing_function must return a key that exists in the mapping_dict. The value associated with that key in the dictionary determines the next node to visit.

    Common decision patterns in this agent:

    • Tool Selection: Routing to specific retrieval tools (chunks, summaries, or quotes) or an answering node.
    • Groundedness Checks: Routing back to a previous node (e.g., replan or keep_only_relevant_content) if a hallucination is detected, or proceeding if the content is grounded.
    • Task Completion: Checking if a question can_be_answered to decide between getting a final answer or breaking down the plan further.
    # Example: Routing based on tool selection
    agent_workflow.add_conditional_edges(
        "task_handler", 
        retrieve_or_answer, 
        {
            "chosen_tool_is_retrieve_chunks": "retrieve_book_chunks", 
            "chosen_tool_is_retrieve_summaries": "retrieve_summaries", 
            "chosen_tool_is_retrieve_quotes": "retrieve_book_quotes", 
            "chosen_tool_is_answer": "answer"
        }
    )
    
    # Example: Routing based on grounding/hallucination check
    agent_workflow.add_conditional_edges(
        "answer",
        is_answer_grounded_on_context,
        {
            "hallucination": "answer", 
            "grounded on context": "replan"
        }
    )
  7. Anonymize questions to prevent LLM bias

    main

    To prevent the LLM from using prior knowledge (biases) when generating a plan, the pipeline first anonymizes the question. It replaces named entities (e.g., "Harry Potter") with variables (e.g., "X") and stores the mapping.

    Example workflow:

    1. Anonymize: anonymize_question_chain converts "Who is Harry Potter?" to "Who is X?" with mapping {"X": "harry potter"}.
    2. Plan: The planner generates steps based on the anonymized query.
    3. De-anonymize: de_anonymize_plan_chain restores the original entities into the plan steps using the mapping.
    # Anonymize the question
    anonymized_question_output = anonymize_question_chain.invoke(state['question'])
    anonymized_question = anonymized_question_output["anonymized_question"]
    mapping = anonymized_question_output["mapping"]
    
    # Generate plan using anonymized question
    plan = planner.invoke({"question": anonymized_question})
    
    # Restore original entities to the plan
    deanonimzed_plan = de_anonymize_plan_chain.invoke({"plan": plan.steps, "mapping": mapping})
  8. Evaluate the RAG agent using Ragas

    main

    To evaluate the performance of the agent, you can use the Ragas framework. This requires a dataset containing the original questions, the agent's generated answers, the retrieved contexts, and the ground truth answers.

    Supported metrics in this implementation:

    • answer_correctness
    • faithfulness
    • answer_relevancy
    • context_recall
    • answer_similarity

    Workflow:

    1. Collect questions, generated_answers, retrieved_documents (from final_state['aggregated_context']), and ground_truth_answers.
    2. Create a Dataset from these samples.
    3. Run the evaluate function with the chosen metrics and an LLM (e.g., gpt-4o).
  9. Run the Controllable RAG Agent via Docker Compose

    main

    You can deploy the agent using Docker Compose. The service runs a Streamlit application accessible on port 8501.

    To ensure the agent functions correctly, you must provide your API keys via environment variables. The docker-compose.yml file is configured to pull these values from a .env file in the same directory.

    Required Environment Variables:

    • OPENAI_API_KEY: Your OpenAI API key.
    • GROQ_API_KEY: Your Groq API key.

    Port Mapping:

    • Host 8501 maps to Container 8501 (Streamlit default).
    services:
      web:
        build: .
        ports:
          - "8501:8501"
        environment:
          - OPENAI_API_KEY=${OPENAI_API_KEY}
          - GROQ_API_KEY=${GROQ_API_KEY}
        volumes:
          - .:/app
  10. Build a controllable RAG agent graph with LangGraph

    main

    The controllable RAG agent is implemented using langgraph.graph.StateGraph. You define the agent's logic by adding nodes (representing individual processing steps or functions) and edges (representing the flow of control between steps).

    Key components of the workflow include:

    • Nodes: Functions that perform tasks like anonymize_question, planner, retrieve_book_chunks, or answer.
    • Edges: Direct transitions from one node to another using add_edge.
    • Conditional Edges: Decision points that route the workflow to different nodes based on the output of a function using add_conditional_edges.
    • Entry Point: The starting node of the graph, defined via set_entry_point.
    • Compilation: The graph must be compiled using .compile() to create an executable application.

    To visualize the resulting graph, you can use the .get_graph(xray=True).draw_mermaid_png() method.

    from langgraph.graph import StateGraph, END
    
    # 1. Initialize the graph with a state schema
    agent_workflow = StateGraph(PlanExecute)
    
    # 2. Add nodes
    agent_workflow.add_node("anonymize_question", anonymize_queries)
    agent_workflow.add_node("planner", plan_step)
    
    # 3. Set entry point
    agent_workflow.set_entry_point("anonymize_question")
    
    # 4. Add edges
    agent_workflow.add_edge("anonymize_question", "planner")
    
    # 5. Add conditional edges
    agent_workflow.add_conditional_edges(
        "task_handler", 
        retrieve_or_answer, 
        {
            "chosen_tool_is_retrieve_chunks": "retrieve_book_chunks", 
            "chosen_tool_is_retrieve_summaries": "retrieve_summaries", 
            "chosen_tool_is_retrieve_quotes": "retrieve_book_quotes", 
            "chosen_tool_is_answer": "answer"
        }
    )
    
    # 6. Compile the graph
    plan_and_execute_app = agent_workflow.compile()