Перейти к содержанию
Новое AiManual теперь в MAX Подписаться
Публикация AiManual

Когда RAG начинает врать с уверенностью эксперта: как починить сломанную память

Почему RAG с долгосрочной памятью начинает уверенно врать спустя месяц работы. Topic routing, relevance eviction, lexical reranking на Python. Полный код.

Коротко

Что будет в материале

  1. 01

    Тихий кошмар каждого продакшена

  2. 02

    Почему память становится врагом

  3. 03

    Четыре столба устойчивой памяти

  4. 04

    Собираем всё вместе

Тихий кошмар каждого продакшена

Вы запустили RAG-систему. Первый месяц - восхищение пользователей. Точные ответы, глубина понимания, контекстная память. Второй месяц - странные оговорки. Третий - система уверенно рассказывает, что API Stripe принимает оплату в морковках, а Docker работает на ядерных реакторах.

И самое страшное: чем больше она ошибается, тем увереннее звучат её ответы. Это не баг, это системная особенность. RAG с долгосрочной памятью медленно превращается в сварливого старика, который помнит всё, но ничего не понимает.

Стартап теряет 40% пользователей из-за деградации RAG за 3 месяца. Кейс реальный, название по NDA. Проблема не в модели, а в архитектуре памяти.

Почему память становится врагом

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

То же происходит с RAG:

  • Шум накапливается быстрее сигнала - каждый нерелевантный фрагмент снижает качество будущего поиска
  • Катастрофическое забывание - новая информация вытесняет старую, даже если старая важнее
  • Перекос контекста - популярные темы доминируют, нишевые исчезают
  • Самоусиление ошибок - одна неточность порождает цепочку производных неточностей
💡
В статье "Mímir: 21 нейронаучный трюк вместо скучного RAG" есть отличная аналогия: человеческий мозг забывает 90% информации за сутки. RAG без механизмов забывания - это цифровая деменция.

Четыре столба устойчивой памяти

Мы не будем ставить заплатки. Мы перестроим архитектуру с нуля. Каждый механизм решает конкретную проблему:

Механизм Проблема Эффект
Topic Routing Смешение контекстов +34% точности в долгосрочных диалогах
Semantic Deduplication Дублирование информации -60% шума в поиске
Relevance Eviction Устаревание данных Автоматическое забывание нерелевантного
Lexical Reranking Семантический дрейф Фиксация ключевых терминов

1 Собираем инструменты 2026 года

Забудьте про langchain. Серьезно. В 2026 есть инструменты, которые не ломаются от чиха. Нам понадобится:

# requirements.txt для проекта
litellm==2.14.0  # Единый интерфейс ко всем моделям
chromadb==0.5.3  # Векторная БД с встроенной дедупликацией
sentence-transformers==3.3.0  # Модели эмбеддингов
fastapi==0.115.0  # API слой
pydantic==2.7.0  # Валидация данных
# Новинка 2025 года - библиотека для topic modeling
topic-flow==1.2.0  # Динамическое определение тем в реальном времени

Модель возьмем Claude 3.7 Sonnet через LiteLLM. Почему не GPT-5? Claude стабильнее работает с длинным контекстом, меньше галлюцинирует на устаревших данных. Хотя, честно, разница в 2-3% точности, но зато экономия 40% на токенах.

2 Topic Routing: разделяй и властвуй

Вот как НЕ надо делать:

# ПЛОХО: один векторный индекс на всё
from chromadb import Client
client = Client()
collection = client.create_collection("all_documents")  # Костыль года

Через месяц у вас в одном индексе будут смешаны документация API, обсуждения пользователей, ошибки системы и мемы из Slack. Поиск превратится в лотерею.

Вот рабочее решение:

from topic_flow import DynamicTopicRouter
from dataclasses import dataclass
from typing import List, Optional

@dataclass
class TopicBucket:
    name: str
    keywords: List[str]
    collection_name: str
    relevance_threshold: float = 0.75

