Split Truth проблема в RAG: галлюцинации LLM на устаревших данных | AiManual
AiManual Logo Ai / Manual.
16 Фев 2026 Гайд

Разбор провала RAG в продакшене: проблема "Разделённой истины" и галлюцинации LLM на устаревших резюме

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

Тихий апокалипсис в HR-автоматизации

Представьте: ваша система на базе RAG для анализа резюме работает безупречно три месяца. Кандидаты получают персонализированные ответы, рекрутеры экономят часы на сортировке. А потом — бах! — система начинает рекомендовать на позицию Senior Python разработчика человека, который последний раз программировал на Pascal в 2005 году.

Это не гипотетический сценарий. Это реальный пост-мортем инцидента, который стоил компании трёх сильных кандидатов и репутации в HR-сообществе.

Архитектура, которая казалась надёжной

Стек выглядел солидно:

  • GPT-4.5 Turbo (последняя доступная версия на 16.02.2026)
  • Pinecone для векторного хранилища
  • PostgreSQL для структурированных данных
  • Кастомный пайплайн эмбеддингов

Логика проста: резюме парсится, разбивается на чанки, эмбеддинги отправляются в Pinecone. Метаданные (имя, email, опыт, навыки) — в Postgres. Когда LLM нужно ответить на запрос "Найди Senior Python разработчика с опытом FastAPI", система:

  1. Ищет похожие чанки в Pinecone
  2. Достаёт полные профили из Postgres по ID
  3. Формирует контекст для LLM
  4. Генерирует ответ

Где собака зарылась (точнее, где данные разъехались)

Проблема началась с обновления резюме. Кандидат Иван обновил своё резюме: убрал устаревший опыт работы с Django 2.x, добавил свежий проект на FastAPI. Система получила:

Действие PostgreSQL Pinecone
Обновление резюме ✅ Данные обновлены ⚠️ Частично обновлено
Удаление старого опыта ✅ Удалено ❌ Осталось в векторах
Добавление нового ✅ Добавлено ✅ Добавлено

И вот тут родился монстр под названием "Split Truth problem" (проблема разделённой истины).

💡
Split Truth problem — ситуация, когда разные части системы содержат противоречивые версии истины. В нашем случае: Postgres знает актуальную версию резюме, а Pinecone хранит "фантомные" чанки из старой версии.

Как LLM превращает мусор в опасные советы

Когда система искала "Senior Python FastAPI", происходило следующее:

# Упрощённая логика поиска
query = "Senior Python разработчик с опытом FastAPI"
query_embedding = embed(query)

# Шаг 1: Поиск в Pinecone
results = pinecone.query(
    vector=query_embedding,
    top_k=5,
    include_metadata=True
)
# Возвращает: [{'id': 'ivan_old_chunk3', 'score': 0.87, 'text': '10 лет опыта Django 2.x'}]

# Шаг 2: Получение полных профилей из Postgres
profile_ids = extract_profile_ids(results)
profiles = postgres.get_profiles(profile_ids)  # Актуальные данные!

# Шаг 3: Формирование контекста для LLM
context = """
Кандидат Иван:
- Из Postgres: 3 года опыта FastAPI, последний проект 2025
- Из Pinecone: 10 лет опыта Django 2.x (устарело в 2024)
"""

LLM получает противоречивую информацию. И вот что делает GPT-4.5 Turbo: пытается "примирить" данные. Результат? Классическая галлюцинация:

"Иван имеет 10 лет общего опыта в Python, включая 3 года специализации на FastAPI и исторический опыт с Django 2.x, который демонстрирует его способность работать с legacy системами."

Правда? Нет. Опасная ложь? Абсолютно. Кандидат сознательно убрал Django из резюме — он не хочет работать с legacy.

Почему это происходит именно в RAG-системах

Проблема не в LLM. GPT-4.5 достаточно умен, чтобы заметить противоречия. Проблема в том, как мы обучаем ИИ работать с правдой.

Три ключевых фактора:

1 Разная скорость обновления

Postgres обновляет запись мгновенно. Pinecone требует:

  • Пересоздания эмбеддингов для новых чанков
  • Удаления старых векторов (которые могут "зависнуть" из-за проблем с индексацией)
  • Синхронизации между репликами

2 Гранулярность данных

Резюме обновляется целиком. Но в Pinecone оно разбито на 10-15 чанков. Обновить один чанк — просто. Проследить, что все 15 чанков синхронизированы с Postgres — ад.

3 Отсутствие транзакционности

В реляционной базе есть транзакции: либо всё обновилось, либо ничего. В векторном хранилище такого нет. Частичное обновление — норма.

Решение, которое сработало (после трёх недель отладки)

1 Версионность всего

# Каждое резюме получает версию
class ResumeVersion:
    id: str  # resume_ivan_v3
    profile_id: str  # ivan_123
    version: int  # 3
    created_at: datetime
    chunks: List[Chunk]  # Все чанки этой версии
    is_active: bool

# При поиске учитываем только активные версии
def get_relevant_chunks(query, profile_ids):
    active_versions = get_active_versions(profile_ids)
    chunks = get_chunks_for_versions(active_versions)
    return search_in_chunks(query, chunks)

