1. Главная
  2. Блог
  3. «А почему я вижу чужие договоры?» Как мы за ночь переписали мульти-тенант в pgvector

«А почему я вижу чужие договоры?» Как мы за ночь переписали мульти-тенант в pgvector

«А почему я вижу чужие договоры?» Как мы за ночь переписали мульти-тенант в pgvector

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

Внутри всё оборвалось. Мы разрабатываем ИИ-агентов для бизнеса не первый год, и утечка чужих данных между арендаторами — это ровно тот сценарий, ради которого ты не спишь спокойно. Открываем БД, смотрим запрос — формально tenant_id передаётся, фильтр есть. А чужое всё равно проехало. Разобрались за пару часов, переписали за ночь, к утру выкатили патч. Ниже — что именно сломалось, как теперь устроена мульти-тенантность в pgvector у нас, и почему «просто WHERE tenant_id = $1» не спасает, если рядом стоит HNSW.

Где утекло

Архитектура была подчёркнуто простая — мы её сами такой сделали, когда только запускали настройку ИИ-агента для первого клиента. Одна таблица documents:

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 в pgvector — это approximate индекс. Планировщик сначала идёт по графу и вытягивает топ-N кандидатов по близости вектора. Только потом применяется WHERE tenant_id = $1 к этим кандидатам. Если в верхушку выдачи попали фрагменты чужого арендатора, а от нашего — только четыре, вернётся четыре правильных результата вместо пяти. Молча, без ошибок в логе.

Проблема была не в этом — фильтр после индекса всё-таки отсеивает чужое, и клиент видит только своё. Настоящая беда обнаружилась глубже: у нас был ещё один путь — старый эндпоинт для интеграции с шиной, — и там tenant_id терялся на входе. Дефолтное значение NULL обработчик молча интерпретировал как «глобальный поиск». Один забытый Depends, одна цепочка вызовов, через которую давно никто не ходил — и вот тебе чужой договор в выдаче.

Это не баг pgvector. Это наша ошибка проектирования. Но она вскрылась именно потому, что HNSW толкает разработчика к подходу «достанем побольше, потом отфильтруем», и обработчик где-то по дороге привык доверять, что фильтр уже стоит выше.

Что мы переписали

Первая попытка ночью, кстати, была совсем не той, что уехала в прод. Я честно попробовал просто добавить лишний assert tenant_id is not None на входе и разойтись спать. Через полчаса понял, что это не решение, а заплатка на заплатке — рядом с этим эндпоинтом было ещё несколько мест, где легко повторить ту же ошибку. Тогда сели переписывать по-настоящему.

Три изменения, по убыванию очевидности.

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 на каждый запрос в зависимости устанавливаем сессионный параметр:

async def with_tenant(conn, tenant_id: int):
    await conn.execute(
        "SELECT set_config('app.current_tenant', $1, true)",
        str(tenant_id),
    )

Параметр true делает значение локальным для транзакции. Если разработчик забудет фильтр в SQL — БД сама не вернёт чужих строк. Если разработчик забудет вызвать with_tenant — current_setting упадёт с ошибкой, и запрос не выполнится. Это лучше, чем тихая утечка: лучше пятисотка в логе, чем чужой договор на экране.

Коллеги, когда увидели diff, поворчали — «а если случайно уроним половину запросов». Уронили один, в старом фоновом скрипте, и это оказалось ровно то, что мы хотели поймать. Скрипт правда ходил без tenant-контекста и правда не должен был так делать.

Партиционирование по tenant_id для крупных арендаторов

У нас десятки клиентов, но основной объём документов даёт небольшая горстка крупных. Сделали hash-партиционирование:

CREATE TABLE documents (...) PARTITION BY HASH (tenant_id);

CREATE TABLE documents_p0 PARTITION OF documents
    FOR VALUES WITH (modulus 8, remainder 0);
-- и так далее до 7

HNSW-индекс теперь строится локально для каждой партиции. У небольшого клиента весь его рабочий датасет умещается в одну партицию — поиск идёт по компактному графу. У крупного — равномерно размазан по нескольким. Бонусом: переиндексация одного арендатора (например, после массовой загрузки документов) не блокирует чтения у других — это мы поняли не сразу, а когда клиент залил свой архив и мы полдня боялись за латентность у соседей.