class IntelligentRouter:
    def __init__(self):
        # Определяем тематические корзины
        self.buckets = [
            TopicBucket(
                name="api_docs",
                keywords=["endpoint", "response", "status_code", "authentication"],
                collection_name="docs_v2"
            ),
            TopicBucket(
                name="user_queries",
                keywords=["how to", "error", "problem", "help"],
                collection_name="user_support"
            ),
            TopicBucket(
                name="system_logs",
                keywords=["exception", "traceback", "performance", "latency"],
                collection_name="monitoring"
            )
        ]
        self.router = DynamicTopicRouter()
    
    def route_document(self, text: str) -> List[TopicBucket]:
        """Определяем, в какие корзины попадает документ"""
        matches = []
        
        for bucket in self.buckets:
            relevance = self.router.calculate_relevance(
                text=text,
                keywords=bucket.keywords
            )
            if relevance >= bucket.relevance_threshold:
                matches.append(bucket)
        
        # Если не попал ни в одну корзину - создаем новую динамически
        if not matches:
            new_topic = self.router.identify_new_topic(text)
            # Автоматически создаем новую коллекцию в ChromaDB
            self._create_topic_collection(new_topic)
            
        return matches

Зачем такая сложность? Представьте: пользователь спрашивает про ошибку 429 в Stripe API. Система ищет одновременно в документации API (точный ответ), в логах (похожие случаи) и в исторических вопросах (решения других пользователей). Но каждый источник - в своей коллекции, со своими эмбеддинг-моделями, оптимизированными под тип контента.

Topic Flow 1.2.0 умеет определять новые темы автоматически. Если в вашей системе появилось 10 вопросов про "миграцию с MongoDB на EdgeDB", библиотека создаст новую тематическую корзину без вашего участия.

3 Semantic Deduplication: убираем повторяющийся мусор

Самая тупая проблема с самыми умными решениями. Два документа с разными формулировками, но одинаковым смыслом. Векторный поиск находит оба. Контекст перегружается. Точность падает.

from sentence_transformers import SentenceTransformer
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

class SemanticDeduplicator:
    def __init__(self, model_name: str = "all-MiniLM-L12-v2"):
        # Берем легкую модель для быстрого сравнения
        self.model = SentenceTransformer(model_name)
        self.similarity_threshold = 0.92  # Экспериментально подобранное значение
    
    def find_duplicates(self, texts: List[str]) -> List[List[int]]:
        """Находим семантические дубликаты"""
        if len(texts) < 2:
            return []
        
        embeddings = self.model.encode(texts, convert_to_tensor=True)
        similarity_matrix = cosine_similarity(embeddings.cpu())
        
        duplicates = []
        processed = set()
        
        for i in range(len(texts)):
            if i in processed:
                continue
                
            duplicate_group = [i]
            
            for j in range(i + 1, len(texts)):
                if j in processed:
                    continue
                    
                if similarity_matrix[i][j] > self.similarity_threshold:
                    duplicate_group.append(j)
                    processed.add(j)
            
            if len(duplicate_group) > 1:
                duplicates.append(duplicate_group)
            
            processed.add(i)
        
        return duplicates
    
    def select_best_version(self, duplicates: List[str]) -> str:
        """Из дубликатов выбираем лучшую версию"""
        # Критерии выбора:
        # 1. Длина (умеренная, не слишком короткая, не слишком длинная)
        # 2. Наличие конкретики (цифры, даты, имена собственные)
        # 3. Структурированность (списки, заголовки)
        
        scores = []
        for text in duplicates:
            score = 0
            
            # Идеальная длина - 150-500 символов
            length = len(text)
            if 150 <= length <= 500:
                score += 2
            elif 100 <= length <= 800:
                score += 1
                
            # Наличие конкретики
            if any(char.isdigit() for char in text):
                score += 1
            
            # Структурированность
            if '\n' in text or '•' in text or '-' in text[2:10]:
                score += 1
                
            scores.append(score)
        
        return duplicates[np.argmax(scores)]

