Python & AI Tutorials Logo
LangChain & LangGraph

11. Диалоговый RAG: добавление памяти в поиск

В главе 10 мы улучшили качество поиска в нашей системе RAG. Но остаётся одно ограничение: каждый вопрос обрабатывается по отдельности. Когда пользователь спрашивает «Какова ваша политика возврата?», наша система находит релевантный контент в документах и отвечает. Когда поступает следующий вопрос, система отвечает на него, не имея памяти о предыдущем разговоре.

Давайте посмотрим, почему это проблема в реальном разговоре. Пользователь спрашивает «Какова ваша политика возврата?», а затем уточняет: «А распространяется ли это на цифровые продукты?» Этот уточняющий вопрос подразумевает контекст «политики возврата» из предыдущей реплики, но сам текст вопроса не содержит такой информации. Если мы используем «А распространяется ли это на цифровые продукты?» напрямую в качестве поискового запроса, ретривер извлечёт нерелевантную информацию, связанную с «цифровыми продуктами» (например, о ценах или характеристиках), и система RAG сгенерирует ответ, не соответствующий намерению пользователя.

В этой главе мы научимся решать эту проблему. Мы научимся переписывать неоднозначные уточняющие вопросы в полноценные вопросы, использовать эту технику для построения системы диалогового RAG(conversational RAG) и разберём, как управлять историей диалога по мере того, как разговоры становятся длиннее.

11.1) Переписывание уточняющих вопросов в полноценные вопросы

Как мы видели во введении, уточняющие вопросы опираются на контекст предыдущего разговора, поэтому люди склонны опускать значительную часть информации. В результате уточняющий вопрос сам по себе часто оказывается неполным. Как это решить?

В главе 8 мы научились помогать LLM понимать контекст разговора, передавая историю диалога вместе с каждым сообщением. Здесь мы можем применить тот же подход. Мы передаём уточняющий вопрос вместе с историей диалога в LLM и просим её переписать его в полноценный вопрос, отражающий контекст. Например, уточняющий вопрос «А распространяется ли это на цифровые продукты?» вместе с историей диалога переписывается в «Подлежат ли цифровые продукты возврату?». С этим переписанным вопросом поиск сможет найти нужные документы о политике возврата для цифровых продуктов.

Эта техника называется переписывание запросов(query rewriting). Давайте создадим для неё системный промпт.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
 
llm = ChatOpenAI(model="gpt-5-mini")
 
system_prompt = (
    "Учитывая историю чата и последний вопрос пользователя, "
    "который может ссылаться на контекст из истории чата, "
    "сформулируй самостоятельный вопрос, "
    "который можно понять без истории чата. "
    "НЕ отвечай на вопрос, просто переформулируй его при необходимости, "
    "а в противном случае верни его как есть."
)

Основная инструкция в этом системном промпте — «перепиши уточняющий вопрос в полноценный вопрос, используя историю чата». Здесь важны две конкретные директивы.

Первая — «НЕ отвечай на вопрос, просто переформулируй его». Она указывает LLM только переписать вопрос, а не отвечать на него. Без этой директивы LLM склонна отвечать на вопрос вместо того, чтобы его переписывать. Здесь нам нужен не ответ, а полноценный вопрос, который можно понять без истории чата.

Вторая — «а в противном случае верни его как есть». Она указывает LLM оставить вопрос без изменений, если он не нуждается в переписывании. Без неё LLM может без необходимости перефразировать вопрос, потенциально изменив его исходный смысл или объём.

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

python
messages = [
    SystemMessage(content=system_prompt),
    # История чата
    HumanMessage(content="Какова ваша политика возврата?"),
    AIMessage(content="Все физические товары можно вернуть в течение 30 дней с момента покупки для полного возврата средств."),
    # Уточняющий вопрос
    HumanMessage(content="А распространяется ли это на цифровые продукты?"),
]
 
response = llm.invoke(messages)
print(response.content)

Вывод:

Подлежат ли цифровые продукты возврату?

LLM прочитала историю диалога, распознала, что вопрос был о «политике возврата», и переписала его в полноценный вопрос. Поиск по этому переписанному вопросу вернёт документы, соответствующие намерению пользователя.

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

11.2) Построение диалогового RAG

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

LangChain предоставляет утилиты-цепочки для построения диалогового RAG (create_history_aware_retriever, create_retrieval_chain и т. д.), но эти функции находятся в пакете langchain-classic, поддержка которого прекращается в декабре 2026 года. Официальная документация LangChain теперь рекомендует использовать вместо них агентов.

Поэтому в этой главе мы будем использовать агентов для реализации диалогового RAG. Агенты подробно рассматриваются в части V (главы 15–17), поэтому здесь мы представим только то, что необходимо для нашей реализации диалогового RAG.

