Как стать автором
Обновить

Создаем свой RAG: введение в LangGraph

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров1.3K

Привет, Хабр!

В последние годы все чаще dстали появляться системы RAG(Retrieval Augmented Generation или "генерация с дополненной выборкой").

Их применяют в областях, где необходима работа со специализированной  информацией/документацией и высокая точность генерации с минимальным количеством фактических ошибок.

Возможно, вы уже пользовались такими системы, когда обращались в службу клиентской поддержки или юридические/медицинские организации.

В одной статье сложно охватить все аспекты RAG, поэтому в первой части я расскажу про LangGraph и разберу ключевые концепции. В следующих частях мы перейдем к созданию простых RAG-систем и изучению их основных компонентов.

Установим необходимые библиотеки:

pip install langgraph langchain langchain_core langchain_community

Концепция

Если сильно упростить, основная идея заключается в следующем: разделить этапы обработки и выборки данных на узлы и определить связи между ними. Такая система называется графом.

Отсюда вытекает важная особенность: у любого графа должно быть состояние (State), которое хранит необходимую информацию. State служит входной схемой для всех узлов графа.

Начинаем начинать

Создадим простой последовательный граф из 3-х узлов, результатом которого будет предложение: 'Я люблю Москву!'.

Для начала создадим состояние с помощью TypedDict, которое будет содержать одно поле: graph_state. Помимо TypedDict можно использовать Pydantic, dataclass.

Пример кода:

from typing_extensions import TypedDict

class State(TypedDict):
    graph_state: str

Определим узлы:

def node_1(state):
    print("---Node 1---")
    return {"graph_state": state['graph_state'] +"Я"}

def node_2(state):
    print("---Node 2---")
    return {"graph_state": state['graph_state'] +"люблю"}

def node_3(state):
    print("---Node 3---")
    return {"graph_state": state['graph_state'] +"Москву!"}

Пояснение: в качестве аргумента узлы принимают состояние, которое мы определили выше (но узлы пока об этом не знают). В качестве результата каждый узел возвращает обновленное значение поля graph_state.

Самая сложная часть выполнена, осталось добавить узлы в граф и определить связи между ними:

from langgraph.graph import StateGraph, START, END

builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)

Добавили узлы, добавляем ребра:

builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", "node_3")
builder.add_edge("node_3", END)

Здесь у нас появились два не известных узла- START, END. Они определяют вход и выход из нашего графа. Таким образом node1 - стартовый узел, node3 - конечный.

Последний штрих:

graph = builder.compile()

# дополнительно. Показываем схему
display(Image(graph.get_graph().draw_mermaid_png()))
Граф с 3 последовательными узлами
Граф с 3 последовательными узлами

Так как граф является обьектом LangChain, то он имеет метод invoke для вызова.

graph.invoke({"graph_state": ""})
---Node 1---
---Node 2---
---Node 3---
{'graph_state': 'Я люблю Москву'}

Мы получили требуемый результат, значит можем идти дальше.

Условные ребра

Небольшая, но очень важная тема - условные ребра. Они позволяют динамически выбирать следующий узел графа, в зависимости от ситуации.

Для этого примера используем узлы из предыдущего и добавим несколько новых.

Добавим узел, который вместо "Москву" будет добавлять "Питер".

def node_4(state):
    print("---Node 4---")
    return {"graph_state": state['graph_state'] +"Питер"}

Для выбора следующего узла необходимо создать функцию, которая будет возвращать название следующего узла:

import random
from typing import Literal

def decide_mood(state) -> Literal["node_3", "node_4"]:

    user_input = state['graph_state']

    if random.random() < 0.5:
        return "node_3"

    return "node_4"

Снова добавим узлы и связи:

from langgraph.graph import StateGraph, START, END

# Build graph
builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)
builder.add_node("node_4", node_4)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_conditional_edges("node_2", decide_mood)
builder.add_edge("node_3", END)
builder.add_edge("node_4", END)

У нас появилось условное ребро, которое создается с помощью add_conditional_edges, в котором мы указываем: предыдущий узел, функцию, которая отвечает за выбор следующего.

Компилируем граф:

graph = builder.compile()

# View
display(Image(graph.get_graph().draw_mermaid_png()))
---Node 1---
---Node 2---
---Node 4--- #node 3 не вызывался
{'graph_state': 'Я люблю Питер'}

Вызов инструментов

Возможно, вы захотите использовать вызов функций в своем RAG, поэтому я решил привести простой пример их использования.

Для начала создадим несколько простых инструментов:

from langchain_core.tools import tool
@tool
def multiply(a: int, b: int) -> int:
    """Multiply a and b.

    Args:
        a: first int
        b: second int
    """
    return a * b
@tool
# This will be a tool
def add(a: int, b: int) -> int:
    """Adds a and b.

    Args:
        a: first int
        b: second int
    """
    return a + b
@tool
def divide(a: int, b: int) -> float:
    """Divide a and b.

    Args:
        a: first int
        b: second int
    """
    return a / b

tools = [add, multiply, divide]

Подключим языковую модель, которая поддерживает вызов функций (tool calling):

from langchain_gigachat import GigaChat


llm  = GigaChat(verify_ssl_certs=False, credentials="api key", model="GigaChat-2")

Я буду использовать GigaChat, для этого потребуется установить:

pip install langchain-gigachat

Добавим инстументы к модели:

llm_with_tools = llm.bind_tools(tools)