Время семантического поиска после этого просело заметно. Не «в разы, всё летает», а честно — раньше на глаз секунду задумывалось, теперь отдаёт почти мгновенно. Просто потому, что HNSW обходит меньший граф.

Частичные индексы под «горячие» предикаты

Для случаев, когда внутри одного арендатора нужно ещё фильтровать — например, только договоры за конкретный год, — заметили, что HNSW начинает «недобирать»: кандидаты уходят мимо нужного года, и в выдаче остаётся меньше строк, чем просили. Для таких сценариев держим частичный индекс:

CREATE INDEX documents_2025_hnsw
ON documents USING hnsw (embedding vector_cosine_ops)
WHERE doc_year = 2025;

Запрос с WHERE doc_year = 2025 использует именно его — планировщик видит совпадение предиката и идёт по уменьшенному графу. Чисто индексный pre-filter, без выкидывания строк после. Не панацея, но для двух-трёх реально горячих срезов работает отлично.

Что в логах

После инцидента добавили аудит-лог семантических запросов с обязательными полями: tenant_id, user_id, query_hash (не сам текст — у нас 152-ФЗ, а часть клиентов работает и по 44-ФЗ), result_ids, returned_count. Если снова случится «мне показали чужое», за минуту восстановим, какой именно вектор подняли и из какой партиции. До этого пришлось бы собирать картину по крохам, и я это очень не хотел бы повторять.

Параллельно прикрутили простую проверку в CI: тест поднимает реальный PostgreSQL, заливает данные двух фиктивных арендаторов, делает поиск через приложение и убеждается, что в выдаче нет ни одной чужой строки. Отрабатывает за считаные секунды и стоит на каждом PR. Пожалуй, самые полезные секунды, которые мы добавили к CI за последнее время — потому что теперь эту проверку в принципе нельзя случайно обойти, даже если очень хочется побыстрее влить хотфикс.

Что мы поняли про мульти-тенантность в pgvector

Главный вывод, который мы теперь тащим во все проекты, где приезжает ИИ-агент в компанию с несколькими бизнес-единицами: HNSW и фильтры — это компромисс, который надо проектировать сознательно. Если у вас разнокалиберные тенанты, варианты примерно такие:

  • Один индекс на всё — просто, но фильтр работает пост-фактум. Риск «недобора» в выдаче и риск утечки при ошибке в коде.
  • Партиционирование по tenant_id — индекс становится локальным, поиск быстрее, изоляция жёстче. Минус — расходы на DDL при добавлении партиций (мы используем pg_partman для автоматизации).
  • Schema-per-tenant — максимальная изоляция, но переезд на pgvector с миграциями превращается в кошмар, когда у вас несколько десятков схем.

Мы выбрали средний путь: одна таблица, hash-партиционирование, RLS поверх. Такая схема спокойно масштабируется под наш текущий рост — а если упрёмся, будем думать отдельно, потому что менять архитектуру под гипотетическую нагрузку я больше не готов.

И отдельная мораль, немного человеческая. Мы в «Интеллект Технологии» делаем ИИ-агентов для программирования, для внутренних помощников, для работы с документами — и я вижу, как в каждом втором проекте команда упирается в тот же самый вопрос: кому доверять фильтрацию, коду или базе. Правильный ответ — обоим, но база должна быть последней линией. RLS как страховочная сетка — это не «защита от хакеров». Это защита от самого себя, когда ты в час ночи чинишь прод и не помнишь, какие ещё эндпоинты ходят в эту таблицу. Один set_config в зависимости — и любой случайно протёкший SQL вернёт пусто, а не данные другого клиента.

Тот вечерний звонок был самым неприятным за последнее время. Зато теперь система устроена так, чтобы такого звонка больше не было. Правда, я слишком долго занимаюсь этой работой, чтобы обещать «никогда». Скажем так: до следующего креативного эндпоинта уверенность точно продержится.

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

Опишите задачу — подберём ИИ-решение под ваш процесс и бюджет: что сделать агентом, какие нужны данные и интеграции, с чего начать пилот.