11.2.1) Компоненты агента, которые мы будем использовать здесь

В главе 5 мы кратко познакомились с основной концепцией агентов. Когда LLM анализирует запрос пользователя и решает, какой инструмент использовать, система выполняет это решение. Тогда мы реализовали этот процесс вручную, но LangChain предоставляет API, которые делают его намного проще. Вот краткое введение в три компонента, которые мы будем использовать.

@tool: декоратор, который превращает обычную функцию Python в инструмент, который может использовать агент. Агент автономно выбирает и вызывает подходящий инструмент из зарегистрированных инструментов на основе запроса пользователя.

create_agent: функция, которая принимает LLM, список инструментов и системный промпт для создания агента. Она внутренне обрабатывает поток «решение-выполнение» агента.

InMemorySaver: чекпойнтер, который автоматически управляет историей диалога. Он организует разговоры по thread_id, поэтому при вызове агента с тем же thread_id он автоматически загружает предыдущую историю диалога.

11.2.2) Создание инструмента поиска

Сначала давайте превратим поиск по векторному хранилищу, который мы построили в главе 10, в инструмент, который может использовать агент.

python
from langchain.tools import tool
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Подключаемся к векторному хранилищу, построенному в главе 10
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
@tool
def retrieve_context(query: str):
    """Ищет в документах содержимое, релевантное запросу."""
    retrieved_docs = vector_store.similarity_search(query, k=3)
    serialized = "\n\n".join(
        f"Source: {doc.metadata['source']}\nContent: {doc.page_content}"
        for doc in retrieved_docs
    )
    return serialized

Декоратор @tool превращает функцию retrieve_context в инструмент, который может использовать агент. Агент автономно решает, вызывать ли этот инструмент, на основе вопроса пользователя.

11.2.3) Создание агента

Мы передаём инструмент поиска, системный промпт и чекпойнтер в create_agent, чтобы создать агента.

python
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver  # Устанавливается автоматически вместе с langchain
 
agent = create_agent(
    model="gpt-5-mini",
    tools=[retrieve_context],
    system_prompt=(
        "Ты полезный помощник, который отвечает на вопросы о политиках компании. "
        "Используй инструмент retrieve_context для поиска релевантной информации. "
        "Если в найденном контексте нет релевантной информации, "
        "скажи, что ты не знаешь. "
        "Делай ответ кратким, максимум три предложения."
    ),
    checkpointer=InMemorySaver(),
)
  • model: LLM, которую будет использовать агент.
  • tools: список инструментов, доступных агенту. Мы регистрируем созданный выше инструмент поиска документов (retrieve_context).
  • system_prompt: поведенческие инструкции агента. Он указывает агенту использовать инструмент поиска для ответа на вопросы о политиках компании и говорить, что он не знает, когда в найденном контексте нет релевантной информации.
  • checkpointer: автоматически управляет историей диалога. InMemorySaver() хранит разговоры в памяти, автоматически обрабатывая историю диалога, которой мы управляли вручную в главе 8.

Да

Нет

Вопрос пользователя

Агент

История диалога
(InMemorySaver)

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

Выполнение инструмента
retrieve_context

Финальный ответ

Когда агент получает вопрос пользователя, он обращается к истории диалога и решает, нужен ли поиск документов в векторном хранилище. Если да, он вызывает инструмент retrieve_context, чтобы получить релевантные документы, и генерирует ответ через LLM. История диалога автоматически управляется InMemorySaver.

11.2.4) Запуск многоходового диалога

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

python
# thread_id — это идентификатор, который различает разговоры
# Использование одного и того же thread_id продолжает тот же разговор
thread_config = {"configurable": {"thread_id": "1"}}
 
# --- Ход 1: полноценный вопрос ---
response1 = agent.invoke(
    {"messages": [{"role": "user", "content": "Какова ваша политика возврата?"}]},
    thread_config,
)
print("В: Какова ваша политика возврата?")
print("О:", response1["messages"][-1].content)
 
# --- Ход 2: уточнение, которое зависит от хода 1 ---
response2 = agent.invoke(
    {"messages": [{"role": "user", "content": "А распространяется ли это на цифровые продукты?"}]},
    thread_config,
)
print("\nВ: А распространяется ли это на цифровые продукты?")
print("О:", response2["messages"][-1].content)

Вывод:

В: Какова ваша политика возврата?
О: Все физические товары можно вернуть в течение 30 дней с момента покупки для полного возврата средств.
Требуется оригинальный чек или письмо с подтверждением заказа, а товары должны быть
в оригинальной упаковке и неиспользованном состоянии.
По истечении 30 дней возвраты принимаются только в виде кредита магазина.
 