И проверим работоспособность:

llm_with_tools.invoke("what is 2 + 2?")

Немного контекста для тех, кто не сталкивался с вызовом инструментов.

Пример вызова модели с вопросом, который требует вызова инструмента, получение результата и передача в модель:

query = "What is 2 * 2?"
messages  = [HumanMessage(query)]

ai_msg  = llm_with_tools.invoke(messages)

print(ai_msg.tool_calls)

messages.append(ai_msg)
print(messages)

В результате получили AIMessage с вызовом функции инструмента, теперь необходимо его вызвать и передать выделенные аргументы:

for tool_call in ai_msg.tool_calls:
  selected_tool = {"add": add, "multiply": multiply, "divide": divide}[tool_call["name"].lower()]
  tool_msg  = selected_tool.invoke(tool_call)
  messages.append(tool_msg)

Вызов модели с результатом вызова инструмента (передаем все сообщения)

llm_with_tools.invoke(messages)
AIMessage(content='Произведение чисел $2$ и $2$ равно $4$.'....

Создадим небольшой граф с возможностью вызова функций

схема графа
схема графа

Для начала определим состояние:

from typing import Annotated
from langgraph.graph.message import add_messages

class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]

Когда граф работает, мы хотим добавлять сообщения в наше состояние, а не перезаписывать (такой вариант определяется по умолчанию). Для этого будем использовать add_messages. Это гарантирует, что любые сообщения добавляются к существующему списку сообщений.

У нас будет всего два узла: узел с вызовом модели и узел с вызовом инструмента.

Первый узел:

 def chatbot(state: State):
  return {"messages": [llm_with_tools.invoke(state["messages"])]}

Добавляет ответ модели, который может содержать вызов инструмента.

Теперь добавим узел, в котором будет происходить вызов инструментов, если это требуется. Мы будем проверять, нужен ли вызов инструмента в последнем сообщении состояния.

import json
from langchain_core.messages import ToolMessage

class ToolNode:
  def __init__(self, tools: list) -> None:
    self.tools_by_name  = {tool.name:tool for tool in tools}

  def __call__(self,inputs: dict):
    if messages := inputs.get("messages", []):
      mesasage = messages[-1] #берем последнее сообщение
    else:
      raise ValueError("No messages")

    outputs  = []
    for tool_call in mesasage.tool_calls:
      tool_res = self.tools_by_name[tool_call["name"]].invoke(tool_call["args"])

      outputs.append(ToolMessage(content = json.dumps(tool_res), name=tool_call["name"], tool_call_id=tool_call["id"]))
    return {"messages": outputs}

Думаю, здесь требуется комментарий: для начала создадим словарь для доступа к нашим инструментам. Выглядит он примерно так:

{'multiply': StructuredTool(name='multiply', description='Multiply a and b.\n\nArgs:\n    a: first int\n    b: second int', args_schema=<class 'langchain_core.utils.pydantic.multiply'>, func=<function multiply at 0x00000170638BB600>)}
....

При вызове узла предполагается, что в состоянии уже есть сообщения: сообщение пользователя и какой то ответ модели. Нас интересует ответ модели, а именно: содержит ли он вызов инструмента. Если вызов инструмента есть, то получаем инструмент по имени и вызываем с извлеченными аргументами. После получения результата добавляем ответ в виде ToolMessage в наше состояние

Остался последний, но не менее важный шаг: написать маршрутизатор, который определяет, когда нужно заканчивать работу.

def route_tools(state: State):
  if isinstance(state, list):
    ai_message = state[-1]
  elif messages := state.get("messages", []):
    ai_message = messages[-1]
  else:
    raise ValueError("No messages")
  if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
    return "tools"
  return END

Обьяснение: для начала нам нужно получить последнее сообщение в состоянии. После чего проверить, содержит ли оно вызов инструмента. Если последнее сообщение содержит вызов инструмента, то мы переходим в узел "tools", иначе заканчиваем выполнение.

Определяем узлы и связи:

graph_builder = StateGraph(State)
graph_builder.add_node("chatbot", chatbot)

tool_node = ToolNode(tools)
graph_builder.add_node("tools", tool_node)

Добавляем ребро с условием

graph_builder.add_conditional_edges(
    "chatbot",
    route_tools,
    {"tools": "tools", END: END}
)
graph_builder.add_edge("tools", "chatbot")
graph_builder.add_edge(START, "chatbot")

graph  = graph_builder.compile()

Результат выполнения:

{'messages':
[HumanMessage(content='What is 2 + 2?',
  AIMessage(content='', additional_kwargs={'function_call': {'name': 'add', 'arguments': {'a': 2, 'b': 2}},
  ToolMessage(content='4', name='add', 
  AIMessage(content='Сумма двух и двух равна четырем.', 
]}

На этом я завершаю первую часть. Теперь, когда мы разобрали суть LangGraph и основные принципы его работы, в следующей статье перейдем к изучению ключевых компонентов RAG-систем и рассмотрим их практическое применение.

Подписывайтесь на мой Telegram-канал:

t.me/Viacheslav_Talks

где я регулярно публикую материалы о языковых моделях и искусственном интеллекте, а также анонсирую новые статьи. Подписывайтесь, чтобы не пропустить новые части

Спасибо за прочтение!

Теги:
Хабы:
0
Комментарии0

Публикации

Работа

Data Scientist
44 вакансии

Ближайшие события