Что пошло не так
Поздно вечером, уже хотел выключить ноут, звонит менеджер из логистической компании. Ищет формулировку из своего договора, а в ответе — куски из чужих контрактов. Другая отрасль, другой регион. Внутри всё сжалось.
Мы с pgvector работали не первый год, но утечка между арендаторами — это тот сценарий, от которого не спишь спокойно. Открыл БД, посмотрел запрос: tenant_id вроде передаётся, фильтр стоит. Но чужое всё равно пролезло. Разобрался за пару часов и за ночь переписал.
Почему фильтр не спасал
Таблица была простой:
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
chunk_text TEXT,
embedding vector(1024),
metadata JSONB
);
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
Запрос выглядел надёжно:
SELECT id, chunk_text
FROM documents
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 5;
HNSW — approximate-индекс. Планировщик сначала набирает кандидатов по близости векторов, а только потом применяет WHERE. Если в топ попали чужие записи, а своих не хватило, возвращается меньше строк. Фильтр отсеивает, но не всегда спасает.
Настоящая дыра оказалась в старом эндпоинте для интеграции. Там tenant_id иногда терялся и превращался в NULL, а NULL означал «ищи везде».
Что я изменил
Row-Level Security как подстраховка
Сделал tenant_id обязательным на уровне БД. Включил RLS:
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON documents
USING (tenant_id = current_setting('app.current_tenant')::bigint);
В FastAPI на каждый запрос ставлю сессионный параметр. Если забуду фильтр — база сама не отдаст чужое. Если забуду установить контекст — получу ошибку, а не утечку.
Hash-партиционирование
Крупных клиентов немного, но они дают основной объём. Разбил таблицу по hash(tenant_id). Каждый HNSW-индекс теперь живёт внутри своей партиции. У маленького клиента граф компактный, поиск быстрее. У большого — данные размазаны, и переиндексация одного не трогает остальных.
Частичные индексы
Для частых срезов внутри tenant (например, только за 2025 год) добавил частичные HNSW-индексы. Планировщик сразу идёт по нужному графу, без лишних кандидатов.
Что добавил в процесс
После инцидента завёл аудит всех семантических запросов с tenant_id, user_id и result_ids. В CI поставил тест: поднимает чистый PostgreSQL, льёт двух фейковых арендаторов и проверяет, что в выдаче нет чужих строк. Теперь такой косяк не пройдёт даже в спешке.
Главный вывод
HNSW и фильтры — это компромисс, который нужно проектировать заранее. Один индекс на всех — просто, но рискованно. Партиционирование + RLS даёт и скорость, и защиту. Я выбрал этот путь и теперь сплю спокойнее. А тот звонок точно не хочу повторять.