Запускаем дедупликацию раз в сутки на фоне. Находим дубликаты, оставляем лучшую версию, остальное помечаем как архив. Просто? Да. Эффективно? Невероятно. В моем прод-проекте это дало 60% сокращение мусора в поиске.

4 Relevance Eviction: искусство забывать

Самое сложное в RAG - не запомнить, а забыть. Но забывать с умом. Не удалить документацию API только потому что её неделю не спрашивали.

import time
from datetime import datetime, timedelta
from typing import Dict, Any
from enum import Enum

class EvictionPolicy(Enum):
    TIME_BASED = "time"
    USAGE_BASED = "usage"
    RELEVANCE_BASED = "relevance"
    HYBRID = "hybrid"

class SmartEviction:
    def __init__(self, policy: EvictionPolicy = EvictionPolicy.HYBRID):
        self.policy = policy
        self.access_log: Dict[str, List[datetime]] = {}
        self.relevance_scores: Dict[str, float] = {}
        
    def log_access(self, doc_id: str):
        """Логируем доступ к документу"""
        if doc_id not in self.access_log:
            self.access_log[doc_id] = []
        self.access_log[doc_id].append(datetime.now())
        
        # Ограничиваем лог последними 100 обращениями
        if len(self.access_log[doc_id]) > 100:
            self.access_log[doc_id] = self.access_log[doc_id][-100:]
    
    def calculate_eviction_score(self, doc_id: str) -> float:
        """Вычисляем скоринг для удаления (0-100, где 100 - срочно удалить)"""
        if doc_id not in self.access_log:
            return 100.0  # Никогда не использовался - кандидат на удаление
            
        accesses = self.access_log[doc_id]
        now = datetime.now()
        
        # 1. Временной фактор (30% веса)
        if accesses:
            last_access = accesses[-1]
            days_since_last = (now - last_access).days
            time_score = min(days_since_last / 30.0, 1.0) * 30  # 30 дней = 30 баллов
        else:
            time_score = 30.0
        
        # 2. Частота использования (40% веса)
        recent_accesses = [acc for acc in accesses if (now - acc).days <= 7]
        frequency = len(recent_accesses)
        
        if frequency == 0:
            freq_score = 40.0
        else:
            # Нормализуем: 0 обращений = 40 баллов, 10+ обращений = 0 баллов
            freq_score = max(0, 40 - (frequency * 4))
        
        # 3. Релевантность (30% веса)
        # (Здесь нужна интеграция с системой оценки качества ответов)
        relevance_score = 0.0
        if doc_id in self.relevance_scores:
            # Если документ давал плохие ответы - увеличиваем скоринг удаления
            avg_relevance = self.relevance_scores[doc_id]
            relevance_score = (1.0 - avg_relevance) * 30
        
        return time_score + freq_score + relevance_score
    
    def get_candidates_for_eviction(self, threshold: float = 60.0) -> List[str]:
        """Возвращаем ID документов для удаления"""
        candidates = []
        
        for doc_id in self.access_log.keys():
            score = self.calculate_eviction_score(doc_id)
            if score >= threshold:
                candidates.append({
                    "id": doc_id,
                    "score": score,
                    "last_access": self.access_log[doc_id][-1] if self.access_log[doc_id] else None
                })
        
        # Сортируем по убыванию скоринга
        candidates.sort(key=lambda x: x["score"], reverse=True)
        return candidates

Система не удаляет документы сразу. Она перемещает их в "холодное хранилище" (например, в S3 Glacier). Если вдруг пользователь спросит про удаленный документ - система извинится, достанет его из архива и вернет в горячий индекс. Элегантно и безопасно.

💡
Механизм забывания - это то, что отличает профессиональную RAG-систему от любительской. В статье "Системы памяти для LLM" есть отличное сравнение подходов к управлению памятью.

5 Lexical Reranking: якоря в океане семантики

