Предположим, вы построили RAG-сервис на SQL, и он отлично работает. Довольно быстро, очень точно, и очень дорого, ведь каждый запрос к сервису требует обращения к LLM для генерации ответа по чанкам, извлеченным из базы знаний. И чем больше мы извлекли таких фрагментов, тем больше входных токенов тратится на составной промпт, даже если ответ будет состоять из одного предложения.
Можно, конечно, заранее срезать количество извлекаемых чанков, но это отразится на качестве ответов.
Можно настроить кэш, который экономит на обращениях к сервису, когда приходят одинаковые вопросы. Но когда пользователь спрашивает "How to get developer support?”, и тут же другой пользователь спрашивает "How to ask development-related questions?", ваш сервис каждый раз будет генерировать ответ заново, сжигая ваши токены и заставляя пользователя ждать. Обычный кэш тут бессилен: для него эти две фразы — абсолютно разные ключи.
В этой статье я расскажу, как развернуть мощный семантический кэш на базе AlloyDB Omni (PostgreSQL от Google), используя векторный поиск ScaNN, автоматическое партиционирование и планировщик задач. Мы пройдём путь от настройки Docker-контейнера до продакшн-архитектуры.
Логика работы кэша следующая:
Пользователь задает вопрос.
Приложение генерирует вектор (эмбеддинг) этого вопроса.
Выполняется поиск в базе данных по индексу ScaNN с порогом сходства (similarity threshold).
Если найден похожий вопрос, возвращается сохраненный ответ.
Если не найден: эмбеддинг вопроса отправляется в RAG, извлекаются подходящие фрагменты информации, ответ генерируется, а пара "вопрос + вектор + ответ сохраняется в базе данных.
В отличие от классического кэша, который ищет точное совпадение текста, семантический кэш ищет смысловую близость.
Мы превращаем вопрос пользователя в вектор (набор чисел) и ищем в базе близкие векторы предыдущих вопросов. Если найден такой вопрос с близким вектором (дистанция меньше некоторого порога), мы отдаем сохраненный ответ сразу, не обращаясь к RAG.
Итак, наша задача: максимально ускорить среднее время отклика нашей системы “Вопрос-Ответ”. Для этого мы будем использовать и классический кэш по полному совпадению, как самый быстрый способ получить ответ, так и семантический кэш, позволяющий найти ответ по эмбеддингу вопроса.
Уровень 1 (поиск в кэше по полному совпадению вопроса): Клиент считает MD5-хэш вопроса. База ищет его за микросекунды. Если нашли — отдаем ответ.
Уровень 2 (поиск по семантической близости сохраненных вопросов): Если точного совпадения нет, вычисляем эмбеддинг вопроса и ищем ближайший к нему соседей среди сохраненных в кэше. Если находим — отдаем ответ.
Уровень 3 (RAG): Если ничего не нашли — спрашиваем в базе знаний, а результат сохраняем в кэше.
Информация в базе знаний – вещь динамическая. Появляются новые факты, окружающий мир меняется, и ответы на вопросы тоже должны корректироваться. Предположим, мы – выпускаем некий сервис c доступом по API, и сначала у нас была служба поддержки разработчиков, но мы решили внедрить агентный ИИ, который будет искать ответы по кодовой базе и отвечать на вопросы по API. И если раньше ответ на вопрос “How to get developer support?” был что-то типа “There is ticket system available for developer support”, то теперь он должен давать ссылку на этот сервис.
Самое простое решение - удалять старые записи кэша, которые могут оказаться нерелевантными текущему моменту. На этапе планирования надо рассчитать время жизни, исходя из динамики обновления информации, которую вы предоставляете. Например, установить, что время жизни записи – три месяца, если вы – справочная служба по археологии. Если же вы – техподдержка сайта, то жизнь кэша не может быть такой долгой, и лучше поставить время жизни в один день.
Итак, нам надо заранее прикинуть баланс между двумя качественными показателями: экономия токенов + снижение задержки, против реактивности сервиса.
В этой статье мы настроим месячный кэш, и пусть это будет раздел FAQ сайта по блокчейн: Вопросы приходят одни и те же, информация пополняется регулярно, но нечасто. Если появилась какая-то новая тема с часто задаваемыми вопросами, можно настроить очистку кэша по похожим вопросам (но тематическую очистку кэша мы здесь не рассматриваем, это разговор на целую отдельную статью). А кэш старше месяца можно смело удалять планировщиком задач.
Традиционная команда DELETE в PostgreSQL работает как пометка маркером "удалено". Физически данные остаются на диске, создавая "дыры" и нагружая процесс очистки. При интенсивной ротации кэша это убивает производительность.
Элегантное решение здесь – разделение большой таблицы кэша на партиции, которые будут храниться в разных файлах, и команда DROP PARTITION, которая работает на уровне файловой системы: она просто "отцепляет" файл с данными. Это мгновенно, бесплатно для процессора и не оставляет мусора. Для этого мы включим расширение pg_partman и создадим для нее схему partman.
Однако партиционирование порождает конфликт: мы хотим разбить данные на куски (по месяцам) для удобного удаления, но индекс ScaNN требует больших таблиц. В PostgreSQL партиционирование — это создание иллюзорной таблицы. Родительская таблица — это "призрак", логический маршрутизатор, не содержащий данных. Поскольку данных нет, индексу ScaNN не на чем учиться.
В отличие от классических индексов (B-Tree), которые просто упорядочивают данные, ScaNN — это обучаемый индекс. Ему необходимо "взглянуть" на данные, чтобы построить карту многомерного пространства (кластеризовать векторы и квантовать их).
Попытка создать индекс на родительской таблице вернёт ошибку, требующую создания индексов на каждой дочерней партиции отдельно. Это не программный баг, а математическая неизбежность: нельзя построить карту местности, которой ещё не существует.
Значит, будем создавать индекс scann на каждой партиции, что немного усложняет задачу, но вполне решаемо.
В алгоритме индекса предусмотрен функционал обработки небольших наборов записей, когда включается обычный brute force, измеряющий расстояния между всеми эбеддингами попарно. Но если у нас в партициях будет всегда brute force вместо полноценного scann, то мы теряем всю магию. Правильный путь — не создавать маленькие таблицы.
Эффективность ScaNN и партиционирования зависит от предполагаемого количества вопросов к сервису. Выберите стратегию исходя из вашего объема:
Если у вас всего пара тысяч вопросов в день, суточные партиции вам вредят.
Будем разбивать по месяцам. За месяц наберется ~150k векторов. Этого объема достаточно, чтобы индекс ScaNN построился качественно и работал быстро.
DROP PARTITION работает раз в месяц. Хранить кэш чуть дольше (например, удалять вопрос не ровно через месяц, а когда устареет целый месяц) обычно допустимо для кэша.
Итог: ScaNN работает отлично, управление легкое.
Суточные партиции имеют смысл, но ScaNN для каждой из них – слишком дорого.
Можно использовать суточные партиции, не создавая ScaNN индекс. На 20-30k записях brute force (последовательный скан) работает быстрее, чем поиск по индексу. PostgreSQL умеет делать Parallel Seq Scan. Если вы ищете по 7 последним дням, база запустит 7 параллельных воркеров, каждый просканирует свою маленькую табличку за 5-10 мс. Индекс ScaNN здесь добавит накладные расходы на чтение структуры дерева, но не даст выигрыша.
В результате мы получим скорость (за счет параллелизма) и удобство удаления (DROP PARTITION), не страдая от ограничений индекса.
Суточные партиции огромны, поиск без индекса тормозит.
Здесь и суточные партиции, и индекс ScaNN. При таком объеме ScaNN обучается великолепно. Проблема "менее 5000 строк" исчезает сама собой в первые часы работы системы.
Но есть нюансы: При создании новой (пустой) партиции “на завтра” используйте при создании партиции scann.allow_blocked_operations = true, и создайте "пустой" индекс заранее. Вам понадобится отдельная задача для планировщика, которая будет каждые N часов запускать REINDEX CONCURRENTLY на активной партиции, чтобы переобучать центроиды на свежих данных.
AlloyDB Omni — это open source версия PostgreSQL от Google, обогащенная функциями ИИ, которую можно запустить на своих ресурсах. Попробуйте вот такую команду запуска:
Bash
docker run --name <your_server_name> \
-e POSTGRES_PASSWORD=<your_password> \
-d \
google/alloydbomni
Будет скачан образ с сервером Alloy DB и запущен с вашим паролем. После запуска проверьте логи: контейнер должен стартовать за пару секунд, а не висеть в бесконечном перезапуске. Если всё получилось, к нему можно будет подключиться как к обычному серверу PostgreSQL.
Нам нужны 4 расширения:
vector — для хранения эмбеддингов.
alloydb_scann — супер-быстрый индекс от Google.
pg_partman — для управления партициями (чистка старого кэша).
pg_cron — планировщик задач.
Здесь есть нюансы: pg_partman любит порядок и свою схему, а pg_cron в AlloyDB Omni жестко привязан к системной схеме. С первым мы справимся легко, просто создаем нужную схему ‘patrman’ и потом делаем расширение с указанием этой схемы.
Вот SQL-скрипт инициализации:
-- 1. Включаем расширения
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS alloydb_scann;
-- 2. pg_partman ставим ЯВНО в его схему.
CREATE SCHEMA IF NOT EXISTS partman;
CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman;
С расширением ‘pg_cron’ хитрее. По умолчанию оно не включено в контейнере, и его надо включать специальным флагом конфигурации сервера при запуске docker:
-c 'shared_preload_libraries=pg_cron'
Здесь важно помнить, что флаг -c (config) должен стоять ПОСЛЕ названия образа, тогда он будет опцией запускаемого сервера. Если поставить до, Docker подумает, что это флаг --cpus (CPU shares), который относится к настройке самого Docker.
Второй момент: в стандартной конфигурации это расширение ставится на системную базу postgres. И это правильно, потому что если у вас несколько баз данных на сервере, лучше, если планировщик задач сидит в системной и оттуда запускает задачи по расписанию в любой базе. Так и делайте, если вам нужно держать несколько разных баз.
Но предположим, что мы простых путей не ищем, и нам нравится выполнять все манипуляции в одной базе данных, не создавая дополнительное подключение к системной базе postgres. А мне именно так и нравится, я хочу создать герметичный сервис и администрировать его в одном подключении, настроить все гранты пользователя для одной базы и не пускать его в системную. Для этого в строке запуска контейнера я добавил еще одну переменную конфигурации cron.database_name.
Вот полный скрипт запуска с обоими ключами:
docker run --name <your_server_name> \
-e POSTGRES_PASSWORD=<your_password> \
-d \
google/alloydbomni \
-c 'shared_preload_libraries=pg_cron' \ -c ‘cron.database_name=<your_db_name>’
После перезапуска я запустил прямо в своей базе:
CREATE EXTENSION IF NOT EXISTS pg_cron;
И у меня в списке появилась новая схема cron, содержащая всё необходимое для организации запуска задач по расписанию. Вот так это выглядит после всех манипуляций:
Для эффективной работы ScaNN требуется критическая масса данных. На малых выборках невозможно выделить качественные центроиды кластеров. Индекс, обученный на 100 строках, будет давать очень плохой Recall при поиске среди будущих миллионов эмбеддингов. Мы уже говорили, что в таблице должно быть не меньше 5000 записей. Если партиция содержит меньше строк, создание индекса ScaNN либо блокируется системой, либо становится бессмысленным.
Ну и на уровне железа – для малых объемов данных классический алгоритм построения ближайших соседей Brute Force (попарное вычисление расстояний между всеми эмбеддингами) работает быстрее. Прочитать 2000 векторов из оперативной памяти и сравнить их попарно напрямую стоит дешевле, чем траверс сложного дерева индекса.
Итак, будем создавать отдельную таблицу на каждый месяц и просто удалять (DROP) устаревшие таблицы целиком.
CREATE TABLE public.semantic_cache (
id BIGSERIAL NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
user_query TEXT NOT NULL,
-- MD5 хэш считаем на клиенте (Python), база просто его сохранит.
-- Это экономит CPU базы данных.
query_hash CHAR(32) NOT NULL CHECK (query_hash ~ '^[a-f0-9]{32}$'),
-- Вектор (размер 768 для OpenAI/Gecko)
embedding VECTOR(768),
rag_response TEXT NOT NULL,
-- created_at обязан быть в PK для партиционирования
PRIMARY KEY (created_at, id)
) PARTITION BY RANGE (created_at);
Нам нужны два типа индексов. Первый – для точного поиска по хэш-таблице с использованием MD5 (быстро и дешево)
CREATE INDEX idx_cache_hash ON semantic_cache USING HASH (query_hash);
Второй – Для семантического поиска (ScaNN)
CREATE INDEX idx_cache_scann ON semantic_cache
USING scann (embedding vector_cosine_ops)
WITH (num_leaves = 100, quantizer = 'sq8');
Сюрприз! Этот индекс сразу выдаст ошибку, так как его нельзя создавать на партиционированной таблице.
Построение индекса на малом количестве данных приведет к большому дисбалансу в кластерах, и поэтому, по мере наполнения партиций его придется перестраивать планировщиком. Всё это загромождает реализацию и делает решение непрозрачным. В последних версиях AlloyDB внедряется функционал автоматического обслуживания индексов (scann.enable_preview_features), который пытается автоматизировать процессы перестроения центроидов (UpdateCentroid) и разделения партиций (SplitCentroid). Включение этого функционала может снизить операционную нагрузку, но требует тщательного тестирования.
Есть компромиссное решение: индексация закрытого периода. Вместо того чтобы пытаться создать индекс на пустой партиции "на завтра" (что приведет к ошибке или плохому качеству индекса), мы создаем индекс на партиции "за вчера", как только она перестает принимать активную запись.
Посмотрите, как красиво получается: партиция уже заполнена данными (> 5000 строк), поэтому AlloyDB ничего не блокирует, индекс обучается на реальных законченных данных, а не на пустоте, что гарантирует хороший Recall.
Что касается текущей, пополняемой партиции – не будем на ней делать индекс ScaNN. Тогда вставка записей там будет работать с максимальной скоростью, не тратя ресурсы CPU на пересчёт векторного дерева в реальном времени.
Поиск по текущей партиции выполняется через Brute Force (быстро, так как данных мало), а по огромной истории — через ScaNN. По-моему – красиво.
Вы используете pg_partman для создания ежемесячных партиций.
При настройке pg_partman убедитесь, что в шаблонной таблице (template table) или на родительской таблице НЕТ определения индекса ScaNN. Обычные индексы (B-Tree на id, created_at) — можно и нужно оставить.
Настраиваем partman (в рабочей базе my_app_db):
-- Говорим partman управлять нашей таблицей
SELECT partman.create_parent(
p_parent_table => 'public.semantic_cache',
p_control => 'created_at',
p_type => 'range',
p_interval => '1 month',
p_premake => 2
);
-- Настраиваем удаление старого кэща (Retention 3 месяца)
UPDATE partman.part_config
SET retention = '3 months', retention_keep_table = false
WHERE parent_table = 'public.semantic_cache';
Создайте процедуру, которая находит партицию прошлого месяца и индексирует её:
CREATE OR REPLACE PROCEDURE public.create_scann_on_last_month()LANGUAGE 'plpgsql'
AS $BODY$
DECLARE-- Имя партиции pg_partman генерирует стандартно:-- table_name_pYYYYMMDD-- Вычисляем имя таблицы за прошлый месяц
part_name TEXT := CONCAT('semantic_cache_p', to_char(date_trunc('month',NOW() - interval '1 month'), 'YYYYMMDD'));
index_name TEXT := CONCAT(part_name, '_embedding_idx');sql_cmd TEXT;
BEGIN
-- Проверяем, существует ли такая таблица (защита от сбоев)IF EXISTS (SELECT FROM pg_tables WHERE tablename = part_name)THEN-- Формируем команду создания индекса
sql_cmd := format('CREATE INDEX IF NOT EXISTS %I ON %I USING scann (embedding cosine) WITH (num_leaves = 100)',index_name, part_name ); -- ВыполняемRAISE NOTICE 'Indexing partition: %', part_name;EXECUTE sql_cmd;ELSERAISE NOTICE 'Partition % not found, skipping.', part_name;END IF;
END;
$BODY$;
Запустите эту процедуру и посмотрите что получилось.
Вот так выглядят партиции с индексами в моей базе после всех этих манипуляций:
Теперь самое интересное. Нам нужно, чтобы база сама создавала новые таблицы на следующий месяц и удаляла старые (старше 3 месяцев).
Установите и включите pg_cron, если еще не сделали.
Если вы последовали моему примеру и включили cron в собственной базе данных, то всё просто:
-- Создаем задачу
SELECT cron.schedule('partman-maintenance', '0 * * * *', 'CALL partman.run_maintenance()');–- '0 * * * *' – в синтаксисе cron означает, что задание будет выполняться,-- когда часы покажут нулевую минуту. То есть раз в час в 00 минут.
Если же вам нужно запускать планировщик задач для нескольких баз данных и вы оставили его в postgres, то сначала подключитесь к postgres, там создайте задачу, выполнив запрос, указанный выше, а затем измените эту задачу так, чтобы она стучалась в нужную базу данных my_app_db:
UPDATE cron.job
SET database = 'my_app_db',
username = 'postgres',
nodename = '127.0.0.1' -- или localhost
WHERE jobname = 'partman-maintenance';
Лайфхак: Чтобы pg_cron мог подключиться к 127.0.0.1 без пароля, убедитесь, что в файле pg_hba.conf есть правило host all all 127.0.0.1/32 trust. Оно там стоит по умолчанию, но убедиться не мешает. Postgres умеет показывать таблицу с правилами, которые он сейчас применяет:
SELECT * FROM pg_hba_file_rules
Вам нужна строка, которая выглядит примерно так:
type: host
database: all (или имя вашей базы)
user_name: all (или postgres)
address: 127.0.0.1/32
auth_method: trust (это главное!)
И наконец, нам нужно задание, которое раз в месяц проверяет заполненную партицию и создает на ней индекс.
Запланируйте выполнение этой процедуры каждый первый день месяца (например, в 01:00), когда запись в предыдущий месяц точно завершена:
SELECT cron.schedule('0 1 1 * *', 'CALL create_scann_on_last_month()');
Вот пример вызова кэша на клиенте:
import hashlib
from alloydb_connection import get_alloydb_connection_params, try_connect_to_alloydb #эти функции надо написать самостоятельно, я не хочу загружать текст служебными функциями
def get_cache(user_query): # 1. Считаем хэш на клиенте query_hash = hashlib.md5(user_query.encode()).hexdigest() params = get_alloydb_connection_params() conn = try_connect_to_alloydb(params) cursor = conn.cursor() # 2. Ищем точное совпадение (Мгновенно) cursor.execute("SELECT rag_response FROM semantic_cache WHERE query_hash = %s", (query_hash,)) res = cursor.fetchone()
if res: cursor.close() conn.close() return res[0]
# 3. Ищем семантическое совпадение (ScaNN) # Порог 0.3 означает среднюю схожесть
cursor = conn.cursor() cursor.execute(""" SELECT rag_response FROM semantic_cache WHERE (embedding <=> embedding('text-embedding-005', %s)::vector) < 0.3 ORDER BY embedding <=> embedding('text-embedding-005', %s)::vector LIMIT 1 """, (user_query, user_query)) res = cursor.fetchone() cursor.close() conn.close() return res[0] if res else None
if name == "__main__": print(get_cache("How to ask developer questions?"))
Вот и всё, высоконагруженный кэш к RAG-сервису готов.
Напоследок давайте вспомним про наш простой хэш-индекс, построенный функцией MD5, и почему мы не вычисляем его на сервере.
Postgres имеет свои механизмы хеширования, например, мы могли бы создать нашу таблицу semantic_cache с автозаполняемым полем query_hash:
CREATE TABLE IF NOT EXISTS public.semantic_cache(… query_hash character(32) NOT NULL GENERATED ALWAYS AS (MD5(user_query)) STORED,…)
но в нашем случае убирать hashlib на стороне Python может быть неэффективно. Для быстрого поиска в кэше нам выгоднее отправлять на сервер готовое значение хэш-функции вместе с текстом вопроса, так как неизвестно, надо ли нам будет его сохранять в кэше, может быть он уже там есть. Логика будет такая:
Клиент: Считает hash (тратит CPU).
Клиент: Делает SELECT по этому хэшу.
Клиент: Если не нашел, делает INSERT, отправленного текста.
Сервер (если есть GENERATED COLUMN): Получает текст -> снова считает хэш (снова тратит CPU) -> записывает на диск.
Тем не менее, такая автогенерация имеет смысл, например, в сценарии с микросервисами:
Например, у вас много разных микросервисов (на Python, Go, Node.js), которые пишут в эту таблицу, и можно предположить, что один программист использует md5, другой sha256, а третий вообще забудет заполнить поле query_hash. Это приведет к "грязным" данным. Поэтому вы запрещаете клиентам присылать хэш вручную. База данных сама гарантирует, что поле query_hash всегда заполнено правильно и по единому алгоритму, Мы жертвуем лишним использованием CPU базы данных на вычисление, но получаем гарантию целостности данных.
Итак, мы получили систему, которая:
Отсекает повторные и похожие вопросы до вызова RAG, разгружая LLM и экономя токены;
Сама себя обслуживает: Старые данные удаляются автоматически, партиции создаются сами;
Готова к большим нагрузкам: AlloyDB Omni оптимизирован под векторы лучше стандартного Postgres.
Источник
![[Перевод] Космические дата-центры и 100 ГВт на орбите: где ломается бизнес-модель. Разбор от TechCrunch](https://mexc-rainbown-activityimages.s3.ap-northeast-1.amazonaws.com/banner/F20250611171322267jYDo0LEq24FJKD.png)