2 Транзакционный пайплайн обновления

@transactional
def update_resume(resume_id, new_content):
    # 1. Начинаем транзакцию в Postgres
    with postgres.transaction():
        # 2. Деактивируем старую версию
        deactivate_old_version(resume_id)
        
        # 3. Создаём новую версию в Postgres
        new_version = create_version(resume_id, new_content)
        
        # 4. Генерируем чанки и эмбеддинги
        chunks = chunk_resume(new_content)
        embeddings = embed_chunks(chunks)
        
        # 5. Атомарно обновляем Pinecone
        # Удаляем ВСЕ старые векторы этого профиля
        pinecone.delete_by_metadata({"profile_id": resume_id})
        
        # Добавляем новые с метаданными версии
        for chunk, embedding in zip(chunks, embeddings):
            pinecone.upsert(
                vectors=[{
                    'id': f"{resume_id}_v{new_version.version}_{chunk.id}",
                    'values': embedding,
                    'metadata': {
                        'profile_id': resume_id,
                        'version': new_version.version,
                        'chunk_id': chunk.id,
                        'is_active': True
                    }
                }]
            )
        
        # 6. Помечаем версию как активную
        activate_version(new_version.id)
    # Если что-то пошло не так — откатываем ВСЁ

3 Валидация консистентности

Ежедневный джоб проверяет:

def validate_consistency():
    # Для каждого активного профиля в Postgres
    for profile in get_active_profiles():
        # Получаем его активные чанки из Pinecone
        pinecone_chunks = pinecone.fetch_by_metadata({
            "profile_id": profile.id,
            "is_active": True
        })
        
        # Сравниваем с ожидаемыми
        expected_chunks = get_expected_chunks(profile.active_version)
        
        if len(pinecone_chunks) != len(expected_chunks):
            # Аларм! Данные разъехались
            trigger_alert(f"Split truth detected for {profile.id}")
            # Автоматический repair
            repair_profile(profile.id)

Чего НЕ делать (выучите на нашей боли)

❌ Не надейтесь на eventual consistency. "Векторное хранилище догонит" — это сказка для стартапов. В продакшене это гарантированный провал.

❌ Не используйте разные ID в разных системах. Если в Postgres профиль имеет ID "user_123", а в Pinecone чанки хранятся с ID "chunk_456" без привязки — вы уже проиграли.

❌ Не экономьте на метаданных. Каждый вектор должен нести полную информацию о своей принадлежности, версии и актуальности.

Как тестировать такие системы

Наш инцидент произошёл потому, что тесты проверяли "счастливый путь". Теперь мы используем подход из статьи про тестирование недетерминированных LLM, но с фокусом на консистентность:

class TestSplitTruth:
    def test_update_creates_new_version(self):
        # Создаём профиль
        profile = create_profile("test")
        
        # Обновляем его
        updated = update_profile(profile.id, "новое содержание")
        
        # Проверяем, что старая версия деактивирована
        old_chunks = pinecone.fetch_by_metadata({
            "profile_id": profile.id,
            "version": profile.version,
            "is_active": True
        })
        assert len(old_chunks) == 0  # Старых активных чанков быть не должно
        
        # Проверяем новую версию
        new_chunks = pinecone.fetch_by_metadata({
            "profile_id": profile.id,
            "version": updated.version,
            "is_active": True
        })
        assert len(new_chunks) > 0
        
        # Симулируем поиск
        results = search("тестовый запрос")
        
        # Убеждаемся, что возвращается только актуальная информация
        for result in results:
            assert result["version"] == updated.version
            assert result["is_active"] == True

Что делать, если уже попали в Split Truth

  1. Остановите систему. Лучше не давать ответов, чем давать ложные.
  2. Запустите валидацию консистентности для всех профилей.
  3. Восстановите данные из источника истины (обычно это основная база).
  4. Реализуйте транзакционный пайплайн до возобновления работы.
  5. Добавьте мониторинг расхождений как ключевую метрику.

Будущее RAG: меньше магии, больше инженерии

Тренд 2026 года — гибридный поиск и production-ready решения. Но фундаментальная проблема остаётся: чем сложнее система, тем больше точек рассинхронизации.

Мой прогноз: следующие 2 года мы увидим:

  • Векторные базы с транзакционной поддержкой — уже появляются первые ласточки
  • Стандарты метаданных для RAG — что хранить, как versionить, как валидировать
  • Автоматические repair-пайплайны — системы, которые сами находят и чинят расхождения
💡
Самый важный урок: RAG — это не просто "поиск + LLM". Это распределённая система хранения данных со всеми вытекающими проблемами консистентности, репликации и восстановления после сбоев.

И последний совет: если вы строите RAG для критически важных данных (медицина, финансы, кадры) — реализуйте двухслойную валидацию. Пусть LLM предлагает ответ, а детерминированный код проверяет его на противоречия с источниками.

Потому что однажды ваша система может рекомендовать Django-разработчика на позицию FastAPI. И хорошо, если это всего лишь потеря кандидата. А если это будет диагноз или финансовый прогноз?