Как вы храните историю диалога в RAG для multi-turn QA?

Краткий тезис

В multi-turn QA (многооборотные диалоги) пользователь задаёт уточняющие вопросы, которые ссылаются на предыдущие сообщения. Проблема: LLM не имеет памяти между вызовами — нужно явно передавать историю диалога в каждом новом запросе. Но контекстное окно LLM ограничено (4k-128k токенов). Решения: sliding window (храним последние N сообщений), summarization (сжимаем историю в саммари), recompression (переформулируем запрос с учётом истории). Для долгосрочного хранения — внешняя БД (DynamoDB, PostgreSQL) с TTL.

Ключевая идея Хранить всю историю нельзя (контекст переполнится). Нужно сжимать, обрезать или переформулировать.


1. Термин: Multi-turn QA

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

Пример:

User: "Какая цена iPhone 15?"
Assistant: "iPhone 15 стоит от 799 долларов."

User: "А у Pro версии?"        ← уточняющий вопрос (без "iPhone 15")
Assistant: "iPhone 15 Pro стоит от 1099 долларов."

User: "А в рублях?"            ← ещё одно уточнение
Assistant: "799 долларов ≈ 73000 рублей по текущему курсу."

Проблема На третий вопрос модель должна знать, что "А в рублях?" относится к iPhone 15 Pro, но в запросе этой информации нет.

Решение Передавать историю диалога вместе с каждым новым вопросом.


2. Проблема: контекстное окно ограничено

Что происходит без управления историей

Вопрос 1: context = [User: "Цена iPhone?", Assistant: "799$"] (50 токенов)
Вопрос 2: context = [Вопрос1 + Ответ1 + Вопрос2] (100 токенов)
Вопрос 10: context = [Вопрос1...Ответ9 + Вопрос10] (2000 токенов)
Вопрос 100: context = [Вопрос1...Ответ99 + Вопрос100] (20000 токенов) → окно LLM переполнено!

Термин «Context window explosion» Экспоненциальный рост контекста при добавлении каждого нового сообщения.

Стратегии решения

СтратегияСутьКогда использовать
Sliding windowХраним только последние N сообщенийДефолтный выбор для чат-ботов
SummarizationСжимаем всю историю в краткое саммариДлинные диалоги (>20 сообщений)
RecompressionПереформулируем уточняющий запрос в самостоятельныйКогда нужна точность для RAG

3. Стратегия 1: Sliding window (скользящее окно)

Что это Храним в контексте только последние N сообщений (например, последние 5 пар "вопрос-ответ").