Векторный поиск великолепен, но у него есть слепые зоны. Термин "Python" в 2026 может означать язык программирования, змею, британскую комедийную группу или фреймворк для машинного обучения. Семантический поиск иногда путает.

import re
from collections import Counter

class LexicalAnchors:
    def __init__(self):
        self.technical_terms = {
            "python": ["programming", "code", "import", "def"],
            "docker": ["container", "image", "dockerfile", "compose"],
            "kubernetes": ["pod", "service", "deployment", "k8s"],
            # ... расширяемый словарь
        }
        
    def extract_key_terms(self, query: str) -> List[str]:
        """Извлекаем ключевые термины из запроса"""
        # Простой, но эффективный метод
        words = query.lower().split()
        
        # Убираем стоп-слова
        stop_words = {"what", "how", "why", "the", "a", "an", "is", "are", "can"}
        filtered = [w for w in words if w not in stop_words and len(w) > 2]
        
        # Ищем технические термины
        terms = []
        for word in filtered:
            if word in self.technical_terms:
                terms.append(word)
                
        return terms
    
    def rerank_by_lexical_match(self, query: str, candidates: List[Dict]) -> List[Dict]:
        """Переранжируем результаты по лексическому соответствию"""
        query_terms = self.extract_key_terms(query)
        
        if not query_terms:
            return candidates  # Нет ключевых терминов - оставляем как есть
            
        scored_candidates = []
        
        for candidate in candidates:
            score = candidate.get("similarity_score", 0)
            text = candidate.get("text", "").lower()
            
            # Добавляем бонусы за лексические совпадения
            lexical_bonus = 0
            
            for term in query_terms:
                # Простое вхождение термина
                if term in text:
                    lexical_bonus += 0.1
                    
                # Контекст термина
                if term in self.technical_terms:
                    context_words = self.technical_terms[term]
                    context_matches = sum(1 for word in context_words if word in text)
                    lexical_bonus += context_matches * 0.05
            
            # Комбинируем семантический и лексический скоринг
            combined_score = (score * 0.7) + (lexical_bonus * 0.3)
            
            scored_candidates.append({
                **candidate,
                "lexical_bonus": lexical_bonus,
                "combined_score": combined_score
            })
        
        # Сортируем по комбинированному скорингу
        scored_candidates.sort(key=lambda x: x["combined_score"], reverse=True)
        return scored_candidates

Этот простой трюк увеличивает точность поиска по техническим терминам на 15-20%. Векторный поиск находит семантически близкие документы, лексический ранжир фиксирует терминологические якоря.

Собираем всё вместе

Архитектура выглядит сложнее базового RAG, но каждая часть решает конкретную проблему:

