Тихий апокалипсис в HR-автоматизации
Представьте: ваша система на базе RAG для анализа резюме работает безупречно три месяца. Кандидаты получают персонализированные ответы, рекрутеры экономят часы на сортировке. А потом — бах! — система начинает рекомендовать на позицию Senior Python разработчика человека, который последний раз программировал на Pascal в 2005 году.
Это не гипотетический сценарий. Это реальный пост-мортем инцидента, который стоил компании трёх сильных кандидатов и репутации в HR-сообществе.
Архитектура, которая казалась надёжной
Стек выглядел солидно:
- GPT-4.5 Turbo (последняя доступная версия на 16.02.2026)
- Pinecone для векторного хранилища
- PostgreSQL для структурированных данных
- Кастомный пайплайн эмбеддингов
Логика проста: резюме парсится, разбивается на чанки, эмбеддинги отправляются в Pinecone. Метаданные (имя, email, опыт, навыки) — в Postgres. Когда LLM нужно ответить на запрос "Найди Senior Python разработчика с опытом FastAPI", система:
- Ищет похожие чанки в Pinecone
- Достаёт полные профили из Postgres по ID
- Формирует контекст для LLM
- Генерирует ответ
Где собака зарылась (точнее, где данные разъехались)
Проблема началась с обновления резюме. Кандидат Иван обновил своё резюме: убрал устаревший опыт работы с Django 2.x, добавил свежий проект на FastAPI. Система получила:
| Действие | PostgreSQL | Pinecone |
|---|---|---|
| Обновление резюме | ✅ Данные обновлены | ⚠️ Частично обновлено |
| Удаление старого опыта | ✅ Удалено | ❌ Осталось в векторах |
| Добавление нового | ✅ Добавлено | ✅ Добавлено |
И вот тут родился монстр под названием "Split Truth problem" (проблема разделённой истины).
Как 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
- Остановите систему. Лучше не давать ответов, чем давать ложные.
- Запустите валидацию консистентности для всех профилей.
- Восстановите данные из источника истины (обычно это основная база).
- Реализуйте транзакционный пайплайн до возобновления работы.
- Добавьте мониторинг расхождений как ключевую метрику.
Будущее RAG: меньше магии, больше инженерии
Тренд 2026 года — гибридный поиск и production-ready решения. Но фундаментальная проблема остаётся: чем сложнее система, тем больше точек рассинхронизации.
Мой прогноз: следующие 2 года мы увидим:
- Векторные базы с транзакционной поддержкой — уже появляются первые ласточки
- Стандарты метаданных для RAG — что хранить, как versionить, как валидировать
- Автоматические repair-пайплайны — системы, которые сами находят и чинят расхождения
И последний совет: если вы строите RAG для критически важных данных (медицина, финансы, кадры) — реализуйте двухслойную валидацию. Пусть LLM предлагает ответ, а детерминированный код проверяет его на противоречия с источниками.
Потому что однажды ваша система может рекомендовать Django-разработчика на позицию FastAPI. И хорошо, если это всего лишь потеря кандидата. А если это будет диагноз или финансовый прогноз?