В: А распространяется ли это на цифровые продукты?
О: Цифровые продукты (лицензии на ПО, электронные книги, онлайн-курсы) не подлежат возврату
после активации ссылки на скачивание или доступ.
Однако, если вы столкнулись с техническими проблемами, препятствующими доступу, вы можете обратиться в поддержку
в течение 7 дней для замены или возврата средств.

Во втором ходу мы передали «А распространяется ли это на цифровые продукты?», но агент по истории диалога распознал, что это уточнение о политике возврата, и точно извлёк раздел о цифровых продуктах из документов о политике возврата.

Подождите — для этого агента мы не добавляли шаг переписывания запроса, как в разделе 11.1. Тогда почему уточняющий вопрос был обработан правильно? Когда LLM вызывает инструмент (функцию с декоратором @tool), он самостоятельно генерирует аргументы инструмента. Это относится и к пользовательскому запросу, передаваемому в retrieve_context — поскольку LLM видит всю историю диалога, он переписал уточняющий вопрос в полный, самостоятельный вопрос перед вызовом. Мы не настраивали отдельный шаг переписывания, и тем не менее переписывание запроса произошло в рамках процесса вызова инструмента.

Также обратите внимание, что нам не пришлось управлять историей диалога вручную — InMemorySaver автоматически управляет ею для каждого thread_id.

В следующем разделе рассматривается проблема, которая возникает по мере того, как разговоры становятся длиннее и история разрастается, а также способ её решения.

11.3) Управление длинными диалогами

Диалоговый RAG, который мы построили, поначалу работает хорошо, но по мере того, как разговоры становятся длиннее, могут возникать проблемы. Как мы узнали в главе 8, у LLM есть максимальный размер входных данных, который они могут обработать за один вызов. Системный промпт, история диалога, найденные документы и вопрос пользователя — всё должно уместиться в этот лимит.

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

В главе 8 мы решили эту проблему с помощью скользящего окна(sliding window): сохраняя только последние N сообщений и отбрасывая более старые. Та же концепция применима и в среде агента. create_agent поддерживает middleware — шаг обработки, который может изменять сообщения перед вызовом LLM. Мы можем использовать middleware для обрезки старой истории.

11.3.1) Ограничение истории с помощью middleware

Декоратор @before_model работает аналогично декоратору @tool, который мы видели в разделе 11.2. Так же как @tool превращает функцию в инструмент, который может использовать агент, @before_model превращает функцию в middleware, которое выполняется перед каждым вызовом LLM. Полученное middleware активируется путём его регистрации в параметре middleware функции create_agent.

python
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import before_model
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
 
@before_model
def trim_old_messages(state: AgentState, runtime) -> dict | None:
    """Удаляет старые сообщения перед каждым вызовом LLM."""
    messages = state["messages"]
    # Если сообщений достаточно мало, ничего не делаем
    if len(messages) <= 10:
        return None
    # Сохраняем только системное сообщение (первое) и 10 самых последних сообщений
    return {
        "messages": [
            RemoveMessage(id=REMOVE_ALL_MESSAGES),
            messages[0],     # Системное сообщение
            *messages[-10:], # Последние 10 сообщений (5 ходов)
        ]
    }

AgentState — это объект, который хранит данные состояния агента, где state["messages"] содержит список сообщений диалога на текущий момент. Возвращаемое значение middleware определяет, как изменяется этот список диалога.

  • Возврат None оставляет существующие данные состояния агента без изменений.
  • Возврат словаря применяет его содержимое к существующему списку сообщений. В коде выше RemoveMessage(id=REMOVE_ALL_MESSAGES) сначала удаляет все существующие сообщения, затем добавляет обратно только системное сообщение и 10 самых последних сообщений. В результате в LLM передаются только эти сообщения.

Зарегистрируйте это middleware у агента:

python
agent = create_agent(
    model="gpt-5-mini",
    tools=[retrieve_context],
    system_prompt=(
        "Ты полезный помощник, который отвечает на вопросы о политиках компании. "
        "Используй инструмент retrieve_context для поиска релевантной информации. "
        "Если в найденном контексте нет релевантной информации, "
        "скажи, что ты не знаешь. "
        "Делай ответ кратким, максимум три предложения."
    ),
    checkpointer=InMemorySaver(),
    middleware=[trim_old_messages],  # Регистрируем middleware
)

Это тот же агент из раздела 11.2 с добавленным middleware=[trim_old_messages]. Теперь, как бы длинным ни был разговор, в LLM передаются только недавние сообщения.

11.3.2) Компромисс скользящего окна

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

Когда необходимо сохранить более старое содержимое диалога, альтернативой является замена старых сообщений сгенерированной LLM сводкой(summary) вместо их удаления. LangChain предоставляет для этой цели SummarizationMiddleware, который мы рассмотрим в части V (начиная с главы 15), когда углубимся в архитектуры агентов и графов.