Чужие договоры в поиске: как я за ночь переделал мульти-тенант в pgvector

Чужие договоры в поиске: как я за ночь переделал мульти-тенант в pgvector

Что пошло не так

Поздно вечером, уже хотел выключить ноут, звонит менеджер из логистической компании. Ищет формулировку из своего договора, а в ответе — куски из чужих контрактов. Другая отрасль, другой регион. Внутри всё сжалось.

Мы с 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 даёт и скорость, и защиту. Я выбрал этот путь и теперь сплю спокойнее. А тот звонок точно не хочу повторять.