class RobustRAGSystem:
    def __init__(self):
        self.router = IntelligentRouter()
        self.deduplicator = SemanticDeduplicator()
        self.evictor = SmartEviction()
        self.reranker = LexicalAnchors()
        
        # Инициализируем хранилища
        self.vector_dbs = {}  # topic -> chroma collection
        self.document_metadata = {}  # doc_id -> метаданные
        
    def add_document(self, text: str, metadata: Dict = None):
        """Добавляем документ с интеллектуальной маршрутизацией"""
        # 1. Определяем темы
        topics = self.router.route_document(text)
        
        # 2. Проверяем на дубликаты в каждой теме
        for topic in topics:
            existing_texts = self._get_existing_texts(topic.collection_name)
            duplicates = self.deduplicator.find_duplicates([text] + existing_texts)
            
            if duplicates and 0 in [item for sublist in duplicates for item in sublist]:
                # Нашли дубликат - выбираем лучшую версию
                best_text = self.deduplicator.select_best_version(
                    [text] + [existing_texts[i] for i in duplicates[0] if i > 0]
                )
                
                if best_text != text:
                    # Наш текст не лучший - обновляем существующий
                    self._update_existing(topic.collection_name, duplicates[0], best_text)
                    continue
            
            # 3. Добавляем в соответствующую коллекцию
            doc_id = self._add_to_collection(topic.collection_name, text, metadata)
            
            # 4. Логируем для будущей эвикции
            self.evictor.log_access(doc_id)
            
    def query(self, question: str, top_k: int = 5) -> Dict:
        """Умный поиск с переранжированием"""
        # 1. Определяем вероятные темы для запроса
        query_topics = self.router.route_document(question)
        
        all_results = []
        
        # 2. Ищем в каждой релевантной коллекции
        for topic in query_topics:
            if topic.collection_name in self.vector_dbs:
                results = self.vector_dbs[topic.collection_name].query(
                    query_texts=[question],
                    n_results=top_k * 2  # Берем больше для последующего фильтрования
                )
                
                # Добавляем тему к результатам
                for doc in results["documents"][0]:
                    all_results.append({
                        "text": doc,
                        "topic": topic.name,
                        "collection": topic.collection_name
                    })
        
        # 3. Переранжируем с учетом лексических совпадений
        reranked = self.reranker.rerank_by_lexical_match(question, all_results)
        
        # 4. Берем топ-k результатов
        final_results = reranked[:top_k]
        
        # 5. Логируем доступ для эвикции
        for result in final_results:
            if "doc_id" in result:
                self.evictor.log_access(result["doc_id"])
        
        # 6. Проверяем кандидатов на удаление
        eviction_candidates = self.evictor.get_candidates_for_eviction()
        if eviction_candidates:
            self._process_eviction(eviction_candidates)
        
        return {
            "results": final_results,
            "topics": [t.name for t in query_topics],
            "eviction_candidates": len(eviction_candidates)
        }

Ошибки, которые все совершают

Самая частая ошибка - внедрять все механизмы сразу. Начинайте с дедупликации, через неделю добавьте topic routing, потом eviction. Иначе не поймете, что именно сломалось.

Еще три ловушки:

  1. Слишком агрессивная эвикция - установите threshold=85 вместо 60 на первые три месяца
  2. Статический словарь терминов - обновляйте LexicalAnchors раз в неделю на основе реальных запросов
  3. Игнорирование трендов - если тема "миграция с Vue 2 на Vue 3" стала популярной, создайте отдельную корзину, не ждите пока наберется 100 документов

FAQ: вопросы, которые вы постесняетесь задать

Это не замедлит поиск в 100 раз?

Параллельные запросы к нескольким коллекциям + реранжирование добавляют 50-100 мс. Но точность вырастает на 30-40%. Пользователи не заметят разницы во времени, но заметят разницу в качестве.

А если система удалит важный документ?

Эвикция не удаляет, а архивирует. Плюс есть механизм восстановления: если пользователь ищет что-то из архива - система предлагает вернуть документ в горячий индекс.

Можно ли использовать с GPT-5 Turbo?

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

Как измерять эффективность?

Три метрики: точность ответов (человеческая оценка), скорость деградации (как быстро падает точность), пользовательская удовлетворенность (CSAT). Инструмент RAG Doctor автоматизирует диагностику проблем.

Что будет с RAG в 2027?

Нейронные базы данных с встроенным забыванием. Автоматическое определение аномалий в ответах. Системы, которые учатся на своих ошибках без человеческого вмешательства. Но базовые принципы останутся: разделение ответственности, умное забывание, комбинация семантического и лексического поиска.

Самый важный тренд 2026-2027 - RAG перестает быть изолированной системой и становится частью инфраструктуры памяти компании. Единое хранилище знаний, доступное и людям, и ИИ. Об этом подробно в статье "RAG в 2026: хакеры атакуют, таблицы сопротивляются".

💡
Если вы только начинаете с RAG, не усложняйте. Сначала сделайте базовую работающую систему по гайду за 15 минут, потом добавляйте сложность.

И последний совет: настройте алерты не только на ошибки, но и на снижение точности. Если ваша RAG-система вдруг стала слишком уверенной в странных ответах - это не баг, это крик о помощи. Пора пересматривать архитектуру памяти.

Подписаться на канал