English translation is not available yet. Showing Russian content.
Как вы храните историю диалога в 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 | Хранит всё (без обрезания) | Короткие диалоги |
ConversationBufferWindowMemory | Sliding window (последние K) | Дефолтный выбор |
ConversationSummaryMemory | Саммари всей истории | Длинные диалоги |
ConversationSummaryBufferMemory | Sliding 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
Шаги
- Собрать диалог из 20-30 уточняющих вопросов (можно симулировать)
- Реализовать три стратегии:
- Sliding window (последние 5 сообщений)
- Summarization (сжатие всей истории)
- Recompression (переформулирование запроса)
- Для каждого подхода:
- Прогнать все вопросы последовательно
- Оценить: правильность ответов, латенси, количество токенов
- Сравнить результаты и выбрать лучший подход для своего use case
Ожидаемый результат Sliding window — самый быстрый, Recompression — лучший для RAG, Summarization — для очень длинных диалогов.
Связь с другими вопросами
| Вопрос | Тема |
|---|---|
| 1 | RAG архитектура (включает память) |
| 2 | Lost in the middle (в длинном контексте) |
| 8 | Обработка запросов без ответа (когда память не помогает) |
| 9 | Обновление документов (TTL для памяти) |
| 14 | Обрезка контекста (sliding window как частный случай) |
19. Как вы храните историю диалога в RAG для 19. Как вы храните историю диалога в RAG для 19. Как вы храните историю диалога в RAG для 19 полностью разобран. Переходим к вопросу 20, когда будете готовы|Вопрос 19 полностью разобран. Переходим к вопросу 20, когда будете готовы]]
Навигация
- Предыдущий: 18
- Следующий: 20
- Индекс: 00. Индекс разборов