{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "## Prompt: \n", "In the context of BrahmaKumaris murlis, can you tell by looking at the question below if you have enough context to answer the question accurately without making up things or you need external data to be able to answer the question. Specify exactly what you need to answer the question accurately.\n", "\n", "Question:\n", "\n", "\"What is the overall sentiment in the avyakt murlis of 1969. How does the sentiment of murlis change from 1969 to 1970\"" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Response: \n", "Based on the question, here's an analysis of what's needed to answer it accurately:\n", "\n", "**What I Need to Answer Accurately:**\n", "\n", "* **Access to the full text of the Avyakt Murlis of 1969 and 1970:**\n", " * To determine the \"overall sentiment,\" I need to analyze the language, themes, and tone used throughout the murlis of each year.\n", " * This requires a deep reading and understanding of the text.\n", "* **A framework for sentiment analysis specific to Brahma Kumaris terminology:**\n", " * \"Sentiment\" in this context goes beyond simple positive/negative emotions. It involves understanding the spiritual concepts and their emotional connotations within the Brahma Kumaris teachings.\n", " * For example, concepts like \"remembrance,\" \"service,\" \"purity,\" and \"divine love\" have specific emotional and spiritual weight.\n", " * I would need to understand the context of how words are used within the Brahma Kumaris teachings.\n", "* **A method for comparing and contrasting the sentiment between the two years:**\n", " * I need a way to identify and articulate the specific changes in sentiment from 1969 to 1970.\n", " * This may involve identifying shifts in emphasis, new themes, or changes in the way familiar themes are presented.\n", "\n", "**Why External Data Is Necessary:**\n", "\n", "* I do not have direct access to the full text of the Brahma Kumaris Avyakt Murlis.\n", "* I do not have a pre-existing, specialized framework for sentiment analysis within the context of Brahma Kumaris teachings.\n", "* Therefore, to answer the question accurately, I would need to be provided with the text of the murlis, or have access to a database of them, along with a guide to the specific meaning of terms used within those texts.\n", "\n", "**2. Structured Data (Ideal for Larger Volumes):**\n", "\n", "* **Text Files (.txt, .csv, .json):**\n", " * You can create text files containing the Murlis, with each Murli or paragraph clearly separated.\n", " * For the glossary, a CSV (comma-separated values) or JSON (JavaScript Object Notation) file would be ideal.\n", " * CSV: Term, Definition\n", " * JSON: { \"term\": \"definition\" }\n", " * While I cannot directly access local files on your computer, you can copy the contents of these files and paste them into our conversation in sections.\n", "* **Considerations:**\n", " * Ensure the text files are accurately formatted and encoded (e.g., UTF-8) to preserve special characters.\n", " * If the Murlis are in a language other than English, clearly indicate the language.\n", "* **Example of CSV Glossary:**\n", " ```csv\n", " Avyakt,Incorporeal, subtle angelic form\n", " Smriti,Remembrance, to remember God\n", " Seva,Service, actions done with divine intention\n", " ```\n", "\n", "**3. Providing Context within the Conversation:**\n", "\n", "* **Explain Key Concepts:**\n", " * Instead of a formal glossary, you can explain the meanings of terms as they appear in the text.\n", " * This is useful for providing context and nuanced interpretations.\n", "* **Highlight Important Themes:**\n", " * Point out recurring themes or concepts that are relevant to the sentiment analysis.\n", " * This helps me understand the overall context of the Murlis.\n", "\n", "\n", "**In summary:** Without the specific text of those Murlis, and without specific information on how to interpret the sentiment of those texts, I cannot give an accurate response.\n", "\n", "---" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Prompt 2:\n", "If I have indexed the murlis in a vector database, what is the best way to retrieve the relevant murli passages.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Response 2:\n", "If you've indexed the Murlis in a vector database, you're well-positioned to retrieve relevant passages efficiently. Here's a breakdown of the best practices and techniques:\n", "\n", "**1. Semantic Search with Embeddings:**\n", "\n", "* **Query Embedding:**\n", " * Convert your query (\"What is the purpose of human life?\") into a vector embedding using the same model you used to embed the Murli passages.\n", "* **Similarity Search:**\n", " * Perform a similarity search in your vector database. This will find the Murli passages whose embeddings are most similar to your query embedding.\n", " * Common similarity metrics include cosine similarity and dot product.\n", "* **Retrieve Relevant Passages:**\n", " * Retrieve the text associated with the top-k most similar vectors. The \"k\" value determines how many passages you retrieve.\n", "\n", "**2. Metadata Filtering (If Applicable):**\n", "\n", "* **Metadata Enrichment:**\n", " * If you've added metadata to your vector database (e.g., date, topic, speaker), use metadata filters to refine your search.\n", "* **Combined Search:**\n", " * Combine semantic search with metadata filters. For example, you could search for passages related to \"purpose of life\" and filter by passages from a specific year or speaker.\n", "* **Example:** If you tagged certain murlis with the topic \"purpose of life\" you could combine the vector search, with a metadata filter to only return results with that tag.\n", "\n", "**3. Contextual Windowing:**\n", "\n", "* **Expand Retrieved Passages:**\n", " * Instead of retrieving only the exact matching passages, retrieve a larger contextual window around them. This provides more context for the AI to understand the meaning.\n", " * This can be done by retrieving the passages before and after the matched passage.\n", "* **Chunking Strategy:**\n", " * The way you initially chunked your murlis for embedding will impact the quality of your results. If you chunked by paragraph, then retrieving the surrounding paragraphs is simple. If you used a different method, you will need to adjust your retrieval logic.\n", "\n", "**4. Reranking:**\n", "\n", "* **Cross-Encoder Models:**\n", " * After retrieving the top-k passages, use a cross-encoder model to rerank them. Cross-encoders are more accurate than bi-encoders (used for initial embedding) but are also more computationally expensive.\n", " * This reranking step helps to ensure that the most relevant passages are at the top of the list.\n", "* **Relevance Scoring:**\n", " * The cross encoder will produce a relevance score that can be used to sort the retrieved passages.\n", "\n", "**5. Prompt Engineering for the AI:**\n", "\n", "* **Clear Instructions:**\n", " * When you provide the retrieved passages to the AI, give clear instructions on how to use them to answer the question.\n", "* **Contextual Information:**\n", " * Provide any relevant contextual information, such as the source of the passages (Brahma Kumaris Murlis) and any key terminology.\n", "* **Example Prompt:** \"Using the following passages from the Brahma Kumaris Murlis, answer the question: 'What is the purpose of human life?'\"\n", "\n", "**Tools and Libraries:**\n", "\n", "* **Vector Databases:** Pinecone, Weaviate, Milvus, Chroma.\n", "* **Embedding Models:** OpenAI Embeddings, Sentence Transformers, Google's Vertex AI embeddings.\n", "* **Reranking Models:** Sentence Transformers cross-encoders.\n", "* **LangChain/LlamaIndex:** These libraries can help you orchestrate the entire retrieval and generation process.\n", "\n", "**Key Considerations:**\n", "\n", "* **Embedding Model Choice:** The choice of embedding model is crucial for the quality of your results. Experiment with different models to find the one that performs best for your data.\n", "* **Chunking Strategy:** The way you chunk your Murlis into smaller pieces for embedding can significantly impact retrieval performance.\n", "* **Metadata Quality:** If you use metadata, ensure it is accurate and consistent.\n", "\n", "By combining semantic search, metadata filtering, contextual windowing, and prompt engineering, you can effectively retrieve relevant Murli passages from your vector database and provide the AI with the necessary context to answer your questions.\n", "\n", "---" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Prompt 3:\n", "Here is the complete code from previous question can you explain what is the difference between add_node and add_edge methods. And how these are used for creation of an agent. Explain in detail how both add_node and add_edge methods work in the context of code provided, explain each of the parameters to these methods and what is their use.\n", "\n", "How agent execution happens, basically all the details what happens when the agent is first invoked. \n", "\n", "Also the retriever node output is a dictionary with key 'context'. How is this output from retriever node handled by langgraph and made available to the next node in the graph which is the generator node. \n", "\n", "Also in generator node which is invoking the llm (gemini) we are passing in a list of messages to llm.invoke method. Is this correct. I have seen elsewhere that you pass in a dictionary with 'messages' key and list of messages as the value. Which invocation method is correct?\n", "\n", "Code:\n", "```python\n", "from langchain_chroma import Chroma\n", "from langchain_google_genai import ChatGoogleGenerativeAI\n", "from langchain.prompts import PromptTemplate\n", "from langchain.schema import HumanMessage, AIMessage\n", "from langgraph.graph import START, StateGraph\n", "from vector_store import load_existing_index \n", "from config import Config\n", "import datetime\n", "from logger import logger\n", "\n", "def retriever_node(state: dict, vectordb: Chroma, k: int = 25, date_filter: str = None, \n", " search_type: str = \"similarity\", score_threshold: float = 0.5) -> dict:\n", " \"\"\"Retrieves documents from the vector database with improved query normalization and filtering.\"\"\"\n", " raw_query = state[\"query\"]\n", " query = raw_query.strip().lower() # Normalize query\n", " extra_kwargs = {\"k\": k, \"score_threshold\": score_threshold}\n", "\n", " if date_filter:\n", " try:\n", " filter_date = datetime.datetime.strptime(date_filter, '%Y-%m-%d')\n", " formatted_date = filter_date.strftime('%Y-%m-%d')\n", " logger.info(f\"Applying date filter: {formatted_date}\")\n", " except ValueError:\n", " raise ValueError(\"Invalid date format. Please use YYYY-MM-DD.\")\n", "\n", " filter_criteria = {\"date\": {\"$eq\": formatted_date}}\n", " extra_kwargs[\"filter\"] = filter_criteria\n", " # Optionally, if available, switch search type for filtered queries\n", " search_type = \"similarity\" # or another type like \"mmr\" if appropriate\n", "\n", " # Create retriever with refined kwargs and search type option\n", " retriever = vectordb.as_retriever(search_type=search_type, search_kwargs=extra_kwargs)\n", " retrieved_docs = retriever.invoke(query)\n", " context = \"\\n\\n\".join([doc.page_content for doc in retrieved_docs])\n", " logger.info(f\"Retrieved {len(retrieved_docs)} documents for query: {query}\")\n", " return {\"context\": context}\n", "\n", "\n", "def generator_node(state: dict, llm: ChatGoogleGenerativeAI) -> dict:\n", " \"\"\"Generates a response using the LLM and retrieved context.\"\"\"\n", " system_prompt_text = Config.SYSTEM_PROMPT\n", " custom_prompt = PromptTemplate(\n", " input_variables=[\"context\", \"question\"],\n", " template=(\n", " system_prompt_text +\n", " \"Context: {context}\\n\"\n", " \"Question: {question}\\n\"\n", " ),\n", " )\n", " context = state[\"context\"]\n", " query = state[\"query\"]\n", " formatted_query = custom_prompt.format(context=context, question=query)\n", " messages = [HumanMessage(content=formatted_query)]\n", " response = llm.invoke(messages)\n", " if isinstance(response, AIMessage):\n", " return {\"answer\": response.content}\n", " else:\n", " return {\"answer\": str(response)}\n", "\n", "\n", "def build_agent(vectordb: Chroma, date_filter: str = None, k: int = 25, \n", " model_name: str = \"gemini-2.0-flash\") -> StateGraph:\n", " \"\"\"Builds and returns a persistent LangGraph agent.\"\"\"\n", " llm = ChatGoogleGenerativeAI(model=model_name, temperature=0.3)\n", " builder = StateGraph(dict)\n", " builder.add_node(\"retriever\", lambda state: retriever_node(state, vectordb, k, date_filter))\n", " builder.add_node(\"generator\", lambda state: generator_node(state, llm))\n", " builder.add_edge(START, \"retriever\", {\"query\": None})\n", " builder.add_edge(\"retriever\", \"generator\")\n", " return builder.compile()\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Response 3:\n", "Let's break down the LangGraph agent creation process using the provided code.\n", "\n", "**Difference between `add_node` and `add_edge` methods:**\n", "\n", "* **`add_node`:** This method is used to define the individual processing units within your LangGraph. Each node represents a specific function or operation that will be executed as part of the agent's workflow. In your code, `retriever_node` and `generator_node` are defined as functions, and `add_node` is used to incorporate them as nodes in the graph. Think of nodes as the *steps* or *stages* in your agent's process.\n", "\n", "* **`add_edge`:** This method defines the connections and the flow of information between the nodes in your LangGraph. Edges specify the order in which the nodes will be executed and how the output of one node is passed to the next. In your code, `add_edge` is used to connect the `START` of the graph to the `retriever` node and then connect the `retriever` node to the `generator` node. Think of edges as the *pathways* or *transitions* that dictate the agent's execution flow.\n", "\n", "**How these are used for the creation of an agent:**\n", "\n", "You build an agent in LangGraph by first defining the individual processing steps as functions (like `retriever_node` and `generator_node`). Then, you use `add_node` to register these functions as nodes within a `StateGraph` builder. Finally, you use `add_edge` to define the sequence in which these nodes should be executed, essentially creating the workflow of your agent. The `builder.compile()` step then finalizes the graph into an executable agent.\n", "\n", "**Detailed explanation of `add_node` and `add_edge` parameters in the context of the code:**\n", "\n", "**`builder.add_node(key, func)`**\n", "\n", "* **`key` (string):** This is the unique identifier or name you assign to the node within your graph. In your code:\n", " * `\"retriever\"`: This key identifies the node that executes the `retriever_node` function.\n", " * `\"generator\"`: This key identifies the node that executes the `generator_node` function.\n", " The `key` allows you to refer to this specific node when defining the edges in your graph.\n", "\n", "* **`func` (callable):** This is the function that will be executed when this node is visited during the agent's execution. The function should take a `state` dictionary as its first argument. In your code:\n", " * `lambda state: retriever_node(state, vectordb, k, date_filter)`: This lambda function takes the current `state` and passes it, along with the `vectordb`, `k`, and `date_filter` variables (captured from the surrounding scope), to the `retriever_node` function.\n", " * `lambda state: generator_node(state, llm)`: This lambda function takes the current `state` and passes it, along with the initialized `llm`, to the `generator_node` function.\n", " The use of lambda functions here allows you to pass additional arguments (like `vectordb`, `llm`, `k`, `date_filter`) to your node functions.\n", "\n", "**`builder.add_edge(start_key, end_key, config=None)`**\n", "\n", "* **`start_key` (string or `langgraph.graph.START`):** This specifies the node from which the edge originates. It can be either the `key` of a previously added node or the special `START` object indicating the beginning of the graph execution. In your code:\n", " * `START`: This indicates that the execution of the graph begins at this edge, leading to the `\"retriever\"` node.\n", " * `\"retriever\"`: This indicates that after the `\"retriever\"` node finishes execution, the flow moves to the `\"generator\"` node.\n", "\n", "* **`end_key` (string):** This specifies the node to which the edge leads. It should be the `key` of a previously added node. In your code:\n", " * `\"retriever\"`: This means the execution flows from the `START` to the node identified by the key `\"retriever\"`.\n", " * `\"generator\"`: This means the execution flows from the node identified by the key `\"retriever\"` to the node identified by the key `\"generator\"`.\n", "\n", "* **`config` (dict, optional):** This argument allows you to specify conditions or data transformations for the transition between nodes. In your code:\n", " * `{\"query\": None}` in the edge from `START` to `\"retriever\"`: This `config` dictionary specifies a mapping. It indicates that the input provided when the agent is invoked (using `agent.invoke()`) should be placed under the key `\"query\"` in the `state` that is passed to the `\"retriever\"` node. This is how the initial query gets into the agent's workflow.\n", "\n", "**How agent execution happens when first invoked:**\n", "\n", "When the compiled agent's `.invoke()` method is called with an input dictionary (e.g., `agent.invoke({\"query\": \"your question\"})`), the following happens:\n", "\n", "1. **Initialization:** The execution starts from the implicit `START` of the `StateGraph`.\n", "2. **First Edge:** The graph follows the edge defined from `START` to the `\"retriever\"` node. The `config` associated with this edge, `{\"query\": None}`, indicates that the input provided to `.invoke()` will be made available in the agent's internal state under the key `\"query\"`.\n", "3. **`retriever_node` Execution:** The `retriever_node` function is executed. It receives the current state (which now contains your input query).\n", " * It extracts the query from the state: `raw_query = state[\"query\"]`.\n", " * It normalizes the query.\n", " * It constructs the retrieval arguments (`k`, `search_type`, `score_threshold`, and potentially a `date_filter`).\n", " * It uses the provided `vectordb` (Chroma instance) to retrieve relevant documents based on the query and filter (if any).\n", " * It extracts the content from the retrieved documents and joins them into a single string stored in the `context` variable.\n", " * Finally, it returns a dictionary `{\"context\": context}`. This dictionary represents the output of the `\"retriever\"` node and will update the agent's state.\n", "4. **Second Edge:** The graph follows the edge from the `\"retriever\"` node to the `\"generator\"` node. By default, LangGraph merges the output of the previous node into the current state. So, the state passed to the `\"generator\"` node will now contain the original `\"query\"` and the newly added `\"context\"` from the `retriever_node` output.\n", "5. **`generator_node` Execution:** The `generator_node` function is executed. It receives the updated state.\n", " * It retrieves the `context` and `query` from the state: `context = state[\"context\"]`, `query = state[\"query\"]`.\n", " * It formats a prompt using the `system_prompt_text` from your `Config` and the retrieved `context` and `query`.\n", " * It creates a list of messages for the language model, in this case, a single `HumanMessage` with the formatted query.\n", " * It invokes the provided `llm` (Gemini) with this list of messages using `llm.invoke(messages)`.\n", " * It processes the `response` from the LLM. If it's an `AIMessage`, it extracts the content; otherwise, it converts the response to a string.\n", " * Finally, it returns a dictionary `{\"answer\": response.content}` or `{\"answer\": str(response)}`. This represents the final output of the agent.\n", "6. **Return Value:** The `.invoke()` method will return the final state of the graph, which in this case will likely contain the `\"answer\"` generated by the `\"generator\"` node.\n", "\n", "**How the output from the retriever node is handled by LangGraph and made available to the generator node:**\n", "\n", "LangGraph automatically manages the flow of data between nodes through the agent's state. When a node function (like `retriever_node`) returns a dictionary, the key-value pairs in this dictionary are merged into the agent's current state.\n", "\n", "In your case, `retriever_node` returns `{\"context\": context}`. LangGraph takes this output and updates the state. When the execution moves to the next node, `\"generator\"`, the `generator_node` function receives this updated state as its `state` argument. Therefore, `generator_node` can access the retrieved context using `state[\"context\"]`.\n", "\n", "**LLM invocation in `generator_node`:**\n", "\n", "Yes, in the provided code, you are correctly invoking the `ChatGoogleGenerativeAI` model using the `.invoke()` method with a list of messages:\n", "\n", "```python\n", "response = llm.invoke(messages)\n", "```\n", "\n", "The `messages` variable is a list containing a single `HumanMessage` in this case.\n", "\n", "Regarding the alternative you've seen where a dictionary with a `'messages'` key is passed, this might be the format expected by a different LLM integration or a different method of invocation. For `langchain-google-genai.ChatGoogleGenerativeAI`, the `.invoke()` method typically accepts a list of messages (which can include `HumanMessage`, `AIMessage`, `SystemMessage`, etc.).\n", "\n", "It's always best to refer to the documentation of the specific LLM integration you are using to confirm the correct format for invoking the model. However, based on your provided code and common LangChain patterns, the way you are invoking `llm.invoke(messages)` is correct for `ChatGoogleGenerativeAI`." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**How ```builder.add_edge(START, \"retriever\", {\"query\": None})``` Works**\n", "\n", "Let's dissect this line:\n", "\n", "```builder.add_edge(START, \"retriever\", {\"query\": None})```\n", "- START: The starting point of the graph.\n", "- \"retriever\": The name of the first node that will be executed.\n", "- {\"query\": None}: This is not a third positional argument to add_edge in the way you might initially think. Instead, it's a way to specify the initial state that will be passed to the \"retriever\" node when the graph starts.\n", "\n", "**What Happens Internally**\n", "\n", "```1. StateGraph's add_edge (in state.py)```\n", "\n", "- When add_edge is called with START as the start_key, the code in state.py does not directly handle the third argument.\n", "- Instead, it creates an edge from START to \"retriever\" and it also does something else.\n", "- It sets up the initial state for the graph.\n", "- The {\"query\": None} part is used to define the initial state of the graph. It means that when the graph starts, the state will be a dictionary with a single key-value pair: {\"query\": None}.\n", "- This initial state is then passed to the first node connected to START (in this case, the \"retriever\" node).\n", "- The attach_edge method in CompiledStateGraph (in state.py) is where the magic happens. It creates a channel between START and \"retriever\". It also sets up the PregelNode for START to write to this channel.\n", "- The _get_root function in CompiledStateGraph is used to extract the initial state from the input.\n", "\n", "```2. retriever_node's Input:```\n", "\n", "- The retriever_node function (in rag_agent.py) is defined as retriever_node(state: dict, ...)\n", "- It expects a state dictionary as its first argument.\n", "- When the graph starts, the state dictionary passed to retriever_node will initially be {\"query\": None}.\n", "- Later, when the graph is invoked, the user will pass in a dictionary that will update the state. For example, agent.invoke({\"query\": \"What is the meaning of life?\"}) will update the state to {\"query\": \"What is the meaning of life?\"}.\n", "\n", "**In Summary**\n", "\n", "- The add_edge method in StateGraph is overloaded. When the start_key is START, it has special behavior.\n", "- The third argument {\"query\": None} is not a direct argument to add_edge in the same way as start_key and end_key.\n", "- It's a way to define the initial state of the graph, which is a dictionary.\n", "- This initial state is passed to the first node connected to START.\n", "- The attach_edge method in CompiledStateGraph is responsible for setting up the channel between START and the first node.\n", "- The _get_root function in CompiledStateGraph is responsible for extracting the initial state from the input.\n", "\n", "**Why This Design?**\n", "\n", "This design allows for:\n", "\n", "- Clear Initialization: You can explicitly define the initial state of your graph, ensuring that the first node has the necessary data to start processing.\n", "- State Management: StateGraph can manage the state of the graph, passing it between nodes.\n", "- Flexibility: You can have different initial states for different graphs." ] } ], "metadata": { "kernelspec": { "display_name": "ml_env", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.11.9" } }, "nbformat": 4, "nbformat_minor": 2 }