2 Как вы решаете проблему lost in the middle при работе с длинными контекстами|2 Как вы решаете проблему lost in the middle при работе с длинными контекстами|2 Как вы решаете проблему lost in the middle при работе с длинными контекстами|2|Пример (N=2

История (всего 10 сообщений):
[Q1, A1, Q2, A2, Q3, A3, Q4, A4, Q5, A5]

Sliding window (последние 4 сообщения):
[Q4, A4, Q5, A5]  ← передаём только это

Реализация

from collections import deque

class SlidingWindowMemory:
    def __init__(self, max_pairs=5):
        self.max_pairs = max_pairs
        self.history = deque(maxlen=max_pairs * 2)  # *2 для Q+A
    
    def add_message(self, role, content):
        self.history.append({"role": role, "content": content})
    
    def get_context(self):
        return list(self.history)
    
    def clear(self):
        self.history.clear()

# Использование
memory = SlidingWindowMemory(max_pairs=5)
memory.add_message("user", "Сколько стоит iPhone?")
memory.add_message("assistant", "799 долларов")
memory.add_message("user", "А в рублях?")

context = memory.get_context()
# [{"role":"user","content":"Сколько стоит iPhone?"},
#  {"role":"assistant","content":"799 долларов"},
#  {"role":"user","content":"А в рублях?"}]

Термин «Deque» (Double-ended queue Структура данных с быстрым добавлением в конец и удалением из начала. В Python collections.deque(maxlen=N) автоматически удаляет старые элементы.

Плюсы

  • Простота
  • Предсказуемый размер контекста
  • Быстро (O(1))

Минусы

  • Теряется информация из начала диалога

Когда использовать Дефолтный выбор для большинства чат-ботов Хорошо работает для диалогов до 20-30 сообщений.

Инструмент LangChain

from langchain.memory import ConversationBufferWindowMemory

memory = ConversationBufferWindowMemory(k=5)  # храним 5 последних сообщений

4. Стратегия 2: Summarization (суммаризация истории)

Что это Не храним каждое сообщение, а периодически сжимаем историю в краткое саммари через LLM.

Pipeline

Сообщения 1-5 → LLM → саммари (50 токенов)
Сообщения 6-10 → LLM → саммари (50 токенов)
+ продолжаем накапливать новые сообщения

Пример саммари

Исходная история (1000 токенов):
Q: "Какая цена iPhone 15?"
A: "799 долларов"
Q: "А у Pro?"
A: "1099 долларов"
Q: "А в рублях?"
A: "799 ≈ 73000 руб, 1099 ≈ 100000 руб"
... (ещё 7 вопросов)

Саммари (100 токенов):
"Пользователь интересуется ценами iPhone 15 и 15 Pro в долларах и рублях.
Ему названы цены: iPhone 15 — 799$ (~73000₽), iPhone 15 Pro — 1099$ (~100000₽)."

Реализация

from langchain.memory import ConversationSummaryBufferMemory

memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=2000,  # когда история превышает 2000 токенов — суммаризируем
    return_messages=True
)

# Добавляем сообщения
memory.save_context({"input": "Цена iPhone?"}, {"output": "799$"})
memory.save_context({"input": "А Pro?"}, {"output": "1099$"})

# При запросе, LLM сам решит, отдавать raw историю или саммари

Термин «ConversationSummaryBufferMemory» Умная память в LangChain, которая хранит последние сообщения raw, а старые сжимает в саммари.

Плюсы

  • Может хранить всю историю (в сжатом виде)
  • Адаптируется под длинные диалоги

Минусы

  • Дополнительные LLM-вызовы (дорого, медленно)
  • Саммари может терять детали

Когда использовать Длинные диалоги (>30 сообщений), где важна полная история, но точность деталей не критична.


5. Стратегия 3: Recompression (переформулирование запроса)

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

Термин «Query recompression» или «Query rewriting» Превращение зависимого вопроса ("А у Pro?") в независимый ("Какая цена iPhone 15 Pro?").

Пример:

История:
User: "Какая цена iPhone 15?"
Assistant: "iPhone 15 стоит 799 долларов."

Новый вопрос User: "А у Pro?"

→ LLM переформулирует: "Какая цена iPhone 15 Pro?"
→ Затем этот переформулированный запрос идёт в RAG

Реализация

def rewrite_query_with_history(query, history):
    prompt = f"""
    История диалога:
    {history}
    
    Пользователь спрашивает: "{query}"
    
    Переформулируй вопрос пользователя в самостоятельный запрос, 
    подставив контекст из истории. Если вопрос уже самостоятельный — оставь как есть.
    
    Переформулированный запрос:
    """
    rewritten = llm.invoke(prompt)
    return rewritten

# Пример
history = [
    ("user", "Какая цена iPhone 15?"),
    ("assistant", "iPhone 15 стоит 799 долларов.")
]
query = "А у Pro?"

rewritten = rewrite_query_with_history(query, history)
# → "Какая цена iPhone 15 Pro?"

Термин «Self-contained query» (самостоятельный запрос Запрос, который содержит всю необходимую информацию и может быть понят без истории.

Плюсы

  • Позволяет использовать стандартный RAG (без модификаций)
  • Улучшает качество retrieval (ищем по полному запросу)

Минусы

  • Дополнительный LLM-вызов (дорого, медленно)
  • Если LLM плохо переформулирует — ошибка накапливается

Когда использовать Когда у вас RAG и нужно точно искать по документам Особенно полезно для уточняющих вопросов с местоимениями ("он", "она", "у него").


6. Кэширование для повторяющихся цепочек

Что это Если один и тот же диалог (или его начало) повторяется, можно закэшировать результат.

Пример кэша

import hashlib
import redis

cache = redis.Redis()

def get_cached_response(query, history_hash):
    cache_key = f"rag:{hashlib.md5(query.encode()).hexdigest()}:{history_hash}"
    cached = cache.get(cache_key)
    if cached:
        return cached.decode()
    return None

def store_response(query, history_hash, response):
    cache_key = f"rag:{hashlib.md5(query.encode()).hexdigest()}:{history_hash}"
    cache.setex(cache_key, 3600, response)  # TTL 1 час

Когда кэшировать

  • Одинаковые последовательности вопросов (например, onboarding-диалог)
  • Часто задаваемые вопросы в начале диалога

7. Долгосрочное хранение (DynamoDB, PostgreSQL)

Что это Для диалогов, которые могут длиться дни или недели, нужно хранить историю во внешней БД.

Схема

CREATE TABLE conversations (
    session_id UUID PRIMARY KEY,
    user_id VARCHAR(255),
    history JSONB,  -- список сообщений
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    ttl TIMESTAMP  -- время жизни (например, 30 дней)
);

Реализация

import asyncpg

class PostgresMemory:
    async def save_message(self, session_id, role, content):
        await self.pool.execute("""
            INSERT INTO conversations (session_id, history, updated_at)
            VALUES ($1, $2, NOW())
            ON CONFLICT (session_id) DO UPDATE
            SET history = history || $[[2 Как вы решаете проблему lost in the middle при работе с длинными контекстами|2]], updated_at = NOW()
        """, session_id, [{"role": role, "content": content}])
    
    async def load_history(self, session_id, limit=10):
        row = await self.pool.fetchrow(
            "SELECT history FROM conversations WHERE session_id = $1",
            session_id
        )
        if row:
            # Возвращаем последние limit сообщений
            return row['history'][-limit*2:]  # *2 для Q+A
        return []

Термин «TTL (Time To Live)» Время жизни записи в БД. Через TTL запись автоматически удаляется.

Практика Устанавливать TTL = 30 дней для обычных диалогов, 7 дней для гостевых пользователей.


8. Инструменты LangChain для памяти

Класс LangChainЧто делаетКогда использовать
ConversationBufferMemoryХранит всё (без обрезания)Короткие диалоги
ConversationBufferWindowMemorySliding window (последние K)Дефолтный выбор
ConversationSummaryMemoryСаммари всей историиДлинные диалоги
ConversationSummaryBufferMemorySliding window + саммари старыхЛучший для длинных диалогов
VectorStoreRetrieverMemoryХранит историю в векторной БДОчень длинная история

Пример с ConversationSummaryBufferMemory (рекомендуется

from langchain.memory import ConversationSummaryBufferMemory
from langchain.chains import ConversationChain

memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=2000,  # когда история превышает 2000 токенов → суммаризация
    return_messages=True
)

conversation = ConversationChain(llm=llm, memory=memory)

conversation.predict(input="Сколько стоит iPhone?")
conversation.predict(input="А в рублях?")
conversation.predict(input="А Pro версия?")

9. Полный пайплайн для production

class MultiTurnRAG:
    def __init__(self, llm, vector_store, memory_type="sliding_window"):
        self.llm = llm
        self.vector_store = vector_store
        
        if memory_type == "sliding_window":
            self.memory = ConversationBufferWindowMemory(k=5)
        elif memory_type == "summary":
            self.memory = ConversationSummaryBufferMemory(llm=llm, max_token_limit=2000)
        else:
            self.memory = ConversationBufferMemory()
    
    def process_query(self, session_id, query):
        # 1. Загружаем историю (из Redis/Postgres)
        history = self.load_history(session_id)
        
        # 2. Recompression: переформулируем запрос с учётом истории
        if len(history) > 0:
            rewritten_query = self.rewrite_query_with_history(query, history)
        else:
            rewritten_query = query
        
        # 3. RAG
        context = self.vector_store.search(rewritten_query, top_k=5)
        response = self.llm.generate(rewritten_query, context)
        
        # 4. Сохраняем в историю
        self.save_message(session_id, "user", query)
        self.save_message(session_id, "assistant", response)
        
        return response

10. Пет-проект для закрепления

Задача Реализовать multi-turn RAG с 3 разными стратегиями памяти и сравнить их.

Инструменты Python, LangChain, Qdrant, Ollama

Шаги

  1. Собрать диалог из 20-30 уточняющих вопросов (можно симулировать)
  2. Реализовать три стратегии:
    • Sliding window (последние 5 сообщений)
    • Summarization (сжатие всей истории)
    • Recompression (переформулирование запроса)
  3. Для каждого подхода:
    • Прогнать все вопросы последовательно
    • Оценить: правильность ответов, латенси, количество токенов
  4. Сравнить результаты и выбрать лучший подход для своего use case

Ожидаемый результат Sliding window — самый быстрый, Recompression — лучший для RAG, Summarization — для очень длинных диалогов.


Связь с другими вопросами

ВопросТема
1RAG архитектура (включает память)
2Lost in the middle (в длинном контексте)
8Обработка запросов без ответа (когда память не помогает)
9Обновление документов (TTL для памяти)
14Обрезка контекста (sliding window как частный случай)

19. Как вы храните историю диалога в RAG для 19. Как вы храните историю диалога в RAG для 19. Как вы храните историю диалога в RAG для 19 полностью разобран. Переходим к вопросу 20, когда будете готовы|Вопрос 19 полностью разобран. Переходим к вопросу 20, когда будете готовы]]


Навигация