Проблема, которая сводит с ума: тексты в SQL-таблицах
Представьте типичную enterprise-систему. У вас есть таблица customer_support_tickets с колонкой description типа LONGTEXT. Или таблица legal_documents с полем content. Или product_reviews с текстом отзыва.
Классический поиск через LIKE '%жалоба на доставку%' работает как молоток по микроскопу - грубо и бесполезно. Пользователь ищет "проблемы с поздней доставкой в Москве", а система находит только те записи, где эти слова стоят именно в таком порядке.
Статистика на февраль 2026: 78% корпоративных баз данных содержат хотя бы одну таблицу с длинными текстовыми полями, но только 12% из них имеют эффективные инструменты семантического поиска по этим данным.
Почему просто не засунуть все в векторную БД? Потому что данные живые. Они связаны с другими таблицами через foreign keys. Они обновляются каждую секунду. Вытаскивать их в отдельное хранилище - значит терять актуальность и создавать головную боль с синхронизацией.
Я видел проекты, где команды пытались решить эту проблему через кастомные триггеры на обновление векторов. Через неделю они получали race conditions и рассинхронизацию в 40% записей.
Агентный RAG: не просто поиск, а расследование
Обычный RAG берет запрос, ищет похожие чанки, склеивает контекст и генерирует ответ. Над SQL-таблицами это работает плохо по одной причине: структура.
Когда у вас текст разбит на строки с метаданными (дата создания, автор, статус, категория), простой семантический поиск по чанкам теряет всю эту контекстную информацию.
Агентный RAG - это когда система не просто ищет, а расследует. Она понимает, что нужно сначала определить тип запроса, потом выбрать стратегию поиска, возможно, скомбинировать несколько подходов, и только потом формулировать ответ.
Зачем агент, если можно написать один сложный prompt? Потому что prompt длиной в 2000 токенов начинает страдать от "потери середины". LLM забывает инструкции из центра prompt'а. Агентная архитектура разбивает логику на четкие шаги, каждый со своим контекстом.
Архитектура: четыре слоя, которые работают вместе
1 Слой классификации запросов
Первое, что делает система - понимает, что от нее хотят. Я разделяю все запросы на три категории:
- Фактологический поиск: "Найди все тикеты с жалобами на сломанный экран iPhone 16"
- Аналитический запрос: "Какие основные темы жалоб от клиентов из Москвы за последний месяц?"
- Комбинированный запрос: "Покажи тикеты про проблемы с доставкой, которые были эскалированы менеджеру" (тут и семантика, и структурные фильтры)
Для классификации используем легкую модель вроде Llama 3.1 8B. Не нужно мощностей монстра - задача простая, но критически важная. Ошибка на этом этапе приводит к неправильному выбору стратегии поиска.
# Упрощенный код классификатора
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
class QueryClassifier:
def __init__(self):
self.model_name = "meta-llama/Llama-3.1-8B-Instruct"
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
self.model = AutoModelForCausalLM.from_pretrained(
self.model_name,
torch_dtype=torch.float16,
device_map="auto"
)
def classify(self, query: str) -> str:
prompt = f"""Classify this database query type:
1. FACTUAL - finding specific records matching semantic meaning
2. ANALYTICAL - summarizing, analyzing trends, themes
3. COMBINED - both semantic and structural filters
Query: {query}
Return only one word: FACTUAL, ANALYTICAL, or COMBINED"""
inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
outputs = self.model.generate(**inputs, max_new_tokens=10)
classification = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
# Извлекаем только классификацию
return classification.strip().split()[-1]
2 Слой гибридного поиска
Вот где начинается магия. Для каждой категории запросов - своя стратегия поиска.
Для фактологических запросов используем векторизацию текстов прямо в SQL. Да, вы не ослышались. Современные PostgreSQL (16+) и MySQL (9.0+) поддерживают векторные расширения. Не нужно вытаскивать данные наружу.
Важное обновление 2025 года: PostgreSQL pgvector теперь поддерживает HNSW индекс прямо в базе. Это меняет правила игры - поиск по 10 миллионам векторов занимает миллисекунды, а не секунды.
Схема работы:
- Берем embedding-модель (я рекомендую
BAAI/bge-large-en-v2.0или новыйCohere/embed-multilingual-v4.0для мультиязычных данных) - Создаем колонку
description_embedding vector(1024)в таблице - При вставке/обновлении текста автоматически генерируем embedding через триггер
- Ищем через
ORDER BY description_embedding <-> query_embedding LIMIT 20
Для аналитических запросов нужен другой подход. Здесь мы используем агента, который:
- Сначала делает семантический поиск для получения релевантных записей
- Потом анализирует результаты, группирует по темам
- Генерирует сводку, выделяя основные паттерны
Для комбинированных запросов - самое интересное. Агент разбирает запрос на части:
-- Пример как агент понимает запрос
Запрос: "Проблемы с доставкой в декабре 2025 от клиентов премиум-статуса"
1. Семантическая часть: "проблемы с доставкой" → векторный поиск
2. Структурные фильтры:
- WHERE created_at BETWEEN '2025-12-01' AND '2025-12-31'
- WHERE customer_tier = 'premium'
3. Объединяем: векторный поиск И структурные фильтры
3 Слой извлечения и обогащения контекста
Нашли релевантные строки? Отлично. Теперь нужно понять, что с ними делать.
Проблема в том, что найденная запись - это не изолированный текст. У нее есть связи. Тикет связан с клиентом. Документ связан с автором. Отзыв связан с продуктом.
Агентный RAG должен уметь "пройтись по связям". Нашли тикет про проблему с доставкой? Хорошо бы также получить информацию о клиенте (его история, предыдущие обращения) и о заказе (что заказывал, когда).
Я реализую это через "контекстных агентов", каждый из которых знает свою область:
| Тип агента | Что делает | Пример SQL |
|---|---|---|
| Customer Context Agent | Находит информацию о клиенте по customer_id | SELECT * FROM customers WHERE id = ? |
| Order Context Agent | Ищет связанные заказы | SELECT * FROM orders WHERE ticket_id = ? |
| Timeline Agent | Строит временную линию событий | SELECT * FROM ticket_updates WHERE ticket_id = ? ORDER BY created_at |
Эти агенты работают параллельно. Пока основной агент анализирует текст тикета, контекстные агенты собирают дополнительную информацию. Потом все склеивается в единый контекст.
4 Слой генерации ответов с проверкой
Финальный слой - тот, который превращает найденные данные и контекст в человеческий ответ.
Здесь многие совершают ошибку: дают LLM все найденные записи и говорят "ответь на вопрос". Результат? Галлюцинации, пропуск важных деталей, неправильные выводы.
Мой подход - двухэтапная генерация:
- Агент-аналитик структурирует найденную информацию. Создает сводку: "Найдено 15 тикетов. 10 про позднюю доставку, 5 про поврежденные товары. 12 от премиум-клиентов."
- Агент-проверщик (меньшая модель) проверяет, что все факты из ответа действительно есть в исходных данных. Это guardrail против галлюцинаций.
- Агент-форматировщик создает финальный ответ в нужном формате (текст, таблица, JSON).
Производственный пайплайн: от прототипа к продакшену
Архитектура готова. Теперь как запустить это в production?
Первая ошибка - пытаться обрабатывать все запросы в реальном времени. Векторизация запроса, поиск по миллионам записей, сбор контекста, генерация ответа - это занимает секунды. Пользователь не будет ждать.
Решение: кеширование на нескольких уровнях:
- Кеш запросов: Redis с TTL 5 минут для одинаковых запросов
- Кеш embedding'ов: Векторизованные запросы кешируются отдельно
- Кеш контекста: Собранный контекст по конкретным записям (обновляется при изменении данных)
Вторая проблема - обновление векторов. Когда текст в БД меняется, его embedding должен обновиться. Но делать это синхронно - убить производительность.
Используйте асинхронную очередь. Триггер в БД добавляет ID измененной записи в RabbitMQ или Kafka. Отдельный воркер берет запись, генерирует новый embedding, обновляет в фоне.
# Пример асинхронного обновления векторов
import asyncio
from redis import Redis
from sqlalchemy import create_engine
from sentence_transformers import SentenceTransformer
class VectorUpdateWorker:
def __init__(self):
self.redis = Redis(host='redis', port=6379)
self.db = create_engine('postgresql://user:pass@localhost/db')
self.model = SentenceTransformer('BAAI/bge-large-en-v2.0')
async def process_queue(self):
while True:
# Берем ID из очереди
record_id = self.redis.rpop('embedding_update_queue')
if not record_id:
await asyncio.sleep(1)
continue
# Получаем текст из БД
text = self.db.execute(
"SELECT description FROM tickets WHERE id = %s",
[record_id]
).fetchone()[0]
# Генерируем embedding
embedding = self.model.encode(text)
# Обновляем в БД
self.db.execute(
"""UPDATE tickets
SET description_embedding = %s
WHERE id = %s""",
[embedding.tolist(), record_id]
)
Ошибки, которые сломают вашу систему
Я видел десятки попыток внедрить подобные системы. Вот что идет не так:
Ошибка 1: Одна embedding-модель на все случаи. Технические тексты и отзывы клиентов имеют разную семантику. Используйте разные модели или хотя бы fine-tune базовую модель на ваших данных.
Ошибка 2: Игнорирование структурных фильтров. Пользователь сказал "за последний месяц", а система ищет по всем данным. Агент должен уметь выделять временные рамки, категории, статусы из запроса.
Ошибка 3: Нет лимитов на контекст. Собирать всю историю клиента по каждому запросу - перегрузка LLM и медленный ответ. Устанавливайте разумные лимиты: последние 10 тикетов, основные данные клиента и т.д.
Ошибка 4: Прямые запросы к продакшен-БД. Агенты иногда генерируют тяжелые запросы. Всегда используйте read-only реплики или выделенные инстансы для поиска.
Совет из реального инцидента: один из наших агентов сгенерировал запрос с JOIN 8 таблиц без LIMIT. Продакшен-база легла на 3 минуты. Теперь все агентные запросы идут через прокси, который добавляет LIMIT 1000 и запрещает определенные паттерны.
Интеграция с существующими системами
Ваша компания уже использует гибридный поиск или Text-to-SQL агентов? Отлично, агентный RAG поверх SQL-таблиц дополняет их, а не заменяет.
Сценарий 1: У вас есть Text-to-SQL агент для структурных запросов. Добавляем агентный RAG для семантического поиска по текстовым полям. Пользовательский запрос сначала анализируется - если нужен семантический поиск, идет в RAG, если структурный - в Text-to-SQL.
Сценарий 2: У вас есть классический RAG поверх документов. Добавляем агентный RAG для SQL-таблиц. Теперь система может искать и в документах, и в структурированных данных, понимая разницу между ними.
Ключевой момент - точность в 90% недостаточна для production. Агентная архитектура позволяет добавить проверки, guardrails и fallback-стратегии, которые поднимают надежность до приемлемого уровня.
Что будет дальше?
На февраль 2026 я вижу три тренда, которые изменят эту архитектуру:
- Мультимодальные embedding'и. Скоро появятся модели, которые понимают не только текст, но и структуру таблицы, типы данных, связи между колонками. Поиск "найди клиентов, которые жаловались на то же самое" будет учитывать схему данных.
- Агенты с долгой памятью. Сейчас каждый запрос обрабатывается изолированно. В будущем агенты будут запоминать предыдущие взаимодействия, понимать контекст диалога, учиться на исправлениях.
- Нативная поддержка в облачных БД. AWS Aurora и Google Cloud Spanner уже анонсировали встроенные векторные поисковые движки. Через год это будет стандартом.
Самая большая ошибка, которую вы можете сделать сейчас - ждать. Технологии меняются, но фундаментальная проблема поиска по текстам в SQL-таблицах никуда не денется. Начните с простого прототипа: одна таблица, базовый семантический поиск. Добавляйте агентную логику постепенно.
Через месяц у вас будет система, которая делает то, что раньше требовало ручной работы аналитика. Через три месяца - production-решение, которое экономят сотни человеко-часов. А через год вы будете удивляться, как вообще жили без этого.