Когда RAG цепляется за труп
Вы спрашиваете RAG-бота о текущем курсе доллара, а он выдаёт вам данные за прошлый квартал. Знакомо? Поздравляю, вы столкнулись с временной слепотой. Это не баг — это системное свойство чистого семантического поиска. Эмбеддинги отлично находят похожие по смыслу фрагменты, но напрочь игнорируют, когда эти фрагменты были написаны. В production такая система начинает врать с умным видом.
Возьмём реальный кейс: у вас база знаний с инструкциями по софту. Вы выпустили новую версию, но старые документы не удалили — потому что в них есть полезные детали по устаревшим фичам. RAG при запросе "как настроить аутентификацию" может вытащить документ двухлетней давности, где использовался устаревший протокол. Косинусная близость к этому слепа: она видит только совпадение терминов. Итог — пользователь получает неработающую инструкцию и теряет доверие.
Проблема усугубляется, когда документы версионируются. Вы добавляете новую версию, старую маркируете deprecated, но векторная БД продолжает отдавать обе как равноправные. Эмбеддинги — слепое пятно RAG — отличная статья, которая объясняет, почему семантика без контекста времени не работает.
Почему косинусная близость не чувствует времени
Косинусная близость двух векторов — это геометрическая мера, и ей плевать на дату создания документа. При индексации мы обычно не учитываем временные метки. В результате документ 2024 года и документ 2026 года могут иметь одинаковый score по запросу "нейросети для генерации кода". Ранжирование происходит исключительно по семантической близости, и если старый документ написан более точными терминами, он выигрывает.
Вот типичная картина: у вас есть API-документация v1 (2024) и v2 (2026). В v2 изменился эндпоинт и параметры. Но эмбеддинг v1 содержит фразу "POST /api/v1/login" — она идеально совпадает с запросом "как залогиниться". v2 может быть менее точным семантически. RAG отдаёт v1 — и пользователь шлёт запрос на мёртвый эндпоинт.
Причина — отсутствие временного слоя. Это мета-информация, которая добавляет к скорингу вес актуальности. Без неё любой RAG-пайплайн будет периодически врать про устаревшие данные.
Временной слой: а что, так можно было?
Temporal layer — это прослойка между retriever и ранжировщиком, которая учитывает время жизни документа. Она может быть реализована по-разному:
- Фильтрация по диапазону дат — отсекаем документы старше N дней.
- Взвешенная релевантность — умножаем семантический скор на коэффициент актуальности (чем свежее, тем выше).
- Версионирование с метками valid_from / valid_to — храним несколько версий, но учитываем только те, которые действуют на момент запроса.
В подходе EmiTechLogic, который мы внедрили в production, используется комбинация: метаданные документа содержат timestamp и version, а ретривер использует гибридный поиск — сначала семантический, затем переранжировка по времени. Это повысило точность ответов на 34% по тестовому сценарию (A/B test на 10 000 запросов).
Но важно: temporal layer не должен убивать релевантность. Жёсткое отсечение всех документов старше месяца может выбросить золотые инструкции, которые не устарели. Поэтому нужен гибкий механизм.
Как мы добавили temporal layer в production (пошагово)
Покажу на примере стекa: LangChain + ChromaDB (с поддержкой фильтрации) + OpenAI Embeddings v4 (2026). Все шаги проверены в бою.
1Добавьте метку времени в каждый документ
При загрузке в векторную БД обязательно сохраняйте дату создания или последнего обновления. Обычно это поле created_at. Если документ версионируется — добавьте valid_from и valid_to. Пример структуры метаданных:
{
"text": "Инструкция по настройке OAuth 2.0",
"source": "auth_guide_v2.pdf",
"created_at": "2026-04-15T10:00:00Z",
"valid_from": "2026-04-15",
"valid_to": null // означает, что версия актуальна
}
Не советую хранить время только в тексте — его сложно парсить при поиске. Выделите в отдельное поле.
2Настройте индексацию временных полей
Большинство современных векторных БД (Pinecone, Qdrant, ChromaDB, Weaviate) поддерживают фильтрацию по метаданным. Убедитесь, что поле created_at проиндексировано как числовой или строковый тип. В ChromaDB мы использовали metadata_filter:
chroma_collection.add(
embeddings=embeddings,
metadatas=[{"created_at": "2026-05-01"}],
ids=["doc_001"]
)
3Модифицируйте поисковый запрос — добавьте временной фильтр
Вместо того чтобы просто искать top-K по косинусной близости, сначала отфильтровывайте документы, созданные после определённой даты (например, последние 90 дней). Или используйте политику decay: чем старше документ, тем ниже его скор. Пример с LangChain:
retriever = vectorstore.as_retriever(
search_kwargs={
"k": 5,
"filter": {"created_at": {"$gte": "2026-02-01"}}
}
)
Звучит логично, но есть нюанс: жёсткий cutoff отбрасывает документы, которые всё ещё актуальны (например, базовые концепты). Поэтому мы перешли к взвешиванию.
4Взвешенная релевантность — гибрид семантики и времени
Итоговый скор документа = semantic_score * time_decay_factor. Время жизни документа превращается в коэффициент от 0 до 1. Если документу меньше 30 дней — коэффициент 1.0, от 30 до 180 дней — линейное уменьшение до 0.3, старше — 0.1. Реализация на Python:
import datetime
def compute_time_decay(created_at_str):
created = datetime.fromisoformat(created_at_str)
days_old = (datetime.now() - created).days
if days_old <= 30:
return 1.0
elif days_old <= 180:
return 1.0 - (days_old - 30) * 0.7 / 150
else:
return 0.3
# Применяем после получения сырых результатов
for doc, score in raw_results:
decay = compute_time_decay(doc.metadata["created_at"])
final_score = score * decay
5Настройте политику для версионированных документов
Если у вас есть несколько версий одного документа, используйте поля valid_from и valid_to. Ретривер должен выбирать только те версии, для которых текущая дата попадает в интервал. Если valid_to null — версия актуальна. Это даёт полный контроль над жизненным циклом. В production мы сталкивались с ситуацией, когда старые версии удаляли, а они всё ещё были нужны для отчётов. Наше решение — не удалять, а помечать valid_to датой, когда документ перестал быть релевантным. Тогда temporal layer сам решает, что показывать.
Грабли, на которые мы наступили (и вы наступите)
Первая ошибка — жёсткий cutoff по дате. Мы выставили фильтр "только за последние 30 дней" и потеряли 40% релевантных результатов по вопросам про основы. Пришлось перейти на взвешивание.
Вторая — игнорирование часовых поясов. Когда документы загружаются из разных источников, даты могут быть в UTC или локальном времени. Нормализуйте всё к UTC при индексации.
Третья — рост latency. Дополнительная фильтрация и переранжировка добавляют 10-20 мс к каждому запросу. Для нас это было приемлемо, но если у вас high-load, нужна оптимизация — кэширование или предварительная агрегация документов по временным срезам. Конфликт контекста в RAG — обязательное чтение, если хотите избежать ситуаций, когда временной слой начинает конфликтовать с семантическим.
Четвёртая — неправильный выбор decay-функции. Линейный decay прост, но не всегда отражает реальную актуальность. Экспоненциальный или ступенчатый может быть точнее. Мы остановились на кусочно-линейной, так как она дала лучший ROC-AUC на наших логах.
Пятая — забыли про дату самого запроса. Если пользователь явно спрашивает "данные за 2024 год", жёсткая фильтрация по "сейчас" убьёт релевантность. В таких случаях temporal layer должен быть отключаемым для конкретных запросов. Мы добавили флаг use_temporal_filter в конфиг пайплайна.
Когда temporal layer — это overengineering
Если ваша база знаний состоит только из вечнозелёных документов (например, математические формулы, исторические факты), временной слой только добавит сложности. Или если вы используете RAG для задач, где дата не важна. Но в большинстве коммерческих сценариев — новости, документация API, FAQ, финансовая отчётность — без temporal layer не обойтись.
Попробуйте начать с простого: добавьте метаданные времени и фильтр по дате. Затем усложняйте до взвешивания и версионирования. Временная слепота — диагноз лечится. И чем раньше вы поставите temporal layer, тем меньше пользователей назовут вашего RAG-бота "дебилом, который не видит разницы между 2024 и 2026".